diff --git a/.github/workflows/release-portable.yml b/.github/workflows/release-portable.yml
index d12184b..9ad929d 100644
--- a/.github/workflows/release-portable.yml
+++ b/.github/workflows/release-portable.yml
@@ -31,7 +31,7 @@ permissions:
jobs:
build-windows:
- runs-on: windows-latest
+ runs-on: ubuntu-latest
steps:
- name: Checkout
@@ -42,21 +42,30 @@ jobs:
with:
global-json-file: global.json
- - name: Restore desktop project
- run: dotnet restore src/PhysOn.Desktop/PhysOn.Desktop.csproj
+ - name: Install NSIS
+ run: sudo apt-get update && sudo apt-get install -y nsis
- - name: Publish portable desktop build
- run: dotnet publish src/PhysOn.Desktop/PhysOn.Desktop.csproj -c Release -r win-x64 --self-contained true -o out/win-x64
+ - name: Build Windows release assets
+ env:
+ VERSION_INPUT: ${{ github.event_name == 'workflow_dispatch' && inputs.version || github.ref_name }}
+ run: |
+ chmod +x scripts/release/build-windows-distributions.sh
+ ./scripts/release/build-windows-distributions.sh \
+ --version "$VERSION_INPUT" \
+ --output out
- - name: Create portable ZIP
- shell: pwsh
- run: Compress-Archive -Path out/win-x64/* -DestinationPath out/KoTalk-windows-x64.zip
+ cp out/KoTalk-windows-x64-"$VERSION_INPUT".zip out/KoTalk-windows-x64.zip
+ cp out/KoTalk-windows-x64-onefile-"$VERSION_INPUT".exe out/KoTalk-windows-x64-onefile.exe
+ cp out/KoTalk-windows-x64-installer-"$VERSION_INPUT".exe out/KoTalk-windows-x64-installer.exe
- name: Upload Windows artifact
uses: actions/upload-artifact@v4
with:
- name: windows-portable
- path: out/KoTalk-windows-x64.zip
+ name: windows-release
+ path: |
+ out/KoTalk-windows-x64.zip
+ out/KoTalk-windows-x64-onefile.exe
+ out/KoTalk-windows-x64-installer.exe
build-android:
if: ${{ hashFiles('src/PhysOn.Mobile.Android/*.csproj') != '' }}
@@ -80,23 +89,16 @@ jobs:
- name: Install Android workload
run: dotnet workload install android
- - name: Restore Android project
- run: dotnet restore src/PhysOn.Mobile.Android/PhysOn.Mobile.Android.csproj
-
- name: Publish Android APK
+ env:
+ VERSION_INPUT: ${{ github.event_name == 'workflow_dispatch' && inputs.version || github.ref_name }}
run: |
- dotnet publish src/PhysOn.Mobile.Android/PhysOn.Mobile.Android.csproj \
- -c Release \
- -f net8.0-android \
- -p:AndroidPackageFormat=apk \
- -p:AndroidKeyStore=false \
- -o out/android
+ chmod +x scripts/release/build-android-apk.sh
+ ./scripts/release/build-android-apk.sh \
+ --version "$VERSION_INPUT" \
+ --output out
- - name: Collect Android artifact
- run: |
- apk_path="$(find out/android -type f -name '*.apk' | head -n 1)"
- test -n "$apk_path"
- cp "$apk_path" out/KoTalk-android-universal.apk
+ cp out/KoTalk-android-universal-"$VERSION_INPUT".apk out/KoTalk-android-universal.apk
- name: Upload Android artifact
uses: actions/upload-artifact@v4
@@ -118,7 +120,7 @@ jobs:
- name: Download Windows artifact
uses: actions/download-artifact@v4
with:
- name: windows-portable
+ name: windows-release
path: incoming/windows
- name: Download Android artifact
@@ -149,6 +151,8 @@ jobs:
--version "$VERSION_INPUT"
--channel "$channel"
--windows-zip incoming/windows/KoTalk-windows-x64.zip
+ --windows-portable-exe incoming/windows/KoTalk-windows-x64-onefile.exe
+ --windows-installer-exe incoming/windows/KoTalk-windows-x64-installer.exe
--force
)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 759302b..32968ab 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,9 @@
### Added
+- Android WebView 기반 첫 APK 프로젝트와 `KoTalk-android-universal-*.apk` 산출물 경로 추가
+- Windows `installer exe + onefile exe + zip` 3종 배포 체인 추가
+- 모바일/iOS/Linux 프레임워크 결론 문서 `CLIENT_PLATFORM_DECISION.md` 추가
- 공개 원격용 버전 태그 생성 스크립트 `scripts/release/release-create-tag.sh`
- GitHub 릴리즈 게시 스크립트 `scripts/release/release-publish-github.sh`
- 제2·제3 공개 원격 순차 게시 스크립트 `scripts/release/release-publish-public.sh`
@@ -53,6 +56,8 @@
### Changed
+- 공개 릴리즈 Assets에서 스크린샷과 릴리즈 노트 첨부를 제거하고, 최신 화면은 변경 노트 본문에서 직접 확인하도록 조정
+- Android 상태를 `계획`에서 `첫 APK 기준선 확보` 단계로 상향하고, iOS/Linux 계획을 공개 문서에 명시
- 데스크톱/웹 스크린샷 캡처를 앱 창·앱 셸 기준으로 정리하고, README·SHOWCASE 이미지를 실제 종횡비에 맞춘 `width/height` 기준으로 재배치
- 공개 원격 배포 정책을 `public/* 브랜치 + 버전 태그 + 릴리즈 페이지 + 자산` 기준으로 고정
- Gitea/GitHub 릴리즈 게시 스크립트가 지정 원격 기준으로 동작하고 최신 스크린샷 자산도 함께 첨부하도록 확장
diff --git a/CLIENT_PLATFORM_DECISION.md b/CLIENT_PLATFORM_DECISION.md
new file mode 100644
index 0000000..18904bd
--- /dev/null
+++ b/CLIENT_PLATFORM_DECISION.md
@@ -0,0 +1,160 @@
+# Client Platform Decision
+
+마지막 정리일: `2026-04-16`
+
+## 결론
+
+KoTalk의 장기적인 네이티브 클라이언트 기준선은 **Avalonia 중심**으로 유지하는 편이 맞습니다.
+현재 Android의 WebView 셸은 `alpha` 단계의 빠른 APK 배포를 위한 전술적 경로로 유지하되, iOS와 Linux까지 포함하는 장기 클라이언트 전략은 Avalonia를 축으로 정리합니다.
+
+## 왜 다시 판단했는가
+
+초기에는 `Windows + mobile web + Android APK`만 빨리 닫는 것이 우선이었습니다. 그런데 이제 조건이 바뀌었습니다.
+
+- Android만이 아니라 iOS도 예정돼 있습니다.
+- Linux 공식 지원도 예정돼 있습니다.
+- Windows는 이미 네이티브 데스크톱 축으로 유지해야 합니다.
+- 저장소와 공식 서비스 모두에서 UI/UX 일관성을 더 강하게 요구받고 있습니다.
+
+즉, Android만 빨리 붙이는 기준이 아니라 `Windows + Android + iOS + Linux`를 함께 수용하는 클라이언트 구조가 필요해졌습니다.
+
+## 현재 기준선
+
+### 지금 이미 있는 것
+
+- Windows 네이티브 데스크톱 앱: Avalonia 기반
+- Mobile web: 실제 운영 중
+- Android: WebView 기반 첫 APK 셸 생성 가능
+
+### 지금 없는 것
+
+- iOS 클라이언트
+- Linux 네이티브 패키징
+- Android/iOS 전용 네이티브 경험을 공용 UI 축으로 가져가는 체계
+
+## 검토한 선택지
+
+### 1. Android/iOS를 계속 각각 별도 네이티브 셸로 유지
+
+장점:
+
+- 지금 당장 APK를 빠르게 배포하기 쉽다
+- 모바일 웹을 감싸는 방식이라 초기 유지 비용이 낮다
+
+한계:
+
+- Windows와 Linux 쪽 UI 자산을 재사용하기 어렵다
+- iOS까지 같은 방식으로 늘어나면 표면은 빠르게 늘지만 제품 일관성은 약해진다
+- 장기적으로는 플랫폼마다 다른 껍데기를 유지하게 된다
+
+판단:
+
+- **전술적으로는 유효**
+- **장기 기준선으로는 부적합**
+
+### 2. .NET MAUI로 장기 축 전환
+
+장점:
+
+- Android와 iOS, Windows까지 한 프레임워크로 묶기 좋다
+- 모바일 장치 API 접근이 익숙한 생태계다
+
+한계:
+
+- Microsoft 공식 문서 기준 `.NET MAUI`의 기본 지원 플랫폼은 Android, iOS, macOS, Windows다. Linux는 기본 축에 없다.
+- 현재 저장소의 Windows 클라이언트는 이미 Avalonia 기반이라, MAUI로 옮기면 데스크톱 축을 다시 크게 재구성해야 한다.
+
+판단:
+
+- **Linux 공식 지원 예정 조건과 현재 코드베이스 기준을 함께 놓고 보면, 주 프레임워크로 선택하기 어렵다**
+
+참고:
+
+- Microsoft Learn, “What is .NET MAUI?”:
+- Microsoft Learn, “Supported platforms for .NET MAUI apps”:
+
+### 3. Uno Platform으로 장기 축 전환
+
+장점:
+
+- 공식 문서 기준 Android, iOS, Web, macOS, Linux, Windows를 포괄한다
+- 광범위한 플랫폼 표면 자체는 매력적이다
+
+한계:
+
+- 현재 Windows 클라이언트가 Avalonia 기반이라, Uno로 가면 데스크톱 UI 자산과 운영 경험을 다시 맞춰야 한다
+- 지금 시점의 저장소 기준으로는 전환 비용이 작지 않다
+
+판단:
+
+- **대안으로는 충분히 검토 가능**
+- **하지만 현재 코드베이스를 고려하면 1순위는 아님**
+
+참고:
+
+- Uno Platform, “Supported platforms”:
+
+### 4. Avalonia를 장기 축으로 유지
+
+장점:
+
+- 현재 Windows 클라이언트가 이미 Avalonia 기반이다
+- 공식 문서 기준 Windows, Linux, iOS, Android, WebAssembly까지 포괄한다
+- 데스크톱 밀도, 멀티 윈도우, 플랫한 커스텀 UI를 유지하기 유리하다
+- Linux 공식 지원 예정이라는 조건과 가장 자연스럽게 맞는다
+
+전제 조건:
+
+- Avalonia 공식 문서 기준 데스크톱은 `.NET 8` 이상이지만, 모바일인 iOS/Android는 `.NET 10` 기준을 따른다
+- 따라서 현재 저장소의 `.NET 8` 기준선을 유지한 채 즉시 Android/iOS를 Avalonia로 통합하는 것은 현실적이지 않다
+
+주의:
+
+- 모바일은 데스크톱과 같은 방식으로 밀어붙이면 안 된다
+- 작은 화면, 제스처, OS 관례를 반영한 모바일 전용 UX 조정은 별도로 필요하다
+- 현재 Android alpha 셸을 곧바로 폐기할 필요는 없지만, 장기적으로는 공용 UI 구조와의 관계를 분명히 해야 한다
+
+판단:
+
+- **현 시점 KoTalk의 가장 현실적인 장기 기준선**
+
+참고:
+
+- Avalonia Docs, “Supported platforms”:
+- Avalonia Docs, “Welcome / What is Avalonia?”:
+
+## 최종 결정
+
+### 전술
+
+- Android는 당분간 현재 WebView 셸을 유지합니다.
+- 이유는 APK 배포, 설치 동선 검증, 알파 피드백 수집을 빠르게 계속하기 위해서입니다.
+- 현재 저장소가 `.NET 8` 기준선이므로, 모바일 공용 UI로 바로 넘어가기 전까지는 이 경로가 가장 안전합니다.
+
+### 전략
+
+- 장기적인 네이티브 클라이언트 축은 **Avalonia 기반 공유 구조**로 정리합니다.
+- 다만 이 단계는 `.NET 10` 모바일 기준선을 감당할 준비가 됐을 때 들어갑니다.
+- Windows와 Linux는 같은 데스크톱 계열 기준으로 묶습니다.
+- Android와 iOS는 같은 제품 경험 원칙 아래로 수렴시키되, 모바일 전용 UX 조정은 별도 계층으로 둡니다.
+
+## 배포 원칙
+
+- 자체 미러와 공개 저장소 Assets:
+ - Windows installer / onefile / zip
+ - Android APK
+- Apple 배포 채널:
+ - iOS는 저장소 Assets나 자체 미러 직접 배포가 아니라 Apple 채널을 전제로 준비합니다
+
+## 지금 바로 바뀌는 문장
+
+- `Android는 계획 단계`라고 쓰지 않습니다.
+- `Android APK 기준선은 확보됐고, 장기 모바일 전략은 재정의 중`이라고 씁니다.
+- `iOS와 Linux는 예정`이라고 공개적으로 적되, 아직 완성된 것처럼 쓰지 않습니다.
+
+## 다음 실행 항목
+
+1. Android alpha 셸을 유지하면서 배포와 설치 루프를 먼저 안정화
+2. `.NET 10` 전환 부담과 시점을 포함해 Avalonia 기반 공유 클라이언트 구조를 설계
+3. Linux 패키징 기준선 정리
+4. iOS 배포 준비 문서와 빌드 전제 정리
diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md
index 581d5f9..ea47375 100644
--- a/DEVELOPMENT.md
+++ b/DEVELOPMENT.md
@@ -80,22 +80,21 @@ dotnet test PhysOn.sln -c Debug
Windows:
```bash
-dotnet publish src/PhysOn.Desktop/PhysOn.Desktop.csproj \
- -c Release \
- -r win-x64 \
- --self-contained true \
- -o artifacts/release/v0.1.0-alpha.1-win-x64
+./scripts/release/build-windows-distributions.sh \
+ --version 2026.04.16-alpha.6
```
+생성물:
+
+- `KoTalk-windows-x64-.zip`
+- `KoTalk-windows-x64-onefile-.exe`
+- `KoTalk-windows-x64-installer-.exe`
+
Android:
```bash
-dotnet workload install android
-dotnet publish src/PhysOn.Mobile.Android/PhysOn.Mobile.Android.csproj \
- -c Release \
- -f net8.0-android \
- -p:AndroidPackageFormat=apk \
- -o artifacts/release/android
+./scripts/release/build-android-apk.sh \
+ --version 2026.04.16-alpha.6
```
공개 산출물 네이밍은 `KoTalk-*` 기준으로 정리하는 방향이고, 현재 내부 스크립트와 프로젝트명은 별도 정렬 단계에 있습니다.
@@ -104,10 +103,12 @@ dotnet publish src/PhysOn.Mobile.Android/PhysOn.Mobile.Android.csproj \
```bash
./scripts/release/release-prepare-assets.sh \
- --version v0.1.0-alpha.1 \
+ --version 2026.04.16-alpha.6 \
--channel alpha \
- --windows-zip artifacts/release/PhysOn-win-x64-v0.1.0-alpha.1.zip \
- --android-apk artifacts/release/PhysOn-android-universal-v0.1.0-alpha.1.apk \
+ --windows-zip artifacts/builds/2026.04.16-alpha.6/KoTalk-windows-x64-2026.04.16-alpha.6.zip \
+ --windows-portable-exe artifacts/builds/2026.04.16-alpha.6/KoTalk-windows-x64-onefile-2026.04.16-alpha.6.exe \
+ --windows-installer-exe artifacts/builds/2026.04.16-alpha.6/KoTalk-windows-x64-installer-2026.04.16-alpha.6.exe \
+ --android-apk artifacts/builds/2026.04.16-alpha.6/KoTalk-android-universal-2026.04.16-alpha.6.apk \
--screenshots artifacts/screenshots \
--force
```
diff --git a/PROJECT_STATUS.md b/PROJECT_STATUS.md
index 320cb42..2c6081c 100644
--- a/PROJECT_STATUS.md
+++ b/PROJECT_STATUS.md
@@ -9,7 +9,7 @@
| Public brand | `KoTalk` |
| Stage | `Alpha` |
| Most usable surface | Mobile web live + Windows build |
-| Biggest current gap | Android 실빌드와 데스크톱 멀티윈도우 완성도 |
+| Biggest current gap | 네이티브 모바일 공용 UI 수렴과 데스크톱 멀티윈도우 완성도 |
| Signup direction | 공개형 1회성 인증 중심으로 재설계 중 |
| Tone of this repo | 현재 동작 범위와 남은 갭을 함께 적는 제품형 저장소 |
@@ -19,6 +19,7 @@ KoTalk는 아직 모든 플랫폼이 완성된 상태는 아니지만, “문서
- Windows 데스크톱 클라이언트 빌드
- 모바일 웹 실서비스 채널
+- Android APK 빌드 기준선
- 기본 인증, 최근 대화, 메시지 전송, 읽기 커서, 세션 복구 루프
- 최신 기준 스크린샷 세트
- 릴리즈 경로와 다운로드 경로 문서
@@ -29,8 +30,10 @@ KoTalk는 아직 모든 플랫폼이 완성된 상태는 아니지만, “문서
|---|---|---|---|
| Windows desktop | 저장소 빌드 / 릴리즈 산출물 | Buildable | 핵심 메시징 루프 검증 가능 |
| Mobile web | [vstalk.phy.kr](https://vstalk.phy.kr) | Live | 가입, 대화, 검색, 보관 1차 흐름 제공 |
-| Android | 저장소 릴리즈 예정 | In progress | 문서와 배포 구조 우선 정리 중 |
-| Official mirror | [download-vstalk.phy.kr](https://download-vstalk.phy.kr) | Live | Windows latest와 version manifest를 HTTPS로 제공 |
+| Android | 저장소 APK / 릴리즈 파이프라인 | Buildable | alpha WebView 셸과 APK 산출물 생성 가능 |
+| iOS | Apple 배포 채널 예정 | Planned | 자체 미러가 아닌 Apple 채널 전제 |
+| Linux | 저장소 빌드 예정 | Planned | Windows와 같은 공유 UI 축으로 준비 |
+| Official mirror | [download-vstalk.phy.kr](https://download-vstalk.phy.kr) | Live | Windows latest, Android latest, version manifest를 HTTPS로 제공 |
## Verified Now
@@ -38,6 +41,7 @@ KoTalk는 아직 모든 플랫폼이 완성된 상태는 아니지만, “문서
- Windows 클라이언트는 저장소 기준으로 빌드 가능한 상태입니다.
- 모바일 웹은 [vstalk.phy.kr](https://vstalk.phy.kr)에서 공개 중입니다.
+- Android APK는 저장소 기준으로 생성 가능한 상태입니다.
- 기본 메시징 루프와 세션 복구 흐름은 구현돼 있습니다.
- 검색, 보관, 빈 상태 UX는 1차 개편이 반영돼 있습니다.
- 최신 스크린샷은 저장소에 함께 보관됩니다.
@@ -66,7 +70,9 @@ KoTalk는 아직 모든 플랫폼이 완성된 상태는 아니지만, “문서
## In Progress
-- Android 첫 실사용 빌드
+- Android APK 서명과 배포 품질 정리
+- iOS 채널 준비와 Apple 배포 절차 정리
+- Linux 네이티브 패키징 기준선
- 릴리즈 페이지와 미러 간 latest 라우트 통합
- 검색 범위 확장
- 파일 전송
@@ -76,10 +82,12 @@ KoTalk는 아직 모든 플랫폼이 완성된 상태는 아니지만, “문서
아직 부족한 부분도 그대로 남깁니다.
-- Android 실사용 빌드는 아직 제공되지 않습니다.
+- Android는 현재 WebView 셸 중심의 첫 APK 기준선으로, 네이티브 모바일 경험은 더 다듬어야 합니다.
+- iOS 클라이언트는 아직 시작되지 않았습니다.
+- Linux 클라이언트는 장기 목표로만 정리돼 있습니다.
- 파일 전송은 미구현입니다.
- 검색은 전역 파일/링크/사람 범위까지 확장되지 않았습니다.
-- 공식 다운로드 미러는 현재 Windows latest와 version manifest 기준으로 동작합니다.
+- 공식 다운로드 미러는 현재 Windows latest, Android latest, version manifest 기준으로 동작합니다.
- 데스크톱 멀티 윈도우는 방향은 잡혀 있지만, 실제 생산성 흐름은 더 다듬어야 합니다.
## Download And Release Paths
@@ -111,3 +119,4 @@ KoTalk는 아직 모든 플랫폼이 완성된 상태는 아니지만, “문서
- 라이브 우선순위: [문서/35-live-user-review-and-priority-backlog.md](문서/35-live-user-review-and-priority-backlog.md)
- 가입/온보딩 정책: [문서/10-signup-onboarding-and-auth-policy.md](문서/10-signup-onboarding-and-auth-policy.md)
- 신뢰와 보안 표면: [TRUST_CENTER.md](TRUST_CENTER.md), [SECURITY.md](SECURITY.md)
+- 클라이언트 플랫폼 결론: [CLIENT_PLATFORM_DECISION.md](CLIENT_PLATFORM_DECISION.md)
diff --git a/PhysOn.sln b/PhysOn.sln
index 2e37bd4..6d0ff60 100644
--- a/PhysOn.sln
+++ b/PhysOn.sln
@@ -23,6 +23,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{04EBE1E2
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PhysOn.Api.IntegrationTests", "tests\PhysOn.Api.IntegrationTests\PhysOn.Api.IntegrationTests.csproj", "{38821CD9-915B-4C96-8A35-11BDE991C16E}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PhysOn.Mobile.Android", "src\PhysOn.Mobile.Android\PhysOn.Mobile.Android.csproj", "{FD4774AB-47F7-4C47-BCC7-FD97BCBF4101}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -64,6 +66,10 @@ Global
{38821CD9-915B-4C96-8A35-11BDE991C16E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{38821CD9-915B-4C96-8A35-11BDE991C16E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{38821CD9-915B-4C96-8A35-11BDE991C16E}.Release|Any CPU.Build.0 = Release|Any CPU
+ {FD4774AB-47F7-4C47-BCC7-FD97BCBF4101}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {FD4774AB-47F7-4C47-BCC7-FD97BCBF4101}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {FD4774AB-47F7-4C47-BCC7-FD97BCBF4101}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {FD4774AB-47F7-4C47-BCC7-FD97BCBF4101}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{DD8A5885-9647-4248-A0B2-C8695BBFB54E} = {BD1AEF6A-5B59-4DAC-82C9-1983916200FB}
@@ -74,5 +80,6 @@ Global
{925D92EC-965D-44D9-A3FF-011E1815C9EE} = {BD1AEF6A-5B59-4DAC-82C9-1983916200FB}
{90EF510F-E338-45C4-9EF4-0A4916725EF0} = {BD1AEF6A-5B59-4DAC-82C9-1983916200FB}
{38821CD9-915B-4C96-8A35-11BDE991C16E} = {04EBE1E2-D374-4F58-A0D5-5062BB4674FA}
+ {FD4774AB-47F7-4C47-BCC7-FD97BCBF4101} = {BD1AEF6A-5B59-4DAC-82C9-1983916200FB}
EndGlobalSection
EndGlobal
diff --git a/README.md b/README.md
index 7f0179e..e1b9a78 100644
--- a/README.md
+++ b/README.md
@@ -16,6 +16,10 @@
짧은 답장, 빠른 복귀, 설명 가능한 제품 표면, 그리고 한국어 사용 습관에 맞는 조용한 UI를 핵심 기준으로 삼습니다.
+
+ 현재는 Windows, 모바일 웹, Android APK 기준선을 운영하고 있으며, iOS와 Linux 네이티브 채널은 같은 제품 경험 안으로 수렴시키는 방향을 공개적으로 준비 중입니다.
+
+
@@ -31,6 +35,7 @@
Showcase ·
Background ·
Alternative Gap ·
+ Platform Strategy ·
Brand Guide ·
FAQ ·
Releases ·
@@ -61,8 +66,8 @@
| Layer | Current read |
|---|---|
| What this is | 한국어 UI, 낮은 피로도, 업무형 메시징 복귀 흐름을 중심에 둔 Windows-first 오픈소스 메신저 |
-| What works now | Windows 빌드, 모바일 웹 라이브, 기본 인증/대화/세션 루프 |
-| What is still moving | Android 첫 실빌드, 파일 전송, 검색 확장, 공개 다운로드 미러 정합성 |
+| What works now | Windows 빌드, 모바일 웹 라이브, Android APK 기준선, 기본 인증/대화/세션 루프 |
+| What is still moving | 파일 전송, 검색 확장, 네이티브 모바일 공용 UI 수렴, 공개 다운로드 미러 정합성 |
| What this repo tries to show | 화면, 빌드 산출물, 릴리즈 경로, 상태 문서, 배경 문서를 한 눈에 읽히게 정리한 제품형 저장소 |
## Why Now
@@ -156,14 +161,16 @@ KoTalk는 바로 그 비어 있는 결합점을 목표로 둡니다. 이 항목
|---|---|---|---|
| Windows desktop | 저장소 빌드와 버전별 산출물 | Buildable | 핵심 메시징 루프와 데스크톱 레이아웃 실험 진행 중 |
| Mobile web | [vstalk.phy.kr](https://vstalk.phy.kr) | Live | 가입, 대화, 검색, 보관 1차 흐름 검증 |
-| Android | 저장소 릴리즈 예정 | In progress | 문서와 배포 구조 우선 정리 중 |
-| Official download mirror | [download-vstalk.phy.kr](https://download-vstalk.phy.kr) | Live | Windows latest와 version manifest를 HTTPS로 제공 |
+| Android | 저장소 APK / 릴리즈 파이프라인 | Buildable | WebView 기반 alpha 셸과 APK 산출물 기준선 확보 |
+| iOS | Apple 배포 채널 예정 | Planned | 자체 미러 대신 App Store/TestFlight 전제 |
+| Linux | 저장소 빌드 예정 | Planned | 장기적으로 Windows와 같은 네이티브 클라이언트 축 |
+| Official download mirror | [download-vstalk.phy.kr](https://download-vstalk.phy.kr) | Live | Windows latest, Android latest, version manifest를 HTTPS로 제공 |
## Architecture Snapshot
KoTalk의 현재 구조는 지나치게 복잡한 플랫폼보다, 작은 조각을 조합해 실서비스와 로컬 빌드를 함께 검증하는 쪽에 가깝습니다.
-- 클라이언트: Windows 데스크톱 + 모바일 웹 + Android 예정
+- 클라이언트: Windows 데스크톱 + 모바일 웹 + Android APK 기준선 + iOS/Linux 예정
- API: 인증, 최근 대화, 메시지 전송, 읽기 커서, 세션 루프
- 배포: VPS 기반 same-origin 웹앱과 API 운영
- 공개 증거: 저장소 스크린샷, 빌드 산출물, 상태 문서, 릴리즈 경로
@@ -211,6 +218,7 @@ KoTalk의 현재 구조는 지나치게 복잡한 플랫폼보다, 작은 조각
- [ROADMAP.md](ROADMAP.md)
- [BUSINESS_MODEL.md](BUSINESS_MODEL.md)
- [ALTERNATIVE_GAP.md](ALTERNATIVE_GAP.md)
+- [CLIENT_PLATFORM_DECISION.md](CLIENT_PLATFORM_DECISION.md)
- [문서/01-product-strategy-and-mvp.md](문서/01-product-strategy-and-mvp.md)
- [문서/18-white-material-compact-ui-system.md](문서/18-white-material-compact-ui-system.md)
- [문서/22-work-communication-ux-playbook.md](문서/22-work-communication-ux-playbook.md)
diff --git a/RELEASING.md b/RELEASING.md
index b7a84ce..5d8a1a7 100644
--- a/RELEASING.md
+++ b/RELEASING.md
@@ -15,7 +15,7 @@ KoTalk의 릴리즈는 단순한 파일 업로드가 아니라, 산출물과 공
## Current Note
2026-04-16 기준 [download-vstalk.phy.kr](https://download-vstalk.phy.kr)는 DNS와 HTTPS가 정상입니다.
-현재는 Windows latest와 version manifest를 제공하고, 저장소 릴리즈 경로를 함께 유지합니다.
+현재 기준선은 Windows installer / onefile / zip, Android latest APK, version manifest를 함께 제공하는 구조입니다.
## Minimum Release Contract
@@ -25,13 +25,16 @@ KoTalk의 릴리즈는 단순한 파일 업로드가 아니라, 산출물과 공
4. 최신 스크린샷이 현재 UI를 대표해야 합니다.
5. 다운로드 경로와 릴리즈 링크가 함께 갱신돼야 합니다.
6. 공개 원격은 `브랜치 + 태그 + 릴리즈 페이지 + 자산`을 한 세트로 맞춥니다.
-7. 공개 릴리즈 페이지에는 산출물과 최신 스크린샷을 함께 게시합니다.
+7. 공개 릴리즈 페이지의 Assets는 실행 파일, 체크섬, 메타데이터처럼 실제 배포에 필요한 파일만 남깁니다.
+8. 최신 스크린샷은 Assets 첨부 대신 변경 노트 안에 담고, 다운로드 미러의 정적 경로를 이미지 소스로 사용합니다.
## Platform Policy
-- Windows: 빌드 산출물, 스크린샷, 체크섬을 함께 남깁니다.
+- Windows: installer, onefile portable, zip, 체크섬을 기본 산출물로 유지합니다.
- Mobile web: 라이브 반영이 있으면 스크린샷과 상태 문서를 함께 갱신합니다.
- Android: APK 공개 시 공식 미러와 저장소 릴리즈를 함께 맞춥니다.
+- iOS: 자체 미러나 저장소 Assets로 직접 배포하지 않고, Apple 배포 채널을 전제로 준비합니다.
+- Linux: Windows와 함께 장기적인 네이티브 클라이언트 축으로 다루며, 공유 UI 프레임워크 기준선을 유지합니다.
## Public Release Sequence
@@ -41,7 +44,8 @@ KoTalk의 릴리즈는 단순한 파일 업로드가 아니라, 산출물과 공
4. 제2 공개 레포에 브랜치와 태그를 푸시합니다.
5. 제2 공개 레포 릴리즈 페이지에 자산과 노트를 게시합니다.
6. 명시적 요청이 있을 때만 같은 태그와 자산을 제3 공개 레포에 게시합니다.
-7. `download-vstalk.phy.kr`는 최신 포인터만 유지합니다.
+7. `download-vstalk.phy.kr`는 `Windows latest landing`, `Android latest landing`, `latest/version.json` 포인터를 유지합니다.
+8. 공개 릴리즈 노트에는 최신 스크린샷을 포함하고, 공개 릴리즈 Assets에는 스크린샷을 첨부하지 않습니다.
## Release Scripts
diff --git a/ROADMAP.md b/ROADMAP.md
index dae7837..8485f15 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -3,7 +3,8 @@
## 현재 상태
- Alpha 가입, 대화 목록, 대화창, 텍스트 전송이 동작하는 첫 사용 가능 프로토타입 확보
-- Windows x64 portable build 생성 가능
+- Windows x64 installer / onefile / zip 생성 가능
+- Android APK alpha 기준선 생성 가능
- `vstalk.phy.kr` 모바일 웹앱과 API를 VPS에 실제 배포
- 원격 저장소에 최신 기준 스크린샷 포함 시작
- `vstalk.phy.kr` 모바일 웹앱 MVP 빌드 및 same-origin API 검증 완료
@@ -15,9 +16,11 @@
- [x] 메시지 전송
- [x] 읽기 커서 갱신
- [x] Windows portable zip 생성
+- [x] Windows installer / onefile / zip 생성
- [x] 모바일 웹앱 PWA 셸/가입/대화/전송
- [x] VPS 공개 API 상시 구동
- [x] `vstalk.phy.kr` same-origin 웹앱 배포
+- [x] Android WebView 셸과 첫 APK 생성
- [ ] 데스크톱 WebSocket 실시간 반영
## v0.2 Collaboration Basics
@@ -30,12 +33,19 @@
## v0.2 Android First-class
-- [ ] Android 셸/네비게이션 골격
-- [ ] Android 로그인/대화 목록/대화 진입 MVP
+- [x] Android 셸/네비게이션 골격
+- [x] Android 로그인/대화 목록/대화 진입 MVP 기준선
- [ ] APK 서명 및 산출물 규칙 확정
- [ ] Windows/Android 동시 릴리즈 메타데이터 검증
- [ ] Forge Releases + VPS 미러 동시 게시
+## v0.3 Shared Client Expansion
+
+- [ ] Avalonia 기반 공유 클라이언트 구조를 Android/iOS/Linux 관점으로 재정리
+- [ ] iOS 배포 채널 준비 문서와 빌드 체인 검증
+- [ ] Linux 패키징 기준선 확보
+- [ ] Android WebView 셸에서 네이티브 공용 UI 단계로 이행할 범위 정의
+
## v0.2 Mobile Web Entry
- [x] `vstalk.phy.kr` 모바일 웹 IA와 핵심 사용자 흐름 확정
@@ -89,6 +99,6 @@
- 하나의 태그는 하나의 릴리즈 레코드를 뜻합니다.
- 같은 버전 번호 아래에 Windows와 Android 자산을 함께 게시합니다.
-- 원격 저장소에는 최신 기준 스크린샷도 함께 포함합니다.
+- 원격 저장소 릴리즈 Assets에는 실행 파일과 체크섬만 두고, 최신 스크린샷은 변경 노트 안에서 참조합니다.
- 다운로드 미러와 원격 Releases는 같은 자산 이름과 같은 노트를 기준으로 맞춥니다.
- 모바일 웹은 설치형 산출물 대신 `https://vstalk.phy.kr`를 기준 진입점으로 관리합니다.
diff --git a/deploy/Caddyfile b/deploy/Caddyfile
index 5009816..96b0ad0 100644
--- a/deploy/Caddyfile
+++ b/deploy/Caddyfile
@@ -7,8 +7,8 @@
root * /srv/download
redir /windows /windows/latest 302
redir /android /android/latest 302
- redir /windows/latest /latest/KoTalk-windows-x64.zip 302
- redir /android/latest /latest/KoTalk-android-universal.apk 302
+ redir /windows/latest /latest/windows/index.html 302
+ redir /android/latest /latest/android/index.html 302
redir /latest /latest/version.json 302
header {
Content-Security-Policy "default-src 'none'; frame-ancestors 'none'; base-uri 'none'"
diff --git a/docs/assets/latest/README.md b/docs/assets/latest/README.md
index 9c6a8a5..478c8f9 100644
--- a/docs/assets/latest/README.md
+++ b/docs/assets/latest/README.md
@@ -13,4 +13,5 @@
- 현재 포함:
- Windows 데스크톱 셸
- Windows 온보딩/대화 화면
+ - Android APK shell 목업
- `vstalk` 모바일 웹 온보딩/목록/검색/보관/대화 화면
diff --git a/docs/assets/latest/kotalk-android-mockup.png b/docs/assets/latest/kotalk-android-mockup.png
new file mode 100644
index 0000000..adbab6d
Binary files /dev/null and b/docs/assets/latest/kotalk-android-mockup.png differ
diff --git a/packaging/windows/KoTalkInstaller.nsi b/packaging/windows/KoTalkInstaller.nsi
new file mode 100644
index 0000000..5e9d887
--- /dev/null
+++ b/packaging/windows/KoTalkInstaller.nsi
@@ -0,0 +1,85 @@
+Unicode True
+ManifestDPIAware True
+RequestExecutionLevel user
+SetCompressor /SOLID lzma
+
+!include "MUI2.nsh"
+
+!ifndef APP_NAME
+ !define APP_NAME "KoTalk"
+!endif
+
+!ifndef APP_COMPANY
+ !define APP_COMPANY "PHYSIA"
+!endif
+
+!ifndef APP_VERSION
+ !define APP_VERSION "0.1.0"
+!endif
+
+!ifndef SOURCE_DIR
+ !error "SOURCE_DIR define is required"
+!endif
+
+!ifndef OUTPUT_FILE
+ !error "OUTPUT_FILE define is required"
+!endif
+
+!ifndef APP_ICON
+ !define APP_ICON "branding/ico/kotalk.ico"
+!endif
+
+!define MUI_ABORTWARNING
+!define MUI_ICON "${APP_ICON}"
+!define MUI_UNICON "${APP_ICON}"
+!define MUI_FINISHPAGE_RUN "$INSTDIR\KoTalk.exe"
+!define MUI_FINISHPAGE_RUN_TEXT "KoTalk 실행"
+
+Name "${APP_NAME}"
+OutFile "${OUTPUT_FILE}"
+InstallDir "$LOCALAPPDATA\KoTalk"
+InstallDirRegKey HKCU "Software\${APP_COMPANY}\${APP_NAME}" "InstallDir"
+
+!insertmacro MUI_PAGE_WELCOME
+!insertmacro MUI_PAGE_DIRECTORY
+!insertmacro MUI_PAGE_INSTFILES
+!insertmacro MUI_PAGE_FINISH
+
+!insertmacro MUI_UNPAGE_CONFIRM
+!insertmacro MUI_UNPAGE_INSTFILES
+
+!insertmacro MUI_LANGUAGE "Korean"
+
+Section "Install"
+ SetOutPath "$INSTDIR"
+ File /r "${SOURCE_DIR}/*"
+ WriteUninstaller "$INSTDIR\Uninstall KoTalk.exe"
+
+ WriteRegStr HKCU "Software\${APP_COMPANY}\${APP_NAME}" "InstallDir" "$INSTDIR"
+ WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APP_NAME}" "DisplayName" "${APP_NAME}"
+ WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APP_NAME}" "Publisher" "${APP_COMPANY}"
+ WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APP_NAME}" "DisplayVersion" "${APP_VERSION}"
+ WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APP_NAME}" "InstallLocation" "$INSTDIR"
+ WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APP_NAME}" "DisplayIcon" "$INSTDIR\KoTalk.exe"
+ WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APP_NAME}" "UninstallString" "$INSTDIR\Uninstall KoTalk.exe"
+ WriteRegDWORD HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APP_NAME}" "NoModify" 1
+ WriteRegDWORD HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APP_NAME}" "NoRepair" 1
+
+ CreateDirectory "$SMPROGRAMS\KoTalk"
+ CreateShortcut "$SMPROGRAMS\KoTalk\KoTalk.lnk" "$INSTDIR\KoTalk.exe"
+ CreateShortcut "$SMPROGRAMS\KoTalk\KoTalk 제거.lnk" "$INSTDIR\Uninstall KoTalk.exe"
+ CreateShortcut "$DESKTOP\KoTalk.lnk" "$INSTDIR\KoTalk.exe"
+SectionEnd
+
+Section "Uninstall"
+ Delete "$DESKTOP\KoTalk.lnk"
+ Delete "$SMPROGRAMS\KoTalk\KoTalk.lnk"
+ Delete "$SMPROGRAMS\KoTalk\KoTalk 제거.lnk"
+ RMDir "$SMPROGRAMS\KoTalk"
+
+ Delete "$INSTDIR\Uninstall KoTalk.exe"
+ RMDir /r "$INSTDIR"
+
+ DeleteRegKey HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APP_NAME}"
+ DeleteRegKey HKCU "Software\${APP_COMPANY}\${APP_NAME}"
+SectionEnd
diff --git a/release-assets/README.md b/release-assets/README.md
index 4e17265..d11c41e 100644
--- a/release-assets/README.md
+++ b/release-assets/README.md
@@ -19,10 +19,14 @@ release-assets/
SHA256SUMS.txt
screenshots/
windows/
+ index.html
KoTalk-windows-x64.zip
+ KoTalk-windows-x64-onefile.exe
+ KoTalk-windows-x64-installer.exe
SHA256SUMS.txt
version.json
android/
+ index.html
KoTalk-android-universal.apk
SHA256SUMS.txt
version.json
@@ -35,6 +39,8 @@ release-assets/
windows/
x64/
KoTalk-windows-x64-v0.2.0-alpha.1.zip
+ KoTalk-windows-x64-onefile-v0.2.0-alpha.1.exe
+ KoTalk-windows-x64-installer-v0.2.0-alpha.1.exe
SHA256SUMS.txt
android/
universal/
@@ -46,14 +52,16 @@ release-assets/
- 같은 버전은 같은 서버 API 계약과 같은 릴리즈 노트를 공유합니다.
- Windows와 Android는 같은 태그 아래 병렬 산출물로 게시합니다.
-- Windows 기본 공개 형식은 `zip`, Android 기본 공개 형식은 `apk`입니다.
+- Windows 기본 공개 형식은 `installer exe + onefile exe + zip`, Android 기본 공개 형식은 `apk`입니다.
- APK는 공개 채널에 올릴 때 반드시 서명본을 사용합니다.
- `latest/version.json`은 전체 플랫폼 상태를 담고, `latest/windows/version.json`, `latest/android/version.json`은 플랫폼별 상세 포인터를 담습니다.
+- `screenshots/`는 다운로드 미러와 변경 노트에서 참조하는 정적 자산입니다. 공개 릴리즈 페이지의 Assets 첨부에는 넣지 않습니다.
+- `RELEASE_NOTES.ko.md`도 자산 첨부 대신 릴리즈 본문으로 사용합니다.
## 다운로드 경로 규칙
-- 최신 Windows: `https://download-vstalk.phy.kr/windows/latest`
-- 최신 Android: `https://download-vstalk.phy.kr/android/latest`
+- 최신 Windows landing: `https://download-vstalk.phy.kr/windows/latest`
+- 최신 Android landing: `https://download-vstalk.phy.kr/android/latest`
- 전체 최신 메타데이터: `https://download-vstalk.phy.kr/latest/version.json`
- 버전별 Windows: `https://download-vstalk.phy.kr/releases//windows/x64/...`
- 버전별 Android: `https://download-vstalk.phy.kr/releases//android/universal/...`
@@ -69,6 +77,8 @@ release-assets/
--version v0.2.0-alpha.1 \
--channel alpha \
--windows-zip artifacts/release/KoTalk-windows-x64-v0.2.0-alpha.1.zip \
+ --windows-portable-exe artifacts/release/KoTalk-windows-x64-onefile-v0.2.0-alpha.1.exe \
+ --windows-installer-exe artifacts/release/KoTalk-windows-x64-installer-v0.2.0-alpha.1.exe \
--android-apk artifacts/release/KoTalk-android-universal-v0.2.0-alpha.1.apk \
--screenshots artifacts/screenshots \
--force
@@ -87,11 +97,14 @@ release-assets/
- Forge Releases: 버전별 원본 보관
- 다운로드 미러: 최신 포인터와 빠른 정적 다운로드
- 모바일 웹앱: `release-assets/`가 아니라 `vstalk.phy.kr` 배포 트랙에서 별도 운영
-- 공개 원격 릴리즈 페이지에는 ZIP/APK뿐 아니라 `screenshots/` 아래 최신 캡처도 함께 게시합니다.
+- 공개 원격 릴리즈 페이지 Assets는 EXE/ZIP/APK, 최상위 `SHA256SUMS.txt`, 최상위 `version.json`만 유지합니다.
+- 최신 스크린샷은 `release-assets/releases//screenshots/`에 보관하고, 릴리즈 노트 본문에서 직접 참조합니다.
## 운영 메모
- 생성된 버전별 산출물은 워크트리에 유지하며, 최신 로컬 검수와 서버 업로드 기준으로 사용합니다.
- 공개 릴리즈마다 `RELEASE_NOTES.ko.md`, `SHA256SUMS.txt`, `version.json`을 함께 갱신합니다.
- 같은 버전에서 Windows만 있고 Android가 아직 없을 수는 있지만, 장기 원칙은 `같은 버전 아래 두 플랫폼 병렬 게시`입니다.
+- Windows latest landing은 설치형, onefile, 압축본을 동시에 노출합니다.
+- Android latest landing은 APK 하나만 직접 노출하고, iOS는 저장소 Assets에 포함하지 않습니다.
- 모바일 웹앱 정적 산출물은 `release-assets/`가 아니라 `/srv/vs-messanger/webapp/releases/`에 배포합니다.
diff --git a/release-assets/templates/RELEASE_NOTES.ko.md b/release-assets/templates/RELEASE_NOTES.ko.md
index 77db2c0..33835c6 100644
--- a/release-assets/templates/RELEASE_NOTES.ko.md
+++ b/release-assets/templates/RELEASE_NOTES.ko.md
@@ -5,11 +5,13 @@
## 이번 빌드에 포함된 것
+- Windows x64 installer exe
+- Windows x64 onefile portable exe
- Windows x64 portable zip
- Android universal apk
- 무결성 체크용 `SHA256SUMS.txt`
- 버전 메타데이터 `version.json`, `latest.json`
-- 필요 시 한국어 스크린샷
+- 최신 스크린샷은 이 변경 노트 하단에서 직접 확인할 수 있습니다.
## 확인할 것
diff --git a/scripts/branding/generate_android_icon_assets.py b/scripts/branding/generate_android_icon_assets.py
new file mode 100755
index 0000000..8e7a171
--- /dev/null
+++ b/scripts/branding/generate_android_icon_assets.py
@@ -0,0 +1,50 @@
+#!/usr/bin/env python3
+from __future__ import annotations
+
+from pathlib import Path
+
+from PIL import Image
+
+
+ROOT = Path(__file__).resolve().parents[2]
+SOURCE = ROOT / "branding" / "png" / "kotalk-transparent-1024.png"
+ANDROID_RES = ROOT / "src" / "PhysOn.Mobile.Android" / "Resources"
+
+BACKGROUND = (247, 243, 238, 255)
+ICON_SIZES = {
+ "mipmap-mdpi": 48,
+ "mipmap-hdpi": 72,
+ "mipmap-xhdpi": 96,
+ "mipmap-xxhdpi": 144,
+ "mipmap-xxxhdpi": 192,
+}
+
+
+def resize_logo(size: int, scale: float) -> Image.Image:
+ source = Image.open(SOURCE).convert("RGBA")
+ logo_size = max(8, round(size * scale))
+ logo = source.resize((logo_size, logo_size), Image.Resampling.LANCZOS)
+
+ canvas = Image.new("RGBA", (size, size), (0, 0, 0, 0))
+ offset = ((size - logo_size) // 2, (size - logo_size) // 2)
+ canvas.alpha_composite(logo, offset)
+ return canvas
+
+
+def render_assets() -> None:
+ for folder, size in ICON_SIZES.items():
+ directory = ANDROID_RES / folder
+ directory.mkdir(parents=True, exist_ok=True)
+
+ background = Image.new("RGBA", (size, size), BACKGROUND)
+ foreground = resize_logo(size, 0.74)
+ full_icon = background.copy()
+ full_icon.alpha_composite(resize_logo(size, 0.64))
+
+ background.save(directory / "appicon_background.png")
+ foreground.save(directory / "appicon_foreground.png")
+ full_icon.save(directory / "appicon.png")
+
+
+if __name__ == "__main__":
+ render_assets()
diff --git a/scripts/release/build-android-apk.sh b/scripts/release/build-android-apk.sh
new file mode 100755
index 0000000..f358def
--- /dev/null
+++ b/scripts/release/build-android-apk.sh
@@ -0,0 +1,119 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+usage() {
+ cat <<'EOF'
+Usage:
+ ./scripts/release/build-android-apk.sh --version 2026.04.16-alpha.6 [options]
+
+Options:
+ --configuration Build configuration. Default: Release
+ --output Output directory. Default: artifacts/builds/
+ --dotnet Explicit dotnet binary path
+EOF
+}
+
+version=""
+configuration="Release"
+output_dir=""
+dotnet_bin="${DOTNET_BIN:-}"
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --version)
+ version="${2:-}"
+ shift 2
+ ;;
+ --configuration)
+ configuration="${2:-}"
+ shift 2
+ ;;
+ --output)
+ output_dir="${2:-}"
+ shift 2
+ ;;
+ --dotnet)
+ dotnet_bin="${2:-}"
+ shift 2
+ ;;
+ -h|--help)
+ usage
+ exit 0
+ ;;
+ *)
+ echo "Unknown argument: $1" >&2
+ usage >&2
+ exit 1
+ ;;
+ esac
+done
+
+if [[ -z "$version" ]]; then
+ usage >&2
+ exit 1
+fi
+
+repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
+
+if [[ -z "$dotnet_bin" ]]; then
+ if command -v dotnet >/dev/null 2>&1; then
+ dotnet_bin="$(command -v dotnet)"
+ elif [[ -x "$HOME/.dotnet/dotnet" ]]; then
+ dotnet_bin="$HOME/.dotnet/dotnet"
+ else
+ echo "Unable to find dotnet. Set --dotnet or DOTNET_BIN." >&2
+ exit 1
+ fi
+fi
+
+if [[ -z "${JAVA_HOME:-}" && -d /usr/lib/jvm/java-17-openjdk-amd64 ]]; then
+ export JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64
+ export PATH="$JAVA_HOME/bin:$PATH"
+fi
+
+if [[ -z "${ANDROID_SDK_ROOT:-}" ]]; then
+ export ANDROID_SDK_ROOT="$HOME/Android/Sdk"
+fi
+
+if [[ -z "$output_dir" ]]; then
+ output_dir="$repo_root/artifacts/builds/$version"
+fi
+
+publish_dir="$output_dir/android/publish"
+apk_path="$output_dir/KoTalk-android-universal-$version.apk"
+
+rm -rf "$publish_dir"
+mkdir -p "$publish_dir" "$output_dir"
+
+if [[ ! -d "$ANDROID_SDK_ROOT/platforms" ]]; then
+ mkdir -p "$ANDROID_SDK_ROOT"
+ "$dotnet_bin" build "$repo_root/src/PhysOn.Mobile.Android/PhysOn.Mobile.Android.csproj" \
+ -t:InstallAndroidDependencies \
+ -f net8.0-android \
+ -p:AndroidSdkDirectory="$ANDROID_SDK_ROOT" \
+ -p:JavaSdkDirectory="${JAVA_HOME:-}" >/dev/null
+fi
+
+"$dotnet_bin" publish "$repo_root/src/PhysOn.Mobile.Android/PhysOn.Mobile.Android.csproj" \
+ -c "$configuration" \
+ -f net8.0-android \
+ -p:AndroidPackageFormat=apk \
+ -p:AndroidKeyStore=false \
+ -p:AndroidSdkDirectory="$ANDROID_SDK_ROOT" \
+ -p:JavaSdkDirectory="${JAVA_HOME:-}" \
+ -o "$publish_dir"
+
+apk_source="$(find "$publish_dir" -type f -name '*.apk' | head -n 1)"
+if [[ -z "$apk_source" ]]; then
+ echo "Android publish did not produce an APK." >&2
+ exit 1
+fi
+
+cp "$apk_source" "$apk_path"
+
+(
+ cd "$output_dir"
+ sha256sum "$(basename "$apk_path")" > KoTalk-android-universal-$version.apk.sha256
+)
+
+echo "Built Android APK in $output_dir"
diff --git a/scripts/release/build-windows-distributions.sh b/scripts/release/build-windows-distributions.sh
new file mode 100755
index 0000000..9c90a8c
--- /dev/null
+++ b/scripts/release/build-windows-distributions.sh
@@ -0,0 +1,130 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+usage() {
+ cat <<'EOF'
+Usage:
+ ./scripts/release/build-windows-distributions.sh --version 2026.04.16-alpha.6 [options]
+
+Options:
+ --configuration Build configuration. Default: Release
+ --runtime Runtime identifier. Default: win-x64
+ --output Output directory. Default: artifacts/builds/
+ --dotnet Explicit dotnet binary path
+EOF
+}
+
+version=""
+configuration="Release"
+runtime="win-x64"
+output_dir=""
+dotnet_bin="${DOTNET_BIN:-}"
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --version)
+ version="${2:-}"
+ shift 2
+ ;;
+ --configuration)
+ configuration="${2:-}"
+ shift 2
+ ;;
+ --runtime)
+ runtime="${2:-}"
+ shift 2
+ ;;
+ --output)
+ output_dir="${2:-}"
+ shift 2
+ ;;
+ --dotnet)
+ dotnet_bin="${2:-}"
+ shift 2
+ ;;
+ -h|--help)
+ usage
+ exit 0
+ ;;
+ *)
+ echo "Unknown argument: $1" >&2
+ usage >&2
+ exit 1
+ ;;
+ esac
+done
+
+if [[ -z "$version" ]]; then
+ usage >&2
+ exit 1
+fi
+
+repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
+
+if [[ -z "$dotnet_bin" ]]; then
+ if command -v dotnet >/dev/null 2>&1; then
+ dotnet_bin="$(command -v dotnet)"
+ elif [[ -x "$HOME/.dotnet/dotnet" ]]; then
+ dotnet_bin="$HOME/.dotnet/dotnet"
+ else
+ echo "Unable to find dotnet. Set --dotnet or DOTNET_BIN." >&2
+ exit 1
+ fi
+fi
+
+if [[ -z "$output_dir" ]]; then
+ output_dir="$repo_root/artifacts/builds/$version"
+fi
+
+publish_dir="$output_dir/publish/$runtime"
+onefile_dir="$output_dir/onefile/$runtime"
+zip_path="$output_dir/KoTalk-windows-x64-$version.zip"
+portable_exe_path="$output_dir/KoTalk-windows-x64-onefile-$version.exe"
+installer_path="$output_dir/KoTalk-windows-x64-installer-$version.exe"
+installer_script="$repo_root/packaging/windows/KoTalkInstaller.nsi"
+app_icon="$repo_root/branding/ico/kotalk.ico"
+
+rm -rf "$publish_dir" "$onefile_dir"
+mkdir -p "$publish_dir" "$onefile_dir" "$output_dir"
+
+"$dotnet_bin" publish "$repo_root/src/PhysOn.Desktop/PhysOn.Desktop.csproj" \
+ -c "$configuration" \
+ -r "$runtime" \
+ --self-contained true \
+ -p:DebugSymbols=false \
+ -p:DebugType=None \
+ -o "$publish_dir"
+
+rm -f "$zip_path"
+(cd "$publish_dir" && zip -rq "$zip_path" .)
+
+"$dotnet_bin" publish "$repo_root/src/PhysOn.Desktop/PhysOn.Desktop.csproj" \
+ -c "$configuration" \
+ -r "$runtime" \
+ --self-contained true \
+ -p:PublishSingleFile=true \
+ -p:IncludeNativeLibrariesForSelfExtract=true \
+ -p:EnableCompressionInSingleFile=true \
+ -p:DebugSymbols=false \
+ -p:DebugType=None \
+ -o "$onefile_dir"
+
+cp "$onefile_dir/KoTalk.exe" "$portable_exe_path"
+
+makensis \
+ -DAPP_VERSION="$version" \
+ -DAPP_ICON="$app_icon" \
+ -DSOURCE_DIR="$publish_dir" \
+ -DOUTPUT_FILE="$installer_path" \
+ "$installer_script" >/dev/null
+
+(
+ cd "$output_dir"
+ sha256sum \
+ "$(basename "$zip_path")" \
+ "$(basename "$portable_exe_path")" \
+ "$(basename "$installer_path")" \
+ > SHA256SUMS.txt
+)
+
+echo "Built Windows release assets in $output_dir"
diff --git a/scripts/release/release-prepare-assets.sh b/scripts/release/release-prepare-assets.sh
index 3a3d752..c84b505 100755
--- a/scripts/release/release-prepare-assets.sh
+++ b/scripts/release/release-prepare-assets.sh
@@ -8,6 +8,10 @@ Usage:
Options:
--windows-zip Windows x64 ZIP artifact path
+ --windows-portable-exe
+ Windows onefile portable EXE artifact path
+ --windows-installer-exe
+ Windows installer EXE artifact path
--android-apk Android universal APK artifact path
--zip Backward-compatible alias for --windows-zip
--channel Release channel. Default: alpha
@@ -23,6 +27,8 @@ EOF
version=""
channel="alpha"
windows_zip=""
+windows_portable_exe=""
+windows_installer_exe=""
android_apk=""
notes_path=""
screenshots_dir=""
@@ -42,6 +48,14 @@ while [[ $# -gt 0 ]]; do
windows_zip="${2:-}"
shift 2
;;
+ --windows-portable-exe)
+ windows_portable_exe="${2:-}"
+ shift 2
+ ;;
+ --windows-installer-exe)
+ windows_installer_exe="${2:-}"
+ shift 2
+ ;;
--android-apk)
android_apk="${2:-}"
shift 2
@@ -75,8 +89,8 @@ if [[ -z "$version" ]]; then
exit 1
fi
-if [[ -z "$windows_zip" && -z "$android_apk" ]]; then
- echo "At least one artifact must be provided: --windows-zip or --android-apk" >&2
+if [[ -z "$windows_zip" && -z "$windows_portable_exe" && -z "$windows_installer_exe" && -z "$android_apk" ]]; then
+ echo "At least one artifact must be provided for Windows or Android." >&2
usage >&2
exit 1
fi
@@ -86,6 +100,16 @@ if [[ -n "$windows_zip" && ! -f "$windows_zip" ]]; then
exit 1
fi
+if [[ -n "$windows_portable_exe" && ! -f "$windows_portable_exe" ]]; then
+ echo "Windows portable EXE artifact not found: $windows_portable_exe" >&2
+ exit 1
+fi
+
+if [[ -n "$windows_installer_exe" && ! -f "$windows_installer_exe" ]]; then
+ echo "Windows installer EXE artifact not found: $windows_installer_exe" >&2
+ exit 1
+fi
+
if [[ -n "$android_apk" && ! -f "$android_apk" ]]; then
echo "Android APK artifact not found: $android_apk" >&2
exit 1
@@ -146,6 +170,31 @@ else
fi
cp "$release_root/RELEASE_NOTES.ko.md" "$latest_root/RELEASE_NOTES.ko.md"
+append_screenshot_gallery() {
+ local notes_file="$1"
+ local screenshot_root_url="$2"
+
+ if [[ -z "$screenshots_dir" ]]; then
+ return 0
+ fi
+
+ mapfile -t note_screenshots < <(find "$screenshots_dir" -maxdepth 1 -type f \( -iname '*.png' -o -iname '*.jpg' -o -iname '*.jpeg' \) | sort)
+ if [[ ${#note_screenshots[@]} -eq 0 ]]; then
+ return 0
+ fi
+
+ {
+ printf '\n## 최신 화면\n\n'
+ printf '스크린샷은 릴리즈 Assets 대신 변경 노트 안에서 직접 확인할 수 있도록 정리합니다.\n\n'
+ for screenshot in "${note_screenshots[@]}"; do
+ name="$(basename "$screenshot")"
+ label="${name%.*}"
+ printf '### %s\n\n' "$label"
+ printf '\n\n' "$label" "$screenshot_root_url" "$name"
+ done
+ } >> "$notes_file"
+}
+
if [[ -n "$screenshots_dir" ]]; then
while IFS= read -r screenshot; do
cp "$screenshot" "$release_root/screenshots/$(basename "$screenshot")"
@@ -153,6 +202,9 @@ if [[ -n "$screenshots_dir" ]]; then
done < <(find "$screenshots_dir" -maxdepth 1 -type f \( -iname '*.png' -o -iname '*.jpg' -o -iname '*.jpeg' \) | sort)
fi
+append_screenshot_gallery "$release_root/RELEASE_NOTES.ko.md" "$download_base_url/releases/$version/screenshots"
+cp "$release_root/RELEASE_NOTES.ko.md" "$latest_root/RELEASE_NOTES.ko.md"
+
platform_count=0
platforms_json=""
top_level_windows_alias=""
@@ -168,6 +220,23 @@ append_platform_json() {
platform_count=$((platform_count + 1))
}
+join_json_lines() {
+ local result=""
+ local total=$#
+ local index=0
+ local line=""
+
+ for line in "$@"; do
+ index=$((index + 1))
+ result+="$line"
+ if (( index < total )); then
+ result+=$',\n'
+ fi
+ done
+
+ printf '%s' "$result"
+}
+
write_platform_version_json() {
local path="$1"
local body="$2"
@@ -187,38 +256,69 @@ $body
EOF
}
-if [[ -n "$windows_zip" ]]; then
- windows_release_name="KoTalk-windows-x64-$version.zip"
- windows_latest_name="KoTalk-windows-x64.zip"
+if [[ -n "$windows_zip" || -n "$windows_portable_exe" || -n "$windows_installer_exe" ]]; then
windows_release_dir="$release_root/windows/x64"
windows_latest_dir="$latest_root/windows"
mkdir -p "$windows_release_dir" "$windows_latest_dir"
- cp "$windows_zip" "$windows_release_dir/$windows_release_name"
- cp "$windows_zip" "$windows_latest_dir/$windows_latest_name"
+ windows_release_hash_items=()
+ windows_latest_hash_items=()
+ windows_platform_lines=(
+ ' "name": "KoTalk for Windows"'
+ ' "kind": "desktop"'
+ ' "arch": "x64"'
+ " \"landingUrl\": \"$download_base_url/windows/latest\""
+ )
+
+ if [[ -n "$windows_zip" ]]; then
+ windows_release_name="KoTalk-windows-x64-$version.zip"
+ windows_latest_name="KoTalk-windows-x64.zip"
+ cp "$windows_zip" "$windows_release_dir/$windows_release_name"
+ cp "$windows_zip" "$windows_latest_dir/$windows_latest_name"
+ release_hash_paths+=("windows/x64/$windows_release_name")
+ latest_hash_paths+=("windows/$windows_latest_name")
+ windows_release_hash_items+=("$windows_release_name")
+ windows_latest_hash_items+=("$windows_latest_name")
+ windows_platform_lines+=(" \"portableZipUrl\": \"$download_base_url/windows/latest/$windows_latest_name\"")
+ fi
+
+ if [[ -n "$windows_portable_exe" ]]; then
+ windows_onefile_release_name="KoTalk-windows-x64-onefile-$version.exe"
+ windows_onefile_latest_name="KoTalk-windows-x64-onefile.exe"
+ cp "$windows_portable_exe" "$windows_release_dir/$windows_onefile_release_name"
+ cp "$windows_portable_exe" "$windows_latest_dir/$windows_onefile_latest_name"
+ release_hash_paths+=("windows/x64/$windows_onefile_release_name")
+ latest_hash_paths+=("windows/$windows_onefile_latest_name")
+ windows_release_hash_items+=("$windows_onefile_release_name")
+ windows_latest_hash_items+=("$windows_onefile_latest_name")
+ windows_platform_lines+=(" \"portableExeUrl\": \"$download_base_url/windows/latest/$windows_onefile_latest_name\"")
+ fi
+
+ if [[ -n "$windows_installer_exe" ]]; then
+ windows_installer_release_name="KoTalk-windows-x64-installer-$version.exe"
+ windows_installer_latest_name="KoTalk-windows-x64-installer.exe"
+ cp "$windows_installer_exe" "$windows_release_dir/$windows_installer_release_name"
+ cp "$windows_installer_exe" "$windows_latest_dir/$windows_installer_latest_name"
+ release_hash_paths+=("windows/x64/$windows_installer_release_name")
+ latest_hash_paths+=("windows/$windows_installer_latest_name")
+ windows_release_hash_items+=("$windows_installer_release_name")
+ windows_latest_hash_items+=("$windows_installer_latest_name")
+ windows_platform_lines+=(" \"installerUrl\": \"$download_base_url/windows/latest/$windows_installer_latest_name\"")
+ fi
+
+ windows_platform_lines+=(" \"sha256Url\": \"$download_base_url/windows/latest/SHA256SUMS.txt\"")
(
cd "$windows_release_dir"
- sha256sum "$windows_release_name" > SHA256SUMS.txt
+ sha256sum "${windows_release_hash_items[@]}" > SHA256SUMS.txt
)
(
cd "$windows_latest_dir"
- sha256sum "$windows_latest_name" > SHA256SUMS.txt
+ sha256sum "${windows_latest_hash_items[@]}" > SHA256SUMS.txt
)
- release_hash_paths+=("windows/x64/$windows_release_name")
- latest_hash_paths+=("windows/$windows_latest_name")
-
- windows_platform_body="$(cat < 0 )); then
fi
windows_landing_card=""
-if [[ -n "$windows_zip" ]]; then
+if [[ -n "$windows_zip" || -n "$windows_portable_exe" || -n "$windows_installer_exe" ]]; then
windows_landing_card="$(cat <
Windows
Latest Windows build
- ZIP package and SHA256 checksum
+ Installer, onefile portable, ZIP, SHA256
EOF
)"
@@ -321,6 +421,156 @@ EOF
)"
fi
+windows_installer_card=""
+if [[ -n "$windows_installer_exe" ]]; then
+ windows_installer_card="$(cat <
+ Installer
+ Windows installer
+ 설치형 EXE
+
+EOF
+)"
+fi
+
+windows_portable_card=""
+if [[ -n "$windows_portable_exe" ]]; then
+ windows_portable_card="$(cat <
+ Portable
+ Onefile executable
+ 압축 해제 없이 바로 실행
+
+EOF
+)"
+fi
+
+windows_zip_card=""
+if [[ -n "$windows_zip" ]]; then
+ windows_zip_card="$(cat <
+ Archive
+ Extracted bundle ZIP
+ 폴더 배포본 압축 파일
+
+EOF
+)"
+fi
+
+if [[ -d "${latest_root}/windows" ]]; then
+ cat > "${latest_root}/windows/index.html" <
+
+
+
+
+ KoTalk for Windows
+
+
+
+
+
+ KoTalk for Windows
+ 설치형과 onefile portable, 압축본을 같은 기준선으로 제공합니다.
+
+ version.json
+
+
+
+
+EOF
+fi
+
+if [[ -d "${latest_root}/android" ]]; then
+ cat > "${latest_root}/android/index.html" <
+
+
+
+
+ KoTalk for Android
+
+
+
+
+
+ KoTalk for Android
+ KoTalk Android shell APK와 버전 메타데이터를 같은 기준선으로 제공합니다.
+
+ version.json
+
+
+
+
+EOF
+fi
+
cat > "$download_root/index.html" <
diff --git a/scripts/release/release-publish-forge.sh b/scripts/release/release-publish-forge.sh
index 4dc928d..1f1a6d4 100755
--- a/scripts/release/release-publish-forge.sh
+++ b/scripts/release/release-publish-forge.sh
@@ -168,9 +168,11 @@ case "$version" in
esac
mapfile -t asset_files < <(
- find "$release_root" -type f \
- \( -name '*.zip' -o -name '*.apk' -o -name '*.png' -o -name '*.jpg' -o -name '*.jpeg' -o -name 'RELEASE_NOTES.ko.md' -o -name 'SHA256SUMS.txt' -o -name 'version.json' \) \
- | sort
+ {
+ find "$release_root" -type f \( -name '*.zip' -o -name '*.exe' -o -name '*.apk' \)
+ [[ -f "$release_root/SHA256SUMS.txt" ]] && printf '%s\n' "$release_root/SHA256SUMS.txt"
+ [[ -f "$release_root/version.json" ]] && printf '%s\n' "$release_root/version.json"
+ } | sort
)
if [[ ${#asset_files[@]} -eq 0 ]]; then
diff --git a/scripts/release/release-publish-github.sh b/scripts/release/release-publish-github.sh
index f8660ed..61ba7b9 100755
--- a/scripts/release/release-publish-github.sh
+++ b/scripts/release/release-publish-github.sh
@@ -123,9 +123,11 @@ case "$version" in
esac
mapfile -t asset_files < <(
- find "$release_root" -type f \
- \( -name '*.zip' -o -name '*.apk' -o -name '*.png' -o -name '*.jpg' -o -name '*.jpeg' -o -name 'RELEASE_NOTES.ko.md' -o -name 'SHA256SUMS.txt' -o -name 'version.json' \) \
- | sort
+ {
+ find "$release_root" -type f \( -name '*.zip' -o -name '*.exe' -o -name '*.apk' \)
+ [[ -f "$release_root/SHA256SUMS.txt" ]] && printf '%s\n' "$release_root/SHA256SUMS.txt"
+ [[ -f "$release_root/version.json" ]] && printf '%s\n' "$release_root/version.json"
+ } | sort
)
if [[ ${#asset_files[@]} -eq 0 ]]; then
diff --git a/src/PhysOn.Desktop/PhysOn.Desktop.csproj b/src/PhysOn.Desktop/PhysOn.Desktop.csproj
index e6eb2cc..2d6e552 100644
--- a/src/PhysOn.Desktop/PhysOn.Desktop.csproj
+++ b/src/PhysOn.Desktop/PhysOn.Desktop.csproj
@@ -12,10 +12,10 @@
KoTalk
한국어 중심의 차분한 메시징 경험을 다시 설계하는 Windows-first 메신저
KoTalk
- 0.1.0.5
- 0.1.0.5
- 0.1.0-alpha.5
- 0.1.0-alpha.5
+ 0.1.0.6
+ 0.1.0.6
+ 0.1.0-alpha.6
+ 0.1.0-alpha.6
true
diff --git a/src/PhysOn.Desktop/ViewModels/MainWindowViewModel.cs b/src/PhysOn.Desktop/ViewModels/MainWindowViewModel.cs
index e9ce4c6..cddd308 100644
--- a/src/PhysOn.Desktop/ViewModels/MainWindowViewModel.cs
+++ b/src/PhysOn.Desktop/ViewModels/MainWindowViewModel.cs
@@ -329,7 +329,7 @@ public partial class MainWindowViewModel : ViewModelBase, IAsyncDisposable
$"desktop-{Environment.MachineName.ToLowerInvariant()}",
"windows",
Environment.MachineName,
- "0.1.0-alpha.5"));
+ "0.1.0-alpha.6"));
var response = await _apiClient.RegisterAlphaQuickAsync(apiBaseUrl, request, CancellationToken.None);
ApiBaseUrl = apiBaseUrl;
diff --git a/src/PhysOn.Mobile.Android/AndroidManifest.xml b/src/PhysOn.Mobile.Android/AndroidManifest.xml
new file mode 100644
index 0000000..f8c2c84
--- /dev/null
+++ b/src/PhysOn.Mobile.Android/AndroidManifest.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
diff --git a/src/PhysOn.Mobile.Android/MainActivity.cs b/src/PhysOn.Mobile.Android/MainActivity.cs
new file mode 100644
index 0000000..790fff3
--- /dev/null
+++ b/src/PhysOn.Mobile.Android/MainActivity.cs
@@ -0,0 +1,268 @@
+using Android.App;
+using Android.Content;
+using Android.Content.PM;
+using Android.Graphics;
+using Android.Net;
+using Android.Net.Http;
+using Android.OS;
+using Android.Views;
+using Android.Webkit;
+using Android.Widget;
+
+namespace PhysOn.Mobile.Android;
+
+[Activity(
+ Label = "@string/app_name",
+ MainLauncher = true,
+ Exported = true,
+ LaunchMode = LaunchMode.SingleTask,
+ Theme = "@style/KoTalkTheme",
+ ConfigurationChanges =
+ ConfigChanges.Orientation |
+ ConfigChanges.ScreenSize |
+ ConfigChanges.SmallestScreenSize |
+ ConfigChanges.ScreenLayout |
+ ConfigChanges.UiMode |
+ ConfigChanges.KeyboardHidden |
+ ConfigChanges.Density)]
+public class MainActivity : Activity
+{
+ private const string AppVersion = "0.1.0-alpha.6";
+ private const string HomeUrl = "https://vstalk.phy.kr";
+
+ private static readonly HashSet AllowedHosts = new(StringComparer.OrdinalIgnoreCase)
+ {
+ "vstalk.phy.kr",
+ "download-vstalk.phy.kr"
+ };
+
+ private WebView? _webView;
+ private ProgressBar? _loadingBar;
+ private View? _offlineOverlay;
+ private ImageButton? _retryButton;
+
+ protected override void OnCreate(Bundle? savedInstanceState)
+ {
+ base.OnCreate(savedInstanceState);
+ ConfigureWindowChrome();
+ SetContentView(Resource.Layout.activity_main);
+
+ _webView = FindViewById(Resource.Id.app_webview);
+ _loadingBar = FindViewById(Resource.Id.loading_bar);
+ _offlineOverlay = FindViewById(Resource.Id.offline_overlay);
+ _retryButton = FindViewById(Resource.Id.retry_button);
+
+ if (_webView is null || _loadingBar is null || _offlineOverlay is null || _retryButton is null)
+ {
+ throw new InvalidOperationException("KoTalk Android shell layout failed to load.");
+ }
+
+ _retryButton.Click += HandleRetryClick;
+ ConfigureWebView(_webView, _loadingBar);
+
+ if (savedInstanceState is not null)
+ {
+ _webView.RestoreState(savedInstanceState);
+ }
+ else
+ {
+ _webView.LoadUrl(HomeUrl);
+ }
+ }
+
+ protected override void OnSaveInstanceState(Bundle outState)
+ {
+ base.OnSaveInstanceState(outState);
+ _webView?.SaveState(outState);
+ }
+
+ protected override void OnDestroy()
+ {
+ if (_retryButton is not null)
+ {
+ _retryButton.Click -= HandleRetryClick;
+ }
+
+ if (_webView is not null)
+ {
+ _webView.StopLoading();
+ _webView.Destroy();
+ _webView = null;
+ }
+
+ base.OnDestroy();
+ }
+
+ public override void OnBackPressed()
+ {
+ if (_webView?.CanGoBack() == true)
+ {
+ _webView.GoBack();
+ return;
+ }
+
+#pragma warning disable CA1422
+ base.OnBackPressed();
+#pragma warning restore CA1422
+ }
+
+ private void HandleRetryClick(object? sender, EventArgs e)
+ {
+ _webView?.Reload();
+ }
+
+ private void ConfigureWindowChrome()
+ {
+ Window?.AddFlags(WindowManagerFlags.DrawsSystemBarBackgrounds);
+
+ if (Window is null)
+ {
+ return;
+ }
+
+ Window.SetStatusBarColor(Color.ParseColor("#F7F3EE"));
+ Window.SetNavigationBarColor(Color.ParseColor("#F7F3EE"));
+ }
+
+ private void ConfigureWebView(WebView webView, ProgressBar loadingBar)
+ {
+ WebView.SetWebContentsDebuggingEnabled(System.Diagnostics.Debugger.IsAttached);
+
+ var settings = webView.Settings!;
+ settings.JavaScriptEnabled = true;
+ settings.DomStorageEnabled = true;
+ settings.DatabaseEnabled = true;
+ settings.AllowFileAccess = false;
+ settings.AllowContentAccess = false;
+ settings.SetSupportZoom(false);
+ settings.BuiltInZoomControls = false;
+ settings.DisplayZoomControls = false;
+ settings.LoadWithOverviewMode = true;
+ settings.UseWideViewPort = true;
+ settings.MixedContentMode = MixedContentHandling.NeverAllow;
+ settings.CacheMode = CacheModes.Default;
+ settings.MediaPlaybackRequiresUserGesture = true;
+ settings.UserAgentString = $"{settings.UserAgentString} KoTalkAndroid/{AppVersion}";
+
+ var cookies = CookieManager.Instance;
+ cookies?.SetAcceptCookie(true);
+ cookies?.SetAcceptThirdPartyCookies(webView, false);
+
+ webView.SetBackgroundColor(Color.ParseColor("#F7F3EE"));
+ webView.SetWebChromeClient(new KoTalkWebChromeClient(loadingBar));
+ webView.SetWebViewClient(
+ new KoTalkWebViewClient(
+ AllowedHosts,
+ ShowOfflineOverlay,
+ HideOfflineOverlay));
+ }
+
+ private void ShowOfflineOverlay()
+ {
+ RunOnUiThread(() =>
+ {
+ if (_offlineOverlay is not null)
+ {
+ _offlineOverlay.Visibility = ViewStates.Visible;
+ }
+
+ if (_loadingBar is not null)
+ {
+ _loadingBar.Visibility = ViewStates.Invisible;
+ }
+ });
+ }
+
+ private void HideOfflineOverlay()
+ {
+ RunOnUiThread(() =>
+ {
+ if (_offlineOverlay is not null)
+ {
+ _offlineOverlay.Visibility = ViewStates.Gone;
+ }
+ });
+ }
+
+ private sealed class KoTalkWebChromeClient(ProgressBar loadingBar) : WebChromeClient
+ {
+ public override void OnProgressChanged(WebView? view, int newProgress)
+ {
+ base.OnProgressChanged(view, newProgress);
+ loadingBar.Progress = newProgress;
+ loadingBar.Visibility = newProgress >= 100 ? ViewStates.Invisible : ViewStates.Visible;
+ }
+ }
+
+ private sealed class KoTalkWebViewClient(
+ IReadOnlySet allowedHosts,
+ Action showOfflineOverlay,
+ Action hideOfflineOverlay) : WebViewClient
+ {
+ public override bool ShouldOverrideUrlLoading(WebView? view, IWebResourceRequest? request)
+ {
+ if (request?.Url is null)
+ {
+ return false;
+ }
+
+ var url = request.Url;
+ var scheme = url.Scheme?.ToLowerInvariant();
+ var host = url.Host?.ToLowerInvariant();
+
+ if (scheme is "http" or "https" && host is not null && allowedHosts.Contains(host))
+ {
+ return false;
+ }
+
+ if (view?.Context is null)
+ {
+ return true;
+ }
+
+ try
+ {
+ var intent = new Intent(Intent.ActionView, global::Android.Net.Uri.Parse(url.ToString()));
+ intent.AddFlags(ActivityFlags.NewTask);
+ view.Context.StartActivity(intent);
+ }
+ catch (ActivityNotFoundException)
+ {
+ showOfflineOverlay();
+ }
+
+ return true;
+ }
+
+ public override void OnPageStarted(WebView? view, string? url, Bitmap? favicon)
+ {
+ base.OnPageStarted(view, url, favicon);
+ hideOfflineOverlay();
+ }
+
+ public override void OnPageFinished(WebView? view, string? url)
+ {
+ base.OnPageFinished(view, url);
+ hideOfflineOverlay();
+ }
+
+ public override void OnReceivedError(
+ WebView? view,
+ IWebResourceRequest? request,
+ WebResourceError? error)
+ {
+ base.OnReceivedError(view, request, error);
+
+ if (request?.IsForMainFrame ?? true)
+ {
+ showOfflineOverlay();
+ }
+ }
+
+ public override void OnReceivedSslError(WebView? view, SslErrorHandler? handler, SslError? error)
+ {
+ handler?.Cancel();
+ showOfflineOverlay();
+ }
+ }
+}
diff --git a/src/PhysOn.Mobile.Android/PhysOn.Mobile.Android.csproj b/src/PhysOn.Mobile.Android/PhysOn.Mobile.Android.csproj
new file mode 100644
index 0000000..a58c1ed
--- /dev/null
+++ b/src/PhysOn.Mobile.Android/PhysOn.Mobile.Android.csproj
@@ -0,0 +1,37 @@
+
+
+ net8.0-android
+ 26
+ Exe
+ enable
+ enable
+ PhysOn.Mobile.Android
+ KoTalk.Mobile.Android
+ kr.physia.kotalk
+ 6
+ 0.1.0-alpha.6
+ PHYSIA
+ PHYSIA
+ KoTalk
+ KoTalk Android shell for the live Korean messaging experience
+ KoTalk
+ apk
+ false
+
+
+
+ $(JAVA_HOME)
+
+
+
+ /usr/lib/jvm/java-17-openjdk-amd64
+
+
+
+ $(ANDROID_SDK_ROOT)
+
+
+
+ $([System.Environment]::GetEnvironmentVariable('HOME'))/Android/Sdk
+
+
diff --git a/src/PhysOn.Mobile.Android/Resources/AboutResources.txt b/src/PhysOn.Mobile.Android/Resources/AboutResources.txt
new file mode 100644
index 0000000..219f425
--- /dev/null
+++ b/src/PhysOn.Mobile.Android/Resources/AboutResources.txt
@@ -0,0 +1,44 @@
+Images, layout descriptions, binary blobs and string dictionaries can be included
+in your application as resource files. Various Android APIs are designed to
+operate on the resource IDs instead of dealing with images, strings or binary blobs
+directly.
+
+For example, a sample Android app that contains a user interface layout (main.xml),
+an internationalization string table (strings.xml) and some icons (drawable-XXX/icon.png)
+would keep its resources in the "Resources" directory of the application:
+
+Resources/
+ drawable/
+ icon.png
+
+ layout/
+ main.xml
+
+ values/
+ strings.xml
+
+In order to get the build system to recognize Android resources, set the build action to
+"AndroidResource". The native Android APIs do not operate directly with filenames, but
+instead operate on resource IDs. When you compile an Android application that uses resources,
+the build system will package the resources for distribution and generate a class called "Resource"
+(this is an Android convention) that contains the tokens for each one of the resources
+included. For example, for the above Resources layout, this is what the Resource class would expose:
+
+public class Resource {
+ public class Drawable {
+ public const int icon = 0x123;
+ }
+
+ public class Layout {
+ public const int main = 0x456;
+ }
+
+ public class Strings {
+ public const int first_string = 0xabc;
+ public const int second_string = 0xbcd;
+ }
+}
+
+You would then use Resource.Drawable.icon to reference the drawable/icon.png file, or
+Resource.Layout.main to reference the layout/main.xml file, or Resource.Strings.first_string
+to reference the first string in the dictionary file values/strings.xml.
\ No newline at end of file
diff --git a/src/PhysOn.Mobile.Android/Resources/drawable/retry_button_background.xml b/src/PhysOn.Mobile.Android/Resources/drawable/retry_button_background.xml
new file mode 100644
index 0000000..2ef2c5b
--- /dev/null
+++ b/src/PhysOn.Mobile.Android/Resources/drawable/retry_button_background.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/src/PhysOn.Mobile.Android/Resources/layout/activity_main.xml b/src/PhysOn.Mobile.Android/Resources/layout/activity_main.xml
new file mode 100644
index 0000000..41376b0
--- /dev/null
+++ b/src/PhysOn.Mobile.Android/Resources/layout/activity_main.xml
@@ -0,0 +1,52 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/PhysOn.Mobile.Android/Resources/mipmap-anydpi-v26/appicon.xml b/src/PhysOn.Mobile.Android/Resources/mipmap-anydpi-v26/appicon.xml
new file mode 100644
index 0000000..7751f69
--- /dev/null
+++ b/src/PhysOn.Mobile.Android/Resources/mipmap-anydpi-v26/appicon.xml
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/src/PhysOn.Mobile.Android/Resources/mipmap-anydpi-v26/appicon_round.xml b/src/PhysOn.Mobile.Android/Resources/mipmap-anydpi-v26/appicon_round.xml
new file mode 100644
index 0000000..7751f69
--- /dev/null
+++ b/src/PhysOn.Mobile.Android/Resources/mipmap-anydpi-v26/appicon_round.xml
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/src/PhysOn.Mobile.Android/Resources/mipmap-hdpi/appicon.png b/src/PhysOn.Mobile.Android/Resources/mipmap-hdpi/appicon.png
new file mode 100644
index 0000000..05cb38e
Binary files /dev/null and b/src/PhysOn.Mobile.Android/Resources/mipmap-hdpi/appicon.png differ
diff --git a/src/PhysOn.Mobile.Android/Resources/mipmap-hdpi/appicon_background.png b/src/PhysOn.Mobile.Android/Resources/mipmap-hdpi/appicon_background.png
new file mode 100644
index 0000000..8ce7c08
Binary files /dev/null and b/src/PhysOn.Mobile.Android/Resources/mipmap-hdpi/appicon_background.png differ
diff --git a/src/PhysOn.Mobile.Android/Resources/mipmap-hdpi/appicon_foreground.png b/src/PhysOn.Mobile.Android/Resources/mipmap-hdpi/appicon_foreground.png
new file mode 100644
index 0000000..5f5ccfa
Binary files /dev/null and b/src/PhysOn.Mobile.Android/Resources/mipmap-hdpi/appicon_foreground.png differ
diff --git a/src/PhysOn.Mobile.Android/Resources/mipmap-mdpi/appicon.png b/src/PhysOn.Mobile.Android/Resources/mipmap-mdpi/appicon.png
new file mode 100644
index 0000000..d19247e
Binary files /dev/null and b/src/PhysOn.Mobile.Android/Resources/mipmap-mdpi/appicon.png differ
diff --git a/src/PhysOn.Mobile.Android/Resources/mipmap-mdpi/appicon_background.png b/src/PhysOn.Mobile.Android/Resources/mipmap-mdpi/appicon_background.png
new file mode 100644
index 0000000..d867285
Binary files /dev/null and b/src/PhysOn.Mobile.Android/Resources/mipmap-mdpi/appicon_background.png differ
diff --git a/src/PhysOn.Mobile.Android/Resources/mipmap-mdpi/appicon_foreground.png b/src/PhysOn.Mobile.Android/Resources/mipmap-mdpi/appicon_foreground.png
new file mode 100644
index 0000000..20b637e
Binary files /dev/null and b/src/PhysOn.Mobile.Android/Resources/mipmap-mdpi/appicon_foreground.png differ
diff --git a/src/PhysOn.Mobile.Android/Resources/mipmap-xhdpi/appicon.png b/src/PhysOn.Mobile.Android/Resources/mipmap-xhdpi/appicon.png
new file mode 100644
index 0000000..4238084
Binary files /dev/null and b/src/PhysOn.Mobile.Android/Resources/mipmap-xhdpi/appicon.png differ
diff --git a/src/PhysOn.Mobile.Android/Resources/mipmap-xhdpi/appicon_background.png b/src/PhysOn.Mobile.Android/Resources/mipmap-xhdpi/appicon_background.png
new file mode 100644
index 0000000..d890683
Binary files /dev/null and b/src/PhysOn.Mobile.Android/Resources/mipmap-xhdpi/appicon_background.png differ
diff --git a/src/PhysOn.Mobile.Android/Resources/mipmap-xhdpi/appicon_foreground.png b/src/PhysOn.Mobile.Android/Resources/mipmap-xhdpi/appicon_foreground.png
new file mode 100644
index 0000000..1dad34d
Binary files /dev/null and b/src/PhysOn.Mobile.Android/Resources/mipmap-xhdpi/appicon_foreground.png differ
diff --git a/src/PhysOn.Mobile.Android/Resources/mipmap-xxhdpi/appicon.png b/src/PhysOn.Mobile.Android/Resources/mipmap-xxhdpi/appicon.png
new file mode 100644
index 0000000..57783e9
Binary files /dev/null and b/src/PhysOn.Mobile.Android/Resources/mipmap-xxhdpi/appicon.png differ
diff --git a/src/PhysOn.Mobile.Android/Resources/mipmap-xxhdpi/appicon_background.png b/src/PhysOn.Mobile.Android/Resources/mipmap-xxhdpi/appicon_background.png
new file mode 100644
index 0000000..39e0160
Binary files /dev/null and b/src/PhysOn.Mobile.Android/Resources/mipmap-xxhdpi/appicon_background.png differ
diff --git a/src/PhysOn.Mobile.Android/Resources/mipmap-xxhdpi/appicon_foreground.png b/src/PhysOn.Mobile.Android/Resources/mipmap-xxhdpi/appicon_foreground.png
new file mode 100644
index 0000000..db7b224
Binary files /dev/null and b/src/PhysOn.Mobile.Android/Resources/mipmap-xxhdpi/appicon_foreground.png differ
diff --git a/src/PhysOn.Mobile.Android/Resources/mipmap-xxxhdpi/appicon.png b/src/PhysOn.Mobile.Android/Resources/mipmap-xxxhdpi/appicon.png
new file mode 100644
index 0000000..dc9464b
Binary files /dev/null and b/src/PhysOn.Mobile.Android/Resources/mipmap-xxxhdpi/appicon.png differ
diff --git a/src/PhysOn.Mobile.Android/Resources/mipmap-xxxhdpi/appicon_background.png b/src/PhysOn.Mobile.Android/Resources/mipmap-xxxhdpi/appicon_background.png
new file mode 100644
index 0000000..228d0c6
Binary files /dev/null and b/src/PhysOn.Mobile.Android/Resources/mipmap-xxxhdpi/appicon_background.png differ
diff --git a/src/PhysOn.Mobile.Android/Resources/mipmap-xxxhdpi/appicon_foreground.png b/src/PhysOn.Mobile.Android/Resources/mipmap-xxxhdpi/appicon_foreground.png
new file mode 100644
index 0000000..1e690c3
Binary files /dev/null and b/src/PhysOn.Mobile.Android/Resources/mipmap-xxxhdpi/appicon_foreground.png differ
diff --git a/src/PhysOn.Mobile.Android/Resources/values/colors.xml b/src/PhysOn.Mobile.Android/Resources/values/colors.xml
new file mode 100644
index 0000000..9437d23
--- /dev/null
+++ b/src/PhysOn.Mobile.Android/Resources/values/colors.xml
@@ -0,0 +1,8 @@
+
+
+ #F7F3EE
+ #FFFFFF
+ #DDD1C4
+ #20242B
+ #F05B2B
+
diff --git a/src/PhysOn.Mobile.Android/Resources/values/ic_launcher_background.xml b/src/PhysOn.Mobile.Android/Resources/values/ic_launcher_background.xml
new file mode 100644
index 0000000..6ec24e6
--- /dev/null
+++ b/src/PhysOn.Mobile.Android/Resources/values/ic_launcher_background.xml
@@ -0,0 +1,4 @@
+
+
+ #2C3E50
+
\ No newline at end of file
diff --git a/src/PhysOn.Mobile.Android/Resources/values/strings.xml b/src/PhysOn.Mobile.Android/Resources/values/strings.xml
new file mode 100644
index 0000000..c3b649c
--- /dev/null
+++ b/src/PhysOn.Mobile.Android/Resources/values/strings.xml
@@ -0,0 +1,4 @@
+
+ KoTalk
+ 다시 불러오기
+
diff --git a/src/PhysOn.Mobile.Android/Resources/values/styles.xml b/src/PhysOn.Mobile.Android/Resources/values/styles.xml
new file mode 100644
index 0000000..043bc62
--- /dev/null
+++ b/src/PhysOn.Mobile.Android/Resources/values/styles.xml
@@ -0,0 +1,8 @@
+
+
+
+
diff --git a/src/PhysOn.Mobile.Android/Resources/xml/network_security_config.xml b/src/PhysOn.Mobile.Android/Resources/xml/network_security_config.xml
new file mode 100644
index 0000000..68be2bc
--- /dev/null
+++ b/src/PhysOn.Mobile.Android/Resources/xml/network_security_config.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/src/PhysOn.Web/package-lock.json b/src/PhysOn.Web/package-lock.json
index 524a1e5..7d01fd4 100644
--- a/src/PhysOn.Web/package-lock.json
+++ b/src/PhysOn.Web/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "physon-web",
- "version": "0.1.0-alpha.5",
+ "version": "0.1.0-alpha.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "physon-web",
- "version": "0.1.0-alpha.5",
+ "version": "0.1.0-alpha.6",
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
diff --git a/src/PhysOn.Web/package.json b/src/PhysOn.Web/package.json
index b66ad08..23735de 100644
--- a/src/PhysOn.Web/package.json
+++ b/src/PhysOn.Web/package.json
@@ -1,7 +1,7 @@
{
"name": "physon-web",
"private": true,
- "version": "0.1.0-alpha.5",
+ "version": "0.1.0-alpha.6",
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0",
diff --git a/src/PhysOn.Web/src/App.tsx b/src/PhysOn.Web/src/App.tsx
index 35de5b5..aa1f79b 100644
--- a/src/PhysOn.Web/src/App.tsx
+++ b/src/PhysOn.Web/src/App.tsx
@@ -53,7 +53,7 @@ type IconName =
| 'group'
const DEFAULT_API_BASE_URL = import.meta.env.VITE_API_BASE_URL?.trim() ?? ''
-const APP_VERSION = 'web-0.1.0-alpha.5'
+const APP_VERSION = 'web-0.1.0-alpha.6'
const CONNECTION_LABEL: Record = {
idle: '준비 중',
diff --git a/문서/33-platform-capability-matrix.md b/문서/33-platform-capability-matrix.md
index 32b550a..1720dbf 100644
--- a/문서/33-platform-capability-matrix.md
+++ b/문서/33-platform-capability-matrix.md
@@ -17,22 +17,22 @@ Windows, Mobile Web, Android를 병렬로 운영하면 언제든지 `문서상
| 기능 | Windows | Mobile Web | Android | 비고 |
|---|---|---|---|---|
-| 초간단 가입 | Buildable | Live | Planned | Alpha 기준 |
-| 자기 자신과의 대화 | Buildable | Live | Planned | 첫 진입 루프 |
-| 텍스트 메시지 전송 | Buildable | Live | Planned | 기본 루프 |
-| 읽음 커서 갱신 | Partial | Partial | Planned | 복귀 정확도 보강 필요 |
-| 실시간 수신 | Partial | Partial | Planned | 경계 조건 검증 부족 |
-| 로컬 검색 | Partial | Partial | Planned | 제목/기본 검색 수준 |
+| 초간단 가입 | Buildable | Live | Buildable | Android는 WebView 셸 기준 |
+| 자기 자신과의 대화 | Buildable | Live | Buildable | 첫 진입 루프 |
+| 텍스트 메시지 전송 | Buildable | Live | Buildable | 기본 루프 |
+| 읽음 커서 갱신 | Partial | Partial | Partial | 복귀 정확도 보강 필요 |
+| 실시간 수신 | Partial | Partial | Partial | 경계 조건 검증 부족 |
+| 로컬 검색 | Partial | Partial | Partial | 제목/기본 검색 수준 |
| 전역 검색 | Planned | Planned | Planned | 핵심 우선순위 |
-| 드래프트 보존 | Partial | Partial | Planned | 실제 복원 체감 부족 |
-| 세션 자동 갱신 | Planned | Planned | Planned | 현 시점 취약점 |
+| 드래프트 보존 | Partial | Partial | Partial | 실제 복원 체감 부족 |
+| 세션 자동 갱신 | Planned | Planned | Partial | WebView 셸 기준 |
| 파일 첨부 | Planned | Planned | Planned | 다음 대규모 과제 |
| 링크 프리뷰 | Planned | Planned | Planned | 제품성 핵심 |
| 알림 묶음 정책 | Planned | Planned | Planned | 정책 문서화 완료 |
| 팝아웃 창 | Planned | N/A | N/A | 데스크톱 핵심 |
| 다중 창 | Planned | N/A | N/A | 데스크톱 핵심 |
-| Android APK | N/A | N/A | Planned | 병렬 도입 예정 |
-| Releases 배포 | Partial | Partial | Planned | 표면 정리 필요 |
+| Android APK | N/A | N/A | Buildable | alpha 기준선 확보 |
+| Releases 배포 | Partial | Partial | Buildable | 표면 정리 진행 중 |
## 제품 메시지 규칙
@@ -58,7 +58,13 @@ Windows, Mobile Web, Android를 병렬로 운영하면 언제든지 `문서상
- 장기적으로 일상 주사용 모바일 채널
- 푸시/미디어/백그라운드 안정성으로 모바일 웹을 보완
-- 아직 설계 우선 단계다
+- 현재는 WebView 기반 APK 셸을 확보했고, 장기적으로는 공용 네이티브 UI 축으로 옮길지 판단 중이다
+
+## iOS / Linux
+
+- iOS는 저장소 Assets 직접 배포가 아니라 Apple 채널 기준으로 준비한다
+- Linux는 Windows와 같은 장기 네이티브 데스크톱 축으로 본다
+- 두 채널 모두 공용 UI 프레임워크 선택에 직접 연결되므로, Android 단독 최적화와 분리해 판단하면 안 된다
## 기능 우선순위 매핑