diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 82c541e..3e938f4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,6 +33,32 @@ jobs: - name: Run API integration tests run: dotnet test tests/PhysOn.Api.IntegrationTests/PhysOn.Api.IntegrationTests.csproj -c Release --no-restore + web: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: src/PhysOn.Web/package-lock.json + + - name: Install web dependencies + run: npm ci + working-directory: src/PhysOn.Web + + - name: Lint web + run: npm run lint + working-directory: src/PhysOn.Web + + - name: Build web + run: npm run build + working-directory: src/PhysOn.Web + desktop-windows: runs-on: windows-latest diff --git a/.github/workflows/release-portable.yml b/.github/workflows/release-portable.yml index a4c4bc6..d12184b 100644 --- a/.github/workflows/release-portable.yml +++ b/.github/workflows/release-portable.yml @@ -43,23 +43,23 @@ jobs: global-json-file: global.json - name: Restore desktop project - run: dotnet restore src/VsMessenger.Desktop/VsMessenger.Desktop.csproj + run: dotnet restore src/PhysOn.Desktop/PhysOn.Desktop.csproj - name: Publish portable desktop build - run: dotnet publish src/VsMessenger.Desktop/VsMessenger.Desktop.csproj -c Release -r win-x64 --self-contained true -o out/win-x64 + run: dotnet publish src/PhysOn.Desktop/PhysOn.Desktop.csproj -c Release -r win-x64 --self-contained true -o out/win-x64 - name: Create portable ZIP shell: pwsh - run: Compress-Archive -Path out/win-x64/* -DestinationPath out/VsMessenger-win-x64.zip + run: Compress-Archive -Path out/win-x64/* -DestinationPath out/KoTalk-windows-x64.zip - name: Upload Windows artifact uses: actions/upload-artifact@v4 with: name: windows-portable - path: out/VsMessenger-win-x64.zip + path: out/KoTalk-windows-x64.zip build-android: - if: ${{ hashFiles('src/VsMessenger.Mobile.Android/*.csproj') != '' }} + if: ${{ hashFiles('src/PhysOn.Mobile.Android/*.csproj') != '' }} runs-on: ubuntu-latest steps: @@ -81,11 +81,11 @@ jobs: run: dotnet workload install android - name: Restore Android project - run: dotnet restore src/VsMessenger.Mobile.Android/VsMessenger.Mobile.Android.csproj + run: dotnet restore src/PhysOn.Mobile.Android/PhysOn.Mobile.Android.csproj - name: Publish Android APK run: | - dotnet publish src/VsMessenger.Mobile.Android/VsMessenger.Mobile.Android.csproj \ + dotnet publish src/PhysOn.Mobile.Android/PhysOn.Mobile.Android.csproj \ -c Release \ -f net8.0-android \ -p:AndroidPackageFormat=apk \ @@ -96,13 +96,13 @@ jobs: run: | apk_path="$(find out/android -type f -name '*.apk' | head -n 1)" test -n "$apk_path" - cp "$apk_path" out/VsMessenger-android-universal.apk + cp "$apk_path" out/KoTalk-android-universal.apk - name: Upload Android artifact uses: actions/upload-artifact@v4 with: name: android-apk - path: out/VsMessenger-android-universal.apk + path: out/KoTalk-android-universal.apk assemble-release: if: ${{ always() && needs.build-windows.result == 'success' && (needs.build-android.result == 'success' || needs.build-android.result == 'skipped') }} @@ -148,12 +148,12 @@ jobs: prepare_args=( --version "$VERSION_INPUT" --channel "$channel" - --windows-zip incoming/windows/VsMessenger-win-x64.zip + --windows-zip incoming/windows/KoTalk-windows-x64.zip --force ) - if [[ -f incoming/android/VsMessenger-android-universal.apk ]]; then - prepare_args+=(--android-apk incoming/android/VsMessenger-android-universal.apk) + if [[ -f incoming/android/KoTalk-android-universal.apk ]]; then + prepare_args+=(--android-apk incoming/android/KoTalk-android-universal.apk) fi ./scripts/release/release-prepare-assets.sh \ diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d20205..951711f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,13 @@ ### Added +- 공개 원격용 버전 태그 생성 스크립트 `scripts/release/release-create-tag.sh` +- GitHub 릴리즈 게시 스크립트 `scripts/release/release-publish-github.sh` +- 제2·제3 공개 원격 순차 게시 스크립트 `scripts/release/release-publish-public.sh` + +- `artifacts/builds/2026.04.16-alpha.4/` Windows 포터블 산출물과 체크섬 추가 +- `download-vstalk.phy.kr` HTTPS 미러를 실제 latest ZIP/manifest 경로로 활성화 + - `KoTalk` 공개 브랜드 기준과 다운로드/릴리즈 표면 정리 - 공개 가입 전략을 `1회성 인증 중심`으로 재정의한 기획 문서 보강 - Apache-2.0 기준의 라이선스/상표/기여 정책 정리 @@ -46,6 +53,15 @@ ### Changed +- 공개 원격 배포 정책을 `public/* 브랜치 + 버전 태그 + 릴리즈 페이지 + 자산` 기준으로 고정 +- Gitea/GitHub 릴리즈 게시 스크립트가 지정 원격 기준으로 동작하고 최신 스크린샷 자산도 함께 첨부하도록 확장 +- 비-`origin` 원격에 대한 로컬 pre-push 가드가 `public/*` 브랜치와 `refs/tags/*`를 함께 허용하도록 조정 + +- 데스크톱·웹 UI의 설명형 카피를 더 줄이고 말풍선/칩/버튼 라운드를 2px 기준으로 축소 +- 웹 앱 버전을 `0.1.0-alpha.4`로 올리고 최신 캡처 자산을 재생성 +- 다운로드 스크립트, 릴리즈 워크플로, Caddy 예시 설정을 `download-vstalk.phy.kr`와 `KoTalk-*` 자산명 기준으로 정렬 +- 다운로드 미러 상태를 DNS/HTTPS 정상 동작 기준으로 문서에 반영 + - 공개 브랜드를 `KoTalk`로 정리하고 공개 문서의 직접적·내부지향 표현을 제거 - README를 대중용 첫인상 기준으로 다시 구성하고 다운로드는 공식 미러와 저장소 릴리즈를 함께 표기 - 보안/신뢰 문서에서 운영 힌트와 공유 접근값 노출을 줄이고 공개 범위를 재정의 diff --git a/PROJECT_STATUS.md b/PROJECT_STATUS.md index 4894517..320cb42 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 | Android 실빌드와 데스크톱 멀티윈도우 완성도 | | Signup direction | 공개형 1회성 인증 중심으로 재설계 중 | | Tone of this repo | 현재 동작 범위와 남은 갭을 함께 적는 제품형 저장소 | @@ -30,7 +30,7 @@ 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) | Normalizing | 2026-04-16 기준 DNS/HTTPS 정합성 확인 필요 | +| Official mirror | [download-vstalk.phy.kr](https://download-vstalk.phy.kr) | Live | Windows latest와 version manifest를 HTTPS로 제공 | ## Verified Now @@ -67,7 +67,6 @@ KoTalk는 아직 모든 플랫폼이 완성된 상태는 아니지만, “문서 ## In Progress - Android 첫 실사용 빌드 -- 공개 다운로드 미러 정합성 - 릴리즈 페이지와 미러 간 latest 라우트 통합 - 검색 범위 확장 - 파일 전송 @@ -80,7 +79,7 @@ KoTalk는 아직 모든 플랫폼이 완성된 상태는 아니지만, “문서 - Android 실사용 빌드는 아직 제공되지 않습니다. - 파일 전송은 미구현입니다. - 검색은 전역 파일/링크/사람 범위까지 확장되지 않았습니다. -- 공식 다운로드 미러는 DNS/HTTPS 정상화가 끝나야 안정 채널로 표기할 수 있습니다. +- 공식 다운로드 미러는 현재 Windows latest와 version manifest 기준으로 동작합니다. - 데스크톱 멀티 윈도우는 방향은 잡혀 있지만, 실제 생산성 흐름은 더 다듬어야 합니다. ## Download And Release Paths diff --git a/README.md b/README.md index 962abde..78cad12 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ docs license web - download + download verified

@@ -108,7 +108,7 @@ 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) | Normalizing | 2026-04-16 기준 DNS/HTTPS 정합성 점검 진행 중 | +| Official download mirror | [download-vstalk.phy.kr](https://download-vstalk.phy.kr) | Live | Windows latest와 version manifest를 HTTPS로 제공 | ## Architecture Snapshot diff --git a/RELEASING.md b/RELEASING.md index 99e457e..b7a84ce 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -14,8 +14,8 @@ KoTalk의 릴리즈는 단순한 파일 업로드가 아니라, 산출물과 공 ## Current Note -2026-04-16 기준 [download-vstalk.phy.kr](https://download-vstalk.phy.kr)의 DNS/HTTPS 정합성은 재점검이 필요합니다. -그래서 현재는 저장소 릴리즈 경로를 함께 유지하는 것을 원칙으로 둡니다. +2026-04-16 기준 [download-vstalk.phy.kr](https://download-vstalk.phy.kr)는 DNS와 HTTPS가 정상입니다. +현재는 Windows latest와 version manifest를 제공하고, 저장소 릴리즈 경로를 함께 유지합니다. ## Minimum Release Contract @@ -24,6 +24,8 @@ KoTalk의 릴리즈는 단순한 파일 업로드가 아니라, 산출물과 공 3. [CHANGELOG.md](CHANGELOG.md)에 의미 있는 변경이 기록돼야 합니다. 4. 최신 스크린샷이 현재 UI를 대표해야 합니다. 5. 다운로드 경로와 릴리즈 링크가 함께 갱신돼야 합니다. +6. 공개 원격은 `브랜치 + 태그 + 릴리즈 페이지 + 자산`을 한 세트로 맞춥니다. +7. 공개 릴리즈 페이지에는 산출물과 최신 스크린샷을 함께 게시합니다. ## Platform Policy @@ -31,6 +33,23 @@ KoTalk의 릴리즈는 단순한 파일 업로드가 아니라, 산출물과 공 - Mobile web: 라이브 반영이 있으면 스크린샷과 상태 문서를 함께 갱신합니다. - Android: APK 공개 시 공식 미러와 저장소 릴리즈를 함께 맞춥니다. +## Public Release Sequence + +1. 내부 기준선에서 산출물과 스크린샷을 먼저 고정합니다. +2. `public/*` 브랜치에 공개 가능한 이력을 정리합니다. +3. 같은 기준선에 버전 태그를 생성합니다. +4. 제2 공개 레포에 브랜치와 태그를 푸시합니다. +5. 제2 공개 레포 릴리즈 페이지에 자산과 노트를 게시합니다. +6. 명시적 요청이 있을 때만 같은 태그와 자산을 제3 공개 레포에 게시합니다. +7. `download-vstalk.phy.kr`는 최신 포인터만 유지합니다. + +## Release Scripts + +- 공개 기준 태그 생성: [`scripts/create-release-tag.sh`](scripts/create-release-tag.sh) +- 제2 공개 레포 릴리즈 게시: [`scripts/publish-gitea-release.sh`](scripts/publish-gitea-release.sh) +- 제3 GitHub 릴리즈 게시: [`scripts/publish-github-release.sh`](scripts/publish-github-release.sh) +- 공개 브랜치/태그/릴리즈 순차 게시: [`scripts/publish-public-release.sh`](scripts/publish-public-release.sh) + ## Related Docs - 배포 골격: [deploy/README.md](deploy/README.md) diff --git a/deploy/Caddyfile b/deploy/Caddyfile index 55d4b92..5009816 100644 --- a/deploy/Caddyfile +++ b/deploy/Caddyfile @@ -7,14 +7,18 @@ root * /srv/download redir /windows /windows/latest 302 redir /android /android/latest 302 - redir /windows/latest /windows/latest/VsMessenger-win-x64.zip 302 - redir /android/latest /android/latest/VsMessenger-android-universal.apk 302 + redir /windows/latest /latest/KoTalk-windows-x64.zip 302 + redir /android/latest /latest/KoTalk-android-universal.apk 302 redir /latest /latest/version.json 302 header { + Content-Security-Policy "default-src 'none'; frame-ancestors 'none'; base-uri 'none'" + Cross-Origin-Resource-Policy "same-origin" + Permissions-Policy "accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()" Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" X-Content-Type-Options "nosniff" X-Frame-Options "DENY" Referrer-Policy "strict-origin-when-cross-origin" + X-Robots-Tag "noindex, nofollow" } file_server } @@ -22,6 +26,10 @@ {$WEBAPP_HOST:vstalk.phy.kr} { encode zstd gzip header { + Content-Security-Policy "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self' https: wss:; object-src 'none'; base-uri 'self'; frame-ancestors 'none'" + Cross-Origin-Opener-Policy "same-origin" + Cross-Origin-Resource-Policy "same-origin" + Permissions-Policy "accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()" Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" X-Content-Type-Options "nosniff" X-Frame-Options "DENY" @@ -41,6 +49,9 @@ {$API_HOST} { encode zstd gzip header { + Content-Security-Policy "default-src 'none'; frame-ancestors 'none'; base-uri 'none'" + Cross-Origin-Resource-Policy "same-origin" + Permissions-Policy "accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()" Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" X-Content-Type-Options "nosniff" Referrer-Policy "strict-origin-when-cross-origin" diff --git a/docs/assets/latest/conversation.png b/docs/assets/latest/conversation.png index d163f5e..8cf9d3c 100644 Binary files a/docs/assets/latest/conversation.png and b/docs/assets/latest/conversation.png differ diff --git a/docs/assets/latest/hero-shell.png b/docs/assets/latest/hero-shell.png index b77cdea..f0fccd9 100644 Binary files a/docs/assets/latest/hero-shell.png and b/docs/assets/latest/hero-shell.png differ diff --git a/docs/assets/latest/onboarding.png b/docs/assets/latest/onboarding.png index 8d8323a..d033cd1 100644 Binary files a/docs/assets/latest/onboarding.png and b/docs/assets/latest/onboarding.png differ diff --git a/docs/assets/latest/vstalk-web-chat.png b/docs/assets/latest/vstalk-web-chat.png index 22da3cd..eb05981 100644 Binary files a/docs/assets/latest/vstalk-web-chat.png and b/docs/assets/latest/vstalk-web-chat.png differ diff --git a/docs/assets/latest/vstalk-web-list.png b/docs/assets/latest/vstalk-web-list.png index c7e4d37..ed91abc 100644 Binary files a/docs/assets/latest/vstalk-web-list.png and b/docs/assets/latest/vstalk-web-list.png differ diff --git a/docs/assets/latest/vstalk-web-onboarding.png b/docs/assets/latest/vstalk-web-onboarding.png index 09f306c..a6c59f3 100644 Binary files a/docs/assets/latest/vstalk-web-onboarding.png and b/docs/assets/latest/vstalk-web-onboarding.png differ diff --git a/docs/assets/latest/vstalk-web-saved.png b/docs/assets/latest/vstalk-web-saved.png index 1ddba0a..2bbb236 100644 Binary files a/docs/assets/latest/vstalk-web-saved.png and b/docs/assets/latest/vstalk-web-saved.png differ diff --git a/docs/assets/latest/vstalk-web-search.png b/docs/assets/latest/vstalk-web-search.png index 2569e92..11fb563 100644 Binary files a/docs/assets/latest/vstalk-web-search.png and b/docs/assets/latest/vstalk-web-search.png differ diff --git a/release-assets/README.md b/release-assets/README.md index 230aad3..4e17265 100644 --- a/release-assets/README.md +++ b/release-assets/README.md @@ -6,7 +6,7 @@ - 같은 버전 번호 아래에 Windows와 Android 산출물을 병렬로 보관합니다. - `latest/`는 최신 포인터, `releases//`는 불변 이력으로 구분합니다. -- 원격 Forge Releases는 버전별 원본 저장소, `download-vs-messanger.phy.kr`는 최종 사용자용 다운로드 미러로 사용합니다. +- 원격 Forge Releases는 버전별 원본 저장소, `download-vstalk.phy.kr`는 최종 사용자용 다운로드 미러로 사용합니다. ## 목표 구조 @@ -19,11 +19,11 @@ release-assets/ SHA256SUMS.txt screenshots/ windows/ - VsMessenger-win-x64.zip + KoTalk-windows-x64.zip SHA256SUMS.txt version.json android/ - VsMessenger-android-universal.apk + KoTalk-android-universal.apk SHA256SUMS.txt version.json releases/ @@ -34,11 +34,11 @@ release-assets/ screenshots/ windows/ x64/ - VsMessenger-win-x64-v0.2.0-alpha.1.zip + KoTalk-windows-x64-v0.2.0-alpha.1.zip SHA256SUMS.txt android/ universal/ - VsMessenger-android-universal-v0.2.0-alpha.1.apk + KoTalk-android-universal-v0.2.0-alpha.1.apk SHA256SUMS.txt ``` @@ -52,11 +52,11 @@ release-assets/ ## 다운로드 경로 규칙 -- 최신 Windows: `https://download-vs-messanger.phy.kr/windows/latest` -- 최신 Android: `https://download-vs-messanger.phy.kr/android/latest` -- 전체 최신 메타데이터: `https://download-vs-messanger.phy.kr/latest/version.json` -- 버전별 Windows: `https://download-vs-messanger.phy.kr/releases//windows/x64/...` -- 버전별 Android: `https://download-vs-messanger.phy.kr/releases//android/universal/...` +- 최신 Windows: `https://download-vstalk.phy.kr/windows/latest` +- 최신 Android: `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/...` ## 생성 스크립트 @@ -68,8 +68,8 @@ release-assets/ ./scripts/release/release-prepare-assets.sh \ --version v0.2.0-alpha.1 \ --channel alpha \ - --windows-zip artifacts/release/VsMessenger-win-x64-v0.2.0-alpha.1.zip \ - --android-apk artifacts/release/VsMessenger-android-universal-v0.2.0-alpha.1.apk \ + --windows-zip artifacts/release/KoTalk-windows-x64-v0.2.0-alpha.1.zip \ + --android-apk artifacts/release/KoTalk-android-universal-v0.2.0-alpha.1.apk \ --screenshots artifacts/screenshots \ --force ``` @@ -78,16 +78,20 @@ release-assets/ - VPS 다운로드 미러 업로드: `scripts/release/release-upload-assets.sh` - Forge Releases 게시: `scripts/release/release-publish-forge.sh` +- GitHub Releases 게시: `scripts/release/release-publish-github.sh` +- 공개 원격 전체 게시: `scripts/release/release-publish-public.sh` +- 공개 태그 생성: `scripts/release/release-create-tag.sh` 두 채널은 목적이 다릅니다. - Forge Releases: 버전별 원본 보관 - 다운로드 미러: 최신 포인터와 빠른 정적 다운로드 - 모바일 웹앱: `release-assets/`가 아니라 `vstalk.phy.kr` 배포 트랙에서 별도 운영 +- 공개 원격 릴리즈 페이지에는 ZIP/APK뿐 아니라 `screenshots/` 아래 최신 캡처도 함께 게시합니다. ## 운영 메모 -- 생성된 버전별 산출물은 기본적으로 Git 추적 대상이 아닙니다. +- 생성된 버전별 산출물은 워크트리에 유지하며, 최신 로컬 검수와 서버 업로드 기준으로 사용합니다. - 공개 릴리즈마다 `RELEASE_NOTES.ko.md`, `SHA256SUMS.txt`, `version.json`을 함께 갱신합니다. - 같은 버전에서 Windows만 있고 Android가 아직 없을 수는 있지만, 장기 원칙은 `같은 버전 아래 두 플랫폼 병렬 게시`입니다. - 모바일 웹앱 정적 산출물은 `release-assets/`가 아니라 `/srv/vs-messanger/webapp/releases/`에 배포합니다. diff --git a/scripts/capture_kotalk_desktop_screenshots.sh b/scripts/capture_kotalk_desktop_screenshots.sh new file mode 100755 index 0000000..929bb5f --- /dev/null +++ b/scripts/capture_kotalk_desktop_screenshots.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +"$SCRIPT_DIR/ci/capture-kotalk-desktop-screenshots.sh" "$@" diff --git a/scripts/ci/capture-kotalk-desktop-screenshots.sh b/scripts/ci/capture-kotalk-desktop-screenshots.sh new file mode 100755 index 0000000..6adb920 --- /dev/null +++ b/scripts/ci/capture-kotalk-desktop-screenshots.sh @@ -0,0 +1,206 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +OUTPUT_DIR="${1:-$ROOT_DIR/docs/assets/latest}" +CAPTURE_MODE="${2:-all}" +PROJECT_PATH="$ROOT_DIR/src/PhysOn.Desktop/PhysOn.Desktop.csproj" +DOTNET_BIN="${DOTNET_BIN:-$HOME/.dotnet/dotnet}" + +if [[ ! -x "$DOTNET_BIN" ]]; then + echo "dotnet not found at $DOTNET_BIN" >&2 + exit 1 +fi + +mkdir -p "$OUTPUT_DIR" +TMP_DIR="$(mktemp -d)" +trap 'rm -rf "$TMP_DIR"' EXIT + +capture_mode() { + local mode="$1" + local output_path="$2" + local main_title="$3" + local detached_title="${4:-}" + local data_home="$TMP_DIR/$mode-data" + local config_home="$TMP_DIR/$mode-config" + local cache_home="$TMP_DIR/$mode-cache" + local runtime_home="$TMP_DIR/$mode-runtime" + local tree_path="$TMP_DIR/$mode-tree.txt" + local log_path="$TMP_DIR/$mode.log" + mkdir -p "$data_home" "$config_home" "$cache_home" "$runtime_home" + + env \ + XDG_DATA_HOME="$data_home" \ + XDG_CONFIG_HOME="$config_home" \ + XDG_CACHE_HOME="$cache_home" \ + XDG_RUNTIME_DIR="$runtime_home" \ + DOTNET_BIN="$DOTNET_BIN" \ + PROJECT_PATH="$PROJECT_PATH" \ + TREE_PATH="$tree_path" \ + LOG_PATH="$log_path" \ + OUTPUT_PATH="$output_path" \ + MAIN_TITLE="$main_title" \ + DETACHED_TITLE="$detached_title" \ + MODE="$mode" \ + xvfb-run -a bash -lc ' + set -euo pipefail + + refresh_tree() { + xwininfo -root -tree >"$TREE_PATH" 2>/dev/null || true + } + + find_window_id() { + local title="$1" + python3 - "$TREE_PATH" "$title" <<'"'"'PY'"'"' +import re +import sys + +tree_path, title = sys.argv[1], sys.argv[2] +pattern = re.compile(r"^\s*(0x[0-9a-fA-F]+)\s+\"([^\"]+)\"") + +with open(tree_path, "r", encoding="utf-8", errors="ignore") as handle: + for line in handle: + match = pattern.match(line) + if not match: + continue + window_id, window_title = match.groups() + if window_title == title: + print(window_id) + raise SystemExit(0) + +raise SystemExit(1) +PY + } + + wait_for_window() { + local title="$1" + local attempts="${2:-40}" + local pause="${3:-0.5}" + local window_id="" + + for _ in $(seq 1 "$attempts"); do + refresh_tree + if window_id="$(find_window_id "$title" 2>/dev/null)"; then + echo "$window_id" + return 0 + fi + sleep "$pause" + done + + return 1 + } + + window_geometry() { + local window_id="$1" + python3 - "$window_id" <<'"'"'PY'"'"' +import re +import subprocess +import sys + +window_id = sys.argv[1] +output = subprocess.check_output(["xwininfo", "-id", window_id], text=True, errors="ignore") +patterns = { + "x": r"Absolute upper-left X:\s+(-?\d+)", + "y": r"Absolute upper-left Y:\s+(-?\d+)", + "w": r"Width:\s+(\d+)", + "h": r"Height:\s+(\d+)", +} + +values = {} +for key, pattern in patterns.items(): + match = re.search(pattern, output) + if not match: + raise SystemExit(1) + values[key] = int(match.group(1)) + +if values["w"] < 240 or values["h"] < 240: + raise SystemExit(2) + +print(values["x"], values["y"], values["w"], values["h"]) +PY + } + + wait_for_geometry() { + local window_id="$1" + local attempts="${2:-30}" + local pause="${3:-0.4}" + local geometry="" + local previous="" + + for _ in $(seq 1 "$attempts"); do + if geometry="$(window_geometry "$window_id" 2>/dev/null)"; then + if [[ "$geometry" == "$previous" ]]; then + echo "$geometry" + return 0 + fi + previous="$geometry" + fi + sleep "$pause" + done + + if [[ -n "$previous" ]]; then + echo "$previous" + return 0 + fi + + return 1 + } + + capture_window() { + local window_id="$1" + local target_path="$2" + local root_capture="${target_path%.*}-root.${target_path##*.}" + local geometry + read -r crop_x crop_y crop_w crop_h < <(wait_for_geometry "$window_id") + import -window root "$root_capture" + convert "$root_capture" -crop "${crop_w}x${crop_h}+${crop_x}+${crop_y}" +repage "$target_path" + rm -f "$root_capture" + } + + create_conversation_fallback() { + local source_path="$1" + local target_path="$2" + convert "$source_path" -gravity east -crop 58%x84%+0+0 +repage "$target_path" + } + + if [[ "$MODE" == "sample" ]]; then + export KOTALK_DESKTOP_SAMPLE_MODE=1 + export KOTALK_DESKTOP_OPEN_SAMPLE_WINDOW=1 + fi + export XDG_DATA_HOME="$XDG_DATA_HOME" + export XDG_CONFIG_HOME="$XDG_CONFIG_HOME" + export XDG_CACHE_HOME="$XDG_CACHE_HOME" + export XDG_RUNTIME_DIR="$XDG_RUNTIME_DIR" + + "$DOTNET_BIN" run --project "$PROJECT_PATH" -c Debug >"$LOG_PATH" 2>&1 & + app_pid=$! + + cleanup() { + kill "$app_pid" >/dev/null 2>&1 || true + wait "$app_pid" >/dev/null 2>&1 || true + } + trap cleanup EXIT + + main_id="$(wait_for_window "$MAIN_TITLE" 60 0.5)" + capture_window "$main_id" "$OUTPUT_PATH" + + if [[ -n "$DETACHED_TITLE" ]]; then + conversation_output="${OUTPUT_PATH%/*}/conversation.${OUTPUT_PATH##*.}" + if detached_id="$(wait_for_window "$DETACHED_TITLE" 12 0.5 2>/dev/null)"; then + capture_window "$detached_id" "$conversation_output" + else + create_conversation_fallback "$OUTPUT_PATH" "$conversation_output" + fi + fi + ' +} + +if [[ "$CAPTURE_MODE" == "all" || "$CAPTURE_MODE" == "onboarding" ]]; then + capture_mode "onboarding" "$OUTPUT_DIR/onboarding.png" "KoTalk" +fi + +if [[ "$CAPTURE_MODE" == "all" || "$CAPTURE_MODE" == "sample" ]]; then + capture_mode "sample" "$OUTPUT_DIR/hero-shell.png" "KoTalk" "제품 운영" +fi + +echo "Desktop screenshots written to $OUTPUT_DIR" diff --git a/scripts/create-release-tag.sh b/scripts/create-release-tag.sh new file mode 100755 index 0000000..b473848 --- /dev/null +++ b/scripts/create-release-tag.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +exec "$script_dir/release/release-create-tag.sh" "$@" diff --git a/scripts/publish-github-release.sh b/scripts/publish-github-release.sh new file mode 100755 index 0000000..5c42cf2 --- /dev/null +++ b/scripts/publish-github-release.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +exec "$script_dir/release/release-publish-github.sh" "$@" diff --git a/scripts/publish-public-release.sh b/scripts/publish-public-release.sh new file mode 100755 index 0000000..e946a56 --- /dev/null +++ b/scripts/publish-public-release.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +exec "$script_dir/release/release-publish-public.sh" "$@" diff --git a/scripts/release/release-create-tag.sh b/scripts/release/release-create-tag.sh new file mode 100755 index 0000000..3115f9b --- /dev/null +++ b/scripts/release/release-create-tag.sh @@ -0,0 +1,102 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: + ./scripts/release/release-create-tag.sh --version 2026.04.16-alpha.4 [options] + +Options: + --ref Target ref or commit. Default: public/main + --message Annotated tag message + --remote Push tag to a remote after creation + --force Replace an existing local tag + --dry-run Print what would happen +EOF +} + +version="" +target_ref="public/main" +message="" +remote_name="" +force="false" +dry_run="false" + +while [[ $# -gt 0 ]]; do + case "$1" in + --version) + version="${2:-}" + shift 2 + ;; + --ref) + target_ref="${2:-}" + shift 2 + ;; + --message) + message="${2:-}" + shift 2 + ;; + --remote) + remote_name="${2:-}" + shift 2 + ;; + --force) + force="true" + shift + ;; + --dry-run) + dry_run="true" + shift + ;; + -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)" +cd "$repo_root" + +target_commit="$(git rev-parse "$target_ref")" +tag_message="${message:-Release $version}" + +echo "Tag: $version" +echo "Target ref: $target_ref" +echo "Target commit: $target_commit" +[[ -n "$remote_name" ]] && echo "Remote: $remote_name" + +if [[ "$dry_run" == "true" ]]; then + exit 0 +fi + +if git rev-parse -q --verify "refs/tags/$version" >/dev/null; then + if [[ "$force" != "true" ]]; then + echo "Tag already exists: $version" >&2 + echo "Use --force to replace it." >&2 + exit 1 + fi + git tag -d "$version" >/dev/null +fi + +git tag -a "$version" "$target_commit" -m "$tag_message" + +if [[ -n "$remote_name" ]]; then + push_args=() + if [[ "$force" == "true" ]]; then + push_args+=(--force) + fi + git push "${push_args[@]}" "$remote_name" "refs/tags/$version:refs/tags/$version" +fi + +echo "Created annotated tag $version at $target_commit" diff --git a/scripts/release/release-prepare-assets.sh b/scripts/release/release-prepare-assets.sh index 4a96b92..90efd56 100755 --- a/scripts/release/release-prepare-assets.sh +++ b/scripts/release/release-prepare-assets.sh @@ -16,7 +16,7 @@ Options: --force Overwrite an existing release folder Environment: - DOWNLOAD_BASE_URL Defaults to https://download-vs-messanger.phy.kr + DOWNLOAD_BASE_URL Defaults to https://download-vstalk.phy.kr EOF } @@ -95,7 +95,7 @@ repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" release_root="$repo_root/release-assets/releases/$version" latest_root="$repo_root/release-assets/latest" template_path="$repo_root/release-assets/templates/RELEASE_NOTES.ko.md" -download_base_url="${DOWNLOAD_BASE_URL:-https://download-vs-messanger.phy.kr}" +download_base_url="${DOWNLOAD_BASE_URL:-https://download-vstalk.phy.kr}" published_at="$(date -u +"%Y-%m-%dT%H:%M:%SZ")" derive_release_url() { @@ -179,8 +179,8 @@ EOF } if [[ -n "$windows_zip" ]]; then - windows_release_name="VsMessenger-win-x64-$version.zip" - windows_latest_name="VsMessenger-win-x64.zip" + windows_release_name="KoTalk-windows-x64-$version.zip" + windows_latest_name="KoTalk-windows-x64.zip" windows_release_dir="$release_root/windows/x64" windows_latest_dir="$latest_root/windows" mkdir -p "$windows_release_dir" "$windows_latest_dir" @@ -229,8 +229,8 @@ EOF fi if [[ -n "$android_apk" ]]; then - android_release_name="VsMessenger-android-universal-$version.apk" - android_latest_name="VsMessenger-android-universal.apk" + android_release_name="KoTalk-android-universal-$version.apk" + android_latest_name="KoTalk-android-universal.apk" android_release_dir="$release_root/android/universal" android_latest_dir="$latest_root/android" mkdir -p "$android_release_dir" "$android_latest_dir" diff --git a/scripts/release/release-publish-forge.sh b/scripts/release/release-publish-forge.sh index 147f230..4dc928d 100755 --- a/scripts/release/release-publish-forge.sh +++ b/scripts/release/release-publish-forge.sh @@ -7,17 +7,23 @@ Usage: ./scripts/release/release-publish-forge.sh --version v0.2.0-alpha.1 [options] Options: + --remote Git remote name. Default: public-stage --base-url Forge base URL. Example: https://forge.example.com --repo Repository in owner/name form --token Gitea API token + --target-commitish Branch or commit to associate when creating the tag + --notes Release notes markdown file --dry-run Print planned uploads without calling the API EOF } version="" +remote_name="public-stage" base_url="" repo_full_name="" token="${FORGE_RELEASE_TOKEN:-${GITEA_RELEASE_TOKEN:-}}" +target_commitish="" +notes_path="" dry_run="false" while [[ $# -gt 0 ]]; do @@ -30,6 +36,10 @@ while [[ $# -gt 0 ]]; do base_url="${2:-}" shift 2 ;; + --remote) + remote_name="${2:-}" + shift 2 + ;; --repo) repo_full_name="${2:-}" shift 2 @@ -38,6 +48,14 @@ while [[ $# -gt 0 ]]; do token="${2:-}" shift 2 ;; + --target-commitish) + target_commitish="${2:-}" + shift 2 + ;; + --notes) + notes_path="${2:-}" + shift 2 + ;; --dry-run) dry_run="true" shift @@ -67,20 +85,24 @@ if [[ ! -d "$release_root" ]]; then exit 1 fi -origin_url="$(git -C "$repo_root" remote get-url origin 2>/dev/null || true)" +remote_url="$(git -C "$repo_root" remote get-url "$remote_name" 2>/dev/null || true)" if [[ -z "$base_url" ]]; then - if [[ "$origin_url" =~ ^(https?://[^/]+)/(.+)\.git$ ]]; then + if [[ "$remote_url" =~ ^(https?://[^/]+)(/open-source/projects)/([^/]+)/([^/]+?)(\.git)?$ ]]; then + base_url="${BASH_REMATCH[1]}${BASH_REMATCH[2]}" + elif [[ "$remote_url" =~ ^(https?://[^/]+)/(.+?)(\.git)?$ ]]; then base_url="${BASH_REMATCH[1]}" - elif [[ "$origin_url" =~ ^git@([^:]+):(.+)\.git$ ]]; then + elif [[ "$remote_url" =~ ^git@([^:]+):(.+)\.git$ ]]; then base_url="https://${BASH_REMATCH[1]}" fi fi if [[ -z "$repo_full_name" ]]; then - if [[ "$origin_url" =~ ^https?://[^/]+/(.+)\.git$ ]]; then + if [[ "$remote_url" =~ ^https?://[^/]+/open-source/projects/([^/]+)/([^/]+?)(\.git)?$ ]]; then + repo_full_name="${BASH_REMATCH[1]}/${BASH_REMATCH[2]}" + elif [[ "$remote_url" =~ ^https?://[^/]+/(.+?)(\.git)?$ ]]; then repo_full_name="${BASH_REMATCH[1]}" - elif [[ "$origin_url" =~ ^git@[^:]+:(.+)\.git$ ]]; then + elif [[ "$remote_url" =~ ^git@[^:]+:(.+)\.git$ ]]; then repo_full_name="${BASH_REMATCH[1]}" fi fi @@ -90,8 +112,47 @@ if [[ -z "$base_url" || -z "$repo_full_name" ]]; then exit 1 fi -if [[ "$dry_run" != "true" && -z "$token" ]]; then - echo "A Gitea API token is required. Use --token or FORGE_RELEASE_TOKEN." >&2 +if [[ -z "$notes_path" ]]; then + notes_path="$release_root/RELEASE_NOTES.ko.md" +fi + +if [[ -n "$notes_path" && ! -f "$notes_path" ]]; then + echo "Release notes file not found: $notes_path" >&2 + exit 1 +fi + +if [[ -z "$target_commitish" ]]; then + target_commitish="main" +fi + +basic_auth_user="" +basic_auth_password="" + +if [[ -z "$token" ]]; then + for fallback_path in \ + "$repo_root/.workspace-secrets/${remote_name}.token" \ + "$repo_root/.workspace-secrets/forge-release.token"; do + if [[ -f "$fallback_path" ]]; then + token="$(tr -d '\r\n' < "$fallback_path")" + break + fi + done +fi + +if [[ -z "$token" && -n "$remote_url" ]]; then + credential_output="$(printf 'url=%s\n\n' "$remote_url" | git credential fill 2>/dev/null || true)" + if [[ -n "$credential_output" ]]; then + while IFS='=' read -r key value; do + case "$key" in + username) basic_auth_user="$value" ;; + password) basic_auth_password="$value" ;; + esac + done <<< "$credential_output" + fi +fi + +if [[ "$dry_run" != "true" && -z "$token" && ( -z "$basic_auth_user" || -z "$basic_auth_password" ) ]]; then + echo "A Gitea API token or basic credential is required. Use --token, FORGE_RELEASE_TOKEN, a local secret file, or configured git credentials." >&2 exit 1 fi @@ -108,7 +169,7 @@ esac mapfile -t asset_files < <( find "$release_root" -type f \ - \( -name '*.zip' -o -name '*.apk' -o -name 'RELEASE_NOTES.ko.md' -o -name 'SHA256SUMS.txt' -o -name 'version.json' \) \ + \( -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 ) @@ -118,7 +179,9 @@ if [[ ${#asset_files[@]} -eq 0 ]]; then fi echo "Forge release target: $base_url/$repo_full_name" +echo "Remote: $remote_name" echo "Version: $version" +echo "Target commitish: $target_commitish" printf 'Assets:\n' printf ' - %s\n' "${asset_files[@]#$release_root/}" @@ -126,11 +189,16 @@ if [[ "$dry_run" == "true" ]]; then exit 0 fi -auth_header="Authorization: token $token" +auth_args=() +if [[ -n "$token" ]]; then + auth_args=(-H "Authorization: token $token") +else + auth_args=(-u "$basic_auth_user:$basic_auth_password") +fi tmp_response="$(mktemp)" trap 'rm -f "$tmp_response"' EXIT -existing_status="$(curl -sS -o "$tmp_response" -w '%{http_code}' -H "$auth_header" "$tag_api")" +existing_status="$(curl -sS -o "$tmp_response" -w '%{http_code}' "${auth_args[@]}" "$tag_api")" if [[ "$existing_status" == "200" ]]; then existing_release_id="$(python3 - <<'PY' "$tmp_response" import json, sys @@ -138,17 +206,17 @@ with open(sys.argv[1], "r", encoding="utf-8") as fh: print(json.load(fh)["id"]) PY )" - curl -sS -X DELETE -H "$auth_header" "${release_api}/${existing_release_id}" >/dev/null + curl -sS -X DELETE "${auth_args[@]}" "${release_api}/${existing_release_id}" >/dev/null fi -release_body=$'Windows와 Android 클라이언트 산출물을 병렬로 정리한 릴리즈입니다.\n\n' -release_body+=$'동일 버전 번호 아래 OS별 자산을 함께 게시하며, 최신 다운로드 채널은 download-vs-messanger.phy.kr에서 운영합니다.' +release_body="$(cat "$notes_path")" -create_payload="$(python3 - <<'PY' "$version" "$release_body" "$pre_release" +create_payload="$(python3 - <<'PY' "$version" "$release_body" "$pre_release" "$target_commitish" import json, sys -version, body, prerelease = sys.argv[1], sys.argv[2], sys.argv[3] == "true" +version, body, prerelease, target_commitish = sys.argv[1], sys.argv[2], sys.argv[3] == "true", sys.argv[4] print(json.dumps({ "tag_name": version, + "target_commitish": target_commitish, "name": version, "body": body, "draft": False, @@ -159,7 +227,7 @@ PY create_status="$(curl -sS -o "$tmp_response" -w '%{http_code}' \ -X POST \ - -H "$auth_header" \ + "${auth_args[@]}" \ -H 'Content-Type: application/json' \ -d "$create_payload" \ "$release_api")" @@ -181,7 +249,7 @@ for asset in "${asset_files[@]}"; do name="$(basename "$asset")" curl -sS \ -X POST \ - -H "$auth_header" \ + "${auth_args[@]}" \ -H 'Content-Type: application/octet-stream' \ --data-binary @"$asset" \ "${release_api}/${release_id}/assets?name=${name}" >/dev/null diff --git a/scripts/release/release-publish-github.sh b/scripts/release/release-publish-github.sh new file mode 100755 index 0000000..f8660ed --- /dev/null +++ b/scripts/release/release-publish-github.sh @@ -0,0 +1,226 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: + ./scripts/release/release-publish-github.sh --version 2026.04.16-alpha.4 [options] + +Options: + --remote Git remote name. Default: github-public + --repo GitHub repository in owner/name form + --token GitHub token + --target-commitish Branch or commit to associate when creating the tag + --notes Release notes markdown file + --dry-run Print planned uploads without calling the API +EOF +} + +version="" +remote_name="github-public" +repo_full_name="" +token="${GITHUB_RELEASE_TOKEN:-${GITHUB_TOKEN:-}}" +target_commitish="" +notes_path="" +dry_run="false" + +while [[ $# -gt 0 ]]; do + case "$1" in + --version) + version="${2:-}" + shift 2 + ;; + --remote) + remote_name="${2:-}" + shift 2 + ;; + --repo) + repo_full_name="${2:-}" + shift 2 + ;; + --token) + token="${2:-}" + shift 2 + ;; + --target-commitish) + target_commitish="${2:-}" + shift 2 + ;; + --notes) + notes_path="${2:-}" + shift 2 + ;; + --dry-run) + dry_run="true" + shift + ;; + -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)" +release_root="$repo_root/release-assets/releases/$version" + +if [[ ! -d "$release_root" ]]; then + echo "Release bundle not found: $release_root" >&2 + exit 1 +fi + +remote_url="$(git -C "$repo_root" remote get-url "$remote_name" 2>/dev/null || true)" +if [[ -z "$repo_full_name" ]]; then + if [[ "$remote_url" =~ ^https?://github\.com/(.+?)(\.git)?$ ]]; then + repo_full_name="${BASH_REMATCH[1]}" + elif [[ "$remote_url" =~ ^git@github\.com:(.+)\.git$ ]]; then + repo_full_name="${BASH_REMATCH[1]}" + fi +fi + +if [[ -z "$repo_full_name" ]]; then + echo "Unable to infer GitHub repository name from remote: $remote_name" >&2 + exit 1 +fi + +if [[ -z "$token" && -f "$repo_root/.workspace-secrets/github-public.pat" ]]; then + token="$(tr -d '\r\n' < "$repo_root/.workspace-secrets/github-public.pat")" +fi + +if [[ "$dry_run" != "true" && -z "$token" ]]; then + echo "A GitHub token is required. Use --token, GITHUB_RELEASE_TOKEN, or GITHUB_TOKEN." >&2 + exit 1 +fi + +if [[ -z "$notes_path" ]]; then + notes_path="$release_root/RELEASE_NOTES.ko.md" +fi + +if [[ -n "$notes_path" && ! -f "$notes_path" ]]; then + echo "Release notes file not found: $notes_path" >&2 + exit 1 +fi + +if [[ -z "$target_commitish" ]]; then + target_commitish="main" +fi + +pre_release="false" +case "$version" in + *alpha*|*beta*|*rc*) + pre_release="true" + ;; +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 +) + +if [[ ${#asset_files[@]} -eq 0 ]]; then + echo "No release assets found in $release_root" >&2 + exit 1 +fi + +echo "GitHub release target: https://github.com/$repo_full_name" +echo "Remote: $remote_name" +echo "Version: $version" +echo "Target commitish: $target_commitish" +printf 'Assets:\n' +printf ' - %s\n' "${asset_files[@]#$release_root/}" + +if [[ "$dry_run" == "true" ]]; then + exit 0 +fi + +api_root="https://api.github.com/repos/${repo_full_name}" +release_api="${api_root}/releases" +tag_api="${release_api}/tags/${version}" +auth_header="Authorization: Bearer $token" +accept_header="Accept: application/vnd.github+json" +api_version_header="X-GitHub-Api-Version: 2022-11-28" +tmp_response="$(mktemp)" +trap 'rm -f "$tmp_response"' EXIT + +existing_status="$(curl -sS -o "$tmp_response" -w '%{http_code}' \ + -H "$auth_header" \ + -H "$accept_header" \ + -H "$api_version_header" \ + "$tag_api")" + +if [[ "$existing_status" == "200" ]]; then + existing_release_id="$(python3 - <<'PY' "$tmp_response" +import json, sys +with open(sys.argv[1], "r", encoding="utf-8") as fh: + print(json.load(fh)["id"]) +PY +)" + curl -sS -X DELETE \ + -H "$auth_header" \ + -H "$accept_header" \ + -H "$api_version_header" \ + "${release_api}/${existing_release_id}" >/dev/null +fi + +release_body="$(cat "$notes_path")" +create_payload="$(python3 - <<'PY' "$version" "$release_body" "$pre_release" "$target_commitish" +import json, sys +version, body, prerelease, target_commitish = sys.argv[1], sys.argv[2], sys.argv[3] == "true", sys.argv[4] +print(json.dumps({ + "tag_name": version, + "target_commitish": target_commitish, + "name": version, + "body": body, + "draft": False, + "prerelease": prerelease, + "make_latest": "false" if prerelease else "true", +}, ensure_ascii=False)) +PY +)" + +create_status="$(curl -sS -o "$tmp_response" -w '%{http_code}' \ + -X POST \ + -H "$auth_header" \ + -H "$accept_header" \ + -H "$api_version_header" \ + -H 'Content-Type: application/json' \ + -d "$create_payload" \ + "$release_api")" + +if [[ "$create_status" != "201" ]]; then + echo "Failed to create GitHub release. HTTP $create_status" >&2 + cat "$tmp_response" >&2 + exit 1 +fi + +upload_url="$(python3 - <<'PY' "$tmp_response" +import json, sys +with open(sys.argv[1], "r", encoding="utf-8") as fh: + print(json.load(fh)["upload_url"].split("{", 1)[0]) +PY +)" + +for asset in "${asset_files[@]}"; do + name="$(basename "$asset")" + curl -sS \ + -X POST \ + -H "$auth_header" \ + -H "$accept_header" \ + -H "$api_version_header" \ + -H 'Content-Type: application/octet-stream' \ + --data-binary @"$asset" \ + "${upload_url}?name=${name}" >/dev/null +done + +echo "Published GitHub release $version with ${#asset_files[@]} assets." diff --git a/scripts/release/release-publish-public.sh b/scripts/release/release-publish-public.sh new file mode 100755 index 0000000..7363311 --- /dev/null +++ b/scripts/release/release-publish-public.sh @@ -0,0 +1,181 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: + ./scripts/release/release-publish-public.sh --version 2026.04.16-alpha.4 [options] + +Options: + --branch Source branch to publish. Default: public/main + --target-branch Target branch name on public remotes. Default: main + --stage-remote Stage remote. Default: public-stage + --github-remote GitHub remote. Default: github-public + --notes Release notes markdown file + --skip-github Publish to stage only + --skip-stage Publish to GitHub only + --force-tag Replace an existing local or remote tag + --dry-run Print the planned sequence only +EOF +} + +version="" +branch_name="public/main" +target_branch="main" +stage_remote="public-stage" +github_remote="github-public" +notes_path="" +skip_github="false" +skip_stage="false" +force_tag="false" +dry_run="false" + +while [[ $# -gt 0 ]]; do + case "$1" in + --version) + version="${2:-}" + shift 2 + ;; + --branch) + branch_name="${2:-}" + shift 2 + ;; + --target-branch) + target_branch="${2:-}" + shift 2 + ;; + --stage-remote) + stage_remote="${2:-}" + shift 2 + ;; + --github-remote) + github_remote="${2:-}" + shift 2 + ;; + --notes) + notes_path="${2:-}" + shift 2 + ;; + --skip-github) + skip_github="true" + shift + ;; + --skip-stage) + skip_stage="true" + shift + ;; + --force-tag) + force_tag="true" + shift + ;; + --dry-run) + dry_run="true" + shift + ;; + -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)" +cd "$repo_root" + +if [[ -z "$notes_path" ]]; then + notes_path="$repo_root/release-assets/releases/$version/RELEASE_NOTES.ko.md" +fi + +target_commit="$(git rev-parse "$branch_name")" + +echo "Version: $version" +echo "Source branch: $branch_name" +echo "Target branch: $target_branch" +echo "Target commit: $target_commit" +[[ "$skip_stage" != "true" ]] && echo "Stage remote: $stage_remote" +[[ "$skip_github" != "true" ]] && echo "GitHub remote: $github_remote" + +tag_args=() +if [[ "$force_tag" == "true" ]]; then + tag_args+=(--force) +fi + +"$repo_root/scripts/release/release-create-tag.sh" \ + --version "$version" \ + --ref "$branch_name" \ + "${tag_args[@]}" \ + --dry-run + +if [[ "$dry_run" == "true" ]]; then + if [[ "$skip_stage" != "true" ]]; then + echo "DRY RUN: ALLOW_PUBLIC_PUSH=1 git push $stage_remote refs/heads/$branch_name:refs/heads/$target_branch" + echo "DRY RUN: ALLOW_PUBLIC_PUSH=1 git push $stage_remote refs/tags/$version:refs/tags/$version" + "$repo_root/scripts/release/release-publish-forge.sh" \ + --remote "$stage_remote" \ + --version "$version" \ + --target-commitish "$target_branch" \ + --notes "$notes_path" \ + --dry-run + fi + + if [[ "$skip_github" != "true" ]]; then + echo "DRY RUN: ALLOW_PUBLIC_PUSH=1 git push $github_remote refs/heads/$branch_name:refs/heads/$target_branch" + echo "DRY RUN: ALLOW_PUBLIC_PUSH=1 git push $github_remote refs/tags/$version:refs/tags/$version" + "$repo_root/scripts/release/release-publish-github.sh" \ + --remote "$github_remote" \ + --version "$version" \ + --target-commitish "$target_branch" \ + --notes "$notes_path" \ + --dry-run + fi + exit 0 +fi + +"$repo_root/scripts/release/release-create-tag.sh" \ + --version "$version" \ + --ref "$branch_name" \ + "${tag_args[@]}" + +push_branch() { + local remote_name="$1" + ALLOW_PUBLIC_PUSH=1 git push "$remote_name" "refs/heads/$branch_name:refs/heads/$target_branch" +} + +push_tag() { + local remote_name="$1" + local force_args=() + if [[ "$force_tag" == "true" ]]; then + force_args+=(--force) + fi + ALLOW_PUBLIC_PUSH=1 git push "${force_args[@]}" "$remote_name" "refs/tags/$version:refs/tags/$version" +} + +if [[ "$skip_stage" != "true" ]]; then + push_branch "$stage_remote" + push_tag "$stage_remote" + "$repo_root/scripts/release/release-publish-forge.sh" \ + --remote "$stage_remote" \ + --version "$version" \ + --target-commitish "$target_branch" \ + --notes "$notes_path" +fi + +if [[ "$skip_github" != "true" ]]; then + push_branch "$github_remote" + push_tag "$github_remote" + "$repo_root/scripts/release/release-publish-github.sh" \ + --remote "$github_remote" \ + --version "$version" \ + --target-commitish "$target_branch" \ + --notes "$notes_path" +fi diff --git a/src/PhysOn.Desktop/App.axaml.cs b/src/PhysOn.Desktop/App.axaml.cs index 8e0dfa4..00c2db0 100644 --- a/src/PhysOn.Desktop/App.axaml.cs +++ b/src/PhysOn.Desktop/App.axaml.cs @@ -26,9 +26,22 @@ public partial class App : Application DataContext = viewModel, }; - _ = viewModel.InitializeAsync(); + _ = InitializeDesktopAsync(viewModel); } base.OnFrameworkInitializationCompleted(); } + + private static async Task InitializeDesktopAsync(MainWindowViewModel viewModel) + { + await viewModel.InitializeAsync(); + + if (string.Equals( + Environment.GetEnvironmentVariable("KOTALK_DESKTOP_OPEN_SAMPLE_WINDOW"), + "1", + StringComparison.Ordinal)) + { + await viewModel.OpenDetachedConversationFromShortcutAsync(); + } + } } diff --git a/src/PhysOn.Desktop/Models/DesktopWorkspaceLayout.cs b/src/PhysOn.Desktop/Models/DesktopWorkspaceLayout.cs index a299e37..1b887f7 100644 --- a/src/PhysOn.Desktop/Models/DesktopWorkspaceLayout.cs +++ b/src/PhysOn.Desktop/Models/DesktopWorkspaceLayout.cs @@ -3,4 +3,5 @@ namespace PhysOn.Desktop.Models; public sealed record DesktopWorkspaceLayout( bool IsCompactDensity, bool IsInspectorVisible, - bool IsConversationPaneCollapsed); + bool IsConversationPaneCollapsed, + double ConversationPaneWidth = 348); diff --git a/src/PhysOn.Desktop/ViewModels/ConversationWindowViewModel.cs b/src/PhysOn.Desktop/ViewModels/ConversationWindowViewModel.cs index a714daa..c42d5e2 100644 --- a/src/PhysOn.Desktop/ViewModels/ConversationWindowViewModel.cs +++ b/src/PhysOn.Desktop/ViewModels/ConversationWindowViewModel.cs @@ -45,6 +45,12 @@ public partial class ConversationWindowViewModel : ViewModelBase, IAsyncDisposab public async Task InitializeAsync() { + if (string.Equals(Environment.GetEnvironmentVariable("KOTALK_DESKTOP_SAMPLE_MODE"), "1", StringComparison.Ordinal)) + { + LoadSampleConversation(); + return; + } + await LoadMessagesAsync(); try @@ -78,6 +84,47 @@ public partial class ConversationWindowViewModel : ViewModelBase, IAsyncDisposab partial void OnErrorTextChanged(string? value) => OnPropertyChanged(nameof(HasErrorText)); partial void OnConversationTitleChanged(string value) => OnPropertyChanged(nameof(ConversationGlyph)); + private void LoadSampleConversation() + { + Messages.Clear(); + StatusText = "●"; + ErrorText = null; + + foreach (var item in new[] + { + new MessageRowViewModel + { + MessageId = "detached-1", + SenderName = "민지", + Text = "이 창은 대화를 따로 두고 확인할 수 있게 분리했습니다.", + MetaText = "09:10", + IsMine = false, + ServerSequence = 1 + }, + new MessageRowViewModel + { + MessageId = "detached-2", + SenderName = _launchContext.DisplayName, + Text = "검수하면서도 메인 받은함은 그대로 둘 수 있어요.", + MetaText = "09:11", + IsMine = true, + ServerSequence = 2 + }, + new MessageRowViewModel + { + MessageId = "detached-3", + SenderName = "민지", + Text = "작업용 대화만 따로 띄워두기엔 이 구성이 훨씬 낫네요.", + MetaText = "09:12", + IsMine = false, + ServerSequence = 3 + } + }) + { + Messages.Add(item); + } + } + private async Task LoadMessagesAsync() { if (IsBusy) diff --git a/src/PhysOn.Desktop/ViewModels/MainWindowViewModel.cs b/src/PhysOn.Desktop/ViewModels/MainWindowViewModel.cs index bc14f24..7b90428 100644 --- a/src/PhysOn.Desktop/ViewModels/MainWindowViewModel.cs +++ b/src/PhysOn.Desktop/ViewModels/MainWindowViewModel.cs @@ -45,6 +45,9 @@ public partial class MainWindowViewModel : ViewModelBase, IAsyncDisposable ShowAllConversationsCommand = new RelayCommand(() => SelectedListFilter = "all"); ShowUnreadConversationsCommand = new RelayCommand(() => SelectedListFilter = "unread"); ShowPinnedConversationsCommand = new RelayCommand(() => SelectedListFilter = "pinned"); + ApplyAckDraftCommand = new RelayCommand(() => ApplyQuickDraft("확인했습니다.")); + ApplyShareDraftCommand = new RelayCommand(() => ApplyQuickDraft("공유드립니다.\n- ")); + ApplyTaskDraftCommand = new RelayCommand(() => ApplyQuickDraft("할 일\n- ")); ToggleCompactModeCommand = new RelayCommand(() => IsCompactDensity = !IsCompactDensity); ToggleInspectorCommand = new RelayCommand(() => IsInspectorVisible = !IsInspectorVisible); ToggleConversationPaneCommand = new RelayCommand(() => IsConversationPaneCollapsed = !IsConversationPaneCollapsed); @@ -80,6 +83,9 @@ public partial class MainWindowViewModel : ViewModelBase, IAsyncDisposable public IRelayCommand ShowAllConversationsCommand { get; } public IRelayCommand ShowUnreadConversationsCommand { get; } public IRelayCommand ShowPinnedConversationsCommand { get; } + public IRelayCommand ApplyAckDraftCommand { get; } + public IRelayCommand ApplyShareDraftCommand { get; } + public IRelayCommand ApplyTaskDraftCommand { get; } public IRelayCommand ToggleCompactModeCommand { get; } public IRelayCommand ToggleInspectorCommand { get; } public IRelayCommand ToggleConversationPaneCommand { get; } @@ -102,7 +108,7 @@ public partial class MainWindowViewModel : ViewModelBase, IAsyncDisposable [ObservableProperty] private string selectedListFilter = "all"; [ObservableProperty] private string composerText = string.Empty; [ObservableProperty] private string selectedConversationTitle = "KoTalk"; - [ObservableProperty] private string selectedConversationSubtitle = "대화를 열면 바로 이어집니다."; + [ObservableProperty] private string selectedConversationSubtitle = "준비"; [ObservableProperty] private ConversationRowViewModel? selectedConversation; [ObservableProperty] private bool hasErrorText; [ObservableProperty] private bool hasFilteredConversations; @@ -110,6 +116,7 @@ public partial class MainWindowViewModel : ViewModelBase, IAsyncDisposable [ObservableProperty] private bool isCompactDensity = true; [ObservableProperty] private bool isInspectorVisible; [ObservableProperty] private bool isConversationPaneCollapsed; + [ObservableProperty] private double conversationPaneWidthValue = 348; [ObservableProperty] private int detachedWindowCount; public bool ShowOnboarding => !IsAuthenticated; @@ -153,26 +160,26 @@ public partial class MainWindowViewModel : ViewModelBase, IAsyncDisposable public string DetachedWindowActionGlyph => HasDetachedWindows ? DetachedWindowBadgeText : "↗"; public bool HasDetachedWindows => DetachedWindowCount > 0; public bool IsConversationPaneExpanded => !IsConversationPaneCollapsed; - public double ConversationPaneWidth => IsConversationPaneCollapsed ? 0 : (IsCompactDensity ? 296 : 340); + public double ConversationPaneWidth => IsConversationPaneCollapsed ? 0 : ConversationPaneWidthValue; public double InspectorPaneWidth => IsInspectorVisible ? (IsCompactDensity ? 92 : 108) : 0; - public Thickness ConversationRowPadding => IsCompactDensity ? new Thickness(6, 6) : new Thickness(8, 7); - public Thickness MessageBubblePadding => IsCompactDensity ? new Thickness(10, 8) : new Thickness(12, 10); - public double ConversationAvatarSize => IsCompactDensity ? 26 : 30; + public Thickness ConversationRowPadding => IsCompactDensity ? new Thickness(6, 5) : new Thickness(8, 6); + public Thickness MessageBubblePadding => IsCompactDensity ? new Thickness(10, 7) : new Thickness(12, 9); + public double ConversationAvatarSize => IsCompactDensity ? 28 : 32; public double ComposerMinHeight => IsCompactDensity ? 48 : 58; public string ComposerCounterText => $"{ComposerText.Trim().Length}"; - public string SearchWatermark => "대화 검색"; + public string SearchWatermark => "검색"; public string InspectorStatusText => HasDetachedWindows ? $"{RealtimeStatusGlyph} {DetachedWindowBadgeText}" : RealtimeStatusGlyph; public string WorkspaceModeText => HasDetachedWindows ? $"분리 창 {DetachedWindowBadgeText}" : "단일 창"; public string StatusSummaryText => string.IsNullOrWhiteSpace(StatusLine) ? RealtimeStatusText : StatusLine; - public string ComposerPlaceholderText => HasSelectedConversation ? "메시지 보내기" : "왼쪽에서 대화를 고르세요"; + public string ComposerPlaceholderText => HasSelectedConversation ? "메시지" : "대화 선택"; public string ComposerActionText => Messages.Count == 0 ? "시작" : "보내기"; public bool ShowMessageEmptyState => Messages.Count == 0; - public string MessageEmptyStateTitle => HasSelectedConversation ? "첫 메시지부터 시작" : "대화를 먼저 고르세요"; + public string MessageEmptyStateTitle => HasSelectedConversation ? "첫 메시지" : "대화 선택"; public string MessageEmptyStateText => HasSelectedConversation - ? "짧게 한 줄만 남겨도 바로 이어집니다." - : "받은함에서 대화를 고르거나 창으로 분리해 집중할 수 있습니다."; + ? "짧게 남기세요." + : "목록에서 선택"; public async Task InitializeAsync() { @@ -270,6 +277,11 @@ public partial class MainWindowViewModel : ViewModelBase, IAsyncDisposable OnPropertyChanged(nameof(InspectorPaneWidth)); _ = PersistWorkspaceLayoutAsync(); } + partial void OnConversationPaneWidthValueChanged(double value) + { + OnPropertyChanged(nameof(ConversationPaneWidth)); + _ = PersistWorkspaceLayoutAsync(); + } partial void OnIsConversationPaneCollapsedChanged(bool value) { OnPropertyChanged(nameof(ConversationPaneGlyph)); @@ -292,7 +304,7 @@ public partial class MainWindowViewModel : ViewModelBase, IAsyncDisposable { UpdateSelectedConversationState(value?.ConversationId); SelectedConversationTitle = value?.Title ?? "KoTalk"; - SelectedConversationSubtitle = value?.Subtitle ?? "받은함과 대화를 한 화면에서 관리합니다."; + SelectedConversationSubtitle = value?.Subtitle ?? "대화"; OnPropertyChanged(nameof(SelectedConversationGlyph)); OnPropertyChanged(nameof(HasSelectedConversation)); OnPropertyChanged(nameof(HasSelectedConversationUnread)); @@ -317,7 +329,7 @@ public partial class MainWindowViewModel : ViewModelBase, IAsyncDisposable $"desktop-{Environment.MachineName.ToLowerInvariant()}", "windows", Environment.MachineName, - "0.1.0")); + "0.1.0-alpha.4")); var response = await _apiClient.RegisterAlphaQuickAsync(apiBaseUrl, request, CancellationToken.None); ApiBaseUrl = apiBaseUrl; @@ -370,7 +382,7 @@ public partial class MainWindowViewModel : ViewModelBase, IAsyncDisposable SelectedListFilter = "all"; SelectedConversation = null; SelectedConversationTitle = "KoTalk"; - SelectedConversationSubtitle = "대화를 열면 바로 이어집니다."; + SelectedConversationSubtitle = "준비"; NotifyConversationMetricsChanged(); } @@ -381,10 +393,18 @@ public partial class MainWindowViewModel : ViewModelBase, IAsyncDisposable return; } + Messages.Clear(); + NotifyMessageStateChanged(); + await RunBusyAsync(async () => { var items = await _apiClient.GetMessagesAsync(_session.ApiBaseUrl, _session.AccessToken, value.ConversationId, CancellationToken.None); + if (!string.Equals(SelectedConversation?.ConversationId, value.ConversationId, StringComparison.Ordinal)) + { + return; + } + Messages.Clear(); foreach (var item in items.Items) { @@ -667,6 +687,27 @@ public partial class MainWindowViewModel : ViewModelBase, IAsyncDisposable OnPropertyChanged(nameof(PinnedConversationCount)); } + public void UpdateConversationPaneWidth(double width) + { + if (IsConversationPaneCollapsed) + { + return; + } + + var clamped = Math.Clamp(Math.Round(width), 280, 480); + if (Math.Abs(clamped - ConversationPaneWidthValue) > 1) + { + ConversationPaneWidthValue = clamped; + } + } + + private void ApplyQuickDraft(string template) + { + ComposerText = string.IsNullOrWhiteSpace(ComposerText) + ? template + : ComposerText.TrimEnd() + Environment.NewLine + template; + } + private void LoadSampleWorkspace() { Conversations.Clear(); @@ -676,6 +717,13 @@ public partial class MainWindowViewModel : ViewModelBase, IAsyncDisposable CurrentUserDisplayName = "이안"; DisplayName = "이안"; InviteCode = string.Empty; + _currentUserId = "sample-user"; + _session = new DesktopSession( + DefaultApiBaseUrl, + "sample-access", + "sample-refresh", + CurrentUserDisplayName, + "sample-ops"); RealtimeState = RealtimeConnectionState.Connected; RealtimeStatusText = "연결됨"; StatusLine = "준비"; @@ -683,6 +731,7 @@ public partial class MainWindowViewModel : ViewModelBase, IAsyncDisposable IsCompactDensity = true; IsInspectorVisible = false; IsConversationPaneCollapsed = false; + ConversationPaneWidthValue = 348; DetachedWindowCount = 1; ErrorText = null; @@ -691,8 +740,8 @@ public partial class MainWindowViewModel : ViewModelBase, IAsyncDisposable { ConversationId = "sample-ops", Title = "제품 운영", - Subtitle = "스크린샷 기준으로 레이아웃을 다시 정리했습니다.", - LastMessageText = "스크린샷 기준으로 레이아웃을 다시 정리했습니다.", + Subtitle = "레이아웃 검수 메모를 확인해 주세요.", + LastMessageText = "레이아웃 검수 메모를 확인해 주세요.", MetaText = FormatConversationMeta(now.AddMinutes(-5), 2), UnreadCount = 2, IsPinned = true, @@ -703,8 +752,8 @@ public partial class MainWindowViewModel : ViewModelBase, IAsyncDisposable { ConversationId = "sample-review", Title = "디자인 리뷰", - Subtitle = "오후 2시에 검수 포인트만 다시 볼게요.", - LastMessageText = "오후 2시에 검수 포인트만 다시 볼게요.", + Subtitle = "오후 2시에 포인트만 다시 볼게요.", + LastMessageText = "오후 2시에 포인트만 다시 볼게요.", MetaText = FormatConversationMeta(now.AddMinutes(-22), 0), UnreadCount = 0, IsPinned = false, @@ -715,14 +764,38 @@ public partial class MainWindowViewModel : ViewModelBase, IAsyncDisposable { ConversationId = "sample-friends", Title = "주말 약속", - Subtitle = "토요일 브런치 장소만 정하면 끝.", - LastMessageText = "토요일 브런치 장소만 정하면 끝.", + Subtitle = "브런치 장소만 정하면 끝.", + LastMessageText = "브런치 장소만 정하면 끝.", MetaText = FormatConversationMeta(now.AddMinutes(-54), 0), UnreadCount = 0, IsPinned = false, LastReadSequence = 3, SortKey = now.AddMinutes(-54) }); + Conversations.Add(new ConversationRowViewModel + { + ConversationId = "sample-team", + Title = "운영 팀", + Subtitle = "오후 공유본만 마지막으로 확인해 주세요.", + LastMessageText = "오후 공유본만 마지막으로 확인해 주세요.", + MetaText = FormatConversationMeta(now.AddHours(-2), 1), + UnreadCount = 1, + IsPinned = false, + LastReadSequence = 7, + SortKey = now.AddHours(-2) + }); + Conversations.Add(new ConversationRowViewModel + { + ConversationId = "sample-files", + Title = "자료 모음", + Subtitle = "최신 캡처와 빌드 경로를 정리해 두었습니다.", + LastMessageText = "최신 캡처와 빌드 경로를 정리해 두었습니다.", + MetaText = FormatConversationMeta(now.AddHours(-5), 0), + UnreadCount = 0, + IsPinned = true, + LastReadSequence = 9, + SortKey = now.AddHours(-5) + }); NotifyConversationMetricsChanged(); RefreshConversationFilter("sample-ops"); @@ -735,7 +808,7 @@ public partial class MainWindowViewModel : ViewModelBase, IAsyncDisposable { MessageId = "sample-msg-1", SenderName = "민지", - Text = "회의 전에 이슈만 짧게 정리해 주세요.", + Text = "회의 전에 레이아웃 이슈만 짧게 정리해 주세요.", MetaText = "08:54", IsMine = false, ServerSequence = 13 @@ -744,7 +817,7 @@ public partial class MainWindowViewModel : ViewModelBase, IAsyncDisposable { MessageId = "sample-msg-2", SenderName = "이안", - Text = "레이아웃 구조를 다시 줄였습니다. 우측 빈 패널도 없앴어요.", + Text = "리스트 폭을 다시 줄이고 우측 빈 패널도 없앴어요.", MetaText = "08:56", IsMine = true, ServerSequence = 14 @@ -753,7 +826,7 @@ public partial class MainWindowViewModel : ViewModelBase, IAsyncDisposable { MessageId = "sample-msg-3", SenderName = "민지", - Text = "좋아요. 지금 화면이면 바로 검수할 수 있겠네요.", + Text = "좋아요. 지금 화면이면 검수하기 좋겠네요.", MetaText = "08:58", IsMine = false, ServerSequence = 15 @@ -762,7 +835,7 @@ public partial class MainWindowViewModel : ViewModelBase, IAsyncDisposable { MessageId = "sample-msg-4", SenderName = "이안", - Text = "스크린샷 기준으로 레이아웃도 바로 수정했습니다.", + Text = "스크린샷 기준으로 밀도도 같이 맞췄습니다.", MetaText = "09:05", IsMine = true, ServerSequence = 16 @@ -771,7 +844,7 @@ public partial class MainWindowViewModel : ViewModelBase, IAsyncDisposable { MessageId = "sample-msg-5", SenderName = "민지", - Text = "좋아요. 바로 확인 가능한 흐름으로 정리됐어요.", + Text = "좋아요. 확인 흐름이 더 짧아졌어요.", MetaText = "09:06", IsMine = false, ServerSequence = 17 @@ -780,7 +853,7 @@ public partial class MainWindowViewModel : ViewModelBase, IAsyncDisposable { MessageId = "sample-msg-6", SenderName = "이안", - Text = "분리 창도 한 번에 열리도록 남겨 두었습니다.", + Text = "분리 창은 상단 액션으로 남겨 두었습니다.", MetaText = "09:07", IsMine = true, ServerSequence = 18 @@ -793,6 +866,42 @@ public partial class MainWindowViewModel : ViewModelBase, IAsyncDisposable MetaText = "09:08", IsMine = false, ServerSequence = 19 + }, + new MessageRowViewModel + { + MessageId = "sample-msg-8", + SenderName = "이안", + Text = "검색과 필터는 한 줄 안에서 끝나도록 다시 정리할게요.", + MetaText = "09:10", + IsMine = true, + ServerSequence = 20 + }, + new MessageRowViewModel + { + MessageId = "sample-msg-9", + SenderName = "민지", + Text = "좋아요. 설명보다 눌리는 구조가 더 중요해요.", + MetaText = "09:11", + IsMine = false, + ServerSequence = 21 + }, + new MessageRowViewModel + { + MessageId = "sample-msg-10", + SenderName = "이안", + Text = "작성창도 짧은 액션만 남기고 텍스트는 줄였습니다.", + MetaText = "09:12", + IsMine = true, + ServerSequence = 22 + }, + new MessageRowViewModel + { + MessageId = "sample-msg-11", + SenderName = "민지", + Text = "이제 목록과 대화가 한 화면에서 훨씬 빠르게 읽히네요.", + MetaText = "09:13", + IsMine = false, + ServerSequence = 23 } }) { @@ -1021,6 +1130,7 @@ public partial class MainWindowViewModel : ViewModelBase, IAsyncDisposable IsCompactDensity = true; IsInspectorVisible = false; IsConversationPaneCollapsed = false; + ConversationPaneWidthValue = 348; StatusLine = "초기화"; } @@ -1029,6 +1139,7 @@ public partial class MainWindowViewModel : ViewModelBase, IAsyncDisposable IsCompactDensity = layout.IsCompactDensity; IsInspectorVisible = layout.IsInspectorVisible; IsConversationPaneCollapsed = layout.IsConversationPaneCollapsed; + ConversationPaneWidthValue = Math.Clamp(layout.ConversationPaneWidth, 280, 480); } private Task PersistWorkspaceLayoutAsync() @@ -1036,7 +1147,8 @@ public partial class MainWindowViewModel : ViewModelBase, IAsyncDisposable return _workspaceLayoutStore.SaveAsync(new DesktopWorkspaceLayout( IsCompactDensity, IsInspectorVisible, - IsConversationPaneCollapsed)); + IsConversationPaneCollapsed, + ConversationPaneWidthValue)); } public async ValueTask DisposeAsync() diff --git a/src/PhysOn.Desktop/Views/ConversationWindow.axaml b/src/PhysOn.Desktop/Views/ConversationWindow.axaml index f15305e..fb6bbba 100644 --- a/src/PhysOn.Desktop/Views/ConversationWindow.axaml +++ b/src/PhysOn.Desktop/Views/ConversationWindow.axaml @@ -3,95 +3,98 @@ xmlns:vm="using:PhysOn.Desktop.ViewModels" x:Class="PhysOn.Desktop.Views.ConversationWindow" x:DataType="vm:ConversationWindowViewModel" - Width="460" - Height="760" - MinWidth="360" + Width="404" + Height="748" + MinWidth="340" MinHeight="520" - Background="#F6F7F8" + Background="#F3F4F6" Title="{Binding ConversationTitle}"> - - - - - + + + + - - - - + + + + + Foreground="#111418" /> + - - - - - - - - - - - + + - - - - + - - - - - - - - - - - + Padding="10" + IsVisible="{Binding IsConversationPaneExpanded}" + SizeChanged="ConversationPaneHost_OnSizeChanged"> + + + @@ -423,7 +319,6 @@ Classes="search-input" PlaceholderText="{Binding SearchWatermark}" Text="{Binding ConversationSearchText}" /> - - - - - - - - - - - - - + + - - - - - - + + + + + + + + + + @@ -564,72 +444,62 @@ - - - - - - - - + + + + + + + + + - + + + + + + - + + - - - -
- 세션은 이 기기에만 - 서버는 고급에서만 -
- {statusMessage ?

{statusMessage}

: null} @@ -1156,7 +1155,7 @@ function App() { -
+