diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1ba2852..bc763ca 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -165,6 +165,9 @@ jobs: - name: Audit npm dependencies run: npm audit --audit-level=high + - name: Rebuild Electron native dependencies + run: npm run rebuild:native + - name: Verify desktop endpoint config run: npm run check:desktop-endpoint-config @@ -204,6 +207,9 @@ jobs: - name: Verify desktop theme config run: npm run check:desktop-theme + - name: Verify desktop efficiency config + run: npm run check:desktop-efficiency + - name: Verify desktop docs consistency run: npm run check:desktop-docs @@ -270,6 +276,12 @@ jobs: - name: Verify security governance config run: npm run check:security-governance + - name: Verify priority UX polish + run: npm run check:ux-priority-polish + + - name: Verify cross-client user experience + run: npm run check:user-experience + - name: Verify Web client shell run: node ..\scripts\check-web-client.mjs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0dcce43..c77bef5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,7 +7,7 @@ on: workflow_dispatch: inputs: version: - description: "Release version, for example v0.1.6" + description: "Release version, for example v0.1.8" required: true type: string publish_latest: @@ -47,7 +47,7 @@ jobs: shell: bash run: | if [[ ! "${RELEASE_VERSION}" =~ ^v[0-9]+(\.[0-9]+){0,2}([-.][0-9A-Za-z.]+)?$ ]]; then - echo "::error::Release version must start with v, for example v0.1.6." + echo "::error::Release version must start with v, for example v0.1.8." exit 1 fi expected_version="$(tr -d '[:space:]' < VERSION)" @@ -154,6 +154,60 @@ jobs: git push origin "refs/tags/${RELEASE_VERSION}" fi + deployment: + name: Build self-hosting bundle + runs-on: ubuntu-latest + needs: prepare + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Build deployment bundle + shell: bash + run: | + mkdir -p artifacts release-staging + artifact="$(pwd)/artifacts/TaskBridge-${RELEASE_VERSION}-deployment.zip" + bundle_root="release-staging/TaskBridge-${RELEASE_VERSION}" + mkdir -p "$bundle_root" + git archive --format=tar HEAD deploy web README.md VERSION | tar -xf - -C "$bundle_root" + + release_image="ghcr.io/${GITHUB_REPOSITORY_OWNER,,}/taskbridge:${RELEASE_VERSION}" + release_number="${RELEASE_VERSION#v}" + for env_file in \ + "$bundle_root/deploy/.env.example" \ + "$bundle_root/deploy/.env.local.example"; do + sed -i "s#^TASKBRIDGE_BACKEND_IMAGE=.*#TASKBRIDGE_BACKEND_IMAGE=${release_image}#" "$env_file" + sed -i "s#^APP_VERSION=.*#APP_VERSION=${release_number}#" "$env_file" + done + sed -i \ + "s#ghcr.io/27xk/taskbridge:latest#${release_image}#g" \ + "$bundle_root/deploy/docker-compose.release.yml" + + ( + cd release-staging + zip -qr "$artifact" "TaskBridge-${RELEASE_VERSION}" + ) + unzip -l "$artifact" | tee deployment-files.txt + for required in \ + "deploy/docker-compose.release.yml" \ + "deploy/.env.example" \ + "deploy/nginx.web.conf" \ + "deploy/start-local.cmd" \ + "deploy/start-local.sh" \ + "web/index.html"; do + if ! grep -Fq "$required" deployment-files.txt; then + echo "::error::Deployment bundle is missing ${required}." + exit 1 + fi + done + + - name: Upload deployment artifact + uses: actions/upload-artifact@v4 + with: + name: taskbridge-deployment + path: artifacts/*.zip + if-no-files-found: error + android: name: Build Android APK runs-on: ubuntu-latest @@ -198,13 +252,13 @@ jobs: fi done if [ "$configured" -eq 0 ]; then - echo "::notice::Android signing secrets are not configured; building an unsigned release APK." + echo "::notice::Skipping public Android APK because signing secrets are not configured." echo "TASKBRIDGE_ANDROID_SIGNED_RELEASE=false" >> "$GITHUB_ENV" exit 0 fi if [ "${#missing[@]}" -gt 0 ]; then echo "::error::Incomplete Android signing secrets: ${missing[*]}." - echo "Configure all Android signing secrets or leave all of them empty for an unsigned APK." >&2 + echo "Configure all Android signing secrets, or remove all of them to omit the APK from the public release." >&2 exit 1 fi echo "$ANDROID_KEYSTORE_BASE64" | base64 --decode > release.keystore @@ -226,6 +280,7 @@ jobs: run: ./gradlew testReleaseUnitTest -PTASKBRIDGE_USE_CHINA_MIRRORS=false -PTASKBRIDGE_BASE_URL="${TASKBRIDGE_BASE_URL}" -PTASKBRIDGE_WS_URL="${TASKBRIDGE_WS_URL}" --stacktrace - name: Build release APK + if: env.TASKBRIDGE_ANDROID_SIGNED_RELEASE == 'true' run: ./gradlew :app:assembleRelease -PTASKBRIDGE_USE_CHINA_MIRRORS=false -PTASKBRIDGE_BASE_URL="${TASKBRIDGE_BASE_URL}" -PTASKBRIDGE_WS_URL="${TASKBRIDGE_WS_URL}" --stacktrace env: ANDROID_KEYSTORE_PATH: ${{ github.workspace }}/android/release.keystore @@ -234,12 +289,9 @@ jobs: ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} - name: Verify Android APK signature + if: env.TASKBRIDGE_ANDROID_SIGNED_RELEASE == 'true' shell: bash run: | - if [ "$TASKBRIDGE_ANDROID_SIGNED_RELEASE" != "true" ]; then - echo "::notice::Skipping Android APK signature verification because this release is unsigned." - exit 0 - fi apk_path="$(find app/build/outputs/apk/release -maxdepth 1 -type f -name 'app-release.apk' | head -n 1)" if [ -z "$apk_path" ]; then echo "No release APK was produced." >&2 @@ -258,17 +310,13 @@ jobs: "$apksigner" verify --verbose --print-certs "$apk_path" - name: Prepare Android artifact + if: env.TASKBRIDGE_ANDROID_SIGNED_RELEASE == 'true' shell: bash run: | mkdir -p ../artifacts find app/build/outputs/apk -maxdepth 3 -type f -print - if [ "$TASKBRIDGE_ANDROID_SIGNED_RELEASE" = "true" ]; then - apk_path="$(find app/build/outputs/apk/release -maxdepth 1 -type f -name 'app-release.apk' | head -n 1)" - artifact_name="TaskBridge-${RELEASE_VERSION}-android.apk" - else - apk_path="$(find app/build/outputs/apk/release -maxdepth 1 -type f -name 'app-release-unsigned.apk' | head -n 1)" - artifact_name="TaskBridge-${RELEASE_VERSION}-android-unsigned.apk" - fi + apk_path="$(find app/build/outputs/apk/release -maxdepth 1 -type f -name 'app-release.apk' | head -n 1)" + artifact_name="TaskBridge-${RELEASE_VERSION}-android.apk" if [ -z "$apk_path" ]; then echo "No release APK was produced." >&2 exit 1 @@ -276,6 +324,7 @@ jobs: cp "$apk_path" "../artifacts/${artifact_name}" - name: Upload Android artifact + if: env.TASKBRIDGE_ANDROID_SIGNED_RELEASE == 'true' uses: actions/upload-artifact@v4 with: name: taskbridge-android @@ -314,6 +363,9 @@ jobs: - name: Audit npm dependencies run: npm audit --audit-level=high + - name: Rebuild Electron native dependencies + run: npm run rebuild:native + - name: Verify desktop endpoint config run: npm run check:desktop-endpoint-config @@ -353,6 +405,9 @@ jobs: - name: Verify desktop theme config run: npm run check:desktop-theme + - name: Verify desktop efficiency config + run: npm run check:desktop-efficiency + - name: Verify desktop docs consistency run: npm run check:desktop-docs @@ -419,6 +474,12 @@ jobs: - name: Verify security governance config run: npm run check:security-governance + - name: Verify priority UX polish + run: npm run check:ux-priority-polish + + - name: Verify cross-client user experience + run: npm run check:user-experience + - name: Verify Web client shell run: node ..\scripts\check-web-client.mjs @@ -434,6 +495,9 @@ jobs: - name: Smoke test Web client shell run: node ..\scripts\smoke-web-client.mjs + - name: Build desktop application + run: npm run build + - name: Prepare Windows signing certificate shell: pwsh run: | @@ -442,11 +506,11 @@ jobs: if (-not $hasCertificate -and -not $hasPassword) { "TASKBRIDGE_WINDOWS_SIGNED_RELEASE=false" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 "CSC_IDENTITY_AUTO_DISCOVERY=false" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 - Write-Host "::notice::Windows signing certificate is not configured; building an unsigned installer." + Write-Host "::notice::Skipping public Windows installer because signing secrets are not configured." exit 0 } if (-not $hasCertificate -or -not $hasPassword) { - throw "Incomplete Windows signing secrets. Configure both WINDOWS_CERTIFICATE_BASE64 and WINDOWS_CERTIFICATE_PASSWORD, or leave both empty for an unsigned installer." + throw "Incomplete Windows signing secrets. Configure both values, or remove both to omit the installer from the public release." } $certificatePath = Join-Path $env:RUNNER_TEMP "taskbridge-codesign.pfx" [IO.File]::WriteAllBytes($certificatePath, [Convert]::FromBase64String($env:WINDOWS_CERTIFICATE_BASE64)) @@ -461,6 +525,7 @@ jobs: WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }} - name: Build Windows installer + if: env.TASKBRIDGE_WINDOWS_SIGNED_RELEASE == 'true' run: npm run dist env: TASKBRIDGE_BASE_URL: ${{ env.TASKBRIDGE_BASE_URL }} @@ -469,12 +534,9 @@ jobs: ELECTRON_BUILDER_CACHE: ${{ github.workspace }}\\.cache\\electron-builder - name: Verify Windows installer signatures + if: env.TASKBRIDGE_WINDOWS_SIGNED_RELEASE == 'true' shell: pwsh run: | - if ($env:TASKBRIDGE_WINDOWS_SIGNED_RELEASE -ne "true") { - Write-Host "::notice::Skipping Windows installer signature verification because this release is unsigned." - exit 0 - } $installers = @(Get-ChildItem -Path release -File | Where-Object { $_.Extension -eq '.exe' }) if ($installers.Count -eq 0) { throw "No Windows installer .exe was produced." @@ -487,17 +549,19 @@ jobs: } - name: Prepare desktop artifacts + if: env.TASKBRIDGE_WINDOWS_SIGNED_RELEASE == 'true' shell: pwsh run: | New-Item -ItemType Directory -Force -Path ..\artifacts | Out-Null Get-ChildItem -Path release -Recurse -File | Select-Object FullName, Length Get-ChildItem -Path release -File | Where-Object { - $_.Extension -in @('.exe', '.yml', '.blockmap') -or $_.Name -eq 'latest.yml' + $_.Extension -in @('.exe', '.blockmap') -or $_.Name -eq 'latest.yml' } | ForEach-Object { Copy-Item -LiteralPath $_.FullName -Destination ..\artifacts\$($_.Name) } - name: Validate desktop update artifacts + if: env.TASKBRIDGE_WINDOWS_SIGNED_RELEASE == 'true' shell: pwsh run: | # Desktop auto-update publishes to the stable latest channel. @@ -513,6 +577,7 @@ jobs: } - name: Upload Windows artifacts + if: env.TASKBRIDGE_WINDOWS_SIGNED_RELEASE == 'true' uses: actions/upload-artifact@v4 with: name: taskbridge-desktop @@ -617,6 +682,7 @@ jobs: runs-on: ubuntu-latest needs: - android + - deployment - desktop - docker steps: @@ -642,6 +708,18 @@ jobs: with: subject-path: release-artifacts/* + - name: Delete assets from an existing release + shell: bash + run: | + if gh release view "$RELEASE_VERSION" >/dev/null 2>&1; then + gh release view "$RELEASE_VERSION" --json assets --jq '.assets[].name' | + while IFS= read -r asset_name; do + [ -z "$asset_name" ] || gh release delete-asset "$RELEASE_VERSION" "$asset_name" --yes + done + fi + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Publish GitHub Release uses: softprops/action-gh-release@v2 with: @@ -652,6 +730,11 @@ jobs: files: release-artifacts/* fail_on_unmatched_files: true body: | + The deployment bundle is ready for Docker Compose self-hosting. Android and + Windows downloads are attached only when their signing secrets were configured + and the generated files passed signature verification. Unsigned client files are + never attached to a public release. + Backend Docker images: - `${{ needs.docker.outputs.ghcr_image }}:${{ env.RELEASE_VERSION }}` @@ -663,3 +746,27 @@ jobs: - `${{ needs.docker.outputs.dockerhub_image }}:latest` env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Verify published release assets + shell: bash + run: | + asset_names="$(gh release view "$RELEASE_VERSION" --json assets --jq '.assets[].name')" + required_assets=("TaskBridge-${RELEASE_VERSION}-deployment.zip" "SHA256SUMS.txt") + for required_asset in "${required_assets[@]}"; do + if ! grep -Fxq "$required_asset" <<<"$asset_names"; then + echo "::error::Published release is missing ${required_asset}." + exit 1 + fi + done + + forbidden_assets=("*unsigned*" "builder-debug.yml") + while IFS= read -r asset_name; do + for forbidden_pattern in "${forbidden_assets[@]}"; do + if [[ "$asset_name" == $forbidden_pattern ]]; then + echo "::error::Published release contains forbidden asset ${asset_name}." + exit 1 + fi + done + done <<<"$asset_names" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/PRODUCT.md b/PRODUCT.md index d396dac..4b0a61f 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -6,11 +6,11 @@ product ## Users -TaskBridge is for people who manage personal or work tasks across a desktop computer, an Android phone, and a small desktop or home-server deployment. They need to capture work quickly, see what matters today, keep using the app offline after first login, and sync changes when the network returns. +TaskBridge is for people who manage personal or work tasks across a desktop computer, an Android phone, a browser, and a small desktop or home-server deployment. They need to capture work quickly, see what matters today, keep using cached tasks after an initial sync, and understand when signing in again is required before changes can reach other devices. ## Product Purpose -TaskBridge provides a local-first, self-hostable task system with desktop, Android, widget, floating-window, and static Web/PWA clients. Success means a user can connect to a server once, trust local task handling afterward, and move between devices without relearning the workflow. +TaskBridge provides a local-first, self-hostable task system with desktop, Android, widget, floating-window, and static Web/PWA clients. Success means users can trust local task handling, move between devices without relearning the workflow, and distinguish local availability from authenticated online sync. Each client can retain the scoped local workspace after natural session expiry, but sending queued changes always requires signing in again. ## Brand Personality @@ -27,6 +27,9 @@ Avoid marketing-first landing-page patterns inside the product UI, including ove - Hide complexity until it is useful: schedule, reminder, priority, template, repeat, and checklist controls should remain available without dominating the first screen. - Keep destructive or secondary actions out of the main path: completion and recovery are primary list actions; editing, template use, and deletion can sit behind a secondary menu. - Preserve self-hosting clarity: server setup and connection language should help non-developers choose the right entry point without duplicate decision trees. +- Give regular users one server URL: Release Compose clients use the shared `8080` entry; direct API and WebSocket endpoints remain an advanced deployment option. +- Keep healthy infrastructure quiet: sync panels should remain visible only when a user needs to make a decision or recover an item. +- Make installed-version and update discovery available inside each packaged client instead of requiring users to infer it from filenames. ## Accessibility & Inclusion diff --git a/README.md b/README.md index 4cbb08c..8d5e4df 100644 --- a/README.md +++ b/README.md @@ -4,108 +4,59 @@ [![Android](https://img.shields.io/badge/android-APK-3DDC84)](https://github.com/27xk/TaskBridge/releases) [![Windows](https://img.shields.io/badge/windows-installer-0078D4)](https://github.com/27xk/TaskBridge/releases) -TaskBridge 是一个登录后本地优先的跨端待办应用。首次使用需要连接一个 TaskBridge 服务器;登录并同步过后,手机、电脑和桌面小组件都能继续离线查看和处理本机任务,网络恢复后会自动同步到其他设备。 +TaskBridge 是一个登录后本地优先的跨端待办应用。首次使用需要连接一个 TaskBridge 服务器;登录并同步过后,手机、电脑和 Web 都能继续处理本机任务。Windows、Android 或 Web 的登录会话自然失效时,可以进入本机工作区继续处理缓存任务,重新登录后再同步。 它适合需要在多个设备之间切换工作的用户:在电脑上快速录入任务,在手机上接收提醒,在桌面小组件里扫一眼今天要做什么。 ## 入口 -- **下载:** [GitHub Releases](https://github.com/27xk/TaskBridge/releases) -- **普通用户快速开始:** [普通用户快速开始](./docs/user-quick-start.md) -- **已有服务器地址:** 按下面的普通用户快速开始下载客户端并登录。 -- **没有服务器地址:** 先看下面的本机试用或自托管路径。 +先按这张表决定下一步,普通使用不需要阅读开发者说明。 -## 普通用户快速开始 +| 你的情况 | 先做什么 | 入口 | +| --- | --- | --- | +| 我已经有服务器地址和账号 | 下载客户端或打开部署者提供的 Web/PWA 地址,然后登录 | [普通用户快速开始](./docs/user-quick-start.md) | +| 我还没有服务器地址 | 如果你只是使用别人部署好的 TaskBridge,请先向管理员或部署者索取服务器地址和账号 | 拿到地址后按“已有服务器地址”登录 | +| 我要维护或部署服务 | 本机试用先启动服务;长期使用先完成自托管部署 | [本机试用](./deploy/README.md#本机试用) / [自托管部署](./deploy/README.md) | -先按这一句选择路径:已有服务器地址就直接登录;没有服务器地址再选择本机试用或长期自托管。 +下载客户端:[GitHub Releases](https://github.com/27xk/TaskBridge/releases)。普通用户只安装名称中不带 `unsigned` 的已签名客户端;新版工作流不会再把 unsigned APK 或 EXE 上传到公开 Release。 -如果你已经拿到服务器地址和账号,普通使用不需要安装部署工具、不需要处理网络地址细节,也不需要阅读部署命令。只要打开客户端,填写服务器地址并登录。 +## 普通用户快速开始 -| 你的情况 | 先做什么 | 需要看的说明 | -| --- | --- | --- | -| 已有服务器地址 | 下载客户端或打开部署者提供的 Web/PWA 地址,然后登录 | 下面的“已有服务器地址” | -| 本机试用 | 没有服务器地址时,按本机试用说明启动服务,再回到客户端填写地址 | 下面的“没有服务器地址” | -| 长期自托管 | 先完成自托管服务,再把正式服务器地址填到客户端 | `deploy/README.md` | +如果你已经拿到服务器地址和账号,普通使用不需要安装部署工具、不需要处理网络地址细节,也不需要阅读部署命令。只要打开客户端,填写服务器地址并登录。 ### 已有服务器地址 如果管理员已经给你 TaskBridge 服务器地址和账号,直接按下面的路径开始: 1. 如果使用 Web/PWA,打开部署者提供的 TaskBridge 访问地址。 -2. 如果使用桌面或手机客户端,打开 [Releases](https://github.com/27xk/TaskBridge/releases),下载 Windows 安装包或 Android APK。 +2. 如果使用桌面或手机客户端,打开 [Releases](https://github.com/27xk/TaskBridge/releases),下载该版本提供的已签名 Windows 安装包或 Android APK;没有对应文件表示维护者尚未配置该平台签名,请先使用 Web/PWA。 3. 在登录页填写管理员或部署者给你的服务器地址。 4. 直接登录同一个账号。登录会自动检查连接;“检查连接”只用于排查服务器地址。 ### 没有服务器地址 -- **本机试用:** 只想先在自己的电脑上体验同步流程时,按[部署说明的本机试用](./deploy/README.md#本机试用)启动服务,再把说明里给出的服务器地址填到客户端。 -- **长期自托管:** 准备长期使用、多人使用或公网访问时,按[部署说明](./deploy/README.md)完成生产配置,再把正式服务器地址填到客户端。 +- **普通使用者:** 如果你只是使用别人部署好的 TaskBridge,请先向管理员或部署者索取服务器地址和账号。拿到地址后,回到上面的“已有服务器地址”路径登录。 +- **本机试用:** 如果你要自己在这台电脑上体验同步流程,按[部署说明的本机试用](./deploy/README.md#本机试用)启动服务,再把说明里给出的服务器地址填到客户端。 +- **长期自托管:** 如果你要维护长期使用、多人使用或公网访问的服务,按[部署说明](./deploy/README.md)完成生产配置,再把正式服务器地址填到客户端。 说明: - 安装包可能带有默认服务器地址;如果登录页地址不对,请在登录页或设置页填写管理员给你的服务器地址。 +- 下载后可用 Release 中的 `SHA256SUMS.txt` 核对文件;不要安装历史版本中名称带 `unsigned` 的文件。 - 桌面端安装后可以在「设置」里修改服务器地址。 - Android 端可以在登录 / 注册页的「连接设置」或设置页修改服务器地址。 - 桌面端和 Android 端都优先填写「服务器地址」;高级连接设置通常不需要修改。 +- 使用 Release Compose 时,Web、Windows 和 Android 都填写统一入口 `http://<服务器>:8080`;端口 `8000` 只用于开发或高级 API 直连。 +- 新建任务只需要先写标题;备注、清单、时间安排、标签和模板都可以按需展开。 - 手机或另一台电脑访问本机试用服务时,以部署说明里的客户端地址为准。 -- 离线新增、编辑和完成任务依赖本机已有登录会话;如果是第一次打开,请先完成一次登录。 +- 第一次离线使用前必须至少成功登录并同步一次。会话自然失效后,Web 显示“继续离线使用”,Windows 和 Android 显示“进入本机工作区”;联网后点“登录并同步”。主动退出仍会清除当前工作区身份。 普通使用不需要阅读开发者说明;登录、离线使用和同步状态处理都在客户端内完成。下面的开发者和自托管内容只适合维护、部署或排障时继续阅读。 -## 截图 - -> 截图仅展示当前版本的主要工作区;连接、注册、同步和权限设置以当前客户端实际页面为准。 - -### Windows 桌面端 - -

- Windows 桌面端今日待办 -

- - - - - - -
- Windows 桌面端全部任务 -
- 全部任务 -
- Windows 桌面端设置页 -
- 基础设置 -
- -### Android App - -

- Android App 首页 -    - Android App 设置页 -

- -### 桌面小组件与悬浮窗 - - - - - - -
- Android 两套小组件样式 -
- Android 小组件 -
- Windows 桌面悬浮窗 -
- Windows 悬浮窗 -
- ## 你可以用它做什么 - 在电脑上记录工作任务,切到手机后继续查看。 -- 登录并同步过后,可以在离线状态下新增、编辑、完成任务,稍后自动同步。 +- 登录并同步过后,可以在离线状态下新增、编辑和完成任务;Web 关闭会话后可继续使用本机缓存,重新登录后同步。 - 用今日视图区分逾期、待办和已完成任务。 - 通过 Android 小组件查看今天的任务。 - 用 Windows 悬浮窗常驻显示今日待办。 @@ -113,192 +64,30 @@ TaskBridge 是一个登录后本地优先的跨端待办应用。首次使用需 ## 功能 -- **登录后的本地优先:** 首次完成登录后,没网时也能新增、编辑和完成任务。 -- **多端同步:** 网络恢复后,修改会自动同步到其他设备。 +- **登录后的本地优先:** 首次完成登录后,没网时也能新增、编辑和完成任务;Web 支持从登录页重新打开本机缓存。 +- **多端同步:** 有效登录会话恢复联网后会自动同步;Web 离线恢复模式需先重新登录。 - **冲突处理:** 多台设备同时改同一条任务时,客户端会提示用户选择保留哪一份。 - **今日待办:** 支持计划日期、截止时间、逾期判断、已完成排序和今日视图。 -- **Android 小组件:** 支持清晰黑字和透明白字两套样式。 +- **Android 小组件:** 支持清晰黑字和带深色遮罩的透明白字样式;任务超出显示容量时会提示打开应用查看全部。 - **Windows 桌面端:** 提供主窗口、系统托盘、悬浮窗、全局快捷键、本地提醒和可持久化的桌面端主题。 - **自托管:** 可以把服务部署在自己的电脑、NAS 或服务器上。 ## 开发者和自托管说明 +普通使用不需要阅读开发者说明;从这里开始的内容面向维护、部署、开发和排障。 + ### 维护者入口 - **Demo 演示:** [演示脚本](./docs/demo.md) - **一键部署:** [部署说明](./deploy/README.md) +- **开发启动与验证:** [开发说明](./docs/development.md) +- **常见问题与排障:** [常见问题](./docs/troubleshooting.md) - **发布与镜像:** [GitHub 发布说明](./docs/github-release.md) - **容器镜像:** [GHCR / Docker Hub](https://github.com/27xk/TaskBridge/pkgs/container/taskbridge) - **路线图:** [Roadmap](./ROADMAP.md) - **参与贡献:** [CONTRIBUTING.md](./CONTRIBUTING.md) -### 自托管后端 - -复制以下命令即可启动后端、MySQL 和 Redis: - -```bash -git clone https://github.com/27xk/TaskBridge.git -cd TaskBridge/deploy -cp .env.local.example .env -docker compose -f docker-compose.release.yml up -d -``` - -Windows PowerShell: - -```powershell -git clone https://github.com/27xk/TaskBridge.git -cd TaskBridge\deploy -Copy-Item .env.local.example .env -docker compose -f docker-compose.release.yml up -d -``` - -本机试用请使用 `.env.local.example`;正式部署到公网或共享服务器时,再按 `deploy/README.md` 的生产配置替换密码、密钥和域名。 - -默认服务器地址: - -```text -http://127.0.0.1:8000 -``` - -如果要让手机或其他电脑访问,请把客户端后端地址改成服务器的局域网 IP 或域名,例如: - -```text -http://192.168.1.10:8000 -``` - -## 开发启动 - -### 后端 - -```powershell -cd backend -python -m venv .venv -.\.venv\Scripts\Activate.ps1 -pip install -r requirements-dev.txt -Copy-Item .env.example .env -alembic upgrade head -uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload -``` - -### Android - -```powershell -cd android -.\gradlew.bat :app:assembleDebug -``` - -连接本机模拟器后端: - -```powershell -.\gradlew.bat :app:assembleDebug ` - -PTASKBRIDGE_BASE_URL=http://10.0.2.2:8000/api/v1/ ` - -PTASKBRIDGE_WS_URL=ws://10.0.2.2:8000/ws/sync -``` - -### Windows 桌面端 - -```powershell -cd desktop -npm ci -npm run dev -``` - -### Web/PWA - -```powershell -# 先启动后端,再启动静态 Web 客户端 -python -m http.server 8080 -d web -``` - -默认地址: - -```text -http://127.0.0.1:8080 -``` - -需要在后端设置允许的浏览器来源。生产环境请写明确的 HTTPS Web/PWA 来源: - -```text -WEB_CORS_ORIGINS=https://taskbridge.example.com -``` - -仅本机试用可以使用 `WEB_CORS_ORIGINS=*`,方便手机、局域网电脑和 Web/PWA 同时访问开发后端;正式部署不要使用通配来源。 - -Web/PWA 支持登录、注册、任务新建、编辑、完成、删除、恢复、搜索和同步状态查看。断网时可以继续查看和修改本机任务,网络恢复后会自动同步;如果同一条任务在其他设备也被修改,页面会对比这台设备版本和同步来的版本,让用户选择保留哪一版。 - -## 开发者技术栈 - -| 模块 | 技术 | -| --- | --- | -| 后端 | Python 3.11、FastAPI、SQLAlchemy 2.x、Alembic、MySQL、Redis、JWT、Docker | -| Android | Kotlin、Jetpack Compose、Room、Retrofit、OkHttp WebSocket、WorkManager、DataStore、AppWidget | -| Web/PWA | 静态 HTML、CSS、JavaScript、Service Worker、Manifest、IndexedDB 离线队列 | -| Windows 桌面端 | Electron、Vue 3、TypeScript、Pinia、SQLite、轻量 JSON 配置、electron-builder | -| 同步 | HTTP 增量同步、同步队列、WebSocket 通知、任务版本控制、软删除 | - -## 开发者常用验证命令 - -```powershell -# 首次拉取或依赖缺失时,先补齐本地验证依赖 -.\scripts\bootstrap-local.ps1 - -# 本地统一验收入口;缺失依赖会在汇总中标记为 blocked 并让脚本失败 -.\scripts\check-local.ps1 - -# check-local 会同时运行版本来源、Web/PWA 静态守卫、离线优先守卫、离线核心行为回归和 HTTP smoke -# 确认 index、CSS、JS、Manifest、Service Worker、本机缓存、离线视图过滤与图标都能通过行为守卫或本地 HTTP 访问 - -# 只生成报告,blocked 不会影响退出码 -.\scripts\check-local.ps1 -ReportOnly - -# 一条命令先补齐依赖再执行本地验证 -.\scripts\check-local.ps1 -BootstrapMissing - -# 版本来源 -node scripts/check-version-source.mjs - -# 完整验收;Android 需要本地 Gradle / SDK 缓存完整 -.\scripts\check-local.ps1 -IncludeAndroid -IncludeAndroidAssemble - -# 后端 -cd backend -python -m pytest tests -q -python -m pytest tests/test_migrations.py -q -python -m compileall -q app tests tools -python -m tools.openapi_contract --check -python -m ruff check app tests tools - -# Android -cd android -.\gradlew.bat testDebugUnitTest -.\gradlew.bat :app:assembleDebug - -# 桌面端 -cd desktop -npm run check:security-config -npm run check:auth-session-config -npm run check:backend-observability -npm run check:desktop-endpoint-config -npm run check:package-size-config -npm run test:unit -npm run check:quick-add-parser -npm run check:task-order -npm run check:sync-push -npm run check:sync-diagnostics -npm run check:sync-recovery-center -npm run check:desktop-backup -npm run check:desktop-theme -npm run check:desktop-efficiency -npm run check:desktop-docs -npm run check:release-readiness -npm run check:release-artifacts -npm run check:production-hardening -npm run check:android-sync-recovery -npm run check:ci-workflows -npm run check:contract-drift -npm run typecheck -npm run build -``` +开发启动、技术栈、Web/PWA 本地访问、CORS 示例和完整验证命令已经移到 [开发说明](./docs/development.md)。README 只保留入口,避免普通用户在登录前被部署命令打断。 ## Keywords @@ -309,6 +98,7 @@ TaskBridge, todo app, task manager, offline-first, cross-platform sync, Android - [后端说明](./backend/README.md) - [Android 构建说明](./android/README.md) - [桌面端说明](./desktop/README.md) +- [开发说明](./docs/development.md) - [架构说明](./docs/architecture.md) - [API 设计](./docs/api-design.md) - [同步设计](./docs/sync-design.md) diff --git a/VERSION b/VERSION index 1180819..699c6c6 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.7 +0.1.8 diff --git a/android/README.md b/android/README.md index c08add0..995d9d6 100644 --- a/android/README.md +++ b/android/README.md @@ -11,9 +11,7 @@ 没有服务器时,先看根目录 README 的“没有服务器地址”章节;服务准备好后,再回到 Android 端填写服务器地址并登录。登录会自动检查连接。 -如果手机要连接电脑上的本机试用服务,跨设备地址以根目录说明为准。 - -离线新增、编辑和完成任务依赖本机已有登录会话;如果是第一次打开,请先完成一次登录。 +第一次使用仍需完成登录。会话自然失效后,登录页会提供本机工作区入口;可以继续查看和修改缓存任务,但不会连接服务器或上传修改,重新登录后才同步。主动退出会清除本机工作区身份。 ## 开发者说明 @@ -38,6 +36,8 @@ cd android 后端地址可通过 Gradle 参数或 `local.properties` 配置。 +连接电脑上的 Release Compose 本机试用服务时,Android 真机填写 `http://<电脑局域网 IP>:8080`,模拟器填写 `http://10.0.2.2:8080`。 + 复制本地配置: ```powershell @@ -45,14 +45,14 @@ cd android Copy-Item local.properties.example local.properties ``` -常用参数: +下面参数用于不经过 Release Compose 代理、直接连接开发后端: ```properties TASKBRIDGE_BASE_URL=http://10.0.2.2:8000/api/v1/ TASKBRIDGE_WS_URL=ws://10.0.2.2:8000/ws/sync ``` -这些值会在构建时写入 APK 的 `BuildConfig`,作为首次启动默认地址。安装后也可以在登录 / 注册页的「连接设置」或 App 设置页修改服务器地址。 +这些值会在构建时写入 APK 的 `BuildConfig`,作为首次启动默认地址。安装后也可以在登录 / 注册页的「连接设置」或 App 设置页修改服务器地址。Debug 和 Release 都允许在运行时填写 HTTP / HTTPS API 地址以及 WS / WSS 同步地址;Release 不会因为使用明文 HTTP / WS 而拒绝保存端点。 ## 构建命令 @@ -62,7 +62,7 @@ TASKBRIDGE_WS_URL=ws://10.0.2.2:8000/ws/sync .\gradlew.bat :app:assembleDebug ``` -连接模拟器访问本机后端: +开发直连:模拟器访问本机后端: ```powershell .\gradlew.bat :app:assembleDebug ` @@ -70,7 +70,7 @@ TASKBRIDGE_WS_URL=ws://10.0.2.2:8000/ws/sync -PTASKBRIDGE_WS_URL=ws://10.0.2.2:8000/ws/sync ``` -连接局域网后端: +开发直连:连接局域网后端: ```powershell .\gradlew.bat :app:assembleDebug ` @@ -84,9 +84,9 @@ Release APK: .\gradlew.bat :app:assembleRelease ``` -Release 构建会优先使用正式签名。配置完整 keystore、密码、alias 和 key 密码时会生成已签名 APK;缺少签名配置时,`assembleRelease` 会生成 unsigned release APK,不会回退到 debug signing。 +本地 `assembleRelease` 仍允许生成 unsigned APK 用于开发验证,不会回退到 debug signing。配置完整 keystore、密码、alias 和 key 密码时,本地构建会生成已签名 APK。 -本机临时验证仍建议使用 debug 构建;如果需要发布 unsigned release,请在文件名和说明中明确标注,避免用户把它当作已签名正式 APK。 +GitHub Release workflow 的发布条件不同:配置完整 Android 签名时,workflow 才会构建并上传公开 APK;未配置完整 Android 签名时,Release workflow 只运行测试并省略 APK 发布,不会上传 unsigned APK。unsigned release 仅用于本地开发验证,不应作为公开下载文件。 ## Release 签名 @@ -118,7 +118,9 @@ GitHub Actions 发布时还支持 `ANDROID_KEYSTORE_BASE64`,详见 [GitHub 发 ## 主要功能 - 登录、注册、Token 保存和自动刷新。 +- 冷启动先等待本机登录态读取完成,不会短暂闪现登录页;Token 被清除或失效后会自动返回登录页。 - 今日任务、全部任务、任务详情、添加和编辑。 +- 编辑中的表单和“是否有未保存修改”状态通过 `SavedStateHandle` 恢复;Android 回收并重建进程后仍可继续编辑,保存成功或确认放弃后清除恢复草稿。 - Room 本地缓存和离线操作。 - WorkManager 网络恢复后自动同步。 - 前台 WebSocket 通知。 @@ -126,6 +128,7 @@ GitHub Actions 发布时还支持 `ANDROID_KEYSTORE_BASE64`,详见 [GitHub 发 - Android 桌面小组件,支持清晰黑字和透明白字两套样式。 - 分享文本快速添加任务。 - 导入 TaskBridge 备份 JSON。 +- 登录、注册和设置中的帮助链接在系统没有可用浏览器时显示错误提示,不会导致 App 崩溃。 ## 小组件测试 diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 157b45e..fa077ce 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -24,8 +24,6 @@ android { val taskBridgeBaseUrl = taskBridgeProperty("TASKBRIDGE_BASE_URL", "https://taskbridge.example.invalid/api/v1/") val taskBridgeWebSocketUrl = taskBridgeProperty("TASKBRIDGE_WS_URL", "wss://taskbridge.example.invalid/ws/sync") - val taskBridgeUsesCleartext = - taskBridgeBaseUrl.startsWith("http://") || taskBridgeWebSocketUrl.startsWith("ws://") val releaseEndpointLooksPlaceholder = taskBridgeBaseUrl.contains("example.invalid") || taskBridgeWebSocketUrl.contains("example.invalid") val releaseKeystorePath = taskBridgeProperty("ANDROID_KEYSTORE_PATH", System.getenv("ANDROID_KEYSTORE_PATH") ?: "") @@ -43,14 +41,14 @@ android { applicationId = "com.taskbridge.app" minSdk = 26 targetSdk = 35 - versionCode = 4 - versionName = "0.1.7" + versionCode = 5 + versionName = "0.1.8" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" buildConfigField("String", "TASKBRIDGE_BASE_URL", "\"$taskBridgeBaseUrl\"") buildConfigField("String", "TASKBRIDGE_WS_URL", "\"$taskBridgeWebSocketUrl\"") manifestPlaceholders["allowBackup"] = true - manifestPlaceholders["usesCleartextTraffic"] = taskBridgeUsesCleartext + manifestPlaceholders["usesCleartextTraffic"] = true } compileOptions { @@ -106,7 +104,7 @@ android { signingConfig = signingConfigs.getByName("release") } manifestPlaceholders["allowBackup"] = false - manifestPlaceholders["usesCleartextTraffic"] = taskBridgeUsesCleartext + manifestPlaceholders["usesCleartextTraffic"] = true proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro", diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 1d99d05..d1fbd10 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,6 +1,7 @@ + + + + + + + + + + diff --git a/android/app/src/main/java/com/taskbridge/app/AppContainer.kt b/android/app/src/main/java/com/taskbridge/app/AppContainer.kt index 7187826..bf212a2 100644 --- a/android/app/src/main/java/com/taskbridge/app/AppContainer.kt +++ b/android/app/src/main/java/com/taskbridge/app/AppContainer.kt @@ -3,6 +3,7 @@ package com.taskbridge.app import android.content.Context import com.taskbridge.app.data.datastore.TokenDataStore import com.taskbridge.app.data.local.AppDatabase +import com.taskbridge.app.data.local.WorkspaceMigrationCoordinator import com.taskbridge.app.data.remote.RetrofitClient import com.taskbridge.app.data.repository.AuthRepository import com.taskbridge.app.data.repository.SyncRepository @@ -17,18 +18,32 @@ class AppContainer(context: Context) { val tokenDataStore = TokenDataStore(appContext) private val apiService = RetrofitClient.create(appContext, tokenDataStore) + private val workspaceMigration = WorkspaceMigrationCoordinator( + database, + database.taskDao(), + database.syncQueueDao(), + tokenDataStore, + ) private val deviceIdProvider = DeviceIdProvider(appContext) val authRepository = AuthRepository(apiService, tokenDataStore, deviceIdProvider) - val taskRepository = TaskRepository(apiService, database, database.taskDao(), database.syncQueueDao(), tokenDataStore) + val taskRepository = TaskRepository( + apiService, + database, + database.taskDao(), + database.syncQueueDao(), + tokenDataStore, + workspaceMigration, + ) val syncRepository = SyncRepository( apiService = apiService, database = database, taskDao = database.taskDao(), syncQueueDao = database.syncQueueDao(), tokenDataStore = tokenDataStore, + workspaceMigration = workspaceMigration, ) - val syncManager = SyncManager(appContext, syncRepository, tokenDataStore) - val reminderManager = ReminderManager(appContext, tokenDataStore) + val reminderManager = ReminderManager(appContext, tokenDataStore, taskRepository) + val syncManager = SyncManager(appContext, syncRepository, tokenDataStore, reminderManager) } diff --git a/android/app/src/main/java/com/taskbridge/app/MainActivity.kt b/android/app/src/main/java/com/taskbridge/app/MainActivity.kt index 52e01f6..5aa1361 100644 --- a/android/app/src/main/java/com/taskbridge/app/MainActivity.kt +++ b/android/app/src/main/java/com/taskbridge/app/MainActivity.kt @@ -10,8 +10,10 @@ import android.content.pm.PackageManager import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.Box import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.lightColorScheme import androidx.compose.material3.Surface @@ -26,8 +28,11 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.graphics.Color import androidx.compose.ui.Modifier +import androidx.compose.ui.Alignment import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat @@ -35,8 +40,8 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.lifecycleScope import androidx.navigation.NavHostController -import androidx.navigation.NavGraph.Companion.findStartDestination import androidx.navigation.NavType import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable @@ -45,7 +50,12 @@ import androidx.navigation.navArgument import com.google.gson.JsonParser import com.taskbridge.app.ui.editor.EditorEntryPreset import com.taskbridge.app.ui.editor.EditorScreen +import com.taskbridge.app.ui.editor.requireSharedPayloadWithinLimit import com.taskbridge.app.ui.editor.sharedTextToEditorDraft +import com.taskbridge.app.ui.editor.sharedPayloadReadLimit +import com.taskbridge.app.ui.editor.readUtf8TextWithLimit +import com.taskbridge.app.ui.editor.ExternalEditorNavigationDecision +import com.taskbridge.app.ui.editor.externalEditorNavigationDecision import com.taskbridge.app.ui.i18n.AppLanguage import com.taskbridge.app.ui.i18n.TaskBridgeLanguageProvider import com.taskbridge.app.ui.editor.EditorViewModelFactory @@ -55,20 +65,39 @@ import com.taskbridge.app.ui.login.RegisterScreen import com.taskbridge.app.ui.login.RegisterViewModelFactory import com.taskbridge.app.ui.settings.SettingsScreen import com.taskbridge.app.ui.task.TaskDetailScreen +import com.taskbridge.app.ui.task.TaskListFilter import com.taskbridge.app.ui.task.TaskListScreen import com.taskbridge.app.ui.task.TaskListViewModelFactory +import com.taskbridge.app.data.datastore.WorkspaceIdentity import com.taskbridge.app.utils.ShanghaiTime import com.taskbridge.app.widget.TodayTaskWidgetUpdateWorker import com.taskbridge.app.widget.WidgetConstants import kotlinx.coroutines.launch -import java.io.ByteArrayOutputStream +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.combine + +private const val WIDGET_LAUNCH_HANDLED_STATE_KEY = "taskbridge.widget_launch_handled" +private const val SHARED_TEXT_HANDLED_STATE_KEY = "taskbridge.shared_text_handled" + +private sealed interface AuthenticationState { + data object Loading : AuthenticationState + data class Ready(val token: String?, val workspace: WorkspaceIdentity?) : AuthenticationState +} -private const val MAX_SHARED_TEXT_BYTES = 20_000_000 +private data class EditorNavigationGuardState( + val hasUnsavedChanges: Boolean = false, + val discardDraft: (() -> Unit)? = null, +) private object Routes { const val Login = "login" const val Register = "register" const val Tasks = "tasks" + const val TasksPattern = "tasks?filter={filter}" const val Today = "today" const val Editor = "editor" const val EditorToday = "editor-today" @@ -77,6 +106,7 @@ private object Routes { const val SettingsPattern = "settings?section={section}" const val TaskDetailPattern = "task-detail/{localId}" + fun tasks(filter: String? = null): String = if (filter.isNullOrBlank()) Tasks else "$Tasks?filter=$filter" fun settings(section: String? = null): String = if (section.isNullOrBlank()) Settings else "$Settings?section=$section" fun editTask(localId: String): String = "editor/$localId" fun taskDetail(localId: String): String = "task-detail/$localId" @@ -85,34 +115,64 @@ private object Routes { class MainActivity : ComponentActivity() { private var widgetLaunchState: MutableState? = null private var sharedTextState: MutableState? = null + private var sharedTextErrorState: MutableState? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val container = AppContainer(applicationContext) container.reminderManager.ensureChannel() TodayTaskWidgetUpdateWorker.enqueue(this) - widgetLaunchState = mutableStateOf(WidgetLaunchTarget.fromIntent(intent)) - sharedTextState = mutableStateOf(sharedTextFromIntent(applicationContext, intent)) + val widgetLaunchHandled = savedInstanceState?.getBoolean(WIDGET_LAUNCH_HANDLED_STATE_KEY) == true + widgetLaunchState = mutableStateOf( + if (widgetLaunchHandled) null else WidgetLaunchTarget.fromIntent(intent), + ) + val sharedTextHandled = savedInstanceState?.getBoolean(SHARED_TEXT_HANDLED_STATE_KEY) == true + sharedTextState = mutableStateOf(null) + sharedTextErrorState = mutableStateOf(false) setContent { TaskBridgeTheme { val fallbackWidgetLaunchState = remember { mutableStateOf(null) } val fallbackSharedTextState = remember { mutableStateOf(null) } + val fallbackSharedTextErrorState = remember { mutableStateOf(false) } TaskBridgeApp( container, widgetLaunchState ?: fallbackWidgetLaunchState, sharedTextState ?: fallbackSharedTextState, + sharedTextErrorState ?: fallbackSharedTextErrorState, onRequestNotificationPermission = ::requestNotificationPermissionIfNeeded, ) } } + if (!sharedTextHandled) loadSharedText(intent) + } + + override fun onSaveInstanceState(outState: Bundle) { + outState.putBoolean(WIDGET_LAUNCH_HANDLED_STATE_KEY, widgetLaunchState?.value == null) + outState.putBoolean(SHARED_TEXT_HANDLED_STATE_KEY, sharedTextState?.value == null) + super.onSaveInstanceState(outState) } override fun onNewIntent(intent: android.content.Intent) { super.onNewIntent(intent) setIntent(intent) widgetLaunchState?.value = WidgetLaunchTarget.fromIntent(intent) - sharedTextState?.value = sharedTextFromIntent(applicationContext, intent) + loadSharedText(intent) + } + + private fun loadSharedText(intent: Intent?) { + lifecycleScope.launch { + val result = withContext(Dispatchers.IO) { + runCatching { sharedTextFromIntent(applicationContext, intent) } + } + result.onSuccess { text -> + sharedTextErrorState?.value = false + sharedTextState?.value = text + }.onFailure { + sharedTextState?.value = null + sharedTextErrorState?.value = true + } + } } private fun requestNotificationPermissionIfNeeded() { @@ -136,41 +196,124 @@ fun TaskBridgeApp( container: AppContainer, widgetLaunchState: MutableState, sharedTextState: MutableState, + sharedTextErrorState: MutableState, onRequestNotificationPermission: () -> Unit, ) { val navController = rememberNavController() val appContext = LocalContext.current.applicationContext - val token by container.tokenDataStore.accessToken.collectAsStateWithLifecycle(initialValue = null) + val authenticationStateFlow: Flow = remember(container.tokenDataStore) { + flow { + container.tokenDataStore.initializeLegacyWorkspaceOwnership() + emitAll( + combine( + container.tokenDataStore.accessToken, + container.tokenDataStore.currentWorkspace, + ) { token, workspace -> + AuthenticationState.Ready(token, workspace) + }, + ) + } + } + val authenticationState by authenticationStateFlow.collectAsStateWithLifecycle( + initialValue = AuthenticationState.Loading, + ) val languageCode by container.tokenDataStore.language.collectAsStateWithLifecycle(initialValue = AppLanguage.Chinese.code) val language = AppLanguage.fromCode(languageCode) val widgetLaunchTarget = widgetLaunchState.value val sharedText = sharedTextState.value val scope = rememberCoroutineScope() + val editorNavigationGuard = remember { mutableStateOf(EditorNavigationGuardState()) } + val pendingExternalRoute = remember { mutableStateOf(null) } + var continueWithCachedWorkspace by rememberSaveable { mutableStateOf(false) } - ForegroundWebSocketLifecycle(container) + if (authenticationState is AuthenticationState.Loading) { + TaskBridgeLanguageProvider(language) { + AuthenticationLoadingScreen() + } + return + } - LaunchedEffect(token, widgetLaunchTarget) { - if (token.isNullOrBlank()) return@LaunchedEffect + val readyAuthentication = authenticationState as AuthenticationState.Ready + val token = readyAuthentication.token + val workspace = readyAuthentication.workspace + val localWorkspaceMode = token.isNullOrBlank() && workspace != null && continueWithCachedWorkspace + val workspaceActive = !token.isNullOrBlank() || localWorkspaceMode + val startDestination = remember(navController) { + if (token.isNullOrBlank()) Routes.Login else Routes.Today + } + if (!token.isNullOrBlank()) { + ForegroundWebSocketLifecycle(container) + } + + LaunchedEffect(token, workspace, continueWithCachedWorkspace) { + if (!token.isNullOrBlank()) { + continueWithCachedWorkspace = false + } else if (!localWorkspaceMode) { + val currentRoute = navController.currentDestination?.route + if (currentRoute != null && currentRoute != Routes.Login && currentRoute != Routes.Register) { + navController.navigateToAuthentication() + } + } + } + + LaunchedEffect(workspaceActive, widgetLaunchTarget) { + if (!workspaceActive) return@LaunchedEffect val targetRoute = widgetLaunchTarget?.toRoute() ?: Routes.Today - if (widgetLaunchTarget != null || navController.currentDestination?.route == Routes.Login) { - navController.navigate(targetRoute) { - popUpTo(Routes.Login) { inclusive = true } + val currentRoute = navController.currentDestination?.route + if ( + widgetLaunchTarget != null || + currentRoute == Routes.Login || currentRoute == Routes.Register + ) { + if ( + externalEditorNavigationDecision( + currentRoute = currentRoute, + hasUnsavedChanges = editorNavigationGuard.value.hasUnsavedChanges, + ) == ExternalEditorNavigationDecision.ConfirmDiscard + ) { + pendingExternalRoute.value = targetRoute + } else { + navController.navigateAfterAuthentication(targetRoute) } widgetLaunchState.value = null } } - LaunchedEffect(token, sharedText) { - if (token.isNullOrBlank() || sharedText.isNullOrBlank()) return@LaunchedEffect + LaunchedEffect(workspaceActive, sharedText) { + if (!workspaceActive || sharedText.isNullOrBlank()) return@LaunchedEffect if (isTaskBridgeBackupText(sharedText)) return@LaunchedEffect - navController.navigate(Routes.Editor) + val currentRoute = navController.currentDestination?.route + if (currentRoute == Routes.Login || currentRoute == Routes.Register) { + navController.navigateAfterAuthentication(Routes.Editor) + } else { + navController.navigate(Routes.Editor) + } } TaskBridgeLanguageProvider(language) { - TaskBridgeNavHost(container, navController, sharedTextState, language, onRequestNotificationPermission) + TaskBridgeNavHost( + container = container, + navController = navController, + sharedTextState = sharedTextState, + language = language, + startDestination = startDestination, + localWorkspaceMode = localWorkspaceMode, + canContinueOffline = workspace != null && token.isNullOrBlank(), + onContinueOffline = { + if (workspace != null) { + continueWithCachedWorkspace = true + navController.navigateAfterAuthentication(Routes.Today) + } + }, + onSignInToSync = { + continueWithCachedWorkspace = false + navController.navigateToAuthentication() + }, + onRequestNotificationPermission = onRequestNotificationPermission, + onEditorNavigationGuardChanged = { editorNavigationGuard.value = it }, + ) val pendingBackupText = sharedText - if (!token.isNullOrBlank() && pendingBackupText != null && isTaskBridgeBackupText(pendingBackupText)) { + if (workspaceActive && pendingBackupText != null && isTaskBridgeBackupText(pendingBackupText)) { SharedBackupImportDialog( isEnglish = language == AppLanguage.English, onDismiss = { sharedTextState.value = null }, @@ -187,6 +330,68 @@ fun TaskBridgeApp( }, ) } + if (sharedTextErrorState.value) { + AlertDialog( + onDismissRequest = { sharedTextErrorState.value = false }, + title = { Text(if (language == AppLanguage.English) "Unable to import shared text" else "无法导入分享内容") }, + text = { + Text( + if (language == AppLanguage.English) { + "The shared text is unreadable or larger than 1 MiB. Choose a smaller plain-text file." + } else { + "分享内容无法读取或超过 1 MiB,请选择更小的纯文本文件。" + }, + ) + }, + confirmButton = { + TextButton(onClick = { sharedTextErrorState.value = false }) { + Text(if (language == AppLanguage.English) "OK" else "知道了") + } + }, + ) + } + pendingExternalRoute.value?.let { route -> + AlertDialog( + onDismissRequest = { pendingExternalRoute.value = null }, + title = { Text(if (language == AppLanguage.English) "Discard unsaved changes?" else "放弃未保存的修改?") }, + text = { + Text( + if (language == AppLanguage.English) { + "Opening this task will leave the current editor. Your unsaved changes are still available if you keep editing." + } else { + "打开该任务会离开当前编辑页。选择继续编辑可保留当前未保存内容。" + }, + ) + }, + confirmButton = { + Button( + onClick = { + editorNavigationGuard.value.discardDraft?.invoke() + editorNavigationGuard.value = EditorNavigationGuardState() + pendingExternalRoute.value = null + navController.navigateAfterAuthentication(route) + }, + ) { + Text(if (language == AppLanguage.English) "Discard and open" else "放弃并打开") + } + }, + dismissButton = { + TextButton(onClick = { pendingExternalRoute.value = null }) { + Text(if (language == AppLanguage.English) "Keep editing" else "继续编辑") + } + }, + ) + } + } +} + +@Composable +private fun AuthenticationLoadingScreen() { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() } } @@ -229,7 +434,13 @@ private fun TaskBridgeNavHost( navController: NavHostController, sharedTextState: MutableState, language: AppLanguage, + startDestination: String, + localWorkspaceMode: Boolean, + canContinueOffline: Boolean, + onContinueOffline: () -> Unit, + onSignInToSync: () -> Unit, onRequestNotificationPermission: () -> Unit, + onEditorNavigationGuardChanged: (EditorNavigationGuardState) -> Unit, ) { val context = LocalContext.current val scope = rememberCoroutineScope() @@ -237,23 +448,23 @@ private fun TaskBridgeNavHost( initialValue = ShanghaiTime.DEFAULT_ZONE_ID, ) - NavHost(navController = navController, startDestination = Routes.Login) { + NavHost(navController = navController, startDestination = startDestination) { composable(Routes.Login) { val viewModel = viewModel( factory = LoginViewModelFactory(container.authRepository, container.syncManager, container.tokenDataStore), ) LoginScreen( viewModel = viewModel, + canContinueOffline = canContinueOffline, onLanguageChange = { nextLanguage -> scope.launch { container.tokenDataStore.saveLanguage(nextLanguage.code) } }, onLoginSuccess = { - navController.navigate(Routes.Today) { - popUpTo(Routes.Login) { inclusive = true } - } + navController.navigateAfterAuthentication(Routes.Today) }, + onContinueOffline = onContinueOffline, onRegisterClick = { navController.navigate(Routes.Register) }, ) } @@ -270,15 +481,19 @@ private fun TaskBridgeNavHost( } }, onRegisterSuccess = { - navController.navigate(Routes.Today) { - popUpTo(Routes.Login) { inclusive = true } - } + navController.navigateAfterAuthentication(Routes.Today) }, onLoginClick = { navController.popBackStack() }, ) } - composable(Routes.Tasks) { + composable( + route = Routes.TasksPattern, + arguments = listOf(navArgument("filter") { + type = NavType.StringType + defaultValue = "" + }), + ) { backStackEntry -> val viewModel = viewModel( factory = TaskListViewModelFactory( context.applicationContext, @@ -290,12 +505,15 @@ private fun TaskBridgeNavHost( TaskListScreen( viewModel = viewModel, todayOnly = false, + localWorkspaceMode = localWorkspaceMode, + initialFilter = taskListFilterFromRoute(backStackEntry.arguments?.getString("filter")), onAddClick = { navController.navigate(Routes.Editor) }, onTaskClick = { navController.navigate(Routes.taskDetail(it)) }, onEditClick = { navController.navigate(Routes.editTask(it)) }, onSettingsClick = { navController.navigate(Routes.Settings) }, onSyncDetailsClick = { navController.navigate(Routes.settings("sync-recovery")) }, - onTodayClick = { navController.navigate(Routes.Today) }, + onSignInToSync = onSignInToSync, + onTodayClick = { navController.navigateTopLevel(Routes.Today) }, onAllClick = { }, ) } @@ -312,13 +530,15 @@ private fun TaskBridgeNavHost( TaskListScreen( viewModel = viewModel, todayOnly = true, + localWorkspaceMode = localWorkspaceMode, onAddClick = { navController.navigate(Routes.EditorToday) }, onTaskClick = { navController.navigate(Routes.taskDetail(it)) }, onEditClick = { navController.navigate(Routes.editTask(it)) }, onSettingsClick = { navController.navigate(Routes.Settings) }, onSyncDetailsClick = { navController.navigate(Routes.settings("sync-recovery")) }, + onSignInToSync = onSignInToSync, onTodayClick = { }, - onAllClick = { navController.navigate(Routes.Tasks) }, + onAllClick = { navController.navigateTopLevel(Routes.tasks()) }, ) } @@ -330,6 +550,7 @@ private fun TaskBridgeNavHost( displayTimeZone = displayTimeZone, sharedTextState = sharedTextState, onRequestNotificationPermission = onRequestNotificationPermission, + onNavigationGuardChanged = onEditorNavigationGuardChanged, onSaved = { sharedTextState.value = null navController.popBackStack() @@ -349,6 +570,7 @@ private fun TaskBridgeNavHost( displayTimeZone = displayTimeZone, sharedTextState = sharedTextState, onRequestNotificationPermission = onRequestNotificationPermission, + onNavigationGuardChanged = onEditorNavigationGuardChanged, onSaved = { sharedTextState.value = null navController.popBackStack() @@ -369,6 +591,7 @@ private fun TaskBridgeNavHost( displayTimeZone = displayTimeZone, sharedTextState = sharedTextState, onRequestNotificationPermission = onRequestNotificationPermission, + onNavigationGuardChanged = onEditorNavigationGuardChanged, onSaved = { sharedTextState.value = null navController.popBackStack() @@ -423,14 +646,11 @@ private fun TaskBridgeNavHost( } }, onBack = { navController.popBackStack() }, + onOpenConflictTasks = { navController.navigate(Routes.tasks("conflict")) }, onLogout = { scope.launch { container.authRepository.logout() - navController.navigate(Routes.Login) { - popUpTo(navController.graph.findStartDestination().id) { - inclusive = true - } - } + navController.navigateToAuthentication() } }, ) @@ -438,6 +658,35 @@ private fun TaskBridgeNavHost( } } +private fun NavHostController.navigateTopLevel(route: String) { + navigate(route) { + popUpTo(Routes.Today) { + saveState = true + } + launchSingleTop = true + restoreState = true + } +} + +private fun NavHostController.navigateAfterAuthentication(targetRoute: String) { + navigate(Routes.Today) { + popUpTo(graph.id) { inclusive = true } + launchSingleTop = true + } + if (targetRoute != Routes.Today) { + navigate(targetRoute) { + launchSingleTop = true + } + } +} + +private fun NavHostController.navigateToAuthentication() { + navigate(Routes.Login) { + popUpTo(graph.id) { inclusive = true } + launchSingleTop = true + } +} + @Composable private fun EditorRoute( container: AppContainer, @@ -446,6 +695,7 @@ private fun EditorRoute( displayTimeZone: String, sharedTextState: MutableState, onRequestNotificationPermission: () -> Unit, + onNavigationGuardChanged: (EditorNavigationGuardState) -> Unit, onSaved: () -> Unit, onCancel: () -> Unit, ) { @@ -458,6 +708,18 @@ private fun EditorRoute( container.tokenDataStore, ), ) + val editorState by viewModel.uiState.collectAsStateWithLifecycle() + LaunchedEffect(editorState.hasUnsavedChanges) { + onNavigationGuardChanged( + EditorNavigationGuardState( + hasUnsavedChanges = editorState.hasUnsavedChanges, + discardDraft = viewModel::discardDraft, + ), + ) + } + DisposableEffect(viewModel) { + onDispose { onNavigationGuardChanged(EditorNavigationGuardState()) } + } LaunchedEffect(localId, entryPreset) { if (!localId.isNullOrBlank()) { viewModel.loadTask(localId) @@ -471,6 +733,7 @@ private fun EditorRoute( val draft = sharedTextToEditorDraft(text) viewModel.updateTitle(draft.title) viewModel.updateContent(draft.content) + sharedTextState.value = null } EditorScreen( viewModel = viewModel, @@ -484,11 +747,15 @@ private fun EditorRoute( private fun sharedTextFromIntent(context: Context, intent: Intent?): String? { val mimeType = intent?.type ?: return null if (intent.action != Intent.ACTION_SEND || mimeType !in setOf("text/plain", "application/json")) return null - val inlineText = intent.getStringExtra(Intent.EXTRA_TEXT)?.trim()?.takeIf { it.isNotBlank() } - if (inlineText != null) return inlineText - - val streamUri = sharedStreamUri(intent) ?: return null - return readSharedStreamText(context, streamUri)?.trim()?.takeIf { it.isNotBlank() } + val raw = intent.getStringExtra(Intent.EXTRA_TEXT) + ?: sharedStreamUri(intent)?.let { streamUri -> + context.contentResolver.openInputStream(streamUri)?.use { input -> + readUtf8TextWithLimit(input, sharedPayloadReadLimit(mimeType)) + } + } + ?: return null + val text = raw.trim().takeIf { it.isNotBlank() } ?: return null + return requireSharedPayloadWithinLimit(text, isTaskBridgeBackupText(text)) } private fun sharedStreamUri(intent: Intent): Uri? { @@ -501,26 +768,6 @@ private fun sharedStreamUri(intent: Intent): Uri? { ?.uri } -private fun readSharedStreamText(context: Context, uri: Uri): String? { - return runCatching { - context.contentResolver.openInputStream(uri)?.use { input -> - val output = ByteArrayOutputStream() - val buffer = ByteArray(DEFAULT_BUFFER_SIZE) - var totalBytes = 0 - while (true) { - val read = input.read(buffer) - if (read == -1) break - totalBytes += read - if (totalBytes > MAX_SHARED_TEXT_BYTES) { - throw IllegalArgumentException("Shared payload is too large") - } - output.write(buffer, 0, read) - } - output.toString(Charsets.UTF_8.name()) - } - }.getOrNull() -} - private fun isTaskBridgeBackupText(value: String): Boolean { return runCatching { val root = JsonParser.parseString(value).asJsonObject @@ -596,7 +843,7 @@ data class WidgetLaunchTarget( ) { fun toRoute(): String { return when (target) { - WidgetConstants.TARGET_ALL -> Routes.Tasks + WidgetConstants.TARGET_ALL -> Routes.tasks() WidgetConstants.TARGET_ADD -> Routes.Editor WidgetConstants.TARGET_TASK -> localId?.let { Routes.taskDetail(it) } ?: Routes.Today else -> Routes.Today @@ -613,3 +860,10 @@ data class WidgetLaunchTarget( } } } + +private fun taskListFilterFromRoute(filter: String?): TaskListFilter { + return when (filter?.trim()?.lowercase()) { + "conflict" -> TaskListFilter.Conflict + else -> TaskListFilter.All + } +} diff --git a/android/app/src/main/java/com/taskbridge/app/data/datastore/TokenDataStore.kt b/android/app/src/main/java/com/taskbridge/app/data/datastore/TokenDataStore.kt index cce5b50..8443ebd 100644 --- a/android/app/src/main/java/com/taskbridge/app/data/datastore/TokenDataStore.kt +++ b/android/app/src/main/java/com/taskbridge/app/data/datastore/TokenDataStore.kt @@ -3,6 +3,7 @@ package com.taskbridge.app.data.datastore import android.content.Context import android.content.SharedPreferences import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.preferencesDataStore @@ -13,6 +14,7 @@ import com.taskbridge.app.utils.ShanghaiTime import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.withContext @@ -22,14 +24,17 @@ import java.time.ZoneId private val Context.syncPreferences by preferencesDataStore(name = "taskbridge_sync") private val Context.legacyTokenPreferences by preferencesDataStore(name = "taskbridge_tokens") +data class RequestAuthContext( + val workspace: WorkspaceIdentity?, + val apiBaseUrl: String, + val accessToken: String?, +) + class TokenDataStore(context: Context) { private val appContext = context.applicationContext private val securePreferences = createSecurePreferences(appContext) - private val accessTokenState = MutableStateFlow(securePreferences.getString(ACCESS_TOKEN, null)) - private val refreshTokenState = MutableStateFlow(securePreferences.getString(REFRESH_TOKEN, null)) + private val tokenRevision = MutableStateFlow(0L) - val accessToken: Flow = accessTokenState - val refreshToken: Flow = refreshTokenState val currentUserId: Flow = appContext.syncPreferences.data.map { preferences -> preferences[CURRENT_USER_ID] } @@ -46,8 +51,21 @@ class TokenDataStore(context: Context) { normalizeWebSocketUrl(preferences[WEB_SOCKET_URL]) } + val currentWorkspace: Flow = appContext.syncPreferences.data.map { preferences -> + workspaceFromPreferences(preferences) + } + + val accessToken: Flow = combine(currentWorkspace, tokenRevision) { workspace, _ -> + workspace?.let { scopedToken(ACCESS_TOKEN, it) } + } + + val refreshToken: Flow = combine(currentWorkspace, tokenRevision) { workspace, _ -> + workspace?.let { scopedToken(REFRESH_TOKEN, it) } + } + val lastSyncTime: Flow = appContext.syncPreferences.data.map { preferences -> - preferences[LAST_SYNC_TIME] + val workspace = workspaceFromPreferences(preferences) + workspace?.let { preferences[lastSyncTimeKey(it)] } } val language: Flow = appContext.syncPreferences.data.map { preferences -> @@ -55,7 +73,7 @@ class TokenDataStore(context: Context) { } val widgetOpacityPercent: Flow = appContext.syncPreferences.data.map { preferences -> - preferences[WIDGET_OPACITY_PERCENT] ?: DEFAULT_WIDGET_OPACITY_PERCENT + (preferences[WIDGET_OPACITY_PERCENT] ?: DEFAULT_WIDGET_OPACITY_PERCENT).coerceIn(60, 100) } val widgetTaskScope: Flow = appContext.syncPreferences.data.map { preferences -> @@ -71,40 +89,129 @@ class TokenDataStore(context: Context) { } val displayTimeZone: Flow = appContext.syncPreferences.data.map { preferences -> - val userId = preferences[CURRENT_USER_ID] - val userTimeZone = userId?.let { preferences[userDisplayTimeZoneKey(it)] } - normalizeTimeZone(userTimeZone ?: preferences[DISPLAY_TIME_ZONE] ?: DEFAULT_DISPLAY_TIME_ZONE) + val workspace = workspaceFromPreferences(preferences) + val workspaceTimeZone = workspace?.let { preferences[workspaceDisplayTimeZoneKey(it)] } + val legacyUserTimeZone = workspace?.userId?.let { preferences[userDisplayTimeZoneKey(it)] } + effectiveDisplayTimeZone( + workspaceTimeZone ?: legacyUserTimeZone ?: preferences[DISPLAY_TIME_ZONE] ?: DEFAULT_DISPLAY_TIME_ZONE, + ) } val lastBackupImportUndoItems: Flow = appContext.syncPreferences.data.map { preferences -> - preferences[LAST_BACKUP_IMPORT_UNDO_ITEMS] + val workspace = workspaceFromPreferences(preferences) + workspace?.let { + preferences[lastBackupImportUndoItemsKey(it)] ?: preferences[LAST_BACKUP_IMPORT_UNDO_ITEMS] + } } suspend fun hasAccessToken(): Boolean { return accessToken.first().isNullOrBlank().not() } + suspend fun requestAuthContext(): RequestAuthContext { + val preferences = appContext.syncPreferences.data.first() + val workspace = workspaceFromPreferences(preferences) + return RequestAuthContext( + workspace = workspace, + apiBaseUrl = normalizeApiBaseUrl(preferences[API_BASE_URL]), + accessToken = workspace?.let { scopedToken(ACCESS_TOKEN, it) }, + ) + } + + suspend fun initializeLegacyWorkspaceOwnership() { + appContext.syncPreferences.edit { preferences -> + if (preferences[LEGACY_WORKSPACE_OWNERSHIP_INITIALIZED] == true) return@edit + workspaceFromPreferences(preferences)?.let { workspace -> + val userOwnerKey = legacyWorkspaceOwnerKey(workspace.userId) + if (preferences[userOwnerKey].isNullOrBlank()) { + preferences[userOwnerKey] = workspace.id + } + val globalOwnerKey = legacyWorkspaceOwnerKey("legacy") + if (preferences[globalOwnerKey].isNullOrBlank()) { + preferences[globalOwnerKey] = workspace.id + } + if ( + preferences[LAST_SYNC_TIME] != null && + preferences[LEGACY_SYNC_CURSOR_CLAIMED_WORKSPACE].isNullOrBlank() + ) { + preferences[LEGACY_SYNC_CURSOR_CLAIMED_WORKSPACE] = workspace.id + } + } + preferences[LEGACY_WORKSPACE_OWNERSHIP_INITIALIZED] = true + } + } + + suspend fun canClaimLegacyWorkspace( + ownerUserId: String, + workspace: WorkspaceIdentity, + ): Boolean { + val claimedWorkspaceId = appContext.syncPreferences.data.first()[ + legacyWorkspaceOwnerKey(ownerUserId) + ] + return com.taskbridge.app.data.datastore.canClaimLegacyWorkspace( + claimedWorkspaceId, + workspace, + ) + } + suspend fun saveTokens(accessToken: String, refreshToken: String, userId: Int? = null) { + currentWorkspace.first()?.let { claimLegacySyncCursor(it) } + val resolvedUserId = userId?.toString() + ?: currentUserId.first()?.takeIf { it.isNotBlank() } + ?: throw IllegalStateException("active user required") + val workspace = WorkspaceIdentity.create(serverBaseUrl.first(), resolvedUserId) withContext(Dispatchers.IO) { securePreferences.edit() - .putString(ACCESS_TOKEN, accessToken) - .putString(REFRESH_TOKEN, refreshToken) + .putString(scopedSecureKey(ACCESS_TOKEN, workspace), accessToken) + .putString(scopedSecureKey(REFRESH_TOKEN, workspace), refreshToken) + .remove(ACCESS_TOKEN) + .remove(REFRESH_TOKEN) .apply() } - userId?.let { id -> - appContext.syncPreferences.edit { preferences -> - preferences[CURRENT_USER_ID] = id.toString() - } + appContext.syncPreferences.edit { preferences -> + preferences[CURRENT_USER_ID] = resolvedUserId } - accessTokenState.value = accessToken - refreshTokenState.value = refreshToken + tokenRevision.value += 1 clearLegacyTokenPreferences() } suspend fun saveLastSyncTime(serverTime: String) { + val workspace = currentWorkspace.first() ?: return + saveLastSyncTime(workspace, serverTime) + } + + suspend fun saveLastSyncTime(workspace: WorkspaceIdentity, serverTime: String) { + appContext.syncPreferences.edit { preferences -> + preferences[lastSyncTimeKey(workspace)] = serverTime + val legacyClaim = preferences[LEGACY_SYNC_CURSOR_CLAIMED_WORKSPACE] + if (legacyClaim == null || legacyClaim == workspace.id) { + preferences[LEGACY_SYNC_CURSOR_CLAIMED_WORKSPACE] = workspace.id + preferences.remove(LAST_SYNC_TIME) + } + } + } + + suspend fun lastSyncTimeFor(workspace: WorkspaceIdentity): String? { + var resolvedCursor: String? = null appContext.syncPreferences.edit { preferences -> - preferences[LAST_SYNC_TIME] = serverTime + val resolution = resolveWorkspaceSyncCursor( + scopedCursor = preferences[lastSyncTimeKey(workspace)], + legacyCursor = preferences[LAST_SYNC_TIME], + legacyClaimedWorkspaceId = preferences[LEGACY_SYNC_CURSOR_CLAIMED_WORKSPACE], + activeWorkspaceId = workspace.id, + ) + resolvedCursor = resolution.cursor + if (resolution.shouldClaimLegacyCursor) { + resolution.cursor?.let { cursor -> + if (preferences[lastSyncTimeKey(workspace)] == null) { + preferences[lastSyncTimeKey(workspace)] = cursor + } + } + preferences[LEGACY_SYNC_CURSOR_CLAIMED_WORKSPACE] = workspace.id + preferences.remove(LAST_SYNC_TIME) + } } + return resolvedCursor } suspend fun saveLanguage(language: String) { @@ -114,42 +221,44 @@ class TokenDataStore(context: Context) { } suspend fun saveNetworkEndpoints(apiBaseUrl: String, webSocketUrl: String) { - appContext.syncPreferences.edit { preferences -> - preferences[API_BASE_URL] = validateApiBaseUrl(apiBaseUrl) - preferences[WEB_SOCKET_URL] = validateWebSocketUrl(webSocketUrl) - preferences[SERVER_BASE_URL] = inferServerBaseUrlFromApi(validateApiBaseUrl(apiBaseUrl)) - } + val normalizedApiBaseUrl = validateApiBaseUrl(apiBaseUrl) + saveNetworkEndpoints( + NetworkEndpoints( + serverBaseUrl = inferServerBaseUrlFromApi(normalizedApiBaseUrl), + apiBaseUrl = normalizedApiBaseUrl, + webSocketUrl = validateWebSocketUrl(webSocketUrl), + ), + ) } suspend fun saveLastBackupImportUndoItems(raw: String) { + val workspace = currentWorkspace.first() ?: return appContext.syncPreferences.edit { preferences -> val value = raw.trim() if (value.isBlank() || value == "[]") { - preferences.remove(LAST_BACKUP_IMPORT_UNDO_ITEMS) + preferences.remove(lastBackupImportUndoItemsKey(workspace)) } else { - preferences[LAST_BACKUP_IMPORT_UNDO_ITEMS] = value + preferences[lastBackupImportUndoItemsKey(workspace)] = value } + preferences.remove(LAST_BACKUP_IMPORT_UNDO_ITEMS) } } suspend fun clearLastBackupImportUndoItems() { + val workspace = currentWorkspace.first() appContext.syncPreferences.edit { preferences -> + workspace?.let { preferences.remove(lastBackupImportUndoItemsKey(it)) } preferences.remove(LAST_BACKUP_IMPORT_UNDO_ITEMS) } } suspend fun saveServerBaseUrl(serverBaseUrl: String) { - val endpoints = deriveNetworkEndpoints(serverBaseUrl) - appContext.syncPreferences.edit { preferences -> - preferences[SERVER_BASE_URL] = endpoints.serverBaseUrl - preferences[API_BASE_URL] = endpoints.apiBaseUrl - preferences[WEB_SOCKET_URL] = endpoints.webSocketUrl - } + saveNetworkEndpoints(deriveNetworkEndpoints(serverBaseUrl)) } suspend fun saveWidgetOpacityPercent(opacityPercent: Int) { appContext.syncPreferences.edit { preferences -> - preferences[WIDGET_OPACITY_PERCENT] = opacityPercent.coerceIn(0, 100) + preferences[WIDGET_OPACITY_PERCENT] = opacityPercent.coerceIn(60, 100) } } @@ -172,33 +281,80 @@ class TokenDataStore(context: Context) { } suspend fun saveDisplayTimeZone(timeZoneId: String) { - val userId = appContext.syncPreferences.data.first()[CURRENT_USER_ID] - val normalized = normalizeTimeZone(timeZoneId) + val workspace = currentWorkspace.first() + val normalized = effectiveDisplayTimeZone(timeZoneId) appContext.syncPreferences.edit { preferences -> - if (userId.isNullOrBlank()) { + if (workspace == null) { preferences[DISPLAY_TIME_ZONE] = normalized } else { - preferences[userDisplayTimeZoneKey(userId)] = normalized + preferences[workspaceDisplayTimeZoneKey(workspace)] = normalized } } } + suspend fun expireSession() { + val workspace = currentWorkspace.first() + withContext(Dispatchers.IO) { + securePreferences.edit().apply { + workspace?.let { + remove(scopedSecureKey(ACCESS_TOKEN, it)) + remove(scopedSecureKey(REFRESH_TOKEN, it)) + } + remove(ACCESS_TOKEN) + remove(REFRESH_TOKEN) + }.apply() + } + tokenRevision.value += 1 + clearLegacyTokenPreferences() + } + suspend fun clear() { + val workspace = currentWorkspace.first() + workspace?.let { claimLegacySyncCursor(it) } withContext(Dispatchers.IO) { - securePreferences.edit() - .remove(ACCESS_TOKEN) - .remove(REFRESH_TOKEN) - .apply() + securePreferences.edit().apply { + workspace?.let { + remove(scopedSecureKey(ACCESS_TOKEN, it)) + remove(scopedSecureKey(REFRESH_TOKEN, it)) + } + remove(ACCESS_TOKEN) + remove(REFRESH_TOKEN) + }.apply() } - accessTokenState.value = null - refreshTokenState.value = null appContext.syncPreferences.edit { preferences -> preferences.remove(LAST_SYNC_TIME) preferences.remove(CURRENT_USER_ID) } + tokenRevision.value += 1 clearLegacyTokenPreferences() } + private suspend fun saveNetworkEndpoints(endpoints: NetworkEndpoints) { + val currentServerUrl = serverBaseUrl.first() + val resetSession = shouldResetSessionForServerChange(currentServerUrl, endpoints.serverBaseUrl) + if (resetSession) currentWorkspace.first()?.let { claimLegacySyncCursor(it) } + appContext.syncPreferences.edit { preferences -> + preferences[SERVER_BASE_URL] = endpoints.serverBaseUrl + preferences[API_BASE_URL] = endpoints.apiBaseUrl + preferences[WEB_SOCKET_URL] = endpoints.webSocketUrl + if (resetSession) { + preferences.remove(CURRENT_USER_ID) + preferences.remove(LAST_SYNC_TIME) + preferences.remove(LAST_BACKUP_IMPORT_UNDO_ITEMS) + } + } + if (resetSession) tokenRevision.value += 1 + } + + private fun scopedToken(baseKey: String, workspace: WorkspaceIdentity): String? { + return securePreferences.getString(scopedSecureKey(baseKey, workspace), null) + ?: securePreferences.getString(baseKey, null) + } + + private suspend fun claimLegacySyncCursor(workspace: WorkspaceIdentity) { + lastSyncTimeFor(workspace) + } + private suspend fun clearLegacyTokenPreferences() { appContext.legacyTokenPreferences.edit { preferences -> preferences.remove(LEGACY_ACCESS_TOKEN) @@ -221,6 +377,8 @@ class TokenDataStore(context: Context) { val WIDGET_STYLE = stringPreferencesKey("widget_style") val DISPLAY_TIME_ZONE = stringPreferencesKey("display_time_zone") val CURRENT_USER_ID = stringPreferencesKey("current_user_id") + val LEGACY_SYNC_CURSOR_CLAIMED_WORKSPACE = stringPreferencesKey("legacy_sync_cursor_claimed_workspace") + val LEGACY_WORKSPACE_OWNERSHIP_INITIALIZED = booleanPreferencesKey("legacy_workspace_ownership_initialized") val LAST_BACKUP_IMPORT_UNDO_ITEMS = stringPreferencesKey("last_backup_import_undo_items") const val DEFAULT_LANGUAGE = "zh-CN" const val DEFAULT_WIDGET_OPACITY_PERCENT = 78 @@ -339,8 +497,35 @@ private fun defaultServerBaseUrl(): String { private fun userDisplayTimeZoneKey(userId: String) = stringPreferencesKey("display_time_zone_user_$userId") -private fun normalizeTimeZone(timeZoneId: String): String { - return runCatching { ZoneId.of(timeZoneId.trim()).id } +private fun workspaceDisplayTimeZoneKey(workspace: WorkspaceIdentity) = + stringPreferencesKey("display_time_zone_workspace_${workspace.preferenceSuffix}") + +private fun lastSyncTimeKey(workspace: WorkspaceIdentity) = + stringPreferencesKey("last_sync_time_workspace_${workspace.preferenceSuffix}") + +private fun lastBackupImportUndoItemsKey(workspace: WorkspaceIdentity) = + stringPreferencesKey("last_backup_import_undo_items_workspace_${workspace.preferenceSuffix}") + +private fun scopedSecureKey(baseKey: String, workspace: WorkspaceIdentity): String = + "${baseKey}_workspace_${workspace.preferenceSuffix}" + +private fun legacyWorkspaceOwnerKey(ownerUserId: String) = stringPreferencesKey( + "legacy_workspace_owner_${ownerUserId.trim().ifBlank { "legacy" }}", +) + +private fun workspaceFromPreferences( + preferences: androidx.datastore.preferences.core.Preferences, +): WorkspaceIdentity? { + val userId = preferences[stringPreferencesKey("current_user_id")]?.takeIf { it.isNotBlank() } ?: return null + val serverBaseUrl = normalizeStoredServerBaseUrl( + preferences[stringPreferencesKey("server_base_url")] + ?: inferServerBaseUrlFromApi(preferences[stringPreferencesKey("api_base_url")]), + ) + return runCatching { WorkspaceIdentity.create(serverBaseUrl, userId) }.getOrNull() +} + +fun effectiveDisplayTimeZone(timeZoneId: String?): String { + return runCatching { ZoneId.of(timeZoneId.orEmpty().trim()).id } .getOrDefault(ShanghaiTime.DEFAULT_ZONE_ID) } diff --git a/android/app/src/main/java/com/taskbridge/app/data/datastore/WorkspaceIdentity.kt b/android/app/src/main/java/com/taskbridge/app/data/datastore/WorkspaceIdentity.kt new file mode 100644 index 0000000..6ccf24d --- /dev/null +++ b/android/app/src/main/java/com/taskbridge/app/data/datastore/WorkspaceIdentity.kt @@ -0,0 +1,84 @@ +package com.taskbridge.app.data.datastore + +import java.net.URI +import java.io.IOException +import java.security.MessageDigest + +class WorkspaceChangedException : IOException("active workspace changed") + +class WorkspaceIdentity private constructor( + val serverOrigin: String, + val userId: String, +) { + val id: String = "$serverOrigin|$userId" + val preferenceSuffix: String = id.sha256() + + companion object { + fun create(serverUrl: String, userId: String): WorkspaceIdentity { + val normalizedUserId = userId.trim().takeIf { it.isNotBlank() } + ?: throw IllegalArgumentException("user id required") + return WorkspaceIdentity( + serverOrigin = normalizeServerOrigin(serverUrl), + userId = normalizedUserId, + ) + } + } + + override fun equals(other: Any?): Boolean { + return other is WorkspaceIdentity && other.serverOrigin == serverOrigin && other.userId == userId + } + + override fun hashCode(): Int = 31 * serverOrigin.hashCode() + userId.hashCode() + + override fun toString(): String = "WorkspaceIdentity(serverOrigin=$serverOrigin, userId=$userId)" +} + +fun normalizeServerOrigin(serverUrl: String): String { + val raw = serverUrl.trim().takeIf { it.isNotBlank() } + ?: throw IllegalArgumentException("server url required") + val candidate = if (raw.contains("://")) raw else "http://$raw" + val uri = runCatching { URI(candidate) } + .getOrElse { throw IllegalArgumentException("invalid server url", it) } + val scheme = uri.scheme?.lowercase() + if (scheme != "http" && scheme != "https") { + throw IllegalArgumentException("invalid server url") + } + val host = uri.host?.lowercase() ?: throw IllegalArgumentException("invalid server url") + val normalizedPort = when { + scheme == "http" && uri.port == 80 -> -1 + scheme == "https" && uri.port == 443 -> -1 + else -> uri.port + } + return URI(scheme, null, host, normalizedPort, null, null, null).toASCIIString() +} + +fun legacyWorkspaceId(ownerUserId: String): String { + val normalizedOwner = ownerUserId.trim().ifBlank { "legacy" } + return "legacy:$normalizedOwner" +} + +fun canClaimLegacyWorkspace( + claimedWorkspaceId: String?, + workspace: WorkspaceIdentity, +): Boolean = claimedWorkspaceId?.trim()?.takeIf { it.isNotBlank() } == workspace.id + +fun shouldResetSessionForServerChange(currentServerUrl: String, nextServerUrl: String): Boolean { + return normalizeServerOrigin(currentServerUrl) != normalizeServerOrigin(nextServerUrl) +} + +fun requireMatchingWorkspace(expectedWorkspaceId: String, currentWorkspace: WorkspaceIdentity?) { + if (currentWorkspace?.id != expectedWorkspaceId) throw WorkspaceChangedException() +} + +fun canApplyTokenRefresh( + expectedWorkspaceId: String, + refreshTokenUsed: String, + currentWorkspace: WorkspaceIdentity?, + currentRefreshToken: String?, +): Boolean = currentWorkspace?.id == expectedWorkspaceId && currentRefreshToken == refreshTokenUsed + +private fun String.sha256(): String { + return MessageDigest.getInstance("SHA-256") + .digest(toByteArray(Charsets.UTF_8)) + .joinToString("") { byte -> "%02x".format(byte) } +} diff --git a/android/app/src/main/java/com/taskbridge/app/data/datastore/WorkspaceSyncCursorPolicy.kt b/android/app/src/main/java/com/taskbridge/app/data/datastore/WorkspaceSyncCursorPolicy.kt new file mode 100644 index 0000000..a32d8f2 --- /dev/null +++ b/android/app/src/main/java/com/taskbridge/app/data/datastore/WorkspaceSyncCursorPolicy.kt @@ -0,0 +1,22 @@ +package com.taskbridge.app.data.datastore + +data class WorkspaceSyncCursorResolution( + val cursor: String?, + val shouldClaimLegacyCursor: Boolean, +) + +fun resolveWorkspaceSyncCursor( + scopedCursor: String?, + legacyCursor: String?, + legacyClaimedWorkspaceId: String?, + activeWorkspaceId: String, +): WorkspaceSyncCursorResolution { + val canClaimLegacy = legacyCursor != null && legacyClaimedWorkspaceId == activeWorkspaceId + val cursor = scopedCursor ?: legacyCursor?.takeIf { + legacyClaimedWorkspaceId == activeWorkspaceId + } + return WorkspaceSyncCursorResolution( + cursor = cursor, + shouldClaimLegacyCursor = canClaimLegacy, + ) +} diff --git a/android/app/src/main/java/com/taskbridge/app/data/local/AppDatabase.kt b/android/app/src/main/java/com/taskbridge/app/data/local/AppDatabase.kt index a5d836d..0884d7b 100644 --- a/android/app/src/main/java/com/taskbridge/app/data/local/AppDatabase.kt +++ b/android/app/src/main/java/com/taskbridge/app/data/local/AppDatabase.kt @@ -9,9 +9,11 @@ import androidx.room.migration.Migration import androidx.sqlite.db.SupportSQLiteDatabase import java.util.concurrent.TimeUnit +const val APP_DATABASE_VERSION = 7 + @Database( entities = [TaskEntity::class, SyncQueueEntity::class], - version = 6, + version = APP_DATABASE_VERSION, exportSchema = false, ) abstract class AppDatabase : RoomDatabase() { @@ -32,7 +34,14 @@ abstract class AppDatabase : RoomDatabase() { ) .setJournalMode(RoomDatabase.JournalMode.WRITE_AHEAD_LOGGING) .setAutoCloseTimeout(5, TimeUnit.MINUTES) - .addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6) + .addMigrations( + MIGRATION_1_2, + MIGRATION_2_3, + MIGRATION_3_4, + MIGRATION_4_5, + MIGRATION_5_6, + MIGRATION_6_7, + ) .build() .also { instance = it } } @@ -110,5 +119,29 @@ abstract class AppDatabase : RoomDatabase() { db.execSQL("ALTER TABLE tasks ADD COLUMN conflictLocalJson TEXT") } } + + private val MIGRATION_6_7 = object : Migration(6, 7) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE tasks ADD COLUMN workspaceId TEXT NOT NULL DEFAULT 'legacy'") + db.execSQL("ALTER TABLE sync_queue ADD COLUMN workspaceId TEXT NOT NULL DEFAULT 'legacy'") + db.execSQL("UPDATE tasks SET workspaceId = 'legacy:' || ownerUserId") + db.execSQL("UPDATE sync_queue SET workspaceId = 'legacy:' || ownerUserId") + db.execSQL("DROP INDEX IF EXISTS index_tasks_ownerUserId_serverId") + db.execSQL( + "CREATE UNIQUE INDEX IF NOT EXISTS index_tasks_workspaceId_serverId " + + "ON tasks(workspaceId, serverId)", + ) + db.execSQL( + "CREATE INDEX IF NOT EXISTS index_tasks_workspaceId_localId " + + "ON tasks(workspaceId, localId)", + ) + db.execSQL("CREATE INDEX IF NOT EXISTS index_tasks_workspaceId ON tasks(workspaceId)") + db.execSQL("CREATE INDEX IF NOT EXISTS index_sync_queue_workspaceId ON sync_queue(workspaceId)") + db.execSQL( + "CREATE INDEX IF NOT EXISTS index_sync_queue_workspaceId_localId " + + "ON sync_queue(workspaceId, localId)", + ) + } + } } } diff --git a/android/app/src/main/java/com/taskbridge/app/data/local/SyncQueueEntity.kt b/android/app/src/main/java/com/taskbridge/app/data/local/SyncQueueEntity.kt index c997cbf..bc28891 100644 --- a/android/app/src/main/java/com/taskbridge/app/data/local/SyncQueueEntity.kt +++ b/android/app/src/main/java/com/taskbridge/app/data/local/SyncQueueEntity.kt @@ -7,6 +7,8 @@ import androidx.room.PrimaryKey @Entity( tableName = "sync_queue", indices = [ + Index(value = ["workspaceId"]), + Index(value = ["workspaceId", "localId"]), Index(value = ["ownerUserId"]), Index(value = ["ownerUserId", "localId"]), Index(value = ["localId"]), @@ -17,6 +19,7 @@ import androidx.room.PrimaryKey ) data class SyncQueueEntity( @PrimaryKey(autoGenerate = true) val id: Long = 0, + val workspaceId: String, val ownerUserId: String, val localId: String, val serverId: Int?, diff --git a/android/app/src/main/java/com/taskbridge/app/data/local/TaskDao.kt b/android/app/src/main/java/com/taskbridge/app/data/local/TaskDao.kt index 8e470b5..cf1059b 100644 --- a/android/app/src/main/java/com/taskbridge/app/data/local/TaskDao.kt +++ b/android/app/src/main/java/com/taskbridge/app/data/local/TaskDao.kt @@ -32,7 +32,7 @@ interface TaskDao { @Query( """ SELECT * FROM tasks - WHERE ownerUserId = :ownerUserId + WHERE workspaceId = :workspaceId AND isDeleted = 0 ORDER BY CASE WHEN status IN ('completed', 'done') THEN 1 ELSE 0 END, @@ -53,23 +53,23 @@ interface TaskDao { LIMIT :limit """, ) - fun observeActiveTasks(ownerUserId: String, limit: Int, nowTime: String): Flow> + fun observeActiveTasks(workspaceId: String, limit: Int, nowTime: String): Flow> @Query( """ SELECT * FROM tasks - WHERE ownerUserId = :ownerUserId + WHERE workspaceId = :workspaceId AND isDeleted = 1 ORDER BY updatedAt DESC LIMIT :limit """, ) - fun observeDeletedTasks(ownerUserId: String, limit: Int): Flow> + fun observeDeletedTasks(workspaceId: String, limit: Int): Flow> @Query( """ SELECT * FROM tasks - WHERE ownerUserId = :ownerUserId + WHERE workspaceId = :workspaceId AND isDeleted = 0 AND ( (dueTime IS NOT NULL AND datetime(dueTime) >= datetime(:startTime) AND datetime(dueTime) < datetime(:endTime)) @@ -98,7 +98,7 @@ interface TaskDao { """, ) fun observeTodayTasks( - ownerUserId: String, + workspaceId: String, today: String, startTime: String, endTime: String, @@ -109,7 +109,7 @@ interface TaskDao { @Query( """ SELECT * FROM tasks - WHERE ownerUserId = :ownerUserId + WHERE workspaceId = :workspaceId AND isDeleted = 0 AND ( title LIKE '%' || :keyword || '%' @@ -121,26 +121,32 @@ interface TaskDao { LIMIT :limit """, ) - fun observeSearchTasks(ownerUserId: String, keyword: String, limit: Int): Flow> + fun observeSearchTasks(workspaceId: String, keyword: String, limit: Int): Flow> - @Query("SELECT * FROM tasks WHERE ownerUserId = :ownerUserId AND localId = :localId LIMIT 1") - suspend fun getByLocalId(ownerUserId: String, localId: String): TaskEntity? + @Query("SELECT * FROM tasks WHERE workspaceId = :workspaceId AND localId = :localId LIMIT 1") + suspend fun getByLocalId(workspaceId: String, localId: String): TaskEntity? @Query( """ SELECT * FROM tasks - WHERE ownerUserId = :ownerUserId + WHERE workspaceId = :workspaceId AND isDeleted = 0 ORDER BY updatedAt DESC LIMIT :limit """, ) - suspend fun getBackupTasks(ownerUserId: String, limit: Int): List + suspend fun getBackupTasks(workspaceId: String, limit: Int): List + + @Query( + "SELECT * FROM tasks WHERE workspaceId = :workspaceId " + + "ORDER BY updatedAt DESC LIMIT :limit", + ) + suspend fun getTasksForReminderRebuild(workspaceId: String, limit: Int): List @Query( """ SELECT localId, title, status, priority, dueTime, remindTime, plannedDate, completedAt, sortOrder, updatedAt FROM tasks - WHERE ownerUserId = :ownerUserId + WHERE workspaceId = :workspaceId AND isDeleted = 0 AND ( (dueTime IS NOT NULL AND datetime(dueTime) >= datetime(:startTime) AND datetime(dueTime) < datetime(:endTime)) @@ -169,7 +175,7 @@ interface TaskDao { """, ) suspend fun getTodayWidgetTasks( - ownerUserId: String, + workspaceId: String, today: String, startTime: String, endTime: String, @@ -180,7 +186,7 @@ interface TaskDao { @Query( """ SELECT localId, title, status, priority, dueTime, remindTime, plannedDate, completedAt, sortOrder, updatedAt FROM tasks - WHERE ownerUserId = :ownerUserId + WHERE workspaceId = :workspaceId AND isDeleted = 0 ORDER BY CASE WHEN status IN ('completed', 'done') THEN 1 ELSE 0 END, @@ -201,33 +207,33 @@ interface TaskDao { LIMIT :limit """, ) - suspend fun getAllWidgetTasks(ownerUserId: String, limit: Int, nowTime: String): List + suspend fun getAllWidgetTasks(workspaceId: String, limit: Int, nowTime: String): List - @Query("SELECT * FROM tasks WHERE ownerUserId = :ownerUserId AND serverId IN (:serverIds)") - suspend fun getByServerIds(ownerUserId: String, serverIds: List): List + @Query("SELECT * FROM tasks WHERE workspaceId = :workspaceId AND serverId IN (:serverIds)") + suspend fun getByServerIds(workspaceId: String, serverIds: List): List - @Query("SELECT * FROM tasks WHERE ownerUserId = :ownerUserId AND syncStatus != 'synced'") - suspend fun getPendingTasks(ownerUserId: String): List + @Query("SELECT * FROM tasks WHERE workspaceId = :workspaceId AND syncStatus != 'synced'") + suspend fun getPendingTasks(workspaceId: String): List @Query( """ SELECT COUNT(*) FROM tasks - WHERE ownerUserId = :ownerUserId + WHERE workspaceId = :workspaceId AND isDeleted = 0 AND syncStatus = 'conflict' """, ) - suspend fun countConflictTasks(ownerUserId: String): Int + suspend fun countConflictTasks(workspaceId: String): Int @Query( """ SELECT COUNT(*) FROM tasks - WHERE ownerUserId = :ownerUserId + WHERE workspaceId = :workspaceId AND isDeleted = 0 AND syncStatus = 'sync_failed' """, ) - suspend fun countFailedSyncTasks(ownerUserId: String): Int + suspend fun countFailedSyncTasks(workspaceId: String): Int @Upsert suspend fun upsert(task: TaskEntity) @@ -245,11 +251,11 @@ interface TaskDao { conflictLocalJson = NULL, updatedAt = :updatedAt, lastSyncAt = :syncedAt - WHERE ownerUserId = :ownerUserId AND localId = :localId + WHERE workspaceId = :workspaceId AND localId = :localId """, ) suspend fun markSynced( - ownerUserId: String, + workspaceId: String, localId: String, serverId: Int?, version: Int, @@ -257,20 +263,26 @@ interface TaskDao { syncedAt: String, ) - @Query("UPDATE tasks SET syncStatus = :syncStatus WHERE ownerUserId = :ownerUserId AND localId = :localId") - suspend fun updateSyncStatus(ownerUserId: String, localId: String, syncStatus: String) + @Query("UPDATE tasks SET syncStatus = :syncStatus WHERE workspaceId = :workspaceId AND localId = :localId") + suspend fun updateSyncStatus(workspaceId: String, localId: String, syncStatus: String) - @Query("DELETE FROM tasks WHERE ownerUserId = :ownerUserId AND localId = :localId") - suspend fun deleteByLocalId(ownerUserId: String, localId: String) + @Query("DELETE FROM tasks WHERE workspaceId = :workspaceId AND localId = :localId") + suspend fun deleteByLocalId(workspaceId: String, localId: String) - @Query("DELETE FROM tasks WHERE ownerUserId = :ownerUserId") - suspend fun deleteAllForOwner(ownerUserId: String) + @Query("DELETE FROM tasks WHERE workspaceId = :workspaceId") + suspend fun deleteAllForWorkspace(workspaceId: String) + + @Query( + "UPDATE OR IGNORE tasks SET workspaceId = :workspaceId, ownerUserId = :ownerUserId " + + "WHERE workspaceId = :legacyWorkspaceId", + ) + suspend fun claimLegacyWorkspace(legacyWorkspaceId: String, workspaceId: String, ownerUserId: String): Int } @Dao interface SyncQueueDao { - @Query("SELECT * FROM sync_queue WHERE ownerUserId = :ownerUserId AND attemptCount < 8 ORDER BY attemptCount ASC, id ASC LIMIT :limit") - suspend fun pendingChanges(ownerUserId: String, limit: Int): List + @Query("SELECT * FROM sync_queue WHERE workspaceId = :workspaceId AND attemptCount < 8 ORDER BY attemptCount ASC, id ASC LIMIT :limit") + suspend fun pendingChanges(workspaceId: String, limit: Int): List @Query( """ @@ -279,32 +291,40 @@ interface SyncQueueDao { COALESCE(SUM(CASE WHEN attemptCount < 8 THEN 1 ELSE 0 END), 0) AS pending, COALESCE(SUM(CASE WHEN attemptCount >= 8 THEN 1 ELSE 0 END), 0) AS exhausted FROM sync_queue - WHERE ownerUserId = :ownerUserId + WHERE workspaceId = :workspaceId """, ) - suspend fun queueCounts(ownerUserId: String): SyncQueueCounts + suspend fun queueCounts(workspaceId: String): SyncQueueCounts - @Query("SELECT * FROM sync_queue WHERE ownerUserId = :ownerUserId AND attemptCount >= 8 ORDER BY attemptCount DESC, id ASC LIMIT :limit") - suspend fun exhaustedChanges(ownerUserId: String, limit: Int): List + @Query("SELECT * FROM sync_queue WHERE workspaceId = :workspaceId AND attemptCount >= 8 ORDER BY attemptCount DESC, id ASC LIMIT :limit") + suspend fun exhaustedChanges(workspaceId: String, limit: Int): List - @Query("UPDATE sync_queue SET attemptCount = 0 WHERE ownerUserId = :ownerUserId AND attemptCount >= 8") - suspend fun resetExhaustedAttempts(ownerUserId: String): Int + @Query("UPDATE sync_queue SET attemptCount = 0 WHERE workspaceId = :workspaceId AND attemptCount >= 8") + suspend fun resetExhaustedAttempts(workspaceId: String): Int @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun enqueue(change: SyncQueueEntity): Long - @Query("DELETE FROM sync_queue WHERE ownerUserId = :ownerUserId AND id = :id") - suspend fun deleteById(ownerUserId: String, id: Long) + @Query("DELETE FROM sync_queue WHERE workspaceId = :workspaceId AND id = :id") + suspend fun deleteById(workspaceId: String, id: Long) - @Query("DELETE FROM sync_queue WHERE ownerUserId = :ownerUserId AND localId = :localId") - suspend fun deleteByLocalId(ownerUserId: String, localId: String) + @Query("DELETE FROM sync_queue WHERE workspaceId = :workspaceId AND localId = :localId") + suspend fun deleteByLocalId(workspaceId: String, localId: String) - @Query("DELETE FROM sync_queue WHERE ownerUserId = :ownerUserId") - suspend fun deleteAllForOwner(ownerUserId: String) + @Query("DELETE FROM sync_queue WHERE workspaceId = :workspaceId") + suspend fun deleteAllForWorkspace(workspaceId: String) @Delete suspend fun delete(change: SyncQueueEntity) - @Query("UPDATE sync_queue SET attemptCount = attemptCount + 1 WHERE ownerUserId = :ownerUserId AND id = :id") - suspend fun incrementAttempt(ownerUserId: String, id: Long) + @Query("UPDATE sync_queue SET attemptCount = attemptCount + 1 WHERE workspaceId = :workspaceId AND id = :id") + suspend fun incrementAttempt(workspaceId: String, id: Long) + + @Query( + "UPDATE sync_queue SET workspaceId = :workspaceId, ownerUserId = :ownerUserId " + + "WHERE workspaceId = :legacyWorkspaceId " + + "AND EXISTS (SELECT 1 FROM tasks WHERE tasks.workspaceId = :workspaceId " + + "AND tasks.localId = sync_queue.localId)", + ) + suspend fun claimLegacyWorkspace(legacyWorkspaceId: String, workspaceId: String, ownerUserId: String): Int } diff --git a/android/app/src/main/java/com/taskbridge/app/data/local/TaskEntity.kt b/android/app/src/main/java/com/taskbridge/app/data/local/TaskEntity.kt index e90640b..4568b0e 100644 --- a/android/app/src/main/java/com/taskbridge/app/data/local/TaskEntity.kt +++ b/android/app/src/main/java/com/taskbridge/app/data/local/TaskEntity.kt @@ -7,7 +7,9 @@ import androidx.room.PrimaryKey @Entity( tableName = "tasks", indices = [ - Index(value = ["ownerUserId", "serverId"], unique = true), + Index(value = ["workspaceId", "serverId"], unique = true), + Index(value = ["workspaceId", "localId"]), + Index(value = ["workspaceId"]), Index(value = ["ownerUserId"]), Index(value = ["syncStatus"]), Index(value = ["dueTime"]), @@ -20,6 +22,7 @@ import androidx.room.PrimaryKey ) data class TaskEntity( @PrimaryKey val localId: String, + val workspaceId: String, val ownerUserId: String, val serverId: Int?, val title: String, diff --git a/android/app/src/main/java/com/taskbridge/app/data/local/WorkspaceMigrationCoordinator.kt b/android/app/src/main/java/com/taskbridge/app/data/local/WorkspaceMigrationCoordinator.kt new file mode 100644 index 0000000..a25b6f3 --- /dev/null +++ b/android/app/src/main/java/com/taskbridge/app/data/local/WorkspaceMigrationCoordinator.kt @@ -0,0 +1,38 @@ +package com.taskbridge.app.data.local + +import androidx.room.withTransaction +import com.taskbridge.app.data.datastore.WorkspaceIdentity +import com.taskbridge.app.data.datastore.TokenDataStore +import com.taskbridge.app.data.datastore.legacyWorkspaceId +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +class WorkspaceMigrationCoordinator( + private val database: AppDatabase, + private val taskDao: TaskDao, + private val syncQueueDao: SyncQueueDao, + private val tokenDataStore: TokenDataStore, +) { + private val mutex = Mutex() + private val initializedWorkspaces = mutableSetOf() + + suspend fun ensureWorkspace(workspace: WorkspaceIdentity) { + if (workspace.id in initializedWorkspaces) return + mutex.withLock { + if (workspace.id in initializedWorkspaces) return + database.withTransaction { + claim(legacyWorkspaceId(workspace.userId), workspace) + claim(legacyWorkspaceId("legacy"), workspace) + } + initializedWorkspaces += workspace.id + } + } + + private suspend fun claim(legacyId: String, workspace: WorkspaceIdentity) { + val legacyOwnerUserId = legacyId.removePrefix("legacy:") + val claimAllowed = tokenDataStore.canClaimLegacyWorkspace(legacyOwnerUserId, workspace) + if (!claimAllowed) return + taskDao.claimLegacyWorkspace(legacyId, workspace.id, workspace.userId) + syncQueueDao.claimLegacyWorkspace(legacyId, workspace.id, workspace.userId) + } +} diff --git a/android/app/src/main/java/com/taskbridge/app/data/remote/ApiService.kt b/android/app/src/main/java/com/taskbridge/app/data/remote/ApiService.kt index 29eacca..d219ea4 100644 --- a/android/app/src/main/java/com/taskbridge/app/data/remote/ApiService.kt +++ b/android/app/src/main/java/com/taskbridge/app/data/remote/ApiService.kt @@ -6,15 +6,20 @@ import com.taskbridge.app.data.remote.dto.ChecklistItemUpdateDto import com.taskbridge.app.data.remote.dto.DeviceDto import com.taskbridge.app.data.remote.dto.DeviceRegisterRequestDto import com.taskbridge.app.data.remote.dto.LoginRequestDto +import com.taskbridge.app.data.remote.dto.PasswordChangeRequestDto import com.taskbridge.app.data.remote.dto.RefreshTokenRequestDto import com.taskbridge.app.data.remote.dto.RegisterRequestDto import com.taskbridge.app.data.remote.dto.RegistrationStatusDto +import com.taskbridge.app.data.remote.dto.AuthSessionDto +import com.taskbridge.app.data.remote.dto.RevokeOtherSessionsRequestDto +import com.taskbridge.app.data.remote.dto.RevokeSessionsResponseDto import com.taskbridge.app.data.remote.dto.SyncPullResponseDto import com.taskbridge.app.data.remote.dto.SyncPushRequestDto import com.taskbridge.app.data.remote.dto.SyncPushResponseDto import com.taskbridge.app.data.remote.dto.TaskCreateRequestDto import com.taskbridge.app.data.remote.dto.TaskDto import com.taskbridge.app.data.remote.dto.TaskHistoryDto +import com.taskbridge.app.data.remote.dto.TaskMetaDto import com.taskbridge.app.data.remote.dto.TaskTemplateInstantiateRequestDto import com.taskbridge.app.data.remote.dto.TaskUpdateRequestDto import com.taskbridge.app.data.remote.dto.TokenPairDto @@ -25,6 +30,7 @@ import retrofit2.Call import retrofit2.http.Body import retrofit2.http.DELETE import retrofit2.http.GET +import retrofit2.http.Header import retrofit2.http.POST import retrofit2.http.PUT import retrofit2.http.Path @@ -46,16 +52,31 @@ interface ApiService { @GET("auth/me") suspend fun me(): ApiEnvelope + @PUT("auth/password") + suspend fun changePassword( + @Body request: PasswordChangeRequestDto, + ): ApiEnvelope + + @GET("auth/sessions") + suspend fun getSessions(): ApiEnvelope> + + @POST("auth/sessions/revoke-other-devices") + suspend fun revokeOtherSessions( + @Body request: RevokeOtherSessionsRequestDto, + ): ApiEnvelope + @GET("sync/status") suspend fun syncStatus(): ApiEnvelope> @POST("auth/ws-ticket") suspend fun createWebSocketTicket( @Body request: WebSocketTicketRequestDto, + @Header(INTERNAL_WORKSPACE_HEADER) expectedWorkspaceId: String? = null, ): ApiEnvelope @GET("tasks") suspend fun getTasks( + @Query("timezone") timezone: String, @Query("q") q: String? = null, @Query("view") view: String? = null, @Query("now") now: String? = null, @@ -72,6 +93,12 @@ interface ApiService { @Query("limit") limit: Int? = null, ): ApiEnvelope> + @GET("tasks/meta") + suspend fun getTaskMeta( + @Query("timezone") timezone: String, + @Query("now") now: String? = null, + ): ApiEnvelope + @POST("tasks") suspend fun createTask(@Body request: TaskCreateRequestDto): ApiEnvelope @@ -137,7 +164,10 @@ interface ApiService { ): ApiEnvelope @POST("devices/register") - suspend fun registerDevice(@Body request: DeviceRegisterRequestDto): ApiEnvelope + suspend fun registerDevice( + @Body request: DeviceRegisterRequestDto, + @Header(INTERNAL_WORKSPACE_HEADER) expectedWorkspaceId: String? = null, + ): ApiEnvelope @GET("sync/pull") suspend fun pullSync( @@ -145,13 +175,20 @@ interface ApiService { @Query("limit") limit: Int? = null, @Query("cursor_updated_at") cursorUpdatedAt: String? = null, @Query("cursor_id") cursorId: Int? = null, + @Header(INTERNAL_WORKSPACE_HEADER) expectedWorkspaceId: String? = null, ): ApiEnvelope @POST("sync/push") - suspend fun pushSync(@Body request: SyncPushRequestDto): ApiEnvelope + suspend fun pushSync( + @Body request: SyncPushRequestDto, + @Header(INTERNAL_WORKSPACE_HEADER) expectedWorkspaceId: String? = null, + ): ApiEnvelope } interface TokenRefreshApi { @POST("auth/refresh") - fun refresh(@Body request: RefreshTokenRequestDto): Call> + fun refresh( + @Body request: RefreshTokenRequestDto, + @Header(INTERNAL_WORKSPACE_HEADER) expectedWorkspaceId: String, + ): Call> } diff --git a/android/app/src/main/java/com/taskbridge/app/data/remote/RetrofitClient.kt b/android/app/src/main/java/com/taskbridge/app/data/remote/RetrofitClient.kt index d5f8589..759cd71 100644 --- a/android/app/src/main/java/com/taskbridge/app/data/remote/RetrofitClient.kt +++ b/android/app/src/main/java/com/taskbridge/app/data/remote/RetrofitClient.kt @@ -4,6 +4,8 @@ import android.content.Context import com.google.gson.GsonBuilder import com.taskbridge.app.BuildConfig import com.taskbridge.app.data.datastore.TokenDataStore +import com.taskbridge.app.data.datastore.canApplyTokenRefresh +import com.taskbridge.app.data.datastore.requireMatchingWorkspace import com.taskbridge.app.data.remote.dto.RefreshTokenRequestDto import com.taskbridge.app.sync.DeviceIdProvider import kotlinx.coroutines.flow.first @@ -19,6 +21,8 @@ import retrofit2.converter.gson.GsonConverterFactory import java.net.URI import java.util.concurrent.TimeUnit +const val INTERNAL_WORKSPACE_HEADER = "X-TaskBridge-Expected-Workspace" + object RetrofitClient { fun create( context: Context, @@ -31,6 +35,7 @@ object RetrofitClient { val refreshClient = baseHttpClient() .newBuilder() .addInterceptor(endpointInterceptor) + .addInterceptor(StripInternalWorkspaceHeaderInterceptor()) .build() val refreshApi = Retrofit.Builder() .baseUrl(retrofitBaseUrl) @@ -78,8 +83,15 @@ private class EndpointInterceptor( ?: error("Invalid TASKBRIDGE_BASE_URL") override fun intercept(chain: Interceptor.Chain): Response { - val targetBase = endpointUri(runBlocking { tokenDataStore.apiBaseUrl.first() }) ?: defaultBase val original = chain.request() + val requestContext = runBlocking { tokenDataStore.requestAuthContext() } + original.header(INTERNAL_WORKSPACE_HEADER)?.let { expectedWorkspaceId -> + requireMatchingWorkspace( + expectedWorkspaceId, + requestContext.workspace, + ) + } + val targetBase = endpointUri(requestContext.apiBaseUrl) ?: defaultBase val rewrittenUrl = rewriteUrl(original.url().toString(), targetBase) return chain.proceed(original.newBuilder().url(rewrittenUrl).build()) } @@ -122,18 +134,39 @@ private class AuthInterceptor( private val tokenDataStore: TokenDataStore, ) : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { - val token = runBlocking { tokenDataStore.accessToken.first() } + val original = chain.request() + val requestContext = runBlocking { tokenDataStore.requestAuthContext() } + original.header(INTERNAL_WORKSPACE_HEADER)?.let { expectedWorkspaceId -> + requireMatchingWorkspace( + expectedWorkspaceId, + requestContext.workspace, + ) + } + val token = requestContext.accessToken val request = if (token.isNullOrBlank()) { - chain.request() + original.newBuilder() + .removeHeader(INTERNAL_WORKSPACE_HEADER) + .build() } else { - chain.request().newBuilder() + original.newBuilder() .header("Authorization", "Bearer $token") + .removeHeader(INTERNAL_WORKSPACE_HEADER) .build() } return chain.proceed(request) } } +private class StripInternalWorkspaceHeaderInterceptor : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + return chain.proceed( + chain.request().newBuilder() + .removeHeader(INTERNAL_WORKSPACE_HEADER) + .build(), + ) + } +} + private class RefreshTokenAuthenticator( private val tokenDataStore: TokenDataStore, private val refreshApi: TokenRefreshApi, @@ -155,20 +188,60 @@ private class RefreshTokenAuthenticator( .build() } - val refreshToken = runBlocking { tokenDataStore.refreshToken.first() } ?: return@synchronized null + val workspace = runBlocking { tokenDataStore.currentWorkspace.first() } + ?: return@synchronized null + val refreshToken = runBlocking { tokenDataStore.refreshToken.first() } + ?: return@synchronized null - val tokenPair = try { + val refreshResponse = try { refreshApi - .refresh(RefreshTokenRequestDto(refreshToken, deviceIdProvider.getDeviceId())) + .refresh( + RefreshTokenRequestDto(refreshToken, deviceIdProvider.getDeviceId()), + workspace.id, + ) .execute() - .body() - ?.data } catch (_: Exception) { null } ?: return@synchronized null + if (!refreshResponse.isSuccessful) { + if (refreshResponse.code() == 401 || refreshResponse.code() == 403) { + val rejectedWorkspace = runBlocking { tokenDataStore.currentWorkspace.first() } + val rejectedRefreshToken = runBlocking { tokenDataStore.refreshToken.first() } + if ( + canApplyTokenRefresh( + workspace.id, + refreshToken, + rejectedWorkspace, + rejectedRefreshToken, + ) + ) { + runBlocking { tokenDataStore.expireSession() } + } + } + return@synchronized null + } + val tokenPair = refreshResponse.body()?.data ?: return@synchronized null + + val currentWorkspace = runBlocking { tokenDataStore.currentWorkspace.first() } + val currentRefreshToken = runBlocking { tokenDataStore.refreshToken.first() } + if (!canApplyTokenRefresh(workspace.id, refreshToken, currentWorkspace, currentRefreshToken)) { + val replacementAccessToken = runBlocking { tokenDataStore.accessToken.first() } + return@synchronized replacementAccessToken + ?.takeIf { currentWorkspace?.id == workspace.id && it.isNotBlank() } + ?.let { token -> + response.request().newBuilder() + .header("Authorization", "Bearer $token") + .build() + } + } + runBlocking { - tokenDataStore.saveTokens(tokenPair.accessToken, tokenPair.refreshToken) + tokenDataStore.saveTokens( + tokenPair.accessToken, + tokenPair.refreshToken, + workspace.userId.toIntOrNull(), + ) } response.request().newBuilder() diff --git a/android/app/src/main/java/com/taskbridge/app/data/remote/dto/ApiDtos.kt b/android/app/src/main/java/com/taskbridge/app/data/remote/dto/ApiDtos.kt index 76f7e9c..a278708 100644 --- a/android/app/src/main/java/com/taskbridge/app/data/remote/dto/ApiDtos.kt +++ b/android/app/src/main/java/com/taskbridge/app/data/remote/dto/ApiDtos.kt @@ -37,6 +37,27 @@ data class RefreshTokenRequestDto( @SerializedName("device_id") val deviceId: String? = null, ) +data class PasswordChangeRequestDto( + @SerializedName("current_password") val currentPassword: String, + @SerializedName("new_password") val newPassword: String, +) + +data class AuthSessionDto( + val id: Int, + @SerializedName("device_id") val deviceId: String?, + @SerializedName("created_at") val createdAt: String, + @SerializedName("expires_at") val expiresAt: String, + @SerializedName("revoked_at") val revokedAt: String?, +) + +data class RevokeOtherSessionsRequestDto( + @SerializedName("device_id") val deviceId: String, +) + +data class RevokeSessionsResponseDto( + val revoked: Int, +) + data class TokenPairDto( @SerializedName("access_token") val accessToken: String, @SerializedName("refresh_token") val refreshToken: String, @@ -69,6 +90,12 @@ data class DeviceDto( @SerializedName("last_online_at") val lastOnlineAt: String?, ) +data class TaskMetaDto( + val projects: List = emptyList(), + val tags: List = emptyList(), + val counts: Map = emptyMap(), +) + data class TaskDto( val id: Int, @SerializedName("user_id") val userId: Int, diff --git a/android/app/src/main/java/com/taskbridge/app/data/repository/AuthRepository.kt b/android/app/src/main/java/com/taskbridge/app/data/repository/AuthRepository.kt index 43b3dda..2832cd1 100644 --- a/android/app/src/main/java/com/taskbridge/app/data/repository/AuthRepository.kt +++ b/android/app/src/main/java/com/taskbridge/app/data/repository/AuthRepository.kt @@ -3,7 +3,10 @@ package com.taskbridge.app.data.repository import com.taskbridge.app.data.datastore.TokenDataStore import com.taskbridge.app.data.remote.ApiService import com.taskbridge.app.data.remote.dto.LoginRequestDto +import com.taskbridge.app.data.remote.dto.PasswordChangeRequestDto import com.taskbridge.app.data.remote.dto.RegisterRequestDto +import com.taskbridge.app.data.remote.dto.AuthSessionDto +import com.taskbridge.app.data.remote.dto.RevokeOtherSessionsRequestDto import com.taskbridge.app.data.remote.dto.UserDto import com.taskbridge.app.sync.DeviceIdProvider @@ -46,6 +49,29 @@ class AuthRepository( } } + suspend fun changePassword(currentPassword: String, newPassword: String): Result { + return runCatching { + apiService.changePassword(PasswordChangeRequestDto(currentPassword, newPassword)) + .data + ?.revoked + ?: error("Password change failed") + } + } + + suspend fun sessions(): Result> { + return runCatching { + apiService.getSessions().data ?: error("Session list unavailable") + } + } + + suspend fun revokeOtherSessions(): Result { + return runCatching { + apiService.revokeOtherSessions( + RevokeOtherSessionsRequestDto(deviceIdProvider.getDeviceId()), + ).data?.revoked ?: error("Session revocation failed") + } + } + suspend fun testConnection(): Result { return runCatching { apiService.syncStatus() diff --git a/android/app/src/main/java/com/taskbridge/app/data/repository/Mappers.kt b/android/app/src/main/java/com/taskbridge/app/data/repository/Mappers.kt index d1212ee..ba73225 100644 --- a/android/app/src/main/java/com/taskbridge/app/data/repository/Mappers.kt +++ b/android/app/src/main/java/com/taskbridge/app/data/repository/Mappers.kt @@ -11,6 +11,7 @@ import com.google.gson.Gson private val mapperGson = Gson() fun TaskDto.toEntity( + workspaceId: String, ownerUserId: String, localId: String, syncStatus: SyncStatus = SyncStatus.Synced, @@ -20,6 +21,7 @@ fun TaskDto.toEntity( ): TaskEntity { return TaskEntity( localId = localId, + workspaceId = workspaceId, ownerUserId = ownerUserId, serverId = id, title = title, diff --git a/android/app/src/main/java/com/taskbridge/app/data/repository/SyncRepository.kt b/android/app/src/main/java/com/taskbridge/app/data/repository/SyncRepository.kt index 0c9d627..900defe 100644 --- a/android/app/src/main/java/com/taskbridge/app/data/repository/SyncRepository.kt +++ b/android/app/src/main/java/com/taskbridge/app/data/repository/SyncRepository.kt @@ -2,10 +2,12 @@ package com.taskbridge.app.data.repository import androidx.room.withTransaction import com.taskbridge.app.data.datastore.TokenDataStore +import com.taskbridge.app.data.datastore.WorkspaceIdentity import com.taskbridge.app.data.local.AppDatabase import com.taskbridge.app.data.local.SyncQueueDao import com.taskbridge.app.data.local.TaskDao import com.taskbridge.app.data.local.TaskEntity +import com.taskbridge.app.data.local.WorkspaceMigrationCoordinator import com.taskbridge.app.data.remote.ApiService import com.taskbridge.app.data.remote.dto.DeviceRegisterRequestDto import com.taskbridge.app.data.remote.dto.SyncPushRequestDto @@ -23,6 +25,12 @@ class SyncRepository( private val taskDao: TaskDao, private val syncQueueDao: SyncQueueDao, private val tokenDataStore: TokenDataStore, + private val workspaceMigration: WorkspaceMigrationCoordinator = WorkspaceMigrationCoordinator( + database, + taskDao, + syncQueueDao, + tokenDataStore, + ), ) { private companion object { const val PUSH_BATCH_SIZE = 100 @@ -32,40 +40,42 @@ class SyncRepository( suspend fun syncNow(deviceId: String): Result { return runCatching { - val ownerUserId = ownerUserId() ?: return@runCatching - ensureDeviceRegistered(deviceId) - pushPendingChanges(deviceId, ownerUserId) - pullChanges(ownerUserId) + val workspace = activeWorkspace() ?: return@runCatching + ensureDeviceRegistered(deviceId, workspace) + pushPendingChanges(deviceId, workspace) + pullChanges(workspace) } } suspend fun createWebSocketTicket(deviceId: String): Result { return runCatching { - ensureDeviceRegistered(deviceId) - apiService.createWebSocketTicket(WebSocketTicketRequestDto(deviceId)) + val workspace = activeWorkspace() ?: error("active workspace required") + ensureDeviceRegistered(deviceId, workspace) + apiService.createWebSocketTicket(WebSocketTicketRequestDto(deviceId), workspace.id) .data ?.ticket ?: error("missing websocket ticket") } } - private suspend fun ensureDeviceRegistered(deviceId: String) { + private suspend fun ensureDeviceRegistered(deviceId: String, workspace: WorkspaceIdentity) { apiService.registerDevice( DeviceRegisterRequestDto( deviceId = deviceId, deviceName = "Android phone", deviceType = "android", ), + workspace.id, ) } suspend fun pullChanges() { - val ownerUserId = ownerUserId() ?: return - pullChanges(ownerUserId) + val workspace = activeWorkspace() ?: return + pullChanges(workspace) } - private suspend fun pullChanges(ownerUserId: String) { - val lastSyncTime = tokenDataStore.lastSyncTime.first() ?: "1970-01-01T00:00:00Z" + private suspend fun pullChanges(workspace: WorkspaceIdentity) { + val lastSyncTime = tokenDataStore.lastSyncTimeFor(workspace) ?: "1970-01-01T00:00:00Z" var cursorUpdatedAt: String? = null var cursorId: Int? = null @@ -75,20 +85,22 @@ class SyncRepository( limit = PULL_PAGE_SIZE, cursorUpdatedAt = cursorUpdatedAt, cursorId = cursorId, + expectedWorkspaceId = workspace.id, ).data ?: return val remoteTasks = data.changedTasks + data.deletedTasks val existingByServerId: Map = if (remoteTasks.isEmpty()) { emptyMap() } else { - taskDao.getByServerIds(ownerUserId, remoteTasks.map { it.id }) + taskDao.getByServerIds(workspace.id, remoteTasks.map { it.id }) .mapNotNull { entity -> entity.serverId?.let { serverId -> serverId to entity } } .toMap() } val entities = remoteTasks.map { remoteTask -> remoteTask.toEntity( - ownerUserId = ownerUserId, - localId = existingByServerId[remoteTask.id]?.localId ?: "server-${remoteTask.id}", + workspaceId = workspace.id, + ownerUserId = workspace.userId, + localId = existingByServerId[remoteTask.id]?.localId ?: remoteTaskLocalId(workspace, remoteTask.id), syncStatus = SyncStatus.Synced, lastSyncAt = data.serverTime, ) @@ -100,7 +112,8 @@ class SyncRepository( } if (!data.hasMore) { - tokenDataStore.saveLastSyncTime(data.serverTime) + ensureWorkspaceUnchanged(workspace) + tokenDataStore.saveLastSyncTime(workspace, data.serverTime) return } cursorUpdatedAt = data.nextCursorUpdatedAt @@ -110,11 +123,11 @@ class SyncRepository( } } - private suspend fun pushPendingChanges(deviceId: String, ownerUserId: String) { + private suspend fun pushPendingChanges(deviceId: String, workspace: WorkspaceIdentity) { var processedBatches = 0 while (processedBatches < MAX_PUSH_BATCHES) { - val pending = syncQueueDao.pendingChanges(ownerUserId, PUSH_BATCH_SIZE) + val pending = syncQueueDao.pendingChanges(workspace.id, PUSH_BATCH_SIZE) if (pending.isEmpty()) return val data = apiService.pushSync( @@ -122,8 +135,11 @@ class SyncRepository( deviceId = deviceId, changes = pending.map { it.toDto() }, ), + workspace.id, ).data ?: return + ensureWorkspaceUnchanged(workspace) + val pendingByLocalId = pending.associateBy { it.localId } database.withTransaction { data.results.forEach { result -> @@ -133,25 +149,26 @@ class SyncRepository( result.task?.let { task -> taskDao.upsert( task.toEntity( - ownerUserId = ownerUserId, + workspaceId = workspace.id, + ownerUserId = workspace.userId, localId = result.localId, syncStatus = SyncStatus.Synced, lastSyncAt = data.serverTime, ), ) } ?: taskDao.markSynced( - ownerUserId = ownerUserId, + workspaceId = workspace.id, localId = result.localId, serverId = result.serverId, version = result.version ?: queued.version, updatedAt = Instant.now().toString(), syncedAt = data.serverTime, ) - syncQueueDao.deleteById(ownerUserId, queued.id) + syncQueueDao.deleteById(workspace.id, queued.id) } "conflict" -> { - val localTask = taskDao.getByLocalId(ownerUserId, result.localId) + val localTask = taskDao.getByLocalId(workspace.id, result.localId) if (localTask != null) { taskDao.upsert( localTask.copy( @@ -167,7 +184,8 @@ class SyncRepository( result.serverTask?.let { task -> taskDao.upsert( task.toEntity( - ownerUserId = ownerUserId, + workspaceId = workspace.id, + ownerUserId = workspace.userId, localId = result.localId, syncStatus = SyncStatus.Conflict, lastSyncAt = data.serverTime, @@ -175,13 +193,13 @@ class SyncRepository( conflictLocalJson = null, ), ) - } ?: taskDao.updateSyncStatus(ownerUserId, result.localId, SyncStatus.Conflict.wireName) + } ?: taskDao.updateSyncStatus(workspace.id, result.localId, SyncStatus.Conflict.wireName) } - syncQueueDao.deleteById(ownerUserId, queued.id) + syncQueueDao.deleteById(workspace.id, queued.id) } "failed" -> { - val localTask = taskDao.getByLocalId(ownerUserId, result.localId) + val localTask = taskDao.getByLocalId(workspace.id, result.localId) if (localTask != null) { taskDao.upsert( localTask.copy( @@ -191,12 +209,12 @@ class SyncRepository( ), ) } else { - taskDao.updateSyncStatus(ownerUserId, result.localId, SyncStatus.Failed.wireName) + taskDao.updateSyncStatus(workspace.id, result.localId, SyncStatus.Failed.wireName) } - syncQueueDao.incrementAttempt(ownerUserId, queued.id) + syncQueueDao.incrementAttempt(workspace.id, queued.id) } - else -> syncQueueDao.incrementAttempt(ownerUserId, queued.id) + else -> syncQueueDao.incrementAttempt(workspace.id, queued.id) } } } @@ -206,7 +224,17 @@ class SyncRepository( } } - private suspend fun ownerUserId(): String? { - return tokenDataStore.currentUserId.first()?.takeIf { it.isNotBlank() } + private suspend fun activeWorkspace(): WorkspaceIdentity? { + val workspace = tokenDataStore.currentWorkspace.first() ?: return null + workspaceMigration.ensureWorkspace(workspace) + return workspace + } + + private suspend fun ensureWorkspaceUnchanged(expected: WorkspaceIdentity) { + check(tokenDataStore.currentWorkspace.first() == expected) { "workspace changed during sync" } } } + +fun remoteTaskLocalId(workspace: WorkspaceIdentity, serverId: Int): String { + return "${workspace.preferenceSuffix}-server-$serverId" +} diff --git a/android/app/src/main/java/com/taskbridge/app/data/repository/TaskRepository.kt b/android/app/src/main/java/com/taskbridge/app/data/repository/TaskRepository.kt index 48c0130..4a90ee8 100644 --- a/android/app/src/main/java/com/taskbridge/app/data/repository/TaskRepository.kt +++ b/android/app/src/main/java/com/taskbridge/app/data/repository/TaskRepository.kt @@ -2,12 +2,14 @@ package com.taskbridge.app.data.repository import androidx.room.withTransaction import com.taskbridge.app.data.datastore.TokenDataStore +import com.taskbridge.app.data.datastore.WorkspaceIdentity import com.taskbridge.app.data.local.SyncQueueDao import com.taskbridge.app.data.local.SyncQueueCounts import com.taskbridge.app.data.local.SyncQueueEntity import com.taskbridge.app.data.local.AppDatabase import com.taskbridge.app.data.local.TaskDao import com.taskbridge.app.data.local.TaskEntity +import com.taskbridge.app.data.local.WorkspaceMigrationCoordinator import com.taskbridge.app.data.remote.ApiService import com.taskbridge.app.data.remote.dto.TaskDto import com.taskbridge.app.domain.model.SyncStatus @@ -22,6 +24,8 @@ import com.google.gson.reflect.TypeToken import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import java.time.Instant @@ -36,6 +40,7 @@ private const val ACTIVE_TASK_LIMIT = 5_000 private const val TODAY_TASK_LIMIT = 5_000 private const val SEARCH_TASK_LIMIT = 500 private const val BACKUP_EXPORT_LIMIT = 5_000 +private const val REMINDER_REBUILD_LIMIT = 5_000 private const val SIGNED_OUT_OWNER = "signed-out" private val ACCEPTED_BACKUP_FORMATS = setOf( "taskbridge.local.backup.v1", @@ -96,28 +101,24 @@ class TaskRepository( private val taskDao: TaskDao, private val syncQueueDao: SyncQueueDao, private val tokenDataStore: TokenDataStore, + private val workspaceMigration: WorkspaceMigrationCoordinator = WorkspaceMigrationCoordinator( + database, + taskDao, + syncQueueDao, + tokenDataStore, + ), ) { - @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) fun observeTasks(now: Instant = Instant.now()): Flow> { - return tokenDataStore.currentUserId.flatMapLatest { ownerUserId -> - if (ownerUserId.isNullOrBlank()) { - flowOf(emptyList()) - } else { - taskDao.observeActiveTasks(ownerUserId, ACTIVE_TASK_LIMIT, now.toString()) - .map { tasks -> tasks.map { it.toDomain() } } - } + return workspaceFlow { workspaceId -> + taskDao.observeActiveTasks(workspaceId, ACTIVE_TASK_LIMIT, now.toString()) + .map { tasks -> tasks.map { it.toDomain() } } } } - @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) fun observeTrashTasks(): Flow> { - return tokenDataStore.currentUserId.flatMapLatest { ownerUserId -> - if (ownerUserId.isNullOrBlank()) { - flowOf(emptyList()) - } else { - taskDao.observeDeletedTasks(ownerUserId, ACTIVE_TASK_LIMIT) - .map { tasks -> tasks.map { it.toDomain() } } - } + return workspaceFlow { workspaceId -> + taskDao.observeDeletedTasks(workspaceId, ACTIVE_TASK_LIMIT) + .map { tasks -> tasks.map { it.toDomain() } } } } @@ -128,54 +129,49 @@ class TaskRepository( now: Instant = Instant.now(), ): Flow> { val (startTime, endTime) = ShanghaiTime.dayBounds(todayPrefix, timeZoneId) - return tokenDataStore.currentUserId.flatMapLatest { ownerUserId -> - if (ownerUserId.isNullOrBlank()) { - flowOf(emptyList()) - } else { - taskDao.observeTodayTasks(ownerUserId, todayPrefix, startTime, endTime, now.toString(), TODAY_TASK_LIMIT) - .map { tasks -> tasks.map { it.toDomain() } } - } + return workspaceFlow { workspaceId -> + taskDao.observeTodayTasks(workspaceId, todayPrefix, startTime, endTime, now.toString(), TODAY_TASK_LIMIT) + .map { tasks -> tasks.map { it.toDomain() } } } } - @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) fun observeSearchTasks(keyword: String): Flow> { - return tokenDataStore.currentUserId.flatMapLatest { ownerUserId -> - if (ownerUserId.isNullOrBlank()) { - flowOf(emptyList()) - } else { - taskDao.observeSearchTasks(ownerUserId, keyword, SEARCH_TASK_LIMIT) - .map { tasks -> tasks.map { it.toDomain() } } - } + return workspaceFlow { workspaceId -> + taskDao.observeSearchTasks(workspaceId, keyword, SEARCH_TASK_LIMIT) + .map { tasks -> tasks.map { it.toDomain() } } } } suspend fun getTask(localId: String): Task? { - return taskDao.getByLocalId(ownerUserId(), localId)?.toDomain() + return taskDao.getByLocalId(workspaceId(), localId)?.toDomain() } suspend fun exportBackupTasks(): List { - return taskDao.getBackupTasks(ownerUserId(), BACKUP_EXPORT_LIMIT).map { it.toDomain() } + return taskDao.getBackupTasks(workspaceId(), BACKUP_EXPORT_LIMIT).map { it.toDomain() } + } + + suspend fun getTasksForReminderRebuild(): List { + return taskDao.getTasksForReminderRebuild(workspaceId(), REMINDER_REBUILD_LIMIT).map { it.toDomain() } } suspend fun getSyncQueueCounts(): SyncQueueCounts { - return syncQueueDao.queueCounts(ownerUserId()) + return syncQueueDao.queueCounts(workspaceId()) } suspend fun getExhaustedSyncQueuePreview(limit: Int = 20): List { - return syncQueueDao.exhaustedChanges(ownerUserId(), limit) + return syncQueueDao.exhaustedChanges(workspaceId(), limit) } suspend fun getConflictTaskCount(): Int { - return taskDao.countConflictTasks(ownerUserId()) + return taskDao.countConflictTasks(workspaceId()) } suspend fun getFailedSyncTaskCount(): Int { - return taskDao.countFailedSyncTasks(ownerUserId()) + return taskDao.countFailedSyncTasks(workspaceId()) } suspend fun retryExhaustedSyncQueue(): Int { - return syncQueueDao.resetExhaustedAttempts(ownerUserId()) + return syncQueueDao.resetExhaustedAttempts(workspaceId()) } suspend fun addTask( @@ -196,10 +192,11 @@ class TaskRepository( ): String { val now = Instant.now().toString() val localId = UUID.randomUUID().toString() - val ownerUserId = ownerUserId() + val workspace = activeWorkspace() val task = TaskEntity( localId = localId, - ownerUserId = ownerUserId, + workspaceId = workspace.id, + ownerUserId = workspace.userId, serverId = null, title = title, content = content, @@ -238,7 +235,7 @@ class TaskRepository( } suspend fun previewBackupImport(raw: String): BackupImportPreview { - val result = parseBackupImport(raw, ownerUserId()) + val result = parseBackupImport(raw, activeWorkspace()) return BackupImportPreview( importableCount = result.tasks.size, scannedCount = result.scannedCount, @@ -252,8 +249,8 @@ class TaskRepository( } suspend fun importBackupJsonDetailed(raw: String): BackupImportResult { - val ownerUserId = ownerUserId() - val preview = parseBackupImport(raw, ownerUserId) + val workspace = activeWorkspace() + val preview = parseBackupImport(raw, workspace) val importedTasks = preview.tasks if (importedTasks.isEmpty()) { return BackupImportResult( @@ -283,19 +280,19 @@ class TaskRepository( } suspend fun undoImportedBackupTasks(items: List): BackupImportUndoResult { - val ownerUserId = ownerUserId() + val workspaceId = workspaceId() var undoneCount = 0 var skippedChangedCount = 0 database.withTransaction { items.distinctBy { it.localId }.forEach { item -> - val current = taskDao.getByLocalId(ownerUserId, item.localId) ?: return@forEach + val current = taskDao.getByLocalId(workspaceId, item.localId) ?: return@forEach if (current.updatedAt != item.importedUpdatedAt) { skippedChangedCount += 1 return@forEach } if (current.serverId == null) { - syncQueueDao.deleteByLocalId(ownerUserId, item.localId) - taskDao.deleteByLocalId(ownerUserId, item.localId) + syncQueueDao.deleteByLocalId(workspaceId, item.localId) + taskDao.deleteByLocalId(workspaceId, item.localId) } else { val updated = current.copy( isDeleted = true, @@ -314,11 +311,11 @@ class TaskRepository( ) } - private fun parseImportableBackupTasks(raw: String, ownerUserId: String): List { - return parseBackupImport(raw, ownerUserId).tasks + private fun parseImportableBackupTasks(raw: String, workspace: WorkspaceIdentity): List { + return parseBackupImport(raw, workspace).tasks } - private fun parseBackupImport(raw: String, ownerUserId: String): BackupImportParseResult { + private fun parseBackupImport(raw: String, workspace: WorkspaceIdentity): BackupImportParseResult { if (raw.length > MAX_IMPORT_BYTES) { return BackupImportParseResult(emptyList(), 0, 0, BackupImportErrorCode.FileTooLarge) } @@ -340,7 +337,8 @@ class TaskRepository( val task = runCatching { TaskEntity( localId = "import-${UUID.randomUUID()}", - ownerUserId = ownerUserId, + workspaceId = workspace.id, + ownerUserId = workspace.userId, serverId = null, title = title, content = item.stringOrNull("content"), @@ -408,7 +406,7 @@ class TaskRepository( templateName: String? = null, updateTemplateName: Boolean = false, ) { - val current = taskDao.getByLocalId(ownerUserId(), localId) ?: return + val current = taskDao.getByLocalId(workspaceId(), localId) ?: return val now = Instant.now().toString() val syncStatus = if (current.serverId == null) { SyncStatus.PendingCreate @@ -438,7 +436,7 @@ class TaskRepository( } suspend fun completeTask(localId: String) { - val current = taskDao.getByLocalId(ownerUserId(), localId) ?: return + val current = taskDao.getByLocalId(workspaceId(), localId) ?: return val now = Instant.now().toString() val updated = current.copy( status = TaskStatus.Completed.wireName, @@ -458,22 +456,23 @@ class TaskRepository( } suspend fun resolveConflictUseServer(localId: String) { - val ownerUserId = ownerUserId() - val current = taskDao.getByLocalId(ownerUserId, localId) ?: return + val workspace = activeWorkspace() + val current = taskDao.getByLocalId(workspace.id, localId) ?: return val serverTask = parseConflictServerTask(current.conflictServerJson) ?: return taskDao.upsert( serverTask.toEntity( - ownerUserId = ownerUserId, + workspaceId = workspace.id, + ownerUserId = workspace.userId, localId = localId, syncStatus = SyncStatus.Synced, lastSyncAt = Instant.now().toString(), ), ) - syncQueueDao.deleteByLocalId(ownerUserId, localId) + syncQueueDao.deleteByLocalId(workspace.id, localId) } suspend fun forceOverwriteServer(localId: String) { - val current = taskDao.getByLocalId(ownerUserId(), localId) ?: return + val current = taskDao.getByLocalId(workspaceId(), localId) ?: return val updated = current.copy( syncStatus = if (current.serverId == null) { SyncStatus.PendingCreate.wireName @@ -511,7 +510,7 @@ class TaskRepository( } suspend fun undoCompleteTask(localId: String) { - val current = taskDao.getByLocalId(ownerUserId(), localId) ?: return + val current = taskDao.getByLocalId(workspaceId(), localId) ?: return val now = Instant.now().toString() val updated = current.copy( status = TaskStatus.Todo.wireName, @@ -528,7 +527,7 @@ class TaskRepository( } suspend fun restoreDeletedTask(localId: String) { - val current = taskDao.getByLocalId(ownerUserId(), localId) ?: return + val current = taskDao.getByLocalId(workspaceId(), localId) ?: return val now = Instant.now().toString() val updated = current.copy( isDeleted = false, @@ -544,8 +543,8 @@ class TaskRepository( } suspend fun purgeDeletedTask(localId: String) { - val ownerUserId = ownerUserId() - val current = taskDao.getByLocalId(ownerUserId, localId) ?: return + val workspaceId = workspaceId() + val current = taskDao.getByLocalId(workspaceId, localId) ?: return current.serverId?.let { serverId -> runCatching { apiService.purgeTask(serverId) @@ -555,22 +554,22 @@ class TaskRepository( } } database.withTransaction { - syncQueueDao.deleteByLocalId(ownerUserId, localId) - taskDao.deleteByLocalId(ownerUserId, localId) + syncQueueDao.deleteByLocalId(workspaceId, localId) + taskDao.deleteByLocalId(workspaceId, localId) } } suspend fun clearLocalDeviceData() { - val ownerUserId = ownerUserId() + val workspaceId = workspaceId() database.withTransaction { - syncQueueDao.deleteAllForOwner(ownerUserId) - taskDao.deleteAllForOwner(ownerUserId) + syncQueueDao.deleteAllForWorkspace(workspaceId) + taskDao.deleteAllForWorkspace(workspaceId) } tokenDataStore.clearLastBackupImportUndoItems() } suspend fun postponeTask(localId: String, dueTime: String?, remindTime: String?, plannedDate: String?) { - val current = taskDao.getByLocalId(ownerUserId(), localId) ?: return + val current = taskDao.getByLocalId(workspaceId(), localId) ?: return val now = Instant.now().toString() val updated = current.copy( dueTime = dueTime ?: current.dueTime, @@ -589,7 +588,7 @@ class TaskRepository( } suspend fun snoozeTask(localId: String, snoozedUntil: String) { - val current = taskDao.getByLocalId(ownerUserId(), localId) ?: return + val current = taskDao.getByLocalId(workspaceId(), localId) ?: return val now = Instant.now().toString() val updated = current.copy( snoozedUntil = snoozedUntil, @@ -605,7 +604,7 @@ class TaskRepository( } suspend fun planTaskForToday(localId: String, plannedDate: String, dueTime: String? = null) { - val current = taskDao.getByLocalId(ownerUserId(), localId) ?: return + val current = taskDao.getByLocalId(workspaceId(), localId) ?: return val now = Instant.now().toString() val updated = current.copy( listType = "today", @@ -623,7 +622,7 @@ class TaskRepository( } suspend fun moveTaskToInbox(localId: String) { - val current = taskDao.getByLocalId(ownerUserId(), localId) ?: return + val current = taskDao.getByLocalId(workspaceId(), localId) ?: return val now = Instant.now().toString() val updated = current.copy( listType = "inbox", @@ -639,7 +638,7 @@ class TaskRepository( } suspend fun softDeleteTask(localId: String) { - val current = taskDao.getByLocalId(ownerUserId(), localId) ?: return + val current = taskDao.getByLocalId(workspaceId(), localId) ?: return val now = Instant.now().toString() val updated = current.copy( isDeleted = true, @@ -651,7 +650,7 @@ class TaskRepository( } suspend fun addChecklistItem(localId: String, title: String) { - val current = taskDao.getByLocalId(ownerUserId(), localId) ?: return + val current = taskDao.getByLocalId(workspaceId(), localId) ?: return val trimmed = title.trim() if (trimmed.isBlank()) return val checklist = parseChecklist(current.checklistJson) + LocalChecklistItem( @@ -663,7 +662,7 @@ class TaskRepository( } suspend fun toggleChecklistItem(localId: String, itemId: String) { - val current = taskDao.getByLocalId(ownerUserId(), localId) ?: return + val current = taskDao.getByLocalId(workspaceId(), localId) ?: return val checklist = parseChecklist(current.checklistJson).map { item -> if (item.id == itemId) item.copy(done = !item.done) else item } @@ -671,13 +670,13 @@ class TaskRepository( } suspend fun deleteChecklistItem(localId: String, itemId: String) { - val current = taskDao.getByLocalId(ownerUserId(), localId) ?: return + val current = taskDao.getByLocalId(workspaceId(), localId) ?: return val checklist = parseChecklist(current.checklistJson).filterNot { it.id == itemId } updateChecklist(current, checklist) } suspend fun instantiateTemplate(localId: String): String? { - val template = taskDao.getByLocalId(ownerUserId(), localId) ?: return null + val template = taskDao.getByLocalId(workspaceId(), localId) ?: return null val now = Instant.now().toString() val next = template.copy( localId = UUID.randomUUID().toString(), @@ -703,7 +702,7 @@ class TaskRepository( } suspend fun createNextOccurrence(localId: String): String? { - val current = taskDao.getByLocalId(ownerUserId(), localId) ?: return null + val current = taskDao.getByLocalId(workspaceId(), localId) ?: return null val shiftDays = when (current.repeatRule?.trim()?.lowercase()) { "daily", "every_day", "every day" -> 1L "weekly", "every_week", "every week" -> 7L @@ -736,14 +735,34 @@ class TaskRepository( return next.localId } - private suspend fun ownerUserId(): String { - return tokenDataStore.currentUserId.first()?.takeIf { it.isNotBlank() } ?: SIGNED_OUT_OWNER + private suspend fun activeWorkspace(): WorkspaceIdentity { + val workspace = tokenDataStore.currentWorkspace.first() + ?: throw IllegalStateException("active workspace required") + workspaceMigration.ensureWorkspace(workspace) + return workspace + } + + private suspend fun workspaceId(): String = activeWorkspace().id + + @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) + private fun workspaceFlow(block: (String) -> Flow): Flow { + return tokenDataStore.currentWorkspace.flatMapLatest { workspace -> + if (workspace == null) { + flowOf() + } else { + flow { + workspaceMigration.ensureWorkspace(workspace) + emitAll(block(workspace.id)) + } + } + } } private suspend fun replaceQueue(task: TaskEntity, action: String) { - syncQueueDao.deleteByLocalId(task.ownerUserId, task.localId) + syncQueueDao.deleteByLocalId(task.workspaceId, task.localId) syncQueueDao.enqueue( SyncQueueEntity( + workspaceId = task.workspaceId, ownerUserId = task.ownerUserId, localId = task.localId, serverId = task.serverId, diff --git a/android/app/src/main/java/com/taskbridge/app/notification/ReminderManager.kt b/android/app/src/main/java/com/taskbridge/app/notification/ReminderManager.kt index 9460b10..2c8e593 100644 --- a/android/app/src/main/java/com/taskbridge/app/notification/ReminderManager.kt +++ b/android/app/src/main/java/com/taskbridge/app/notification/ReminderManager.kt @@ -13,6 +13,7 @@ import androidx.core.app.NotificationManagerCompat import androidx.core.content.ContextCompat import com.taskbridge.app.MainActivity import com.taskbridge.app.data.datastore.TokenDataStore +import com.taskbridge.app.data.repository.TaskRepository import com.taskbridge.app.domain.model.Task import com.taskbridge.app.domain.model.TaskStatus import com.taskbridge.app.ui.i18n.AppLanguage @@ -26,6 +27,7 @@ import java.time.Instant class ReminderManager( private val context: Context, private val tokenDataStore: TokenDataStore, + private val taskRepository: TaskRepository? = null, ) { fun ensureChannel() { val copy = reminderCopy() @@ -41,17 +43,25 @@ class ReminderManager( } fun schedule(task: Task) { - val remindAt = task.remindTime ?: task.dueTime ?: return - if (task.status == TaskStatus.Completed || task.isDeleted) return + val remindAt = reminderTrigger(task.remindTime, task.dueTime) + if (remindAt == null || task.status == TaskStatus.Completed || task.isDeleted) { + cancel(task) + return + } val triggerAt = runCatching { Instant.parse(remindAt).toEpochMilli() }.getOrNull() ?: return - if (triggerAt <= System.currentTimeMillis()) return + if (triggerAt <= System.currentTimeMillis()) { + cancel(task) + return + } + val workspaceId = currentWorkspaceId() ?: return val intent = Intent(context, ReminderReceiver::class.java).apply { putExtra(EXTRA_TASK_LOCAL_ID, task.localId) + putExtra(EXTRA_WORKSPACE_ID, workspaceId) } val pendingIntent = PendingIntent.getBroadcast( context, - task.localId.hashCode(), + reminderRequestCode(workspaceId, task.localId), intent, pendingIntentFlags(), ) @@ -60,10 +70,11 @@ class ReminderManager( } fun cancel(task: Task) { + val workspaceId = currentWorkspaceId() ?: return val intent = Intent(context, ReminderReceiver::class.java) val pendingIntent = PendingIntent.getBroadcast( context, - task.localId.hashCode(), + reminderRequestCode(workspaceId, task.localId), intent, pendingIntentFlags(), ) @@ -71,6 +82,7 @@ class ReminderManager( } fun showReminder(task: Task) { + if (task.status == TaskStatus.Completed || task.isDeleted) return TodayTaskWidgetUpdateWorker.enqueue(context) if (ContextCompat.checkSelfPermission( @@ -97,6 +109,11 @@ class ReminderManager( .notify(task.localId.hashCode(), notification) } + suspend fun rebuildAll() { + val repository = taskRepository ?: return + repository.getTasksForReminderRebuild().forEach(::schedule) + } + private fun openTaskIntent(localId: String): PendingIntent { val intent = Intent(context, MainActivity::class.java).apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP @@ -123,6 +140,14 @@ class ReminderManager( return PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE } + private fun currentWorkspaceId(): String? { + return runBlocking { tokenDataStore.currentWorkspace.first()?.id } + } + + private fun reminderRequestCode(workspaceId: String, localId: String): Int { + return "$workspaceId|$localId".hashCode() + } + private fun reminderCopy(): ReminderCopy { val language = runBlocking { tokenDataStore.language.first() } val appLanguage = AppLanguage.fromCode(language) @@ -148,5 +173,6 @@ class ReminderManager( companion object { const val CHANNEL_ID = "taskbridge_reminders" const val EXTRA_TASK_LOCAL_ID = "taskbridge.reminder.extra.TASK_LOCAL_ID" + const val EXTRA_WORKSPACE_ID = "taskbridge.reminder.extra.WORKSPACE_ID" } } diff --git a/android/app/src/main/java/com/taskbridge/app/notification/ReminderPolicy.kt b/android/app/src/main/java/com/taskbridge/app/notification/ReminderPolicy.kt new file mode 100644 index 0000000..b6bee46 --- /dev/null +++ b/android/app/src/main/java/com/taskbridge/app/notification/ReminderPolicy.kt @@ -0,0 +1,18 @@ +package com.taskbridge.app.notification + +private val reminderRebuildActions = setOf( + "android.intent.action.BOOT_COMPLETED", + "android.intent.action.MY_PACKAGE_REPLACED", + "android.intent.action.TIME_SET", + "android.intent.action.TIMEZONE_CHANGED", +) + +fun reminderTrigger(remindTime: String?, dueTime: String?): String? { + return remindTime?.takeIf { it.isNotBlank() } ?: dueTime?.takeIf { it.isNotBlank() } +} + +fun shouldRequestReminderPermission(remindTime: String?, dueTime: String?): Boolean { + return reminderTrigger(remindTime, dueTime) != null +} + +fun isReminderRebuildAction(action: String?): Boolean = action in reminderRebuildActions diff --git a/android/app/src/main/java/com/taskbridge/app/notification/ReminderReceiver.kt b/android/app/src/main/java/com/taskbridge/app/notification/ReminderReceiver.kt index 0eee136..2282887 100644 --- a/android/app/src/main/java/com/taskbridge/app/notification/ReminderReceiver.kt +++ b/android/app/src/main/java/com/taskbridge/app/notification/ReminderReceiver.kt @@ -8,6 +8,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch +import kotlinx.coroutines.flow.first class ReminderReceiver : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { @@ -16,6 +17,13 @@ class ReminderReceiver : BroadcastReceiver() { CoroutineScope(SupervisorJob() + Dispatchers.IO).launch { try { val container = AppContainer(context.applicationContext) + val expectedWorkspaceId = intent.getStringExtra(ReminderManager.EXTRA_WORKSPACE_ID) + if ( + expectedWorkspaceId != null && + container.tokenDataStore.currentWorkspace.first()?.id != expectedWorkspaceId + ) { + return@launch + } val task = container.taskRepository.getTask(localId) ?: return@launch container.reminderManager.showReminder(task) } finally { diff --git a/android/app/src/main/java/com/taskbridge/app/notification/ReminderRescheduleReceiver.kt b/android/app/src/main/java/com/taskbridge/app/notification/ReminderRescheduleReceiver.kt new file mode 100644 index 0000000..5ce12a7 --- /dev/null +++ b/android/app/src/main/java/com/taskbridge/app/notification/ReminderRescheduleReceiver.kt @@ -0,0 +1,26 @@ +package com.taskbridge.app.notification + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import com.taskbridge.app.AppContainer +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch + +class ReminderRescheduleReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (!isReminderRebuildAction(intent.action)) return + val pendingResult = goAsync() + CoroutineScope(SupervisorJob() + Dispatchers.IO).launch { + try { + val container = AppContainer(context.applicationContext) + container.reminderManager.ensureChannel() + runCatching { container.reminderManager.rebuildAll() } + } finally { + pendingResult.finish() + } + } + } +} diff --git a/android/app/src/main/java/com/taskbridge/app/sync/SyncManager.kt b/android/app/src/main/java/com/taskbridge/app/sync/SyncManager.kt index 64d966d..eef6c8e 100644 --- a/android/app/src/main/java/com/taskbridge/app/sync/SyncManager.kt +++ b/android/app/src/main/java/com/taskbridge/app/sync/SyncManager.kt @@ -1,27 +1,39 @@ package com.taskbridge.app.sync import android.content.Context +import android.net.ConnectivityManager +import android.net.NetworkCapabilities import androidx.work.Constraints import androidx.work.ExistingWorkPolicy import androidx.work.NetworkType import androidx.work.OneTimeWorkRequestBuilder import androidx.work.WorkManager +import androidx.work.workDataOf import com.taskbridge.app.data.datastore.TokenDataStore import com.taskbridge.app.data.repository.SyncRepository +import com.taskbridge.app.notification.ReminderManager import com.taskbridge.app.widget.TodayTaskWidgetUpdateWorker import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext class SyncManager( private val context: Context, private val syncRepository: SyncRepository, private val tokenDataStore: TokenDataStore, + private val reminderManager: ReminderManager, ) { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val deviceIdProvider = DeviceIdProvider(context) @@ -31,55 +43,107 @@ class SyncManager( ticketProvider = { deviceId -> syncRepository.createWebSocketTicket(deviceId).getOrNull() }, ) private val syncMutex = Mutex() - @Volatile - private var syncRequested = false private var syncDebounceJob: Job? = null + private val _syncState = MutableStateFlow(SyncRunState.Idle) + val syncState: StateFlow = _syncState + + init { + scope.launch { + combine(tokenDataStore.currentWorkspace, tokenDataStore.accessToken) { workspace, accessToken -> + workspace?.id to accessToken.isNullOrBlank() + } + .distinctUntilChanged() + .drop(1) + .collect { + syncDebounceJob?.cancel() + webSocketClient.disconnect() + _syncState.value = SyncRunState.Idle + } + } + } fun enqueueNetworkSync() { - val request = OneTimeWorkRequestBuilder() - .setConstraints( - Constraints.Builder() - .setRequiredNetworkType(NetworkType.CONNECTED) - .build(), - ) - .build() + scope.launch { + val authContext = tokenDataStore.requestAuthContext() + val workspace = authContext.workspace ?: return@launch + if (authContext.accessToken.isNullOrBlank()) { + _syncState.value = SyncRunState.Offline + return@launch + } + val request = OneTimeWorkRequestBuilder() + .setInputData(workDataOf(SyncWorker.INPUT_WORKSPACE_ID to workspace.id)) + .setConstraints( + Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build(), + ) + .build() - WorkManager.getInstance(context).enqueueUniqueWork( - "taskbridge-sync", - ExistingWorkPolicy.KEEP, - request, - ) + WorkManager.getInstance(context).enqueueUniqueWork( + "taskbridge-sync-${workspace.preferenceSuffix}", + ExistingWorkPolicy.KEEP, + request, + ) + } } fun syncNow() { - if (syncMutex.isLocked) { - syncRequested = true - return - } syncDebounceJob?.cancel() syncDebounceJob = scope.launch { delay(500) - if (!syncMutex.tryLock()) { - syncRequested = true - return@launch - } - try { - do { - syncRequested = false - syncRepository.syncNow(deviceIdProvider.getDeviceId()) - .onSuccess { TodayTaskWidgetUpdateWorker.enqueue(context) } - } while (syncRequested) - } finally { - syncMutex.unlock() - } + performSync() } } + suspend fun syncNowAndWait(): SyncRunState { + syncDebounceJob?.cancel() + return withContext(Dispatchers.IO) { performSync() } + } + + private suspend fun performSync(): SyncRunState = syncMutex.withLock { + val authContext = tokenDataStore.requestAuthContext() + if (authContext.workspace == null) { + _syncState.value = SyncRunState.Idle + return@withLock SyncRunState.Idle + } + if (authContext.accessToken.isNullOrBlank()) { + _syncState.value = SyncRunState.Offline + return@withLock SyncRunState.Offline + } + val networkAvailable = isNetworkAvailable() + if (!networkAvailable) { + _syncState.value = SyncRunState.Offline + return@withLock SyncRunState.Offline + } + + _syncState.value = SyncRunState.Syncing + val result = syncRepository.syncNow(deviceIdProvider.getDeviceId()) + val terminal = terminalSyncState(networkAvailable = true, failure = result.exceptionOrNull()) + if (result.isSuccess) { + runCatching { reminderManager.rebuildAll() } + TodayTaskWidgetUpdateWorker.enqueue(context) + } + _syncState.value = terminal + terminal + } + + private fun isNetworkAvailable(): Boolean { + val manager = context.getSystemService(ConnectivityManager::class.java) + val network = manager.activeNetwork ?: return false + val capabilities = manager.getNetworkCapabilities(network) ?: return false + return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + } + fun connectForegroundWebSocket() { scope.launch { + val authContext = tokenDataStore.requestAuthContext() + if (authContext.workspace == null || authContext.accessToken.isNullOrBlank()) { + _syncState.value = if (authContext.workspace == null) SyncRunState.Idle else SyncRunState.Offline + webSocketClient.disconnect() + return@launch + } webSocketClient.connect(deviceIdProvider.getDeviceId()) { - syncRepository.pullChanges() - TodayTaskWidgetUpdateWorker.enqueue(context) + performSync() } } } diff --git a/android/app/src/main/java/com/taskbridge/app/sync/SyncRunState.kt b/android/app/src/main/java/com/taskbridge/app/sync/SyncRunState.kt new file mode 100644 index 0000000..8831563 --- /dev/null +++ b/android/app/src/main/java/com/taskbridge/app/sync/SyncRunState.kt @@ -0,0 +1,14 @@ +package com.taskbridge.app.sync + +sealed interface SyncRunState { + data object Idle : SyncRunState + data object Syncing : SyncRunState + data object Success : SyncRunState + data object Offline : SyncRunState + data class Failure(val cause: Throwable) : SyncRunState +} + +fun terminalSyncState(networkAvailable: Boolean, failure: Throwable?): SyncRunState { + if (!networkAvailable) return SyncRunState.Offline + return if (failure == null) SyncRunState.Success else SyncRunState.Failure(failure) +} diff --git a/android/app/src/main/java/com/taskbridge/app/sync/SyncWorker.kt b/android/app/src/main/java/com/taskbridge/app/sync/SyncWorker.kt index d9a69fc..a50d6b6 100644 --- a/android/app/src/main/java/com/taskbridge/app/sync/SyncWorker.kt +++ b/android/app/src/main/java/com/taskbridge/app/sync/SyncWorker.kt @@ -7,7 +7,11 @@ import com.taskbridge.app.data.datastore.TokenDataStore import com.taskbridge.app.data.local.AppDatabase import com.taskbridge.app.data.remote.RetrofitClient import com.taskbridge.app.data.repository.SyncRepository +import com.taskbridge.app.data.repository.TaskRepository +import com.taskbridge.app.data.local.WorkspaceMigrationCoordinator +import com.taskbridge.app.notification.ReminderManager import com.taskbridge.app.widget.TodayTaskWidgetUpdateWorker +import kotlinx.coroutines.flow.first class SyncWorker( appContext: Context, @@ -15,23 +19,56 @@ class SyncWorker( ) : CoroutineWorker(appContext, workerParameters) { override suspend fun doWork(): Result { val tokenDataStore = TokenDataStore(applicationContext) + tokenDataStore.initializeLegacyWorkspaceOwnership() + val authContext = tokenDataStore.requestAuthContext() + if (authContext.workspace == null || authContext.accessToken.isNullOrBlank()) { + return Result.success() + } + val expectedWorkspaceId = inputData.getString(INPUT_WORKSPACE_ID) + if ( + expectedWorkspaceId != null && + authContext.workspace.id != expectedWorkspaceId + ) { + return Result.success() + } val database = AppDatabase.getInstance(applicationContext) val apiService = RetrofitClient.create(applicationContext, tokenDataStore) + val workspaceMigration = WorkspaceMigrationCoordinator( + database, + database.taskDao(), + database.syncQueueDao(), + tokenDataStore, + ) val repository = SyncRepository( apiService = apiService, database = database, taskDao = database.taskDao(), syncQueueDao = database.syncQueueDao(), tokenDataStore = tokenDataStore, + workspaceMigration = workspaceMigration, + ) + val taskRepository = TaskRepository( + apiService, + database, + database.taskDao(), + database.syncQueueDao(), + tokenDataStore, + workspaceMigration, ) + val reminderManager = ReminderManager(applicationContext, tokenDataStore, taskRepository) val deviceId = DeviceIdProvider(applicationContext).getDeviceId() return repository.syncNow(deviceId).fold( onSuccess = { + reminderManager.rebuildAll() TodayTaskWidgetUpdateWorker.enqueue(applicationContext) Result.success() }, onFailure = { Result.retry() }, ) } + + companion object { + const val INPUT_WORKSPACE_ID = "taskbridge.sync.workspace_id" + } } diff --git a/android/app/src/main/java/com/taskbridge/app/ui/components/AppUi.kt b/android/app/src/main/java/com/taskbridge/app/ui/components/AppUi.kt index fd97a14..f45388e 100644 --- a/android/app/src/main/java/com/taskbridge/app/ui/components/AppUi.kt +++ b/android/app/src/main/java/com/taskbridge/app/ui/components/AppUi.kt @@ -21,10 +21,18 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.error +import androidx.compose.ui.semantics.LiveRegionMode +import androidx.compose.ui.semantics.liveRegion +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.taskbridge.app.ui.i18n.AppLanguage +import com.taskbridge.app.ui.i18n.LocalAppLanguage import com.taskbridge.app.ui.i18n.TaskBridgeStrings data class AppUiOption( @@ -32,6 +40,23 @@ data class AppUiOption( val label: String, ) +@Composable +fun AppDynamicStatusText( + text: String, + isError: Boolean, + modifier: Modifier = Modifier, +) { + Text( + text = text, + modifier = modifier.semantics { + liveRegion = if (isError) LiveRegionMode.Assertive else LiveRegionMode.Polite + if (isError) error(text) + }, + color = if (isError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.bodySmall, + ) +} + fun languageOptions(strings: TaskBridgeStrings): List> { return listOf( AppUiOption(AppLanguage.Chinese, strings.chinese), @@ -161,6 +186,8 @@ fun AppDropdownField( onSelect: (T) -> Unit, modifier: Modifier = Modifier, ) { + val fieldDescription = if (label.isBlank()) selectedLabel else "$label: $selectedLabel" + val isEnglish = LocalAppLanguage.current == AppLanguage.English Column(modifier = modifier) { if (label.isNotBlank()) { Text( @@ -170,8 +197,17 @@ fun AppDropdownField( ) } OutlinedButton( - onClick = { onExpandedChange(true) }, - modifier = Modifier.fillMaxWidth(), + onClick = { onExpandedChange(!expanded) }, + modifier = Modifier + .fillMaxWidth() + .semantics(mergeDescendants = true) { + contentDescription = fieldDescription + stateDescription = if (expanded) { + if (isEnglish) "Expanded" else "已展开" + } else { + if (isEnglish) "Collapsed" else "已收起" + } + }, shape = RoundedCornerShape(8.dp), colors = ButtonDefaults.outlinedButtonColors( containerColor = MaterialTheme.colorScheme.surface, @@ -185,7 +221,8 @@ fun AppDropdownField( overflow = TextOverflow.Ellipsis, ) Text( - text = "v", + text = "\u25BE", + modifier = Modifier.clearAndSetSemantics { }, color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.labelMedium, ) diff --git a/android/app/src/main/java/com/taskbridge/app/ui/components/ExternalUri.kt b/android/app/src/main/java/com/taskbridge/app/ui/components/ExternalUri.kt new file mode 100644 index 0000000..edd2485 --- /dev/null +++ b/android/app/src/main/java/com/taskbridge/app/ui/components/ExternalUri.kt @@ -0,0 +1,7 @@ +package com.taskbridge.app.ui.components + +import androidx.compose.ui.platform.UriHandler + +fun tryOpenExternalUri(uriHandler: UriHandler, uri: String): Boolean { + return runCatching { uriHandler.openUri(uri) }.isSuccess +} diff --git a/android/app/src/main/java/com/taskbridge/app/ui/components/SyncStatusBar.kt b/android/app/src/main/java/com/taskbridge/app/ui/components/SyncStatusBar.kt index 2e2fa0c..967edf0 100644 --- a/android/app/src/main/java/com/taskbridge/app/ui/components/SyncStatusBar.kt +++ b/android/app/src/main/java/com/taskbridge/app/ui/components/SyncStatusBar.kt @@ -8,6 +8,9 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.LiveRegionMode +import androidx.compose.ui.semantics.liveRegion +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp import com.taskbridge.app.ui.i18n.AppLanguage import com.taskbridge.app.ui.i18n.LocalAppLanguage @@ -30,13 +33,18 @@ sealed interface SyncStatusMessage { object PlannedForToday : SyncStatusMessage object MovedToInbox : SyncStatusMessage object Syncing : SyncStatusMessage + object SyncSucceeded : SyncStatusMessage + object SyncFailed : SyncStatusMessage + object Offline : SyncStatusMessage } @Composable fun SyncStatusBar(message: SyncStatusMessage, modifier: Modifier = Modifier) { val language = LocalAppLanguage.current Surface( - modifier = modifier.fillMaxWidth(), + modifier = modifier + .fillMaxWidth() + .semantics { liveRegion = LiveRegionMode.Polite }, color = MaterialTheme.colorScheme.surface, border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), ) { @@ -69,6 +77,9 @@ fun syncStatusMessageText(message: SyncStatusMessage, language: AppLanguage): St SyncStatusMessage.PlannedForToday -> "已加入今日计划" SyncStatusMessage.MovedToInbox -> "已移回收件箱" SyncStatusMessage.Syncing -> "正在同步" + SyncStatusMessage.SyncSucceeded -> "同步完成" + SyncStatusMessage.SyncFailed -> "同步失败,请重试" + SyncStatusMessage.Offline -> "当前离线,联网后会自动同步" } } else { when (message) { @@ -89,6 +100,9 @@ fun syncStatusMessageText(message: SyncStatusMessage, language: AppLanguage): St SyncStatusMessage.PlannedForToday -> "Planned for today" SyncStatusMessage.MovedToInbox -> "Moved to inbox" SyncStatusMessage.Syncing -> "Syncing" + SyncStatusMessage.SyncSucceeded -> "Sync complete" + SyncStatusMessage.SyncFailed -> "Sync failed. Try again." + SyncStatusMessage.Offline -> "Offline. Changes will sync automatically when connected." } } } diff --git a/android/app/src/main/java/com/taskbridge/app/ui/editor/EditorDraftPersistence.kt b/android/app/src/main/java/com/taskbridge/app/ui/editor/EditorDraftPersistence.kt new file mode 100644 index 0000000..0adde58 --- /dev/null +++ b/android/app/src/main/java/com/taskbridge/app/ui/editor/EditorDraftPersistence.kt @@ -0,0 +1,61 @@ +package com.taskbridge.app.ui.editor + +import android.content.Context +import com.google.gson.Gson +import java.io.File + +data class EditorSavedStateReference( + val draftId: String, + val editingLocalId: String?, +) + +fun editorSavedStateReference(draftId: String, editingLocalId: String?): EditorSavedStateReference { + require(draftId.matches(Regex("[A-Za-z0-9-]+"))) { "invalid draft id" } + return EditorSavedStateReference(draftId, editingLocalId) +} + +data class StoredEditorDraft( + val stateJson: String, + val snapshotJson: String, +) + +interface EditorDraftStore { + suspend fun read(draftId: String): StoredEditorDraft? + suspend fun write(draftId: String, draft: StoredEditorDraft) + suspend fun delete(draftId: String) +} + +class FileEditorDraftStore(context: Context) : EditorDraftStore { + private val directory = File(context.noBackupFilesDir, "editor-drafts") + private val gson = Gson() + + override suspend fun read(draftId: String): StoredEditorDraft? { + val file = fileFor(draftId) + if (!file.isFile) return null + return runCatching { gson.fromJson(file.readText(Charsets.UTF_8), StoredEditorDraft::class.java) } + .getOrNull() + } + + override suspend fun write(draftId: String, draft: StoredEditorDraft) { + if (!directory.exists() && !directory.mkdirs() && !directory.isDirectory) { + throw IllegalStateException("editor draft directory unavailable") + } + val destination = fileFor(draftId) + val temporary = File(directory, "${destination.name}.tmp") + temporary.writeText(gson.toJson(draft), Charsets.UTF_8) + if (!temporary.renameTo(destination)) { + destination.delete() + if (!temporary.renameTo(destination)) throw IllegalStateException("editor draft write failed") + } + } + + override suspend fun delete(draftId: String) { + fileFor(draftId).delete() + File(directory, "$draftId.json.tmp").delete() + } + + private fun fileFor(draftId: String): File { + editorSavedStateReference(draftId, null) + return File(directory, "$draftId.json") + } +} diff --git a/android/app/src/main/java/com/taskbridge/app/ui/editor/EditorOperationCoordinator.kt b/android/app/src/main/java/com/taskbridge/app/ui/editor/EditorOperationCoordinator.kt new file mode 100644 index 0000000..8e6a419 --- /dev/null +++ b/android/app/src/main/java/com/taskbridge/app/ui/editor/EditorOperationCoordinator.kt @@ -0,0 +1,25 @@ +package com.taskbridge.app.ui.editor + +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicLong + +class EditorOperationCoordinator { + private val revision = AtomicLong(0) + private val saving = AtomicBoolean(false) + + fun captureRevision(): Long = revision.get() + + fun markDraftChanged() { + revision.incrementAndGet() + } + + fun canApplyLoadedTask(revisionAtLoadStart: Long): Boolean { + return revision.get() == revisionAtLoadStart + } + + fun tryBeginSave(): Boolean = saving.compareAndSet(false, true) + + fun finishSave() { + saving.set(false) + } +} diff --git a/android/app/src/main/java/com/taskbridge/app/ui/editor/EditorScreen.kt b/android/app/src/main/java/com/taskbridge/app/ui/editor/EditorScreen.kt index f3a4b7e..7c3f114 100644 --- a/android/app/src/main/java/com/taskbridge/app/ui/editor/EditorScreen.kt +++ b/android/app/src/main/java/com/taskbridge/app/ui/editor/EditorScreen.kt @@ -34,6 +34,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.taskbridge.app.ui.components.AppDropdownField +import com.taskbridge.app.ui.components.AppDynamicStatusText import com.taskbridge.app.ui.components.AppHeader import com.taskbridge.app.ui.components.AppPage import com.taskbridge.app.ui.components.AppPanel @@ -45,6 +46,7 @@ import com.taskbridge.app.ui.i18n.LocalTaskBridgeStrings import com.taskbridge.app.ui.i18n.TaskBridgeStrings import com.taskbridge.app.ui.task.getTaskPriorityLabel import com.taskbridge.app.ui.task.getTaskPriorityOptions +import com.taskbridge.app.notification.shouldRequestReminderPermission import com.taskbridge.app.utils.ShanghaiTime import java.time.Instant import java.time.LocalDate @@ -67,7 +69,12 @@ fun EditorScreen( var arrangeOpen by remember(state.editingLocalId) { mutableStateOf(state.editingLocalId != null && hasArrangementFields(state)) } - var advancedOpen by remember(state.editingLocalId) { mutableStateOf(state.editingLocalId != null) } + var bodyOpen by remember(state.editingLocalId) { + mutableStateOf(state.editingLocalId != null && hasTaskBodyFields(state)) + } + var advancedOpen by remember(state.editingLocalId) { + mutableStateOf(state.editingLocalId != null && hasAdvancedMetadataFields(state)) + } var listMenuOpen by remember { mutableStateOf(false) } var priorityMenuOpen by remember { mutableStateOf(false) } var repeatMenuOpen by remember { mutableStateOf(false) } @@ -97,7 +104,7 @@ fun EditorScreen( val repeatOptions = remember(isEnglish) { repeatRuleOptions(isEnglish) } val selectedRepeatLabel = repeatOptions.firstOrNull { it.value == state.repeatRule }?.label ?: state.repeatRule.ifBlank { repeatOptions.first().label } - val scheduleHelpMessage = scheduleHelpText(isEnglish) + val scheduleHelpMessage = strings.taskScheduleHelp fun pickPlannedDate() { showDatePicker( context = context, @@ -126,6 +133,7 @@ fun EditorScreen( if (state.hasUnsavedChanges) { confirmDiscardChanges = true } else { + viewModel.discardDraft() onCancel() } } @@ -151,6 +159,7 @@ fun EditorScreen( TextButton( onClick = { confirmDiscardChanges = false + viewModel.discardDraft() onCancel() }, ) { @@ -172,7 +181,15 @@ fun EditorScreen( bottomBar = { EditorBottomActions( strings = strings, - onSave = { viewModel.save(onSaved) }, + isEnglish = isEnglish, + isLoadingTask = state.isLoadingTask, + isSaving = state.isSaving, + onSave = { + if (shouldRequestReminderPermission(state.remindTime, state.dueTime)) { + onRequestNotificationPermission() + } + viewModel.save(onSaved) + }, onCancel = { requestCancel() }, ) }, @@ -196,24 +213,31 @@ fun EditorScreen( onValueChange = viewModel::updateTitle, label = { Text(strings.quickAddLabel) }, placeholder = { Text(strings.quickAddPlaceholder) }, + isError = state.error != null, modifier = Modifier.fillMaxWidth(), ) QuickAddPreviewChips(chips = quickPreviewChips) - OutlinedTextField( - value = state.content, - onValueChange = viewModel::updateContent, - label = { Text(strings.content) }, - modifier = Modifier.fillMaxWidth(), - minLines = 3, + } + + TextButton(onClick = { bodyOpen = !bodyOpen }, modifier = Modifier.fillMaxWidth()) { + Text(if (bodyOpen) strings.taskHideBodyDetails else strings.taskBodyDetails) + } + + if (bodyOpen) { + BodyTaskFieldsPanel( + state = state, + strings = strings, + onContentChanged = viewModel::updateContent, + onChecklistChanged = viewModel::updateChecklistText, ) } TextButton(onClick = { arrangeOpen = !arrangeOpen }, modifier = Modifier.fillMaxWidth()) { - Text(if (arrangeOpen) hideArrangementText(isEnglish) else arrangementText(isEnglish)) + Text(if (arrangeOpen) strings.taskHideArrangementSettings else strings.taskArrangementSettings) } if (arrangeOpen) { - QuickFieldsPanel( + ArrangementTaskFieldsPanel( state = state, strings = strings, displayTimeZone = displayTimeZone, @@ -252,7 +276,6 @@ fun EditorScreen( onRepeatExpandedChange = { repeatMenuOpen = it }, onTagChanged = viewModel::updateTag, onProjectChanged = viewModel::updateProject, - onChecklistChanged = viewModel::updateChecklistText, onRepeatSelected = viewModel::updateRepeatRule, onTemplateChanged = viewModel::updateIsTemplate, onTemplateNameChanged = viewModel::updateTemplateName, @@ -260,7 +283,10 @@ fun EditorScreen( } state.error?.let { - Text(localizeEditorError(it, strings), color = MaterialTheme.colorScheme.error) + AppDynamicStatusText( + text = localizeEditorError(it, strings), + isError = true, + ) } } } @@ -270,6 +296,9 @@ fun EditorScreen( @Composable private fun EditorBottomActions( strings: TaskBridgeStrings, + isEnglish: Boolean, + isLoadingTask: Boolean, + isSaving: Boolean, onSave: () -> Unit, onCancel: () -> Unit, ) { @@ -287,8 +316,18 @@ private fun EditorBottomActions( TextButton(onClick = onCancel, modifier = Modifier.weight(1f)) { Text(strings.cancel) } - Button(onClick = onSave, modifier = Modifier.weight(1f)) { - Text(strings.saveTask) + Button( + onClick = onSave, + enabled = !isSaving && !isLoadingTask, + modifier = Modifier.weight(1f), + ) { + Text( + if (isSaving) { + if (isEnglish) "Saving..." else "正在保存..." + } else { + strings.saveTask + }, + ) } } } @@ -320,7 +359,32 @@ private fun QuickAddPreviewChips(chips: List) { } @Composable -private fun QuickFieldsPanel( +private fun BodyTaskFieldsPanel( + state: EditorUiState, + strings: TaskBridgeStrings, + onContentChanged: (String) -> Unit, + onChecklistChanged: (String) -> Unit, +) { + AppPanel { + OutlinedTextField( + value = state.content, + onValueChange = onContentChanged, + label = { Text(strings.content) }, + modifier = Modifier.fillMaxWidth(), + minLines = 3, + ) + OutlinedTextField( + value = state.checklistText, + onValueChange = onChecklistChanged, + label = { Text(strings.checklist) }, + modifier = Modifier.fillMaxWidth(), + minLines = 3, + ) + } +} + +@Composable +private fun ArrangementTaskFieldsPanel( state: EditorUiState, strings: TaskBridgeStrings, displayTimeZone: String, @@ -437,7 +501,6 @@ private fun AdvancedTaskFieldsPanel( onRepeatExpandedChange: (Boolean) -> Unit, onTagChanged: (String) -> Unit, onProjectChanged: (String) -> Unit, - onChecklistChanged: (String) -> Unit, onRepeatSelected: (String) -> Unit, onTemplateChanged: (Boolean) -> Unit, onTemplateNameChanged: (String) -> Unit, @@ -456,13 +519,6 @@ private fun AdvancedTaskFieldsPanel( modifier = Modifier.fillMaxWidth(), singleLine = true, ) - OutlinedTextField( - value = state.checklistText, - onValueChange = onChecklistChanged, - label = { Text(strings.checklist) }, - modifier = Modifier.fillMaxWidth(), - minLines = 3, - ) AppDropdownField( label = strings.repeatRule, selectedLabel = selectedRepeatLabel, @@ -502,20 +558,16 @@ private fun hasArrangementFields(state: EditorUiState): Boolean { state.remindTime.isNotBlank() } -private fun arrangementText(isEnglish: Boolean): String { - return if (isEnglish) "Time and schedule" else "时间与安排" +private fun hasTaskBodyFields(state: EditorUiState): Boolean { + return state.content.isNotBlank() || state.checklistText.isNotBlank() } -private fun hideArrangementText(isEnglish: Boolean): String { - return if (isEnglish) "Hide time and schedule" else "收起时间与安排" -} - -private fun scheduleHelpText(isEnglish: Boolean): String { - return if (isEnglish) { - "Plan date is when you intend to work on it; due time is the latest finish time; reminder only sends a notification." - } else { - "计划日期表示哪天要做;截止时间表示最晚完成时间;提醒时间只负责通知。" - } +private fun hasAdvancedMetadataFields(state: EditorUiState): Boolean { + return state.tag.isNotBlank() || + state.project.isNotBlank() || + state.repeatRule.isNotBlank() || + state.isTemplate || + state.templateName.isNotBlank() } private fun showDatePicker( diff --git a/android/app/src/main/java/com/taskbridge/app/ui/editor/EditorViewModel.kt b/android/app/src/main/java/com/taskbridge/app/ui/editor/EditorViewModel.kt index 98b3307..d154981 100644 --- a/android/app/src/main/java/com/taskbridge/app/ui/editor/EditorViewModel.kt +++ b/android/app/src/main/java/com/taskbridge/app/ui/editor/EditorViewModel.kt @@ -1,9 +1,12 @@ package com.taskbridge.app.ui.editor import android.content.Context +import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.createSavedStateHandle import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.CreationExtras import com.google.gson.Gson import com.google.gson.reflect.TypeToken import com.taskbridge.app.data.datastore.TokenDataStore @@ -19,7 +22,10 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import java.time.LocalDate import java.time.LocalDateTime import java.util.UUID @@ -40,6 +46,8 @@ data class EditorUiState( val isTemplate: Boolean = false, val templateName: String = "", val hasUnsavedChanges: Boolean = false, + val isLoadingTask: Boolean = false, + val isSaving: Boolean = false, val error: String? = null, ) @@ -48,7 +56,7 @@ enum class EditorEntryPreset { Today, } -private data class EditorDraftSnapshot( +data class EditorDraftSnapshot( val editingLocalId: String? = null, val title: String = "", val content: String = "", @@ -73,15 +81,40 @@ private data class EditorChecklistItem( private val editorGson = Gson() private val editorChecklistType = object : TypeToken>() {}.type +private const val EDITOR_DRAFT_ID_KEY = "editor_draft_id" +private const val EDITOR_EDITING_LOCAL_ID_KEY = "editor_editing_local_id" class EditorViewModel( private val appContext: Context, private val taskRepository: TaskRepository, private val syncManager: SyncManager, private val tokenDataStore: TokenDataStore, + private val savedStateHandle: SavedStateHandle, + private val draftStore: EditorDraftStore = FileEditorDraftStore(appContext), ) : ViewModel() { - private val _uiState = MutableStateFlow(EditorUiState()) - private var savedSnapshot = EditorDraftSnapshot() + private val restoredDraftId: String? = savedStateHandle[EDITOR_DRAFT_ID_KEY] + private val draftId = restoredDraftId ?: UUID.randomUUID().toString() + private val restoredEditingLocalId: String? = savedStateHandle[EDITOR_EDITING_LOCAL_ID_KEY] + private val _uiState = MutableStateFlow(EditorUiState(editingLocalId = restoredEditingLocalId)) + private var savedSnapshot = EditorDraftSnapshot(editingLocalId = restoredEditingLocalId) + @Volatile + private var restoredDraftLoaded = false + @Volatile + private var draftStorageCleared = false + private val draftStoreMutex = Mutex() + private val operations = EditorOperationCoordinator() + private val restoreRevision = operations.captureRevision() + private val restoreJob = restoredDraftId?.let { id -> + viewModelScope.launch(Dispatchers.IO) { + val stored = runCatching { draftStore.read(id) }.getOrNull() ?: return@launch + val restoredState = decodeEditorState(stored.stateJson) ?: return@launch + val restoredSnapshot = decodeEditorSnapshot(stored.snapshotJson) ?: return@launch + if (!operations.canApplyLoadedTask(restoreRevision)) return@launch + savedSnapshot = restoredSnapshot + _uiState.value = restoredState.copy(isLoadingTask = false, isSaving = false) + restoredDraftLoaded = true + } + } private val reminderManager = ReminderManager(appContext, tokenDataStore) val uiState: StateFlow = _uiState val displayTimeZone: StateFlow = tokenDataStore.displayTimeZone @@ -116,17 +149,36 @@ class EditorViewModel( fun updateIsTemplate(value: Boolean) = updateDraft { it.copy(isTemplate = value) } fun startNewTask(preset: EditorEntryPreset = EditorEntryPreset.Default) { - val next = initialEditorDraftForPreset( - preset = preset, - displayTimeZone = displayTimeZone.value, - ) - savedSnapshot = snapshotFromState(next) - _uiState.value = next.copy(hasUnsavedChanges = false) + viewModelScope.launch { + restoreJob?.join() + if (restoredDraftLoaded && _uiState.value.editingLocalId == null) return@launch + draftStorageCleared = false + val next = initialEditorDraftForPreset( + preset = preset, + displayTimeZone = displayTimeZone.value, + ) + savedSnapshot = snapshotFromState(next) + _uiState.value = next.copy(hasUnsavedChanges = false) + persistDraft() + } } fun loadTask(localId: String) { + val revisionAtLoadStart = operations.captureRevision() + _uiState.update { it.copy(editingLocalId = localId, isLoadingTask = true, error = null) } viewModelScope.launch { - val task = taskRepository.getTask(localId) ?: return@launch + restoreJob?.join() + if (restoredDraftLoaded && _uiState.value.editingLocalId == localId) { + _uiState.update { it.copy(isLoadingTask = false) } + return@launch + } + val task = taskRepository.getTask(localId) + if (task == null) { + if (operations.canApplyLoadedTask(revisionAtLoadStart)) { + _uiState.update { it.copy(isLoadingTask = false, error = "Task could not be loaded.") } + } + return@launch + } val loaded = EditorUiState( editingLocalId = task.localId, title = task.title, @@ -143,8 +195,13 @@ class EditorViewModel( isTemplate = task.isTemplate, templateName = task.templateName.orEmpty(), ) + if (!operations.canApplyLoadedTask(revisionAtLoadStart)) { + _uiState.update { it.copy(isLoadingTask = false) } + return@launch + } savedSnapshot = snapshotFromState(loaded) _uiState.value = loaded.copy(hasUnsavedChanges = false) + persistDraft() } } @@ -163,68 +220,124 @@ class EditorViewModel( _uiState.update { it.copy(error = "Invalid time.") } return } + if (!operations.tryBeginSave()) return + _uiState.update { it.copy(isSaving = true, error = null) } viewModelScope.launch { - val editingLocalId = state.editingLocalId - val localId = if (editingLocalId == null) { - val parsed = QuickAddParser.parse(state.title, timeZoneId) - val tag = state.tag.trim().ifBlank { parsed.tag.orEmpty() }.ifBlank { null } - val project = state.project.trim().ifBlank { parsed.project.orEmpty() }.ifBlank { null } - val dueTime = ShanghaiTime.inputToInstantText(state.dueTime, timeZoneId) ?: parsed.dueTime - val remindTime = ShanghaiTime.inputToInstantText(state.remindTime, timeZoneId) - taskRepository.addTask( - title = parsed.title, - content = state.content.ifBlank { null }, - priority = state.priority.toIntOrNull()?.takeIf { it > 0 } ?: parsed.priority, - tag = tag, - dueTime = dueTime, - remindTime = remindTime, - repeatRule = state.repeatRule.ifBlank { null }, - project = project, - listType = state.listType, - plannedDate = state.plannedDate.trim().ifBlank { parsed.plannedDate.orEmpty() }.ifBlank { null }, - checklistJson = checklistTextToJson(state.checklistText), - isTemplate = state.isTemplate, - templateName = state.templateName.ifBlank { null }, - ) - } else { - val existingChecklistJson = taskRepository.getTask(editingLocalId)?.checklistJson - taskRepository.updateTask( - localId = editingLocalId, - title = state.title.trim(), - content = state.content.ifBlank { null }, - priority = state.priority.toIntOrNull()?.coerceIn(0, 5) ?: 0, - tag = state.tag.trim().ifBlank { null }, - project = state.project.trim().ifBlank { null }, - updateProject = true, - dueTime = ShanghaiTime.inputToInstantText(state.dueTime, timeZoneId), - remindTime = ShanghaiTime.inputToInstantText(state.remindTime, timeZoneId), - repeatRule = state.repeatRule.ifBlank { null }, - listType = state.listType, - plannedDate = state.plannedDate.trim().ifBlank { null }, - updatePlannedDate = true, - checklistJson = checklistTextToJson(state.checklistText, existingChecklistJson), - isTemplate = state.isTemplate, - templateName = state.templateName.ifBlank { null }, - updateTemplateName = true, - ) - editingLocalId + val result = runCatching { + val editingLocalId = state.editingLocalId + val localId = if (editingLocalId == null) { + val parsed = QuickAddParser.parse(state.title, timeZoneId) + val tag = state.tag.trim().ifBlank { parsed.tag.orEmpty() }.ifBlank { null } + val project = state.project.trim().ifBlank { parsed.project.orEmpty() }.ifBlank { null } + val dueTime = ShanghaiTime.inputToInstantText(state.dueTime, timeZoneId) ?: parsed.dueTime + val remindTime = ShanghaiTime.inputToInstantText(state.remindTime, timeZoneId) + taskRepository.addTask( + title = parsed.title, + content = state.content.ifBlank { null }, + priority = state.priority.toIntOrNull()?.takeIf { it > 0 } ?: parsed.priority, + tag = tag, + dueTime = dueTime, + remindTime = remindTime, + repeatRule = state.repeatRule.ifBlank { null }, + project = project, + listType = state.listType, + plannedDate = state.plannedDate.trim().ifBlank { parsed.plannedDate.orEmpty() }.ifBlank { null }, + checklistJson = checklistTextToJson(state.checklistText), + isTemplate = state.isTemplate, + templateName = state.templateName.ifBlank { null }, + ) + } else { + val existingChecklistJson = taskRepository.getTask(editingLocalId)?.checklistJson + taskRepository.updateTask( + localId = editingLocalId, + title = state.title.trim(), + content = state.content.ifBlank { null }, + priority = state.priority.toIntOrNull()?.coerceIn(0, 5) ?: 0, + tag = state.tag.trim().ifBlank { null }, + project = state.project.trim().ifBlank { null }, + updateProject = true, + dueTime = ShanghaiTime.inputToInstantText(state.dueTime, timeZoneId), + remindTime = ShanghaiTime.inputToInstantText(state.remindTime, timeZoneId), + repeatRule = state.repeatRule.ifBlank { null }, + listType = state.listType, + plannedDate = state.plannedDate.trim().ifBlank { null }, + updatePlannedDate = true, + checklistJson = checklistTextToJson(state.checklistText, existingChecklistJson), + isTemplate = state.isTemplate, + templateName = state.templateName.ifBlank { null }, + updateTemplateName = true, + ) + editingLocalId + } + taskRepository.getTask(localId)?.let(reminderManager::schedule) + TodayTaskWidgetUpdateWorker.enqueue(appContext) + syncManager.enqueueNetworkSync() + syncManager.syncNow() + savedSnapshot = snapshotFromState(_uiState.value) + clearPersistedDraft() + } + operations.finishSave() + result.onSuccess { + _uiState.update { it.copy(isSaving = false) } + onSaved() + }.onFailure { + _uiState.update { it.copy(isSaving = false, error = "Task could not be saved.") } } - taskRepository.getTask(localId)?.let(reminderManager::schedule) - TodayTaskWidgetUpdateWorker.enqueue(appContext) - syncManager.enqueueNetworkSync() - syncManager.syncNow() - savedSnapshot = snapshotFromState(_uiState.value) - onSaved() } } + fun discardDraft() { + clearPersistedDraft() + } + private fun updateDraft(transform: (EditorUiState) -> EditorUiState) { + if (_uiState.value.isSaving) return + operations.markDraftChanged() _uiState.update { current -> val next = transform(current) next.copy(hasUnsavedChanges = snapshotFromState(next) != savedSnapshot) } + persistDraft() + } + + private fun persistDraft() { + val reference = editorSavedStateReference(draftId, _uiState.value.editingLocalId) + savedStateHandle[EDITOR_DRAFT_ID_KEY] = reference.draftId + if (reference.editingLocalId == null) { + savedStateHandle.remove(EDITOR_EDITING_LOCAL_ID_KEY) + } else { + savedStateHandle[EDITOR_EDITING_LOCAL_ID_KEY] = reference.editingLocalId + } + val stored = StoredEditorDraft( + stateJson = editorGson.toJson(_uiState.value), + snapshotJson = editorGson.toJson(savedSnapshot), + ) + viewModelScope.launch(Dispatchers.IO) { + draftStoreMutex.withLock { + if (!draftStorageCleared) runCatching { draftStore.write(draftId, stored) } + } + } } + + private fun clearPersistedDraft() { + draftStorageCleared = true + savedStateHandle.remove(EDITOR_DRAFT_ID_KEY) + savedStateHandle.remove(EDITOR_EDITING_LOCAL_ID_KEY) + viewModelScope.launch(Dispatchers.IO) { + draftStoreMutex.withLock { + runCatching { draftStore.delete(draftId) } + } + } + } +} + +private fun decodeEditorState(value: String): EditorUiState? { + return runCatching { editorGson.fromJson(value, EditorUiState::class.java) }.getOrNull() +} + +private fun decodeEditorSnapshot(value: String): EditorDraftSnapshot? { + return runCatching { editorGson.fromJson(value, EditorDraftSnapshot::class.java) }.getOrNull() } fun isValidPlannedDateInput(value: String): Boolean { @@ -384,7 +497,13 @@ class EditorViewModelFactory( private val tokenDataStore: TokenDataStore, ) : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") - override fun create(modelClass: Class): T { - return EditorViewModel(appContext, taskRepository, syncManager, tokenDataStore) as T + override fun create(modelClass: Class, extras: CreationExtras): T { + return EditorViewModel( + appContext, + taskRepository, + syncManager, + tokenDataStore, + extras.createSavedStateHandle(), + ) as T } } diff --git a/android/app/src/main/java/com/taskbridge/app/ui/editor/ExternalEditorNavigationPolicy.kt b/android/app/src/main/java/com/taskbridge/app/ui/editor/ExternalEditorNavigationPolicy.kt new file mode 100644 index 0000000..b4e8837 --- /dev/null +++ b/android/app/src/main/java/com/taskbridge/app/ui/editor/ExternalEditorNavigationPolicy.kt @@ -0,0 +1,18 @@ +package com.taskbridge.app.ui.editor + +enum class ExternalEditorNavigationDecision { + Navigate, + ConfirmDiscard, +} + +fun externalEditorNavigationDecision( + currentRoute: String?, + hasUnsavedChanges: Boolean, +): ExternalEditorNavigationDecision { + val isEditorRoute = currentRoute?.substringBefore('/')?.startsWith("editor") == true + return if (isEditorRoute && hasUnsavedChanges) { + ExternalEditorNavigationDecision.ConfirmDiscard + } else { + ExternalEditorNavigationDecision.Navigate + } +} diff --git a/android/app/src/main/java/com/taskbridge/app/ui/editor/SharedTextReader.kt b/android/app/src/main/java/com/taskbridge/app/ui/editor/SharedTextReader.kt new file mode 100644 index 0000000..639d812 --- /dev/null +++ b/android/app/src/main/java/com/taskbridge/app/ui/editor/SharedTextReader.kt @@ -0,0 +1,39 @@ +package com.taskbridge.app.ui.editor + +import java.io.ByteArrayOutputStream +import java.io.IOException +import java.io.InputStream + +const val MAX_SHARED_TEXT_BYTES = 1_048_576 +const val MAX_SHARED_BACKUP_BYTES = 20_000_000 + +class SharedTextTooLargeException : IOException("shared payload is too large") + +fun sharedPayloadReadLimit(mimeType: String): Int { + return if (mimeType.equals("application/json", ignoreCase = true)) { + MAX_SHARED_BACKUP_BYTES + } else { + MAX_SHARED_TEXT_BYTES + } +} + +fun requireSharedPayloadWithinLimit(value: String, isTaskBridgeBackup: Boolean): String { + val maxBytes = if (isTaskBridgeBackup) MAX_SHARED_BACKUP_BYTES else MAX_SHARED_TEXT_BYTES + if (value.toByteArray(Charsets.UTF_8).size > maxBytes) throw SharedTextTooLargeException() + return value +} + +fun readUtf8TextWithLimit(input: InputStream, maxBytes: Int): String { + require(maxBytes > 0) { "maxBytes must be positive" } + val output = ByteArrayOutputStream(minOf(maxBytes, DEFAULT_BUFFER_SIZE)) + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + var totalBytes = 0 + while (true) { + val read = input.read(buffer, 0, minOf(buffer.size, maxBytes - totalBytes + 1)) + if (read == -1) break + totalBytes += read + if (totalBytes > maxBytes) throw SharedTextTooLargeException() + output.write(buffer, 0, read) + } + return output.toString(Charsets.UTF_8.name()) +} diff --git a/android/app/src/main/java/com/taskbridge/app/ui/i18n/TaskBridgeI18n.kt b/android/app/src/main/java/com/taskbridge/app/ui/i18n/TaskBridgeI18n.kt index 206ed84..cfeb078 100644 --- a/android/app/src/main/java/com/taskbridge/app/ui/i18n/TaskBridgeI18n.kt +++ b/android/app/src/main/java/com/taskbridge/app/ui/i18n/TaskBridgeI18n.kt @@ -28,6 +28,12 @@ data class TaskBridgeStrings( val hidePassword: String, val signingIn: String, val signIn: String, + val localWorkspaceAvailableTitle: String, + val localWorkspaceAvailableBody: String, + val continueWithLocalTasks: String, + val localWorkspaceMode: String, + val localWorkspaceModeBody: String, + val signInToSync: String, val loginRequired: String, val loginFailed: String, val createAccount: String, @@ -67,6 +73,11 @@ data class TaskBridgeStrings( val quickAddLabel: String, val quickAddPlaceholder: String, val autoFillHint: String, + val taskBodyDetails: String, + val taskHideBodyDetails: String, + val taskArrangementSettings: String, + val taskHideArrangementSettings: String, + val taskScheduleHelp: String, val moreSettings: String, val hideSettings: String, val content: String, @@ -110,10 +121,12 @@ data class TaskBridgeStrings( val trash: String, val connectionSettings: String, val serverUrl: String, + val changeServerAddress: String, val serverUrlHint: String, val testingConnection: String, val saveAndTestConnection: String, val advancedConnectionSettings: String, + val advancedConnectionSecondaryHint: String, val requestUrlAdvanced: String, val syncConnectionUrlAdvanced: String, val regenerateFromServerUrl: String, @@ -123,11 +136,13 @@ data class TaskBridgeStrings( val registerFirstUseTitle: String, val signInFirstUseBody: String, val registerFirstUseBody: String, - val setupChecklist: String, val hideSetupChoices: String, val setupHelpSummary: String, val localTrialHelp: String, val localTrialGuide: String, + val openLocalTrialGuide: String, + val openSelfHostGuide: String, + val externalLinkFailed: String, val selfHostGuide: String, val filter: String, val syncHealthTitle: String, @@ -182,6 +197,12 @@ private val zhStrings = TaskBridgeStrings( hidePassword = "隐藏密码", signingIn = "登录中...", signIn = "登录", + localWorkspaceAvailableTitle = "继续使用本机任务", + localWorkspaceAvailableBody = "这台设备仍保留上次使用的任务。可以继续查看和编辑,重新登录后再同步。", + continueWithLocalTasks = "进入本机工作区", + localWorkspaceMode = "本机模式", + localWorkspaceModeBody = "任务会保存在这台设备,但暂不会同步到其他设备。", + signInToSync = "登录并同步", loginRequired = "请输入用户名或邮箱和密码。", loginFailed = "登录失败。", createAccount = "创建账号", @@ -219,11 +240,16 @@ private val zhStrings = TaskBridgeStrings( keepDeviceVersion = "保留这台设备版本", addTask = "添加任务", quickAddLabel = "任务 / 自然语言快速添加", - quickAddPlaceholder = "例如:明天下午3点 写周报 #工作 P3", - autoFillHint = "只填标题即可保存。时间、标签和优先级会从标题中自动识别。", - moreSettings = "更多属性", - hideSettings = "收起属性", - content = "内容", + quickAddPlaceholder = "例如:写周报", + autoFillHint = "只填标题即可保存。需要时,也可以把时间、标签或优先级写进标题让系统识别。", + taskBodyDetails = "添加备注和清单", + taskHideBodyDetails = "收起备注和清单", + taskArrangementSettings = "时间与安排", + taskHideArrangementSettings = "收起时间与安排", + taskScheduleHelp = "计划日期表示哪天要做;截止时间表示最晚完成时间;提醒时间只负责通知。", + moreSettings = "更多:标签、重复、模板", + hideSettings = "收起更多", + content = "备注", priority = "优先级", tag = "标签", dueTime = "截止时间(按显示时区,可选)", @@ -247,7 +273,7 @@ private val zhStrings = TaskBridgeStrings( reminder = "提醒", snoozed = "稍后", list = "归类", - checklist = "子清单", + checklist = "清单", newChecklistItem = "新的清单项", complete = "完成", postponeTomorrow = "顺延到明天", @@ -264,25 +290,29 @@ private val zhStrings = TaskBridgeStrings( trash = "回收站", connectionSettings = "连接设置", serverUrl = "服务器地址", + changeServerAddress = "修改服务器地址", serverUrlHint = "保存后登录和同步都会使用这个地址。", testingConnection = "测试中...", saveAndTestConnection = "检查连接", - advancedConnectionSettings = "高级连接设置", - requestUrlAdvanced = "请求地址(高级)", - syncConnectionUrlAdvanced = "同步连接地址(高级)", + advancedConnectionSettings = "排障:自定义连接地址", + advancedConnectionSecondaryHint = "通常不需要修改;只有使用自定义代理或排查连接时打开。", + requestUrlAdvanced = "自定义请求地址", + syncConnectionUrlAdvanced = "自定义同步地址", regenerateFromServerUrl = "按服务器地址重新生成", savedBeforeSignIn = "登录会自动保存地址并检查连接;此按钮只用于排查服务器地址。", savedBeforeRegistration = "注册前保存,后续同步也会使用这些地址。", signInFirstUseTitle = "已有服务器地址就能登录", registerFirstUseTitle = "已有服务器地址就能创建账号", - signInFirstUseBody = "输入管理员提供的 TaskBridge 服务器地址后直接登录,客户端会自动检查连接。", - registerFirstUseBody = "输入管理员提供的 TaskBridge 服务器地址后创建账号,客户端会自动检查连接。", - setupChecklist = "推荐顺序:先确认服务器地址,再登录或创建账号。检查连接只用于排查服务器地址。", + signInFirstUseBody = "输入管理员或部署者提供的 TaskBridge 服务器地址后直接登录;还没有地址时请先向对方索取。", + registerFirstUseBody = "输入管理员或部署者提供的 TaskBridge 服务器地址后创建账号;还没有地址时请先向对方索取。", hideSetupChoices = "收起使用方式", - setupHelpSummary = "没有服务器地址?查看帮助", - localTrialHelp = "只想在自己的电脑上试用时,使用 Docker 本机试用。Android 模拟器连接电脑后端时使用 10.0.2.2。", - localTrialGuide = "手机访问时,服务器地址要填写运行后端那台电脑的局域网 IP。", - selfHostGuide = "自托管或生产环境请先完成后端部署,再回到这里填写服务器地址。", + setupHelpSummary = "没有服务器地址?", + localTrialHelp = "本机试用:先在后端电脑启动 TaskBridge 服务,再回到这里填写服务器地址。", + localTrialGuide = "Release Compose 的 Web、Windows 和 Android 都使用 8080 入口;真机填写服务电脑的局域网 IP,模拟器使用 10.0.2.2:8080。", + openLocalTrialGuide = "打开本机试用说明", + openSelfHostGuide = "打开自托管说明", + externalLinkFailed = "无法打开链接,请确认设备已安装浏览器后重试。", + selfHostGuide = "自托管:请先完成后端部署,再回到这里填写服务器地址。", filter = "筛选", syncHealthTitle = "同步问题", syncHealthAction = "查看同步详情", @@ -313,6 +343,12 @@ private val enStrings = TaskBridgeStrings( hidePassword = "Hide password", signingIn = "Signing in...", signIn = "Sign in", + localWorkspaceAvailableTitle = "Continue with local tasks", + localWorkspaceAvailableBody = "This device still has tasks from your last session. Keep viewing and editing them, then sign in to sync.", + continueWithLocalTasks = "Open local workspace", + localWorkspaceMode = "Local mode", + localWorkspaceModeBody = "Tasks stay on this device and will not sync to other devices yet.", + signInToSync = "Sign in and sync", loginRequired = "Enter username/email and password.", loginFailed = "Login failed.", createAccount = "Create account", @@ -350,11 +386,16 @@ private val enStrings = TaskBridgeStrings( keepDeviceVersion = "Keep this device version", addTask = "Add task", quickAddLabel = "Task / natural language quick add", - quickAddPlaceholder = "Example: write weekly report tomorrow 3pm #work P3", - autoFillHint = "Title is enough. Time, tag and priority can be recognized from the title.", - moreSettings = "More properties", - hideSettings = "Hide properties", - content = "Content", + quickAddPlaceholder = "Example: write weekly report", + autoFillHint = "Title is enough. You can also add time, tags, or priority in the title.", + taskBodyDetails = "Add notes and checklist", + taskHideBodyDetails = "Hide notes and checklist", + taskArrangementSettings = "Time and schedule", + taskHideArrangementSettings = "Hide time and schedule", + taskScheduleHelp = "Plan date is when you intend to work on it; due time is the latest finish time; reminder only sends a notification.", + moreSettings = "More: tags, repeat, templates", + hideSettings = "Hide more", + content = "Notes", priority = "Priority", tag = "Tag", dueTime = "Due time, display time zone, optional", @@ -395,25 +436,29 @@ private val enStrings = TaskBridgeStrings( trash = "Trash", connectionSettings = "Connection settings", serverUrl = "Server URL", + changeServerAddress = "Change server address", serverUrlHint = "Saved before sign-in and used by sync.", testingConnection = "Testing...", saveAndTestConnection = "Test connection", - advancedConnectionSettings = "Advanced connection settings", - requestUrlAdvanced = "Request URL (advanced)", - syncConnectionUrlAdvanced = "Sync connection URL (advanced)", + advancedConnectionSettings = "Troubleshooting: custom connection URLs", + advancedConnectionSecondaryHint = "Usually not needed; open only for custom proxies or connection troubleshooting.", + requestUrlAdvanced = "Request address for custom proxy", + syncConnectionUrlAdvanced = "Sync address for custom proxy", regenerateFromServerUrl = "Regenerate from server URL", savedBeforeSignIn = "Signing in saves and checks the connection automatically. Use this button only to troubleshoot the server address.", savedBeforeRegistration = "Saved before registration and used by sync.", signInFirstUseTitle = "Sign in with a server address", registerFirstUseTitle = "Create an account with a server address", - signInFirstUseBody = "Enter the TaskBridge server address your administrator gave you, then sign in. The app checks the connection automatically.", - registerFirstUseBody = "Enter the TaskBridge server address your administrator gave you, then create an account. The app checks the connection automatically.", - setupChecklist = "Recommended order: confirm the server URL, then sign in or create an account. Test connection is only for troubleshooting the server address.", + signInFirstUseBody = "Enter the TaskBridge server address from your administrator or deployer, then sign in. If you do not have one, ask them first.", + registerFirstUseBody = "Enter the TaskBridge server address from your administrator or deployer, then create an account. If you do not have one, ask them first.", hideSetupChoices = "Hide setup choices", - setupHelpSummary = "No server address? View setup help", - localTrialHelp = "Use Docker local trial when you only want to test on your own computer. Android emulator reaches the computer backend through 10.0.2.2.", - localTrialGuide = "From a phone, use the LAN IP of the computer running the backend.", - selfHostGuide = "For self-hosting or production, finish backend deployment first, then enter the server address here.", + setupHelpSummary = "No server address?", + localTrialHelp = "Local trial: start the TaskBridge service on the backend computer first, then return here and enter the server address.", + localTrialGuide = "Release Compose uses port 8080 for Web, Windows, and Android. Use the server computer's LAN IP on a phone, or 10.0.2.2:8080 in the emulator.", + openLocalTrialGuide = "Open local trial guide", + openSelfHostGuide = "Open self-hosting guide", + externalLinkFailed = "Could not open the link. Install or enable a browser, then try again.", + selfHostGuide = "Self-hosting: finish backend deployment first, then enter the server address here.", filter = "Filter", syncHealthTitle = "Sync issues", syncHealthAction = "View sync details", diff --git a/android/app/src/main/java/com/taskbridge/app/ui/login/LoginScreen.kt b/android/app/src/main/java/com/taskbridge/app/ui/login/LoginScreen.kt index 0c564f4..155bc68 100644 --- a/android/app/src/main/java/com/taskbridge/app/ui/login/LoginScreen.kt +++ b/android/app/src/main/java/com/taskbridge/app/ui/login/LoginScreen.kt @@ -23,13 +23,16 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.taskbridge.app.ui.components.AppDropdownField +import com.taskbridge.app.ui.components.AppDynamicStatusText import com.taskbridge.app.ui.components.AppHeader import com.taskbridge.app.ui.components.AppPage import com.taskbridge.app.ui.components.AppPanel +import com.taskbridge.app.ui.components.tryOpenExternalUri import com.taskbridge.app.ui.components.loginFailureMessageKey import com.taskbridge.app.ui.components.registrationDisabledMessageKey import com.taskbridge.app.ui.components.userFacingAuthErrorMessage @@ -39,11 +42,18 @@ import com.taskbridge.app.ui.i18n.AppLanguage import com.taskbridge.app.ui.i18n.LocalAppLanguage import com.taskbridge.app.ui.i18n.LocalTaskBridgeStrings +private const val LOCAL_TRIAL_GUIDE_URL = + "https://github.com/27xk/TaskBridge/blob/main/docs/user-quick-start.md#%E6%B2%A1%E6%9C%89%E6%9C%8D%E5%8A%A1%E5%99%A8%E5%9C%B0%E5%9D%80" +private const val SELF_HOST_GUIDE_URL = + "https://github.com/27xk/TaskBridge/blob/main/deploy/README.md" + @Composable fun LoginScreen( viewModel: LoginViewModel, + canContinueOffline: Boolean, onLanguageChange: (AppLanguage) -> Unit, onLoginSuccess: () -> Unit, + onContinueOffline: () -> Unit, onRegisterClick: () -> Unit, ) { val state by viewModel.uiState.collectAsStateWithLifecycle() @@ -106,25 +116,35 @@ fun LoginScreen( onSelect = onLanguageChange, ) - FirstUseGuide(strings = strings) + SignInPanel( + state = state, + strings = strings, + registrationAvailability = registrationAvailability, + showRegistrationStatusUnknownAfterCheck = showRegistrationStatusUnknownAfterCheck, + isEnglish = isEnglish, + canContinueOffline = canContinueOffline, + onServerBaseUrlChange = viewModel::updateServerBaseUrl, + onUsernameOrEmailChange = viewModel::updateUsernameOrEmail, + onPasswordChange = viewModel::updatePassword, + onSignIn = { viewModel.login(onLoginSuccess) }, + onContinueOffline = onContinueOffline, + onCreateAccount = { + if (state.registrationStatusKnown && state.registrationEnabled) { + viewModel.saveConnectionSettings() + onRegisterClick() + } else { + showRegistrationStatusUnknownAfterCheck = false + pendingRegisterNavigation = true + viewModel.testConnection() + } + }, + ) AppPanel { Text( text = strings.connectionSettings, style = MaterialTheme.typography.titleMedium, ) - OutlinedTextField( - value = state.serverBaseUrl, - onValueChange = viewModel::updateServerBaseUrl, - label = { Text(strings.serverUrl) }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - Text( - text = strings.serverUrlHint, - color = MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.bodySmall, - ) localhostWarningText(state.serverBaseUrl, isEnglish)?.let { warning -> Text( text = warning, @@ -142,24 +162,25 @@ fun LoginScreen( ) } state.connectionMessage?.let { - Text( + AppDynamicStatusText( text = localizeConnectionMessage(it, isEnglish), - color = if (state.connectionMessageIsError) { - MaterialTheme.colorScheme.error - } else { - MaterialTheme.colorScheme.primary - }, - style = MaterialTheme.typography.bodySmall, + isError = state.connectionMessageIsError, ) } TextButton(onClick = { advancedConnectionOpen = !advancedConnectionOpen }) { Text(strings.advancedConnectionSettings) } + Text( + text = strings.advancedConnectionSecondaryHint, + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodySmall, + ) if (advancedConnectionOpen) { OutlinedTextField( value = state.apiBaseUrl, onValueChange = viewModel::updateApiBaseUrl, label = { Text(strings.requestUrlAdvanced) }, + isError = state.connectionMessageIsError, singleLine = true, modifier = Modifier.fillMaxWidth(), ) @@ -167,6 +188,7 @@ fun LoginScreen( value = state.webSocketUrl, onValueChange = viewModel::updateWebSocketUrl, label = { Text(strings.syncConnectionUrlAdvanced) }, + isError = state.connectionMessageIsError, singleLine = true, modifier = Modifier.fillMaxWidth(), ) @@ -181,73 +203,119 @@ fun LoginScreen( ) } - AppPanel { - Text( - text = strings.signIn, - style = MaterialTheme.typography.titleMedium, - ) - OutlinedTextField( - value = state.usernameOrEmail, - onValueChange = viewModel::updateUsernameOrEmail, - label = { Text(strings.usernameOrEmail) }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email), - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - PasswordTextField( - value = state.password, - onValueChange = viewModel::updatePassword, - strings = strings, - modifier = Modifier.fillMaxWidth(), - ) - state.error?.let { - Text(localizeLoginError(it, strings), color = MaterialTheme.colorScheme.error) - } - Button( - onClick = { viewModel.login(onLoginSuccess) }, - enabled = !state.isLoading, - modifier = Modifier.fillMaxWidth(), - ) { - Text(if (state.isLoading) strings.signingIn else strings.signIn) - } - if (registrationAvailability.showCreateAccountAction) { - TextButton( - onClick = { - if (state.registrationStatusKnown && state.registrationEnabled) { - viewModel.saveConnectionSettings() - onRegisterClick() - } else { - showRegistrationStatusUnknownAfterCheck = false - pendingRegisterNavigation = true - viewModel.testConnection() - } - }, - enabled = !state.isLoading && !state.isTestingConnection, - modifier = Modifier.fillMaxWidth(), - ) { - Text(registrationAvailability.actionText ?: strings.createAccount) - } - } - val registrationHelperText = if (showRegistrationStatusUnknownAfterCheck && !state.registrationStatusKnown) { - registrationStatusUnknownAfterCheckHelp(isEnglish) - } else { - registrationAvailability.helperText - } - if (registrationHelperText != null) { - Text( - text = registrationHelperText, - color = MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.bodySmall, - ) - } + FirstUseGuide(strings = strings) + } + } +} + +@Composable +private fun SignInPanel( + state: LoginUiState, + strings: com.taskbridge.app.ui.i18n.TaskBridgeStrings, + registrationAvailability: RegistrationAvailabilityUi, + showRegistrationStatusUnknownAfterCheck: Boolean, + isEnglish: Boolean, + canContinueOffline: Boolean, + onServerBaseUrlChange: (String) -> Unit, + onUsernameOrEmailChange: (String) -> Unit, + onPasswordChange: (String) -> Unit, + onSignIn: () -> Unit, + onContinueOffline: () -> Unit, + onCreateAccount: () -> Unit, +) { + AppPanel { + Text( + text = strings.signIn, + style = MaterialTheme.typography.titleMedium, + ) + OutlinedTextField( + value = state.serverBaseUrl, + onValueChange = onServerBaseUrlChange, + label = { Text(strings.serverUrl) }, + isError = state.connectionMessageIsError, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + Text( + text = strings.serverUrlHint, + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodySmall, + ) + if (canContinueOffline) { + Text( + text = strings.localWorkspaceAvailableTitle, + style = MaterialTheme.typography.titleSmall, + ) + Text( + text = strings.localWorkspaceAvailableBody, + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodySmall, + ) + OutlinedButton( + onClick = onContinueOffline, + modifier = Modifier.fillMaxWidth(), + ) { + Text(strings.continueWithLocalTasks) } } + OutlinedTextField( + value = state.usernameOrEmail, + onValueChange = onUsernameOrEmailChange, + label = { Text(strings.usernameOrEmail) }, + isError = state.error != null, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email), + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + PasswordTextField( + value = state.password, + onValueChange = onPasswordChange, + strings = strings, + isError = state.error != null, + modifier = Modifier.fillMaxWidth(), + ) + state.error?.let { + AppDynamicStatusText( + text = localizeLoginError(it, strings), + isError = true, + ) + } + Button( + onClick = onSignIn, + enabled = !state.isLoading, + modifier = Modifier.fillMaxWidth(), + ) { + Text(if (state.isLoading) strings.signingIn else strings.signIn) + } + if (registrationAvailability.showCreateAccountAction) { + TextButton( + onClick = onCreateAccount, + enabled = !state.isLoading && !state.isTestingConnection, + modifier = Modifier.fillMaxWidth(), + ) { + Text(registrationAvailability.actionText ?: strings.createAccount) + } + } + val registrationHelperText = if (showRegistrationStatusUnknownAfterCheck && !state.registrationStatusKnown) { + registrationStatusUnknownAfterCheckHelp(isEnglish) + } else { + registrationAvailability.helperText + } + if (registrationHelperText != null) { + Text( + text = registrationHelperText, + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodySmall, + ) + } } } @Composable private fun FirstUseGuide(strings: com.taskbridge.app.ui.i18n.TaskBridgeStrings) { var detailsOpen by remember { mutableStateOf(false) } + var externalLinkError by remember { mutableStateOf("") } + val uriHandler = LocalUriHandler.current AppPanel { Text( text = strings.signInFirstUseTitle, @@ -262,11 +330,6 @@ private fun FirstUseGuide(strings: com.taskbridge.app.ui.i18n.TaskBridgeStrings) Text(if (detailsOpen) strings.hideSetupChoices else strings.setupHelpSummary) } if (detailsOpen) { - Text( - text = strings.setupChecklist, - color = MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.bodySmall, - ) Text( text = strings.localTrialHelp, color = MaterialTheme.colorScheme.onSurfaceVariant, @@ -282,6 +345,36 @@ private fun FirstUseGuide(strings: com.taskbridge.app.ui.i18n.TaskBridgeStrings) color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.bodySmall, ) + OutlinedButton( + onClick = { + externalLinkError = if (tryOpenExternalUri(uriHandler, LOCAL_TRIAL_GUIDE_URL)) { + "" + } else { + strings.externalLinkFailed + } + }, + modifier = Modifier.fillMaxWidth(), + ) { + Text(strings.openLocalTrialGuide) + } + TextButton( + onClick = { + externalLinkError = if (tryOpenExternalUri(uriHandler, SELF_HOST_GUIDE_URL)) { + "" + } else { + strings.externalLinkFailed + } + }, + modifier = Modifier.fillMaxWidth(), + ) { + Text(strings.openSelfHostGuide) + } + if (externalLinkError.isNotBlank()) { + AppDynamicStatusText( + text = externalLinkError, + isError = true, + ) + } } } } diff --git a/android/app/src/main/java/com/taskbridge/app/ui/login/LoginViewModel.kt b/android/app/src/main/java/com/taskbridge/app/ui/login/LoginViewModel.kt index a1a3437..229eab2 100644 --- a/android/app/src/main/java/com/taskbridge/app/ui/login/LoginViewModel.kt +++ b/android/app/src/main/java/com/taskbridge/app/ui/login/LoginViewModel.kt @@ -53,13 +53,6 @@ class LoginViewModel( webSocketUrl = tokenDataStore.webSocketUrl.first(), ) } - authRepository.registrationEnabled() - .onSuccess { enabled -> - _uiState.update { it.copy(registrationEnabled = enabled, registrationStatusKnown = true) } - } - .onFailure { - _uiState.update { it.copy(registrationEnabled = false, registrationStatusKnown = false) } - } } } @@ -174,10 +167,6 @@ class LoginViewModel( _uiState.update { it.copy(isLoading = false) } return@launch } - if (!ensureConnectionReadyForAuth()) { - _uiState.update { it.copy(isLoading = false) } - return@launch - } authRepository.login(state.usernameOrEmail.trim(), state.password) .onSuccess { syncManager.enqueueNetworkSync() diff --git a/android/app/src/main/java/com/taskbridge/app/ui/login/PasswordTextField.kt b/android/app/src/main/java/com/taskbridge/app/ui/login/PasswordTextField.kt index 599a35c..57660f7 100644 --- a/android/app/src/main/java/com/taskbridge/app/ui/login/PasswordTextField.kt +++ b/android/app/src/main/java/com/taskbridge/app/ui/login/PasswordTextField.kt @@ -22,6 +22,8 @@ fun PasswordTextField( strings: TaskBridgeStrings, modifier: Modifier = Modifier, enabled: Boolean = true, + label: String = strings.password, + isError: Boolean = false, ) { var passwordVisible by remember { mutableStateOf(false) } val toggleLabel = if (passwordVisible) strings.hidePassword else strings.showPassword @@ -29,8 +31,9 @@ fun PasswordTextField( OutlinedTextField( value = value, onValueChange = onValueChange, - label = { Text(strings.password) }, + label = { Text(label) }, enabled = enabled, + isError = isError, visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), trailingIcon = { diff --git a/android/app/src/main/java/com/taskbridge/app/ui/login/RegisterScreen.kt b/android/app/src/main/java/com/taskbridge/app/ui/login/RegisterScreen.kt index a0ac575..ff8ea1b 100644 --- a/android/app/src/main/java/com/taskbridge/app/ui/login/RegisterScreen.kt +++ b/android/app/src/main/java/com/taskbridge/app/ui/login/RegisterScreen.kt @@ -20,15 +20,19 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.taskbridge.app.ui.components.AppDropdownField +import com.taskbridge.app.ui.components.AppDynamicStatusText import com.taskbridge.app.ui.components.AppHeader import com.taskbridge.app.ui.components.AppPage import com.taskbridge.app.ui.components.AppPanel +import com.taskbridge.app.ui.components.tryOpenExternalUri import com.taskbridge.app.ui.components.registrationDisabledMessageKey import com.taskbridge.app.ui.components.registrationFailureMessageKey import com.taskbridge.app.ui.components.registrationStatusUnknownMessageKey @@ -38,6 +42,13 @@ import com.taskbridge.app.ui.components.languageOptions import com.taskbridge.app.ui.i18n.AppLanguage import com.taskbridge.app.ui.i18n.LocalAppLanguage import com.taskbridge.app.ui.i18n.LocalTaskBridgeStrings +import com.taskbridge.app.ui.i18n.TaskBridgeStrings +import kotlinx.coroutines.launch + +private const val LOCAL_TRIAL_GUIDE_URL = + "https://github.com/27xk/TaskBridge/blob/main/docs/user-quick-start.md#%E6%B2%A1%E6%9C%89%E6%9C%8D%E5%8A%A1%E5%99%A8%E5%9C%B0%E5%9D%80" +private const val SELF_HOST_GUIDE_URL = + "https://github.com/27xk/TaskBridge/blob/main/deploy/README.md" @Composable fun RegisterScreen( @@ -51,6 +62,8 @@ fun RegisterScreen( val language = LocalAppLanguage.current var languageMenuOpen by remember { mutableStateOf(false) } var advancedConnectionOpen by remember { mutableStateOf(false) } + val scrollState = rememberScrollState() + val scope = rememberCoroutineScope() val isEnglish = language == AppLanguage.English val registrationAvailability = registrationAvailabilityUi( registrationStatusKnown = state.registrationStatusKnown, @@ -63,7 +76,7 @@ fun RegisterScreen( Column( modifier = Modifier .fillMaxSize() - .verticalScroll(rememberScrollState()) + .verticalScroll(scrollState) .padding(horizontal = 20.dp, vertical = 24.dp), verticalArrangement = Arrangement.spacedBy(18.dp), ) { @@ -86,75 +99,9 @@ fun RegisterScreen( AppPanel { Text( - text = strings.connectionSettings, + text = strings.createAccount, style = MaterialTheme.typography.titleMedium, ) - OutlinedTextField( - value = state.serverBaseUrl, - onValueChange = viewModel::updateServerBaseUrl, - label = { Text(strings.serverUrl) }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - Text( - text = strings.serverUrlHint, - color = MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.bodySmall, - ) - localhostWarningText(state.serverBaseUrl, isEnglish)?.let { warning -> - Text( - text = warning, - color = MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.bodySmall, - ) - } - OutlinedButton( - onClick = { viewModel.testConnection() }, - enabled = !state.isTestingConnection, - modifier = Modifier.fillMaxWidth(), - ) { - Text( - if (state.isTestingConnection) strings.testingConnection else strings.saveAndTestConnection, - ) - } - state.connectionMessage?.let { - Text( - text = localizeConnectionMessage(it, isEnglish), - color = if (state.connectionMessageIsError) { - MaterialTheme.colorScheme.error - } else { - MaterialTheme.colorScheme.primary - }, - style = MaterialTheme.typography.bodySmall, - ) - } - TextButton(onClick = { advancedConnectionOpen = !advancedConnectionOpen }) { - Text(strings.advancedConnectionSettings) - } - if (advancedConnectionOpen) { - OutlinedTextField( - value = state.apiBaseUrl, - onValueChange = viewModel::updateApiBaseUrl, - label = { Text(strings.requestUrlAdvanced) }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - OutlinedTextField( - value = state.webSocketUrl, - onValueChange = viewModel::updateWebSocketUrl, - label = { Text(strings.syncConnectionUrlAdvanced) }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - TextButton(onClick = { viewModel.resetGeneratedEndpoints() }) { - Text(strings.regenerateFromServerUrl) - } - } - Text( - text = strings.savedBeforeRegistration, - color = MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.bodySmall, - ) registrationAvailability.helperText?.let { Text( text = it, @@ -166,6 +113,7 @@ fun RegisterScreen( value = state.username, onValueChange = viewModel::updateUsername, label = { Text(strings.username) }, + isError = state.error != null, enabled = registrationAvailability.canEditAccountFields, singleLine = true, modifier = Modifier.fillMaxWidth(), @@ -174,6 +122,7 @@ fun RegisterScreen( value = state.email, onValueChange = viewModel::updateEmail, label = { Text(strings.email) }, + isError = state.error != null, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email), enabled = registrationAvailability.canEditAccountFields, singleLine = true, @@ -183,30 +132,141 @@ fun RegisterScreen( value = state.password, onValueChange = viewModel::updatePassword, strings = strings, + isError = state.error != null, enabled = registrationAvailability.canEditAccountFields, modifier = Modifier.fillMaxWidth(), ) state.error?.let { - Text(localizeRegisterError(it, strings), color = MaterialTheme.colorScheme.error) + AppDynamicStatusText( + text = localizeRegisterError(it, strings), + isError = true, + ) } Button( onClick = { viewModel.register(onRegisterSuccess) }, - enabled = state.registrationStatusKnown && state.registrationEnabled && registrationAvailability.canSubmitRegistration, + enabled = registrationAvailability.canSubmitRegistration, modifier = Modifier.fillMaxWidth(), ) { - Text(if (state.isLoading) strings.creating else strings.createAccount) + Text(if (state.isLoading) strings.creating else registrationAvailability.actionText ?: strings.createAccount) } + RegistrationConnectionFeedbackNearSubmit( + connectionMessage = state.connectionMessage, + connectionMessageIsError = state.connectionMessageIsError, + isEnglish = isEnglish, + strings = strings, + onChangeServerAddress = { + scope.launch { + scrollState.animateScrollTo(scrollState.maxValue) + } + }, + ) TextButton(onClick = onLoginClick, modifier = Modifier.fillMaxWidth()) { Text(strings.backToSignIn) } } + + AppPanel { + Text( + text = strings.connectionSettings, + style = MaterialTheme.typography.titleMedium, + ) + OutlinedTextField( + value = state.serverBaseUrl, + onValueChange = viewModel::updateServerBaseUrl, + label = { Text(strings.serverUrl) }, + isError = state.connectionMessageIsError, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + Text( + text = strings.serverUrlHint, + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodySmall, + ) + localhostWarningText(state.serverBaseUrl, isEnglish)?.let { warning -> + Text( + text = warning, + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodySmall, + ) + } + OutlinedButton( + onClick = { viewModel.testConnection() }, + enabled = !state.isTestingConnection, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + if (state.isTestingConnection) strings.testingConnection else strings.saveAndTestConnection, + ) + } + state.connectionMessage?.let { + AppDynamicStatusText( + text = localizeConnectionMessage(it, isEnglish), + isError = state.connectionMessageIsError, + ) + } + TextButton(onClick = { advancedConnectionOpen = !advancedConnectionOpen }) { + Text(strings.advancedConnectionSettings) + } + Text( + text = strings.advancedConnectionSecondaryHint, + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodySmall, + ) + if (advancedConnectionOpen) { + OutlinedTextField( + value = state.apiBaseUrl, + onValueChange = viewModel::updateApiBaseUrl, + label = { Text(strings.requestUrlAdvanced) }, + isError = state.connectionMessageIsError, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = state.webSocketUrl, + onValueChange = viewModel::updateWebSocketUrl, + label = { Text(strings.syncConnectionUrlAdvanced) }, + isError = state.connectionMessageIsError, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + TextButton(onClick = { viewModel.resetGeneratedEndpoints() }) { + Text(strings.regenerateFromServerUrl) + } + } + Text( + text = strings.savedBeforeRegistration, + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodySmall, + ) + } } } } +@Composable +private fun RegistrationConnectionFeedbackNearSubmit( + connectionMessage: String?, + connectionMessageIsError: Boolean, + isEnglish: Boolean, + strings: TaskBridgeStrings, + onChangeServerAddress: () -> Unit, +) { + if (connectionMessage.isNullOrBlank() || !connectionMessageIsError) return + AppDynamicStatusText( + text = localizeConnectionMessage(connectionMessage, isEnglish), + isError = true, + ) + TextButton(onClick = onChangeServerAddress, modifier = Modifier.fillMaxWidth()) { + Text(strings.changeServerAddress) + } +} + @Composable private fun FirstUseGuide(strings: com.taskbridge.app.ui.i18n.TaskBridgeStrings) { var detailsOpen by remember { mutableStateOf(false) } + var externalLinkError by remember { mutableStateOf("") } + val uriHandler = LocalUriHandler.current AppPanel { Text( text = strings.registerFirstUseTitle, @@ -221,11 +281,6 @@ private fun FirstUseGuide(strings: com.taskbridge.app.ui.i18n.TaskBridgeStrings) Text(if (detailsOpen) strings.hideSetupChoices else strings.setupHelpSummary) } if (detailsOpen) { - Text( - text = strings.setupChecklist, - color = MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.bodySmall, - ) Text( text = strings.localTrialHelp, color = MaterialTheme.colorScheme.onSurfaceVariant, @@ -241,6 +296,36 @@ private fun FirstUseGuide(strings: com.taskbridge.app.ui.i18n.TaskBridgeStrings) color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.bodySmall, ) + OutlinedButton( + onClick = { + externalLinkError = if (tryOpenExternalUri(uriHandler, LOCAL_TRIAL_GUIDE_URL)) { + "" + } else { + strings.externalLinkFailed + } + }, + modifier = Modifier.fillMaxWidth(), + ) { + Text(strings.openLocalTrialGuide) + } + TextButton( + onClick = { + externalLinkError = if (tryOpenExternalUri(uriHandler, SELF_HOST_GUIDE_URL)) { + "" + } else { + strings.externalLinkFailed + } + }, + modifier = Modifier.fillMaxWidth(), + ) { + Text(strings.openSelfHostGuide) + } + if (externalLinkError.isNotBlank()) { + AppDynamicStatusText( + text = externalLinkError, + isError = true, + ) + } } } } @@ -252,7 +337,7 @@ private fun localizeRegisterError(error: String, strings: com.taskbridge.app.ui. "Registration is disabled on this server." -> registrationDisabledHelp(strings.chinese != "中文") "Registration failed." -> strings.registrationFailed registrationDisabledMessageKey -> registrationDisabledHelp(isEnglish) - registrationStatusUnknownMessageKey -> registrationStatusPendingHelp(isEnglish) + registrationStatusUnknownMessageKey -> registrationStatusUnknownAfterCheckHelp(isEnglish) registrationFailureMessageKey -> strings.registrationFailed else -> userFacingAuthErrorMessage(error, isEnglish, strings.registrationFailed) } diff --git a/android/app/src/main/java/com/taskbridge/app/ui/login/RegistrationAvailability.kt b/android/app/src/main/java/com/taskbridge/app/ui/login/RegistrationAvailability.kt index b3862a7..6f62567 100644 --- a/android/app/src/main/java/com/taskbridge/app/ui/login/RegistrationAvailability.kt +++ b/android/app/src/main/java/com/taskbridge/app/ui/login/RegistrationAvailability.kt @@ -24,7 +24,7 @@ fun registrationAvailabilityUi( !registrationStatusKnown -> RegistrationAvailabilityUi( showCreateAccountAction = true, canEditAccountFields = !isLoading, - canSubmitRegistration = false, + canSubmitRegistration = !isLoading, helperText = registrationStatusPendingHelp(isEnglish), actionText = registrationCheckAction(isEnglish), ) diff --git a/android/app/src/main/java/com/taskbridge/app/ui/settings/SettingsScreen.kt b/android/app/src/main/java/com/taskbridge/app/ui/settings/SettingsScreen.kt index 61e6b7f..f0c4dca 100644 --- a/android/app/src/main/java/com/taskbridge/app/ui/settings/SettingsScreen.kt +++ b/android/app/src/main/java/com/taskbridge/app/ui/settings/SettingsScreen.kt @@ -16,6 +16,7 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Slider import androidx.compose.material3.Text @@ -28,16 +29,19 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.unit.dp import androidx.core.content.FileProvider import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.google.gson.Gson +import com.taskbridge.app.BuildConfig import com.taskbridge.app.data.datastore.TokenDataStore import com.taskbridge.app.data.datastore.deriveNetworkEndpoints import com.taskbridge.app.data.datastore.inferServerBaseUrlFromApi import com.taskbridge.app.data.datastore.validateApiBaseUrl import com.taskbridge.app.data.datastore.validateWebSocketUrl import com.taskbridge.app.data.repository.AuthRepository +import com.taskbridge.app.data.remote.dto.AuthSessionDto import com.taskbridge.app.data.local.SyncQueueCounts import com.taskbridge.app.data.local.SyncQueueEntity import com.taskbridge.app.data.repository.BackupImportErrorCode @@ -45,16 +49,20 @@ import com.taskbridge.app.data.repository.BackupImportPreview import com.taskbridge.app.data.repository.BackupImportUndoItem import com.taskbridge.app.data.repository.TaskRepository import com.taskbridge.app.sync.SyncManager +import com.taskbridge.app.sync.SyncRunState import com.taskbridge.app.ui.components.AppDropdownField +import com.taskbridge.app.ui.components.AppDynamicStatusText import com.taskbridge.app.ui.components.AppHeader import com.taskbridge.app.ui.components.AppPage import com.taskbridge.app.ui.components.AppPanel import com.taskbridge.app.ui.components.AppSection import com.taskbridge.app.ui.components.AppUiOption import com.taskbridge.app.ui.components.languageOptions +import com.taskbridge.app.ui.components.tryOpenExternalUri import com.taskbridge.app.ui.components.userFacingConnectionErrorMessage import com.taskbridge.app.ui.i18n.AppLanguage import com.taskbridge.app.ui.i18n.LocalTaskBridgeStrings +import com.taskbridge.app.ui.login.PasswordTextField import com.taskbridge.app.utils.ShanghaiTime import com.taskbridge.app.widget.TodayTaskWidgetUpdateWorker import com.taskbridge.app.widget.WidgetConstants @@ -69,6 +77,7 @@ import java.time.ZoneId import kotlin.math.roundToInt private const val MAX_SELECTED_BACKUP_BYTES = 20_000_000 +private const val LATEST_RELEASE_URL = "https://github.com/27xk/TaskBridge/releases/latest" private data class PendingBackupImport( val raw: String, @@ -143,9 +152,11 @@ fun SettingsScreen( initialSection: String? = null, onLanguageChange: (AppLanguage) -> Unit, onBack: () -> Unit, + onOpenConflictTasks: () -> Unit, onLogout: () -> Unit, ) { val context = androidx.compose.ui.platform.LocalContext.current + val uriHandler = LocalUriHandler.current val strings = LocalTaskBridgeStrings.current val scope = rememberCoroutineScope() val widgetOpacity by tokenDataStore.widgetOpacityPercent.collectAsStateWithLifecycle(initialValue = 78) @@ -181,7 +192,9 @@ fun SettingsScreen( var failedSyncTaskCount by remember { mutableStateOf(0) } var exhaustedSyncQueuePreview by remember { mutableStateOf>(emptyList()) } var syncRecoveryNote by remember { mutableStateOf("") } + var syncRecoveryNoteIsError by remember { mutableStateOf(false) } var backupImportNote by remember { mutableStateOf("") } + var backupImportNoteIsError by remember { mutableStateOf(false) } var pendingBackupImport by remember { mutableStateOf(null) } var confirmBackupImport by remember { mutableStateOf(false) } var confirmUndoBackupImport by remember { mutableStateOf(false) } @@ -189,6 +202,7 @@ fun SettingsScreen( var lastImportedBackupUndoItems by remember { mutableStateOf>(emptyList()) } var connectionNote by remember { mutableStateOf("") } var connectionNoteIsError by remember { mutableStateOf(false) } + var externalLinkError by remember { mutableStateOf("") } var testConnection by remember { mutableStateOf(false) } var advancedConnectionOpen by remember { mutableStateOf(false) } var supportToolsOpen by remember(initialSection) { mutableStateOf(initialSection == "sync-recovery") } @@ -196,14 +210,131 @@ fun SettingsScreen( var confirmLogoutWithPendingSync by remember { mutableStateOf(false) } var confirmExportBackup by remember { mutableStateOf(false) } var confirmClearLocalData by remember { mutableStateOf(false) } + var confirmRevokeOtherSessions by remember { mutableStateOf(false) } + var currentPassword by remember { mutableStateOf("") } + var newPassword by remember { mutableStateOf("") } + var confirmNewPassword by remember { mutableStateOf("") } + var passwordNote by remember { mutableStateOf("") } + var passwordNoteIsError by remember { mutableStateOf(false) } + var changingPassword by remember { mutableStateOf(false) } + var sessions by remember { mutableStateOf>(emptyList()) } + var sessionsLoading by remember { mutableStateOf(false) } + var sessionsLoaded by remember { mutableStateOf(false) } + var sessionNote by remember { mutableStateOf("") } + var sessionNoteIsError by remember { mutableStateOf(false) } var selectedSection by remember(initialSection) { mutableStateOf(initialSettingsSection(initialSection)) } val isEnglish = language == AppLanguage.English + + suspend fun refreshAccountSessions() { + sessionsLoading = true + authRepository.sessions() + .onSuccess { loadedSessions -> + sessions = loadedSessions + sessionsLoaded = true + if (sessionNoteIsError) sessionNote = "" + sessionNoteIsError = false + } + .onFailure { + sessionNoteIsError = true + sessionNote = if (isEnglish) { + "Could not load signed-in sessions. Try again." + } else { + "无法加载登录会话,请重试。" + } + } + sessionsLoading = false + } + + fun loadSessions() { + scope.launch { refreshAccountSessions() } + } + + fun submitPasswordChange() { + val validationError = when { + currentPassword.isBlank() -> if (isEnglish) "Enter your current password." else "请输入当前密码。" + newPassword.length !in 8..128 -> if (isEnglish) { + "The new password must contain 8 to 128 characters." + } else { + "新密码长度必须为 8 到 128 个字符。" + } + newPassword != confirmNewPassword -> if (isEnglish) { + "The new passwords do not match." + } else { + "两次输入的新密码不一致。" + } + else -> null + } + if (validationError != null) { + passwordNoteIsError = true + passwordNote = validationError + return + } + scope.launch { + changingPassword = true + authRepository.changePassword(currentPassword, newPassword) + .onSuccess { revoked -> + currentPassword = "" + newPassword = "" + confirmNewPassword = "" + passwordNoteIsError = false + passwordNote = if (isEnglish) { + "Password changed. $revoked other sessions were signed out." + } else { + "密码已修改,另有 $revoked 个会话已退出。" + } + refreshAccountSessions() + } + .onFailure { + passwordNoteIsError = true + passwordNote = if (isEnglish) { + "Could not change the password. Check the current password and try again." + } else { + "无法修改密码,请检查当前密码后重试。" + } + } + changingPassword = false + } + } + + fun revokeOtherSessions() { + if (sessionsLoading) return + sessionsLoading = true + scope.launch { + try { + authRepository.revokeOtherSessions() + .onSuccess { revoked -> + sessionNoteIsError = false + sessionNote = if (isEnglish) { + "$revoked other sessions were signed out." + } else { + "已退出其他设备上的 $revoked 个会话。" + } + refreshAccountSessions() + } + .onFailure { + sessionNoteIsError = true + sessionNote = if (isEnglish) { + "Could not sign out other sessions. Try again." + } else { + "无法退出其他会话,请重试。" + } + } + } finally { + sessionsLoading = false + } + } + } LaunchedEffect(initialSection) { selectedSection = initialSettingsSection(initialSection) if (initialSection == "sync-recovery") { supportToolsOpen = true } } + LaunchedEffect(selectedSection) { + if (selectedSection == SettingsSection.Account && !sessionsLoaded && !sessionsLoading) { + refreshAccountSessions() + } + } LaunchedEffect(lastBackupImportUndoItemsRaw) { val undoItems = parseBackupImportUndoItems(gson, lastBackupImportUndoItemsRaw) lastImportedBackupUndoItems = undoItems @@ -288,6 +419,7 @@ fun SettingsScreen( val exhaustedQueueCount = syncQueueCounts?.exhausted ?: 0 val recoverableSyncIssueCount = pendingQueueCount + exhaustedQueueCount + failedSyncTaskCount val clearLocalDataBlocked = recoverableSyncIssueCount > 0 || conflictTaskCount > 0 + val syncRecoveryToolsVisible = supportToolsOpen || recoverableSyncIssueCount > 0 || conflictTaskCount > 0 suspend fun refreshSyncDiagnostics() { syncQueueCounts = taskRepository.getSyncQueueCounts() @@ -300,6 +432,7 @@ fun SettingsScreen( scope.launch { val preview = taskRepository.previewBackupImport(raw) if (preview.errorCode != null || preview.importableCount <= 0) { + backupImportNoteIsError = true backupImportNote = formatBackupImportFailureMessage(preview.errorCode, isEnglish) pendingBackupImport = null confirmBackupImport = false @@ -324,6 +457,7 @@ fun SettingsScreen( } else { tokenDataStore.saveLastBackupImportUndoItems(gson.toJson(result.importedUndoItems)) } + backupImportNoteIsError = result.importedCount <= 0 backupImportNote = if (result.importedCount > 0) { val skippedNote = if (result.skippedCount > 0) { if (isEnglish) " Skipped ${result.skippedCount} invalid tasks." else " 跳过 ${result.skippedCount} 条无效任务。" @@ -358,6 +492,7 @@ fun SettingsScreen( skippedChangedCount = result.skippedChangedCount, isEnglish = isEnglish, ) + backupImportNoteIsError = false } } @@ -371,9 +506,11 @@ fun SettingsScreen( } when (raw) { BackupTextReadResult.Empty -> { + backupImportNoteIsError = true backupImportNote = backupImportEmptyFileMessage(isEnglish) } BackupTextReadResult.TooLarge -> { + backupImportNoteIsError = true backupImportNote = formatBackupImportFailureMessage(BackupImportErrorCode.FileTooLarge, isEnglish) } is BackupTextReadResult.Success -> { @@ -434,6 +571,37 @@ fun SettingsScreen( refreshSyncDiagnostics() } + if (confirmRevokeOtherSessions) { + AlertDialog( + onDismissRequest = { confirmRevokeOtherSessions = false }, + title = { Text(if (isEnglish) "Sign out other devices?" else "退出其他设备?") }, + text = { + Text( + if (isEnglish) { + "All other active sessions will be revoked. This device will stay signed in." + } else { + "其他设备上的所有有效会话都会被撤销,这台设备会保持登录。" + }, + ) + }, + confirmButton = { + Button( + onClick = { + confirmRevokeOtherSessions = false + revokeOtherSessions() + }, + ) { + Text(if (isEnglish) "Sign out other devices" else "退出其他设备") + } + }, + dismissButton = { + TextButton(onClick = { confirmRevokeOtherSessions = false }) { + Text(strings.cancel) + } + }, + ) + } + if (confirmLogoutWithPendingSync) { AlertDialog( onDismissRequest = { confirmLogoutWithPendingSync = false }, @@ -493,13 +661,7 @@ fun SettingsScreen( onDismissRequest = { confirmClearLocalData = false }, title = { Text(if (isEnglish) "Clear this device?" else "清除此设备数据?") }, text = { - Text( - if (isEnglish) { - "This logs out and deletes this account's local tasks, waiting-to-sync changes, and backup undo records on this device. Server tasks will not be deleted. Export a local backup first if needed." - } else { - "这会退出登录,并删除当前账号在这台设备上的本地任务、等待同步的修改和备份撤销记录。服务器上的任务不会被删除。建议先导出本地备份。" - }, - ) + Text(clearLocalDataConfirmationText(isEnglish)) }, confirmButton = { Button( @@ -766,13 +928,13 @@ fun SettingsScreen( value = widgetOpacityDraft, onValueChange = { widgetOpacityDraft = it }, onValueChangeFinished = { - val nextOpacity = widgetOpacityDraft.roundToInt().coerceIn(0, 100) + val nextOpacity = widgetOpacityDraft.roundToInt().coerceIn(60, 100) scope.launch { tokenDataStore.saveWidgetOpacityPercent(nextOpacity) TodayTaskWidgetUpdateWorker.enqueue(context) } }, - valueRange = 0f..100f, + valueRange = 60f..100f, steps = 19, modifier = Modifier.fillMaxWidth(), ) @@ -787,6 +949,39 @@ fun SettingsScreen( ) } } + + AppSection( + title = if (isEnglish) "About" else "关于", + ) { + AppPanel { + Text( + text = if (isEnglish) { + "Installed version ${BuildConfig.VERSION_NAME}" + } else { + "当前版本 ${BuildConfig.VERSION_NAME}" + }, + style = MaterialTheme.typography.titleSmall, + ) + OutlinedButton( + onClick = { + externalLinkError = if (tryOpenExternalUri(uriHandler, LATEST_RELEASE_URL)) { + "" + } else { + strings.externalLinkFailed + } + }, + modifier = Modifier.fillMaxWidth(), + ) { + Text(if (isEnglish) "View latest release" else "查看最新版本") + } + if (externalLinkError.isNotBlank()) { + AppDynamicStatusText( + text = externalLinkError, + isError = true, + ) + } + } + } } if (selectedSection == SettingsSection.Connection) { @@ -802,6 +997,7 @@ fun SettingsScreen( connectionNoteIsError = false }, label = { Text(if (isEnglish) "Server URL" else "服务器地址") }, + isError = connectionNoteIsError, singleLine = true, modifier = Modifier.fillMaxWidth(), ) @@ -860,7 +1056,7 @@ fun SettingsScreen( ) } TextButton(onClick = { advancedConnectionOpen = !advancedConnectionOpen }) { - Text(if (isEnglish) "Advanced connection settings" else "高级连接设置") + Text(if (isEnglish) "Troubleshooting: custom connection URLs" else "排障:自定义连接地址") } if (advancedConnectionOpen) { OutlinedTextField( @@ -872,7 +1068,8 @@ fun SettingsScreen( connectionNote = "" connectionNoteIsError = false }, - label = { Text(if (isEnglish) "Request URL (advanced)" else "请求地址(高级)") }, + label = { Text(if (isEnglish) "Request address for custom proxy" else "自定义请求地址") }, + isError = connectionNoteIsError, singleLine = true, modifier = Modifier.fillMaxWidth(), ) @@ -883,7 +1080,8 @@ fun SettingsScreen( connectionNote = "" connectionNoteIsError = false }, - label = { Text(if (isEnglish) "Sync connection URL (advanced)" else "同步连接地址(高级)") }, + label = { Text(if (isEnglish) "Sync address for custom proxy" else "自定义同步地址") }, + isError = connectionNoteIsError, singleLine = true, modifier = Modifier.fillMaxWidth(), ) @@ -899,14 +1097,9 @@ fun SettingsScreen( } } if (connectionNote.isNotBlank()) { - Text( + AppDynamicStatusText( text = connectionNote, - color = if (connectionNoteIsError) { - MaterialTheme.colorScheme.error - } else { - MaterialTheme.colorScheme.primary - }, - style = MaterialTheme.typography.bodySmall, + isError = connectionNoteIsError, ) } } @@ -918,6 +1111,114 @@ fun SettingsScreen( title = if (isEnglish) "Account" else "账号", ) { AppPanel { + Text( + text = if (isEnglish) "Change password" else "修改密码", + style = MaterialTheme.typography.titleSmall, + ) + PasswordTextField( + value = currentPassword, + onValueChange = { + currentPassword = it + passwordNote = "" + passwordNoteIsError = false + }, + strings = strings, + label = if (isEnglish) "Current password" else "当前密码", + enabled = !changingPassword, + isError = passwordNoteIsError, + modifier = Modifier.fillMaxWidth(), + ) + PasswordTextField( + value = newPassword, + onValueChange = { + newPassword = it + passwordNote = "" + passwordNoteIsError = false + }, + strings = strings, + label = if (isEnglish) "New password" else "新密码", + enabled = !changingPassword, + isError = passwordNoteIsError, + modifier = Modifier.fillMaxWidth(), + ) + PasswordTextField( + value = confirmNewPassword, + onValueChange = { + confirmNewPassword = it + passwordNote = "" + passwordNoteIsError = false + }, + strings = strings, + label = if (isEnglish) "Confirm new password" else "确认新密码", + enabled = !changingPassword, + isError = passwordNoteIsError, + modifier = Modifier.fillMaxWidth(), + ) + Button( + onClick = { submitPasswordChange() }, + enabled = !changingPassword, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + if (changingPassword) { + if (isEnglish) "Changing..." else "正在修改..." + } else { + if (isEnglish) "Change password" else "修改密码" + }, + ) + } + if (passwordNote.isNotBlank()) { + AppDynamicStatusText( + text = passwordNote, + isError = passwordNoteIsError, + ) + } + Text( + text = if (isEnglish) "Signed-in sessions" else "登录会话", + style = MaterialTheme.typography.titleSmall, + ) + OutlinedButton( + onClick = { loadSessions() }, + enabled = !sessionsLoading, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + if (sessionsLoading) { + if (isEnglish) "Loading..." else "正在加载..." + } else { + if (isEnglish) "Refresh sessions" else "刷新会话" + }, + ) + } + sessions.forEach { session -> + Text( + text = session.deviceId?.takeIf { it.isNotBlank() } + ?: if (isEnglish) "Unknown device" else "未知设备", + style = MaterialTheme.typography.bodyMedium, + ) + Text( + text = if (isEnglish) { + "Created ${ShanghaiTime.formatDateTime(session.createdAt, displayTimeZone)} · Expires ${ShanghaiTime.formatDateTime(session.expiresAt, displayTimeZone)}" + } else { + "创建于 ${ShanghaiTime.formatDateTime(session.createdAt, displayTimeZone)} · 到期于 ${ShanghaiTime.formatDateTime(session.expiresAt, displayTimeZone)}" + }, + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodySmall, + ) + } + Button( + onClick = { confirmRevokeOtherSessions = true }, + enabled = !sessionsLoading, + modifier = Modifier.fillMaxWidth(), + ) { + Text(if (isEnglish) "Sign out other devices" else "退出其他设备") + } + if (sessionNote.isNotBlank()) { + AppDynamicStatusText( + text = sessionNote, + isError = sessionNoteIsError, + ) + } Text( text = if (isEnglish) { "Sign out only affects this device. Local data stays here." @@ -944,7 +1245,7 @@ fun SettingsScreen( enabled = !clearLocalDataBlocked, modifier = Modifier.fillMaxWidth(), ) { - Text(if (isEnglish) "Log out and clear this device" else "退出并清除此设备数据") + Text(if (isEnglish) "Clear this device" else "清除此设备数据") } Text( text = if (clearLocalDataBlocked) { @@ -1017,10 +1318,9 @@ fun SettingsScreen( Text(strings.importBackup) } if (backupImportNote.isNotBlank()) { - Text( + AppDynamicStatusText( text = backupImportNote, - color = MaterialTheme.colorScheme.primary, - style = MaterialTheme.typography.bodySmall, + isError = backupImportNoteIsError, ) } if (lastImportedBackupLocalIds.isNotEmpty()) { @@ -1040,110 +1340,133 @@ fun SettingsScreen( title = if (isEnglish) "Sync issues" else "同步问题", ) { AppPanel { - TextButton(onClick = { supportToolsOpen = !supportToolsOpen }, modifier = Modifier.fillMaxWidth()) { - Text( - if (supportToolsOpen) { - if (isEnglish) "Hide sync recovery tools" else "收起同步恢复工具" - } else { - if (isEnglish) "Show sync recovery tools" else "查看同步恢复工具" - }, - ) - } - if (supportToolsOpen) { - Text( - text = if (isEnglish) "Sync recovery tools" else "同步恢复工具", - style = MaterialTheme.typography.titleSmall, - ) - Text( - text = syncRecoverySummaryText( - pendingQueueCount = pendingQueueCount, - exhaustedQueueCount = exhaustedQueueCount, - failedTaskCount = failedSyncTaskCount, - isEnglish = isEnglish, - ), - color = MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.bodySmall, - ) - if (exhaustedSyncQueuePreview.isEmpty()) { + if (recoverableSyncIssueCount == 0 && conflictTaskCount == 0) { + TextButton(onClick = { supportToolsOpen = !supportToolsOpen }, modifier = Modifier.fillMaxWidth()) { + Text(syncRecoveryToolsToggleText(supportToolsOpen, isEnglish)) + } + } + if (syncRecoveryToolsVisible) { Text( - text = if (recoverableSyncIssueCount > 0) { - syncRecoveryRetryAvailableText(isEnglish) - } else { - syncRecoveryNoManualRetryText(isEnglish) - }, + text = syncRecoveryToolsTitle(isEnglish), + style = MaterialTheme.typography.titleSmall, + ) + Text( + text = syncRecoverySummaryText( + pendingQueueCount = pendingQueueCount, + exhaustedQueueCount = exhaustedQueueCount, + failedTaskCount = failedSyncTaskCount, + conflictTaskCount = conflictTaskCount, + isEnglish = isEnglish, + ), color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.bodySmall, ) - } else { - Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { - exhaustedSyncQueuePreview.take(5).forEach { item -> - val itemTitle = item.title?.takeIf { it.isNotBlank() } - ?: if (isEnglish) "Untitled task" else "未命名任务" - Text( - text = itemTitle, - color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.bodySmall, - ) - Text( - text = if (isEnglish) "Needs a manual retry" else "需要手动重试", - color = MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.bodySmall, - ) + if (exhaustedSyncQueuePreview.isEmpty()) { + Text( + text = if (recoverableSyncIssueCount > 0) { + syncRecoveryRetryAvailableText(isEnglish) + } else { + syncRecoveryNoManualRetryText(isEnglish) + }, + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodySmall, + ) + } else { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + exhaustedSyncQueuePreview.take(5).forEach { item -> + val itemTitle = item.title?.takeIf { it.isNotBlank() } + ?: if (isEnglish) "Untitled task" else "未命名任务" + Text( + text = itemTitle, + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.bodySmall, + ) + Text( + text = if (isEnglish) "Needs a manual retry" else "需要手动重试", + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodySmall, + ) + } } } - } - TextButton( - onClick = { showTechnicalDiagnostics = !showTechnicalDiagnostics }, - modifier = Modifier.fillMaxWidth(), - ) { - Text( - if (showTechnicalDiagnostics) { - if (isEnglish) "Hide technical diagnostics" else "收起高级诊断" - } else { - if (isEnglish) "Show technical diagnostics" else "查看高级诊断" - }, - ) - } - if (showTechnicalDiagnostics && exhaustedSyncQueuePreview.isNotEmpty()) { - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - exhaustedSyncQueuePreview.take(5).forEach { item -> - Text( - text = syncQueueDiagnosticText( - action = item.action, - taskTitle = item.title, - attemptCount = item.attemptCount, - isEnglish = isEnglish, - ), - color = MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.bodySmall, - ) + TextButton( + onClick = { showTechnicalDiagnostics = !showTechnicalDiagnostics }, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + if (showTechnicalDiagnostics) { + if (isEnglish) "Hide technical diagnostics" else "收起高级诊断" + } else { + if (isEnglish) "Show technical diagnostics" else "查看高级诊断" + }, + ) + } + if (showTechnicalDiagnostics && exhaustedSyncQueuePreview.isNotEmpty()) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + exhaustedSyncQueuePreview.take(5).forEach { item -> + Text( + text = syncQueueDiagnosticText( + action = item.action, + taskTitle = item.title, + attemptCount = item.attemptCount, + isEnglish = isEnglish, + ), + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodySmall, + ) + } } } - } - Button( - onClick = { - scope.launch { - taskRepository.retryExhaustedSyncQueue() - syncManager.enqueueNetworkSync() - syncManager.syncNow() - refreshSyncDiagnostics() - syncRecoveryNote = syncRecoveryRetryStartedText(isEnglish) + if (conflictTaskCount > 0) { + Button( + onClick = { onOpenConflictTasks() }, + modifier = Modifier.fillMaxWidth(), + ) { + Text(syncRecoveryConflictActionText(isEnglish)) } - }, - enabled = recoverableSyncIssueCount > 0, - modifier = Modifier.fillMaxWidth(), - ) { - Text(syncRecoveryRetryButtonText(isEnglish)) - } - if (syncRecoveryNote.isNotBlank()) { - Text( - text = syncRecoveryNote, - color = MaterialTheme.colorScheme.primary, - style = MaterialTheme.typography.bodySmall, - ) + } + Button( + onClick = { + scope.launch { + taskRepository.retryExhaustedSyncQueue() + syncManager.enqueueNetworkSync() + val syncResult = syncManager.syncNowAndWait() + refreshSyncDiagnostics() + syncRecoveryNoteIsError = syncResult is SyncRunState.Failure + syncRecoveryNote = when (syncResult) { + SyncRunState.Success -> if (isEnglish) { + "Sync completed. Diagnostics are up to date." + } else { + "同步完成,诊断信息已更新。" + } + SyncRunState.Offline -> if (isEnglish) { + "Offline. Retry will resume automatically when connected." + } else { + "当前离线,联网后会自动继续同步。" + } + is SyncRunState.Failure -> if (isEnglish) { + "Sync failed. Diagnostics were refreshed; try again after checking the connection." + } else { + "同步失败,诊断信息已刷新;请检查网络后重试。" + } + SyncRunState.Idle, + SyncRunState.Syncing -> if (isEnglish) "Sync did not finish." else "同步尚未完成。" + } + } + }, + enabled = recoverableSyncIssueCount > 0, + modifier = Modifier.fillMaxWidth(), + ) { + Text(syncRecoveryRetryButtonText(isEnglish)) + } + if (syncRecoveryNote.isNotBlank()) { + AppDynamicStatusText( + text = syncRecoveryNote, + isError = syncRecoveryNoteIsError, + ) + } } } - } } } } @@ -1208,22 +1531,6 @@ private fun settingsSectionStatusText( } } -private fun clearLocalDataSafetyHint(isEnglish: Boolean): String { - return if (isEnglish) { - "Before clearing, confirm there are no pending, failed, or conflicting tasks. Export a local backup if unsure." - } else { - "清除前先确认没有待同步、同步失败或冲突的任务;不确定时先导出本机备份。" - } -} - -private fun clearLocalDataBlockedHint(isEnglish: Boolean): String { - return if (isEnglish) { - "This device still has pending, failed, or conflicting tasks. Handle sync recovery, or export a local backup before clearing this device." - } else { - "当前还有待同步、同步失败或冲突的任务。请先处理同步恢复,或先导出本机备份后再清除此设备数据。" - } -} - @Composable private fun SettingsSectionButton( text: String, diff --git a/android/app/src/main/java/com/taskbridge/app/ui/settings/SettingsUiPolicy.kt b/android/app/src/main/java/com/taskbridge/app/ui/settings/SettingsUiPolicy.kt index 7e794a0..e8c4a9b 100644 --- a/android/app/src/main/java/com/taskbridge/app/ui/settings/SettingsUiPolicy.kt +++ b/android/app/src/main/java/com/taskbridge/app/ui/settings/SettingsUiPolicy.kt @@ -76,12 +76,77 @@ fun syncRecoverySummaryText( pendingQueueCount: Int, exhaustedQueueCount: Int, failedTaskCount: Int, + conflictTaskCount: Int, isEnglish: Boolean, ): String { return if (isEnglish) { - "$pendingQueueCount tasks waiting to sync / $exhaustedQueueCount needs a retry / $failedTaskCount failed" + val secondaryIssues = englishSyncIssueParts(pendingQueueCount, exhaustedQueueCount, failedTaskCount) + when { + conflictTaskCount > 0 && secondaryIssues.isNotEmpty() -> { + val conflictNoun = if (conflictTaskCount == 1) "conflict" else "conflicts" + "Resolve $conflictTaskCount $conflictNoun first. Also check ${joinEnglishList(secondaryIssues)}." + } + conflictTaskCount > 0 -> { + val conflictNoun = if (conflictTaskCount == 1) "conflict" else "conflicts" + "Resolve $conflictTaskCount $conflictNoun first." + } + secondaryIssues.isNotEmpty() -> "Check ${joinEnglishList(secondaryIssues)}." + else -> "No pending, failed, or conflicting tasks." + } + } else { + val secondaryIssues = chineseSyncIssueParts(pendingQueueCount, exhaustedQueueCount, failedTaskCount) + when { + conflictTaskCount > 0 && secondaryIssues.isNotEmpty() -> + "\u5148\u5904\u7406 $conflictTaskCount \u6761\u51b2\u7a81\uff1b\u53e6\u6709${secondaryIssues.joinToString("\u3001")}\u3002" + conflictTaskCount > 0 -> "\u5148\u5904\u7406 $conflictTaskCount \u6761\u51b2\u7a81\u3002" + secondaryIssues.isNotEmpty() -> "\u8bf7\u68c0\u67e5${secondaryIssues.joinToString("\u3001")}\u3002" + else -> "\u6ca1\u6709\u5f85\u540c\u6b65\u3001\u5931\u8d25\u6216\u51b2\u7a81\u7684\u4efb\u52a1\u3002" + } + } +} + +private fun englishSyncIssueParts( + pendingQueueCount: Int, + exhaustedQueueCount: Int, + failedTaskCount: Int, +): List { + return buildList { + if (failedTaskCount > 0) add("$failedTaskCount failed") + if (exhaustedQueueCount > 0) add("$exhaustedQueueCount ${if (exhaustedQueueCount == 1) "retry" else "retries"}") + if (pendingQueueCount > 0) add("$pendingQueueCount waiting to sync") + } +} + +private fun chineseSyncIssueParts( + pendingQueueCount: Int, + exhaustedQueueCount: Int, + failedTaskCount: Int, +): List { + return buildList { + if (failedTaskCount > 0) add("$failedTaskCount \u6761\u540c\u6b65\u5931\u8d25") + if (exhaustedQueueCount > 0) add("$exhaustedQueueCount \u6761\u9700\u8981\u91cd\u8bd5") + if (pendingQueueCount > 0) add("$pendingQueueCount \u6761\u7b49\u5f85\u540c\u6b65") + } +} + +private fun joinEnglishList(parts: List): String { + return when (parts.size) { + 0 -> "" + 1 -> parts.single() + 2 -> "${parts[0]} and ${parts[1]}" + else -> "${parts.dropLast(1).joinToString(", ")}, and ${parts.last()}" + } +} + +fun syncRecoveryToolsTitle(isEnglish: Boolean): String { + return if (isEnglish) "Sync recovery tools" else "\u540c\u6b65\u6062\u590d\u5de5\u5177" +} + +fun syncRecoveryToolsToggleText(isOpen: Boolean, isEnglish: Boolean): String { + return if (isOpen) { + if (isEnglish) "Hide sync recovery tools" else "\u6536\u8d77\u540c\u6b65\u6062\u590d\u5de5\u5177" } else { - "$pendingQueueCount \u6761\u4efb\u52a1\u7b49\u5f85\u540c\u6b65 / $exhaustedQueueCount \u6761\u9700\u8981\u91cd\u8bd5 / $failedTaskCount \u6761\u540c\u6b65\u5931\u8d25" + if (isEnglish) "Show sync recovery tools" else "\u67e5\u770b\u540c\u6b65\u6062\u590d\u5de5\u5177" } } @@ -137,10 +202,38 @@ fun localDataTrustText(isEnglish: Boolean): String { } } +fun clearLocalDataSafetyHint(isEnglish: Boolean): String { + return if (isEnglish) { + "No pending, failed, or conflicting changes are shown. Clearing signs you out and removes this account's local data from this device. Exporting first is optional and creates a backup you can import later." + } else { + "当前没有待同步、同步失败或冲突的修改。清除会退出登录并删除当前账号在这台设备上的本地数据;可选择先导出一份以后能重新导入的备份。" + } +} + +fun clearLocalDataBlockedHint(isEnglish: Boolean): String { + return if (isEnglish) { + "This device has pending, failed, or conflicting changes. Exporting creates a local backup only; it does not sync or resolve them. Resolve the sync issues before clearing this device." + } else { + "这台设备仍有待同步、同步失败或冲突的修改。导出只会生成本地备份,不能完成同步,也不能解决这些问题。请先处理同步问题,再清除此设备数据。" + } +} + +fun clearLocalDataConfirmationText(isEnglish: Boolean): String { + return if (isEnglish) { + "This signs you out and deletes this account's local tasks, queued changes, and backup undo records from this device. Server tasks are not deleted. An exported backup can be imported later, but export does not sync pending changes." + } else { + "这会退出登录,并删除当前账号在这台设备上的本地任务、队列中的修改和备份撤销记录。服务器任务不会被删除。已导出的备份以后可以重新导入,但导出不会同步待处理修改。" + } +} + fun syncRecoveryRetryButtonText(isEnglish: Boolean): String { return if (isEnglish) "Retry pending or failed sync" else "\u91cd\u8bd5\u5f85\u5904\u7406\u6216\u5931\u8d25\u540c\u6b65" } +fun syncRecoveryConflictActionText(isEnglish: Boolean): String { + return if (isEnglish) "View and resolve conflicted tasks" else "\u67e5\u770b\u5e76\u5904\u7406\u51b2\u7a81\u4efb\u52a1" +} + fun syncRecoveryRetryAvailableText(isEnglish: Boolean): String { return if (isEnglish) { "Pending or failed changes can be retried now." @@ -153,14 +246,6 @@ fun syncRecoveryNoManualRetryText(isEnglish: Boolean): String { return if (isEnglish) "No tasks need a manual retry." else "\u5f53\u524d\u6ca1\u6709\u9700\u8981\u624b\u52a8\u91cd\u8bd5\u7684\u4efb\u52a1\u3002" } -fun syncRecoveryRetryStartedText(isEnglish: Boolean): String { - return if (isEnglish) { - "Retry for pending or failed sync has started." - } else { - "\u5df2\u91cd\u65b0\u53d1\u8d77\u5f85\u5904\u7406\u6216\u5931\u8d25\u7684\u540c\u6b65\u3002" - } -} - private fun hasSyncAttention( pendingQueueCount: Int, exhaustedQueueCount: Int, diff --git a/android/app/src/main/java/com/taskbridge/app/ui/task/TaskDetailScreen.kt b/android/app/src/main/java/com/taskbridge/app/ui/task/TaskDetailScreen.kt index 634001c..0a893d8 100644 --- a/android/app/src/main/java/com/taskbridge/app/ui/task/TaskDetailScreen.kt +++ b/android/app/src/main/java/com/taskbridge/app/ui/task/TaskDetailScreen.kt @@ -10,8 +10,10 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Checkbox import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -283,18 +285,6 @@ fun TaskDetailScreen( } AppPanel { - Button( - onClick = { onEditClick(current.localId) }, - modifier = Modifier.fillMaxWidth(), - ) { - Text(strings.edit) - } - Button( - onClick = { pendingTaskDelete = true }, - modifier = Modifier.fillMaxWidth(), - ) { - Text(strings.delete) - } Button( onClick = { scope.launch { @@ -311,7 +301,13 @@ fun TaskDetailScreen( ) { Text(if (current.status == TaskStatus.Completed) strings.restore else strings.complete) } - Button( + OutlinedButton( + onClick = { onEditClick(current.localId) }, + modifier = Modifier.fillMaxWidth(), + ) { + Text(strings.edit) + } + OutlinedButton( onClick = { scope.launch { val tomorrow = ShanghaiTime.todayDate(displayTimeZone).plusDays(1) @@ -329,7 +325,7 @@ fun TaskDetailScreen( ) { Text(strings.postponeTomorrow) } - Button( + OutlinedButton( onClick = { scope.launch { taskRepository.snoozeTask( @@ -345,7 +341,7 @@ fun TaskDetailScreen( Text(strings.snoozeOneHour) } if (!current.repeatRule.isNullOrBlank()) { - Button( + OutlinedButton( onClick = { scope.launch { taskRepository.createNextOccurrence(current.localId) @@ -358,7 +354,7 @@ fun TaskDetailScreen( } } if (current.isTemplate) { - Button( + OutlinedButton( onClick = { scope.launch { taskRepository.instantiateTemplate(current.localId) @@ -370,6 +366,15 @@ fun TaskDetailScreen( Text(strings.useTemplate) } } + TextButton( + onClick = { pendingTaskDelete = true }, + colors = ButtonDefaults.textButtonColors( + contentColor = MaterialTheme.colorScheme.error, + ), + modifier = Modifier.fillMaxWidth(), + ) { + Text(strings.delete, color = MaterialTheme.colorScheme.error) + } } } } diff --git a/android/app/src/main/java/com/taskbridge/app/ui/task/TaskListScreen.kt b/android/app/src/main/java/com/taskbridge/app/ui/task/TaskListScreen.kt index 67ae861..abb6ad3 100644 --- a/android/app/src/main/java/com/taskbridge/app/ui/task/TaskListScreen.kt +++ b/android/app/src/main/java/com/taskbridge/app/ui/task/TaskListScreen.kt @@ -81,15 +81,23 @@ private data class ConflictSnapshotField( val type: String? = null, ) +private data class TaskMetaChip( + val text: String, + val attention: Boolean = false, +) + @Composable fun TaskListScreen( viewModel: TaskListViewModel, todayOnly: Boolean, + localWorkspaceMode: Boolean, + initialFilter: TaskListFilter = TaskListFilter.All, onAddClick: () -> Unit, onTaskClick: (String) -> Unit, onEditClick: (String) -> Unit, onSettingsClick: () -> Unit, onSyncDetailsClick: () -> Unit, + onSignInToSync: () -> Unit, onTodayClick: () -> Unit, onAllClick: () -> Unit, ) { @@ -100,6 +108,11 @@ fun TaskListScreen( val allTasks by viewModel.tasks.collectAsStateWithLifecycle() val todayTasks by viewModel.todayTasks.collectAsStateWithLifecycle() val trashTasks by viewModel.trashTasks.collectAsStateWithLifecycle() + LaunchedEffect(todayOnly, initialFilter) { + if (!todayOnly && initialFilter != TaskListFilter.All && uiState.filter != initialFilter) { + viewModel.setFilter(initialFilter) + } + } val isTrashFilter = !todayOnly && uiState.filter == TaskListFilter.Trash val sourceTasks = when { todayOnly -> todayTasks @@ -213,7 +226,7 @@ fun TaskListScreen( } AppPage(modifier = Modifier.fillMaxSize()) { - if (uiState.syncMessage != SyncStatusMessage.LocalCacheReady) { + if (!localWorkspaceMode && uiState.syncMessage != SyncStatusMessage.LocalCacheReady) { SyncStatusBar(uiState.syncMessage) } Column( @@ -233,17 +246,39 @@ fun TaskListScreen( Button(onClick = onAddClick) { Text(strings.add, maxLines = 1) } - TaskListHeaderActions( - strings = strings, - expanded = headerActionsExpanded, - onExpandedChange = { headerActionsExpanded = it }, - onSyncNow = { viewModel.refresh() }, - languageCode = appLanguage.code, - ) + if (!localWorkspaceMode) { + TaskListHeaderActions( + strings = strings, + expanded = headerActionsExpanded, + onExpandedChange = { headerActionsExpanded = it }, + onSyncNow = { viewModel.refresh() }, + languageCode = appLanguage.code, + ) + } } }, ) + if (localWorkspaceMode) { + AppPanel { + Text( + text = strings.localWorkspaceMode, + style = MaterialTheme.typography.titleSmall, + ) + Text( + text = strings.localWorkspaceModeBody, + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodySmall, + ) + OutlinedButton( + onClick = onSignInToSync, + modifier = Modifier.fillMaxWidth(), + ) { + Text(strings.signInToSync) + } + } + } + TaskListPrimaryNavigation( todaySelected = todayOnly, strings = strings, @@ -252,54 +287,57 @@ fun TaskListScreen( onSettingsClick = onSettingsClick, ) - TaskListSyncHealthBar( - syncHealth = syncHealth, + if (!localWorkspaceMode && syncHealth.needsAttention) { + TaskListSyncHealthBar( + syncHealth = syncHealth, + strings = strings, + onOpenDetails = onSyncDetailsClick, + ) + } + + TaskListSearchField( + searchQuery = uiState.searchQuery, strings = strings, - onOpenDetails = onSyncDetailsClick, + onSearchChange = viewModel::updateSearchQuery, + modifier = Modifier.fillMaxWidth(), ) - TextButton(onClick = { listToolsOpen = !listToolsOpen }, modifier = Modifier.fillMaxWidth()) { - Text( - if (listToolsOpen || uiState.searchQuery.isNotBlank() || (!todayOnly && uiState.filter != TaskListFilter.All)) { - if (isEnglish) "Hide list tools" else "收起列表工具" - } else { - if (isEnglish) "Search and filters" else "搜索与筛选" - }, - ) + if (!todayOnly) { + TextButton(onClick = { listToolsOpen = !listToolsOpen }, modifier = Modifier.fillMaxWidth()) { + Text( + if (listToolsOpen || uiState.filter != TaskListFilter.All) { + if (isEnglish) "Hide filters" else "收起筛选" + } else { + if (isEnglish) "Filters" else "筛选" + }, + ) + } } - if (listToolsOpen || uiState.searchQuery.isNotBlank() || (!todayOnly && uiState.filter != TaskListFilter.All)) { + if (!todayOnly && (listToolsOpen || uiState.filter != TaskListFilter.All)) { AppPanel(contentPadding = PaddingValues(horizontal = 14.dp, vertical = 12.dp)) { - OutlinedTextField( - value = uiState.searchQuery, - onValueChange = viewModel::updateSearchQuery, + val selectedFilterLabel = uiState.filter.localizedLabel(strings) + AppDropdownField( + label = strings.filter, + selectedLabel = selectedFilterLabel, + expanded = filterMenuExpanded, + options = combinedTaskListFilterOptions(strings), + onExpandedChange = { filterMenuExpanded = it }, + onSelect = viewModel::setFilter, modifier = Modifier.fillMaxWidth(), - singleLine = true, - placeholder = { Text(strings.searchHint) }, - ) - if (!todayOnly) { - val selectedFilterLabel = uiState.filter.localizedLabel(strings) - AppDropdownField( - label = strings.filter, - selectedLabel = selectedFilterLabel, - expanded = filterMenuExpanded, - options = combinedTaskListFilterOptions(strings), - onExpandedChange = { filterMenuExpanded = it }, - onSelect = viewModel::setFilter, - modifier = Modifier.fillMaxWidth(), - ) - } - TaskFilterSummaryBar( - activeFilterLabels = activeFilterLabels, - strings = strings, - canClearSearch = uiState.searchQuery.isNotBlank(), - canClearFilter = !todayOnly && uiState.filter != TaskListFilter.All, - onClearSearch = { viewModel.updateSearchQuery("") }, - onClearFilter = { viewModel.setFilter(TaskListFilter.All) }, ) } } + TaskFilterSummaryBar( + activeFilterLabels = activeFilterLabels, + strings = strings, + canClearSearch = uiState.searchQuery.isNotBlank(), + canClearFilter = !todayOnly && uiState.filter != TaskListFilter.All, + onClearSearch = { viewModel.updateSearchQuery("") }, + onClearFilter = { viewModel.setFilter(TaskListFilter.All) }, + ) + LazyColumn( modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues(bottom = 18.dp), @@ -483,6 +521,27 @@ private fun TaskListSyncHealthBar( } } +@Composable +private fun TaskListSearchField( + searchQuery: String, + strings: TaskBridgeStrings, + onSearchChange: (String) -> Unit, + modifier: Modifier = Modifier, +) { + AppPanel( + modifier = modifier, + contentPadding = PaddingValues(horizontal = 14.dp, vertical = 12.dp), + ) { + OutlinedTextField( + value = searchQuery, + onValueChange = onSearchChange, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + placeholder = { Text(strings.searchHint) }, + ) + } +} + @Composable @OptIn(ExperimentalLayoutApi::class) private fun TaskFilterSummaryBar( @@ -876,19 +935,10 @@ private fun TaskRow( }, color = if (isOverdue) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface, ) - taskListSubtitleOrNull(task.subtitle(strings, displayTimeZone, now, languageCode))?.let { subtitle -> - Text( - text = subtitle, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - style = MaterialTheme.typography.bodySmall, - color = if (isOverdue || task.syncStatus == SyncStatus.Conflict) { - MaterialTheme.colorScheme.error - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, - ) - } + TaskMetaChips( + chips = taskMetaChips(task, strings, displayTimeZone, now, languageCode), + modifier = Modifier.padding(top = 4.dp), + ) } } if (task.syncStatus == SyncStatus.Conflict) { @@ -984,6 +1034,45 @@ private fun TaskRow( } } +@Composable +@OptIn(ExperimentalLayoutApi::class) +private fun TaskMetaChips( + chips: List, + modifier: Modifier = Modifier, +) { + if (chips.isEmpty()) return + + FlowRow( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(5.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + chips.forEach { chip -> + Surface( + shape = RoundedCornerShape(999.dp), + color = if (chip.attention) { + MaterialTheme.colorScheme.errorContainer + } else { + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.58f) + }, + contentColor = if (chip.attention) { + MaterialTheme.colorScheme.onErrorContainer + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) { + Text( + text = chip.text, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.labelSmall, + ) + } + } + } +} + @Composable private fun DropdownMenuSectionLabel(label: String) { Text( @@ -1197,17 +1286,29 @@ private fun List.filterByQuery(query: String): List { } } -private fun Task.subtitle(strings: TaskBridgeStrings, displayTimeZone: String, now: Instant, languageCode: String): String { - return listOfNotNull( - strings.overdue.takeIf { isOverdueAt(now, displayTimeZone) }, - project?.takeIf { it.isNotBlank() }?.let { "${strings.project} $it" }, - tag?.takeIf { it.isNotBlank() }?.let { "#$it" }, - plannedDate?.let { "${strings.plan} $it" }, - dueTime?.let { "${strings.due} ${ShanghaiTime.formatDateTime(it, displayTimeZone)}" }, - snoozedUntil?.let { "${strings.snooze} ${ShanghaiTime.formatDateTime(it, displayTimeZone)}" }, - if (priority > 0) "${strings.priority} ${getTaskPriorityLabel(priority, languageCode)}" else null, - getSyncStatusLabel(syncStatus, languageCode), - ).joinToString(" / ") +private fun taskMetaChips( + task: Task, + strings: TaskBridgeStrings, + displayTimeZone: String, + now: Instant, + languageCode: String, +): List { + return buildList { + if (task.isOverdueAt(now, displayTimeZone)) { + add(TaskMetaChip(strings.overdue, attention = true)) + } + task.project?.takeIf { it.isNotBlank() }?.let { add(TaskMetaChip("${strings.project} $it")) } + task.tag?.takeIf { it.isNotBlank() }?.let { add(TaskMetaChip("#$it")) } + task.plannedDate?.let { add(TaskMetaChip("${strings.plan} $it")) } + task.dueTime?.let { add(TaskMetaChip("${strings.due} ${ShanghaiTime.formatDateTime(it, displayTimeZone)}")) } + task.snoozedUntil?.let { add(TaskMetaChip("${strings.snooze} ${ShanghaiTime.formatDateTime(it, displayTimeZone)}")) } + if (task.priority > 0) { + add(TaskMetaChip("${strings.priority} ${getTaskPriorityLabel(task.priority, languageCode)}")) + } + getSyncStatusLabel(task.syncStatus, languageCode)?.let { + add(TaskMetaChip(it, attention = task.syncStatus != SyncStatus.Synced)) + } + } } private fun Task.dueLocalDate(displayTimeZone: String): LocalDate? { diff --git a/android/app/src/main/java/com/taskbridge/app/ui/task/TaskListViewModel.kt b/android/app/src/main/java/com/taskbridge/app/ui/task/TaskListViewModel.kt index 5f204a1..7c00ff1 100644 --- a/android/app/src/main/java/com/taskbridge/app/ui/task/TaskListViewModel.kt +++ b/android/app/src/main/java/com/taskbridge/app/ui/task/TaskListViewModel.kt @@ -10,6 +10,7 @@ import com.taskbridge.app.domain.model.Task import com.taskbridge.app.notification.ReminderManager import com.taskbridge.app.ui.components.SyncStatusMessage import com.taskbridge.app.sync.SyncManager +import com.taskbridge.app.sync.SyncRunState import com.taskbridge.app.utils.ShanghaiTime import com.taskbridge.app.widget.TodayTaskWidgetUpdateWorker import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -83,6 +84,20 @@ class TaskListViewModel( val uiState: StateFlow = _uiState init { + viewModelScope.launch { + syncManager.syncState.collect { syncState -> + val message = when (syncState) { + SyncRunState.Idle -> null + SyncRunState.Syncing -> SyncStatusMessage.Syncing + SyncRunState.Success -> SyncStatusMessage.SyncSucceeded + SyncRunState.Offline -> SyncStatusMessage.Offline + is SyncRunState.Failure -> SyncStatusMessage.SyncFailed + } + if (message != null) { + _uiState.update { it.copy(syncMessage = message) } + } + } + } syncManager.enqueueNetworkSync() syncManager.syncNow() viewModelScope.launch { diff --git a/android/app/src/main/java/com/taskbridge/app/ui/task/TaskUiPolicy.kt b/android/app/src/main/java/com/taskbridge/app/ui/task/TaskUiPolicy.kt index f8c6d6e..218c1f8 100644 --- a/android/app/src/main/java/com/taskbridge/app/ui/task/TaskUiPolicy.kt +++ b/android/app/src/main/java/com/taskbridge/app/ui/task/TaskUiPolicy.kt @@ -160,21 +160,35 @@ fun getSyncStatusLabel(status: SyncStatus, languageCode: String): String? { fun taskListSyncHealthText(tasks: List, languageCode: String): TaskListSyncHealthUi { val isEnglish = languageCode == "en-US" - val issueCount = tasks - .distinctBy { it.localId } - .count { it.syncStatus != SyncStatus.Synced } - return if (issueCount > 0) { - TaskListSyncHealthUi( + val distinctTasks = tasks.distinctBy { it.localId } + val errorCount = distinctTasks.count { + it.syncStatus == SyncStatus.Failed || it.syncStatus == SyncStatus.Conflict + } + val pendingCount = distinctTasks.count { + it.syncStatus == SyncStatus.PendingCreate || + it.syncStatus == SyncStatus.PendingUpdate || + it.syncStatus == SyncStatus.PendingDelete + } + return when { + errorCount > 0 -> TaskListSyncHealthUi( title = if (isEnglish) "Sync needs attention" else "同步需要处理", body = if (isEnglish) { - "$issueCount tasks are pending, failed, or conflicted. Open sync details before clearing this device." + "$errorCount ${if (errorCount == 1) "task failed or conflicted" else "tasks failed or conflicted"}. Open sync details to resolve ${if (errorCount == 1) "it" else "them"}." } else { - "${issueCount} 条任务待同步、失败或冲突。清除此设备数据前请先打开同步详情处理。" + "${errorCount} 条任务同步失败或存在冲突,请打开同步详情处理。" }, needsAttention = true, ) - } else { - TaskListSyncHealthUi( + pendingCount > 0 -> TaskListSyncHealthUi( + title = if (isEnglish) "Waiting to sync" else "等待同步", + body = if (isEnglish) { + "$pendingCount ${if (pendingCount == 1) "task" else "tasks"} will sync automatically when a connection is available." + } else { + "${pendingCount} 条任务将在网络可用后自动同步。" + }, + needsAttention = false, + ) + else -> TaskListSyncHealthUi( title = if (isEnglish) "Sync is healthy" else "同步正常", body = if (isEnglish) { "No action needed. Keep adding tasks and TaskBridge will sync automatically." diff --git a/android/app/src/main/java/com/taskbridge/app/widget/TodayTaskWidgetProvider.kt b/android/app/src/main/java/com/taskbridge/app/widget/TodayTaskWidgetProvider.kt index d01d715..359beab 100644 --- a/android/app/src/main/java/com/taskbridge/app/widget/TodayTaskWidgetProvider.kt +++ b/android/app/src/main/java/com/taskbridge/app/widget/TodayTaskWidgetProvider.kt @@ -74,10 +74,15 @@ class TodayTaskWidgetProvider : AppWidgetProvider() { private fun buildViews(context: Context, state: TodayTaskWidgetState): RemoteViews { return RemoteViews(context.packageName, R.layout.widget_today_task).apply { val transparentStyle = state.style == WidgetConstants.STYLE_TRANSPARENT + setInt( + R.id.widgetBackground, + "setBackgroundResource", + if (transparentStyle) R.drawable.widget_background_dark else R.drawable.widget_background, + ) setFloat( R.id.widgetBackground, "setAlpha", - (state.opacityPercent.coerceIn(0, 100) / 100f) * 0.88f, + state.opacityPercent.coerceIn(60, 100) / 100f, ) val openTarget = if (state.taskScope == WidgetConstants.TASK_SCOPE_ALL) { WidgetConstants.TARGET_ALL @@ -87,9 +92,9 @@ class TodayTaskWidgetProvider : AppWidgetProvider() { setOnClickPendingIntent(R.id.widgetRoot, openAppIntent(context, openTarget)) val message = when { - !state.isLoggedIn -> "\u8BF7\u767B\u5F55 TaskBridge" - state.taskScope == WidgetConstants.TASK_SCOPE_ALL && state.tasks.isEmpty() -> "\u6682\u65E0\u4EFB\u52A1" - state.tasks.isEmpty() -> "\u4ECA\u5929\u6682\u65E0\u5F85\u529E" + !state.hasWorkspace -> state.copy.signInRequired + state.taskScope == WidgetConstants.TASK_SCOPE_ALL && state.tasks.isEmpty() -> state.copy.emptyAll + state.tasks.isEmpty() -> state.copy.emptyToday else -> null } setViewVisibility(R.id.widgetMessage, if (message == null) View.GONE else View.VISIBLE) @@ -97,12 +102,21 @@ class TodayTaskWidgetProvider : AppWidgetProvider() { setTextViewText(R.id.widgetMessage, message.orEmpty()) setTextColor(R.id.widgetMessage, if (transparentStyle) Color.argb(230, 255, 255, 255) else Color.rgb(78, 92, 89)) + val showMoreTasks = state.hasWorkspace && state.hasMoreTasks + setViewVisibility(R.id.widgetMoreTasks, if (showMoreTasks) View.VISIBLE else View.GONE) + setTextViewText(R.id.widgetMoreTasks, state.copy.moreTasks) + setTextColor( + R.id.widgetMoreTasks, + if (transparentStyle) Color.WHITE else Color.rgb(19, 124, 107), + ) + setOnClickPendingIntent(R.id.widgetMoreTasks, openAppIntent(context, openTarget)) + rowIds.forEachIndexed { index, rowId -> val item = state.tasks.getOrNull(index) - if (item == null || !state.isLoggedIn) { + if (item == null || !state.hasWorkspace) { setViewVisibility(rowId, View.GONE) } else { - bindTaskRow(context, this, index, item, transparentStyle) + bindTaskRow(context, this, index, item, transparentStyle, state.copy) } } } @@ -114,6 +128,7 @@ class TodayTaskWidgetProvider : AppWidgetProvider() { index: Int, item: TodayTaskWidgetItem, transparentStyle: Boolean, + copy: WidgetCopy, ) { val titleColor = when { transparentStyle && item.isCompleted -> Color.argb(178, 255, 255, 255) @@ -133,7 +148,7 @@ class TodayTaskWidgetProvider : AppWidgetProvider() { append(item.dueLabel) append(" / ") append(item.priorityLabel) - if (item.isCompleted) append(" / \u5DF2\u5B8C\u6210") + if (item.isCompleted) append(" / ${copy.completed}") } views.setViewVisibility(rowIds[index], View.VISIBLE) @@ -151,6 +166,11 @@ class TodayTaskWidgetProvider : AppWidgetProvider() { }, ) views.setTextColor(statusIds[index], metaColor) + views.setContentDescription(statusIds[index], if (item.isCompleted) { + copy.openCompletedTaskDescription(item.title) + } else { + copy.completeTaskDescription(item.title) + }) views.setTextViewText(titleIds[index], item.title) views.setTextColor(titleIds[index], titleColor) views.setTextViewText(metaIds[index], meta) diff --git a/android/app/src/main/java/com/taskbridge/app/widget/TodayTaskWidgetRepository.kt b/android/app/src/main/java/com/taskbridge/app/widget/TodayTaskWidgetRepository.kt index 1ceaf8c..e584e77 100644 --- a/android/app/src/main/java/com/taskbridge/app/widget/TodayTaskWidgetRepository.kt +++ b/android/app/src/main/java/com/taskbridge/app/widget/TodayTaskWidgetRepository.kt @@ -4,20 +4,24 @@ import android.content.Context import com.taskbridge.app.data.datastore.TokenDataStore import com.taskbridge.app.data.local.AppDatabase import com.taskbridge.app.data.local.TodayWidgetTaskProjection +import com.taskbridge.app.data.local.WorkspaceMigrationCoordinator import com.taskbridge.app.domain.model.TaskStatus import com.taskbridge.app.domain.model.isTaskOverdue import com.taskbridge.app.domain.model.taskTimelineSortKey +import com.taskbridge.app.ui.i18n.AppLanguage import com.taskbridge.app.utils.ShanghaiTime import kotlinx.coroutines.flow.first import java.time.Instant import java.time.LocalDate data class TodayTaskWidgetState( - val isLoggedIn: Boolean, + val hasWorkspace: Boolean, val tasks: List, + val hasMoreTasks: Boolean, val opacityPercent: Int, val taskScope: String, val style: String, + val copy: WidgetCopy, ) data class TodayTaskWidgetItem( @@ -33,36 +37,47 @@ class TodayTaskWidgetRepository( context: Context, ) { private val appContext = context.applicationContext - private val taskDao = AppDatabase.getInstance(appContext).taskDao() + private val database = AppDatabase.getInstance(appContext) + private val taskDao = database.taskDao() private val tokenDataStore = TokenDataStore(appContext) + private val workspaceMigration = WorkspaceMigrationCoordinator( + database, + taskDao, + database.syncQueueDao(), + tokenDataStore, + ) suspend fun loadState(): TodayTaskWidgetState { - val accessToken = tokenDataStore.accessToken.first() - val ownerUserId = tokenDataStore.currentUserId.first() + tokenDataStore.initializeLegacyWorkspaceOwnership() + val workspace = tokenDataStore.currentWorkspace.first() val opacityPercent = tokenDataStore.widgetOpacityPercent.first() + val copy = widgetCopy(AppLanguage.fromCode(tokenDataStore.language.first())) val displayTimeZone = tokenDataStore.displayTimeZone.first() val taskScope = normalizeScope(tokenDataStore.widgetTaskScope.first()) val completionScope = normalizeCompletionScope(tokenDataStore.widgetCompletionScope.first()) val style = normalizeStyle(tokenDataStore.widgetStyle.first()) val today = ShanghaiTime.todayDate(displayTimeZone).toString() val now = Instant.now() - if (accessToken.isNullOrBlank() || ownerUserId.isNullOrBlank()) { + if (workspace == null) { return TodayTaskWidgetState( - isLoggedIn = false, + hasWorkspace = false, tasks = emptyList(), + hasMoreTasks = false, opacityPercent = opacityPercent, taskScope = taskScope, style = style, + copy = copy, ) } + workspaceMigration.ensureWorkspace(workspace) val queryLimit = WidgetConstants.MAX_TASKS * 3 val candidates = if (taskScope == WidgetConstants.TASK_SCOPE_ALL) { - taskDao.getAllWidgetTasks(ownerUserId, queryLimit, now.toString()) + taskDao.getAllWidgetTasks(workspace.id, queryLimit, now.toString()) } else { val (startTime, endTime) = ShanghaiTime.dayBounds(today, displayTimeZone) taskDao.getTodayWidgetTasks( - ownerUserId = ownerUserId, + workspaceId = workspace.id, today = today, startTime = startTime, endTime = endTime, @@ -71,21 +86,30 @@ class TodayTaskWidgetRepository( ).filter { isWidgetCandidate(it, today, displayTimeZone, now = now) } } - val tasks = candidates + val filteredCandidates = candidates .filter { completionScope == WidgetConstants.COMPLETION_SCOPE_ALL || TaskStatus.fromWire(it.status) != TaskStatus.Completed } .sortedForWidget(now, displayTimeZone) - .take(WidgetConstants.MAX_TASKS) - .map { it.toWidgetItem(today, displayTimeZone, now) } + val hasMoreTasks = filteredCandidates.size > WidgetConstants.MAX_TASKS + val visibleTaskLimit = if (hasMoreTasks) { + WidgetConstants.MAX_TASKS - 2 + } else { + WidgetConstants.MAX_TASKS + } + val tasks = filteredCandidates + .take(visibleTaskLimit) + .map { it.toWidgetItem(today, displayTimeZone, now, copy) } return TodayTaskWidgetState( - isLoggedIn = true, + hasWorkspace = true, tasks = tasks, + hasMoreTasks = hasMoreTasks, opacityPercent = opacityPercent, taskScope = taskScope, style = style, + copy = copy, ) } @@ -128,23 +152,29 @@ private fun TodayWidgetTaskProjection.toWidgetItem( today: String, displayTimeZone: String, now: Instant, + copy: WidgetCopy, ): TodayTaskWidgetItem { return TodayTaskWidgetItem( localId = localId, title = title, - dueLabel = dueLabel(today, displayTimeZone, now), + dueLabel = dueLabel(today, displayTimeZone, now, copy), priorityLabel = if (priority > 0) "P$priority" else "P0", isCompleted = TaskStatus.fromWire(status) == TaskStatus.Completed, isOverdue = isTaskOverdue(status, dueTime, now, displayTimeZone), ) } -private fun TodayWidgetTaskProjection.dueLabel(today: String, displayTimeZone: String, now: Instant): String { - val prefix = if (isTaskOverdue(status, dueTime, now, displayTimeZone)) "\u903E\u671F " else "" - dueTime.dateTimeLabel(today, displayTimeZone)?.let { return prefix + it } - plannedDate.dateLabel(today)?.let { return it } - remindTime.dateTimeLabel(today, displayTimeZone)?.let { return it } - return "\u65E0\u622A\u6B62\u65F6\u95F4" +private fun TodayWidgetTaskProjection.dueLabel( + today: String, + displayTimeZone: String, + now: Instant, + copy: WidgetCopy, +): String { + val prefix = if (isTaskOverdue(status, dueTime, now, displayTimeZone)) copy.overduePrefix else "" + dueTime.dateTimeLabel(today, displayTimeZone, copy)?.let { return prefix + it } + plannedDate.dateLabel(today, copy)?.let { return it } + remindTime.dateTimeLabel(today, displayTimeZone, copy)?.let { return it } + return copy.noDueTime } private fun String?.isToday(today: String, displayTimeZone: String): Boolean { @@ -160,19 +190,19 @@ private fun String?.localDate(): LocalDate? { return runCatching { LocalDate.parse(raw) }.getOrNull() } -private fun String?.dateTimeLabel(today: String, displayTimeZone: String): String? { +private fun String?.dateTimeLabel(today: String, displayTimeZone: String, copy: WidgetCopy): String? { val raw = this?.takeIf { it.isNotBlank() } ?: return null val date = ShanghaiTime.localDate(raw, displayTimeZone) ?: return null val time = ShanghaiTime.formatTime(raw, displayTimeZone) - return "${dateLabel(date, today)} $time" + return "${dateLabel(date, today, copy)} $time" } -private fun String?.dateLabel(today: String): String? { - return localDate()?.let { dateLabel(it, today) } +private fun String?.dateLabel(today: String, copy: WidgetCopy): String? { + return localDate()?.let { dateLabel(it, today, copy) } } -private fun dateLabel(date: LocalDate, today: String): String { - return if (date.toString() == today) "\u4ECA\u5929" else ShanghaiTime.formatMonthDay(date) +private fun dateLabel(date: LocalDate, today: String, copy: WidgetCopy): String { + return if (date.toString() == today) copy.today else ShanghaiTime.formatMonthDay(date) } private fun normalizeScope(scope: String): String { diff --git a/android/app/src/main/java/com/taskbridge/app/widget/WidgetCopy.kt b/android/app/src/main/java/com/taskbridge/app/widget/WidgetCopy.kt new file mode 100644 index 0000000..ded702d --- /dev/null +++ b/android/app/src/main/java/com/taskbridge/app/widget/WidgetCopy.kt @@ -0,0 +1,50 @@ +package com.taskbridge.app.widget + +import com.taskbridge.app.ui.i18n.AppLanguage + +data class WidgetCopy( + val signInRequired: String, + val emptyToday: String, + val emptyAll: String, + val overduePrefix: String, + val today: String, + val noDueTime: String, + val completed: String, + val moreTasks: String, + private val completeTaskPrefix: String, + private val openCompletedTaskPrefix: String, +) { + fun completeTaskDescription(title: String): String = completeTaskPrefix + title + + fun openCompletedTaskDescription(title: String): String = openCompletedTaskPrefix + title +} + +fun widgetCopy(language: AppLanguage): WidgetCopy { + return if (language == AppLanguage.English) { + WidgetCopy( + signInRequired = "Sign in to TaskBridge", + emptyToday = "No tasks today", + emptyAll = "No tasks", + overduePrefix = "Overdue ", + today = "Today", + noDueTime = "No due time", + completed = "Completed", + moreTasks = "More tasks available. Open TaskBridge to view all.", + completeTaskPrefix = "Complete task: ", + openCompletedTaskPrefix = "Open completed task: ", + ) + } else { + WidgetCopy( + signInRequired = "\u8BF7\u767B\u5F55 TaskBridge", + emptyToday = "\u4ECA\u5929\u6682\u65E0\u5F85\u529E", + emptyAll = "\u6682\u65E0\u4EFB\u52A1", + overduePrefix = "\u903E\u671F ", + today = "\u4ECA\u5929", + noDueTime = "\u65E0\u622A\u6B62\u65F6\u95F4", + completed = "\u5DF2\u5B8C\u6210", + moreTasks = "还有更多任务,打开 TaskBridge 查看全部", + completeTaskPrefix = "\u5B8C\u6210\u4EFB\u52A1\uFF1A", + openCompletedTaskPrefix = "\u6253\u5F00\u5DF2\u5B8C\u6210\u4EFB\u52A1\uFF1A", + ) + } +} diff --git a/android/app/src/main/res/drawable/widget_background_dark.xml b/android/app/src/main/res/drawable/widget_background_dark.xml new file mode 100644 index 0000000..ab8c3d7 --- /dev/null +++ b/android/app/src/main/res/drawable/widget_background_dark.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/android/app/src/main/res/layout/widget_today_task.xml b/android/app/src/main/res/layout/widget_today_task.xml index 3752006..506e7af 100644 --- a/android/app/src/main/res/layout/widget_today_task.xml +++ b/android/app/src/main/res/layout/widget_today_task.xml @@ -193,6 +193,18 @@ + + ") + + assertTrue(statusStyle.contains("48dp")) + assertTrue(statusStyle.contains("48dp")) + } + + @Test + fun remoteViewsExposeLocalizedStatusActionDescriptions() { + val provider = androidSource( + "src/main/java/com/taskbridge/app/widget/TodayTaskWidgetProvider.kt", + ) + + assertTrue(provider.contains("setContentDescription(statusIds[index]")) + assertTrue(provider.contains("completeTaskDescription")) + assertTrue(provider.contains("openCompletedTaskDescription")) + } + + @Test + fun widgetShowsOverflowAndKeepsTransparentTextReadable() { + val repository = androidSource( + "src/main/java/com/taskbridge/app/widget/TodayTaskWidgetRepository.kt", + ) + val provider = androidSource( + "src/main/java/com/taskbridge/app/widget/TodayTaskWidgetProvider.kt", + ) + val layout = androidSource("src/main/res/layout/widget_today_task.xml") + + assertTrue(repository.contains("val hasMoreTasks: Boolean")) + assertTrue(repository.contains("hasMoreTasks = filteredCandidates.size > WidgetConstants.MAX_TASKS")) + assertTrue(repository.contains("if (workspace == null)")) + assertTrue(!repository.contains("accessToken.isNullOrBlank() || workspace == null")) + assertTrue(layout.contains("@+id/widgetMoreTasks")) + assertTrue(provider.contains("state.hasMoreTasks")) + assertTrue(provider.contains("state.copy.moreTasks")) + assertTrue(provider.contains("R.drawable.widget_background_dark")) + } + + @Test + fun widgetOpacityHasAReadableMinimum() { + val tokenStore = androidSource( + "src/main/java/com/taskbridge/app/data/datastore/TokenDataStore.kt", + ) + val settings = androidSource( + "src/main/java/com/taskbridge/app/ui/settings/SettingsScreen.kt", + ) + val provider = androidSource( + "src/main/java/com/taskbridge/app/widget/TodayTaskWidgetProvider.kt", + ) + + assertTrue(tokenStore.contains("coerceIn(60, 100)")) + assertTrue(settings.contains("valueRange = 60f..100f")) + assertTrue(provider.contains("coerceIn(60, 100)")) + } + + private fun androidSource(relativePath: String): String { + val start = File(checkNotNull(System.getProperty("user.dir"))).absoluteFile + val file = generateSequence(start) { it.parentFile } + .flatMap { directory -> + sequenceOf( + File(directory, relativePath), + File(directory, "app/$relativePath"), + File(directory, "android/app/$relativePath"), + ) + } + .firstOrNull(File::isFile) + ?: error("Unable to locate $relativePath from $start") + return file.readText(Charsets.UTF_8) + } +} diff --git a/backend/.env.docker.example b/backend/.env.docker.example index 4bbf02c..98b5b1c 100644 --- a/backend/.env.docker.example +++ b/backend/.env.docker.example @@ -9,7 +9,7 @@ # - If you use an external MySQL server, copy .env.example instead. APP_NAME=TaskBridge API -APP_VERSION=0.1.6 +APP_VERSION=0.1.8 ENVIRONMENT=development # Docker Compose service names are mysql and redis. diff --git a/backend/.env.example b/backend/.env.example index 5ae2431..7db2f4d 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -13,7 +13,7 @@ APP_NAME=TaskBridge API # Display version. -APP_VERSION=0.1.7 +APP_VERSION=0.1.8 # Runtime environment: development, staging or production. ENVIRONMENT=development diff --git a/backend/README.md b/backend/README.md index a217ae1..b0b9867 100644 --- a/backend/README.md +++ b/backend/README.md @@ -10,7 +10,7 @@ - JWT Access Token、Refresh Token 和短期 WebSocket Ticket。 - 设备注册、设备列表和设备删除。 - 任务 CRUD、完成、恢复、软删除和彻底删除。 -- 今日、收件箱、逾期、本周、高优先级、已完成、待同步、冲突和模板视图。 +- 今日、收件箱、逾期、本周、高优先级、已完成和模板等服务端视图;待同步和冲突由客户端本地队列生成。 - 子清单、任务模板、重复任务、稍后提醒、计划日期、项目和标签。 - 批量操作、导入预览、导入导出、项目重命名、标签重命名和冲突处理。 - HTTP 增量同步、离线变更上传和同步日志。 @@ -118,6 +118,7 @@ POST /api/v1/auth/register POST /api/v1/auth/login POST /api/v1/auth/refresh GET /api/v1/auth/me +PUT /api/v1/auth/password GET /api/v1/auth/sessions POST /api/v1/auth/sessions/revoke-other-devices DELETE /api/v1/auth/sessions/{session_id} @@ -142,6 +143,10 @@ POST /api/v1/sync/push `POST /api/v1/sync/push` 支持安全重试:同一 `device_id + local_id + action + version` 且 payload 相同的变更会返回首次应用结果,不会重复写入。复用同一幂等键但更换 payload 会被拒绝;如果该变更已经落后于服务端版本,则按普通 `conflict` 返回服务端任务快照。 +Web 等直接调用 `POST /api/v1/tasks` 的客户端可传稳定的 `client_request_id`。同一用户使用相同键和相同创建数据重试时返回原任务,不会重复创建或重复写同步日志;复用同一键提交不同数据时返回 409。`GET /api/v1/tasks` 和 `/tasks/meta` 接受 IANA `timezone` 参数,客户端应传当前显示时区,未知时区返回 400。 + +已登录用户通过 `PUT /api/v1/auth/password` 修改密码,成功后会撤销其他活跃会话。无法登录时,自托管管理员可运行 `python -m tools.reset_password --username-or-email <账号>` 重置密码并撤销该账号全部会话。 + ## WebSocket 推荐连接方式: @@ -172,6 +177,7 @@ WS /ws/sync?ticket=&device_id= - Access Token 携带签发时的设备和 Refresh Token 会话声明;会话治理接口会校验当前会话仍然活跃。 - Refresh Token 绑定设备;删除设备会撤销该设备的 Refresh Token。 - 用户可以列出活跃 Refresh Token 会话,撤销单个会话,或一键撤销其他设备会话。 +- 修改密码需要校验当前密码,并撤销其他活跃会话;管理员离线重置会撤销全部会话。 - WebSocket 推荐使用短期 Ticket 建连。 - CSV 导出会转义公式前缀,降低表格软件公式注入风险。 - 生产环境会拒绝默认 `JWT_SECRET` 和默认数据库密码。 diff --git a/backend/alembic/versions/20260715_0013_task_create_idempotency.py b/backend/alembic/versions/20260715_0013_task_create_idempotency.py new file mode 100644 index 0000000..8965054 --- /dev/null +++ b/backend/alembic/versions/20260715_0013_task_create_idempotency.py @@ -0,0 +1,32 @@ +"""add task create idempotency keys + +Revision ID: 20260715_0013 +Revises: 20260605_0012 +Create Date: 2026-07-15 13:00:00 +""" + +import sqlalchemy as sa + +from alembic import op + +revision = "20260715_0013" +down_revision = "20260605_0012" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("tasks", sa.Column("client_request_id", sa.String(length=128), nullable=True)) + op.add_column("tasks", sa.Column("create_payload_hash", sa.String(length=64), nullable=True)) + op.create_index( + "uq_tasks_user_client_request_id", + "tasks", + ["user_id", "client_request_id"], + unique=True, + ) + + +def downgrade() -> None: + op.drop_index("uq_tasks_user_client_request_id", table_name="tasks") + op.drop_column("tasks", "create_payload_hash") + op.drop_column("tasks", "client_request_id") diff --git a/backend/app/api/v1/auth.py b/backend/app/api/v1/auth.py index 53b8e88..dcd53be 100644 --- a/backend/app/api/v1/auth.py +++ b/backend/app/api/v1/auth.py @@ -9,6 +9,7 @@ from app.models.user import User from app.schemas.auth import ( LoginRequest, + PasswordChangeRequest, RefreshTokenRequest, RevokeOtherSessionsRequest, UserCreate, @@ -16,6 +17,7 @@ WebSocketTicketRequest, ) from app.services.auth_service import ( + change_password, create_websocket_ticket, list_refresh_sessions, login_user, @@ -79,6 +81,30 @@ def read_current_user(current_user: User = Depends(get_current_user)): return api_success(UserRead.model_validate(current_user)) +@router.put("/password") +def update_password( + payload: PasswordChangeRequest, + request: Request, + db: Session = Depends(get_db), + current_session: CurrentUserSession = Depends(get_current_user_session), +): + check_rate_limit( + request, + scope="change-password", + identifier=str(current_session.user.id), + limit=10, + window_seconds=300, + ) + return api_success( + change_password( + db, + current_session.user, + current_session.refresh_token.id, + payload, + ), + ) + + @router.get("/sessions") def read_sessions( db: Session = Depends(get_db), diff --git a/backend/app/api/v1/tasks.py b/backend/app/api/v1/tasks.py index cf6af50..4c4d999 100644 --- a/backend/app/api/v1/tasks.py +++ b/backend/app/api/v1/tasks.py @@ -27,6 +27,7 @@ TaskSnoozeRequest, TaskTemplateInstantiateRequest, TaskUpdateWithVersion, + TaskView, ) from app.services.task_service import ( add_checklist_item, @@ -56,6 +57,7 @@ update_checklist_item, update_task, ) +from app.utils.time import DEFAULT_TIMEZONE router = APIRouter(prefix="/tasks", tags=["tasks"]) @@ -88,8 +90,9 @@ def _check_task_rate_limit( def read_tasks( request: Request, q: str | None = None, - view: str | None = None, + view: TaskView | None = None, now: datetime | None = None, + timezone: str = Query(default=DEFAULT_TIMEZONE, min_length=1, max_length=64), status: str | None = None, tag: str | None = None, project: str | None = None, @@ -119,6 +122,7 @@ def read_tasks( q=normalized_q, view=view, now=now, + timezone_name=timezone, status=status, tag=tag, project=project, @@ -137,11 +141,13 @@ def read_tasks( @router.get("/meta") def meta( request: Request, + timezone: str = Query(default=DEFAULT_TIMEZONE, min_length=1, max_length=64), + now: datetime | None = None, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): _check_task_rate_limit(request, current_user, scope="tasks:meta", limit=TASK_READ_RATE_LIMIT) - return api_success(get_task_meta(db, current_user)) + return api_success(get_task_meta(db, current_user, timezone_name=timezone, now=now)) @router.get("/export") diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 36ce8b7..db19347 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -17,7 +17,7 @@ class Settings(BaseSettings): ) app_name: str = "TaskBridge API" - app_version: str = "0.1.7" + app_version: str = "0.1.8" environment: str = "development" database_url: str = Field( default=DEFAULT_DATABASE_URL, diff --git a/backend/app/models/task.py b/backend/app/models/task.py index 19a8f42..2a7e048 100644 --- a/backend/app/models/task.py +++ b/backend/app/models/task.py @@ -18,10 +18,18 @@ class Task(Base): Index("ix_tasks_user_deleted_project", "user_id", "is_deleted", "project"), Index("ix_tasks_user_deleted_list_type", "user_id", "is_deleted", "list_type"), Index("ix_tasks_user_deleted_due_time", "user_id", "is_deleted", "due_time"), + Index( + "uq_tasks_user_client_request_id", + "user_id", + "client_request_id", + unique=True, + ), ) id: Mapped[int] = mapped_column(primary_key=True, index=True) user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True, nullable=False) + client_request_id: Mapped[str | None] = mapped_column(String(128), nullable=True) + create_payload_hash: Mapped[str | None] = mapped_column(String(64), nullable=True) title: Mapped[str] = mapped_column(String(255), nullable=False) content: Mapped[str | None] = mapped_column(Text, nullable=True) status: Mapped[str] = mapped_column(String(32), default="todo", nullable=False) diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py index eda56e0..8138221 100644 --- a/backend/app/schemas/auth.py +++ b/backend/app/schemas/auth.py @@ -37,6 +37,11 @@ class RefreshTokenRequest(BaseModel): device_id: str | None = Field(default=None, min_length=1, max_length=128) +class PasswordChangeRequest(BaseModel): + current_password: str = Field(min_length=1, max_length=128) + new_password: str = Field(min_length=8, max_length=128) + + class WebSocketTicketRequest(BaseModel): device_id: str = Field(min_length=1, max_length=128) diff --git a/backend/app/schemas/task.py b/backend/app/schemas/task.py index 20e9996..aedfc65 100644 --- a/backend/app/schemas/task.py +++ b/backend/app/schemas/task.py @@ -3,6 +3,17 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator +TaskView = Literal[ + "inbox", + "today", + "overdue", + "week", + "high_priority", + "completed", + "templates", + "trash", +] + class ChecklistItem(BaseModel): id: str = Field(min_length=1, max_length=128) @@ -16,6 +27,7 @@ class ChecklistItemUpdate(BaseModel): class TaskCreate(BaseModel): + client_request_id: str | None = Field(default=None, min_length=1, max_length=128) title: str = Field(min_length=1, max_length=255) content: str | None = Field(default=None, max_length=10000) priority: int = Field(default=0, ge=0, le=5) @@ -33,6 +45,16 @@ class TaskCreate(BaseModel): template_name: str | None = Field(default=None, max_length=128) sort_order: int = Field(default=0, ge=0, le=10_000) + @field_validator("client_request_id") + @classmethod + def normalize_client_request_id(cls, value: str | None) -> str | None: + if value is None: + return None + normalized = value.strip() + if not normalized: + raise ValueError("invalid client_request_id") + return normalized + class TaskUpdate(BaseModel): title: str | None = Field(default=None, min_length=1, max_length=255) @@ -161,6 +183,7 @@ class TaskRead(BaseModel): id: int user_id: int + client_request_id: str | None title: str content: str | None status: str diff --git a/backend/app/services/auth_service.py b/backend/app/services/auth_service.py index 1418009..3e57ae4 100644 --- a/backend/app/services/auth_service.py +++ b/backend/app/services/auth_service.py @@ -15,6 +15,7 @@ from app.models.user import RefreshToken, User from app.schemas.auth import ( LoginRequest, + PasswordChangeRequest, RefreshSessionRead, RevokeSessionsResponse, TokenPair, @@ -84,6 +85,35 @@ def login_user(db: Session, payload: LoginRequest) -> TokenPair: return _issue_token_pair(db, user, payload.device_id) +def change_password( + db: Session, + current_user: User, + current_session_id: int, + payload: PasswordChangeRequest, +) -> RevokeSessionsResponse: + if not verify_password(payload.current_password, current_user.password_hash): + raise AppException(status_code=401, message="current password is incorrect") + if verify_password(payload.new_password, current_user.password_hash): + raise AppException(status_code=400, message="new password must be different") + + now = utc_now() + current_user.password_hash = hash_password(payload.new_password) + current_user.updated_at = now + result = db.execute( + update(RefreshToken) + .where( + RefreshToken.user_id == current_user.id, + RefreshToken.id != current_session_id, + RefreshToken.revoked_at.is_(None), + RefreshToken.expires_at > now, + ) + .values(revoked_at=now), + ) + db.add(current_user) + db.commit() + return RevokeSessionsResponse(revoked=result.rowcount or 0) + + def refresh_token_pair(db: Session, refresh_token: str, device_id: str | None = None) -> TokenPair: token_hash = hash_refresh_token(refresh_token) stored_token = db.scalar( diff --git a/backend/app/services/task_service.py b/backend/app/services/task_service.py index 77319cf..40d29ba 100644 --- a/backend/app/services/task_service.py +++ b/backend/app/services/task_service.py @@ -1,8 +1,11 @@ +import json from copy import deepcopy from datetime import date, datetime, timedelta +from hashlib import sha256 from typing import Any from sqlalchemy import and_, case, func, or_, select, update +from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session from sqlalchemy.orm.attributes import flag_modified @@ -27,9 +30,11 @@ ) from app.services.sync_service import append_sync_log from app.utils.time import ( + DEFAULT_TIMEZONE, + day_utc_bounds, + local_date, normalize_to_utc_naive, - shanghai_day_utc_bounds, - shanghai_local_date, + resolve_timezone, utc_now, ) @@ -143,6 +148,7 @@ def list_tasks( planned_date: date | None = None, view: str | None = None, now: datetime | None = None, + timezone_name: str = DEFAULT_TIMEZONE, include_deleted: bool = False, templates_only: bool = False, cursor_id: int | None = None, @@ -150,6 +156,10 @@ def list_tasks( offset: int = 0, limit: int = 100, ) -> list[Task]: + try: + resolved_timezone = resolve_timezone(timezone_name).key + except ValueError as error: + raise AppException(status_code=400, message="invalid timezone") from error if (cursor_id is None) != (cursor_updated_at is None): raise AppException( status_code=400, message="cursor_id and cursor_updated_at must be provided together" @@ -178,7 +188,12 @@ def list_tasks( conditions.append(Task.planned_date == planned_date) if templates_only: conditions.append(Task.is_template.is_(True)) - _apply_view_conditions(conditions, view=view, now=now) + _apply_view_conditions( + conditions, + view=view, + now=now, + timezone_name=resolved_timezone, + ) current = normalize_to_utc_naive(now) if now else utc_now() order_specs = _task_order_specs(current, search_query) if cursor_id is not None: @@ -463,10 +478,20 @@ def _open_status_condition() -> Any: return Task.status.not_in(COMPLETED_STATUSES) -def get_task_meta(db: Session, current_user: User) -> dict[str, Any]: - now = utc_now() - today = shanghai_local_date(now) - today_start, today_end = shanghai_day_utc_bounds(today) +def get_task_meta( + db: Session, + current_user: User, + *, + timezone_name: str = DEFAULT_TIMEZONE, + now: datetime | None = None, +) -> dict[str, Any]: + current = normalize_to_utc_naive(now) if now else utc_now() + try: + resolved_timezone = resolve_timezone(timezone_name).key + except ValueError as error: + raise AppException(status_code=400, message="invalid timezone") from error + today = local_date(current, resolved_timezone) + today_start, today_end = day_utc_bounds(today, resolved_timezone) base = [Task.user_id == current_user.id] active = base + [Task.is_deleted.is_(False)] @@ -513,12 +538,12 @@ def get_task_meta(db: Session, current_user: User) -> dict[str, Any]: Task.remind_time >= today_start, Task.remind_time < today_end, ), - and_(Task.due_time.is_not(None), Task.due_time < now), + and_(Task.due_time.is_not(None), Task.due_time < current), ), ], ).label("today"), _count_tasks_expression( - active + [_open_status_condition(), Task.due_time < now] + active + [_open_status_condition(), Task.due_time < current] ).label("overdue"), _count_tasks_expression(active + [Task.is_template.is_(True)]).label("templates"), _count_tasks_expression(base + [Task.is_deleted.is_(True)]).label("trash"), @@ -748,13 +773,14 @@ def _apply_view_conditions( *, view: str | None, now: datetime | None, + timezone_name: str = DEFAULT_TIMEZONE, ) -> None: if not view: return normalized = view.strip().lower() current = normalize_to_utc_naive(now) if now else utc_now() - today = shanghai_local_date(current) - today_start, today_end = shanghai_day_utc_bounds(today) + today = local_date(current, timezone_name) + today_start, today_end = day_utc_bounds(today, timezone_name) if normalized == "inbox": conditions.extend([Task.list_type == "inbox", _open_status_condition()]) @@ -786,7 +812,7 @@ def _apply_view_conditions( elif normalized == "week": end_date = today + timedelta(days=7) week_start = today_start - _, week_end = shanghai_day_utc_bounds(end_date) + _, week_end = day_utc_bounds(end_date, timezone_name) conditions.append( or_( Task.planned_date.between(today, end_date), @@ -810,14 +836,31 @@ def _apply_view_conditions( conditions.append(Task.is_template.is_(True)) elif normalized == "trash": conditions.append(Task.is_deleted.is_(True)) + else: + raise AppException(status_code=400, message="invalid task view") def create_task(db: Session, current_user: User, payload: TaskCreate) -> Task: data = _task_payload_dump(payload) + client_request_id = data.get("client_request_id") + payload_hash = _create_task_payload_hash(data) if client_request_id else None + if client_request_id: + existing = _find_task_by_client_request_id(db, current_user, client_request_id) + if existing is not None: + return _resolve_idempotent_create(existing, payload_hash) _ensure_owned_parent_task(db, current_user, data.get("parent_task_id")) - task = Task(user_id=current_user.id, **data) + task = Task(user_id=current_user.id, create_payload_hash=payload_hash, **data) db.add(task) - db.flush() + try: + db.flush() + except IntegrityError: + db.rollback() + if not client_request_id: + raise + existing = _find_task_by_client_request_id(db, current_user, client_request_id) + if existing is None: + raise + return _resolve_idempotent_create(existing, payload_hash) append_sync_log( db, user_id=current_user.id, @@ -831,6 +874,40 @@ def create_task(db: Session, current_user: User, payload: TaskCreate) -> Task: return task +def _find_task_by_client_request_id( + db: Session, + current_user: User, + client_request_id: str, +) -> Task | None: + return db.scalar( + select(Task).where( + Task.user_id == current_user.id, + Task.client_request_id == client_request_id, + ), + ) + + +def _resolve_idempotent_create(task: Task, payload_hash: str | None) -> Task: + if task.create_payload_hash != payload_hash: + raise AppException( + status_code=409, + message="client_request_id already used with different task data", + ) + return task + + +def _create_task_payload_hash(data: dict[str, Any]) -> str: + payload = {key: value for key, value in data.items() if key != "client_request_id"} + encoded = json.dumps( + payload, + ensure_ascii=True, + separators=(",", ":"), + sort_keys=True, + default=lambda value: value.isoformat(), + ).encode("utf-8") + return sha256(encoded).hexdigest() + + def get_task(db: Session, current_user: User, task_id: int) -> Task: return _get_owned_task(db, current_user, task_id) diff --git a/backend/app/utils/time.py b/backend/app/utils/time.py index f1dbf06..3338ca5 100644 --- a/backend/app/utils/time.py +++ b/backend/app/utils/time.py @@ -1,7 +1,8 @@ from datetime import UTC, date, datetime, time, timedelta -from zoneinfo import ZoneInfo +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError -SHANGHAI_TZ = ZoneInfo("Asia/Shanghai") +DEFAULT_TIMEZONE = "Asia/Shanghai" +SHANGHAI_TZ = ZoneInfo(DEFAULT_TIMEZONE) def utc_now() -> datetime: @@ -23,19 +24,40 @@ def utc_iso(value: datetime | None = None) -> str: return normalized.isoformat().replace("+00:00", "Z") -def shanghai_local_date(value: datetime | None = None) -> date: +def resolve_timezone(timezone_name: str = DEFAULT_TIMEZONE) -> ZoneInfo: + normalized = timezone_name.strip() + if not normalized: + raise ValueError("invalid timezone") + try: + return ZoneInfo(normalized) + except ZoneInfoNotFoundError as error: + raise ValueError("invalid timezone") from error + + +def local_date(value: datetime | None = None, timezone_name: str = DEFAULT_TIMEZONE) -> date: normalized = value or utc_now() if normalized.tzinfo is None: normalized = normalized.replace(tzinfo=UTC) else: normalized = normalized.astimezone(UTC) - return normalized.astimezone(SHANGHAI_TZ).date() + return normalized.astimezone(resolve_timezone(timezone_name)).date() -def shanghai_day_utc_bounds(day: date) -> tuple[datetime, datetime]: - start = datetime.combine(day, time.min, tzinfo=SHANGHAI_TZ) +def day_utc_bounds( + day: date, + timezone_name: str = DEFAULT_TIMEZONE, +) -> tuple[datetime, datetime]: + start = datetime.combine(day, time.min, tzinfo=resolve_timezone(timezone_name)) end = start + timedelta(days=1) return ( start.astimezone(UTC).replace(tzinfo=None), end.astimezone(UTC).replace(tzinfo=None), ) + + +def shanghai_local_date(value: datetime | None = None) -> date: + return local_date(value, DEFAULT_TIMEZONE) + + +def shanghai_day_utc_bounds(day: date) -> tuple[datetime, datetime]: + return day_utc_bounds(day, DEFAULT_TIMEZONE) diff --git a/backend/pyproject.toml b/backend/pyproject.toml index fe5dbad..571b868 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "taskbridge" -version = "0.1.7" +version = "0.1.8" description = "FastAPI backend for TaskBridge" requires-python = ">=3.11" dependencies = [ diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index c95efca..c5ea20f 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -54,6 +54,86 @@ def test_register_login_refresh_and_me(client: TestClient) -> None: assert me_response.json()["data"]["email"] == "alice@example.com" +def test_current_user_can_change_password_and_revoke_other_sessions(client: TestClient) -> None: + register_response = client.post( + "/api/v1/auth/register", + json={ + "username": "password-change", + "email": "password-change@example.com", + "password": "password123", + "device_id": "password-change-desktop", + }, + ) + desktop_pair = register_response.json()["data"] + desktop_headers = {"Authorization": f"Bearer {desktop_pair['access_token']}"} + phone_response = client.post( + "/api/v1/auth/login", + json={ + "username_or_email": "password-change", + "password": "password123", + "device_id": "password-change-phone", + }, + ) + phone_pair = phone_response.json()["data"] + + change_response = client.put( + "/api/v1/auth/password", + headers=desktop_headers, + json={"current_password": "password123", "new_password": "new-password-456"}, + ) + + assert change_response.status_code == 200 + assert change_response.json()["data"] == {"revoked": 1} + assert client.get("/api/v1/auth/me", headers=desktop_headers).status_code == 200 + assert client.post( + "/api/v1/auth/refresh", + json={ + "refresh_token": phone_pair["refresh_token"], + "device_id": "password-change-phone", + }, + ).status_code == 401 + assert client.post( + "/api/v1/auth/login", + json={ + "username_or_email": "password-change", + "password": "password123", + "device_id": "password-change-old-password", + }, + ).status_code == 401 + assert client.post( + "/api/v1/auth/login", + json={ + "username_or_email": "password-change", + "password": "new-password-456", + "device_id": "password-change-new-password", + }, + ).status_code == 200 + + +def test_change_password_rejects_wrong_current_password(client: TestClient) -> None: + register_response = client.post( + "/api/v1/auth/register", + json={ + "username": "password-change-wrong", + "email": "password-change-wrong@example.com", + "password": "password123", + "device_id": "password-change-wrong-device", + }, + ) + headers = { + "Authorization": f"Bearer {register_response.json()['data']['access_token']}" + } + + response = client.put( + "/api/v1/auth/password", + headers=headers, + json={"current_password": "not-the-password", "new_password": "new-password-456"}, + ) + + assert response.status_code == 401 + assert response.json()["message"] == "current password is incorrect" + + def test_duplicate_registration_returns_uniform_error(client: TestClient) -> None: payload = { "username": "duplicate", diff --git a/backend/tests/test_migrations.py b/backend/tests/test_migrations.py index 2d3b0d1..b1981df 100644 --- a/backend/tests/test_migrations.py +++ b/backend/tests/test_migrations.py @@ -36,6 +36,7 @@ def test_alembic_upgrade_head_runs_against_empty_sqlite_database(tmp_path) -> No row[1] for row in connection.execute("PRAGMA index_list(product_events)") } task_indexes = {row[1] for row in connection.execute("PRAGMA index_list(tasks)")} + task_columns = {row[1] for row in connection.execute("PRAGMA table_info(tasks)")} current_revision = connection.execute( "SELECT version_num FROM alembic_version", ).fetchone()[0] @@ -49,10 +50,12 @@ def test_alembic_upgrade_head_runs_against_empty_sqlite_database(tmp_path) -> No "ix_tasks_user_deleted_due_time", "ix_tasks_user_deleted_updated_id", } <= task_indexes + assert {"client_request_id", "create_payload_hash"} <= task_columns + assert "uq_tasks_user_client_request_id" in task_indexes assert { "ix_product_events_user_created", "ix_product_events_user_name_created", "ix_product_events_user_source_created", } <= product_event_indexes assert "ix_tasks_user_deleted_updated" not in task_indexes - assert current_revision == "20260605_0012" + assert current_revision == "20260715_0013" diff --git a/backend/tests/test_openapi_contract.py b/backend/tests/test_openapi_contract.py index 1696fbd..01f8fd8 100644 --- a/backend/tests/test_openapi_contract.py +++ b/backend/tests/test_openapi_contract.py @@ -2,6 +2,7 @@ from pathlib import Path from app.main import create_app +from tools.openapi_contract import build_contract_schema, normalize_contract_schema REPO_ROOT = Path(__file__).resolve().parents[2] OPENAPI_CONTRACT_PATH = REPO_ROOT / "shared" / "openapi.taskbridge.v1.json" @@ -14,11 +15,41 @@ def test_openapi_contract_snapshot_matches_runtime_schema(): ) committed_schema = json.loads(OPENAPI_CONTRACT_PATH.read_text(encoding="utf-8")) - runtime_schema = create_app().openapi() + runtime_schema = build_contract_schema() assert committed_schema == runtime_schema +def test_openapi_contract_uses_repository_version(): + expected_version = (REPO_ROOT / "VERSION").read_text(encoding="utf-8").strip() + + assert build_contract_schema()["info"]["version"] == expected_version + + +def test_openapi_contract_ignores_framework_validation_detail_drift(): + runtime_schema = { + "info": {"version": "runtime-override"}, + "components": { + "schemas": { + "ValidationError": { + "properties": { + "loc": {"type": "array"}, + "ctx": {"type": "object"}, + "input": {"title": "Input"}, + }, + }, + }, + }, + } + + normalized = normalize_contract_schema(runtime_schema) + + assert normalized["components"]["schemas"]["ValidationError"]["properties"] == { + "loc": {"type": "array"}, + } + assert runtime_schema["components"]["schemas"]["ValidationError"]["properties"]["ctx"] + + def test_openapi_contract_documents_cross_client_surface(): schema = create_app().openapi() diff --git a/backend/tests/test_reset_password_tool.py b/backend/tests/test_reset_password_tool.py new file mode 100644 index 0000000..a5527d7 --- /dev/null +++ b/backend/tests/test_reset_password_tool.py @@ -0,0 +1,50 @@ +from datetime import timedelta + +import pytest +from sqlalchemy.orm import Session + +from app.core.security import verify_password +from app.models.user import RefreshToken +from app.utils.time import utc_now +from tools.create_user import create_user +from tools.reset_password import reset_password + + +def test_reset_password_tool_updates_password_and_revokes_sessions( + db_session: Session, +) -> None: + user = create_user( + db_session, + username="recover-owner", + email="recover-owner@example.com", + password="password123", + ) + session = RefreshToken( + user_id=user.id, + device_id="lost-device", + token_hash="a" * 64, + expires_at=utc_now() + timedelta(days=7), + ) + db_session.add(session) + db_session.commit() + + recovered = reset_password( + db_session, + username_or_email="recover-owner@example.com", + password="recovered-password-456", + ) + + db_session.refresh(session) + assert recovered.id == user.id + assert verify_password("recovered-password-456", recovered.password_hash) + assert not verify_password("password123", recovered.password_hash) + assert session.revoked_at is not None + + +def test_reset_password_tool_rejects_unknown_account(db_session: Session) -> None: + with pytest.raises(ValueError, match="user not found"): + reset_password( + db_session, + username_or_email="missing@example.com", + password="recovered-password-456", + ) diff --git a/backend/tests/test_task_views_export_repeat.py b/backend/tests/test_task_views_export_repeat.py index 103a2ae..e5a2161 100644 --- a/backend/tests/test_task_views_export_repeat.py +++ b/backend/tests/test_task_views_export_repeat.py @@ -206,6 +206,93 @@ def test_task_list_uses_timeline_order_and_today_includes_overdue( ] +def test_task_list_rejects_unknown_view(client: TestClient) -> None: + headers = auth_headers(client, "unknown-view", "unknown-view@example.com") + + response = client.get("/api/v1/tasks", headers=headers, params={"view": "conflict"}) + + assert response.status_code == 422 + + +def test_today_view_uses_requested_iana_timezone(client: TestClient) -> None: + headers = auth_headers(client, "timezone-view", "timezone-view@example.com") + for title, planned_date in ( + ("Shanghai today", "2026-05-20"), + ("Los Angeles today", "2026-05-19"), + ): + response = client.post( + "/api/v1/tasks", + headers=headers, + json={"title": title, "planned_date": planned_date}, + ) + assert response.status_code == 201 + + shanghai_response = client.get( + "/api/v1/tasks", + headers=headers, + params={ + "view": "today", + "now": "2026-05-20T01:00:00Z", + "timezone": "Asia/Shanghai", + }, + ) + los_angeles_response = client.get( + "/api/v1/tasks", + headers=headers, + params={ + "view": "today", + "now": "2026-05-20T01:00:00Z", + "timezone": "America/Los_Angeles", + }, + ) + + assert shanghai_response.status_code == 200 + assert [task["title"] for task in shanghai_response.json()["data"]] == ["Shanghai today"] + assert los_angeles_response.status_code == 200 + assert [task["title"] for task in los_angeles_response.json()["data"]] == [ + "Los Angeles today" + ] + + +def test_task_list_rejects_unknown_timezone(client: TestClient) -> None: + headers = auth_headers(client, "unknown-timezone", "unknown-timezone@example.com") + + response = client.get( + "/api/v1/tasks", + headers=headers, + params={"view": "today", "timezone": "Mars/Olympus_Mons"}, + ) + + assert response.status_code == 400 + assert response.json()["message"] == "invalid timezone" + + +def test_task_meta_uses_the_same_requested_timezone_as_today_view(client: TestClient) -> None: + headers = auth_headers(client, "timezone-meta", "timezone-meta@example.com") + create_response = client.post( + "/api/v1/tasks", + headers=headers, + json={"title": "Los Angeles plan", "planned_date": "2026-05-19"}, + ) + assert create_response.status_code == 201 + + shanghai_response = client.get( + "/api/v1/tasks/meta", + headers=headers, + params={"now": "2026-05-20T01:00:00Z", "timezone": "Asia/Shanghai"}, + ) + los_angeles_response = client.get( + "/api/v1/tasks/meta", + headers=headers, + params={"now": "2026-05-20T01:00:00Z", "timezone": "America/Los_Angeles"}, + ) + + assert shanghai_response.status_code == 200 + assert shanghai_response.json()["data"]["counts"]["today"] == 0 + assert los_angeles_response.status_code == 200 + assert los_angeles_response.json()["data"]["counts"]["today"] == 1 + + def test_csv_export_escapes_spreadsheet_formula_values(client: TestClient) -> None: headers = auth_headers(client, "csv-user", "csv@example.com") diff --git a/backend/tests/test_tasks.py b/backend/tests/test_tasks.py index 8068f38..46038a5 100644 --- a/backend/tests/test_tasks.py +++ b/backend/tests/test_tasks.py @@ -93,6 +93,64 @@ def test_task_crud_complete_restore_and_soft_delete(client: TestClient) -> None: assert hidden_response.json()["data"] == [] +def test_task_create_is_idempotent_per_user(client: TestClient, db_session) -> None: + headers = auth_headers(client, "idempotent-create", "idempotent-create@example.com") + payload = { + "title": "Create exactly once", + "content": "The response may be lost and retried.", + "client_request_id": "web-offline-4aef27d7", + } + + first_response = client.post("/api/v1/tasks", headers=headers, json=payload) + retry_response = client.post("/api/v1/tasks", headers=headers, json=payload) + + assert first_response.status_code == 201 + assert retry_response.status_code == 201 + assert retry_response.json()["data"]["id"] == first_response.json()["data"]["id"] + assert retry_response.json()["data"]["client_request_id"] == payload["client_request_id"] + assert len(list(db_session.scalars(select(Task)))) == 1 + assert len(list(db_session.scalars(select(SyncLog)))) == 1 + + +def test_task_create_rejects_idempotency_key_reuse_with_different_payload( + client: TestClient, +) -> None: + headers = auth_headers(client, "idempotency-conflict", "idempotency-conflict@example.com") + request_id = "web-offline-collision" + + first_response = client.post( + "/api/v1/tasks", + headers=headers, + json={"title": "Original title", "client_request_id": request_id}, + ) + conflict_response = client.post( + "/api/v1/tasks", + headers=headers, + json={"title": "Different title", "client_request_id": request_id}, + ) + + assert first_response.status_code == 201 + assert conflict_response.status_code == 409 + assert conflict_response.json() == { + "code": 409, + "message": "client_request_id already used with different task data", + "data": None, + } + + +def test_task_create_idempotency_key_is_scoped_to_user(client: TestClient) -> None: + first_headers = auth_headers(client, "idempotency-user-a", "idempotency-a@example.com") + second_headers = auth_headers(client, "idempotency-user-b", "idempotency-b@example.com") + payload = {"title": "Independent task", "client_request_id": "shared-client-key"} + + first_response = client.post("/api/v1/tasks", headers=first_headers, json=payload) + second_response = client.post("/api/v1/tasks", headers=second_headers, json=payload) + + assert first_response.status_code == 201 + assert second_response.status_code == 201 + assert first_response.json()["data"]["id"] != second_response.json()["data"]["id"] + + def test_task_update_rejects_stale_expected_version(client: TestClient, db_session) -> None: headers = auth_headers(client, "stale-update", "stale-update@example.com") diff --git a/backend/tools/openapi_contract.py b/backend/tools/openapi_contract.py index b2bca71..69d6a4b 100644 --- a/backend/tools/openapi_contract.py +++ b/backend/tools/openapi_contract.py @@ -4,6 +4,7 @@ import difflib import json import sys +from copy import deepcopy from pathlib import Path from typing import Any @@ -11,6 +12,7 @@ REPO_ROOT = Path(__file__).resolve().parents[2] CONTRACT_PATH = REPO_ROOT / "shared" / "openapi.taskbridge.v1.json" +VERSION_PATH = REPO_ROOT / "VERSION" def main(argv: list[str] | None = None) -> int: @@ -20,7 +22,7 @@ def main(argv: list[str] | None = None) -> int: mode.add_argument("--write", action="store_true", help="refresh the committed contract") args = parser.parse_args(argv) - schema = create_app().openapi() + schema = build_contract_schema() rendered_schema = render_schema(schema) if args.write: @@ -32,6 +34,27 @@ def main(argv: list[str] | None = None) -> int: return check_contract(schema, rendered_schema) +def build_contract_schema() -> dict[str, Any]: + return normalize_contract_schema(create_app().openapi()) + + +def normalize_contract_schema(schema: dict[str, Any]) -> dict[str, Any]: + normalized = deepcopy(schema) + normalized.setdefault("info", {})["version"] = VERSION_PATH.read_text( + encoding="utf-8", + ).strip() + validation_properties = ( + normalized.get("components", {}) + .get("schemas", {}) + .get("ValidationError", {}) + .get("properties", {}) + ) + if isinstance(validation_properties, dict): + validation_properties.pop("ctx", None) + validation_properties.pop("input", None) + return normalized + + def check_contract(schema: dict[str, Any], rendered_schema: str) -> int: if not CONTRACT_PATH.exists(): print( diff --git a/backend/tools/reset_password.py b/backend/tools/reset_password.py new file mode 100644 index 0000000..5de187a --- /dev/null +++ b/backend/tools/reset_password.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +import argparse +import getpass +import sys + +from sqlalchemy import or_, select, update +from sqlalchemy.orm import Session + +from app.core.database import SessionLocal +from app.core.security import hash_password +from app.models.user import RefreshToken, User +from app.utils.time import utc_now + + +def reset_password( + db: Session, + *, + username_or_email: str, + password: str, +) -> User: + identifier = username_or_email.strip().lower() + if not identifier: + raise ValueError("username or email is required") + if not 8 <= len(password) <= 128: + raise ValueError("password must contain 8 to 128 characters") + + user = db.scalar( + select(User).where(or_(User.username == identifier, User.email == identifier)), + ) + if user is None: + raise ValueError("user not found") + + now = utc_now() + user.password_hash = hash_password(password) + user.updated_at = now + db.execute( + update(RefreshToken) + .where( + RefreshToken.user_id == user.id, + RefreshToken.revoked_at.is_(None), + ) + .values(revoked_at=now), + ) + db.add(user) + db.commit() + db.refresh(user) + return user + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + description="Reset a TaskBridge account password and revoke all active sessions.", + ) + parser.add_argument("--username-or-email", required=True, help="account username or email") + parser.add_argument("--password", help="new password; omit to enter it interactively") + parser.add_argument("--password-stdin", action="store_true", help="read the password from stdin") + args = parser.parse_args(argv) + + if args.password_stdin: + password = sys.stdin.readline().rstrip("\r\n") + elif args.password: + password = args.password + else: + password = getpass.getpass("New password: ") + + with SessionLocal() as db: + try: + user = reset_password( + db, + username_or_email=args.username_or_email, + password=password, + ) + except Exception as error: + print(f"failed to reset password: {error}", file=sys.stderr) + return 1 + + print(f"reset password for {user.username} ({user.email}); active sessions revoked") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/deploy/.env.example b/deploy/.env.example index 136796e..5b61489 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -9,6 +9,7 @@ # Docker Hub alternative: 27xk/taskbridge:latest TASKBRIDGE_BACKEND_IMAGE=ghcr.io/27xk/taskbridge:latest TASKBRIDGE_API_BIND=127.0.0.1:8000 +TASKBRIDGE_WEB_BIND=127.0.0.1:8080 # MySQL root password. Change this before using shared or production hosts. MYSQL_ROOT_PASSWORD=change-this-root-password @@ -25,7 +26,7 @@ MYSQL_PASSWORD=change-this-password # Backend runtime. APP_NAME=TaskBridge API -APP_VERSION=0.1.6 +APP_VERSION=0.1.8 ENVIRONMENT=production # Docker Compose service names are mysql and redis. @@ -45,9 +46,9 @@ REGISTRATION_ENABLED=false METRICS_ENABLED=true METRICS_TOKEN=replace-this-with-a-random-metrics-token -# Trusted reverse proxy IPs or CIDR ranges allowed to supply X-Forwarded-For / X-Real-IP. -# Example for Docker Compose internal proxy networks: TRUSTED_PROXY_IPS=172.16.0.0/12 -TRUSTED_PROXY_IPS= +# The bundled Web proxy has this fixed address on taskbridge_internal. +# Keep this exact address unless you also replace the Compose network definition. +TRUSTED_PROXY_IPS=172.30.27.10 # Public Web/PWA origins allowed to call the API. # Do not use localhost values in production. diff --git a/deploy/.env.local.example b/deploy/.env.local.example index 6ae0ab7..8f9bf73 100644 --- a/deploy/.env.local.example +++ b/deploy/.env.local.example @@ -6,6 +6,7 @@ TASKBRIDGE_BACKEND_IMAGE=ghcr.io/27xk/taskbridge:latest TASKBRIDGE_API_BIND=8000 +TASKBRIDGE_WEB_BIND=8080 MYSQL_ROOT_PASSWORD=taskbridge-local-root-password MYSQL_DATABASE=taskbridge @@ -13,7 +14,7 @@ MYSQL_USER=taskbridge MYSQL_PASSWORD=taskbridge-local-password APP_NAME=TaskBridge API -APP_VERSION=0.1.6 +APP_VERSION=0.1.8 ENVIRONMENT=development DATABASE_URL=mysql+pymysql://taskbridge:taskbridge-local-password@mysql:3306/taskbridge @@ -28,7 +29,7 @@ REGISTRATION_ENABLED=true METRICS_ENABLED=false METRICS_TOKEN= -TRUSTED_PROXY_IPS= +TRUSTED_PROXY_IPS=172.30.27.10 # Local trial uses development mode, so wildcard CORS lets phones and other # LAN devices open the static Web/PWA from their own hostnames during testing. diff --git a/deploy/Caddyfile.taskbridge.example b/deploy/Caddyfile.taskbridge.example index 911a017..6ea3baf 100644 --- a/deploy/Caddyfile.taskbridge.example +++ b/deploy/Caddyfile.taskbridge.example @@ -5,5 +5,5 @@ taskbridge.example.com { Strict-Transport-Security "max-age=31536000; includeSubDomains" } - reverse_proxy 127.0.0.1:8000 + reverse_proxy 127.0.0.1:8080 } diff --git a/deploy/README.md b/deploy/README.md index f2b9ed6..26acd08 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -9,25 +9,33 @@ 先确认要走哪条路径: - **本机试用:** 只在当前电脑或同一局域网里体验同步流程,使用下面的本机试用步骤。 -- **长期自托管:** 准备长期使用、多人使用或公网访问,完成试用验证后继续看“配置”,替换密码、密钥、域名和 HTTPS 反向代理。 +- **长期自托管:** 准备长期使用、多人使用或公网访问,完成试用验证后继续看“配置”,替换密码、密钥、域名和反向代理;公网建议使用 HTTPS / WSS。 ### 本机试用 -如果只是先在本机体验完整同步流程,请先确认这条路径需要 Docker: +如果只是先在本机体验完整同步流程,请先确认这条路径需要 Docker。推荐从 [GitHub Releases](https://github.com/27xk/TaskBridge/releases) 下载 `TaskBridge--deployment.zip`,解压后使用内置启动入口,不需要先安装 Git: + +- **Windows:** 启动 Docker Desktop 后,双击 `deploy/start-local.cmd`。 +- **Linux / macOS:** 启动 Docker 后,在解压目录运行 `sh deploy/start-local.sh`。 + +启动入口只会在 `.env` 不存在时复制本机试用模板,不会覆盖已有配置。它会启动容器并等待 `http://127.0.0.1:8080/ready`,确认就绪后才报告成功;Windows 会随后打开 Web 页面。 + +完整步骤: 1. 确认已安装 Docker Desktop。 -2. 复制 `.env.local.example` 为 `.env`,先不用改域名或反向代理。 -3. 执行下面的启动命令。 -4. 看到 `http://127.0.0.1:8000/ready` 返回可用后,在桌面端或 Web/PWA 中填写服务器根地址 `http://127.0.0.1:8000`。 -5. 如果 Android 模拟器连接本机后端,服务器地址使用 `http://10.0.2.2:8000`;真机或另一台电脑请填写后端所在电脑的局域网 IP。 +2. 下载并解压 Release 部署包,或准备完整项目源码。 +3. 使用上面的启动入口;需要手动控制时再复制 `.env.local.example` 为 `.env` 并执行下面的 Compose 命令。 +4. 打开 `http://127.0.0.1:8080/ready` 确认服务可用,再访问 `http://127.0.0.1:8080` 打开 Web/PWA。 +5. Web 登录页和本机 Windows 桌面端都填写 `http://127.0.0.1:8080`;Android 模拟器填写 `http://10.0.2.2:8080`。真机或另一台电脑填写 `http://<运行服务电脑的局域网 IP>:8080`。 环境要求: - 已安装 Docker Engine 或 Docker Desktop。 - `docker compose version` 可以正常执行。 -- 服务器防火墙允许访问 API 端口 `8000`,或由 Nginx / Caddy 反向代理到该端口。 +- 服务器防火墙允许访问统一入口 `8080`。普通客户端不需要访问 `8000`;只有开发或高级 API 直连时才单独开放该端口。 ```bash +# 源码方式;使用 Release 部署包时直接进入解压目录 git clone https://github.com/27xk/TaskBridge.git cd TaskBridge/deploy cp .env.local.example .env @@ -37,33 +45,35 @@ docker compose -f docker-compose.release.yml up -d Windows PowerShell: ```powershell +# 源码方式;使用 Release 部署包时直接进入解压目录 git clone https://github.com/27xk/TaskBridge.git cd TaskBridge\deploy Copy-Item .env.local.example .env docker compose -f docker-compose.release.yml up -d ``` -本机试用示例使用 `ENVIRONMENT=development`,并设置 `WEB_CORS_ORIGINS=*`,方便手机、局域网电脑和 Web/PWA 同时访问开发后端。如果要部署到公网或共享服务器,请改用下面“配置”章节的生产示例,替换密码、密钥和域名,并把 `WEB_CORS_ORIGINS` 改为明确的 HTTPS Web 客户端来源,例如 `WEB_CORS_ORIGINS=https://taskbridge.example.com`。 +本机试用示例使用 `ENVIRONMENT=development`,并设置 `WEB_CORS_ORIGINS=*`,方便手机、局域网电脑和 Web/PWA 同时访问开发后端。如果要部署到公网或共享服务器,请改用下面“配置”章节的生产示例,替换密码、密钥和域名,并把 `WEB_CORS_ORIGINS` 改为明确的 Web 客户端来源,例如 `WEB_CORS_ORIGINS=http://taskbridge.example.com` 或 `WEB_CORS_ORIGINS=https://taskbridge.example.com`。 启动后访问: ```text -http://127.0.0.1:8000/health -http://127.0.0.1:8000/ready +http://127.0.0.1:8080 +http://127.0.0.1:8080/health +http://127.0.0.1:8080/ready ``` -`/health` 只表示 API 进程存活,`/ready` 会检查数据库和 Redis 连通性。Redis 不可用时 `/ready` 返回 503,并在响应体中标记 `degraded`。 +`web` 容器直接提供静态客户端,并把 `/api/`、`/ws/`、`/health`、`/ready` 和 `/status` 转发到 `api` 容器。`/health` 只表示 API 进程存活,`/ready` 会检查数据库和 Redis 连通性。 ### 验证客户端能登录 -1. 打开 Web/PWA、桌面端或 Android 端。 -2. 在登录页填写服务器根地址:本机桌面/Web 使用 `http://127.0.0.1:8000`;Android 模拟器使用 `http://10.0.2.2:8000`;手机或另一台电脑使用后端所在电脑的局域网 IP 或域名。 +1. Web/PWA 访问 `http://127.0.0.1:8080`;桌面端或 Android 端从安装包打开。 +2. 在登录页填写统一服务器根地址:本机 Web 和 Windows 使用 `http://127.0.0.1:8080`;Android 模拟器使用 `http://10.0.2.2:8080`;手机或另一台电脑使用 `http://<服务器局域网 IP>:8080`。 3. 直接登录或注册。登录会自动检查连接;“检查连接”只用于排查服务器地址。 4. 新建一条待办,刷新或切换到另一端确认能看到同一条任务。 ## 镜像来源 -默认后端镜像: +源码部署默认跟随最新后端镜像: ```text ghcr.io/27xk/taskbridge:latest @@ -75,14 +85,19 @@ ghcr.io/27xk/taskbridge:latest TASKBRIDGE_BACKEND_IMAGE=27xk/taskbridge:latest ``` +GitHub Release 的 `TaskBridge--deployment.zip` 会把 `.env.example`、`.env.local.example` 和 Compose fallback 中的后端镜像固定为同一发布标签,例如 `ghcr.io/27xk/taskbridge:v0.1.8`。因此重新解压同一版本不会意外拉取未来的 `latest`。升级时请下载新版本部署包,或主动修改镜像标签。 + ## 服务 | 服务 | 端口 | 说明 | | --- | --- | --- | +| `web` | `8080` | Web/PWA 静态页面,并同源代理 API 和 WebSocket | | `api` | `8000` | TaskBridge 后端 API,对宿主机开放 | | `mysql` | 内部网络 | MySQL 数据库,默认不对宿主机开放 | | `redis` | 内部网络 | Redis,默认不对宿主机开放 | +Release Compose 使用固定内部网段 `172.30.27.0/24`,其中 Web 反向代理固定为 `172.30.27.10`。默认 `TRUSTED_PROXY_IPS=172.30.27.10` 只信任这一个代理出口,避免其他容器伪造客户端转发地址。 + ## 配置 复制并修改 `.env`: @@ -99,11 +114,12 @@ cp .env.example .env - `METRICS_TOKEN` - `REGISTRATION_ENABLED` - `TASKBRIDGE_API_BIND` +- `TASKBRIDGE_WEB_BIND` - `WEB_CORS_ORIGINS` 如果 `ENVIRONMENT=production`,`JWT_SECRET` 必须是至少 32 位的随机字符串,且数据库密码不能使用默认值。 -生产示例默认 `REGISTRATION_ENABLED=false`,避免部署后开放陌生账号注册;本机试用示例会打开注册,方便创建第一个账号。生产示例默认 `TASKBRIDGE_API_BIND=127.0.0.1:8000`,建议通过 Nginx / Caddy 对外提供 HTTPS;如果确实要直接暴露 API 端口,再改成 `8000` 或指定公网监听地址。 +生产示例默认 `REGISTRATION_ENABLED=false`,避免部署后开放陌生账号注册;本机试用示例会打开注册,方便创建第一个账号。生产示例把 Web 和 API 分别绑定到 `127.0.0.1:8080`、`127.0.0.1:8000`,建议让 Nginx / Caddy 对外代理 Web 入口 `8080`;公网建议使用 HTTPS / WSS。如果确实要直接提供明文入口,再把 `TASKBRIDGE_WEB_BIND` 改成 `8080`。 生成 `JWT_SECRET`: ```bash @@ -127,6 +143,22 @@ printf '%s\n' 'replace-with-a-strong-password' | docker compose -f docker-compos 确认第一个账号能登录后,再保持 `REGISTRATION_ENABLED=false`。需要新增用户时,重复执行该命令或临时打开注册后再关闭。 +### 忘记密码 + +已登录用户可在客户端的账号安全入口修改密码。无法登录时,自托管管理员可以在后端容器内重置密码;命令执行后会同时撤销该账号全部登录会话: + +```bash +docker compose -f docker-compose.release.yml exec api \ + python -m tools.reset_password --username-or-email owner@example.com +``` + +自动化环境可通过标准输入提供新密码,避免写入命令历史: + +```bash +printf '%s\n' 'replace-with-a-strong-password' | docker compose -f docker-compose.release.yml exec -T api \ + python -m tools.reset_password --username-or-email owner@example.com --password-stdin +``` + `.env` 中的 `DATABASE_URL` 密码需要和 `MYSQL_PASSWORD` 保持一致。默认文件写法如下: ```text @@ -138,30 +170,41 @@ DATABASE_URL=mysql+pymysql://taskbridge:change-this-password@mysql:3306/taskbrid ## 客户端连接地址 -客户端填写服务器根地址,例如: +Release Compose 下,Web/PWA、Windows 和 Android 都填写同一个入口: ```text -http://<服务器 IP 或域名>:8000 +http://<服务器 IP 或域名>:8080 ``` +该入口已经代理 `/api/` 和 `/ws/`。普通用户不需要知道后端端口,也不需要分别配置 HTTP 和 WebSocket 地址。 + 桌面端可以在设置页修改服务器地址,登录页也会保留首次连接入口。Android 端可以在登录 / 注册页直接填写服务器地址,也可以在设置页修改。客户端会自动派生请求地址和同步连接地址,保存后新的请求和同步连接会使用用户填写的服务器根地址。 构建时注入的地址会作为首次启动默认值,用户保存自己的服务器地址后会覆盖该默认值。 -高级端点通常不需要手动填写。只有在反向代理把请求路径或同步路径分流到不同位置时,才需要展开高级端点并分别填写: +高级端点通常不需要手动填写。只有开发直连后端,或反向代理把请求路径和同步路径分流到不同位置时,才需要展开并分别填写: ```text http://<服务器 IP 或域名>:8000/api/v1/ ws://<服务器 IP 或域名>:8000/ws/sync ``` -## HTTPS / WSS 反向代理 +## HTTP / WS 与 HTTPS / WSS 反向代理 -公网部署不要直接让客户端用明文 HTTP / WS 登录。建议在后端容器前放 Nginx 或 Caddy。客户端填写服务器根地址:`https://<域名>`。 +客户端支持 HTTP / WS,也支持 HTTPS / WSS。公网部署建议在后端容器前放 Nginx 或 Caddy,并使用 HTTPS / WSS;本机试用、内网或明确接受明文传输的部署可以继续使用 HTTP / WS。 + +客户端填写服务器根地址,例如: + +```text +http://<服务器 IP 或域名>:8080 +https://<域名> +``` 客户端会自动派生请求地址和同步连接地址。只有需要手动配置高级端点时,才分别填写: ```text +http://<服务器 IP 或域名>:8000/api/v1/ +ws://<服务器 IP 或域名>:8000/ws/sync https://<域名>/api/v1/ wss://<域名>/ws/sync ``` @@ -171,7 +214,9 @@ wss://<域名>/ws/sync - [Nginx 示例](./nginx.taskbridge.conf.example) - [Caddy 示例](./Caddyfile.taskbridge.example) -替换示例中的 `taskbridge.example.com` 和证书路径后,再把客户端默认服务器根地址改成对应的 HTTPS 域名。 +示例反向代理把外部域名转发到 Compose Web 入口 `127.0.0.1:8080`,再由内层 Nginx 分发静态资源、API 和 WebSocket。替换域名和证书路径后,客户端填写该统一域名。使用明文 HTTP / WS 时,不需要证书文件,但仍应确认网络边界和访问控制。 + +浏览器通常只允许 HTTPS、`localhost` 或 `127.0.0.1` 注册 Service Worker。通过 `http://192.168.x.x:8080` 访问时普通 Web、HTTP 和 WS 可以工作,但“安装为应用”和离线页面缓存可能被浏览器禁用;这是浏览器安全限制,不能由 TaskBridge 绕过。 ## 生产运维 @@ -196,22 +241,36 @@ Authorization: Bearer ### 可信代理 -如果后端部署在 Nginx / Caddy 等反向代理之后,只有把代理出口 IP 或 CIDR 写入 `TRUSTED_PROXY_IPS` 后,后端才会信任 `X-Forwarded-For` / `X-Real-IP` 并用于限流。不要把它设置成开放网段,除非你明确知道入口链路只有可信代理。 +如果后端部署在 Nginx / Caddy 等反向代理之后,只有把代理出口 IP 或 CIDR 写入 `TRUSTED_PROXY_IPS` 后,后端才会信任 `X-Forwarded-For` / `X-Real-IP` 并用于限流。Release Compose 的内层 Web 代理固定使用 `172.30.27.10`,所以示例环境变量只信任该地址;内层 Nginx 会用实际连接地址重写转发头,不接受客户端自行提供的 `X-Forwarded-For`。不要把它设置成开放网段,除非你明确知道入口链路只有可信代理。 + +如果宿主机已有网络与 `172.30.27.0/24` 冲突,需要同时修改 `docker-compose.release.yml` 中的子网、各服务静态地址和 `.env` 中的 `TRUSTED_PROXY_IPS`,然后重新创建容器。不要只改其中一处。 ## 更新 +使用 Git 源码部署时,先更新仓库。Web 容器通过 bind mount 直接读取仓库中的 `web/`,只执行 `docker compose pull` 不会更新 Web/PWA 文件。 + ```bash +cd TaskBridge +git pull --ff-only cd deploy docker compose -f docker-compose.release.yml pull docker compose -f docker-compose.release.yml up -d ``` +如果使用 GitHub Release 中的部署包,请先下载并解压新版部署包,再把原部署目录中的 `.env` 复制到新版 `deploy/` 目录,最后在新版目录中执行上面的两条 `docker compose` 命令。不要直接覆盖数据库备份;更新前建议先运行 `backup-mysql.sh`。 + ## 查看日志 ```bash docker compose -f docker-compose.release.yml logs -f api ``` +查看 Web 入口和代理日志: + +```bash +docker compose -f docker-compose.release.yml logs -f web +``` + ## 数据库迁移 后端镜像启动时会自动执行: @@ -284,8 +343,14 @@ ports: docker compose -f docker-compose.release.yml down ``` -如需删除数据卷: +## 永久删除服务器数据 + +`down -v` 不是普通停止命令。它会删除 Compose 的 MySQL 和 Redis 数据卷,包括所有账号、服务器任务、会话和同步状态;客户端尚未同步的本机数据也不会自动恢复这些服务器记录。 + +执行前先运行 `./backup-mysql.sh`,并确认备份文件可读取。只有明确要重建空白服务器时才执行: ```bash docker compose -f docker-compose.release.yml down -v ``` + +普通停止或重启只使用 `docker compose -f docker-compose.release.yml down` / `up -d`,不要附加 `-v`。 diff --git a/deploy/docker-compose.release.yml b/deploy/docker-compose.release.yml index cda8da0..98af61c 100644 --- a/deploy/docker-compose.release.yml +++ b/deploy/docker-compose.release.yml @@ -1,10 +1,28 @@ services: + web: + image: nginx:1.28-alpine + container_name: taskbridge-web + restart: unless-stopped + ports: + - "${TASKBRIDGE_WEB_BIND:-8080}:80" + volumes: + - ../web:/usr/share/nginx/html:ro + - ./nginx.web.conf:/etc/nginx/conf.d/default.conf:ro + depends_on: + api: + condition: service_healthy + networks: + taskbridge_internal: + ipv4_address: 172.30.27.10 + api: image: ${TASKBRIDGE_BACKEND_IMAGE:-ghcr.io/27xk/taskbridge:latest} container_name: taskbridge-api restart: unless-stopped env_file: - .env + environment: + TRUSTED_PROXY_IPS: ${TRUSTED_PROXY_IPS:-172.30.27.10} ports: - "${TASKBRIDGE_API_BIND:-8000}:8000" depends_on: @@ -12,6 +30,9 @@ services: condition: service_healthy redis: condition: service_healthy + networks: + taskbridge_internal: + ipv4_address: 172.30.27.20 mysql: image: mysql:8.4 @@ -34,6 +55,9 @@ services: interval: 10s timeout: 5s retries: 10 + networks: + taskbridge_internal: + ipv4_address: 172.30.27.30 redis: image: redis:7.4-alpine @@ -47,7 +71,16 @@ services: interval: 10s timeout: 5s retries: 10 + networks: + taskbridge_internal: + ipv4_address: 172.30.27.40 volumes: taskbridge_mysql: taskbridge_redis: + +networks: + taskbridge_internal: + ipam: + config: + - subnet: 172.30.27.0/24 diff --git a/deploy/nginx.taskbridge.conf.example b/deploy/nginx.taskbridge.conf.example index cec047c..992f34c 100644 --- a/deploy/nginx.taskbridge.conf.example +++ b/deploy/nginx.taskbridge.conf.example @@ -17,7 +17,7 @@ server { add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; location / { - proxy_pass http://127.0.0.1:8000; + proxy_pass http://127.0.0.1:8080; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; @@ -27,7 +27,7 @@ server { } location /ws/ { - proxy_pass http://127.0.0.1:8000; + proxy_pass http://127.0.0.1:8080; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; diff --git a/deploy/nginx.web.conf b/deploy/nginx.web.conf new file mode 100644 index 0000000..3974842 --- /dev/null +++ b/deploy/nginx.web.conf @@ -0,0 +1,62 @@ +map $http_x_forwarded_proto $taskbridge_forwarded_proto { + default $http_x_forwarded_proto; + "" $scheme; +} + +server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + client_max_body_size 20m; + + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header X-Frame-Options "DENY" always; + add_header Content-Security-Policy "default-src 'self'; connect-src 'self' http: https: ws: wss:; img-src 'self' data:; style-src 'self'; script-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'" always; + + location = /index.html { + expires -1; + try_files $uri =404; + } + + location = /sw.js { + expires -1; + try_files $uri =404; + } + + location / { + try_files $uri $uri/ /index.html; + } + + location /api/ { + proxy_pass http://api:8000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header X-Forwarded-Proto $taskbridge_forwarded_proto; + } + + location /ws/ { + proxy_pass http://api:8000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header X-Forwarded-Proto $taskbridge_forwarded_proto; + proxy_read_timeout 65s; + } + + location ~ ^/(health|ready|status)$ { + proxy_pass http://api:8000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header X-Forwarded-Proto $taskbridge_forwarded_proto; + } +} diff --git a/deploy/start-local.cmd b/deploy/start-local.cmd new file mode 100644 index 0000000..ee831e4 --- /dev/null +++ b/deploy/start-local.cmd @@ -0,0 +1,48 @@ +@echo off +setlocal +cd /d "%~dp0" + +docker compose version >nul 2>&1 +if errorlevel 1 ( + echo Docker Desktop is required. Install or start Docker Desktop, then run this file again. + pause + exit /b 1 +) + +if not exist ".env" ( + copy /Y ".env.local.example" ".env" >nul + if errorlevel 1 ( + echo Failed to create deploy\.env from the local-trial template. + pause + exit /b 1 + ) +) + +docker compose -f docker-compose.release.yml up -d +if errorlevel 1 ( + echo TaskBridge failed to start. Review the Docker output above. + pause + exit /b 1 +) + +set "TASKBRIDGE_READY_URL=http://127.0.0.1:8080/ready" +set /a TASKBRIDGE_READY_ATTEMPT=0 + +:wait_for_ready +powershell -NoProfile -Command "try { $response = Invoke-WebRequest -UseBasicParsing -TimeoutSec 2 '%TASKBRIDGE_READY_URL%'; if ($response.StatusCode -eq 200) { exit 0 } } catch {}; exit 1" >nul 2>&1 +if not errorlevel 1 goto ready +set /a TASKBRIDGE_READY_ATTEMPT+=1 +if %TASKBRIDGE_READY_ATTEMPT% GEQ 60 goto ready_timeout +timeout /t 2 /nobreak >nul +goto wait_for_ready + +:ready_timeout +echo TaskBridge did not become ready within 120 seconds. +echo Review logs with: docker compose -f docker-compose.release.yml logs +pause +exit /b 1 + +:ready +echo TaskBridge is ready at http://127.0.0.1:8080 +start "" "http://127.0.0.1:8080" +pause diff --git a/deploy/start-local.sh b/deploy/start-local.sh new file mode 100644 index 0000000..946fe8e --- /dev/null +++ b/deploy/start-local.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env sh +set -eu + +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +cd "$SCRIPT_DIR" + +if ! command -v docker >/dev/null 2>&1 || ! docker compose version >/dev/null 2>&1; then + printf '%s\n' "Docker Engine with Docker Compose is required." >&2 + exit 1 +fi + +if [ ! -f .env ]; then + cp .env.local.example .env +fi + +docker compose -f docker-compose.release.yml up -d + +ready_url="http://127.0.0.1:8080/ready" +attempt=0 +while :; do + if command -v curl >/dev/null 2>&1; then + curl -fsS --max-time 2 "$ready_url" >/dev/null 2>&1 && break + elif command -v wget >/dev/null 2>&1; then + wget -q -T 2 -O /dev/null "$ready_url" >/dev/null 2>&1 && break + elif docker compose -f docker-compose.release.yml exec -T web wget -q -T 2 -O /dev/null http://127.0.0.1/ready >/dev/null 2>&1; then + break + fi + + attempt=$((attempt + 1)) + if [ "$attempt" -ge 60 ]; then + printf '%s\n' "TaskBridge did not become ready within 120 seconds." >&2 + printf '%s\n' "Review logs with: docker compose -f docker-compose.release.yml logs" >&2 + exit 1 + fi + sleep 2 +done + +printf '%s\n' "TaskBridge is ready at http://127.0.0.1:8080" diff --git a/desktop/README.md b/desktop/README.md index 04f1f8a..531762f 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -15,6 +15,18 @@ 离线新增、编辑和完成任务依赖本机已有登录会话;第一次打开时请先连接服务器并登录一次。 +## 专注工作台界面 + +![TaskBridge 桌面端专注工作台](../docs/assets/desktop-focus-1440.png) + +主窗口只保留「今日」「全部」和「设置」3 个一级入口。正常同步状态收在左下角账户区,不占用任务工作区。 + +- **今日:** 首屏提供快捷添加,待办按逾期和稍后处理分组;完整编辑表单按需从右侧打开。 +- **全部:** 搜索、常用筛选和新增入口保持在顶部;低频筛选收在菜单中,选择任务后才显示批量操作栏。 +- **设置:** 账号与显示、账号安全、连接与同步、窗口、数据与备份、同步问题、项目与标签共 7 个分类,一次只显示当前分类。 + +窗口收窄后,左侧栏会自动切换为带 Tooltip 的图标导航。在更窄的窗口中,工具栏和设置分类会重新排布;任务编辑器覆盖主工作区,不会挤压列表或产生横向滚动。 + ## 开发者说明 下面内容面向本地开发、构建和发布。应用使用 Vue 3、TypeScript、Pinia、SQLite 和 electron-builder。运行时配置使用轻量 JSON 存储,避免引入额外桌面端配置依赖。 @@ -49,12 +61,15 @@ npm run check:sync-diagnostics npm run check:sync-recovery-center npm run check:desktop-theme npm run check:desktop-efficiency +npm run check:ux-priority-polish +npm run check:user-experience npm run check:desktop-docs npm run check:release-artifacts npm run check:production-hardening npm run check:android-sync-recovery npm run check:ci-workflows npm run check:contract-drift +npm run check:desktop-focus-visual npm run typecheck npm run build npm run dist @@ -88,9 +103,12 @@ npm run rebuild:native - `npm run check:desktop-backup` 检查桌面端备份导出分页、导入错误反馈和导入统计,避免坏备份静默显示为成功。 - `npm run check:desktop-theme` 检查桌面端主题配置、持久化、IPC 校验和设置页入口是否完整。 - `npm run check:desktop-efficiency` 检查桌面端设置自愈与恢复通知、提醒去重缓存裁剪以及任务列表排序 / 索引是否到位。 +- `npm run check:ux-priority-polish` 检查跨端主流程层级、首用引导和高优先级体验约束。 +- `npm run check:user-experience` 检查 Web、Windows 与 Android 的完整用户体验契约。 - `npm run check:desktop-docs` 检查 README、部署说明和桌面端说明是否与实际连接地址策略一致。 - `npm run check:ci-workflows` 检查 CI / release 是否真正运行桌面端关键守门脚本。 - `npm run check:contract-drift` 检查后端、桌面端和 Android 端任务 / 同步字段是否发生漂移。 +- `npm run check:desktop-focus-visual` 使用隔离用户目录和本地假 API 启动真实 Electron,验证 3 档窗口、关键交互、可访问名称和截图像素,并更新 `docs/assets/desktop-focus-*.png`。 - `npm run clean:dry-run` 查看会清理哪些本地构建缓存;`npm run clean` 会移除 `out/`、`release/`、Electron/Vite/npm 本地缓存等桌面端临时目录。 ## 功能 @@ -113,6 +131,8 @@ npm run rebuild:native - `TASKBRIDGE_BASE_URL` - `TASKBRIDGE_WS_URL` +普通用户连接 Release Compose 时,只在登录页填写 `http://<服务器>:8080`。下面分别注入 API / WebSocket 的示例用于开发直连或自定义代理,不是普通用户必填项。 + 本地打包时可这样指定: ```powershell @@ -121,7 +141,11 @@ $env:TASKBRIDGE_WS_URL="ws://192.168.1.10:8000/ws/sync" npm run dist ``` -用户安装后可以在设置页展示和修改后端连接地址。应用会迁移旧版本内置的默认地址,但会保留用户历史版本中已经手动改过的地址。 +用户安装后可以在设置页展示和修改后端连接地址。应用会迁移旧版本内置的默认地址,但会保留用户历史版本中已经手动改过的服务器地址,以及分别填写的自定义 API / WebSocket 端点。使用自定义端点登录失败后再次尝试时,也不会被服务器根地址自动派生的值覆盖。 + +刷新会话自然失效时,桌面端会保留原服务器和用户对应的 SQLite 工作区,并在登录页提供本机模式。进入后可以继续编辑,点击“登录并同步”后恢复网络服务;主动退出或切换服务器会完整清除工作区身份。 + +设置页中的连接地址是显式保存项。修改后如果切换到其他页面、退出或关闭窗口,桌面端会提示是否放弃未保存的连接草稿;刷新同步诊断不会覆盖正在编辑的地址。 ## 卸载清理 @@ -153,6 +177,8 @@ ELECTRON_BUILDER_CACHE=/.cache/electron-builder 这样既避免发布未签名安装包,也避免使用系统级 Electron 缓存目录导致权限问题。 +公开 GitHub Release 只上传通过证书签名的 Windows 安装包。发布环境未配置签名证书时,workflow 会省略桌面客户端文件,不会生成或上传 unsigned EXE;此时 Release 仍可发布其他已满足发布条件的产物。 + ## 本地验证 ```powershell diff --git a/desktop/electron.vite.config.ts b/desktop/electron.vite.config.ts index 83a7520..75f4c98 100644 --- a/desktop/electron.vite.config.ts +++ b/desktop/electron.vite.config.ts @@ -3,8 +3,8 @@ import { resolve } from "node:path"; import vue from "@vitejs/plugin-vue"; import { defineConfig } from "electron-vite"; -const FALLBACK_BASE_URL = "http://127.0.0.1:8000/api/v1"; -const FALLBACK_WS_URL = "ws://127.0.0.1:8000/ws/sync"; +const FALLBACK_BASE_URL = ""; +const FALLBACK_WS_URL = ""; const taskBridgeBaseUrl = readEndpointEnv("TASKBRIDGE_BASE_URL", FALLBACK_BASE_URL, ["http:", "https:"]); const taskBridgeWsUrl = readEndpointEnv("TASKBRIDGE_WS_URL", FALLBACK_WS_URL, ["ws:", "wss:"]); diff --git a/desktop/electron/auto-start.ts b/desktop/electron/auto-start.ts index 47e72f4..0bce6e8 100644 --- a/desktop/electron/auto-start.ts +++ b/desktop/electron/auto-start.ts @@ -7,10 +7,10 @@ export function setAutoStart(enabled: boolean): void { app.setLoginItemSettings({ openAtLogin: enabled, openAsHidden: true, + args: ["--hidden"], }); } export function getAutoStart(): boolean { return app.getLoginItemSettings().openAtLogin || settingsStore.get("autoStart"); } - diff --git a/desktop/electron/db.ts b/desktop/electron/db.ts index b54e951..6b0ed83 100644 --- a/desktop/electron/db.ts +++ b/desktop/electron/db.ts @@ -1,11 +1,18 @@ import { app } from "electron"; import Database from "better-sqlite3"; import { randomUUID } from "node:crypto"; -import { copyFileSync, existsSync } from "node:fs"; +import { existsSync } from "node:fs"; import { join } from "node:path"; import { parseQuickTask, shanghaiDayBounds, todayLocalDate } from "../shared/quick-add-parser"; -import { getSettings } from "./state"; +import { workspaceDatabaseFileName } from "../shared/workspace"; +import { + claimLegacyGlobalDatabaseWorkspace, + claimLegacyUserDatabaseWorkspace, + getActiveWorkspaceKey, + getSettings, +} from "./state"; +import { migrateSqliteDatabase } from "./sqlite-migration"; export type SyncStatus = "synced" | "pending_create" | "pending_update" | "pending_delete" | "sync_failed" | "conflict"; export type SyncAction = "create" | "update" | "delete" | "complete" | "restore"; @@ -76,6 +83,11 @@ export interface SyncQueueCounts { exhausted: number; } +export interface FloatingTaskSummary { + tasks: TaskRecord[]; + totalOpen: number; +} + export interface BackupImportUndoItem { localId: string; importedUpdatedAt: string; @@ -156,9 +168,6 @@ export function database(): Database.Database { db.close(); db = null; } - if (nextUserKey.startsWith("user-")) { - migrateLegacyGlobalDatabase(dbPath); - } try { db = new Database(dbPath); } catch (error) { @@ -238,12 +247,19 @@ export function database(): Database.Database { } function currentUserDatabaseKey(): { key: string; path: string } { - const currentUserId = getSettings().currentUserId; + const settings = getSettings(); + const currentUserId = settings.currentUserId; + const workspaceKey = getActiveWorkspaceKey(); if (typeof currentUserId === "number" && Number.isFinite(currentUserId) && currentUserId > 0) { const userId = Math.trunc(currentUserId); + if (!workspaceKey) { + throw new Error("Authenticated workspace is missing a valid server origin"); + } + const dbPath = join(app.getPath("userData"), workspaceDatabaseFileName(settings.baseUrl, userId)); + migrateLegacyWorkspaceDatabase(dbPath, userId, workspaceKey); return { - key: `user-${userId}`, - path: join(app.getPath("userData"), `taskbridge-user-${userId}.sqlite`), + key: workspaceKey, + path: dbPath, }; } return { @@ -252,15 +268,21 @@ function currentUserDatabaseKey(): { key: string; path: string } { }; } -function legacyGlobalDatabasePath(): string { +function migrateLegacyWorkspaceDatabase(dbPath: string, userId: number, workspaceKey: string): void { + if (existsSync(dbPath)) return; const userDataPath = app.getPath("userData"); - return join(userDataPath, "taskbridge.sqlite"); -} - -function migrateLegacyGlobalDatabase(dbPath: string): void { - const legacyPath = legacyGlobalDatabasePath(); - if (existsSync(dbPath) || !existsSync(legacyPath)) return; - copyFileSync(legacyPath, dbPath); + const legacyUserPath = join(userDataPath, `taskbridge-user-${userId}.sqlite`); + if ( + existsSync(legacyUserPath) && + claimLegacyUserDatabaseWorkspace(userId, workspaceKey) + ) { + migrateSqliteDatabase(legacyUserPath, dbPath); + return; + } + const legacyGlobalPath = join(userDataPath, "taskbridge.sqlite"); + if (existsSync(legacyGlobalPath) && claimLegacyGlobalDatabaseWorkspace(workspaceKey)) { + migrateSqliteDatabase(legacyGlobalPath, dbPath); + } } function normalizeDatabaseOpenError(error: unknown): Error { @@ -428,6 +450,32 @@ export function listTodayFloatingTasks(limit = 8): TaskRecord[] { return rows.map(taskFromRow); } +export function getTodayFloatingTaskSummary(limit = 8): FloatingTaskSummary { + const timeZone = getSettings().displayTimeZone; + const today = todayLocalDate(new Date(), timeZone); + const { startTime, endTime } = shanghaiDayBounds(today, timeZone); + const row = database() + .prepare( + ` + SELECT COUNT(*) AS total + FROM tasks + WHERE is_deleted = 0 + AND status NOT IN ('completed', 'done') + AND ( + (due_time IS NOT NULL AND datetime(due_time) >= datetime(@startTime) AND datetime(due_time) < datetime(@endTime)) + OR (remind_time IS NOT NULL AND datetime(remind_time) >= datetime(@startTime) AND datetime(remind_time) < datetime(@endTime)) + OR planned_date = @plannedDate + OR list_type = 'today' + ) + `, + ) + .get({ startTime, endTime, plannedDate: today }) as { total: number }; + return { + tasks: listTodayFloatingTasks(limit), + totalOpen: row.total, + }; +} + export function getTask(localId: string): TaskRecord | null { const row = database() .prepare("SELECT * FROM tasks WHERE local_id = ? LIMIT 1") diff --git a/desktop/electron/http.ts b/desktop/electron/http.ts index e211505..12a33c6 100644 --- a/desktop/electron/http.ts +++ b/desktop/electron/http.ts @@ -1,4 +1,11 @@ -import { clearTokens, getSettings, getTokens, setTokens } from "./state"; +import { broadcastSessionExpired, expireTokens, getSettings, getTokens, setTokens } from "./state"; +import { setTraySyncStatus } from "./tray"; +import { + canInvalidateAuthSession, + captureAuthSession, + classifyRefreshCommit, + type CapturedAuthSession, +} from "../shared/auth-session"; export interface ApiEnvelope { code: number; @@ -37,7 +44,14 @@ const ALLOWED_API_PATH_PREFIXES = [ "/sync/", ] as const; -let refreshInFlight: Promise | null = null; +const refreshFlights = new Map>(); + +class StaleAuthSessionError extends Error { + constructor() { + super("Authentication session changed while refreshing"); + this.name = "StaleAuthSessionError"; + } +} function isInvalidRefreshTokenError(error: unknown): boolean { return error instanceof ApiHttpError && (error.status === 401 || error.status === 403); @@ -48,6 +62,10 @@ export async function performApiRequest( ): Promise> { const safePath = normalizeApiPath(payload.url); let tokens = getTokens(); + const initialSettings = getSettings(); + const refreshSession = tokens?.refreshToken + ? captureAuthSession(initialSettings.baseUrl, tokens, initialSettings.deviceId) + : null; try { const response = await sendApiRequest(payload, safePath, tokens?.accessToken); @@ -60,28 +78,54 @@ export async function performApiRequest( } try { - const refreshed = await refreshTokenOnce(tokens.refreshToken); - setTokens({ - accessToken: refreshed.access_token, - refreshToken: refreshed.refresh_token, - }); + if (!refreshSession) throw new StaleAuthSessionError(); + const refreshed = await refreshTokenOnce(refreshSession); + commitRefreshResult(refreshSession, refreshed); tokens = getTokens(); return await sendApiRequest(payload, safePath, tokens?.accessToken); } catch (refreshError) { - if (isInvalidRefreshTokenError(refreshError)) { - clearTokens(); + if ( + refreshSession && + isInvalidRefreshTokenError(refreshError) && + canInvalidateAuthSession(refreshSession, getSettings().baseUrl, getTokens()) + ) { + expireTokens(); + setTraySyncStatus("signed-out"); + broadcastSessionExpired("refresh-rejected"); } throw refreshError; } } -function refreshTokenOnce(refreshTokenValue: string): Promise { - if (!refreshInFlight) { - refreshInFlight = refreshToken(refreshTokenValue).finally(() => { - refreshInFlight = null; - }); - } - return refreshInFlight; +function refreshTokenOnce(session: CapturedAuthSession): Promise { + const existing = refreshFlights.get(session.key); + if (existing) return existing; + const request = refreshToken(session).finally(() => { + if (refreshFlights.get(session.key) === request) { + refreshFlights.delete(session.key); + } + }); + refreshFlights.set(session.key, request); + return request; +} + +function commitRefreshResult( + session: CapturedAuthSession, + refreshed: TokenRefreshResponse, +): void { + const decision = classifyRefreshCommit( + session, + getSettings().baseUrl, + getTokens(), + refreshed, + ); + if (decision === "stale") throw new StaleAuthSessionError(); + if (decision === "already-applied") return; + setTokens({ + accessToken: refreshed.access_token, + refreshToken: refreshed.refresh_token, + userId: session.userId ?? undefined, + }); } function persistAuthTokensFromResponse(path: string, response: ApiEnvelope): void { @@ -122,14 +166,13 @@ async function sendApiRequest( }); } -async function refreshToken(refreshTokenValue: string): Promise { - const settings = getSettings(); +async function refreshToken(session: CapturedAuthSession): Promise { const response = await requestJson<{ access_token: string; refresh_token: string }>( - settings.baseUrl, + session.serverOrigin, "/auth/refresh", { method: "POST", - data: { refresh_token: refreshTokenValue, device_id: settings.deviceId }, + data: { refresh_token: session.refreshToken, device_id: session.deviceId }, }, ); return response.data; diff --git a/desktop/electron/ipc.ts b/desktop/electron/ipc.ts index f2f1109..c32db9f 100644 --- a/desktop/electron/ipc.ts +++ b/desktop/electron/ipc.ts @@ -12,6 +12,7 @@ import { enqueueTaskChange, getTask, getSyncQueueCounts, + getTodayFloatingTaskSummary, incrementAttempt, listQueue, listTasks, @@ -46,6 +47,7 @@ import { performApiRequest, type ApiRequestPayload } from "./http"; import { showTaskNotification } from "./notification"; import { clearLastBackupImportUndoItems, + broadcastSessionExpired, clearTokens, getLastBackupImportUndoItems, getLastBackupImportUndoSummary, @@ -60,6 +62,10 @@ import { getTraySyncStatus, refreshTrayMenu, setTraySyncStatus } from "./tray"; import { checkForUpdates, getUpdateStatus } from "./updater"; import { normalizeDesktopTheme } from "../shared/desktop-theme"; import { normalizeTimeZone } from "../shared/quick-add-parser"; +import { + isMutableAppSettingKey, + type MutableAppSettingKey, +} from "../shared/settings-policy"; const BACKUP_FORMAT = "taskbridge.desktop.backup.v1"; const DIAGNOSTIC_FORMAT = "taskbridge.desktop.diagnostics.v1"; @@ -115,12 +121,19 @@ let pendingBackupImport: PendingBackupImport | null = null; export function registerIpcHandlers(): void { handle("app:get-settings", () => getSettings()); handle("app:set-setting", (_, key: string, value: unknown) => setAppSetting(key, value)); + handle("app:set-connection", (_, baseUrl: unknown, wsUrl: unknown) => { + return setConnectionSettings(baseUrl, wsUrl); + }); handle("app:set-sync-status", (_, status: unknown) => { setTraySyncStatus(validateTraySyncStatus(status)); notifyFloatingSyncStatusChanged(); }); - handle("app:notify", (_, title: unknown, body: unknown) => { - showTaskNotification(validateNotificationText(title, "title"), validateNotificationText(body, "body", 320)); + handle("app:notify", (_, title: unknown, body: unknown, localId?: unknown) => { + showTaskNotification( + validateNotificationText(title, "title"), + validateNotificationText(body, "body", 320), + validateOptionalLocalId(localId), + ); }); handle("app:toggle-floating", () => toggleFloatingWindow()); handle("app:show-floating", () => showFloatingWindow()); @@ -135,7 +148,10 @@ export function registerIpcHandlers(): void { handle("app:check-for-updates", () => checkForUpdates()); handle("auth:has-tokens", () => hasTokens()); - handle("auth:clear-tokens", () => clearTokens()); + handle("auth:clear-tokens", () => { + clearTokens(); + setTraySyncStatus("signed-out"); + }); handle("api:request", (_, payload: unknown) => performApiRequest(validateApiRequestPayload(payload))); handle("db:tasks:list", (_, limit?: unknown, offset?: unknown, includeDeleted?: unknown) => { @@ -217,6 +233,7 @@ export function registerIpcHandlers(): void { }); handle("task:list-today", (_, limit?: unknown) => listTodayFloatingTasks(optionalBoundedInteger(limit, "limit", 1, 100))); + handle("task:today-summary", (_, limit?: unknown) => getTodayFloatingTaskSummary(optionalBoundedInteger(limit, "limit", 1, 100))); handle("task:quick-add", (_, title: unknown) => quickAddTask(validateNotificationText(title, "title", 255))); handle("task:complete", (_, localId: unknown) => completeTaskFromFloating(validateLocalId(localId))); handle("task:open-detail", (_, localId: unknown) => openTaskDetail(validateLocalId(localId))); @@ -259,14 +276,14 @@ function validateExternalUrl(value: unknown): string { return url.toString(); } +function validateOptionalLocalId(value: unknown): string | undefined { + if (value === undefined || value === null) return undefined; + return validateLocalId(value); +} + function setAppSetting(key: string, value: unknown): AppSettings { + if (!isMutableAppSettingKey(key)) return getSettings(); if (isStringSetting(key) && typeof value === "string") { - if (key === "baseUrl" && !isAllowedBaseUrl(value)) { - return getSettings(); - } - if (key === "wsUrl" && !isAllowedWebSocketUrl(value)) { - return getSettings(); - } if (key === "language" && value !== "zh-CN" && value !== "en-US") { return getSettings(); } @@ -293,12 +310,37 @@ function setAppSetting(key: string, value: unknown): AppSettings { return getSettings(); } +function setConnectionSettings(baseUrl: unknown, wsUrl: unknown): AppSettings { + if (typeof baseUrl !== "string" || !isAllowedBaseUrl(baseUrl)) { + throw new Error("Invalid API base URL"); + } + if (typeof wsUrl !== "string" || !isAllowedWebSocketUrl(wsUrl)) { + throw new Error("Invalid WebSocket URL"); + } + const previousSettings = getSettings(); + const serverChanged = normalizedOrigin(previousSettings.baseUrl) !== normalizedOrigin(baseUrl); + setSetting("baseUrl", baseUrl.trim()); + setSetting("wsUrl", wsUrl.trim()); + if (serverChanged && (hasTokens() || previousSettings.currentUserId !== null)) { + clearTokens(); + setTraySyncStatus("signed-out"); + broadcastSessionExpired("server-changed"); + } + return getSettings(); +} + +function normalizedOrigin(value: string): string { + try { + return new URL(value).origin; + } catch { + return ""; + } +} + function isStringSetting( key: string, -): key is "baseUrl" | "wsUrl" | "lastSyncTime" | "language" | "desktopTheme" | "displayTimeZone" { +): key is Extract { return ( - key === "baseUrl" || - key === "wsUrl" || key === "lastSyncTime" || key === "language" || key === "desktopTheme" || diff --git a/desktop/electron/main.ts b/desktop/electron/main.ts index 4a5e481..1c220e7 100644 --- a/desktop/electron/main.ts +++ b/desktop/electron/main.ts @@ -7,7 +7,7 @@ import { createFloatingWindow, showFloatingWindow } from "./floating-window"; import { registerIpcHandlers } from "./ipc"; import { showTaskNotification } from "./notification"; import { registerGlobalShortcuts, unregisterGlobalShortcuts } from "./shortcut"; -import { consumeSettingsRecoveryNotice, getSettings, windows } from "./state"; +import { consumeSettingsRecoveryNotice, getSettings, hasTokens, windows } from "./state"; import { createAppTray } from "./tray"; import { initializeAutoUpdater } from "./updater"; @@ -15,6 +15,18 @@ app.disableHardwareAcceleration(); app.commandLine.appendSwitch("disable-gpu"); app.commandLine.appendSwitch("disable-gpu-compositing"); +const hasSingleInstanceLock = app.requestSingleInstanceLock(); +let closeToTrayNoticeShown = false; + +if (!hasSingleInstanceLock) { + app.quit(); +} else { + app.on("second-instance", () => { + if (!app.isReady()) return; + restoreMainWindow(); + }); +} + function createMainWindow(showOnReady = true): BrowserWindow { if (windows.mainWindow && !windows.mainWindow.isDestroyed()) { return windows.mainWindow; @@ -51,6 +63,7 @@ function createMainWindow(showOnReady = true): BrowserWindow { if (!windows.isQuitting) { event.preventDefault(); mainWindow.hide(); + showCloseToTrayNotice(); } }); @@ -98,7 +111,7 @@ app.on("before-quit", () => { windows.isQuitting = true; }); -app.whenReady().then(() => { +if (hasSingleInstanceLock) app.whenReady().then(() => { Menu.setApplicationMenu(null); if (process.platform === "win32") { @@ -106,7 +119,7 @@ app.whenReady().then(() => { } registerIpcHandlers(); - const launchedHidden = app.getLoginItemSettings().wasOpenedAsHidden; + const launchedHidden = process.argv.includes("--hidden") || app.getLoginItemSettings().wasOpenedAsHidden; const mainWindow = createMainWindow(!launchedHidden); initializeAutoUpdater(mainWindow); showSettingsRecoveryNotice(); @@ -125,7 +138,7 @@ app.whenReady().then(() => { return; } - if (!launchedHidden && getSettings().floatingVisibleOnStart) { + if (!launchedHidden && hasTokens() && getSettings().floatingVisibleOnStart) { showFloatingWindow(); } createAppTray(); @@ -136,6 +149,25 @@ app.whenReady().then(() => { }); }); +function restoreMainWindow(): void { + const mainWindow = createMainWindow(false); + if (mainWindow.isMinimized()) mainWindow.restore(); + mainWindow.show(); + mainWindow.focus(); +} + +function showCloseToTrayNotice(): void { + if (closeToTrayNoticeShown) return; + closeToTrayNoticeShown = true; + const english = getSettings().language === "en-US"; + showTaskNotification( + "TaskBridge", + english + ? "TaskBridge is still running in the notification area. Use the tray menu to reopen or quit it." + : "TaskBridge 仍在通知区域运行,可从托盘菜单重新打开或彻底退出。", + ); +} + async function assertBridgeAvailable(window: BrowserWindow, name: string): Promise { await waitForRendererLoad(window); const hasBridge = await window.webContents.executeJavaScript("Boolean(window.taskBridge)"); diff --git a/desktop/electron/notification.ts b/desktop/electron/notification.ts index 9d9107c..2313df5 100644 --- a/desktop/electron/notification.ts +++ b/desktop/electron/notification.ts @@ -1,11 +1,23 @@ import { Notification } from "electron"; -export function showTaskNotification(title: string, body: string): void { +import { windows } from "./state"; + +export function showTaskNotification(title: string, body: string, localId?: string): void { if (!Notification.isSupported()) return; - new Notification({ + const notification = new Notification({ title, body, silent: false, - }).show(); + }); + if (localId) { + notification.on("click", () => { + const mainWindow = windows.mainWindow; + if (!mainWindow || mainWindow.isDestroyed()) return; + if (mainWindow.isMinimized()) mainWindow.restore(); + mainWindow.show(); + mainWindow.focus(); + mainWindow.webContents.send("taskbridge:open-task-detail", localId); + }); + } + notification.show(); } - diff --git a/desktop/electron/preload.ts b/desktop/electron/preload.ts index 77c7fee..778515a 100644 --- a/desktop/electron/preload.ts +++ b/desktop/electron/preload.ts @@ -1,4 +1,5 @@ import { contextBridge, ipcRenderer, type IpcRendererEvent } from "electron"; +import type { MutableAppSettingKey } from "../shared/settings-policy"; type Listener = (...args: Args) => void; @@ -17,9 +18,10 @@ contextBridge.exposeInMainWorld("taskBridge", { }, app: { getSettings: () => ipcRenderer.invoke("app:get-settings"), - setSetting: (key: string, value: unknown) => ipcRenderer.invoke("app:set-setting", key, value), + setSetting: (key: MutableAppSettingKey, value: unknown) => ipcRenderer.invoke("app:set-setting", key, value), + setConnection: (baseUrl: string, wsUrl: string) => ipcRenderer.invoke("app:set-connection", baseUrl, wsUrl), setSyncStatus: (status: string) => ipcRenderer.invoke("app:set-sync-status", status), - notify: (title: string, body: string) => ipcRenderer.invoke("app:notify", title, body), + notify: (title: string, body: string, localId?: string) => ipcRenderer.invoke("app:notify", title, body, localId), toggleFloating: () => ipcRenderer.invoke("app:toggle-floating"), showFloating: () => ipcRenderer.invoke("app:show-floating"), openExternal: (url: string) => ipcRenderer.invoke("app:open-external", url), @@ -31,6 +33,7 @@ contextBridge.exposeInMainWorld("taskBridge", { auth: { hasTokens: () => ipcRenderer.invoke("auth:has-tokens"), clearTokens: () => ipcRenderer.invoke("auth:clear-tokens"), + onSessionExpired: (callback: Listener<[string]>) => on("taskbridge:session-expired", callback), }, api: { request: (payload: unknown) => ipcRenderer.invoke("api:request", payload), @@ -79,6 +82,7 @@ contextBridge.exposeInMainWorld("taskBridge", { }, task: { listToday: (limit?: number) => ipcRenderer.invoke("task:list-today", limit), + getTodaySummary: (limit?: number) => ipcRenderer.invoke("task:today-summary", limit), quickAdd: (title: string) => ipcRenderer.invoke("task:quick-add", title), complete: (localId: string) => ipcRenderer.invoke("task:complete", localId), openDetail: (localId: string) => ipcRenderer.invoke("task:open-detail", localId), diff --git a/desktop/electron/shortcut.ts b/desktop/electron/shortcut.ts index 7c8e9cc..99a67a8 100644 --- a/desktop/electron/shortcut.ts +++ b/desktop/electron/shortcut.ts @@ -5,16 +5,33 @@ import { showTaskNotification } from "./notification"; import { getSettings, windows } from "./state"; export function registerGlobalShortcuts(): void { - globalShortcut.register("CommandOrControl+Alt+T", () => { + const failedShortcuts: string[] = []; + registerShortcut("CommandOrControl+Alt+T", () => { toggleFloatingWindow(); - }); + }, failedShortcuts); - globalShortcut.register("CommandOrControl+Shift+Space", () => { + registerShortcut("CommandOrControl+Shift+Space", () => { windows.mainWindow?.show(); windows.mainWindow?.webContents.send("taskbridge:quick-add"); windows.floatingWindow?.webContents.send("taskbridge:quick-add"); showTaskNotification("TaskBridge", getSettings().language === "en-US" ? "Quick add is ready." : "快速添加已就绪。"); - }); + }, failedShortcuts); + + if (failedShortcuts.length > 0) { + console.warn("[TaskBridge] shortcut registration failed", failedShortcuts); + const english = getSettings().language === "en-US"; + showTaskNotification( + "TaskBridge", + english + ? `Global shortcut registration failed: ${failedShortcuts.join(", ")}. Another app may already use it.` + : `全局快捷键注册失败:${failedShortcuts.join("、")}。可能已被其他应用占用。`, + ); + } +} + +function registerShortcut(accelerator: string, callback: () => void, failures: string[]): void { + const registered = globalShortcut.register(accelerator, callback); + if (!registered) failures.push(accelerator); } export function unregisterGlobalShortcuts(): void { diff --git a/desktop/electron/sqlite-migration.ts b/desktop/electron/sqlite-migration.ts new file mode 100644 index 0000000..836c062 --- /dev/null +++ b/desktop/electron/sqlite-migration.ts @@ -0,0 +1,36 @@ +import Database from "better-sqlite3"; +import { existsSync, renameSync, rmSync } from "node:fs"; + +export function migrateSqliteDatabase(sourcePath: string, destinationPath: string): boolean { + if (existsSync(destinationPath)) return false; + const temporaryPath = `${destinationPath}.migrating`; + rmSync(temporaryPath, { force: true }); + + try { + const source = new Database(sourcePath, { fileMustExist: true }); + try { + source.prepare("VACUUM INTO ?").run(temporaryPath); + } finally { + source.close(); + } + + const migrated = new Database(temporaryPath, { readonly: true, fileMustExist: true }); + try { + if (migrated.pragma("quick_check", { simple: true }) !== "ok") { + throw new Error("Migrated SQLite database failed integrity validation"); + } + } finally { + migrated.close(); + } + + if (existsSync(destinationPath)) { + rmSync(temporaryPath, { force: true }); + return false; + } + renameSync(temporaryPath, destinationPath); + return true; + } catch (error) { + rmSync(temporaryPath, { force: true }); + throw error; + } +} diff --git a/desktop/electron/state.ts b/desktop/electron/state.ts index ebe9265..1396f8d 100644 --- a/desktop/electron/state.ts +++ b/desktop/electron/state.ts @@ -8,18 +8,22 @@ import { type DesktopThemeId, } from "../shared/desktop-theme"; import { getSystemTimeZone, normalizeTimeZone } from "../shared/quick-add-parser"; +import { + EPOCH_SYNC_TIME, + isLegacyWorkspaceOwner, + migrateLegacyWorkspaceCursor, + tryCreateWorkspaceKey, +} from "../shared/workspace"; declare const __TASKBRIDGE_BASE_URL__: string; declare const __TASKBRIDGE_WS_URL__: string; -const FALLBACK_BASE_URL = "http://127.0.0.1:8000/api/v1"; -const FALLBACK_WS_URL = "ws://127.0.0.1:8000/ws/sync"; +const FALLBACK_BASE_URL = ""; +const FALLBACK_WS_URL = ""; const DEFAULT_BASE_URL = normalizeEndpointDefault(__TASKBRIDGE_BASE_URL__, FALLBACK_BASE_URL, isHttpUrl); const DEFAULT_WS_URL = normalizeEndpointDefault(__TASKBRIDGE_WS_URL__, FALLBACK_WS_URL, isWebSocketUrl); -const CURRENT_SETTINGS_SCHEMA_VERSION = 2; +const CURRENT_SETTINGS_SCHEMA_VERSION = 4; const LEGACY_BASE_URLS = new Set([ - FALLBACK_BASE_URL, - `${FALLBACK_BASE_URL}/`, "http://127.0.0.1:8000/api/v1", "http://127.0.0.1:8000/api/v1/", "http://localhost:8000/api/v1", @@ -28,7 +32,6 @@ const LEGACY_BASE_URLS = new Set([ "http://10.0.2.2:8000/api/v1/", ]); const LEGACY_WS_URLS = new Set([ - FALLBACK_WS_URL, "ws://127.0.0.1:8000/ws/sync", "ws://localhost:8000/ws/sync", "ws://10.0.2.2:8000/ws/sync", @@ -79,6 +82,10 @@ export interface StoreSchema { networkSettingsDefaultWsUrl?: string; deviceId?: string; lastSyncTime: string; + lastSyncTimeByWorkspace?: Record; + legacyLastSyncWorkspaceKey?: string; + legacyUserDatabaseWorkspaceByUser?: Record; + legacyGlobalDatabaseWorkspaceKey?: string; autoStart: boolean; floatingOpacity: number; floatingVisibleOnStart: boolean; @@ -176,7 +183,7 @@ export const settingsStore = new JsonSettingsStore({ language: "zh-CN", desktopTheme: DEFAULT_DESKTOP_THEME, displayTimeZone: getSystemTimeZone(), - lastSyncTime: "1970-01-01T00:00:00Z", + lastSyncTime: EPOCH_SYNC_TIME, autoStart: false, floatingOpacity: 0.96, floatingVisibleOnStart: true, @@ -184,7 +191,11 @@ export const settingsStore = new JsonSettingsStore({ floatingHeight: 460, }); +const previousSettingsSchemaVersion = normalizeSettingsSchemaVersion( + settingsStore.get("settingsSchemaVersion"), +); migrateNetworkSettings(); +initializeLegacyWorkspaceOwnership(previousSettingsSchemaVersion); normalizeStoredSettings(); export function getLastBackupImportUndoItems(): BackupImportUndoItem[] { @@ -228,11 +239,26 @@ export const windows: WindowRegistry = { isQuitting: false, }; +export type SessionExpiredReason = "refresh-rejected" | "server-changed"; + +export function broadcastSessionExpired(reason: SessionExpiredReason): void { + windows.mainWindow?.webContents.send("taskbridge:session-expired", reason); + windows.floatingWindow?.webContents.send("taskbridge:session-expired", reason); +} + export function getTokens(): TokenState | null { const accessToken = getSecret("accessToken"); const refreshToken = getSecret("refreshToken"); if (!accessToken || !refreshToken) return null; - return { accessToken, refreshToken }; + const currentUserId = settingsStore.get("currentUserId"); + return { + accessToken, + refreshToken, + userId: + typeof currentUserId === "number" && Number.isSafeInteger(currentUserId) && currentUserId > 0 + ? currentUserId + : undefined, + }; } export function hasTokens(): boolean { @@ -246,7 +272,8 @@ export function setTokens(tokens: TokenState): void { setSecret("accessToken", tokens.accessToken); setSecret("refreshToken", tokens.refreshToken); if (typeof tokens.userId === "number" && Number.isFinite(tokens.userId)) { - settingsStore.set("currentUserId", tokens.userId); + settingsStore.set("currentUserId", Math.trunc(tokens.userId)); + getActiveWorkspaceLastSyncTime(); } } @@ -256,6 +283,11 @@ export function clearTokens(): void { settingsStore.delete("currentUserId"); } +export function expireTokens(): void { + settingsStore.delete("accessToken"); + settingsStore.delete("refreshToken"); +} + export function getDeviceId(): string { let deviceId = settingsStore.get("deviceId"); if (!deviceId) { @@ -277,6 +309,10 @@ export function setSetting( setDisplayTimeZone(value as string); return getSettings(); } + if (key === "lastSyncTime") { + setActiveWorkspaceLastSyncTime(value as string); + return getSettings(); + } if (value === null) { settingsStore.delete(key as keyof StoreSchema); } else { @@ -306,7 +342,7 @@ export function getSettings(): AppSettings { desktopTheme: normalizeDesktopTheme(settingsStore.get("desktopTheme")), displayTimeZone: normalizeTimeZone(displayTimeZone), deviceId: getDeviceId(), - lastSyncTime: settingsStore.get("lastSyncTime"), + lastSyncTime: getActiveWorkspaceLastSyncTime(), autoStart: settingsStore.get("autoStart"), floatingOpacity: normalizeFloatingOpacity(settingsStore.get("floatingOpacity")), floatingVisibleOnStart: settingsStore.get("floatingVisibleOnStart"), @@ -317,6 +353,52 @@ export function getSettings(): AppSettings { }; } +export function getActiveWorkspaceKey(): string | null { + const currentUserId = settingsStore.get("currentUserId"); + return tryCreateWorkspaceKey( + settingsStore.get("baseUrl"), + typeof currentUserId === "number" ? currentUserId : null, + ); +} + +export function claimLegacyUserDatabaseWorkspace(userId: number, workspaceKey: string): boolean { + const userKey = String(Math.trunc(userId)); + const claims = settingsStore.get("legacyUserDatabaseWorkspaceByUser") ?? {}; + return isLegacyWorkspaceOwner(claims[userKey], workspaceKey); +} + +export function claimLegacyGlobalDatabaseWorkspace(workspaceKey: string): boolean { + return isLegacyWorkspaceOwner( + settingsStore.get("legacyGlobalDatabaseWorkspaceKey"), + workspaceKey, + ); +} + +function getActiveWorkspaceLastSyncTime(): string { + const workspaceKey = getActiveWorkspaceKey(); + if (!workspaceKey) return EPOCH_SYNC_TIME; + const migrated = migrateLegacyWorkspaceCursor( + settingsStore.get("lastSyncTimeByWorkspace") ?? {}, + settingsStore.get("lastSyncTime"), + settingsStore.get("legacyLastSyncWorkspaceKey"), + workspaceKey, + ); + settingsStore.setMany({ + lastSyncTimeByWorkspace: migrated.cursors, + legacyLastSyncWorkspaceKey: migrated.legacyWorkspaceKey, + }); + return migrated.current; +} + +function setActiveWorkspaceLastSyncTime(value: string): void { + const workspaceKey = getActiveWorkspaceKey(); + if (!workspaceKey) return; + settingsStore.set("lastSyncTimeByWorkspace", { + ...(settingsStore.get("lastSyncTimeByWorkspace") ?? {}), + [workspaceKey]: value.trim() || EPOCH_SYNC_TIME, + }); +} + export function consumeSettingsRecoveryNotice(): string | null { return settingsStore.consumeRecoveryNotice(); } @@ -359,12 +441,14 @@ function migrateNetworkSettings(): void { DEFAULT_BASE_URL, buildLegacyEndpointSet(LEGACY_BASE_URLS, settingsStore.get("networkSettingsDefaultBaseUrl")), isHttpUrl, + true, ); migrateStringSetting( "wsUrl", DEFAULT_WS_URL, buildLegacyEndpointSet(LEGACY_WS_URLS, settingsStore.get("networkSettingsDefaultWsUrl")), isWebSocketUrl, + true, ); settingsStore.setMany({ networkSettingsDefaultBaseUrl: DEFAULT_BASE_URL, @@ -385,15 +469,45 @@ function normalizeStoredSettings(): void { }); } +function initializeLegacyWorkspaceOwnership(previousSchemaVersion: number): void { + if (previousSchemaVersion >= CURRENT_SETTINGS_SCHEMA_VERSION) return; + const currentUserId = settingsStore.get("currentUserId"); + const workspaceKey = tryCreateWorkspaceKey( + settingsStore.get("baseUrl"), + typeof currentUserId === "number" ? currentUserId : null, + ); + if (!workspaceKey || typeof currentUserId !== "number") return; + + const userKey = String(Math.trunc(currentUserId)); + const existingUserClaims = settingsStore.get("legacyUserDatabaseWorkspaceByUser") ?? {}; + settingsStore.setMany({ + legacyLastSyncWorkspaceKey: + settingsStore.get("legacyLastSyncWorkspaceKey") || workspaceKey, + legacyUserDatabaseWorkspaceByUser: { + ...existingUserClaims, + [userKey]: existingUserClaims[userKey] || workspaceKey, + }, + legacyGlobalDatabaseWorkspaceKey: + settingsStore.get("legacyGlobalDatabaseWorkspaceKey") || workspaceKey, + }); +} + +function normalizeSettingsSchemaVersion(value: unknown): number { + return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 + ? value + : 0; +} + function migrateStringSetting( key: "baseUrl" | "wsUrl", fallback: string, legacyValues: Set, isValid: (value: string) => boolean, + preserveLegacyValue: boolean, ): void { const stored = settingsStore.get(key); const normalized = typeof stored === "string" ? stored.trim() : ""; - if (!normalized || legacyValues.has(normalized) || !isValid(normalized)) { + if (!normalized || (!preserveLegacyValue && legacyValues.has(normalized)) || !isValid(normalized)) { settingsStore.set(key, fallback); return; } diff --git a/desktop/electron/tray.ts b/desktop/electron/tray.ts index d4fa507..cfbbd62 100644 --- a/desktop/electron/tray.ts +++ b/desktop/electron/tray.ts @@ -2,14 +2,16 @@ import { app, Menu, nativeImage, Tray, type NativeImage } from "electron"; import { hideFloatingWindow, showFloatingWindow } from "./floating-window"; import { resolveAppIconPath } from "./app-icon"; -import { getSettings, windows } from "./state"; +import { getSettings, hasTokens, windows } from "./state"; let tray: Tray | null = null; -let latestSyncStatus = "已同步"; +let latestSyncStatus = "未登录"; export function createAppTray(): Tray { if (tray) return tray; + latestSyncStatus = hasTokens() ? "已同步" : "未登录"; + tray = new Tray(createTrayIcon()); tray.setToolTip("TaskBridge"); refreshTrayMenu(); @@ -45,6 +47,7 @@ export function getTraySyncStatus(): string { function formatSyncStatus(status: string): string { const normalized = status.toLowerCase(); if (normalized.includes("syncing")) return "同步中"; + if (normalized.includes("signed-out") || normalized.includes("unauthenticated")) return "未登录"; if (normalized.includes("offline")) return "离线"; if (normalized.includes("error")) return "同步异常"; if (normalized.includes("connected")) return "实时连接"; @@ -66,6 +69,7 @@ function localizedSyncStatus(): string { 实时连接: "Connected", 等待连接: "Waiting", 已同步: "Synced", + 未登录: "Not signed in", }; return map[latestSyncStatus] ?? latestSyncStatus; } diff --git a/desktop/electron/updater.ts b/desktop/electron/updater.ts index 7685fa0..45af47c 100644 --- a/desktop/electron/updater.ts +++ b/desktop/electron/updater.ts @@ -1,5 +1,7 @@ import { app, BrowserWindow } from "electron"; -import { autoUpdater } from "electron-updater"; +import updaterPackage from "electron-updater"; + +const { autoUpdater } = updaterPackage; export type UpdateState = | "disabled" diff --git a/desktop/package-lock.json b/desktop/package-lock.json index bfb278e..895f1e1 100644 --- a/desktop/package-lock.json +++ b/desktop/package-lock.json @@ -1,12 +1,12 @@ { "name": "taskbridge-desktop", - "version": "0.1.7", + "version": "0.1.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "taskbridge-desktop", - "version": "0.1.7", + "version": "0.1.8", "hasInstallScript": true, "dependencies": { "better-sqlite3": "^12.11.1", @@ -19,6 +19,7 @@ "electron": "^42.3.2", "electron-builder": "^26.8.1", "electron-vite": "^5.0.0", + "lucide-vue-next": "^0.577.0", "pinia": "^2.3.0", "typescript": "^5.7.2", "vite": "^6.0.5", @@ -4291,6 +4292,16 @@ "yallist": "^3.0.2" } }, + "node_modules/lucide-vue-next": { + "version": "0.577.0", + "resolved": "https://registry.npmmirror.com/lucide-vue-next/-/lucide-vue-next-0.577.0.tgz", + "integrity": "sha512-py05bAfv9SHVJqscbiOnjcnLlEmOffA58a+7XhZuFxrs6txe1E8VoR1ngWGTYO+9aVKABAz8l3ee3PqiQN9QPA==", + "dev": true, + "license": "ISC", + "peerDependencies": { + "vue": ">=3.0.1" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", diff --git a/desktop/package.json b/desktop/package.json index 0c71b87..80d8b0c 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,6 +1,6 @@ { "name": "taskbridge-desktop", - "version": "0.1.7", + "version": "0.1.8", "private": true, "description": "TaskBridge Windows desktop app", "type": "module", @@ -59,8 +59,10 @@ "check:source-tests-visible": "node ./scripts/check-source-tests-visible-config.mjs", "check:supply-chain": "node ./scripts/check-supply-chain-config.mjs", "check:desktop-auto-update": "node ./scripts/check-desktop-auto-update-config.mjs", + "check:desktop-focus-visual": "node ./scripts/check-desktop-focus-visual.mjs", "check:android-data-extraction": "node ./scripts/check-android-data-extraction-config.mjs", "check:security-governance": "node ./scripts/check-security-governance-config.mjs", + "check:ux-priority-polish": "node ./scripts/check-ux-priority-polish.mjs", "check:user-experience": "node ./scripts/check-user-experience-optimizations.mjs", "clean": "node ./scripts/clean.mjs", "clean:dry-run": "node ./scripts/clean.mjs --dry-run", @@ -77,6 +79,7 @@ "electron": "^42.3.2", "electron-builder": "^26.8.1", "electron-vite": "^5.0.0", + "lucide-vue-next": "^0.577.0", "pinia": "^2.3.0", "typescript": "^5.7.2", "vite": "^6.0.5", diff --git a/desktop/scripts/check-android-list-realtime-config.mjs b/desktop/scripts/check-android-list-realtime-config.mjs index fbfae8f..1eeaeae 100644 --- a/desktop/scripts/check-android-list-realtime-config.mjs +++ b/desktop/scripts/check-android-list-realtime-config.mjs @@ -69,7 +69,8 @@ assert.match(apiServiceSource, /@GET\("sync\/status"\)/, "Android API service mu assert.match(authRepositorySource, /testConnection/, "Android auth repository must expose a connection test"); assert.match(authRepositorySource, /apiService\.syncStatus\(\)/, "Android connection test must hit sync/status"); assert.match(retrofitSource, /EndpointInterceptor/, "Android HTTP client must rewrite requests to the saved API endpoint"); -assert.match(retrofitSource, /tokenDataStore\.apiBaseUrl\.first\(\)/, "Android HTTP endpoint interceptor must read the latest saved API URL"); +assert.match(retrofitSource, /tokenDataStore\.requestAuthContext\(\)/, "Android HTTP endpoint interceptor must read endpoint and auth state from one preference snapshot"); +assert.match(retrofitSource, /endpointUri\(requestContext\.apiBaseUrl\)/, "Android HTTP endpoint interceptor must use the latest saved API URL from the request context"); assert.match(syncManagerSource, /webSocketUrlProvider = \{ tokenDataStore\.webSocketUrl\.first\(\) \}/, "Android WebSocket must read the latest saved URL before connecting"); assert.match(loginScreenSource, /strings\.connectionSettings|Connection settings|连接设置/, "Android login screen must expose connection settings before sign-in"); assert.match(registerScreenSource, /strings\.connectionSettings|Connection settings|连接设置/, "Android register screen must expose connection settings before registration"); diff --git a/desktop/scripts/check-android-sync-recovery-config.mjs b/desktop/scripts/check-android-sync-recovery-config.mjs index 044e1ad..8a7e951 100644 --- a/desktop/scripts/check-android-sync-recovery-config.mjs +++ b/desktop/scripts/check-android-sync-recovery-config.mjs @@ -17,10 +17,10 @@ const [taskDaoSource, taskRepositorySource, settingsScreenSource, mainActivitySo for (const token of [ "data class SyncQueueCounts", - "fun queueCounts(ownerUserId: String)", - "fun exhaustedChanges(ownerUserId: String, limit: Int)", - "fun resetExhaustedAttempts(ownerUserId: String)", - "suspend fun countFailedSyncTasks(ownerUserId: String): Int", + "fun queueCounts(workspaceId: String)", + "fun exhaustedChanges(workspaceId: String, limit: Int)", + "fun resetExhaustedAttempts(workspaceId: String)", + "suspend fun countFailedSyncTasks(workspaceId: String): Int", ]) { assert.match(taskDaoSource, new RegExp(escapeRegExp(token)), `Android DAO must expose ${token}`); } @@ -30,10 +30,11 @@ for (const token of [ "suspend fun getExhaustedSyncQueuePreview", "suspend fun getFailedSyncTaskCount()", "suspend fun retryExhaustedSyncQueue()", - "syncQueueDao.resetExhaustedAttempts(ownerUserId())", + "syncQueueDao.resetExhaustedAttempts(workspaceId())", ]) { assert.match(taskRepositorySource, new RegExp(escapeRegExp(token)), `Android repository must expose ${token}`); } +assert.doesNotMatch(taskDaoSource, /WHERE ownerUserId = :ownerUserId/, "Android sync recovery queries must use server-and-user workspace isolation"); for (const token of [ "syncManager: SyncManager", @@ -57,9 +58,10 @@ assert.match( ); assert.match( settingsScreenSource, - /syncRecoveryRetryStartedText\(isEnglish\)/, - "Android settings must show a generic retry-started result for pending or failed sync", + /val syncResult = syncManager\.syncNowAndWait\(\)[\s\S]{0,900}SyncRunState\.Success[\s\S]{0,900}SyncRunState\.Offline[\s\S]{0,900}SyncRunState\.Failure/, + "Android settings must wait for sync and show distinct success, offline, and failure results", ); +assert.match(settingsScreenSource, /AppDynamicStatusText\([\s\S]{0,160}text = syncRecoveryNote[\s\S]{0,160}isError = syncRecoveryNoteIsError/, "Android sync recovery must render terminal retry feedback with an error state"); assert.match(mainActivitySource, /syncManager = container\.syncManager/, "MainActivity must pass SyncManager to SettingsScreen"); assert.match(packageSource, /check:android-sync-recovery/, "desktop package scripts must expose Android sync recovery check"); assert.match(localCheckSource, /check:android-sync-recovery/, "local check runner must include Android sync recovery check"); diff --git a/desktop/scripts/check-ci-workflow-config.mjs b/desktop/scripts/check-ci-workflow-config.mjs index d1cf16b..8749aa1 100644 --- a/desktop/scripts/check-ci-workflow-config.mjs +++ b/desktop/scripts/check-ci-workflow-config.mjs @@ -5,12 +5,13 @@ import { workspacePaths } from "./script-helpers.mjs"; const { desktopRoot, repoRoot } = workspacePaths(import.meta.url); -const [packageSource, ciSource, releaseSource, nodeVersionSource, rootPackageSource] = await Promise.all([ +const [packageSource, ciSource, releaseSource, nodeVersionSource, rootPackageSource, localCheckSource] = await Promise.all([ readFile(resolve(desktopRoot, "package.json"), "utf8"), readFile(resolve(repoRoot, ".github/workflows/ci.yml"), "utf8"), readFile(resolve(repoRoot, ".github/workflows/release.yml"), "utf8"), readFile(resolve(repoRoot, ".node-version"), "utf8"), readFile(resolve(repoRoot, "package.json"), "utf8"), + readFile(resolve(repoRoot, "scripts/check-local.ps1"), "utf8"), ]); const packageJson = JSON.parse(packageSource); @@ -30,6 +31,7 @@ const requiredDesktopChecks = [ "check:sync-recovery-center", "check:desktop-backup", "check:desktop-theme", + "check:desktop-efficiency", "check:desktop-docs", "check:local-bootstrap", "check:release-readiness", @@ -52,6 +54,8 @@ const requiredDesktopChecks = [ "check:desktop-auto-update", "check:android-data-extraction", "check:security-governance", + "check:ux-priority-polish", + "check:user-experience", ]; for (const script of requiredDesktopChecks) { @@ -62,6 +66,10 @@ for (const script of requiredDesktopChecks) { ); } +for (const script of ["check:desktop-efficiency", "check:ux-priority-polish", "check:user-experience"]) { + assert.ok(localCheckSource.includes(`"${script}"`), `local verification must run ${script}`); +} + for (const [workflowName, source] of [ ["CI", ciSource], ["release", releaseSource], @@ -100,6 +108,16 @@ assert.match( /TASKBRIDGE_SKIP_NATIVE_REBUILD:\s*"1"/, "release desktop npm ci must skip Electron native rebuild", ); +assert.match( + ciSource, + /- name: Rebuild Electron native dependencies[\s\S]{0,160}run: npm run rebuild:native/, + "CI must rebuild native dependencies for Electron after npm ci", +); +assert.match( + releaseSource, + /- name: Rebuild Electron native dependencies[\s\S]{0,160}run: npm run rebuild:native/, + "release must rebuild native dependencies for Electron after npm ci", +); assert.match(releaseSource, /Release version must match VERSION/, "release workflow must reject tags that do not match VERSION"); assert.match(ciSource, /^\s+android:\s*$/m, "CI workflow must include an Android job"); assert.match(ciSource, /runs-on:\s+ubuntu-latest[\s\S]*working-directory:\s+android/, "CI Android job must run from android/"); @@ -151,9 +169,15 @@ console.log("CI workflow desktop check coverage passed"); function assertDesktopCheckOrder(workflowName, source, terminalCommand) { const installIndex = source.indexOf("npm ci"); + const nativeRebuildIndex = source.indexOf("npm run rebuild:native"); + const unitTestIndex = source.indexOf("npm run test:unit"); const terminalIndex = source.indexOf(terminalCommand); const webUnitTestIndex = source.indexOf("npm --prefix .. run test:web"); assert.ok(installIndex >= 0, `${workflowName} workflow desktop job must install dependencies with npm ci`); + assert.ok( + nativeRebuildIndex > installIndex && nativeRebuildIndex < unitTestIndex, + `${workflowName} workflow must rebuild Electron native dependencies before desktop unit tests`, + ); assert.ok(terminalIndex > installIndex, `${workflowName} workflow must run ${terminalCommand} after npm ci`); assert.ok( webUnitTestIndex > installIndex && webUnitTestIndex < terminalIndex, diff --git a/desktop/scripts/check-contract-drift.mjs b/desktop/scripts/check-contract-drift.mjs index b3e624c..244f82f 100644 --- a/desktop/scripts/check-contract-drift.mjs +++ b/desktop/scripts/check-contract-drift.mjs @@ -164,6 +164,7 @@ assertRouteCoverage("backend auth routes", backendAuthRoutes, [ ["/auth/login", /@router\.post\("\/login"\)/], ["/auth/refresh", /@router\.post\("\/refresh"\)/], ["/auth/me", /@router\.get\("\/me"\)/], + ["/auth/password", /@router\.put\("\/password"\)/], ["/auth/ws-ticket", /@router\.post\("\/ws-ticket"\)/], ["/auth/sessions", /@router\.get\("\/sessions"\)/], ["/auth/sessions/revoke-other-devices", /@router\.post\("\/sessions\/revoke-other-devices"\)/], @@ -235,6 +236,10 @@ assertRouteCoverage("desktop auth api", desktopAuthApi, [ ["/auth/login", /request\.post\("\/auth\/login"/], ["/auth/me", /request\.get\("\/auth\/me"/], ["/auth/ws-ticket", /request\.post\("\/auth\/ws-ticket"/], + ["/auth/password", /request\.put\("\/auth\/password"/], + ["/auth/sessions", /request\.get\("\/auth\/sessions"/], + ["/auth/sessions/revoke-other-devices", /request\.post\("\/auth\/sessions\/revoke-other-devices"/], + ["/auth/sessions/{session_id}", /request\.delete\(`\/auth\/sessions\/\$\{sessionId\}`/], ]); assertRouteCoverage("desktop auth refresh path", desktopHttpSource, [ ["/auth/refresh", /"\/auth\/refresh"/], @@ -247,7 +252,8 @@ assertRouteCoverage("desktop sync api", desktopSyncApi, [ ["/sync/push", /request\.post\("\/sync\/push"/], ]); assertRouteCoverage("desktop task api", desktopTaskApi, [ - ["GET /tasks", /request\.get\(params \? `\/tasks\?\$\{params\}` : "\/tasks"\)/], + ["GET /tasks", /request\.get\("\/tasks", \{ params \}\)/], + ["GET /tasks/meta", /request\.get\("\/tasks\/meta", \{ params:/], ["POST /tasks", /request\.post\("\/tasks"/], ["GET /tasks/{task_id}", /request\.get\(`\/tasks\/\$\{taskId\}`/], ["PUT /tasks/{task_id}", /request\.put\(`\/tasks\/\$\{taskId\}`/], diff --git a/desktop/scripts/check-desktop-backup-config.mjs b/desktop/scripts/check-desktop-backup-config.mjs index 1b7d1df..7a82780 100644 --- a/desktop/scripts/check-desktop-backup-config.mjs +++ b/desktop/scripts/check-desktop-backup-config.mjs @@ -15,6 +15,7 @@ const [ androidSettingsSource, androidTaskRepositorySource, androidMainActivitySource, + androidSharedTextReaderSource, androidManifestSource, androidFilePathsSource, ] = await Promise.all([ @@ -27,6 +28,7 @@ const [ readFile(resolve(repoRoot, "android/app/src/main/java/com/taskbridge/app/ui/settings/SettingsScreen.kt"), "utf8"), readFile(resolve(repoRoot, "android/app/src/main/java/com/taskbridge/app/data/repository/TaskRepository.kt"), "utf8"), readFile(resolve(repoRoot, "android/app/src/main/java/com/taskbridge/app/MainActivity.kt"), "utf8"), + readFile(resolve(repoRoot, "android/app/src/main/java/com/taskbridge/app/ui/editor/SharedTextReader.kt"), "utf8"), readFile(resolve(repoRoot, "android/app/src/main/AndroidManifest.xml"), "utf8"), readFile(resolve(repoRoot, "android/app/src/main/res/xml/file_paths.xml"), "utf8"), ]); @@ -105,12 +107,18 @@ assert.match(androidSettingsSource, /FileProvider\.getUriForFile/, "Android back assert.match(androidSettingsSource, /Intent\.EXTRA_STREAM/, "Android backup export must use EXTRA_STREAM"); assert.match(androidMainActivitySource, /Intent\.EXTRA_STREAM/, "Android backup import must read shared backup file streams"); assert.match(androidMainActivitySource, /contentResolver\.openInputStream/, "Android backup import must open shared backup URIs"); -assert.match(androidMainActivitySource, /MAX_SHARED_TEXT_BYTES/, "Android backup import must cap shared payload size"); +assert.match(androidMainActivitySource, /sharedPayloadReadLimit\(mimeType\)/, "Android shared payload reads must select a MIME-aware byte limit"); assert.match( - androidMainActivitySource, - /MAX_SHARED_TEXT_BYTES\s*=\s*20_000_000/, + androidSharedTextReaderSource, + /MAX_SHARED_TEXT_BYTES\s*=\s*1_048_576/, + "Android ordinary shared text must keep a bounded 1MB input limit", +); +assert.match( + androidSharedTextReaderSource, + /MAX_SHARED_BACKUP_BYTES\s*=\s*20_000_000/, "Android share-target backup import must use the same 20MB cap as other backup import paths", ); +assert.match(androidMainActivitySource, /requireSharedPayloadWithinLimit\(text, isTaskBridgeBackupText\(text\)\)/, "Android must reapply the 1MB limit when shared JSON is not a TaskBridge backup"); assert.match(androidSettingsSource, /MAX_SELECTED_BACKUP_BYTES/, "Android settings backup picker must define a selected-file import size cap"); assert.match( androidSettingsSource, diff --git a/desktop/scripts/check-desktop-docs.mjs b/desktop/scripts/check-desktop-docs.mjs index f1f1ee5..d39e6f4 100644 --- a/desktop/scripts/check-desktop-docs.mjs +++ b/desktop/scripts/check-desktop-docs.mjs @@ -5,12 +5,13 @@ import { workspacePaths } from "./script-helpers.mjs"; const { desktopRoot, repoRoot } = workspacePaths(import.meta.url); -const [rootReadme, desktopReadme, deployReadme, troubleshootingReadme, androidReadme] = await Promise.all([ +const [rootReadme, desktopReadme, deployReadme, troubleshootingReadme, androidReadme, developmentReadme] = await Promise.all([ readFile(resolve(repoRoot, "README.md"), "utf8"), readFile(resolve(desktopRoot, "README.md"), "utf8"), readFile(resolve(repoRoot, "deploy/README.md"), "utf8"), readFile(resolve(repoRoot, "docs/troubleshooting.md"), "utf8"), readFile(resolve(repoRoot, "android/README.md"), "utf8"), + readFile(resolve(repoRoot, "docs/development.md"), "utf8"), ]); assert.match( @@ -47,9 +48,9 @@ for (const source of [rootReadme, deployReadme, troubleshootingReadme, androidRe assert.match(source, /服务器地址/, "user-facing docs must mention server address"); } assert.match( - rootReadme, - /http:\/\/192\.168\.1\.10:8000\s*```/, - "README normal setup example must show a single server address", + developmentReadme, + /http:\/\/192\.168\.1\.10:8080\s*```/, + "development docs normal setup example must show a single server address", ); assert.doesNotMatch( rootReadme, @@ -63,14 +64,14 @@ assert.doesNotMatch( "README.md must not tell users installed desktop endpoints are locked after installation", ); assert.match( - rootReadme, + developmentReadme, /轻量 JSON 配置/, - "README.md technology stack must mention the current desktop settings storage", + "development docs technology stack must mention the current desktop settings storage", ); assert.match( - rootReadme, + developmentReadme, /桌面端主题/, - "README.md must document the desktop theme feature", + "development docs must document the desktop theme feature", ); for (const script of [ "check:auth-session-config", @@ -87,7 +88,7 @@ for (const script of [ "check:ci-workflows", "check:contract-drift", ]) { - assert.match(rootReadme, new RegExp(script), `README.md must list ${script} in desktop verification commands`); + assert.match(developmentReadme, new RegExp(script), `development docs must list ${script} in desktop verification commands`); } assert.match( deployReadme, diff --git a/desktop/scripts/check-desktop-efficiency-config.mjs b/desktop/scripts/check-desktop-efficiency-config.mjs index 8026cce..7e6154d 100644 --- a/desktop/scripts/check-desktop-efficiency-config.mjs +++ b/desktop/scripts/check-desktop-efficiency-config.mjs @@ -1,16 +1,21 @@ import assert from "node:assert/strict"; import { readFile } from "node:fs/promises"; import { resolve } from "node:path"; -import { workspaceRoot } from "./script-helpers.mjs"; +import { + extractOpeningTag, + hasLiteralBooleanAttribute, + workspaceRoot, +} from "./script-helpers.mjs"; const root = workspaceRoot(import.meta.url); -const [stateSource, appSource, dbSource, mainSource, settingsViewSource] = await Promise.all([ +const [stateSource, appSource, dbSource, mainSource, settingsViewSource, syncRecoveryPanelSource] = await Promise.all([ readFile(resolve(root, "electron/state.ts"), "utf8"), readFile(resolve(root, "src/App.vue"), "utf8"), readFile(resolve(root, "electron/db.ts"), "utf8"), readFile(resolve(root, "electron/main.ts"), "utf8"), readFile(resolve(root, "src/views/SettingsView.vue"), "utf8"), + readFile(resolve(root, "src/components/settings/SettingsSyncRecoveryPanel.vue"), "utf8"), ]); assert.match( @@ -45,9 +50,10 @@ assert.match( ); assert.match( stateSource, - /const CURRENT_SETTINGS_SCHEMA_VERSION = 2;/, - "settings schema version must be bumped for startup normalization", + /const CURRENT_SETTINGS_SCHEMA_VERSION = 4;/, + "settings schema version must be bumped for workspace ownership migration", ); +assert.match(stateSource, /initializeLegacyWorkspaceOwnership\(previousSettingsSchemaVersion\);/, "settings migration must record the known owner of legacy workspace data once"); assert.match( stateSource, /normalizeStoredSettings\(\);/, @@ -149,11 +155,13 @@ assert.match( /updated_at DESC,\s+local_id ASC/, "desktop task ordering must be deterministic across equal timestamps", ); -assert.match( - settingsViewSource, - /
[\s\S]*?\{\{ settingsStore\.t\("settings\.syncDiagnostics"\) \}\}<\/summary>[\s\S]*?
[\s\S]*?<\/details>/, - "desktop sync diagnostics must be hidden behind an advanced details disclosure", +const diagnosticsDetailsTag = extractOpeningTag( + syncRecoveryPanelSource, + '
\{\{ settingsStore\.t\("settings\.syncDiagnostics"\) \}\}<\/summary>[\s\S]*?
/, "desktop sync diagnostics must be hidden behind an advanced details disclosure"); assert.match( settingsViewSource, /function timeZoneOptionLabel/, diff --git a/desktop/scripts/check-desktop-endpoint-config.mjs b/desktop/scripts/check-desktop-endpoint-config.mjs index c7f979e..de8051d 100644 --- a/desktop/scripts/check-desktop-endpoint-config.mjs +++ b/desktop/scripts/check-desktop-endpoint-config.mjs @@ -97,13 +97,8 @@ assert.match( ); assert.match( settingsViewSource, - /setSetting\("baseUrl"/, - "settings page must persist API base URL edits", -); -assert.match( - settingsViewSource, - /setSetting\("wsUrl"/, - "settings page must persist sync WebSocket URL edits", + /setConnection\(baseUrl\.trim\(\), wsUrl\.trim\(\)\)/, + "settings page must atomically persist API and WebSocket endpoint edits", ); assert.match( settingsSurfaceSource, diff --git a/desktop/scripts/check-desktop-focus-visual.mjs b/desktop/scripts/check-desktop-focus-visual.mjs new file mode 100644 index 0000000..5e000bb --- /dev/null +++ b/desktop/scripts/check-desktop-focus-visual.mjs @@ -0,0 +1,1174 @@ +import assert from "node:assert/strict"; +import http from "node:http"; +import { spawn } from "node:child_process"; +import { existsSync } from "node:fs"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { inflateSync } from "node:zlib"; + +import { findUnexpectedRuntimeLogLines, workspacePaths } from "./script-helpers.mjs"; + +const { desktopRoot, repoRoot } = workspacePaths(import.meta.url); +const electronPath = resolve(desktopRoot, "node_modules/electron/dist/electron.exe"); +const mainPath = resolve(desktopRoot, "out/main/index.js"); +const screenshotDirectory = resolve(repoRoot, "docs/assets"); +const apiRequests = []; +const unexpectedApiPaths = new Set(); +const runtimeLogs = []; +const expectedRuntimeWarningPatterns = [ + /^\[TaskBridge\] shortcut registration failed\b/, +]; +let electronProcess; +let cdp; +let apiServer; +let userDataDirectory; + +async function main() { + assert.equal(process.platform, "win32", "desktop focus visual check currently requires Windows"); + assert.ok(existsSync(electronPath), "Electron runtime is missing; run npm ci in desktop first"); + assert.ok(existsSync(mainPath), "desktop build is missing; run npm run build before visual verification"); + + try { + userDataDirectory = await mkdtemp(join(tmpdir(), "taskbridge-desktop-focus-")); + apiServer = createApiServer(); + await listen(apiServer); + const apiPort = apiServer.address().port; + const cdpPort = await reservePort(); + await writeIsolatedSettings(apiPort); + + electronProcess = launchElectron(cdpPort); + const pageTarget = await waitForPageTarget(cdpPort); + cdp = await CdpClient.connect(pageTarget.webSocketDebuggerUrl, pageTarget.id); + await cdp.send("Page.enable"); + await cdp.send("Runtime.enable"); + await cdp.send("Accessibility.enable"); + await cdp.send("Emulation.setEmulatedMedia", { + features: [{ name: "prefers-reduced-motion", value: "reduce" }], + }); + + await cdp.waitFor("document.readyState === 'complete'", "renderer load"); + await cdp.waitFor("Boolean(document.querySelector('.login-shell'))", "login view"); + await installDomHelpers(); + await loginThroughUserInterface(); + + await verifyTodayWorkspace(); + await verifyAllTasksWorkspace(); + await verifySettingsWorkspace(); + await verifyStatusAndMotionStates(); + await assertNoRuntimeErrors(); + + assert.deepEqual( + [...unexpectedApiPaths], + [], + `visual check reached unhandled API paths: ${[...unexpectedApiPaths].join(", ")}`, + ); + + console.log("desktop focus visual check passed"); + for (const request of apiRequests) console.log(` ${request}`); + } catch (error) { + console.error(error); + if (apiRequests.length > 0) { + console.error(`API requests before failure: ${apiRequests.join(", ")}`); + } + if (cdp) { + try { + const rendererState = await cdp.evaluate(`(() => { + const pinia = document.querySelector('#app')?.__vue_app__?.config?.globalProperties?.$pinia; + const taskStore = pinia?._s?.get('task'); + const syncStore = pinia?._s?.get('sync'); + return { + pageText: document.body.innerText.slice(0, 800), + taskRows: document.querySelectorAll('.task-row').length, + todayRows: document.querySelectorAll('.today-view .task-row').length, + activeTasks: taskStore?.activeTasks?.length, + todayTasks: taskStore?.todayTasks?.length, + syncStatus: syncStore?.status, + syncMessage: syncStore?.message, + visualErrors: window.__taskBridgeVisualErrors ?? [], + }; + })()`); + console.error(`Renderer state: ${JSON.stringify(rendererState)}`); + const asyncProbe = await cdp.evaluate(`Promise.race([ + (async () => { + const rows = await window.taskBridge.db.listTasks(); + const pinia = document.querySelector('#app').__vue_app__.config.globalProperties.$pinia; + await pinia._s.get('task').load(); + return { completed: true, dbRows: rows.length, loadedTasks: pinia._s.get('task').activeTasks.length }; + })(), + new Promise((resolve) => setTimeout(() => resolve({ completed: false }), 3000)), + ])`); + console.error(`Async bridge probe: ${JSON.stringify(asyncProbe)}`); + const manualSyncProbe = await cdp.evaluate(`Promise.race([ + (async () => { + const pinia = document.querySelector('#app').__vue_app__.config.globalProperties.$pinia; + const taskStore = pinia._s.get('task'); + const syncStore = pinia._s.get('sync'); + await syncStore.start(() => taskStore.load()); + return { completed: true, status: syncStore.status, activeTasks: taskStore.activeTasks.length }; + })(), + new Promise((resolve) => setTimeout(() => resolve({ completed: false }), 5000)), + ])`); + console.error(`Manual sync probe: ${JSON.stringify(manualSyncProbe)}`); + } catch (diagnosticError) { + console.error(`Unable to read renderer state: ${diagnosticError}`); + } + } + if (runtimeLogs.length > 0) { + console.error("Electron runtime log tail:"); + console.error(runtimeLogs.join("").slice(-4000)); + } + if (cdp?.events.length > 0) { + console.error(`CDP event tail: ${JSON.stringify(cdp.events.slice(-20))}`); + } + throw error; + } finally { + cdp?.close(); + await stopElectron(electronProcess); + if (apiServer) { + apiServer.closeAllConnections?.(); + await new Promise((resolveClose) => apiServer.close(() => resolveClose())); + } + if (userDataDirectory) { + await rm(userDataDirectory, { recursive: true, force: true }); + } + } +} + +function createApiServer() { + const tasks = buildServerTasks(); + const user = { + id: 1, + username: "desktop-reviewer", + email: "reviewer@taskbridge.test", + is_active: true, + created_at: isoOffset(-7 * 86_400_000), + updated_at: isoOffset(-60_000), + }; + + const server = http.createServer(async (request, response) => { + try { + const url = new URL(request.url ?? "/", "http://127.0.0.1"); + const path = url.pathname.replace(/^\/api\/v1/, "") || "/"; + const method = request.method ?? "GET"; + const body = await readJsonBody(request); + apiRequests.push(`${method} ${path}`); + + if (method === "GET" && path === "/auth/registration") { + return sendEnvelope(response, { registration_enabled: false }); + } + if (method === "GET" && path === "/sync/status") { + return sendEnvelope(response, { + status: "ready", + database: "ok", + redis: "ok", + websocket: "enabled", + server_time: isoOffset(0), + limits: { push_max_changes: 100, pull_default_limit: 200, pull_max_limit: 500 }, + }); + } + if (method === "POST" && path === "/auth/login") { + assert.equal(body?.username_or_email, "desktop-reviewer"); + assert.equal(body?.password, "visual-check-password"); + return sendEnvelope(response, { + access_token: "visual-access-token", + refresh_token: "visual-refresh-token", + token_type: "bearer", + expires_in: 3600, + user, + }); + } + if (method === "POST" && path === "/auth/refresh") { + return sendEnvelope(response, { + access_token: "visual-access-token-refreshed", + refresh_token: "visual-refresh-token-refreshed", + }); + } + if (method === "GET" && path === "/auth/me") return sendEnvelope(response, user); + if (method === "POST" && path === "/auth/ws-ticket") { + return sendEnvelope(response, { ticket: "visual-ws-ticket", expires_in: 60 }); + } + if (method === "GET" && path === "/auth/sessions") { + return sendEnvelope(response, [ + { + id: 1, + device_id: body?.device_id ?? null, + created_at: isoOffset(-3_600_000), + expires_at: isoOffset(7 * 86_400_000), + revoked_at: null, + }, + ]); + } + if (method === "POST" && path === "/devices/register") { + return sendEnvelope(response, { + id: 1, + user_id: 1, + device_id: body?.device_id, + device_name: body?.device_name, + device_type: body?.device_type, + created_at: isoOffset(-60_000), + updated_at: isoOffset(0), + }); + } + if (method === "GET" && path === "/sync/pull") { + const firstPull = (url.searchParams.get("last_sync_time") ?? "").startsWith("1970-01-01"); + return sendEnvelope(response, { + changed_tasks: firstPull ? tasks : [], + deleted_tasks: [], + server_time: isoOffset(0), + has_more: false, + next_cursor_updated_at: null, + next_cursor_id: null, + }); + } + if (method === "POST" && path === "/sync/push") { + const changes = Array.isArray(body?.changes) ? body.changes : []; + return sendEnvelope(response, { + results: changes.map((change, index) => ({ + local_id: change.local_id, + server_id: 1000 + index, + action: change.action, + status: "applied", + version: Math.max(1, Number(change.version ?? 0) + 1), + message: null, + task: null, + server_task: null, + })), + server_time: isoOffset(0), + }); + } + if (method === "GET" && path === "/tasks/meta") { + return sendEnvelope(response, { + projects: ["TaskBridge 桌面端重设计", "季度客户体验复盘"], + tags: ["交互验收", "需要跨团队持续跟进并在下周复盘的关键客户反馈"], + counts: { open: 5, completed: 1, inbox: 1, today: 4, overdue: 1, templates: 0, trash: 0 }, + }); + } + + unexpectedApiPaths.add(`${method} ${path}`); + return sendEnvelope(response, null, 404, "Not found"); + } catch (error) { + return sendEnvelope(response, null, 500, error instanceof Error ? error.message : String(error)); + } + }); + server.on("upgrade", (_request, socket) => socket.destroy()); + return server; +} + +function buildServerTasks() { + const today = localDate(0); + const tomorrow = localDate(86_400_000); + const base = { + user_id: 1, + content: null, + status: "pending", + priority: 0, + tag: null, + project: null, + list_type: "today", + due_time: null, + remind_time: null, + repeat_rule: null, + planned_date: today, + completed_at: null, + snoozed_until: null, + parent_task_id: null, + checklist: [], + is_template: false, + template_name: null, + sort_order: 0, + version: 1, + is_deleted: false, + created_at: isoOffset(-2 * 86_400_000), + updated_at: isoOffset(-60_000), + deleted_at: null, + }; + const task = (id, title, overrides = {}) => ({ ...base, id, title, sort_order: id, ...overrides }); + return [ + task(1, "修复发布流程中仍然阻塞用户安装的签名问题", { + due_time: isoOffset(-26 * 3_600_000), + priority: 5, + project: "TaskBridge 桌面端重设计", + }), + task(2, "确认今天的桌面端回归测试结果", { + due_time: isoOffset(2 * 3_600_000), + tag: "交互验收", + }), + task(3, "这是一个用于确认超长任务标题会自然换行而不会遮挡完成按钮、更多操作菜单或下一条任务内容的桌面端布局验收任务", { + content: "长标题需要完整可读,任务行可以增高,但不能制造横向滚动。", + due_time: isoOffset(5 * 3_600_000), + }), + task(4, "整理客户反馈并安排跨团队复盘", { + tag: "需要跨团队持续跟进并在下周复盘的关键客户反馈", + project: "季度客户体验复盘", + checklist: [ + { id: "item-1", title: "归类反馈", done: true }, + { id: "item-2", title: "确认负责人", done: false }, + ], + priority: 3, + }), + task(5, "准备明天的版本说明", { + list_type: "inbox", + planned_date: tomorrow, + project: "TaskBridge 桌面端重设计", + }), + task(6, "已完成的桌面端信息架构复核", { + status: "completed", + completed_at: isoOffset(-3_600_000), + planned_date: today, + }), + ]; +} + +async function writeIsolatedSettings(apiPort) { + await writeFile( + join(userDataDirectory, "config.json"), + `${JSON.stringify({ + settingsSchemaVersion: 4, + baseUrl: `http://127.0.0.1:${apiPort}/api/v1`, + wsUrl: `ws://127.0.0.1:${apiPort}/ws/sync`, + language: "zh-CN", + desktopTheme: "warm", + displayTimeZone: "Asia/Shanghai", + lastSyncTime: "1970-01-01T00:00:00Z", + autoStart: false, + floatingOpacity: 0.96, + floatingVisibleOnStart: false, + floatingWidth: 320, + floatingHeight: 460, + }, null, 2)}\n`, + "utf8", + ); +} + +function launchElectron(cdpPort) { + const child = spawn( + electronPath, + [ + `--remote-debugging-port=${cdpPort}`, + `--user-data-dir=${userDataDirectory}`, + mainPath, + ], + { + cwd: desktopRoot, + env: { ...process.env, TASKBRIDGE_DISABLE_AUTO_UPDATE: "1" }, + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + }, + ); + child.stdout.on("data", (chunk) => runtimeLogs.push(String(chunk))); + child.stderr.on("data", (chunk) => runtimeLogs.push(String(chunk))); + return child; +} + +async function installDomHelpers() { + await cdp.evaluate(`(() => { + window.__taskBridgeVisualErrors = []; + window.addEventListener('error', (event) => { + window.__taskBridgeVisualErrors.push(event.error?.stack ?? event.message ?? String(event.error)); + }); + window.addEventListener('unhandledrejection', (event) => { + window.__taskBridgeVisualErrors.push(event.reason?.stack ?? event.reason?.message ?? String(event.reason)); + }); + const visible = (element) => Boolean( + element && + element.getClientRects().length > 0 && + getComputedStyle(element).visibility !== "hidden" + ); + const elements = (selector) => [...document.querySelectorAll(selector)]; + const setValue = (selector, value, index = 0) => { + const element = elements(selector)[index]; + if (!element) throw new Error("Missing input: " + selector + " #" + index); + const descriptor = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(element), "value"); + descriptor.set.call(element, value); + element.dispatchEvent(new Event("input", { bubbles: true })); + element.dispatchEvent(new Event("change", { bubbles: true })); + return element.value; + }; + const click = (selector, index = 0, focus = false) => { + const element = elements(selector)[index]; + if (!element) throw new Error("Missing click target: " + selector + " #" + index); + if (focus) element.focus(); + element.click(); + }; + const clickText = (selector, text, root = document) => { + const element = [...root.querySelectorAll(selector)].find((item) => item.textContent.trim() === text); + if (!element) throw new Error("Missing text target: " + selector + " / " + text); + element.click(); + }; + window.__taskBridgeVisual = { visible, elements, setValue, click, clickText }; + return true; + })()`); +} + +async function assertNoRuntimeErrors() { + const rendererErrors = await cdp.evaluate( + "(window.__taskBridgeVisualErrors ?? []).map((error) => String(error))", + ); + const runtimeExceptions = cdp.events + .filter((event) => event.method === "Runtime.exceptionThrown") + .map((event) => + event.params?.exceptionDetails?.exception?.description ?? + event.params?.exceptionDetails?.text ?? + JSON.stringify(event.params ?? event), + ); + const consoleErrors = cdp.events + .filter( + (event) => + event.method === "Runtime.consoleAPICalled" && + ["assert", "error"].includes(event.params?.type), + ) + .map((event) => + (event.params?.args ?? []) + .map((argument) => argument.value ?? argument.description ?? argument.type) + .join(" "), + ); + const processErrors = findUnexpectedRuntimeLogLines(runtimeLogs).filter( + (line) => !expectedRuntimeWarningPatterns.some((pattern) => pattern.test(line)), + ); + + assert.deepEqual(rendererErrors, [], "renderer emitted an error or unhandled rejection"); + assert.deepEqual(runtimeExceptions, [], "CDP observed an uncaught renderer exception"); + assert.deepEqual(consoleErrors, [], "renderer wrote an unexpected console error"); + assert.deepEqual(processErrors, [], "Electron main process wrote an unexpected error"); +} + +async function loginThroughUserInterface() { + await cdp.evaluate(`(() => { + window.__taskBridgeVisual.setValue('.auth-main-form input[autocomplete="email"]', 'desktop-reviewer'); + window.__taskBridgeVisual.setValue('.auth-main-form input[autocomplete="current-password"]', 'visual-check-password'); + window.__taskBridgeVisual.click('.auth-main-form button.primary-button[type="submit"]'); + })()`); + await cdp.waitFor("Boolean(document.querySelector('.focus-workspace'))", "authenticated workspace", 15_000); + await cdp.waitFor("document.querySelectorAll('.today-view .task-row').length >= 4", "synced task data", 15_000); + assert.equal(await cdp.evaluate("document.querySelectorAll('.workspace-status-banner').length"), 0); +} + +async function verifyTodayWorkspace() { + const metrics = await setWindowSize(1440, 1000); + await assertWorkspaceLayout(184, metrics, "today-wide"); + await assertAccountMenuInsideViewport(); + await assertNoUnnamedInteractiveControls("today"); + await captureVerifiedScreenshot("desktop-focus-1440.png", metrics); + + const drawerMetrics = await cdp.evaluate(`(() => { + const list = document.querySelector('.today-view .task-list'); + const opener = document.querySelector('.today-view .view-header .primary-button'); + window.__taskBridgeVisual.drawerOpener = opener; + window.__taskBridgeVisual.listWidthBeforeDrawer = list.getBoundingClientRect().width; + opener.focus(); + opener.click(); + return { listWidth: window.__taskBridgeVisual.listWidthBeforeDrawer }; + })()`); + await cdp.waitFor("Boolean(document.querySelector('.side-panel'))", "today editor drawer"); + const drawerState = await cdp.evaluate(`(() => { + const panel = document.querySelector('.side-panel'); + const list = document.querySelector('.today-view .task-list'); + const title = panel.querySelector('input[type="text"]'); + const rect = panel.getBoundingClientRect(); + return { + width: rect.width, + right: rect.right, + viewport: innerWidth, + listWidth: list.getBoundingClientRect().width, + titleFocused: document.activeElement === title, + }; + })()`); + assert.ok(drawerState.width <= 440.5, `wide drawer must not exceed 440px: ${drawerState.width}`); + assert.ok(Math.abs(drawerState.right - drawerState.viewport) <= 1, "drawer must align with the workspace edge"); + assert.ok(Math.abs(drawerState.listWidth - drawerMetrics.listWidth) <= 1, "drawer must overlay instead of shrinking the list"); + assert.equal(drawerState.titleFocused, true, "drawer must focus the title input"); + + await setEditorTitle("未保存的焦点恢复验收草稿"); + await cdp.evaluate("window.__taskBridgeVisual.click('.side-panel .panel-header .ghost-button')"); + await cdp.waitFor("Boolean(document.querySelector('.confirm-dialog'))", "discard confirmation"); + assert.equal(await cdp.evaluate("Boolean(document.querySelector('.side-panel input').value)"), true); + await cdp.evaluate("window.__taskBridgeVisual.click('.confirm-dialog-actions .secondary-button')"); + await cdp.waitFor("!document.querySelector('.confirm-dialog') && Boolean(document.querySelector('.side-panel'))", "discard cancellation"); + assert.equal( + await cdp.evaluate("document.querySelector('.side-panel input[type=\"text\"]').value"), + "未保存的焦点恢复验收草稿", + ); + await closeDirtyEditorAndConfirm(); + assert.equal( + await cdp.evaluate("document.activeElement === window.__taskBridgeVisual.drawerOpener"), + true, + "drawer close must restore focus to its opener", + ); + + await verifyEditorSaveFailurePreservesDraft(); + await verifySuccessToast(); +} + +async function verifyEditorSaveFailurePreservesDraft() { + await cdp.evaluate(`(() => { + const pinia = document.querySelector('#app').__vue_app__.config.globalProperties.$pinia; + const taskStore = pinia._s.get('task'); + window.__taskBridgeVisual.originalAddTask = taskStore.addTask; + taskStore.addTask = async () => { throw new Error('visual save failure'); }; + const opener = document.querySelector('.today-view .view-header .primary-button'); + window.__taskBridgeVisual.drawerOpener = opener; + opener.focus(); + opener.click(); + })()`); + await cdp.waitFor("Boolean(document.querySelector('.side-panel'))", "save-failure editor"); + await setEditorTitle("保存失败后必须保留的草稿"); + await cdp.evaluate("window.__taskBridgeVisual.click('.side-panel .task-editor button[type=submit]')"); + await cdp.waitFor("Boolean(document.querySelector('.task-editor-save-error[role=alert]'))", "save failure alert"); + const failureState = await cdp.evaluate(`(() => ({ + draft: document.querySelector('.side-panel input[type="text"]').value, + drawerOpen: Boolean(document.querySelector('.side-panel')), + alertText: document.querySelector('.task-editor-save-error').textContent.trim(), + }))()`); + assert.equal(failureState.draft, "保存失败后必须保留的草稿"); + assert.equal(failureState.drawerOpen, true); + assert.ok(failureState.alertText.length > 0); + await cdp.evaluate(`(() => { + const pinia = document.querySelector('#app').__vue_app__.config.globalProperties.$pinia; + pinia._s.get('task').addTask = window.__taskBridgeVisual.originalAddTask; + })()`); + await closeDirtyEditorAndConfirm(); +} + +async function verifySuccessToast() { + await cdp.evaluate(`(() => { + window.__taskBridgeVisual.setValue('.today-view .workspace-quick-add input', '视觉验收成功反馈'); + window.__taskBridgeVisual.click('.today-view .workspace-quick-add button[type="submit"]'); + })()`); + await cdp.waitFor("Boolean(document.querySelector('.app-toast[role=status]'))", "success status toast"); + assert.ok((await cdp.evaluate("document.querySelector('.app-toast').textContent.trim()")).length > 0); + await cdp.waitFor("!document.querySelector('.app-toast')", "toast dismissal", 5_000); +} + +async function verifyAllTasksWorkspace() { + await cdp.evaluate("window.__taskBridgeVisual.click('.nav-list button[aria-label=\"全部\"]')"); + await cdp.waitFor("document.querySelector('.view-header h1')?.textContent.trim() === '全部待办'", "all tasks view"); + const metrics = await setWindowSize(1024, 768); + await assertWorkspaceLayout(72, metrics, "tasks-medium"); + await assertAccountMenuInsideViewport(); + + assert.equal(await cdp.evaluate("Boolean(document.querySelector('.bulk-action-toolbar'))"), false); + await cdp.evaluate("window.__taskBridgeVisual.click('.task-command-actions button[aria-label=\"批量选择任务\"]')"); + await cdp.waitFor("Boolean(document.querySelector('.bulk-action-toolbar')) && Boolean(document.querySelector('.task-selection-checkbox input'))", "selection mode"); + assert.equal( + await cdp.evaluate("[...document.querySelectorAll('.bulk-action-toolbar button.secondary-button')].every((button) => button.disabled)"), + true, + "selection actions must start disabled before a task is selected", + ); + await cdp.evaluate("window.__taskBridgeVisual.click('.task-selection-checkbox input')"); + await cdp.waitFor("document.querySelector('.bulk-action-toolbar span')?.textContent.includes('1 条已选')", "selected task count"); + assert.equal( + await cdp.evaluate("[...document.querySelectorAll('.bulk-action-toolbar button.secondary-button')].every((button) => !button.disabled)"), + true, + "selection actions must enable after selecting a task", + ); + await cdp.evaluate("window.__taskBridgeVisual.click('.task-selection-checkbox input')"); + await cdp.waitFor("document.querySelector('.bulk-action-toolbar span')?.textContent.includes('0 条已选')", "cleared selected task count"); + assert.equal(await cdp.evaluate("Boolean(document.querySelector('.bulk-action-toolbar'))"), true); + await cdp.evaluate("window.__taskBridgeVisual.click('.bulk-action-toolbar .text-button')"); + await cdp.waitFor("!document.querySelector('.bulk-action-toolbar') && !document.querySelector('.task-selection-checkbox')", "selection mode exit"); + + await cdp.evaluate("window.__taskBridgeVisual.click('.filter-menu > summary')"); + await cdp.waitFor("Boolean(document.querySelector('.filter-menu[open] .filter-menu-panel'))", "filter menu"); + await assertElementInsideViewport(".filter-menu-panel", "filter menu"); + await cdp.evaluate("window.__taskBridgeVisual.click('.filter-menu > summary')"); + + await cdp.evaluate("window.__taskBridgeVisual.click('.task-menu > summary')"); + await cdp.waitFor("Boolean(document.querySelector('.task-menu[open] .task-menu-panel'))", "task menu"); + await assertElementInsideViewport(".task-menu-panel", "task menu"); + await cdp.evaluate("window.__taskBridgeVisual.click('.task-menu > summary')"); + + const longContentState = await cdp.evaluate(`(() => { + const rows = [...document.querySelectorAll('.task-row')]; + const longTitle = [...document.querySelectorAll('.task-title')].find((item) => item.textContent.includes('超长任务标题')); + const longTag = [...document.querySelectorAll('.task-meta span')].find((item) => item.textContent.includes('需要跨团队持续跟进')); + const inside = (element) => { + const rect = element.getBoundingClientRect(); + return rect.left >= -1 && rect.right <= innerWidth + 1; + }; + return { + rowsInside: rows.every(inside), + titleInside: inside(longTitle), + titleWrap: getComputedStyle(longTitle).whiteSpace === 'normal' && longTitle.scrollWidth <= longTitle.clientWidth + 1, + tagInside: inside(longTag), + horizontalOverflow: Math.max(document.documentElement.scrollWidth, document.body.scrollWidth) - innerWidth, + }; + })()`); + assert.equal(longContentState.rowsInside, true, "task rows must stay inside the viewport"); + assert.equal(longContentState.titleInside, true, "long title must stay inside the viewport"); + assert.equal(longContentState.titleWrap, true, "long title must wrap without clipping"); + assert.equal(longContentState.tagInside, true, "long tag must stay inside the viewport"); + assert.ok(longContentState.horizontalOverflow <= 1, `tasks view horizontal overflow: ${longContentState.horizontalOverflow}`); + + await assertNoUnnamedInteractiveControls("all tasks"); + await captureVerifiedScreenshot("desktop-focus-1024.png", metrics); + await verifyNarrowTaskInteractions(); +} + +async function verifyNarrowTaskInteractions() { + const metrics = await setWindowSize(799, 700); + await assertWorkspaceLayout(64, metrics, "tasks-narrow"); + + await cdp.evaluate("window.__taskBridgeVisual.click('.filter-menu > summary')"); + await cdp.waitFor("Boolean(document.querySelector('.filter-menu[open] .filter-menu-panel'))", "narrow filter menu"); + await assertElementInsideViewport(".filter-menu-panel", "narrow filter menu"); + await assertNoUnnamedInteractiveControls("all tasks filter menu"); + await cdp.evaluate("window.__taskBridgeVisual.click('.filter-menu > summary')"); + + await cdp.evaluate("window.__taskBridgeVisual.click('.task-menu > summary')"); + await cdp.waitFor("Boolean(document.querySelector('.task-menu[open] .task-menu-panel'))", "narrow task menu"); + await assertElementInsideViewport(".task-menu-panel", "narrow task menu"); + await assertNoUnnamedInteractiveControls("all tasks task menu"); + await cdp.evaluate("window.__taskBridgeVisual.click('.task-menu > summary')"); + + const drawerMetrics = await cdp.evaluate(`(() => { + const list = document.querySelector('.task-list'); + const opener = document.querySelector('.task-command-bar .primary-button'); + window.__taskBridgeVisual.drawerOpener = opener; + opener.focus(); + const listWidth = list.getBoundingClientRect().width; + opener.click(); + return { listWidth }; + })()`); + await cdp.waitFor("Boolean(document.querySelector('.side-panel'))", "narrow task drawer"); + const drawerState = await cdp.evaluate(`(() => { + const panel = document.querySelector('.side-panel'); + const panelRect = panel.getBoundingClientRect(); + const sidebarRect = document.querySelector('.app-sidebar').getBoundingClientRect(); + const listWidth = document.querySelector('.task-list').getBoundingClientRect().width; + return { + left: panelRect.left, + right: panelRect.right, + width: panelRect.width, + sidebarRight: sidebarRect.right, + viewport: innerWidth, + listWidth, + titleFocused: document.activeElement === panel.querySelector('input[type="text"]'), + }; + })()`); + assert.ok(drawerState.left >= drawerState.sidebarRight - 1, "narrow drawer must not cover primary navigation"); + assert.ok(Math.abs(drawerState.right - drawerState.viewport) <= 1, "narrow drawer must align with the viewport edge"); + assert.ok(drawerState.width <= 440.5, `narrow drawer must not exceed 440px: ${drawerState.width}`); + assert.ok(Math.abs(drawerState.listWidth - drawerMetrics.listWidth) <= 1, "narrow drawer must overlay instead of shrinking the list"); + assert.equal(drawerState.titleFocused, true, "narrow drawer must focus the title input"); + await assertNoUnnamedInteractiveControls("all tasks narrow drawer"); + await cdp.evaluate("window.__taskBridgeVisual.click('.side-panel .panel-header .ghost-button')"); + await cdp.waitFor("!document.querySelector('.side-panel')", "narrow task drawer close"); + assert.equal( + await cdp.evaluate("document.activeElement === window.__taskBridgeVisual.drawerOpener"), + true, + "narrow drawer close must restore focus to its opener", + ); +} + +async function verifySettingsWorkspace() { + await cdp.evaluate("window.__taskBridgeVisual.click('.nav-list button[aria-label=\"设置\"]')"); + await cdp.waitFor("Boolean(document.querySelector('.settings-workspace'))", "settings view"); + const mediumMetrics = await setWindowSize(1024, 768); + await assertWorkspaceLayout(72, mediumMetrics, "settings-medium"); + const mediumSettingsLayout = await cdp.evaluate(`(() => { + const nav = document.querySelector('.settings-category-nav').getBoundingClientRect(); + const content = document.querySelector('.settings-content').getBoundingClientRect(); + return { + navRight: nav.right, + contentLeft: content.left, + horizontalOverflow: Math.max(document.documentElement.scrollWidth, document.body.scrollWidth) - innerWidth, + }; + })()`); + assert.ok(mediumSettingsLayout.navRight <= mediumSettingsLayout.contentLeft + 1, "medium settings navigation must not overlap content"); + assert.ok(mediumSettingsLayout.horizontalOverflow <= 1, `medium settings horizontal overflow: ${mediumSettingsLayout.horizontalOverflow}`); + + const metrics = await setWindowSize(800, 700); + await assertWorkspaceLayout(64, metrics, "settings-narrow"); + await assertAccountMenuInsideViewport(); + + const sections = [ + ["账号与显示", "settings-account-display"], + ["账号安全", "settings-account-security"], + ["连接与同步", "settings-connection"], + ["窗口", "settings-window"], + ["数据与备份", "settings-data"], + ["同步问题", "settings-sync-recovery"], + ["项目与标签", "settings-metadata"], + ]; + for (const [label, id] of sections) { + await cdp.evaluate(`window.__taskBridgeVisual.clickText('.settings-category-nav button', ${JSON.stringify(label)})`); + await cdp.waitFor(`document.querySelector('#${id}')?.getClientRects().length > 0`, `settings section ${label}`); + const sectionState = await cdp.evaluate(`(() => ({ + visibleCount: ['settings-account-display','settings-account-security','settings-connection','settings-window','settings-data','settings-sync-recovery','settings-metadata'] + .filter((sectionId) => document.querySelector('#' + sectionId)?.getClientRects().length > 0).length, + currentText: document.querySelector('.settings-category-nav button[aria-current="page"]')?.textContent.trim(), + }))()`); + assert.equal(sectionState.visibleCount, 1, `settings must display one section while ${label} is active`); + assert.equal(sectionState.currentText, label); + await assertNoUnnamedInteractiveControls(`settings ${label}`); + } + + await cdp.evaluate(`(() => { + window.__taskBridgeVisual.click('.settings-metadata-details > summary'); + const project = document.querySelector('.settings-metadata select option:not([value=""])')?.value; + if (!project) throw new Error('Missing project metadata for rename verification'); + window.__taskBridgeVisual.setValue('.settings-metadata select', project); + window.__taskBridgeVisual.setValue('.settings-metadata input[type="text"]', project + '(视觉验收)'); + window.__taskBridgeVisual.clickText('.settings-metadata button', '重命名项目'); + })()`); + await cdp.waitFor("Boolean(document.querySelector('.confirm-dialog'))", "metadata rename confirmation"); + await cdp.evaluate("window.__taskBridgeVisual.click('.confirm-dialog-actions .primary-button')"); + await cdp.waitFor("Boolean(document.querySelector('.settings-metadata [role=status]'))", "metadata rename feedback"); + const metadataFeedback = await cdp.evaluate(`(() => ({ + metadata: document.querySelector('.settings-metadata [role=status]')?.textContent.trim(), + data: document.querySelector('#settings-data .save-note')?.textContent.trim() ?? '', + }))()`); + assert.equal(metadataFeedback.metadata, "项目已更新。"); + assert.equal(metadataFeedback.data, "", "metadata feedback must not leak into data and backup settings"); + await assertNoUnnamedInteractiveControls("settings metadata expanded"); + + await cdp.evaluate("window.__taskBridgeVisual.clickText('.settings-category-nav button', '账号与显示')"); + const settingsLayout = await cdp.evaluate(`(() => { + const nav = document.querySelector('.settings-category-nav').getBoundingClientRect(); + const content = document.querySelector('.settings-content').getBoundingClientRect(); + const buttons = [...document.querySelectorAll('.settings-category-nav button')]; + return { + navBottom: nav.bottom, + contentTop: content.top, + buttonsFit: buttons.every((button) => button.scrollWidth <= button.clientWidth + 1), + horizontalOverflow: Math.max(document.documentElement.scrollWidth, document.body.scrollWidth) - innerWidth, + }; + })()`); + assert.ok(settingsLayout.navBottom <= settingsLayout.contentTop + 1, "narrow settings navigation must not overlap content"); + assert.equal(settingsLayout.buttonsFit, true, "settings category labels must fit their buttons"); + assert.ok(settingsLayout.horizontalOverflow <= 1, `settings view horizontal overflow: ${settingsLayout.horizontalOverflow}`); + + await assertNoUnnamedInteractiveControls("settings account display"); + await captureVerifiedScreenshot("desktop-focus-800.png", metrics); +} + +async function verifyStatusAndMotionStates() { + await cdp.evaluate(`(() => { + const pinia = document.querySelector('#app').__vue_app__.config.globalProperties.$pinia; + const syncStore = pinia._s.get('sync'); + syncStore.stop(); + syncStore.status = 'offline'; + })()`); + await cdp.waitFor("document.querySelectorAll('.workspace-status-banner').length === 1", "offline banner"); + assert.equal(await cdp.evaluate("document.querySelectorAll('.workspace-status-banner').length"), 1); + + await cdp.evaluate(`(() => { + const pinia = document.querySelector('#app').__vue_app__.config.globalProperties.$pinia; + pinia._s.get('sync').status = 'error'; + })()`); + await cdp.waitFor("document.querySelector('.workspace-status-banner')?.dataset.banner === 'attention'", "sync error banner"); + assert.equal(await cdp.evaluate("document.querySelectorAll('.workspace-status-banner').length"), 1); + + await cdp.evaluate(`(() => { + const pinia = document.querySelector('#app').__vue_app__.config.globalProperties.$pinia; + pinia._s.get('sync').status = 'syncing'; + })()`); + await cdp.waitFor("Boolean(document.querySelector('.account-status-indicator.syncing'))", "syncing indicator"); + const animationName = await cdp.evaluate("getComputedStyle(document.querySelector('.account-status-indicator.syncing')).animationName"); + assert.equal(animationName, "none", "reduced motion must disable the syncing animation"); + + await cdp.evaluate(`(() => { + const pinia = document.querySelector('#app').__vue_app__.config.globalProperties.$pinia; + pinia._s.get('sync').status = 'synced'; + })()`); + await cdp.waitFor("!document.querySelector('.workspace-status-banner')", "healthy status silence"); +} + +async function setWindowSize(width, height) { + await cdp.evaluate(`window.resizeTo(${width}, ${height}); true`); + await cdp.waitFor( + `outerWidth === ${width} && outerHeight === ${height}`, + `${width}x${height} window bounds`, + ); + await delay(150); + const metrics = await cdp.evaluate("({ width: innerWidth, height: innerHeight, outerWidth, outerHeight })"); + assert.equal(metrics.outerWidth, width, `Electron outer width must be ${width}px`); + assert.equal(metrics.outerHeight, height, `Electron outer height must be ${height}px`); + return { width: metrics.width, height: metrics.height }; +} + +async function assertWorkspaceLayout(expectedSidebarWidth, metrics, context) { + const state = await cdp.evaluate(`(() => { + const sidebar = document.querySelector('.app-sidebar').getBoundingClientRect(); + const shell = document.querySelector('.focus-workspace'); + const accountName = document.querySelector('.account-name'); + const accountNameStyle = getComputedStyle(accountName); + const accountNameRect = accountName.getBoundingClientRect(); + const navRects = [...document.querySelectorAll('.nav-list button')].map((button) => button.getBoundingClientRect()); + const visibleButtons = [...document.querySelectorAll('button')] + .filter((button) => window.__taskBridgeVisual.visible(button)); + return { + sidebarWidth: sidebar.width, + shellOverflow: shell.scrollWidth - shell.clientWidth, + documentOverflow: Math.max(document.documentElement.scrollWidth, document.body.scrollWidth) - innerWidth, + controlsInside: visibleButtons.every((button) => { + const rect = button.getBoundingClientRect(); + return rect.left >= -1 && rect.right <= innerWidth + 1; + }), + accountNameSingleLine: + !window.__taskBridgeVisual.visible(accountName) || + (accountNameStyle.whiteSpace === 'nowrap' && + accountNameRect.height <= Number.parseFloat(accountNameStyle.lineHeight) + 1), + navigationIsVertical: navRects.every((rect, index) => index === 0 || rect.top >= navRects[index - 1].bottom), + navigationButtonsUsable: navRects.every((rect) => rect.width >= 36 && rect.height >= 36), + viewport: { width: innerWidth, height: innerHeight }, + }; + })()`); + assert.ok(Math.abs(state.sidebarWidth - expectedSidebarWidth) <= 0.5, `${context} sidebar width: ${state.sidebarWidth}`); + assert.ok(state.shellOverflow <= 1, `${context} shell horizontal overflow: ${state.shellOverflow}`); + assert.ok(state.documentOverflow <= 1, `${context} document horizontal overflow: ${state.documentOverflow}`); + assert.equal(state.controlsInside, true, `${context} controls must stay inside the viewport`); + assert.equal(state.accountNameSingleLine, true, `${context} account name must stay on one line`); + assert.equal(state.navigationIsVertical, true, `${context} primary navigation must remain vertical`); + assert.equal(state.navigationButtonsUsable, true, `${context} primary navigation buttons must remain usable`); + assert.deepEqual(state.viewport, metrics); +} + +async function assertAccountMenuInsideViewport() { + await cdp.evaluate(`(() => { + const trigger = document.querySelector('#account-menu-trigger'); + trigger.focus(); + trigger.click(); + })()`); + await cdp.waitFor("Boolean(document.querySelector('.account-menu-items'))", "account menu"); + await assertElementInsideViewport(".account-menu-items", "account menu"); + await cdp.evaluate("document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }))"); + await cdp.waitFor("!document.querySelector('.account-menu-items')", "account menu close"); + assert.equal( + await cdp.evaluate("document.activeElement === document.querySelector('#account-menu-trigger')"), + true, + "account menu Escape must restore trigger focus", + ); +} + +async function assertElementInsideViewport(selector, label) { + const rect = await cdp.evaluate(`(() => { + const value = document.querySelector(${JSON.stringify(selector)}).getBoundingClientRect(); + return { left: value.left, top: value.top, right: value.right, bottom: value.bottom, width: value.width, height: value.height }; + })()`); + assert.ok(rect.left >= -1, `${label} left edge is outside the viewport: ${rect.left}`); + assert.ok(rect.top >= -1, `${label} top edge is outside the viewport: ${rect.top}`); + assert.ok(rect.right <= (await cdp.evaluate("innerWidth")) + 1, `${label} right edge is outside the viewport: ${rect.right}`); + assert.ok(rect.bottom <= (await cdp.evaluate("innerHeight")) + 1, `${label} bottom edge is outside the viewport: ${rect.bottom}`); +} + +async function assertNoUnnamedInteractiveControls(context) { + const { nodes } = await cdp.send("Accessibility.getFullAXTree"); + const roles = new Set(["button", "checkbox", "combobox", "link", "menuitem", "radio", "searchbox", "textbox"]); + const unnamed = nodes + .filter((node) => !node.ignored && roles.has(node.role?.value)) + .filter((node) => !String(node.name?.value ?? "").trim()) + .map((node) => `${node.role?.value}:${node.backendDOMNodeId ?? node.nodeId}`); + assert.deepEqual(unnamed, [], `${context} contains unnamed interactive accessibility nodes`); +} + +async function captureVerifiedScreenshot(fileName, expectedMetrics) { + await mkdir(screenshotDirectory, { recursive: true }); + await cdp.evaluate("document.activeElement instanceof HTMLElement && document.activeElement.blur(); true"); + await delay(50); + const { data } = await cdp.send("Page.captureScreenshot", { + format: "png", + fromSurface: true, + captureBeyondViewport: false, + }); + const png = Buffer.from(data, "base64"); + const analysis = analyzePng(png); + assert.equal(analysis.width, expectedMetrics.width, `${fileName} width must match the Electron viewport`); + assert.equal(analysis.height, expectedMetrics.height, `${fileName} height must match the Electron viewport`); + assert.ok(png.length > 20_000, `${fileName} is unexpectedly small (${png.length} bytes)`); + assert.ok(analysis.uniqueColors > 64, `${fileName} appears blank or nearly monochrome`); + assert.ok(analysis.luminanceRange > 48, `${fileName} lacks visible foreground/background contrast`); + assert.ok(analysis.opaqueRatio > 0.98, `${fileName} contains unexpected transparency`); + await writeFile(join(screenshotDirectory, fileName), png); + console.log( + `${fileName}: ${analysis.width}x${analysis.height}, ${analysis.uniqueColors} sampled colors, ${png.length} bytes`, + ); +} + +async function setEditorTitle(value) { + await cdp.evaluate(`window.__taskBridgeVisual.setValue('.side-panel input[type="text"]', ${JSON.stringify(value)})`); + await cdp.waitFor( + `document.querySelector('.side-panel input[type="text"]').value === ${JSON.stringify(value)}`, + "editor draft", + ); +} + +async function closeDirtyEditorAndConfirm() { + await cdp.evaluate("window.__taskBridgeVisual.click('.side-panel .panel-header .ghost-button')"); + await cdp.waitFor("Boolean(document.querySelector('.confirm-dialog'))", "discard confirmation"); + await cdp.evaluate("window.__taskBridgeVisual.click('.confirm-dialog-actions .primary-button')"); + await cdp.waitFor("!document.querySelector('.side-panel') && !document.querySelector('.confirm-dialog')", "editor close"); +} + +class CdpClient { + constructor(socket, targetId) { + this.socket = socket; + this.targetId = targetId; + this.nextId = 0; + this.pending = new Map(); + this.events = []; + socket.onmessage = (event) => this.handleMessage(event.data); + socket.onclose = () => this.rejectPending(new Error("CDP socket closed")); + socket.onerror = () => this.rejectPending(new Error("CDP socket failed")); + } + + static async connect(url, targetId) { + const socket = new WebSocket(url); + await new Promise((resolveOpen, rejectOpen) => { + socket.onopen = () => resolveOpen(); + socket.onerror = () => rejectOpen(new Error("Unable to connect to Electron CDP")); + }); + return new CdpClient(socket, targetId); + } + + send(method, params = {}) { + const id = ++this.nextId; + return new Promise((resolveMessage, rejectMessage) => { + const timer = setTimeout(() => { + this.pending.delete(id); + rejectMessage(new Error(`CDP command timed out: ${method}`)); + }, 12_000); + this.pending.set(id, { + resolve: (value) => { + clearTimeout(timer); + resolveMessage(value); + }, + reject: (error) => { + clearTimeout(timer); + rejectMessage(error); + }, + }); + this.socket.send(JSON.stringify({ id, method, params })); + }); + } + + notify(method, params = {}) { + if (this.socket.readyState !== WebSocket.OPEN) return; + this.socket.send(JSON.stringify({ id: ++this.nextId, method, params })); + } + + async evaluate(expression) { + const result = await this.send("Runtime.evaluate", { + expression, + awaitPromise: true, + returnByValue: true, + userGesture: true, + }); + if (result.exceptionDetails) { + throw new Error(result.exceptionDetails.exception?.description ?? result.exceptionDetails.text ?? "renderer evaluation failed"); + } + return result.result.value; + } + + async waitFor(expression, label, timeoutMs = 10_000) { + const deadline = Date.now() + timeoutMs; + let lastError; + while (Date.now() < deadline) { + try { + if (await this.evaluate(`Boolean(${expression})`)) return; + } catch (error) { + lastError = error; + } + await delay(80); + } + throw new Error(`Timed out waiting for ${label}${lastError ? `: ${lastError.message}` : ""}`); + } + + handleMessage(raw) { + const message = JSON.parse(String(raw)); + if (!message.id) { + if (message.method === "Runtime.exceptionThrown" || message.method === "Runtime.consoleAPICalled") { + this.events.push(message); + } + return; + } + const pending = this.pending.get(message.id); + if (!pending) return; + this.pending.delete(message.id); + if (message.error) { + pending.reject(new Error(`${message.error.message} (${message.error.code})`)); + } else { + pending.resolve(message.result); + } + } + + rejectPending(error) { + for (const pending of this.pending.values()) pending.reject(error); + this.pending.clear(); + } + + close() { + if (this.socket.readyState === WebSocket.OPEN) this.socket.close(); + } +} + +function analyzePng(buffer) { + assert.equal(buffer.subarray(0, 8).toString("hex"), "89504e470d0a1a0a", "invalid PNG signature"); + let offset = 8; + let width; + let height; + let bitDepth; + let colorType; + const idat = []; + while (offset < buffer.length) { + const length = buffer.readUInt32BE(offset); + const type = buffer.toString("ascii", offset + 4, offset + 8); + const data = buffer.subarray(offset + 8, offset + 8 + length); + if (type === "IHDR") { + width = data.readUInt32BE(0); + height = data.readUInt32BE(4); + bitDepth = data[8]; + colorType = data[9]; + } else if (type === "IDAT") { + idat.push(data); + } else if (type === "IEND") { + break; + } + offset += length + 12; + } + assert.equal(bitDepth, 8, "visual check only supports 8-bit screenshots"); + assert.ok(colorType === 2 || colorType === 6, `unsupported screenshot PNG color type: ${colorType}`); + const bytesPerPixel = colorType === 6 ? 4 : 3; + const rowBytes = width * bytesPerPixel; + const raw = inflateSync(Buffer.concat(idat)); + const pixels = Buffer.alloc(rowBytes * height); + let rawOffset = 0; + for (let y = 0; y < height; y += 1) { + const filter = raw[rawOffset++]; + const rowStart = y * rowBytes; + const previousStart = rowStart - rowBytes; + for (let x = 0; x < rowBytes; x += 1) { + const encoded = raw[rawOffset++]; + const left = x >= bytesPerPixel ? pixels[rowStart + x - bytesPerPixel] : 0; + const up = y > 0 ? pixels[previousStart + x] : 0; + const upLeft = y > 0 && x >= bytesPerPixel ? pixels[previousStart + x - bytesPerPixel] : 0; + pixels[rowStart + x] = unfilterByte(filter, encoded, left, up, upLeft); + } + } + const unique = new Set(); + let minLuminance = 255; + let maxLuminance = 0; + let opaque = 0; + let sampled = 0; + const pixelStep = Math.max(1, Math.floor((width * height) / 30_000)); + for (let index = 0; index < width * height; index += pixelStep) { + const byteIndex = index * bytesPerPixel; + const red = pixels[byteIndex]; + const green = pixels[byteIndex + 1]; + const blue = pixels[byteIndex + 2]; + const alpha = bytesPerPixel === 4 ? pixels[byteIndex + 3] : 255; + unique.add(`${red >> 3}:${green >> 3}:${blue >> 3}:${alpha >> 4}`); + const luminance = 0.2126 * red + 0.7152 * green + 0.0722 * blue; + minLuminance = Math.min(minLuminance, luminance); + maxLuminance = Math.max(maxLuminance, luminance); + if (alpha >= 250) opaque += 1; + sampled += 1; + } + return { + width, + height, + uniqueColors: unique.size, + luminanceRange: maxLuminance - minLuminance, + opaqueRatio: opaque / sampled, + }; +} + +function unfilterByte(filter, encoded, left, up, upLeft) { + if (filter === 0) return encoded; + if (filter === 1) return (encoded + left) & 0xff; + if (filter === 2) return (encoded + up) & 0xff; + if (filter === 3) return (encoded + Math.floor((left + up) / 2)) & 0xff; + if (filter === 4) return (encoded + paeth(left, up, upLeft)) & 0xff; + throw new Error(`unsupported PNG filter: ${filter}`); +} + +function paeth(left, up, upLeft) { + const estimate = left + up - upLeft; + const leftDistance = Math.abs(estimate - left); + const upDistance = Math.abs(estimate - up); + const diagonalDistance = Math.abs(estimate - upLeft); + if (leftDistance <= upDistance && leftDistance <= diagonalDistance) return left; + if (upDistance <= diagonalDistance) return up; + return upLeft; +} + +async function waitForPageTarget(port) { + const deadline = Date.now() + 20_000; + while (Date.now() < deadline) { + if (electronProcess?.exitCode !== null) { + throw new Error(`Electron exited before exposing CDP (code ${electronProcess.exitCode})`); + } + try { + const targets = await fetch(`http://127.0.0.1:${port}/json/list`).then((response) => response.json()); + const target = targets.find((item) => item.type === "page" && item.url.includes("out/renderer/index.html")); + if (target) return target; + } catch { + // Electron has not opened the debugging port yet. + } + await delay(100); + } + throw new Error("Electron did not expose a renderer CDP target"); +} + +async function reservePort() { + const server = http.createServer(); + await listen(server); + const port = server.address().port; + await new Promise((resolveClose) => server.close(() => resolveClose())); + return port; +} + +function listen(server) { + return new Promise((resolveListen, rejectListen) => { + server.once("error", rejectListen); + server.listen(0, "127.0.0.1", () => resolveListen()); + }); +} + +async function readJsonBody(request) { + const chunks = []; + for await (const chunk of request) chunks.push(chunk); + if (chunks.length === 0) return null; + return JSON.parse(Buffer.concat(chunks).toString("utf8")); +} + +function sendEnvelope(response, data, statusCode = 200, message = "ok") { + const body = JSON.stringify({ code: statusCode === 200 ? 0 : statusCode, message, data }); + response.writeHead(statusCode, { + "content-type": "application/json; charset=utf-8", + "content-length": Buffer.byteLength(body), + connection: "close", + }); + response.end(body); +} + +function localDate(offsetMs) { + const parts = new Intl.DateTimeFormat("en-US", { + timeZone: "Asia/Shanghai", + year: "numeric", + month: "2-digit", + day: "2-digit", + }).formatToParts(new Date(Date.now() + offsetMs)); + const values = Object.fromEntries(parts.map((part) => [part.type, part.value])); + return `${values.year}-${values.month}-${values.day}`; +} + +function isoOffset(offsetMs) { + return new Date(Date.now() + offsetMs).toISOString(); +} + +async function stopElectron(child) { + if (!child || child.exitCode !== null) return; + const exited = new Promise((resolveExit) => child.once("exit", () => resolveExit())); + const graceful = await Promise.race([exited.then(() => true), delay(2_500).then(() => false)]); + if (graceful || child.exitCode !== null) return; + child.kill(); + await Promise.race([exited, delay(2_500)]); +} + +function delay(milliseconds) { + return new Promise((resolveDelay) => setTimeout(resolveDelay, milliseconds)); +} + +await main(); diff --git a/desktop/scripts/check-local-bootstrap-config.mjs b/desktop/scripts/check-local-bootstrap-config.mjs index c77712b..132ea6e 100644 --- a/desktop/scripts/check-local-bootstrap-config.mjs +++ b/desktop/scripts/check-local-bootstrap-config.mjs @@ -5,12 +5,12 @@ import { workspacePaths, escapeRegExp } from "./script-helpers.mjs"; const { desktopRoot, repoRoot } = workspacePaths(import.meta.url); -const [bootstrapSource, localCheckSource, scriptsReadmeSource, rootReadmeSource, packageSource] = +const [bootstrapSource, localCheckSource, scriptsReadmeSource, developmentReadmeSource, packageSource] = await Promise.all([ readFile(resolve(repoRoot, "scripts/bootstrap-local.ps1"), "utf8"), readFile(resolve(repoRoot, "scripts/check-local.ps1"), "utf8"), readFile(resolve(repoRoot, "scripts/README.md"), "utf8"), - readFile(resolve(repoRoot, "README.md"), "utf8"), + readFile(resolve(repoRoot, "docs/development.md"), "utf8"), readFile(resolve(desktopRoot, "package.json"), "utf8"), ]); @@ -65,9 +65,9 @@ assert.match( /-BootstrapMissing/, "scripts README must document bootstrap-and-verify local verification", ); -assert.match(rootReadmeSource, /bootstrap-local\.ps1/, "root README must mention local bootstrap"); -assert.match(rootReadmeSource, /-ReportOnly/, "root README must document report-only local verification"); -assert.match(rootReadmeSource, /-BootstrapMissing/, "root README must document bootstrap-and-verify local verification"); +assert.match(developmentReadmeSource, /bootstrap-local\.ps1/, "development docs must mention local bootstrap"); +assert.match(developmentReadmeSource, /-ReportOnly/, "development docs must document report-only local verification"); +assert.match(developmentReadmeSource, /-BootstrapMissing/, "development docs must document bootstrap-and-verify local verification"); assert.match(packageSource, /check:local-bootstrap/, "package scripts must expose the local bootstrap check"); console.log("local bootstrap config check passed"); diff --git a/desktop/scripts/check-production-hardening-config.mjs b/desktop/scripts/check-production-hardening-config.mjs index a615737..5372548 100644 --- a/desktop/scripts/check-production-hardening-config.mjs +++ b/desktop/scripts/check-production-hardening-config.mjs @@ -76,14 +76,28 @@ assert.match( /Verify Android APK signature[\s\S]*TASKBRIDGE_ANDROID_SIGNED_RELEASE[\s\S]*apksigner[\s\S]*verify --verbose --print-certs/, "release workflow must verify the Android APK signature when Android signing is configured", ); +assert.doesNotMatch( + releaseSource, + /app-release-unsigned\.apk|android-unsigned\.apk/, + "release workflow must not publish unsigned Android APKs", +); +assert.match( + releaseSource, + /Skipping public Android APK because signing secrets are not configured/, + "release workflow must omit Android downloads when signing is unavailable", +); assert.match( releaseSource, - /app-release-unsigned\.apk[\s\S]*android-unsigned\.apk/, - "release workflow must publish a clearly named unsigned Android APK when signing is not configured", + /Skipping public Windows installer because signing secrets are not configured/, + "release workflow must omit Windows downloads when signing is unavailable", ); assert.doesNotMatch(androidBuildSource, /Release signing is required/, "Android release builds must not fail solely because signing is not configured"); -assert.match(androidReadmeSource, /unsigned release/i, "Android docs must explain unsigned release artifacts"); -assert.match(troubleshootingSource, /unsigned release APK/i, "troubleshooting docs must explain unsigned release APKs"); +assert.match(androidReadmeSource, /unsigned release/i, "Android docs must explain local unsigned release builds"); +assert.match( + troubleshootingSource, + /unsigned[\s\S]*不(?:应|会)[\s\S]*公开 Release/i, + "troubleshooting docs must explain that unsigned clients are not public downloads", +); for (const token of [ "docker compose", @@ -120,12 +134,7 @@ for (const token of [ assert.match(deployReadmeSource, new RegExp(escapeRegExp(token)), `deploy docs must document ${token}`); } -for (const token of [ - "WINDOWS_CERTIFICATE_BASE64", - "WINDOWS_CERTIFICATE_PASSWORD", - "unsigned Android", - "unsigned Windows", -]) { +for (const token of ["WINDOWS_CERTIFICATE_BASE64", "WINDOWS_CERTIFICATE_PASSWORD", "unsigned APK", "unsigned `.exe`"]) { assert.match(releaseDocsSource, new RegExp(escapeRegExp(token)), `release docs must document ${token}`); } diff --git a/desktop/scripts/check-refresh-singleflight-config.mjs b/desktop/scripts/check-refresh-singleflight-config.mjs index 79ade79..68b5e61 100644 --- a/desktop/scripts/check-refresh-singleflight-config.mjs +++ b/desktop/scripts/check-refresh-singleflight-config.mjs @@ -10,11 +10,12 @@ const [desktopHttpSource, androidRetrofitSource] = await Promise.all([ readFile(resolve(repoRoot, "android/app/src/main/java/com/taskbridge/app/data/remote/RetrofitClient.kt"), "utf8"), ]); -assert.match(desktopHttpSource, /let refreshInFlight: Promise>\(\)/, "desktop HTTP client must isolate shared refresh requests by captured session"); assert.match(desktopHttpSource, /function refreshTokenOnce/, "desktop HTTP client must centralize refresh singleflight"); -assert.match(desktopHttpSource, /refreshInFlight = refreshToken\(refreshTokenValue\)/, "desktop singleflight must wrap refreshToken()"); -assert.match(desktopHttpSource, /\.finally\(\(\) => \{\s*refreshInFlight = null;/s, "desktop singleflight must clear after completion"); -assert.match(desktopHttpSource, /await refreshTokenOnce\(tokens\.refreshToken\)/, "desktop 401 retry must use refresh singleflight"); +assert.match(desktopHttpSource, /const existing = refreshFlights\.get\(session\.key\)/, "desktop singleflight must reuse only a matching session refresh"); +assert.match(desktopHttpSource, /const request = refreshToken\(session\)\.finally/, "desktop singleflight must refresh the captured session"); +assert.match(desktopHttpSource, /if \(refreshFlights\.get\(session\.key\) === request\)[\s\S]{0,120}refreshFlights\.delete\(session\.key\)/, "desktop singleflight must clear only its own completed session request"); +assert.match(desktopHttpSource, /await refreshTokenOnce\(refreshSession\)/, "desktop 401 retry must use session-scoped refresh singleflight"); assert.match(androidRetrofitSource, /private val refreshLock = Any\(\)/, "Android authenticator must serialize refresh attempts"); assert.match(androidRetrofitSource, /synchronized\(refreshLock\)/, "Android authenticator must guard refresh with refreshLock"); diff --git a/desktop/scripts/check-release-artifacts-config.mjs b/desktop/scripts/check-release-artifacts-config.mjs index fbaba83..9623b31 100644 --- a/desktop/scripts/check-release-artifacts-config.mjs +++ b/desktop/scripts/check-release-artifacts-config.mjs @@ -5,12 +5,22 @@ import { workspacePaths, escapeRegExp } from "./script-helpers.mjs"; const { desktopRoot, repoRoot } = workspacePaths(import.meta.url); -const [packageSource, releaseSource, releaseDocsSource, scriptsReadmeSource, localCheckSource] = await Promise.all([ +const [ + packageSource, + releaseSource, + releaseDocsSource, + scriptsReadmeSource, + localCheckSource, + windowsLauncherSource, + shellLauncherSource, +] = await Promise.all([ readFile(resolve(desktopRoot, "package.json"), "utf8"), readFile(resolve(repoRoot, ".github/workflows/release.yml"), "utf8"), readFile(resolve(repoRoot, "docs/github-release.md"), "utf8"), readFile(resolve(repoRoot, "scripts/README.md"), "utf8"), readFile(resolve(repoRoot, "scripts/check-local.ps1"), "utf8"), + readFile(resolve(repoRoot, "deploy/start-local.cmd"), "utf8"), + readFile(resolve(repoRoot, "deploy/start-local.sh"), "utf8"), ]); const packageJson = JSON.parse(packageSource); @@ -29,10 +39,84 @@ for (const token of [ ".yml", ".blockmap", "latest.yml", + "Build deployment bundle", + "TaskBridge-${RELEASE_VERSION}-deployment.zip", + "taskbridge-deployment", + "release-staging", + "TASKBRIDGE_BACKEND_IMAGE", + "${RELEASE_VERSION}", + "Delete assets from an existing release", + "gh release delete-asset", + "Verify published release assets", + "Skipping public Android APK because signing secrets are not configured", + "Skipping public Windows installer because signing secrets are not configured", + "Build desktop application", ]) { assert.match(releaseSource, new RegExp(escapeRegExp(token)), `release workflow must include ${token}`); } +assert.doesNotMatch( + releaseSource, + /android-unsigned\.apk|building an unsigned release APK/i, + "release workflow must never attach an unsigned Android APK to a public release", +); +assert.match( + releaseSource, + /release_image=.*RELEASE_VERSION[\s\S]{0,1000}\.env\.example[\s\S]{0,300}\.env\.local\.example/, + "deployment bundle must pin both environment templates to the release image version", +); +assert.match( + releaseSource, + /required_assets=.*deployment\.zip[\s\S]{0,300}SHA256SUMS\.txt/, + "published release verification must require the deployment archive and checksums", +); +assert.match( + releaseSource, + /forbidden_assets=[\s\S]{0,240}unsigned[\s\S]{0,240}builder-debug\.yml/, + "published release verification must reject unsigned and builder debug assets", +); +assert.doesNotMatch( + releaseSource, + /building an unsigned installer/i, + "release workflow must never attach an unsigned Windows installer to a public release", +); +assert.doesNotMatch( + releaseSource, + /\.Extension -in @\('\.exe', '\.yml'/, + "desktop artifact collection must not copy builder-debug.yml or unrelated YAML files", +); +assert.match( + releaseSource, + /\$_\.Name -eq 'latest\.yml'/, + "desktop artifact collection must explicitly allow only latest.yml update metadata", +); +assert.match( + releaseDocsSource, + /未配置签名[^\n]*不会上传[^\n]*(?:APK|安装包)/, + "release docs must explain that unsigned client artifacts are omitted", +); +for (const requiredPath of ["deploy/start-local.cmd", "deploy/start-local.sh"]) { + assert.match( + releaseSource, + new RegExp(escapeRegExp(requiredPath)), + `deployment bundle validation must require ${requiredPath}`, + ); +} +for (const [name, source] of [ + ["Windows local launcher", windowsLauncherSource], + ["shell local launcher", shellLauncherSource], +]) { + assert.match(source, /\.env\.local\.example/, `${name} must use the safe local-trial env template`); + assert.match(source, /docker compose -f docker-compose\.release\.yml up -d/, `${name} must start the release compose stack`); + assert.match(source, /\/ready/, `${name} must wait for the public readiness endpoint`); + assert.match(source, /60/, `${name} must use a bounded readiness timeout`); +} +assert.match( + releaseDocsSource, + /TaskBridge--deployment\.zip/, + "release docs must document the deployment bundle", +); + assert.match(releaseDocsSource, /SHA256SUMS\.txt/, "release docs must document checksum artifacts"); assert.match(scriptsReadmeSource, /check:release-artifacts/, "scripts README must list the release artifacts guard"); assert.match(localCheckSource, /check:release-artifacts/, "local check runner must include the release artifacts guard"); diff --git a/desktop/scripts/check-release-endpoint-defaults-config.mjs b/desktop/scripts/check-release-endpoint-defaults-config.mjs index d9c4d8f..cd105df 100644 --- a/desktop/scripts/check-release-endpoint-defaults-config.mjs +++ b/desktop/scripts/check-release-endpoint-defaults-config.mjs @@ -20,21 +20,21 @@ for (const [name, source] of [ assert.ok(!source.includes("192.168.10.30"), `${name} must not default to a developer LAN address`); } -assert.match(desktopViteSource, /FALLBACK_BASE_URL = "http:\/\/127\.0\.0\.1:8000\/api\/v1"/, "desktop dev fallback must be loopback-only"); -assert.match(desktopViteSource, /FALLBACK_WS_URL = "ws:\/\/127\.0\.0\.1:8000\/ws\/sync"/, "desktop websocket fallback must be loopback-only"); -assert.match(desktopStateSource, /FALLBACK_BASE_URL = "http:\/\/127\.0\.0\.1:8000\/api\/v1"/, "desktop runtime fallback must be loopback-only"); +assert.match(desktopViteSource, /FALLBACK_BASE_URL = ""/, "desktop builds must not assume a server address when none is configured"); +assert.match(desktopViteSource, /FALLBACK_WS_URL = ""/, "desktop builds must not assume a websocket address when none is configured"); +assert.match(desktopStateSource, /FALLBACK_BASE_URL = ""/, "desktop runtime must prompt for an unconfigured server address"); +assert.match(desktopStateSource, /FALLBACK_WS_URL = ""/, "desktop runtime must prompt for an unconfigured websocket address"); assert.match(androidBuildSource, /TASKBRIDGE_BASE_URL", "https:\/\/taskbridge\.example\.invalid\/api\/v1\/"/, "Android fallback must be a non-routable HTTPS placeholder"); assert.match(androidBuildSource, /TASKBRIDGE_WS_URL", "wss:\/\/taskbridge\.example\.invalid\/ws\/sync"/, "Android websocket fallback must be a non-routable WSS placeholder"); assert.match(androidBuildSource, /releaseEndpointLooksPlaceholder/, "Android release build must reject placeholder endpoints"); -assert.match(androidBuildSource, /taskBridgeUsesCleartext/, "Android build must derive cleartext manifest behavior from endpoint schemes"); assert.match( androidBuildSource, - /manifestPlaceholders\["usesCleartextTraffic"\]\s*=\s*taskBridgeUsesCleartext/, - "Android build must allow cleartext traffic when configured HTTP or WS endpoints require it", + /manifestPlaceholders\["usesCleartextTraffic"\]\s*=\s*true/, + "Android builds must allow users to enter HTTP or WS endpoints at runtime", ); assert.doesNotMatch( androidBuildSource, - /Release endpoints must use HTTPS and WSS|releaseUsesCleartext/, + /Release endpoints must use HTTPS and WSS|releaseUsesCleartext|taskBridgeUsesCleartext/, "Android release build must not reject configured HTTP or WS endpoints", ); assert.match(releaseWorkflowSource, /validate_endpoint "TASKBRIDGE_BASE_URL"/, "release workflow must validate public API endpoint"); diff --git a/desktop/scripts/check-security-config.mjs b/desktop/scripts/check-security-config.mjs index 0ada71d..600b690 100644 --- a/desktop/scripts/check-security-config.mjs +++ b/desktop/scripts/check-security-config.mjs @@ -130,15 +130,20 @@ assert.match(ipcSource, /assertTrustedSender/, "IPC handlers must validate the i assert.doesNotMatch(ipcSource, /ipcMain\.handle\("[^"]+",\s*\(/, "IPC handlers must go through the trusted sender wrapper"); for (const token of [ "function validateLocalId", + "function validateOptionalLocalId", "function validateNotificationText", "function validateServerIds", - "showTaskNotification(validateNotificationText(title", "getTask(validateLocalId(localId))", "listTasksByServerIds(validateServerIds(serverIds))", "openTaskDetail(validateLocalId(localId))", ]) { assert.match(ipcSource, new RegExp(escapeRegExp(token)), `IPC must harden renderer input: ${token}`); } +assert.match( + ipcSource, + /showTaskNotification\([\s\S]{0,180}validateNotificationText\(title[\s\S]{0,180}validateNotificationText\(body[\s\S]{0,180}validateOptionalLocalId\(localId\)/, + "notification IPC must validate title, body, and optional task identity", +); const setTokensSource = sliceBetween( stateSource, diff --git a/desktop/scripts/check-sync-diagnostics.mjs b/desktop/scripts/check-sync-diagnostics.mjs index 381fa5b..a9dab06 100644 --- a/desktop/scripts/check-sync-diagnostics.mjs +++ b/desktop/scripts/check-sync-diagnostics.mjs @@ -1,21 +1,47 @@ import assert from "node:assert/strict"; import { readFile } from "node:fs/promises"; import { resolve } from "node:path"; -import { workspaceRoot, escapeRegExp } from "./script-helpers.mjs"; +import { + escapeRegExp, + extractOpeningTag, + hasLiteralBooleanAttribute, + workspaceRoot, +} from "./script-helpers.mjs"; const desktopRoot = workspaceRoot(import.meta.url); -const [syncStoreSource, settingsViewSource, i18nSource, cssSource, packageSource, preloadSource, ipcSource, envSource] = await Promise.all([ +const [syncStoreSource, settingsViewSource, syncRecoveryPanelSource, i18nSource, cssSource, workspaceCssSource, packageSource, preloadSource, ipcSource, envSource] = await Promise.all([ readFile(resolve(desktopRoot, "src/stores/sync.ts"), "utf8"), readFile(resolve(desktopRoot, "src/views/SettingsView.vue"), "utf8"), + readFile(resolve(desktopRoot, "src/components/settings/SettingsSyncRecoveryPanel.vue"), "utf8"), readFile(resolve(desktopRoot, "src/i18n.ts"), "utf8"), readFile(resolve(desktopRoot, "src/assets/base.css"), "utf8"), + readFile(resolve(desktopRoot, "src/assets/workspace.css"), "utf8"), readFile(resolve(desktopRoot, "package.json"), "utf8"), readFile(resolve(desktopRoot, "electron/preload.ts"), "utf8"), readFile(resolve(desktopRoot, "electron/ipc.ts"), "utf8"), readFile(resolve(desktopRoot, "src/env.d.ts"), "utf8"), ]); +const settingsSurfaceSource = `${settingsViewSource}\n${syncRecoveryPanelSource}`; +const recoveryPanelTag = extractOpeningTag(settingsViewSource, "]*class="settings-advanced-details"[^>]*>\s*\{\{\s*settingsStore\.t\("settings\.syncDiagnostics"\)\s*\}\}<\/summary>[\s\S]*?
/, + syncRecoveryPanelSource, + /\{\{\s*settingsStore\.t\("settings\.syncDiagnostics"\)\s*\}\}<\/summary>[\s\S]*?
/, "settings sync diagnostics must be grouped in a collapsed details block", ); -assert.doesNotMatch( - settingsViewSource, - /
""), + readFile(resolve(desktopRoot, "src/components/WorkspaceStatusBanner.vue"), "utf8"), readFile(resolve(desktopRoot, "src/components/ConfirmDialog.vue"), "utf8"), readFile(resolve(desktopRoot, "src/components/FloatingHeader.vue"), "utf8"), readFile(resolve(desktopRoot, "electron/main.ts"), "utf8"), readFile(resolve(desktopRoot, "electron/ipc.ts"), "utf8"), readFile(resolve(desktopRoot, "build/installer.nsh"), "utf8"), readFile(resolve(desktopRoot, "src/assets/base.css"), "utf8"), + readFile(resolve(desktopRoot, "src/assets/workspace.css"), "utf8"), readFile(resolve(desktopRoot, "src/i18n.ts"), "utf8"), readFile(resolve(desktopRoot, "src/stores/auth.ts"), "utf8"), readFile(resolve(desktopRoot, "src/stores/floating.ts"), "utf8"), @@ -143,6 +159,7 @@ const [ readFile(resolve(repoRoot, "android/app/src/main/java/com/taskbridge/app/ui/login/LoginScreen.kt"), "utf8"), readFile(resolve(repoRoot, "android/app/src/main/java/com/taskbridge/app/ui/login/LoginViewModel.kt"), "utf8"), readFile(resolve(repoRoot, "android/app/src/main/java/com/taskbridge/app/ui/login/RegisterScreen.kt"), "utf8"), + readFile(resolve(repoRoot, "android/app/src/main/java/com/taskbridge/app/ui/login/RegistrationAvailability.kt"), "utf8"), readFile(resolve(repoRoot, "android/app/src/main/java/com/taskbridge/app/ui/login/RegisterViewModel.kt"), "utf8"), readFile(resolve(repoRoot, "android/app/src/main/java/com/taskbridge/app/ui/settings/SettingsScreen.kt"), "utf8"), readFile(resolve(repoRoot, "android/app/src/main/java/com/taskbridge/app/ui/settings/SettingsUiPolicy.kt"), "utf8"), @@ -163,23 +180,28 @@ const [ readFile(resolve(repoRoot, "desktop/README.md"), "utf8"), readFile(resolve(repoRoot, "android/README.md"), "utf8"), readFile(resolve(repoRoot, "docs/user-quick-start.md"), "utf8").catch(() => ""), + readFile(resolve(repoRoot, "docs/development.md"), "utf8"), + readFile(resolve(repoRoot, "docs/troubleshooting.md"), "utf8"), readFile(resolve(repoRoot, "docs/security.md"), "utf8"), readFile(resolve(repoRoot, "docs/sync-design.md"), "utf8"), readFile(resolve(repoRoot, "VERSION"), "utf8"), -]); + readFile(resolve(repoRoot, "package.json"), "utf8"), +])).map(normalizeLineEndings); const desktopSettingsSurfaceSource = [ desktopSettingsSource, desktopSettingsAccountPanelSource, desktopSettingsConnectionPanelSource, desktopSettingsDataPanelSource, + desktopSettingsSyncRecoveryPanelSource, desktopSettingsMetadataPanelSource, desktopSettingsWindowPanelSource, ].join("\n"); assert.match(webAppSource, /function getCurrentUserId\(/, "Web offline cache must resolve the current user before opening storage"); -assert.match(webAppSource, /function offlineDbName\(/, "Web offline cache must use a per-user IndexedDB name"); -assert.match(webAppSource, /indexedDB\.open\(offlineDbName\(\)/, "Web IndexedDB must be opened from the per-user name helper"); +assert.match(webAppSource, /function offlineDbName\(/, "Web offline cache must use a scoped IndexedDB name"); +assert.match(webAppSource, /buildOfflineDatabaseName\(STORAGE_PREFIX, state\.apiBaseUrl, userId\)/, "Web IndexedDB names must isolate both server and user"); +assert.match(webAppSource, /indexedDB\.open\(dbName, WEB_OFFLINE_DB_VERSION\)/, "Web IndexedDB must open the scoped database name"); assert.match(webAppSource, /user_id:\s*getCurrentUserId\(\)/, "Web offline mutations must store the owning user id"); assert.match(webAppSource, /closeOfflineDb\(\)/, "Web logout/account switch must close the previous offline database handle"); assert.doesNotMatch(webAppSource, /indexedDB\.open\(WEB_OFFLINE_DB_NAME,/, "Web must not open one global offline database for every account"); @@ -268,9 +290,9 @@ assert.match( ); assert.match(webHtmlSource, /id="startupFallback"/, "Web must show a recovery panel when JavaScript modules fail to boot"); assert.match(webHtmlSource, /id="clearStartupCacheButton"/, "Web startup recovery must let users clear stale offline cache"); -assert.match(webHtmlSource, /window\.taskBridgeWebReady/, "Web startup recovery must depend on an explicit ready marker"); +assert.match(webStartupSource, /window\.taskBridgeWebReady/, "Web startup recovery must depend on an explicit ready marker"); assert.doesNotMatch( - webHtmlSource, + webStartupSource, /window\.taskBridgeWebReady\s*=\s*false;/, "Web startup recovery must not overwrite the app-ready marker after the module has already booted", ); @@ -330,15 +352,12 @@ const webFirstScreenI18nKeys = [ "auth.register", "auth.firstUseTitle", "auth.firstUseExistingServer", - "auth.firstUseSimpleStart", - "auth.localTrial", - "auth.localTrialDocs", + "auth.serverSetupHelp", + "auth.serverSetupHelpHint", "auth.openLocalTrialGuide", - "auth.localTrialSameDevice", - "auth.localTrialStartBackend", - "auth.selfHost", - "auth.selfHostHint", - "auth.selfHostFirstAccount", + "auth.openSelfHostGuide", + "auth.resumeOffline", + "auth.resumeOfflineHint", "auth.serverUrl", "auth.serverUrlHint", "auth.advancedConnection", @@ -365,15 +384,24 @@ assert.match(webAppSource, /function togglePasswordVisibility\(/, "Web auth pass assert.match(webAppSource, /nodes\.password\.type = state\.passwordVisible \? "text" : "password"/, "Web auth password toggle must switch the input type"); assert.match(webAppSource, /"auth\.showPassword"/, "Web i18n must define show-password copy"); assert.match(webAppSource, /"auth\.hidePassword"/, "Web i18n must define hide-password copy"); -const webFirstRunGuideSource = sourceBetween(webHtmlSource, '
'); assert.doesNotMatch(webFirstRunGuideSource, /API|WebSocket/, "Web first-use guidance must avoid developer endpoint terms"); assert.doesNotMatch(webAppSource, /"auth\.selfHostHint":[^\n]*(API|WebSocket)/, "Web self-host hint must avoid developer endpoint terms in ordinary copy"); -assert.match(webFirstRunGuideSource, /id="serverSetupHelp"/, "Web first-use screen must keep Docker and self-hosting help behind one optional server-help disclosure"); -assertOrder( +assert.match(webHtmlSource, /
]*data-i18n="auth\.localTrial"[^>]*>Docker 本机试用<\/summary>/, - "Web first-use local trial instructions must be clearly labeled as a Docker-backed path", -); -const webAuthGuide = sourceBetween(webHtmlSource, '
'); assert.doesNotMatch( webAuthGuide, /git clone|docker compose|command-snippet|Windows PowerShell|macOS \/ Linux/, @@ -523,21 +546,17 @@ assert.match(webHtmlSource, /data-i18n="startup\.clearAndReload"/, "Web startup assert.doesNotMatch(webHtmlSource, /offline cache|task data cache/i, "Web startup recovery must not imply that local task data will be cleared"); assert.match(webAppSource, /"startup\.clearAndReload"[\s\S]{0,380}Clear page resource cache and refresh/, "Web Chinese startup recovery copy must use page resource cache wording"); assert.match(webAppSource, /"startup\.clearAndReload": "Clear page resource cache and refresh"/, "Web English startup recovery copy must use page resource cache wording"); -assert.match( - webHtmlSource, - /
]*data-i18n="auth\.selfHost"[^>]*>自托管部署<\/summary>/, - "Web self-hosting instructions must be collapsed behind a choice", -); assert.doesNotMatch( webHtmlSource, /[^<]*(127\.0\.0\.1|LAN IP)/i, "Web login first screen must not front-load local-network instructions", ); -assert.match(desktopDbSource, /currentUserDatabaseKey/, "Desktop SQLite must derive a database key from the current user"); -assert.match(desktopDbSource, /taskbridge-user-\$\{userId\}\.sqlite/, "Desktop SQLite must use a per-user database file"); -assert.match(desktopDbSource, /legacyGlobalDatabasePath/, "Desktop SQLite must handle migration from the legacy global database"); -assert.match(desktopDbSource, /dbUserKey !== nextUserKey/, "Desktop SQLite must reopen when the signed-in user changes"); +assert.match(desktopDbSource, /currentUserDatabaseKey/, "Desktop SQLite must derive an active workspace database key"); +assert.match(desktopDbSource, /workspaceDatabaseFileName\(settings\.baseUrl, userId\)/, "Desktop SQLite must isolate database files by server origin and user"); +assert.match(desktopDbSource, /migrateLegacyWorkspaceDatabase/, "Desktop SQLite must migrate legacy per-user and global databases into one claimed workspace"); +assert.match(desktopDbSource, /claimLegacyGlobalDatabaseWorkspace/, "Desktop global database migration must be claimed by only one workspace"); +assert.match(desktopDbSource, /dbUserKey !== nextUserKey/, "Desktop SQLite must reopen when the active server or user changes"); assert.doesNotMatch(desktopDbSource, /join\(app\.getPath\("userData"\), "taskbridge\.sqlite"\)/, "Desktop must not keep using one global task database"); assert.match(desktopLoginSource, /serverUrlDraft/, "Desktop login must allow changing the server address before sign-in"); @@ -564,10 +583,20 @@ assert.match( assert.doesNotMatch(desktopLoginSource, /class="first-use-cards"|auth\.firstUseNoServerTitle/, "Desktop first-use guide must not front-load multiple path cards before the server URL"); assert.match(desktopI18nSource, /"auth\.firstUseNoServerTitle"/, "Desktop i18n must define the no-server first-use title"); assert.match(desktopI18nSource, /"auth\.noServerHelpSummary"/, "Desktop i18n must define the no-server help summary"); +assert.doesNotMatch( + desktopI18nSource, + /"auth\.(firstUseLocalTrial|firstUseLocalTrialTitle|firstUseSelfHost|firstUseSelfHostTitle|setupChecklistTitle|setupStepServer|setupStepCheck|setupStepAccount)"/, + "Desktop i18n must not keep unused first-use path cards or setup checklist copy", +); +assert.doesNotMatch( + desktopCssSource, + /\.first-use-cards|\.first-use-card|\.setup-checklist/, + "Desktop CSS must not keep unused first-use card or setup checklist styles", +); const desktopFirstUseGuideSource = sourceBetween( desktopLoginSource, - '
', + '
', + '
', ); assert.doesNotMatch( desktopFirstUseGuideSource, @@ -579,7 +608,7 @@ assert.doesNotMatch( /class="first-use-cards"|class="setup-checklist"/, "Desktop first-use guide should keep the default path compact and put setup detail behind optional help", ); -assert.match(desktopLoginSource, /first-use-guide|首次使用怎么选|auth\.firstUseTitle/, "Desktop login must orient first-time users before asking for a server"); +assert.match(desktopLoginSource, /first-use-guide|首次使用怎么选|auth\.firstUseTitle/, "Desktop login must keep optional first-use guidance available after the primary sign-in flow"); assert.match(desktopLoginSource, /serverLocalhostHint|localhostServerHint|localhostHint/, "Desktop login must warn when localhost is only valid for this computer"); assert.match(desktopLoginSource, /serverLocalhostHint[\s\S]{0,260}form-message-info/, "Desktop login localhost guidance must be informational, not an error state"); assert.doesNotMatch(desktopLoginSource, /serverLocalhostHint[\s\S]{0,260}form-message-error/, "Desktop login localhost guidance must not look like a failed connection"); @@ -626,14 +655,15 @@ assert.match(desktopSettingsSurfaceSource, /resetGeneratedConnectionEndpoints/, assert.match(desktopI18nSource, /"settings\.resetGeneratedEndpoints"/, "Desktop i18n must include generated-endpoint reset copy"); assert.match(desktopSettingsSurfaceSource, / +
+``` + +- [ ] **步骤 5:加入保存失败反馈并精简任务行操作** + +复制任务 3 的保存错误语义:`catch` 后保留抽屉和草稿,错误使用 `role="alert"`,成功使用 `AppToast`。`TaskItem.vue` 的更多操作 `summary` 改为 `MoreHorizontal` 图标和屏幕阅读器文本;任务标题允许在两行内换行,行内操作只通过 CSS 在悬停和 `:focus-within` 时增强可见度。 + +- [ ] **步骤 6:运行测试与类型检查** + +运行:`node --test --test-name-pattern="all tasks" desktop/tests/desktop-focus-workspace.test.mjs` + +运行:`npm --prefix desktop run typecheck` + +预期:两条命令均 PASS。 + +- [ ] **步骤 7:提交全部任务页面** + +```bash +git add desktop/src/views/TaskView.vue desktop/src/components/TaskItem.vue desktop/src/components/TaskSyncHealthBar.vue desktop/tests/desktop-focus-workspace.test.mjs +git commit -m "feat(桌面端): 精简任务筛选与列表操作" +``` + +## 任务 5:将设置页改为分类导航和单一内容区 + +**文件:** +- 创建:`desktop/src/components/settings/SettingsSyncRecoveryPanel.vue` +- 修改:`desktop/src/views/SettingsView.vue` +- 修改:`desktop/src/i18n.ts` +- 修改:`desktop/tests/desktop-focus-workspace.test.mjs` + +- [ ] **步骤 1:编写失败的设置页结构测试** + +```javascript +test("settings displays one persistent category panel at a time", async () => { + const [settings, recovery] = await Promise.all([ + source("desktop/src/views/SettingsView.vue"), + source("desktop/src/components/settings/SettingsSyncRecoveryPanel.vue"), + ]); + + assert.match(settings, /type SettingsSectionId/); + assert.match(settings, /const activeSettingsSection = ref/); + assert.match(settings, /class="settings-category-nav"/); + assert.match(settings, /v-show="activeSettingsSection === 'connection'"/); + assert.doesNotMatch(settings, /scrollIntoView/); + assert.match(recovery, /settings\.syncRecoveryCenter/); + assert.match(recovery, /settings\.diagnosticsSupportTools/); +}); +``` + +- [ ] **步骤 2:运行测试并确认失败** + +运行:`node --test --test-name-pattern="settings displays" desktop/tests/desktop-focus-workspace.test.mjs` + +预期:FAIL,错误指出恢复面板不存在或设置页仍使用锚点滚动。 + +- [ ] **步骤 3:建立分类状态与外部跳转映射** + +```typescript +type SettingsSectionId = + | "account-display" + | "account-security" + | "connection" + | "window" + | "data" + | "sync-recovery" + | "metadata"; + +const activeSettingsSection = ref("account-display"); + +function showSettingsSection(sectionId: string): void { + if (!settingsNavItems.value.some((item) => item.sectionId === sectionId)) return; + activeSettingsSection.value = sectionId as SettingsSectionId; + if (sectionId === "sync-recovery") syncDiagnosticsOpen.value = true; + if (sectionId === "metadata") metadataOpen.value = false; +} +``` + +`sectionRequest` 监听器调用 `showSettingsSection`。分类面板使用 `v-show`,保证连接草稿在内部切换时不卸载。 + +- [ ] **步骤 4:提取同步恢复面板** + +```typescript +defineProps<{ + deviceId: string; + diagnostics: SyncDiagnostics; + serverTaskMeta: TaskMetaDto | null; + note: string; + updateTechnicalDetail: string; +}>(); + +defineEmits<{ + refresh: []; + retry: []; + exportDiagnostics: []; +}>(); +``` + +组件内部保留诊断折叠区、耗尽队列列表和支持工具折叠区。正常无问题时只显示简短空状态;详细计数和技术信息默认收起。 + +- [ ] **步骤 5:重写设置模板** + +```vue +
+ +
+ +
+
+``` + +- [ ] **步骤 6:运行设置测试与类型检查** + +运行:`node --test --test-name-pattern="settings displays" desktop/tests/desktop-focus-workspace.test.mjs` + +运行:`npm --prefix desktop run typecheck` + +预期:两条命令均 PASS,连接草稿事件仍可传播到 `App.vue`。 + +- [ ] **步骤 7:提交设置页** + +```bash +git add desktop/src/views/SettingsView.vue desktop/src/components/settings/SettingsSyncRecoveryPanel.vue desktop/src/i18n.ts desktop/tests/desktop-focus-workspace.test.mjs +git commit -m "feat(桌面端): 重构设置分类工作区" +``` + +## 任务 6:实现完整视觉系统与响应式布局 + +**文件:** +- 创建:`desktop/src/assets/workspace.css` +- 修改:`desktop/src/main.ts` +- 修改:`desktop/src/assets/base.css` +- 修改:`desktop/tests/desktop-focus-workspace.test.mjs` + +- [ ] **步骤 1:编写失败的响应式样式契约测试** + +```javascript +test("workspace stylesheet defines stable desktop and narrow layouts", async () => { + const [main, css] = await Promise.all([ + source("desktop/src/main.ts"), + source("desktop/src/assets/workspace.css"), + ]); + + assert.match(main, /assets\/workspace\.css/); + assert.match(css, /grid-template-columns:\s*184px minmax\(0, 1fr\)/); + assert.match(css, /max-width:\s*1080px/); + assert.match(css, /width:\s*min\(440px/); + assert.match(css, /@media \(max-width: 1099px\)/); + assert.match(css, /grid-template-columns:\s*72px minmax\(0, 1fr\)/); + assert.match(css, /@media \(max-width: 799px\)/); + assert.match(css, /overflow-x:\s*hidden/); + assert.match(css, /@media \(prefers-reduced-motion: reduce\)/); +}); +``` + +- [ ] **步骤 2:运行测试并确认失败** + +运行:`node --test --test-name-pattern="workspace stylesheet" desktop/tests/desktop-focus-workspace.test.mjs` + +预期:FAIL,错误指出 `workspace.css` 不存在。 + +- [ ] **步骤 3:建立工作台变量和宽屏布局** + +```css +.focus-workspace { + --workspace-sidebar: #f1f5f3; + --workspace-canvas: #ffffff; + --workspace-line: #dce4e0; + --workspace-text: #17211f; + --workspace-muted: #68756f; + display: grid; + grid-template-columns: 184px minmax(0, 1fr); + width: 100%; + height: 100vh; + min-width: 0; + overflow: hidden; + color: var(--workspace-text); + background: var(--workspace-canvas); +} + +.focus-workspace .view-shell { + width: min(1080px, 100%); + margin: 0 auto; + padding: 28px 40px 48px; +} + +.focus-workspace .side-panel { + width: min(440px, calc(100vw - 184px)); +} +``` + +- [ ] **步骤 4:实现任务、菜单、Toast 和设置样式** + +要求:任务行最小高度 `56 px`;主列表无外层卡片;任务行边框半径不超过 `8 px`;成功 Toast 固定在右下角;弹出菜单、抽屉和对话框使用有限阴影;图标按钮为稳定 `36 x 36 px`;所有文字容器允许换行;危险色仅用于逾期、冲突和破坏性操作。 + +- [ ] **步骤 5:实现窄窗口和减少动画规则** + +```css +@media (max-width: 1099px) { + .focus-workspace { + grid-template-columns: 72px minmax(0, 1fr); + } + .focus-workspace .nav-label, + .focus-workspace .brand-name, + .focus-workspace .account-name { + display: none; + } +} + +@media (max-width: 799px) { + .focus-workspace { + grid-template-columns: 64px minmax(0, 1fr); + overflow-x: hidden; + } + .focus-workspace .task-command-bar, + .focus-workspace .settings-workspace { + grid-template-columns: minmax(0, 1fr); + } +} + +@media (prefers-reduced-motion: reduce) { + .focus-workspace *, + .app-toast { + scroll-behavior: auto !important; + transition-duration: 0.01ms !important; + animation-duration: 0.01ms !important; + } +} +``` + +- [ ] **步骤 6:移除 `base.css` 中已失效的常驻同步与重复工作台覆盖** + +删除仅服务于旧结构的 `.sidebar-sync-button`、`.sidebar .sync-status`、`.task-sync-health-bar` 和旧设置锚点导航样式。保留登录页、悬浮窗、编辑表单、对话框和主题变量所需样式。运行 `rg` 确认被删除组件的选择器不再存在。 + +- [ ] **步骤 7:运行样式契约、类型检查和构建** + +运行:`node --test --test-name-pattern="workspace stylesheet" desktop/tests/desktop-focus-workspace.test.mjs` + +运行:`npm --prefix desktop run typecheck` + +运行:`npm --prefix desktop run build` + +预期:3 条命令均 PASS。 + +- [ ] **步骤 8:提交视觉系统** + +```bash +git add desktop/src/assets/workspace.css desktop/src/assets/base.css desktop/src/main.ts desktop/tests/desktop-focus-workspace.test.mjs +git commit -m "style(桌面端): 完成专注工作台视觉与响应式布局" +``` + +## 任务 7:更新 UX 守门并运行桌面完整回归 + +**文件:** +- 修改:`desktop/tests/ux-remediation.test.mjs` +- 修改:`desktop/scripts/check-ux-priority-polish.mjs` +- 修改:`desktop/scripts/check-user-experience-optimizations.mjs` +- 修改:`desktop/tests/desktop-focus-workspace.test.mjs` + +- [ ] **步骤 1:将旧健康条断言改为单一异常横幅断言** + +```javascript +assert.match(desktopAppSource, /v-if="workspaceStatus\.banner !== 'none'"/); +assert.match(desktopWorkspaceBannerSource, /aria-live="polite"/); +assert.doesNotMatch(desktopTodayViewSource, /TaskSyncHealthBar|showTaskSyncHealth/); +assert.doesNotMatch(desktopTaskViewSource, /TaskSyncHealthBar|showTaskSyncHealth/); +``` + +守门文件读取 `WorkspaceStatusBanner.vue` 和 `workspace-ui-policy.ts`,不再读取已删除的 `TaskSyncHealthBar.vue`。 + +- [ ] **步骤 2:运行受影响的 UX 守门并确认通过** + +运行:`npm --prefix desktop run check:ux-priority-polish` + +运行:`npm --prefix desktop run check:user-experience` + +运行:`npm --prefix desktop run test:unit` + +预期:3 条命令均 PASS,桌面测试总数不低于改造前的 `50` 条。 + +- [ ] **步骤 3:运行桌面类型检查和生产构建** + +运行:`npm --prefix desktop run typecheck` + +运行:`npm --prefix desktop run build` + +预期:两条命令均 PASS,`desktop/out` 重新生成。 + +- [ ] **步骤 4:运行仓库静态守门** + +运行:`powershell -ExecutionPolicy Bypass -File .\scripts\check-local.ps1 -ReportOnly -SkipBackend -SkipDesktopBuild` + +预期:桌面静态守门全部 PASS;仅显式跳过后端、Android 和重复桌面构建,不出现 failed 或 blocked。 + +- [ ] **步骤 5:提交守门更新** + +```bash +git add desktop/tests/ux-remediation.test.mjs desktop/tests/desktop-focus-workspace.test.mjs desktop/scripts/check-ux-priority-polish.mjs desktop/scripts/check-user-experience-optimizations.mjs +git commit -m "test(桌面端): 更新专注工作台体验守门" +``` + +## 任务 8:真实视口与交互验收 + +**文件:** +- 创建:`docs/assets/desktop-focus-1440.png` +- 创建:`docs/assets/desktop-focus-1024.png` +- 创建:`docs/assets/desktop-focus-800.png` +- 修改:`desktop/README.md` + +- [ ] **步骤 1:启动桌面开发服务** + +运行:`npm --prefix desktop run dev` + +预期:Electron 窗口成功启动,主进程和渲染进程控制台无未处理异常。 + +- [ ] **步骤 2:准备可重复验收数据** + +使用现有测试账号或本地测试服务创建:1 条逾期任务、3 条今天任务、1 条长标题任务、1 条带项目和标签任务、1 条已完成任务。记录账号、服务地址和数据恢复方式,不修改真实用户数据。 + +- [ ] **步骤 3:检查 `1440 x 1000` 宽屏布局** + +验证:`184 px` 侧栏、快捷添加首屏可见、正常同步只显示状态点、编辑抽屉不压缩列表、任务行操作悬停和键盘聚焦均可见。保存截图到 `docs/assets/desktop-focus-1440.png`。 + +- [ ] **步骤 4:检查 `1024 x 768` 与 `800 x 700` 窄窗口布局** + +验证:侧栏分别收窄、标签不遮挡、筛选可换行、设置分类可用、抽屉覆盖内容且可关闭、页面无横向滚动。保存截图到 `docs/assets/desktop-focus-1024.png` 和 `docs/assets/desktop-focus-800.png`。 + +- [ ] **步骤 5:验证异常和辅助功能状态** + +依次验证同步中、离线、同步失败、冲突、保存失败和未保存关闭。确认同一同步问题只出现一个横幅;保存失败保留草稿并使用 `role="alert"`;成功 Toast 使用 `role="status"`;键盘焦点进入并离开抽屉后返回触发点;启用减少动画后无明显位移动画。 + +- [ ] **步骤 6:检查截图像素和可访问性树** + +对 3 张截图使用图像查看工具确认非空、无遮挡、无裁切。使用浏览器或 Electron DevTools 可访问性树检查所有图标按钮均有名称,且不存在空名称交互节点。 + +- [ ] **步骤 7:更新桌面 README 截图并提交** + +在 `desktop/README.md` 的界面部分引用宽屏截图,并注明窄窗口会收为图标导航。运行 Markdown 本地链接检查后提交。 + +```bash +git add docs/assets/desktop-focus-1440.png docs/assets/desktop-focus-1024.png docs/assets/desktop-focus-800.png desktop/README.md +git commit -m "docs(桌面端): 更新专注工作台界面截图" +``` + +- [ ] **步骤 8:最终验证工作区差异** + +运行:`git diff --check` + +运行:`git status --short` + +预期:`git diff --check` 无输出;状态列表中不包含临时日志、开发数据库、截图之外的生成物或未解释文件。 diff --git a/docs/superpowers/specs/2026-07-15-desktop-focus-workspace-design.md b/docs/superpowers/specs/2026-07-15-desktop-focus-workspace-design.md new file mode 100644 index 0000000..5a633a6 --- /dev/null +++ b/docs/superpowers/specs/2026-07-15-desktop-focus-workspace-design.md @@ -0,0 +1,227 @@ +# TaskBridge 桌面端专注工作台设计规格 + +## 背景 + +当前桌面端在导航、任务列表和设置页同时展示大量说明、同步状态、筛选状态与操作反馈。常驻信息占用了任务工作区,正常状态与异常状态缺少层级差异,用户需要先阅读界面再判断下一步操作。 + +本次重设计采用已确认的「A:专注工作台」方案。桌面端以任务处理为核心,正常状态保持安静,低频功能按需出现,异常状态提供明确且可执行的反馈。 + +## 目标 + +- 用户打开应用后可以直接查看、添加和处理任务。 +- 正常同步状态不占用主工作区,离线、冲突和失败才升级为可见提示。 +- 今天、全部任务和设置拥有一致的页面骨架与操作层级。 +- 新建和编辑任务不改变用户当前列表上下文。 +- 设置页一次只展示一个分类,避免多个复杂面板同时堆叠。 +- 在常见桌面宽度和窄窗口下保持完整操作能力、清晰焦点顺序和无横向滚动。 + +## 非目标 + +- 不改变任务、同步、认证、备份、更新或提醒的业务语义。 +- 不修改后端 API、SQLite 数据结构或跨端同步协议。 +- 不把桌面端改造成统计仪表盘,也不新增项目管理功能。 +- 不重新设计登录页和悬浮窗;仅确保它们继续使用统一的基础视觉变量。 + +## 设计原则 + +### 视觉主张 + +使用安静的浅色工作面、克制的青绿色主操作和清晰的排版层级,让任务内容成为界面主体。边框仅用于分隔,阴影仅用于抽屉、菜单和 Toast 等临时浮层。 + +### 内容策略 + +- 页面只保留定位、状态和操作所需的文案。 +- 删除与标题或控件含义重复的常驻说明。 +- 正常结果使用短暂反馈;需要用户处理的情况保留到问题解决。 +- 技术细节、诊断信息和高级连接项默认收起。 + +### 交互策略 + +- 页面切换和筛选变化使用短促的淡入与位移动画,不阻塞操作。 +- 编辑器从右侧滑入,关闭后恢复原列表位置、筛选和搜索内容。 +- 行内次要操作在悬停或键盘聚焦时显现;触屏和窄窗口提供始终可访问的更多菜单。 +- 尊重 `prefers-reduced-motion`,关闭非必要动画。 + +## 信息架构 + +桌面应用保留 3 个一级入口: + +1. **今天:** 当前最需要处理的任务。 +2. **全部任务:** 搜索、筛选、批量处理和维护全部任务。 +3. **设置:** 账户与外观、连接、窗口、数据安全、同步恢复和高级维护。 + +导航之外的账号、同步和退出操作统一放在侧栏底部的账户区,不再分别占据固定区域。 + +## 应用骨架 + +### 宽屏布局 + +- 左侧固定 `184 px` 导航栏,右侧为单一主工作区。 +- 主内容最大宽度为 `1080 px`,在可用空间内居中;任务列表不使用外层卡片。 +- 主内容采用固定页头、可选工具区和滚动内容区。临时反馈不参与正常文档流,避免推动内容跳动。 +- 编辑器以右侧 `440 px` 抽屉覆盖主工作区边缘,不压缩任务列表。 + +### 侧栏 + +- 顶部展示 `TB` 标记和 `TaskBridge` 名称。 +- 中部只展示今天、全部任务和设置 3 个入口,当前入口使用浅色底和明确的 `aria-current="page"`。 +- 底部账户区展示默认头像、用户名和同步状态点。 +- 同步正常时只显示绿色状态点,悬停或聚焦时通过 Tooltip 显示「已同步」。 +- 同步中显示旋转状态;离线使用中性色;错误或冲突使用警示色和数量标记。 +- 点击账户区打开菜单,提供「立即同步」「同步详情」「退出登录」。退出和未同步数据保护沿用现有确认逻辑。 + +## 今天页面 + +### 页头 + +- 主标题为「今天」,副信息为当前未完成任务数。 +- 右侧只保留一个高强调「新建任务」按钮。 +- 不再使用 eyebrow、说明句或常驻健康状态卡片。 + +### 快捷添加 + +- 页头下方提供一行快捷输入,按 Enter 直接创建。 +- 「更多」打开完整任务编辑抽屉,并继承今天预设。 +- 输入为空时不提交;解析或保存失败时在输入下方显示单行错误,不使用全局 Toast 隐藏失败原因。 + +### 任务列表 + +- 任务按「已逾期」和「接下来」分组,组标题显示数量。 +- 任务行稳定高度不低于 `56 px`,主信息依次为完成控件、标题、时间与项目/标签元信息。 +- 逾期仅使用左侧警示线、时间颜色或小型状态文字,不给整行铺设高饱和背景。 +- 完成、编辑、稍后处理等次要操作在悬停或聚焦时出现。 +- 列表为空时只展示一句状态和「新建任务」按钮,不展示功能说明。 + +## 全部任务页面 + +### 页头与搜索 + +- 主标题为「全部任务」,副信息为未完成任务数。 +- 新建任务按钮位置与今天页面一致。 +- 搜索框和筛选控件位于同一工具行,搜索框占据剩余宽度。 + +### 筛选 + +- 常用筛选使用分段控件:全部、今天、已逾期、已完成。 +- 收件箱、本周、高优先级、待同步、冲突、模板和回收站放入「更多筛选」菜单。 +- 项目和标签放入同一高级筛选弹出面板。 +- 有效筛选仅显示为可移除的紧凑标签,不再重复展示「当前筛选」标题。 +- 清除搜索、清除筛选分别使用输入清除图标和统一的「全部清除」操作。 + +### 批量操作 + +- 未选择任务时不展示批量工具栏。 +- 选择任务后,在列表上方固定显示选择数量、完成、删除或回收站恢复/彻底删除操作。 +- 危险操作继续使用确认对话框,工具栏关闭后保留当前筛选和滚动位置。 + +## 任务编辑器 + +- 新建或编辑统一使用右侧抽屉,抽屉包含标题、关闭按钮、表单和固定底部操作区。 +- 新建时首个可编辑字段自动获得焦点;关闭后焦点返回触发按钮或任务行。 +- 保存成功后关闭抽屉并显示短暂 Toast;保存失败时抽屉保持打开,在相关字段或操作区展示错误。 +- 存在未保存修改时,关闭抽屉、切换页面、退出或关闭窗口均沿用现有放弃修改确认。 +- 抽屉打开时使用焦点约束,`Escape` 触发关闭流程,而不是直接丢弃内容。 + +## 设置页面 + +### 页面结构 + +- 页面内部使用 `200 px` 分类导航和单一内容列。 +- 分类导航包含:账户与外观、账户安全、连接、窗口、数据与更新、同步恢复、高级维护。 +- 一次只显示当前分类,各面板保持挂载以保留未保存的连接草稿;切换分类不会滚动到长页面中的锚点。 +- 页面顶部只展示「设置」和自动保存状态;删除重复副标题和自动保存说明。 + +### 信息分级 + +- 常用设置直接展示,并在修改后自动保存。 +- API、WebSocket、自定义端点、更新技术详情和诊断明细放入折叠区域。 +- 备份导入、清理本地数据、重试同步和元数据重命名按业务分组,不嵌套多层卡片。 +- 危险操作使用独立分隔区和危险色按钮,说明只在操作附近出现。 +- 成功保存使用短暂 Toast;连接失败、导入失败和同步恢复失败在对应设置区域内持续显示。 + +## 状态与反馈 + +### 正常状态 + +- 已同步:仅显示侧栏绿色状态点。 +- 正在同步:侧栏状态点动画,不展示全宽提示。 +- 保存、完成、恢复和删除成功:右下角 Toast,默认显示 `1.8 s`;重要批量结果显示 `4.5 s`。 + +### 需要关注 + +- 离线但本地仍可工作:主内容顶部显示一行中性提示,提供重试入口,不遮挡任务操作。 +- 同步失败、耗尽队列或冲突:显示一行警示提示,包含问题数量和「查看详情」。 +- 会话过期:保留当前工作区状态,进入现有重新登录流程。 +- 表单和连接错误:在发生错误的局部区域显示,错误消失前不自动隐藏。 + +同一个问题只展示一个最高优先级入口,避免侧栏状态、页面健康条和设置提示重复出现。 + +## 组件与职责 + +- `App.vue`:认证、一级视图切换、全局未保存保护和应用骨架编排。 +- 应用侧栏组件:导航、账户菜单和聚合后的同步状态入口。 +- 全局状态提示组件:只渲染需要用户关注的离线、失败或冲突状态。 +- `TodayView.vue`:今天任务分组、快捷添加和编辑抽屉状态。 +- `TaskView.vue`:搜索、筛选、选择、批量操作和编辑抽屉状态。 +- `SettingsView.vue`:设置分类选择、分类内容编排和局部反馈。 +- `TaskEditor.vue`、任务行和现有设置面板继续负责各自业务交互,不把业务逻辑移入纯布局组件。 + +同步状态继续来自 `useSyncStore`,任务数据继续来自 `useTaskStore`。重设计只改变状态的聚合和呈现方式,不增加第二份状态源。 + +## 响应式规则 + +### `>= 1100 px` + +- 使用 `184 px` 完整侧栏。 +- 主内容最大宽度 `1080 px`。 +- 编辑器使用 `440 px` 右侧抽屉。 + +### `800 px - 1099 px` + +- 侧栏收窄为 `72 px` 图标导航,使用 Tooltip 提供名称。 +- 主内容占满剩余空间,保持最小 `24 px` 页面边距。 +- 编辑器宽度不超过窗口宽度的 `56%`,且不超过 `440 px`。 + +### `< 800 px` + +- 使用 `64 px` 图标导航。 +- 工具行允许分为两行,但搜索、主操作和任务标题不得截断关键内容。 +- 编辑器作为覆盖式面板,占据除导航栏外的全部可用宽度。 +- 筛选与行内次要操作进入菜单,页面不得出现横向滚动。 + +应用窗口最小尺寸沿用 Electron 现有约束;视觉验收覆盖 `1440 x 1000`、`1024 x 768` 和 `800 x 700`。 + +## 可访问性与国际化 + +- 所有图标按钮提供可访问名称和 Tooltip。 +- 焦点样式在浅色、深色和暖色主题中均达到可见对比度。 +- 导航、菜单、抽屉、对话框、筛选和任务完成控件可完全使用键盘操作。 +- 动态异常提示使用 `aria-live="polite"`;阻止保存的错误与字段建立语义关联。 +- 中文和英文长文本均允许换行;按钮和固定列不依赖视口宽度缩放字体。 +- 颜色不作为状态的唯一表达方式,状态同时提供图标或文字。 + +## 测试与验收 + +### 自动化验证 + +- 为同步状态聚合、提示优先级、筛选显示和设置分类切换增加行为测试。 +- 更新现有桌面 UX 静态守门,验证正常同步提示不再常驻、编辑抽屉仍保留未保存保护。 +- 运行桌面单元测试、类型检查和生产构建。 +- 运行仓库现有全部静态守门,确保重设计未破坏安全、同步、备份和发布约束。 + +### 浏览器与桌面验收 + +- 在 `1440 x 1000`、`1024 x 768` 和 `800 x 700` 下截图检查布局。 +- 检查今天、全部任务、设置、空状态、长标题、长标签、筛选、批量选择和编辑抽屉。 +- 模拟已同步、同步中、离线、同步失败、冲突和会话过期状态。 +- 验证键盘焦点顺序、抽屉焦点恢复、`Escape` 行为和减少动画偏好。 +- 确认无文字遮挡、控件重叠、意外布局跳动和横向滚动。 + +## 完成标准 + +- 主工作区不再常驻正常同步说明、重复健康状态或成功反馈。 +- 今天页面首屏直接呈现任务与快捷添加,主要操作无需滚动。 +- 全部任务的搜索、常用筛选和新建入口在首屏可用,低频筛选按需展开。 +- 设置页一次只显示一个分类,高级和诊断内容默认收起。 +- 所有原有任务、同步、备份、更新、账号和设置能力仍可访问。 +- 自动化验证全部通过,3 个目标视口的实际截图通过人工检查。 diff --git a/docs/sync-design.md b/docs/sync-design.md index 1ce0a27..1332eaf 100644 --- a/docs/sync-design.md +++ b/docs/sync-design.md @@ -89,6 +89,8 @@ TaskBridge 使用「HTTP 同步 + WebSocket 通知」的架构。 这条规则的回归测试在 `backend/tests/test_sync.py::test_sync_push_rejects_reused_idempotency_key_with_different_payload`,用于确保非法重放不会退化成数据库唯一约束错误。 +Web 离线队列直接重放 `POST /api/v1/tasks` 时使用 `client_request_id`:队列项首次创建时生成,后续网络重试始终复用。服务端按 `user_id + client_request_id` 去重,并保存首次创建数据的摘要;相同数据返回原任务,不同数据返回 409。客户端收到非冲突永久失败时,只阻塞同一任务的依赖操作,其他无依赖队列项仍继续上传,并提供重试或放弃入口。 + ## 拉取流程 `GET /api/v1/sync/pull?last_sync_time=...` 返回服务端在该时间之后变化的任务。 @@ -131,10 +133,21 @@ TaskBridge 使用「HTTP 同步 + WebSocket 通知」的架构。 - 收到 WebSocket 通知后调用 `GET /api/v1/sync/pull`。 - 同步完成后通知主窗口和悬浮窗刷新。 +## 会话失效与本机工作区 + +本机工作区不是独立账号体系。用户必须至少成功登录并同步一次,客户端才有可恢复的服务器、用户身份和本机任务缓存。 + +- Refresh Token 被服务器拒绝时,Windows 和 Android 只清除失效 Token,保留当前服务器与用户对应的工作区身份;Web 清除会话 Token,但保留不含凭据的最小身份摘要。 +- Windows 和 Android 登录页提供“进入本机工作区”,Web 登录页提供“继续离线使用”。进入后可以查看、创建和修改本机任务,但不得启动同步 Worker、HTTP 上传或 WebSocket。 +- 用户点击“登录并同步”并通过同一服务器账号重新认证后,客户端才恢复队列上传和远端拉取。 +- 主动退出会清除工作区身份;切换服务器也必须清除旧身份,避免把任务写入错误的服务器工作区。 +- 不存在已初始化缓存时不显示本机入口,避免把空工作区误认为已恢复数据。 + ## 异常处理 - **网络失败:** 保留本地同步队列,稍后重试。 - **Token 过期:** 先刷新 Token,再重试同步。 +- **Refresh Token 终止失效:** 保留本地任务和队列,清除失效会话并进入重新登录状态;重新认证前不得继续上传。 - **设备未注册:** 先调用设备注册接口。 - **版本冲突:** 标记为 `conflict`,等待用户处理。 - **服务端失败:** 增加尝试次数,避免无限快速重试。 diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index dbd4a2d..9a60e42 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -1,23 +1,167 @@ # 常见问题 -本文档收集 TaskBridge 本地开发、构建、发布和部署时最常见的问题。 +本文档按用户遇到问题的顺序组织:先处理服务器地址和登录,再处理同步、本机数据,最后才是开发、构建和发布排障。 + +## 登录或检查连接失败 + +先确认客户端填写的是服务器根地址,而不是接口路径: + +```text +http://example.com:8080 +``` + +不要在普通服务器地址里填写 `/api/v1/` 或 `/ws/sync`。客户端会自动派生请求地址和同步连接地址。 + +常见处理方式: + +1. 确认后端 `/ready` 可用。 +2. 确认客户端服务器地址和部署者提供的地址一致。 +3. 如果使用桌面端,桌面端可以在设置页直接修改服务器地址。 +4. 如果使用 Android,登录 / 注册页的「连接设置」和设置页都可以修改服务器地址。 +5. 如果安装包自带的默认地址不对,先在客户端覆盖地址;后续发布时再重新设置构建变量并构建安装包。 + +使用 Release Compose 时,Web/PWA、Windows 和 Android 的服务器地址都应为 `http://<服务器>:8080`。只有开发直连后端或部署者明确提供独立 API 端点时才使用 `8000`。 + +发布版会把构建时注入的地址作为默认值;用户在客户端保存自己的服务器地址后,会覆盖这个默认值。 + +## HTTP / WS 与 HTTPS / WSS + +TaskBridge 客户端支持明文和加密两种连接: + +```text +http://example.com:8080 +https://example.com +ws://example.com:8080/ws/sync +wss://example.com/ws/sync +``` + +Release workflow 和客户端构建不会因为端点使用 `http://` 或 `ws://` 而失败。公网环境建议使用 HTTPS / WSS 反向代理;本机试用、内网或明确接受明文传输的部署可以继续使用 HTTP / WS。 + +普通 Web 页面在明文 HTTP 下可以使用。Service Worker 和“安装为应用”需要浏览器认可的安全上下文,通常只有 HTTPS、`localhost` 和 `127.0.0.1` 满足;局域网 IP 的 HTTP 页面无法安装 PWA 时,不代表 API 或 WS 连接失败。 + +Web/PWA 还需要后端允许当前浏览器来源。本机试用可以用: + +```text +WEB_CORS_ORIGINS=* +``` + +长期部署请写明确来源,不要使用通配来源: + +```text +WEB_CORS_ORIGINS=http://taskbridge.example.com +WEB_CORS_ORIGINS=https://taskbridge.example.com +``` + +## 手机无法访问本机后端 + +对手机来说,`127.0.0.1` 是手机自己,不是电脑。 + +- Android 模拟器访问 Release Compose:`http://10.0.2.2:8080` +- 真机访问 Release Compose:使用电脑地址,例如 `http://192.168.1.10:8080` +- Windows 桌面端访问本机 Release Compose:`http://127.0.0.1:8080` + +仅在不使用 Release Compose、需要开发直连后端时,启动命令才是: + +```powershell +uvicorn app.main:app --host 0.0.0.0 --port 8000 +``` + +如果仍然无法访问,检查系统防火墙、路由器隔离、代理软件和容器端口映射。 + +## Web 页面打不开或显示 502 + +Release Compose 默认由 `web` 容器提供 `8080` 入口: + +```text +http://127.0.0.1:8080 +http://127.0.0.1:8080/ready +``` + +先运行 `docker compose -f docker-compose.release.yml ps`,确认 `web`、`api`、`mysql` 和 `redis` 都已启动。页面能打开但 API 显示 502 时,再查看 `docker compose -f docker-compose.release.yml logs web api`;通常是 API 尚未健康、数据库密码不一致或迁移失败。 + +## 客户端显示本机工作区入口 + +这表示当前没有有效登录会话,但设备仍保留当前服务器和上次账号对应的任务缓存。可以先进入本机任务继续记录;恢复联网后点击“登录并同步”,使用同一账号登录后才会上传待同步修改。 + +本机模式不会保存或恢复 Token,也不会启动 WebSocket、后台 Worker 或上传队列。主动退出、切换服务器或清除此设备数据后,该入口会消失;普通断网、服务器暂不可用或会话自然失效不会删除任务缓存。 + +Windows 和 Android 会在刷新会话被服务器拒绝时保留本机工作区。Web 还需要满足下面的浏览器缓存条件: + +如果入口没有出现,请依次确认: + +1. 当前登录页填写的服务器地址与创建缓存时是同一套服务;地址会先规范化,再与离线身份摘要中的 `api_base_url` 比较。 +2. 这个账号至少成功加载或创建过任务,使对应 IndexedDB 写入 `cache_ready` 标记。 +3. 浏览器没有清除站点数据,也没有处于禁止 IndexedDB 的隐私模式。 + +升级自旧版本时,Web/PWA 会把仅按用户 ID 命名的旧数据库迁移到当前服务器工作区。迁移过程中不要同时打开多个旧版本页面操作同一份缓存。 + +## 高级连接设置什么时候需要改 + +大多数用户只需要填写服务器地址。只有下面情况才需要展开高级连接设置: + +- 反向代理把 API 和同步连接放在不同路径或域名。 +- 旧安装包内置地址不正确,需要临时覆盖请求地址。 +- 部署者明确给了独立的请求地址或同步连接地址。 + +高级连接中,请求地址应以 `http://` 或 `https://` 开头;同步连接地址应以 `ws://` 或 `wss://` 开头。 + +## 同步状态需要处理 + +如果任务列表上方或设置页提示「同步问题」,按这个顺序处理: + +1. 先刷新同步状态。 +2. 如果有待同步任务,保持客户端在线等待自动同步。 +3. 如果有同步失败任务,先确认服务器地址仍然可用,再重试同步。 +4. 如果出现冲突,比较「这台设备」和「同步来的版本」,选择要保留的一份。 +5. 在问题没有处理完之前,不要清除此设备数据。 + +## 清除这台设备数据前要确认什么 + +清除这台设备数据只会删除当前设备上的登录状态、本机缓存和等待同步的修改,不会删除服务器上的任务。 + +清除前先确认: + +- 没有待同步任务。 +- 没有同步失败任务。 +- 没有未处理冲突。 +- 已经导出本机备份,或确认本机缓存可以丢弃。 + +不确定时先导出本机备份,再做清除。 + +## 导出诊断和备份 + +排障时优先导出普通用户能理解的材料: + +- 当前服务器地址。 +- 同步状态截图。 +- 任务冲突截图。 +- 本机备份文件。 +- 客户端版本号。 + +Android 可在“设置 -> 偏好 -> 关于”查看已安装版本并打开最新 Release;Windows 桌面端可在设置页查看版本信息。 + +只有维护者要求时,再打开技术信息或错误上报。技术信息用于定位连接、同步队列、请求路径和客户端版本,不需要普通用户长期打开。 + +普通用户排障到这里就够了。下面的内容面向维护者、部署者和构建发布人员,只有在处理 Release、Android 构建、桌面端原生模块或容器启动问题时才需要继续阅读。 ## GitHub Release 页面没有产物 -发布流程分为 4 类 job: +发布流程分为 6 类 job: -- Prepare GitHub Release +- Prepare Release +- Build self-hosting bundle - Build Android APK - Build Windows installer - Build and publish backend image +- Update GitHub Release -只有 Android、Desktop 和 Docker 都成功后,最后的 Release job 才会更新完整说明。若某个构建 job 失败,Release 页面可能没有 APK、安装包或镜像说明。 +所有 job 成功后,最后的 Release job 才会更新说明。部署包和容器镜像不依赖客户端签名;没有 Android 或 Windows 签名 Secrets 时,对应客户端 job 会完成测试但省略下载文件。 处理方式: 1. 打开失败的 GitHub Actions run。 2. 先看 Android、Desktop、Docker 哪个 job 失败。 -3. 修复后重新运行同一个 workflow,或重新推送同一个版本标签前先删除旧标签和旧 Release。 +3. 修复后重新运行同一个 workflow。Release job 会先删除同标签旧资产,再上传当前构建,并检查部署 ZIP、`SHA256SUMS.txt` 和禁止资产。 ## Android Release 构建失败 @@ -28,13 +172,13 @@ Release APK 不会回退到 debug signing。需要已签名产物时,请确认 - `ANDROID_KEY_ALIAS` - `ANDROID_KEY_PASSWORD` -本地临时实验请使用 debug 构建。未配置签名 Secrets 时,GitHub Release 会生成明确标注的 unsigned release APK,不要把它当作已签名正式产物: +本地临时实验请使用 debug 构建: ```powershell .\gradlew.bat :app:assembleDebug ``` -Release workflow 允许发布 unsigned Android artifacts,但文件名会包含 `android-unsigned.apk`。如果只配置了部分 Android 签名 Secrets,workflow 会失败。 +本地 `assembleRelease` 仍可生成 unsigned release APK 供开发验证,但 unsigned 客户端不应进入公开 Release。未配置全部 Android 签名 Secrets 时,公开 Release 会省略 APK;如果只配置了部分 Secrets,workflow 会失败并列出缺失项。Windows 同理,未签名 EXE 不会上传。 ## Gradle JDK 配置无效 @@ -53,10 +197,10 @@ Release workflow 允许发布 unsigned Android artifacts,但文件名会包含 - Release 构建时没有设置 `TASKBRIDGE_BASE_URL` 和 `TASKBRIDGE_WS_URL`。 - 用户机器上安装的是旧构建,内置的默认后端地址仍指向旧环境。 -- API 地址缺少 `/api/v1/`。 -- WebSocket 地址没有指向 `/ws/sync`。 +- 请求地址缺少 `/api/v1/`。 +- 同步连接地址没有指向 `/ws/sync`。 -桌面端可以在设置页直接修改服务器地址。Android 端可以在登录 / 注册页的「连接设置」或设置页修改服务器地址。发布版会把构建时注入的地址作为默认值;如果默认值错了,可以先在客户端覆盖,后续发布再重新设置仓库 Variables 并构建安装包: +如果只改普通连接,优先在设置页修改服务器地址。发布版会把构建时注入的地址作为默认值;如果默认值错了,可以先在客户端覆盖,后续发布再重新设置仓库 Variables 并构建安装包: ```text TASKBRIDGE_BASE_URL=http://example.com:8000/api/v1/ @@ -105,17 +249,3 @@ alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8000 ``` 不同平台的表单格式不一样,关键是不要让最终命令变成 `"[sh,"`。 - -## 手机无法访问本机后端 - -对手机来说,`127.0.0.1` 是手机自己,不是电脑。 - -- Android 模拟器访问电脑:`http://10.0.2.2:8000` -- 真机访问电脑:使用电脑局域网 IP,例如 `http://192.168.1.10:8000` -- Windows 桌面端访问本机:`http://127.0.0.1:8000` - -后端启动时如需允许局域网访问,请使用: - -```powershell -uvicorn app.main:app --host 0.0.0.0 --port 8000 -``` diff --git a/docs/user-quick-start.md b/docs/user-quick-start.md index d4ea244..4364347 100644 --- a/docs/user-quick-start.md +++ b/docs/user-quick-start.md @@ -17,14 +17,18 @@ 如果管理员或部署者已经给你 TaskBridge 服务器地址和账号,按这个顺序开始: 1. Web/PWA:打开部署者提供的 TaskBridge 访问地址。 -2. Windows 或 Android:从 [GitHub Releases](https://github.com/27xk/TaskBridge/releases) 下载 Windows 安装包或 Android APK。 +2. Windows 或 Android:从 [GitHub Releases](https://github.com/27xk/TaskBridge/releases) 下载该版本提供的已签名 Windows 安装包或 Android APK。名称带 `unsigned` 的历史文件不适合普通用户;当前版本没有对应客户端文件时先使用 Web/PWA。 3. 在登录页填写管理员或部署者给你的服务器地址。 4. 直接登录同一个账号。登录会自动检查连接;“检查连接”只用于排查服务器地址。 登录成功后,默认从“今日”开始。新建任务时只填标题就能保存,也可以直接输入“明天下午 3 点写周报 #工作 P3”让客户端识别时间、标签和优先级。 +编辑任务时,标题是默认主输入;备注和清单按“添加备注和清单”展开,日期、提醒和优先级按“时间与安排”展开,标签、重复和模板等低频设置按“更多”展开。设置页里,服务器地址和常用账号信息优先展示,项目 / 标签维护和技术信息默认收起,需要排障或整理分类时再打开。 + ## 没有服务器地址 +如果你不是部署者,请先向管理员或部署者索取服务器地址和账号。拿到地址后,回到“已有服务器地址”路径登录。 + 如果只是想先在自己的电脑上试用,选择本机试用,按 [部署说明的本机试用](../deploy/README.md#本机试用) 启动服务,再回到客户端填写说明里给出的服务器地址。 如果准备长期使用、多人使用或公网访问,选择长期自托管,先按 [部署说明](../deploy/README.md) 完成生产自托管,再把正式服务器地址填到客户端。 @@ -33,16 +37,45 @@ 电脑上本机试用时,电脑客户端可以直接使用本机地址;手机或另一台电脑访问时,需要填写运行后端那台设备的地址。 +- Web:打开 `http://127.0.0.1:8080`,登录页的服务器地址也填写 `http://127.0.0.1:8080`。该入口会统一处理页面和同步连接。 +- Windows 桌面端:填写 `http://127.0.0.1:8080`。 +- Android 模拟器:填写 `http://10.0.2.2:8080`。 +- Android 真机或另一台电脑:填写 `http://<运行服务电脑的局域网 IP>:8080`。 + +Release Compose 的 `8080` 是普通客户端的统一入口。`8000` 只用于开发或高级直连,只有部署者明确要求时才使用。 + 如果手机连不上,先确认手机和电脑在同一个局域网,再确认客户端填写的是电脑的地址,而不是手机自己的地址。 ## 离线使用 -第一次使用需要先登录一次。登录并同步过后,离线时可以继续查看、新增、编辑和完成任务,这些修改会本机保存。网络恢复后,TaskBridge 会自动同步到其他设备。 +第一次使用需要先登录并完成一次任务同步。登录会话仍然有效时,离线可以继续查看、新增、编辑和完成任务,这些修改会保存在本机。 + +Windows 和 Android 的会话自然失效后,登录页会提供本机工作区入口。进入后仍可查看和修改缓存任务,但不会连接服务器或上传修改;点击“登录并同步”,使用同一服务器上的账号登录后才会上传待同步修改。主动点击“退出登录”会清除工作区身份,不等同于会话自然失效。 + +Web/PWA 关闭浏览器或会话过期后,需要重新登录才会恢复在线同步。再次打开登录页时,只有当前填写的服务器与上次同步来源一致、且该账号的本机缓存已经完整初始化,才会显示“继续离线使用”: + +1. 点击“继续离线使用”打开上次账号在这台设备上的任务。 +2. 离线修改会继续进入本机待同步队列。 +3. 网络恢复后点击顶部“登录并同步”,使用同一账号重新登录。 +4. 登录成功后,TaskBridge 才会把待同步修改发送到服务器。 + +如果预期看到恢复入口却没有显示,请先确认服务器地址没有切换到另一套 TaskBridge 服务。仅保存过账号信息、但从未完成任务缓存初始化时,不会提供空的恢复入口。 + +主动点击“退出”或“清除此设备数据”会移除对应客户端的离线恢复身份;普通的断网、服务器暂不可用或会话过期不会删除本机任务缓存。 如果同一条任务在多台设备上都改过,客户端会显示冲突对比。先比较“这台设备”和“同步来的版本”,再选择保留哪一版。 +通过局域网明文 HTTP 打开的 Web 页面仍可正常管理任务。浏览器可能不允许这类页面安装为 PWA 或使用离线页面缓存;这不影响普通网页和已安装客户端连接。 + ## 数据与备份 导出备份会保存这台设备上的本机任务数据,适合换设备、切账号或排查前留一份副本。 清除这台设备数据只会删除当前设备上的登录状态、本机缓存和等待同步的修改,不会删除服务器上的任务。清除前先确认没有待同步、同步失败或冲突的任务;不确定时先导出备份。 + +## 常见入口 + +- 修改服务器地址:登录页或设置页。 +- 查看同步问题:出现异常时使用任务区提示,或打开设置页里的“同步问题”;同步正常时不会重复占用任务布局。 +- 整理项目 / 标签:设置页展开项目和标签维护。 +- 连接失败、手机访问本机服务、清除本机数据前检查:参考 [常见问题](./troubleshooting.md)。 diff --git a/package.json b/package.json index 67a0e4b..887b7d0 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,12 @@ { "name": "taskbridge", - "version": "0.1.7", + "version": "0.1.8", "private": true, "type": "module", "description": "TaskBridge workspace toolchain constraints", "packageManager": "npm@10.9.0", "scripts": { + "check:user-experience": "npm --prefix desktop run check:user-experience", "test:web": "node --test \"web/tests/**/*.test.mjs\"" }, "engines": { diff --git a/scripts/README.md b/scripts/README.md index 4900ae2..9111cb2 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -12,7 +12,7 @@ | `backend/tools/openapi_contract.py` | 从 FastAPI 运行时导出 `shared/openapi.taskbridge.v1.json`,并检查 OpenAPI 契约漂移 | | `npm run test:web` | 运行 Web/PWA Node 单元测试,覆盖离线核心真实行为 | | `check-web-client.mjs` | 检查 Web/PWA 静态客户端、任务列表 cursor 分页、后端 CORS 和 CI 守门是否齐全 | -| `check-web-offline-first.mjs` | 检查 Web/PWA IndexedDB 任务缓存、离线队列、恢复在线同步和文档/CI 守门 | +| `check-web-offline-first.mjs` | 检查 Web/PWA 是否按服务器与用户隔离 IndexedDB、校验缓存就绪标记、迁移旧库、对账远端快照,并守卫离线队列、文档和 CI | | `check-web-offline-core.mjs` | 执行 Web/PWA 离线核心行为回归,覆盖本地任务、视图过滤、冲突状态和本地统计 | | `smoke-web-client.mjs` | 启动随机端口本地 HTTP 服务,检查 Web/PWA shell 和静态资源能被真实 HTTP 访问 | | `clean-local.ps1` | 清理本地生成文件和缓存,支持 `-DryRun` 和 `-All` | @@ -77,13 +77,15 @@ python -m tools.openapi_contract --check | `npm run check:desktop-backup` | 检查桌面端备份导出分页、导入错误反馈和统计 | | `npm run check:desktop-theme` | 检查桌面端主题配置、持久化和设置页入口 | | `npm run check:desktop-efficiency` | 检查桌面端设置自愈与恢复通知、提醒去重缓存裁剪以及任务列表排序/索引 | +| `npm run check:ux-priority-polish` | 检查跨端主流程层级、首用引导与高优先级体验约束 | +| `npm run check:user-experience` | 检查 Web、Windows 与 Android 的完整用户体验契约 | | `npm run check:desktop-docs` | 检查桌面端文档和实际连接地址策略一致 | | `npm run check:release-readiness` | 检查 release 产物不会写死局域网地址,并允许 HTTP/HTTPS 与 WS/WSS 端点 | | `npm run check:release-artifacts` | 检查 release 会生成 SHA-256 校验清单 | | `npm run check:desktop-auto-update` | 检查桌面端自动更新依赖、更新入口和 release manifest / blockmap 产物 | | `npm run check:android-data-extraction` | 检查 Android backup / device transfer 排除 Token、设备 ID、本地数据库和导出缓存 | | `npm run check:security-governance` | 检查 `SECURITY.md`、Dependabot、CodeQL、dependency-review、Scorecard 和 Trivy 治理配置 | -| `npm run check:production-hardening` | 检查发布签名/unsigned 分支、备份恢复脚本和 readiness 策略 | +| `npm run check:production-hardening` | 检查公开 Release 仅含签名客户端、备份恢复脚本和 readiness 策略 | | `npm run check:ci-workflows` | 检查 CI / release 是否运行关键守门脚本 | | `npm run check:contract-drift` | 检查后端、桌面端和 Android 字段契约是否漂移 | diff --git a/scripts/check-local.ps1 b/scripts/check-local.ps1 index 3e8f300..1cc3aff 100644 --- a/scripts/check-local.ps1 +++ b/scripts/check-local.ps1 @@ -275,7 +275,9 @@ if (-not (Test-ExternalCommand "npm")) { "check:supply-chain", "check:desktop-auto-update", "check:android-data-extraction", - "check:security-governance" + "check:security-governance", + "check:ux-priority-polish", + "check:user-experience" ) foreach ($check in $desktopChecks) { diff --git a/scripts/check-version-source.mjs b/scripts/check-version-source.mjs index 726073b..42ad595 100644 --- a/scripts/check-version-source.mjs +++ b/scripts/check-version-source.mjs @@ -18,6 +18,11 @@ const [ androidBuildSource, webIndexSource, readmeSource, + developmentDocSource, + backendEnvExampleSource, + backendDockerEnvExampleSource, + deployEnvExampleSource, + deployLocalEnvExampleSource, ciSource, releaseSource, ] = await Promise.all([ @@ -29,6 +34,11 @@ const [ read("android/app/build.gradle.kts"), read("web/index.html"), read("README.md"), + read("docs/development.md"), + read("backend/.env.example"), + read("backend/.env.docker.example"), + read("deploy/.env.example"), + read("deploy/.env.local.example"), read(".github/workflows/ci.yml"), read(".github/workflows/release.yml"), ]); @@ -62,14 +72,29 @@ assert.match( "Web meta taskbridge-version must match VERSION", ); +assert.match( + readmeSource, + /docs\/development\.md/, + "README.md must link to the developer verification guide", +); + for (const [name, source] of [ - ["README.md", readmeSource], + ["docs/development.md", developmentDocSource], [".github/workflows/ci.yml", ciSource], [".github/workflows/release.yml", releaseSource], ]) { assert.match(source, /check-version-source\.mjs/, `${name} must run or document the version source guard`); } +for (const [name, source] of [ + ["backend/.env.example", backendEnvExampleSource], + ["backend/.env.docker.example", backendDockerEnvExampleSource], + ["deploy/.env.example", deployEnvExampleSource], + ["deploy/.env.local.example", deployLocalEnvExampleSource], +]) { + assert.match(source, new RegExp(`^APP_VERSION=${version}$`, "m"), `${name} APP_VERSION must match VERSION`); +} + assert.match( releaseSource, /Release version must match VERSION/, diff --git a/scripts/check-web-client.mjs b/scripts/check-web-client.mjs index ce79da3..98632a8 100644 --- a/scripts/check-web-client.mjs +++ b/scripts/check-web-client.mjs @@ -20,6 +20,8 @@ const requiredFiles = [ "web/self-hosting.html", "web/styles.css", "web/app.js", + "web/startup.js", + "web/guide-language.js", "web/offline-core.js", "web/manifest.webmanifest", "web/sw.js", @@ -28,6 +30,9 @@ const requiredFiles = [ "web/icon-512.png", "web/icon-maskable-512.png", "web/_headers.example", + "deploy/nginx.web.conf", + "deploy/docker-compose.release.yml", + "deploy/.env.example", ]; for (const file of requiredFiles) { @@ -47,12 +52,19 @@ const [ backendEnvSource, architectureSource, readmeSource, + developmentSource, localCheckSource, ciSource, releaseSource, localTrialSource, selfHostingSource, smokeSource, + startupSource, + guideLanguageSource, + nginxWebSource, + releaseComposeSource, + deployEnvSource, + deployReadmeSource, ] = await Promise.all([ readFile(resolve(repoRoot, "web/index.html"), "utf8"), readFile(resolve(repoRoot, "web/app.js"), "utf8"), @@ -66,12 +78,19 @@ const [ readFile(resolve(repoRoot, "backend/.env.example"), "utf8"), readFile(resolve(repoRoot, "docs/architecture.md"), "utf8"), readFile(resolve(repoRoot, "README.md"), "utf8"), + readFile(resolve(repoRoot, "docs/development.md"), "utf8"), readFile(resolve(repoRoot, "scripts/check-local.ps1"), "utf8"), readFile(resolve(repoRoot, ".github/workflows/ci.yml"), "utf8"), readFile(resolve(repoRoot, ".github/workflows/release.yml"), "utf8"), readFile(resolve(repoRoot, "web/local-trial.html"), "utf8"), readFile(resolve(repoRoot, "web/self-hosting.html"), "utf8"), readFile(resolve(repoRoot, "scripts/smoke-web-client.mjs"), "utf8"), + readFile(resolve(repoRoot, "web/startup.js"), "utf8"), + readFile(resolve(repoRoot, "web/guide-language.js"), "utf8"), + readFile(resolve(repoRoot, "deploy/nginx.web.conf"), "utf8"), + readFile(resolve(repoRoot, "deploy/docker-compose.release.yml"), "utf8"), + readFile(resolve(repoRoot, "deploy/.env.example"), "utf8"), + readFile(resolve(repoRoot, "deploy/README.md"), "utf8"), ]); const webVersionMatch = htmlSource.match(//); @@ -82,6 +101,7 @@ for (const token of [ 'rel="manifest"', 'href="./styles.css"', `src="./app.js?v=${webVersion}"`, + `src="./startup.js?v=${webVersion}"`, 'id="serverBaseUrl"', 'id="apiBaseUrl"', 'id="testConnectionButton"', @@ -95,7 +115,20 @@ for (const token of [ assert.match(htmlSource, /id="startupFallback"/, "web must show a startup recovery panel when modules fail to load"); assert.match(htmlSource, /id="clearStartupCacheButton"/, "web startup recovery must let users clear stale offline cache"); -assert.match(htmlSource, /window\.taskBridgeWebReady/, "web startup recovery must be driven by an app-ready marker"); +assert.match(startupSource, /window\.taskBridgeWebReady/, "web startup recovery must be driven by an app-ready marker"); +for (const [name, source] of [ + ["index", htmlSource], + ["local trial", localTrialSource], + ["self-hosting", selfHostingSource], +]) { + assert.doesNotMatch(source, /]*\bsrc=)[^>]*>/i, `${name} page must not use CSP-blocked inline scripts`); +} +assert.match(localTrialSource, new RegExp(`src="\\./guide-language\\.js\\?v=${webVersion}"`)); +assert.match(selfHostingSource, new RegExp(`src="\\./guide-language\\.js\\?v=${webVersion}"`)); +assert.match(guideLanguageSource, /taskbridge\.web\.v1\.language/, "guide pages must share the app language preference"); +assert.match(htmlSource, /id="authModeSwitch"[^>]*role="group"/, "auth mode controls must use button-group semantics"); +assert.doesNotMatch(htmlSource, /role="tab(?:list)?"|aria-selected/, "auth mode controls must not claim an incomplete tab protocol"); +assert.match(appSource, /setAttribute\("aria-pressed"/, "auth mode buttons must announce their pressed state"); assert.match(htmlSource, /id="confirmDialog"/, "web must provide an in-app confirmation dialog"); assert.match(htmlSource, /id="confirmDialogConfirmButton"/, "web confirmation dialog must expose an explicit confirm button"); assert.match(htmlSource, /id="confirmDialogCancelButton"/, "web confirmation dialog must expose an explicit cancel button"); @@ -151,15 +184,13 @@ assert.match( /
-
- - +
+ +
-

- -
- 已有服务器地址就能登录 - 把管理员给你的 TaskBridge 服务器地址填到下方,然后直接登录;客户端会自动检查连接。 - 还没有服务器地址时,再展开下方帮助选择这台电脑试用或长期自托管。 -
- 没有服务器地址? -

普通使用只需要一个服务器地址;下面两种方式分别适合这台电脑试用和长期自托管。

-
- 推荐顺序 -
    -
  1. 先确认服务器地址。
  2. -
  3. 直接登录或注册,客户端会自动检查连接。
  4. -
  5. 检查连接只用于排查服务器地址。
  6. -
-
-
- Docker 本机试用 - 还没有后端时,先打开 Docker 本机试用说明,在后端电脑上启动服务。 - 打开 Docker 本机试用说明 - 同一台电脑保持默认地址 127.0.0.1;手机或另一台电脑访问时,填写运行后端那台电脑的局域网 IP。 - 先在电脑上启动 TaskBridge 后端,再回到这里填写服务器地址并登录。手机访问时填写那台电脑的局域网 IP。 -
-
- 自托管部署 - 如果要自己部署,先打开部署说明完成服务准备,再把服务器地址填到这里。普通用户不需要修改高级连接设置。 - 打开自托管部署说明 - 生产环境关闭公开注册时,请先在后端容器内创建首个账号。 -
-
-
- + + @@ -121,7 +95,7 @@

登录 TaskBridge

设备标识(自动生成) @@ -167,15 +141,28 @@

登录 TaskBridge

登录会自动检查连接;检查连接只用于排查服务器地址。

+ +
+ 没有服务器地址?先确认来源 +
+ 已有服务器地址就能登录 + 把管理员给你的 TaskBridge 服务器地址填到上方,然后直接登录;客户端会自动检查连接。 +

如果你只是使用别人部署好的 TaskBridge,请联系管理员或部署者索取地址;只有本机试用或长期自托管时,再按说明准备服务。

+ +
+
+
+
-
+

新建任务

- 直接填写标题即可,更多属性可稍后展开。 + 直接填写标题即可,日期、提醒和重复可稍后展开。
-

+

- -
- 更多属性 +
+ 添加备注和清单 +
+ +
+ + + 用于拆分任务,已完成的清单项会在编辑时尽量保留。 +
+
+
+
+
+ 时间与安排

计划日期表示哪天要做;截止时间表示最晚完成时间;提醒时间只负责通知。

+
+
+
-
+ -
    +
      @@ -406,36 +445,6 @@

      确认操作

      - + diff --git a/web/local-trial.html b/web/local-trial.html index 671592c..46540fa 100644 --- a/web/local-trial.html +++ b/web/local-trial.html @@ -3,53 +3,90 @@ - TaskBridge Docker 本机试用说明 + TaskBridge 本机试用说明 - -
      + +

      TaskBridge

      -

      Docker 本机试用说明

      -

      在运行后端的电脑上执行这些命令。服务启动后,回到登录页填写服务器地址并登录,客户端会自动检查连接。

      + -
        -
      1. - 下载或解压源码/部署包,并进入部署目录 -

        如果你拿到的是 Release 部署包,先解压到 TaskBridge 目录;如果使用源码包,也只需要进入其中的 deploy 目录。

        -

        macOS / Linux

        -
        cd TaskBridge/deploy
        -

        Windows PowerShell

        -
        cd TaskBridge\deploy
        -
      2. -
      3. - 复制 Docker 本机试用环境 -

        macOS / Linux

        -
        cp .env.local.example .env
        -

        Windows PowerShell

        -
        Copy-Item .env.local.example .env
        -
      4. -
      5. - 启动后端 -
        docker compose -f docker-compose.release.yml up -d
        -
      6. -
      7. - 确认服务可用 -
        curl http://127.0.0.1:8000/ready
        -

        /ready 会检查数据库和 Redis;如果这里不可用,客户端登录通常也不会成功。

        -
      8. -
      9. - 填写地址 -

        同一台电脑使用 http://127.0.0.1:8000。Android 模拟器使用 http://10.0.2.2:8000。手机或另一台电脑使用后端电脑的局域网 IP。

        -
      10. -
      +

      本机试用说明

      +

      这页适合想自己在一台电脑上运行完整 TaskBridge 服务的人。启动后可直接打开 Web,也可以让 Windows 或 Android 客户端连接后端。

      + +
      +

      第一步:先确认你是不是普通使用者

      +

      如果只是使用别人部署好的 TaskBridge,请先找管理员或部署者要服务器地址、账号和 Web/PWA 入口,不需要在自己电脑上运行下面的命令。

      +

      只有你想先在本机试用,或者你就是负责部署的人,才继续准备后端服务。

      +
      + +
      +

      登录页应该填写哪个地址

      +

      Release Compose 提供统一的 8080 入口;手机或另一台电脑访问时,只需把回环地址换成运行服务电脑的局域网 IP。

      +
        +
      • Web/PWA:打开 http://127.0.0.1:8080,登录页也填写 http://127.0.0.1:8080
      • +
      • Windows 桌面端:如果服务运行在同一台电脑,填写 http://127.0.0.1:8080
      • +
      • Android 真机:填写运行服务电脑的局域网 IP,例如 http://192.168.1.23:8080
      • +
      • Android 模拟器:如果服务运行在宿主电脑,填写 http://10.0.2.2:8080
      • +
      +
      + +
      +

      需要自己启动服务时

      +
        +
      1. + 进入部署目录 +

        准备好 TaskBridge 源码/部署包。如果拿到的是 Release 部署包,先解压到 TaskBridge 目录;如果使用源码包,进入其中的 deploy 目录。

        +

        macOS / Linux

        +
        cd TaskBridge/deploy
        +

        Windows PowerShell

        +
        cd TaskBridge\deploy
        +
      2. +
      3. + 复制本机试用环境 +

        macOS / Linux

        +
        cp .env.local.example .env
        +

        Windows PowerShell

        +
        Copy-Item .env.local.example .env
        +
      4. +
      5. + 启动完整服务 +
        docker compose -f docker-compose.release.yml up -d
        +
      6. +
      7. + 确认服务可用 +
        curl http://127.0.0.1:8080/ready
        +

        /ready 返回可用后,打开 http://127.0.0.1:8080 使用 Web;已安装客户端也填写同一个统一入口。

        +
      8. +
      +
      -

      Docker Local Trial

      -

      Run these commands on the backend computer, then return to sign-in and save/test the server address.

      +

      Local Trial

      +

      This page is for people who want to run the complete TaskBridge service on their own computer. Compose serves the Web client and the backend together.

      + +

      Step 1: check whether you are a regular user

      +

      If you are using someone else's TaskBridge service, ask the administrator or deployer for the server address, account, and Web/PWA entry first. You do not need to run the commands below.

      + +

      Which address to enter

      +
        +
      • Web/PWA: open http://127.0.0.1:8080 and use the same address on its sign-in page.
      • +
      • Windows desktop: use http://127.0.0.1:8080 when the service runs on the same computer.
      • +
      • Android phone: use the LAN IP of the computer running the service, for example http://192.168.1.23:8080.
      • +
      • Android emulator: use http://10.0.2.2:8080 when the service runs on the host computer.
      • +
      + +

      Start the backend yourself

      1. - Download or extract the source or deployment package, then enter the deployment directory -

        If you already have a release or deployment package, extract it to the TaskBridge directory first. Git is not required for the local trial.

        + Enter the deployment directory +

        Prepare the TaskBridge source or deployment package. If you already have a release package, extract it to the TaskBridge directory first. Git is not required for a local trial.

        macOS / Linux

        cd TaskBridge/deploy

        Windows PowerShell

        @@ -63,18 +100,19 @@

        Docker Local Trial

        Copy-Item .env.local.example .env
      2. - Start the backend + Start all services
        docker compose -f docker-compose.release.yml up -d
      3. Check readiness -
        curl http://127.0.0.1:8000/ready
        +
        curl http://127.0.0.1:8080/ready
        +

        Then open http://127.0.0.1:8080 for the Web client.

      -

      Use http://127.0.0.1:8000 on the same computer, http://10.0.2.2:8000 from an Android emulator, or the backend computer's LAN IP from a phone or another computer.

      返回登录页 / Back to sign-in
      + diff --git a/web/offline-core.js b/web/offline-core.js index a4f922b..ae58040 100644 --- a/web/offline-core.js +++ b/web/offline-core.js @@ -20,6 +20,149 @@ const TASK_PRIORITY_LABELS = { 5: "最高优先级", }; +export function normalizeOfflineApiBaseUrl(value) { + const trimmed = String(value ?? "").trim(); + if (!trimmed) { + throw new Error("Offline API address is required"); + } + const url = new URL(trimmed); + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new Error("Offline API address must use HTTP or HTTPS"); + } + url.username = ""; + url.password = ""; + url.search = ""; + url.hash = ""; + const path = url.pathname.replace(/\/+$/, ""); + url.pathname = path.endsWith("/api/v1") ? path : `${path}/api/v1`; + return url.toString().replace(/\/+$/, ""); +} + +export function buildOfflineWorkspaceKey(apiBaseUrl, userIdInput) { + const userId = Number(userIdInput); + if (!Number.isFinite(userId) || userId <= 0) { + throw new Error("Offline workspace requires a positive user id"); + } + const serverNamespace = encodeURIComponent(normalizeOfflineApiBaseUrl(apiBaseUrl)); + return `server.${serverNamespace}.user.${Math.trunc(userId)}`; +} + +export function buildOfflineDatabaseName(storagePrefix, apiBaseUrl, userId) { + return `${storagePrefix}.offline.${buildOfflineWorkspaceKey(apiBaseUrl, userId)}`; +} + +export function buildTaskDraftStorageKey(storagePrefix, apiBaseUrl, userIdInput) { + const userId = Number(userIdInput); + if (!Number.isFinite(userId) || userId <= 0) { + throw new Error("Task draft storage requires a positive user id"); + } + const origin = new URL(normalizeOfflineApiBaseUrl(apiBaseUrl)).origin; + return `${storagePrefix}.taskDraft.origin.${encodeURIComponent(origin)}.user.${Math.trunc(userId)}`; +} + +export function clearAccountScopedInputs(inputs) { + for (const input of inputs || []) { + if (input && "value" in input) input.value = ""; + } +} + +export function withClientRequestId(payload, idFactory = () => crypto.randomUUID()) { + const existing = String(payload?.client_request_id || "").trim(); + if (existing) return { ...payload, client_request_id: existing }; + const generated = String(idFactory()).trim(); + if (!generated) throw new Error("Client request id is required"); + return { ...payload, client_request_id: generated }; +} + +export async function processIndependentMutationQueue({ + listRecords, + processRecord, + markFailed, +}) { + const processedQueueIds = new Set(); + const blockedTaskIds = new Set(); + const failedQueueIds = []; + + const initialRecords = [...await listRecords()].sort( + (left, right) => Number(left.offline_queue_id) - Number(right.offline_queue_id), + ); + for (const record of initialRecords) { + if (!isPersistedMutationFailure(record)) continue; + processedQueueIds.add(String(record.offline_queue_id)); + blockedTaskIds.add(mutationTaskKey(record)); + } + + while (true) { + const records = [...await listRecords()].sort( + (left, right) => Number(left.offline_queue_id) - Number(right.offline_queue_id), + ); + const record = records.find((candidate) => { + const queueId = String(candidate.offline_queue_id); + return !processedQueueIds.has(queueId) && !blockedTaskIds.has(mutationTaskKey(candidate)); + }); + if (!record) break; + + const queueId = String(record.offline_queue_id); + const taskKey = mutationTaskKey(record); + processedQueueIds.add(queueId); + try { + await processRecord(record); + } catch (error) { + await markFailed(record, error); + blockedTaskIds.add(taskKey); + failedQueueIds.push(queueId); + } + } + + return { + blockedTaskIds: [...blockedTaskIds], + failedQueueIds, + }; +} + +function isPersistedMutationFailure(record) { + return record?.offline_status === "sync_failed" || record?.offline_status === "conflict"; +} + +function mutationTaskKey(record) { + const taskId = record?.task_id; + return taskId === null || taskId === undefined + ? `queue:${record?.offline_queue_id}` + : String(taskId); +} + +export function normalizeOfflineProfile(value, fallbackApiBaseUrl = "") { + if (!value || typeof value !== "object") return null; + const id = Number(value.id); + if (!Number.isFinite(id) || id <= 0) return null; + const username = String(value.username ?? "").trim(); + const email = String(value.email ?? "").trim(); + let apiBaseUrl; + try { + apiBaseUrl = normalizeOfflineApiBaseUrl( + value.api_base_url ?? value.apiBaseUrl ?? fallbackApiBaseUrl, + ); + } catch { + return null; + } + return { + id: Math.trunc(id), + username, + email, + api_base_url: apiBaseUrl, + }; +} + +export function isOfflineProfileForApi(profile, apiBaseUrl) { + const normalizedProfile = normalizeOfflineProfile(profile); + if (!normalizedProfile) return false; + try { + return normalizedProfile.api_base_url === normalizeOfflineApiBaseUrl(apiBaseUrl); + } catch { + return false; + } +} + export function makeOfflineTask(payload, options = {}) { const nowDate = normalizeNow(options.now); const now = nowDate.toISOString(); @@ -126,6 +269,10 @@ export function normalizeRemoteTaskForOffline(task) { }; } +export function hasOfflineQueueId(value) { + return typeof value === "number" && Number.isInteger(value) && value > 0; +} + export function canResolveTaskConflict(task) { const id = Number(task?.id); return task?.offline_status === "conflict" && Number.isFinite(id) && id > 0; @@ -166,7 +313,7 @@ export function matchesTaskView(task, options = {}) { return false; } if (view === "pending") { - return Boolean(task.offline_status || Number.isFinite(task.offline_queue_id)); + return Boolean(task.offline_status || hasOfflineQueueId(task.offline_queue_id)); } if (view === "conflict") { return task.offline_status === "conflict"; @@ -184,9 +331,44 @@ export function matchesTaskView(task, options = {}) { return isOverdueTask(task, options.now, options); } if (view === "high") { - return Number(task.priority || 0) >= 3; + return !isCompletedTask(task) && Number(task.priority || 0) >= 3; + } + return !isCompletedTask(task); +} + +export function mapTaskViewForServer(view) { + if (view === "pending" || view === "conflict") return null; + return view === "high" ? "high_priority" : view; +} + +export function reconcileCachedTaskSnapshot(cachedTasks, remoteTasks, viewContext = {}) { + const remoteById = new Map( + remoteTasks.map((task) => [String(task.id), task]), + ); + const reconciled = []; + + for (const cachedTask of cachedTasks) { + const taskId = String(cachedTask.id); + const remoteTask = remoteById.get(taskId); + if (cachedTask.offline_status || hasOfflineQueueId(cachedTask.offline_queue_id)) { + reconciled.push(cachedTask); + remoteById.delete(taskId); + continue; + } + if (remoteTask) { + reconciled.push(normalizeRemoteTaskForOffline(remoteTask)); + remoteById.delete(taskId); + continue; + } + if (!matchesTaskView(cachedTask, viewContext)) { + reconciled.push(cachedTask); + } } - return task.status !== "completed"; + + for (const remoteTask of remoteById.values()) { + reconciled.push(normalizeRemoteTaskForOffline(remoteTask)); + } + return reconciled; } export function compareCachedTasks(left, right, options = {}) { @@ -220,19 +402,19 @@ export function buildLocalMeta(tasks, nowInput = new Date(), options = {}) { counts.trash += 1; continue; } - if (task.offline_status || Number.isFinite(task.offline_queue_id)) { + if (task.offline_status || hasOfflineQueueId(task.offline_queue_id)) { counts.pending += 1; } if (task.offline_status === "conflict") { counts.conflict += 1; } - if (Number(task.priority || 0) >= 3) { - counts.high += 1; - } - if (task.status === "completed") { + if (isCompletedTask(task)) { counts.completed += 1; continue; } + if (Number(task.priority || 0) >= 3) { + counts.high += 1; + } counts.open += 1; if ((task.list_type || "inbox") === "inbox") { counts.inbox += 1; @@ -254,7 +436,7 @@ export function getTaskViewLabel(view) { export function getTaskStatusLabel(task) { if (task.offline_status === "conflict") return "同步冲突"; if (task.offline_error) return "同步失败"; - if (task.offline_status || Number.isFinite(task.offline_queue_id)) return "待同步"; + if (task.offline_status || hasOfflineQueueId(task.offline_queue_id)) return "待同步"; if (task.is_deleted) return "已删除"; if (task.status === "completed" || task.status === "done") return "已完成"; return "进行中"; @@ -319,19 +501,31 @@ function getTaskSortBucket(task, nowInput = new Date(), options = {}) { } function isTodayTask(task, nowInput = new Date(), options = {}) { + if (isCompletedTask(task)) return false; + const now = normalizeNow(nowInput); if ((task.list_type || "inbox") === "today") { return true; } - const today = formatLocalDateKey(normalizeNow(nowInput), options); - const dueDate = task.due_time ? formatLocalDateKey(task.due_time, options) : ""; - return task.planned_date === today || dueDate === today; + const today = formatLocalDateKey(now, options); + const dueTime = task.due_at ?? task.due_time; + const reminderTime = task.reminder_at ?? task.remind_time; + const due = parseDate(dueTime); + const dueDate = due ? formatLocalDateKey(due, options) : ""; + const reminderDate = reminderTime ? formatLocalDateKey(reminderTime, options) : ""; + return ( + task.planned_date === today || + dueDate === today || + reminderDate === today || + Boolean(due && due.getTime() < now.getTime()) + ); } function isOverdueTask(task, nowInput = new Date(), options = {}) { - if (task.status === "completed") return false; + if (isCompletedTask(task)) return false; const now = normalizeNow(nowInput); - if (task.due_time) { - const due = new Date(task.due_time); + const dueTime = task.due_at ?? task.due_time; + if (dueTime) { + const due = new Date(dueTime); return !Number.isNaN(due.getTime()) && due.getTime() < now.getTime(); } if (task.planned_date) { @@ -341,12 +535,103 @@ function isOverdueTask(task, nowInput = new Date(), options = {}) { } function isSnoozedTask(task, nowInput = new Date()) { - if (task.status === "completed" || !task.snoozed_until) return false; + if (isCompletedTask(task) || !task.snoozed_until) return false; const now = normalizeNow(nowInput); const snoozedUntil = new Date(task.snoozed_until); return !Number.isNaN(snoozedUntil.getTime()) && snoozedUntil.getTime() > now.getTime(); } +function isCompletedTask(task) { + return task?.status === "completed" || task?.status === "done"; +} + +export function createLatestRequestGate() { + let sequence = 0; + return { + begin() { + sequence += 1; + return sequence; + }, + isCurrent(requestSequence) { + return requestSequence === sequence; + }, + }; +} + +export function isMixedContentApiUrl(pageProtocol, apiBaseUrl) { + if (pageProtocol !== "https:") return false; + try { + return new URL(apiBaseUrl).protocol === "http:"; + } catch { + return false; + } +} + +export function isAuthHealthUsable(syncStatus) { + return syncStatus?.status === "ready" || syncStatus?.status === "degraded"; +} + +export function shouldShowConnectionBadge(hasLocalWorkspace, syncStatus) { + return Boolean(hasLocalWorkspace || syncStatus); +} + +export function resetEndpointScopedConnectionState( + state, + nextServerBaseUrl, + nextApiBaseUrl, +) { + const endpointChanged = + String(nextServerBaseUrl ?? "") !== String(state.serverBaseUrl ?? "") || + String(nextApiBaseUrl ?? "") !== String(state.apiBaseUrl ?? ""); + if (!endpointChanged) return false; + + state.registrationStatusKnown = false; + state.registrationEnabled = false; + state.syncStatus = null; + return true; +} + +export function isTerminalRefreshStatus(status) { + return status === 401 || status === 403; +} + +export function getTaskReminderAt(task) { + if (!task || task.is_deleted || isCompletedTask(task)) return null; + for (const value of [task.reminder_at, task.remind_time, task.due_at, task.due_time]) { + if (value && parseDate(value)) return value; + } + return null; +} + +export function buildTaskNotificationUrl(taskId, pageUrl) { + const target = new URL("./", pageUrl); + target.searchParams.set("task", String(taskId)); + return target.toString(); +} + +export function normalizeBrowserTimeZone(value) { + const timeZone = String(value || "").trim(); + if (!timeZone) return "UTC"; + try { + new Intl.DateTimeFormat("en", { timeZone }).format(); + return timeZone; + } catch { + return "UTC"; + } +} + +export function buildPasswordChangePayload(currentPassword, newPassword, confirmation) { + if (!currentPassword) throw new Error("Current password is required"); + if (String(newPassword || "").length < 8) { + throw new Error("New password must be at least 8 characters"); + } + if (newPassword !== confirmation) throw new Error("Password confirmation does not match"); + return { + current_password: currentPassword, + new_password: newPassword, + }; +} + function formatTaskDateTime(value) { const date = new Date(value); if (Number.isNaN(date.getTime())) return String(value); diff --git a/web/self-hosting.html b/web/self-hosting.html index 0d80c9b..d5c2672 100644 --- a/web/self-hosting.html +++ b/web/self-hosting.html @@ -6,13 +6,36 @@ TaskBridge 自托管部署说明 - -
      + +

      TaskBridge

      -

      自托管部署说明

      -

      这页用于从 Web/PWA 首屏直接完成部署准备。正式对外使用时,请先准备域名、HTTPS 反向代理、强密码和明确的 Web 跨域来源。

      + -
        +

        自托管部署说明

        +

        这页用于从 Web/PWA 首屏直接完成部署准备。正式对外使用时,请先准备服务器地址、强密码和明确的 Web 跨域来源;公网建议使用 HTTPS / WSS 反向代理。

        + +
        +

        第一步:先确认你是不是部署者

        +
        +
        +

        我只是使用别人部署好的 TaskBridge

        +

        请先向管理员或部署者索取服务器地址和账号,然后回到登录页填写地址。普通使用不需要执行下面的部署命令。

        + 返回登录页填写地址 +
        +
        +

        我是部署者,准备自托管

        +

        继续阅读下面步骤,完成服务启动、健康检查和首个账号创建,再把服务器地址发给需要登录的用户。

        +
        +
        +
        + +
        1. 下载项目并进入部署目录
          git clone https://github.com/27xk/TaskBridge.git
          @@ -31,7 +54,7 @@ 

          自托管部署说明

          DATABASE_URL=mysql+pymysql://taskbridge:replace-with-a-strong-password@mysql:3306/taskbridge REGISTRATION_ENABLED=false WEB_CORS_ORIGINS=https://taskbridge.example.com
          -

          DATABASE_URL 里的密码必须和 MYSQL_PASSWORD 一致。WEB_CORS_ORIGINS 应写成真实 Web/PWA 地址,例如 https://taskbridge.example.com,不要在生产环境继续使用通配来源。

          +

          DATABASE_URL 里的密码必须和 MYSQL_PASSWORD 一致。Compose 默认把 Web 和 API 绑定到本机,再由反向代理对外提供统一域名;WEB_CORS_ORIGINS 不要在生产环境继续使用通配来源。

        2. 启动服务 @@ -39,8 +62,8 @@

          自托管部署说明

        3. 检查健康状态 -
          curl http://127.0.0.1:8000/ready
          -

          /ready 返回可用后,再在客户端填写服务器根地址;如果通过 Nginx 或 Caddy 暴露 HTTPS,请填写反向代理后的域名。

          +
          curl http://127.0.0.1:8080/ready
          +

          /ready 返回可用后,可打开 http://127.0.0.1:8080 验证 Web。通过 Nginx 或 Caddy 对外提供服务时,请把统一域名代理到该 Web 入口,并在客户端填写该域名。

        4. 创建首个账号 @@ -49,9 +72,25 @@

          自托管部署说明

        +

        明文 HTTP/WS 可用于本机和受控内网。非本机 HTTP 地址通常不能安装 PWA 或注册 Service Worker;普通 Web 页面和任务连接仍可使用。

        +

        Self-hosting

        -

        Copy .env.example, set strong secrets, pin WEB_CORS_ORIGINS to your HTTPS Web origin, start Compose, and verify /ready before signing in.

        +

        Copy .env.example, set strong secrets, start Compose, and verify the Web gateway's /ready endpoint before signing in.

        +
        +

        Step 1: check whether you are the deployer

        +
        +
        +

        If you use someone else's TaskBridge service

        +

        Ask the administrator or deployer for the server address and account first. You do not need to run the deployment commands below.

        + Back to sign-in with that address +
        +
        +

        If you are deploying TaskBridge

        +

        Continue with the steps below, start the service, check readiness, and create the first account before sharing the server address.

        +
        +
        +
        1. Download the project and enter the deployment directory @@ -76,16 +115,20 @@

          Self-hosting

        2. Start services and check readiness
          docker compose -f docker-compose.release.yml up -d
          -curl http://127.0.0.1:8000/ready
          +curl http://127.0.0.1:8080/ready +

          Open http://127.0.0.1:8080 to verify the Web client, or proxy your public domain to that gateway.

        3. Create the first account
          docker compose -f docker-compose.release.yml exec api python -m tools.create_user --username owner --email owner@example.com
        +

        Plain HTTP/WS works on local and controlled networks. Browsers may block PWA installation and Service Workers on non-localhost HTTP origins, while the regular Web page still works.

        - 返回登录页 / Back to sign-in + 返回登录页 + Back to sign-in
      + diff --git a/web/startup.js b/web/startup.js new file mode 100644 index 0000000..c76cdb1 --- /dev/null +++ b/web/startup.js @@ -0,0 +1,29 @@ +window.taskBridgeWebReady ??= false; + +function showStartupFallback() { + if (window.taskBridgeWebReady) return; + const panel = document.getElementById("startupFallback"); + if (panel) panel.hidden = false; +} + +function reloadPage() { + window.location.reload(); +} + +async function clearOfflineCacheAndReload() { + if ("serviceWorker" in navigator) { + const registrations = await navigator.serviceWorker.getRegistrations(); + await Promise.all(registrations.map((registration) => registration.unregister())); + } + if ("caches" in window) { + const keys = await caches.keys(); + await Promise.all(keys.map((key) => caches.delete(key))); + } + reloadPage(); +} + +document.getElementById("reloadStartupButton")?.addEventListener("click", reloadPage); +document.getElementById("clearStartupCacheButton")?.addEventListener("click", () => { + void clearOfflineCacheAndReload(); +}); +window.setTimeout(showStartupFallback, 4000); diff --git a/web/styles.css b/web/styles.css index 4cabace..a27c5a7 100644 --- a/web/styles.css +++ b/web/styles.css @@ -150,6 +150,12 @@ textarea { min-width: 0; } +.sidebar-details-body { + display: grid; + gap: 0.65rem; + margin-top: 0.75rem; +} + .task-list-actions { flex-wrap: wrap; } @@ -229,12 +235,14 @@ textarea { .layout { flex: 1; + min-width: 0; padding: 1rem; } .workspace { display: grid; grid-template-columns: 300px minmax(0, 1fr); + grid-template-areas: "sidebar main"; gap: 1rem; } @@ -246,6 +254,8 @@ textarea { } .auth-panel { + width: min(100%, 760px); + min-width: 0; max-width: 760px; margin: 0 auto; padding: 1.25rem; @@ -256,7 +266,12 @@ textarea { padding: 1rem; } +.sidebar { + grid-area: sidebar; +} + .main-panel { + grid-area: main; display: grid; align-content: start; gap: 1rem; @@ -346,26 +361,22 @@ textarea { font-size: 0.95rem; } -.setup-checklist { - display: grid; - gap: 0.35rem; - margin-top: 0.35rem; - padding: 0.65rem 0.75rem; - border-radius: calc(var(--radius) - 2px); - background: var(--surface); - color: var(--text); +.server-setup-help { + display: block; + margin: 1rem 0 0; + background: var(--surface-alt); } -.setup-checklist > span { +.server-setup-help > summary { + cursor: pointer; + color: var(--text); font-weight: 700; } -.setup-checklist ol { +.server-setup-help-body { display: grid; - gap: 0.25rem; - margin: 0; - padding-left: 1.2rem; - color: var(--muted); + gap: 0.55rem; + margin-top: 0.75rem; } .first-run-details { @@ -384,17 +395,16 @@ textarea { font-weight: 700; } -.first-run-details .first-run-details { - margin-top: 0.45rem; - border-color: var(--border); - background: var(--surface); - color: var(--muted); -} - .first-run-details .text-button { justify-self: start; } +.first-run-link-row { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + .command-snippet { margin: 0.35rem 0 0; overflow-x: auto; @@ -411,14 +421,38 @@ textarea { } .local-trial-page { + display: grid; + align-items: stretch; + justify-content: normal; + gap: 1rem; max-width: 760px; margin: 4rem auto; } +.local-trial-page h1, +.local-trial-page h2, +.local-trial-page h3 { + margin: 0; + color: var(--text); +} + +.local-trial-page > p, +.local-trial-intro > p { + margin: 0; + color: var(--muted); + line-height: 1.65; +} + +.local-trial-page > section, +.local-trial-intro { + display: grid; + gap: 0.55rem; +} + .local-trial-steps { display: grid; gap: 0.85rem; - margin: 1.2rem 0; + margin: 0.25rem 0 0; padding-left: 1.2rem; } @@ -430,14 +464,82 @@ textarea { margin: 0.35rem 0 0; } +.local-trial-addresses { + display: grid; + gap: 0.45rem; + margin: 0.25rem 0 0; + padding-left: 1.2rem; + color: var(--muted); + line-height: 1.6; +} + +.local-trial-addresses strong { + color: var(--text); +} + +.local-trial-choice-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(16rem, 100%), 1fr)); + gap: 0.75rem; +} + +.local-trial-choice { + display: grid; + align-content: start; + gap: 0.45rem; + min-width: 0; + padding: 0.85rem; + border: 1px solid var(--border); + border-radius: var(--radius); + background: color-mix(in srgb, var(--surface-alt) 72%, var(--surface)); +} + +.local-trial-choice h3, +.local-trial-choice h4 { + margin: 0; + font-size: 0.98rem; +} + +.local-trial-choice p { + margin: 0; + color: var(--muted); +} + +.local-trial-choice .secondary-button { + justify-self: start; +} + +.local-trial-page .primary-button { + justify-self: start; +} + +.local-trial-language-row { + justify-self: start; + min-width: min(100%, 16rem); +} + +.local-trial-page[data-language="zh-CN"] [lang="en"] { + display: none; +} + +.local-trial-page[data-language="en-US"] [data-lang="zh-CN"] { + display: none; +} + .eyebrow { margin: 0 0 0.25rem; color: var(--accent); - text-transform: uppercase; - letter-spacing: 0.08em; + letter-spacing: 0; font-size: 0.78rem; } +.sidebar h2 { + font-size: 1.125rem; + line-height: 1.35; + letter-spacing: 0; + white-space: nowrap; +} + .segmented { display: inline-flex; padding: 0.25rem; @@ -512,6 +614,21 @@ textarea { display: none; } +.offline-resume-panel { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + align-items: center; + gap: 0.75rem; + padding: 0.75rem; + border: 1px solid color-mix(in srgb, var(--warning) 32%, var(--border)); + border-radius: var(--radius); + background: color-mix(in srgb, var(--warning) 8%, var(--surface)); +} + +.offline-resume-panel[hidden] { + display: none; +} + .task-form { grid-template-columns: repeat(2, minmax(0, 1fr)); } @@ -538,6 +655,7 @@ textarea { background: color-mix(in srgb, var(--surface-alt) 78%, var(--surface)); } +.task-body-fields, .advanced-task-fields { padding: 0.75rem; border: 1px solid var(--border); @@ -545,12 +663,36 @@ textarea { background: var(--surface-alt); } +.task-body-fields summary, .advanced-task-fields summary { cursor: pointer; color: var(--text); font-weight: 700; } +.task-body-fields:not([open]), +.advanced-task-fields:not([open]) { + padding: 0; + border-color: transparent; + background: transparent; +} + +.task-body-fields:not([open]) summary, +.advanced-task-fields:not([open]) summary { + display: inline-flex; + align-items: center; + min-height: 2.5rem; + padding: 0.55rem 0.75rem; + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--muted); + background: var(--surface-alt); +} + +.task-body-grid { + margin-top: 0.75rem; +} + .advanced-task-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); margin-top: 0.75rem; @@ -787,9 +929,73 @@ textarea { } .mobile-quick-actions { + grid-area: mobile-actions; display: none; } +.account-security-form, +.account-session-tools { + display: grid; + gap: 0.65rem; + margin-top: 0.75rem; +} + +.account-security-form button, +.account-session-tools > button, +.notification-settings button { + width: 100%; +} + +.compact-section-head h3, +.notification-settings h3 { + margin: 0; + font-size: 0.92rem; +} + +.session-list { + display: grid; + gap: 0.45rem; + margin: 0; + padding: 0; + list-style: none; +} + +.session-list li { + display: grid; + gap: 0.25rem; + padding: 0.55rem 0.65rem; + border: 1px solid var(--border); + border-radius: calc(var(--radius) - 2px); + background: var(--surface); + overflow-wrap: anywhere; +} + +.session-list small { + color: var(--muted); +} + +.session-list .text-button { + justify-self: start; +} + +.session-current-badge { + display: inline-flex; + margin-left: 0.3rem; + vertical-align: middle; +} + +.notification-settings { + display: grid; + gap: 0.55rem; + margin-top: 0.75rem; + padding-top: 0.75rem; + border-top: 1px solid var(--border); +} + +.notification-settings .sidebar-note { + margin-top: 0; +} + .support-tools-panel { display: grid; gap: 0.8rem; @@ -871,6 +1077,15 @@ textarea { margin-top: 0.75rem; } +.local-data-action-group + .local-data-action-group { + margin-top: 0.75rem; +} + +.local-data-danger-zone { + padding-top: 0.75rem; + border-top: 1px solid color-mix(in srgb, var(--danger) 28%, var(--border)); +} + .sidebar-actions button, .file-button { width: 100%; @@ -967,6 +1182,28 @@ textarea { display: none; } +.offline-task-limit-notice { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 0.55rem; + padding: 0.65rem 0.8rem; + border: 1px solid color-mix(in srgb, var(--warning) 36%, var(--border)); + border-radius: var(--radius); + background: color-mix(in srgb, var(--warning) 10%, var(--surface)); + color: var(--text); +} + +.offline-task-limit-notice > span { + flex: 1 1 16rem; + min-width: 0; +} + +.offline-task-limit-notice[hidden] { + display: none; +} + .task-sync-health-bar { display: flex; flex-wrap: wrap; @@ -1061,6 +1298,38 @@ textarea { background: color-mix(in srgb, var(--warning) 9%, var(--surface-alt)); } +.task-item:focus-visible, +.task-item.is-notification-target { + outline: 3px solid color-mix(in srgb, var(--primary) 35%, transparent); + outline-offset: 2px; + border-color: color-mix(in srgb, var(--primary) 65%, var(--border)); +} + +.sync-failed-recovery { + display: grid; + gap: 0.45rem; + margin: 0.75rem 0; + padding: 0.75rem; + border: 1px solid color-mix(in srgb, var(--danger) 38%, var(--border)); + border-radius: var(--radius); + background: color-mix(in srgb, var(--danger) 7%, var(--surface)); +} + +.sync-failed-recovery p { + margin: 0; + color: var(--muted); + font-size: 0.875rem; + line-height: 1.5; +} + +.sync-failed-recovery .sync-failed-recovery__error { + color: var(--danger); +} + +.sync-failed-recovery__actions { + margin-top: 0.2rem; +} + .task-trash-bulk-actions { display: flex; align-items: center; @@ -1430,14 +1699,10 @@ textarea { @media (max-width: 960px) { .workspace { grid-template-columns: 1fr; - } - - .main-panel { - order: 2; - } - - .sidebar { - order: 1; + grid-template-areas: + "mobile-actions" + "main" + "sidebar"; } .mobile-quick-actions { @@ -1452,6 +1717,13 @@ textarea { } @media (max-width: 760px) { + button, + input, + select, + summary { + min-height: 44px; + } + .topbar, .panel-heading, .section-head, @@ -1511,4 +1783,29 @@ textarea { .layout { padding: 0.75rem; } + + .auth-panel { + padding: 0.9rem; + } + + .auth-panel .panel-heading { + gap: 0.65rem; + margin-bottom: 0.75rem; + } + + .auth-panel .form-grid { + gap: 0.7rem; + } + + .password-field { + grid-template-columns: minmax(0, 1fr); + } + + .password-toggle-button { + justify-self: start; + } + + .offline-resume-panel { + grid-template-columns: 1fr; + } } diff --git a/web/sw.js b/web/sw.js index ff34abc..bd7a6c6 100644 --- a/web/sw.js +++ b/web/sw.js @@ -1,4 +1,4 @@ -const WEB_VERSION = "0.1.7"; +const WEB_VERSION = "0.1.8"; const CACHE_NAME = `taskbridge-web-shell-v${WEB_VERSION}`; const SHELL_ASSETS = [ "./", @@ -7,6 +7,8 @@ const SHELL_ASSETS = [ "./self-hosting.html", "./styles.css", `./app.js?v=${WEB_VERSION}`, + `./startup.js?v=${WEB_VERSION}`, + `./guide-language.js?v=${WEB_VERSION}`, `./offline-core.js?v=${WEB_VERSION}`, "./icon.svg", "./icon-192.png", @@ -42,7 +44,9 @@ self.addEventListener("fetch", (event) => { if (url.origin !== self.location.origin) return; if (event.request.mode === "navigate") { event.respondWith( - fetch(request).catch(() => caches.match("./index.html")), + fetch(request).catch(async () => ( + (await caches.match(request)) || caches.match("./index.html") + )), ); return; } @@ -57,11 +61,50 @@ self.addEventListener("fetch", (event) => { ); }); +self.addEventListener("notificationclick", (event) => { + event.notification.close(); + event.waitUntil(openNotificationTarget(event.notification.data?.url)); +}); + +async function openNotificationTarget(rawUrl) { + const fallbackUrl = new URL("./", self.registration.scope); + let targetUrl; + try { + targetUrl = new URL(rawUrl || fallbackUrl, self.registration.scope); + } catch { + targetUrl = fallbackUrl; + } + if (targetUrl.origin !== self.location.origin) { + targetUrl = fallbackUrl; + } + + const windowClients = await self.clients.matchAll({ + type: "window", + includeUncontrolled: true, + }); + const existingClient = windowClients.find((client) => { + try { + return new URL(client.url).origin === targetUrl.origin; + } catch { + return false; + } + }); + if (existingClient) { + if (typeof existingClient.navigate === "function") { + await existingClient.navigate(targetUrl.toString()); + } + return existingClient.focus(); + } + return self.clients.openWindow(targetUrl.toString()); +} + function isVersionedShellAssetRequest(request) { const url = new URL(request.url); return ( url.searchParams.get("v") === WEB_VERSION && - (url.pathname.endsWith("/app.js") || url.pathname.endsWith("/offline-core.js")) + ["/app.js", "/startup.js", "/guide-language.js", "/offline-core.js"].some((path) => + url.pathname.endsWith(path), + ) ); } diff --git a/web/tests/client-behavior.test.mjs b/web/tests/client-behavior.test.mjs new file mode 100644 index 0000000..f4b734e --- /dev/null +++ b/web/tests/client-behavior.test.mjs @@ -0,0 +1,294 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import * as core from "../offline-core.js"; + +function requireCoreFunction(name) { + assert.equal(typeof core[name], "function", `${name} must be exported`); + return core[name]; +} + +test("task draft keys isolate users and origins without splitting paths on one server", () => { + const buildTaskDraftStorageKey = requireCoreFunction("buildTaskDraftStorageKey"); + const first = buildTaskDraftStorageKey( + "taskbridge.web.v1", + "https://tasks.example.com/team-a/api/v1", + 42, + ); + const sameOrigin = buildTaskDraftStorageKey( + "taskbridge.web.v1", + "https://tasks.example.com/team-b/api/v1", + 42, + ); + const otherUser = buildTaskDraftStorageKey( + "taskbridge.web.v1", + "https://tasks.example.com/team-a/api/v1", + 43, + ); + const otherOrigin = buildTaskDraftStorageKey( + "taskbridge.web.v1", + "https://other.example.com/api/v1", + 42, + ); + + assert.equal(first, sameOrigin); + assert.notEqual(first, otherUser); + assert.notEqual(first, otherOrigin); + assert.match(first, /taskDraft\.origin\..+\.user\.42$/); +}); + +test("account-scoped input reset clears credentials and task text", () => { + const clearAccountScopedInputs = requireCoreFunction("clearAccountScopedInputs"); + const inputs = [ + { value: "alice" }, + { value: "secret-password" }, + { value: "private task draft" }, + ]; + + clearAccountScopedInputs(inputs); + + assert.deepEqual(inputs.map((input) => input.value), ["", "", ""]); +}); + +test("offline create payload gets one stable client request id reused on replay", () => { + const withClientRequestId = requireCoreFunction("withClientRequestId"); + let generated = 0; + const first = withClientRequestId( + { title: "Created offline" }, + () => `request-${++generated}`, + ); + const replay = withClientRequestId(first, () => `request-${++generated}`); + + assert.equal(first.client_request_id, "request-1"); + assert.equal(replay.client_request_id, "request-1"); + assert.equal(generated, 1); +}); + +test("a permanent mutation failure skips only later records for the same task", async () => { + const processIndependentMutationQueue = requireCoreFunction("processIndependentMutationQueue"); + const records = [ + { offline_queue_id: 1, task_id: 10 }, + { offline_queue_id: 2, task_id: 10 }, + { offline_queue_id: 3, task_id: 20 }, + ]; + const attempted = []; + const failed = []; + + const result = await processIndependentMutationQueue({ + listRecords: async () => records, + processRecord: async (record) => { + attempted.push(record.offline_queue_id); + if (record.offline_queue_id === 1) throw new Error("permanent failure"); + }, + markFailed: async (record) => failed.push(record.offline_queue_id), + }); + + assert.deepEqual(attempted, [1, 3]); + assert.deepEqual(failed, [1]); + assert.deepEqual(result.blockedTaskIds, ["10"]); +}); + +test("queue processing reloads records after create id remapping", async () => { + const processIndependentMutationQueue = requireCoreFunction("processIndependentMutationQueue"); + let records = [ + { offline_queue_id: 1, task_id: -1, action: "create" }, + { offline_queue_id: 2, task_id: -1, action: "update", expected_version: 0 }, + ]; + const attemptedTaskIds = []; + + await processIndependentMutationQueue({ + listRecords: async () => records.map((record) => ({ ...record })), + processRecord: async (record) => { + attemptedTaskIds.push(record.task_id); + if (record.action === "create") { + records = records + .filter((candidate) => candidate.offline_queue_id !== record.offline_queue_id) + .map((candidate) => ({ ...candidate, task_id: 99, expected_version: 1 })); + } + }, + markFailed: async () => {}, + }); + + assert.deepEqual(attemptedTaskIds, [-1, 99]); +}); + +test("a persisted failed mutation blocks only its task until the user retries it", async () => { + const processIndependentMutationQueue = requireCoreFunction("processIndependentMutationQueue"); + const records = [ + { offline_queue_id: 1, task_id: 10, offline_status: "sync_failed" }, + { offline_queue_id: 2, task_id: 10, offline_status: "pending" }, + { offline_queue_id: 3, task_id: 20, offline_status: "pending" }, + ]; + const attempted = []; + + const result = await processIndependentMutationQueue({ + listRecords: async () => records, + processRecord: async (record) => attempted.push(record.offline_queue_id), + markFailed: async () => {}, + }); + + assert.deepEqual(attempted, [3]); + assert.deepEqual(result.blockedTaskIds, ["10"]); +}); + +test("server task views map high priority and keep local sync views local", () => { + const mapTaskViewForServer = requireCoreFunction("mapTaskViewForServer"); + + assert.equal(mapTaskViewForServer("high"), "high_priority"); + assert.equal(mapTaskViewForServer("pending"), null); + assert.equal(mapTaskViewForServer("conflict"), null); + assert.equal(mapTaskViewForServer("today"), "today"); +}); + +test("today view matches open overdue and reminder-only tasks but excludes completed work", () => { + const now = new Date("2026-06-05T10:00:00.000Z"); + const options = { view: "today", search: "", now, timeZone: "Asia/Shanghai" }; + + assert.equal(core.matchesTaskView({ + id: 1, + title: "Overdue", + status: "open", + due_time: "2026-06-04T09:00:00.000Z", + }, options), true); + assert.equal(core.matchesTaskView({ + id: 2, + title: "Reminder only", + status: "open", + reminder_at: "2026-06-05T11:00:00.000Z", + }, options), true); + assert.equal(core.matchesTaskView({ + id: 3, + title: "Already done", + status: "completed", + list_type: "today", + }, options), false); + assert.equal(core.buildLocalMeta([{ + id: 4, + title: "Reminder only", + status: "open", + reminder_at: "2026-06-05T11:00:00.000Z", + }], now, { timeZone: "Asia/Shanghai" }).counts.today, 1); +}); + +test("high priority view excludes completed tasks", () => { + const completed = { + id: 1, + title: "Done urgent task", + status: "completed", + priority: 5, + }; + + assert.equal(core.matchesTaskView(completed, { view: "high", search: "" }), false); + assert.equal(core.buildLocalMeta([completed]).counts.high, 0); +}); + +test("latest request gate rejects stale task responses", () => { + const createLatestRequestGate = requireCoreFunction("createLatestRequestGate"); + const gate = createLatestRequestGate(); + const first = gate.begin(); + const second = gate.begin(); + + assert.equal(gate.isCurrent(first), false); + assert.equal(gate.isCurrent(second), true); +}); + +test("latest request gate ignores a delayed result after an endpoint switch", async () => { + const createLatestRequestGate = requireCoreFunction("createLatestRequestGate"); + const gate = createLatestRequestGate(); + const state = { endpoint: "A", syncStatus: null }; + let resolveDelayed; + const delayed = new Promise((resolve) => { + resolveDelayed = resolve; + }); + const requestSequence = gate.begin(); + const applyDelayedResult = delayed.then((result) => { + if (gate.isCurrent(requestSequence)) state.syncStatus = result; + }); + + state.endpoint = "B"; + state.syncStatus = null; + gate.begin(); + resolveDelayed({ status: "ready", source: "A" }); + await applyDelayedResult; + + assert.equal(state.endpoint, "B"); + assert.equal(state.syncStatus, null); +}); + +test("mixed content detection distinguishes blocked HTTPS to HTTP requests", () => { + const isMixedContentApiUrl = requireCoreFunction("isMixedContentApiUrl"); + + assert.equal(isMixedContentApiUrl("https:", "http://192.168.1.20:8000/api/v1"), true); + assert.equal(isMixedContentApiUrl("http:", "http://192.168.1.20:8000/api/v1"), false); + assert.equal(isMixedContentApiUrl("https:", "https://tasks.example.com/api/v1"), false); +}); + +test("degraded health remains usable for authentication", () => { + const isAuthHealthUsable = requireCoreFunction("isAuthHealthUsable"); + + assert.equal(isAuthHealthUsable({ status: "ready" }), true); + assert.equal(isAuthHealthUsable({ status: "degraded" }), true); + assert.equal(isAuthHealthUsable(null), false); +}); + +test("only terminal refresh statuses force reauthentication", () => { + const isTerminalRefreshStatus = requireCoreFunction("isTerminalRefreshStatus"); + + assert.equal(isTerminalRefreshStatus(401), true); + assert.equal(isTerminalRefreshStatus(403), true); + assert.equal(isTerminalRefreshStatus(429), false); + assert.equal(isTerminalRefreshStatus(500), false); +}); + +test("reminder scheduling prefers reminder_at and falls back to due_at", () => { + const getTaskReminderAt = requireCoreFunction("getTaskReminderAt"); + + assert.equal(getTaskReminderAt({ + status: "open", + reminder_at: "2026-06-05T11:00:00.000Z", + due_at: "2026-06-05T12:00:00.000Z", + }), "2026-06-05T11:00:00.000Z"); + assert.equal(getTaskReminderAt({ + status: "open", + due_at: "2026-06-05T12:00:00.000Z", + }), "2026-06-05T12:00:00.000Z"); + assert.equal(getTaskReminderAt({ + status: "completed", + reminder_at: "2026-06-05T11:00:00.000Z", + }), null); +}); + +test("notification task URLs preserve the app path and identify the task", () => { + const buildTaskNotificationUrl = requireCoreFunction("buildTaskNotificationUrl"); + + assert.equal( + buildTaskNotificationUrl(42, "https://tasks.example.com/web/index.html"), + "https://tasks.example.com/web/?task=42", + ); +}); + +test("browser time zones are validated before being sent to task endpoints", () => { + const normalizeBrowserTimeZone = requireCoreFunction("normalizeBrowserTimeZone"); + + assert.equal(normalizeBrowserTimeZone("Asia/Shanghai"), "Asia/Shanghai"); + assert.equal(normalizeBrowserTimeZone("America/New_York"), "America/New_York"); + assert.equal(normalizeBrowserTimeZone("Not/AZone"), "UTC"); + assert.equal(normalizeBrowserTimeZone(""), "UTC"); +}); + +test("password change payload requires matching new passwords", () => { + const buildPasswordChangePayload = requireCoreFunction("buildPasswordChangePayload"); + + assert.deepEqual( + buildPasswordChangePayload("old-password", "new-password", "new-password"), + { current_password: "old-password", new_password: "new-password" }, + ); + assert.throws( + () => buildPasswordChangePayload("old-password", "new-password", "different"), + /password confirmation/i, + ); + assert.throws( + () => buildPasswordChangePayload("old-password", "short", "short"), + /at least 8/i, + ); +}); diff --git a/web/tests/connection-race.test.mjs b/web/tests/connection-race.test.mjs new file mode 100644 index 0000000..c54dead --- /dev/null +++ b/web/tests/connection-race.test.mjs @@ -0,0 +1,219 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; + +import { createLatestRequestGate } from "../offline-core.js"; + +const appSource = await readFile(new URL("../app.js", import.meta.url), "utf8"); + +function extractTestConnectionSource() { + const start = appSource.indexOf("async function testConnection()"); + const end = appSource.indexOf("\nfunction persistTokens", start); + assert.ok(start >= 0 && end > start, "testConnection source must be extractable"); + return appSource.slice(start, end); +} + +function extractLoadRegistrationStatusSource() { + const start = appSource.indexOf("async function loadRegistrationStatus()"); + const end = appSource.indexOf("\nfunction render()", start); + assert.ok(start >= 0 && end > start, "loadRegistrationStatus source must be extractable"); + return appSource.slice(start, end); +} + +function createDeferred() { + let resolve; + let reject; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, reject, resolve }; +} + +function createTestConnectionHarness() { + const effects = { + authModeRenders: 0, + badgeRenders: 0, + busy: [], + feedback: [], + healthRenders: 0, + registrationLoads: 0, + registrationRequests: 0, + reveals: 0, + statusMessages: [], + syncRenders: 0, + toasts: [], + }; + const state = { + registrationEnabled: false, + registrationStatusKnown: false, + syncStatus: null, + }; + const controls = { + applyServerBaseUrlToApi() {}, + async apiRequest() { + return { status: "ready" }; + }, + savePreferenceInputs() {}, + }; + const dependencies = { + applyServerBaseUrlToApi: (...args) => controls.applyServerBaseUrlToApi(...args), + savePreferenceInputs: (...args) => controls.savePreferenceInputs(...args), + connectionRequestGate: createLatestRequestGate(), + registrationRequestGate: createLatestRequestGate(), + setBusy: (isBusy) => effects.busy.push(isBusy), + setStatus: (_node, message) => effects.statusMessages.push(message), + nodes: { authMessage: {} }, + t: (key) => key, + apiRequest: (...args) => { + if (args[0] === "/auth/registration") { + effects.registrationLoads += 1; + effects.registrationRequests += 1; + } + return controls.apiRequest(...args); + }, + state, + updateAuthMode: () => { + effects.authModeRenders += 1; + }, + renderSyncStatus: () => { + effects.syncRenders += 1; + }, + renderTaskSyncHealthBar: () => { + effects.healthRenders += 1; + }, + updateConnectionBadge: () => { + effects.badgeRenders += 1; + }, + hasSession: () => false, + registrationDisabledHelp: () => "registration disabled", + toast: (message) => effects.toasts.push(message), + showSyncStatusFeedback: (...args) => effects.feedback.push(args[0] ?? null), + isConnectionReadyForAuth: () => state.syncStatus?.status === "ready", + revealAdvancedConnectionSettings: () => { + effects.reveals += 1; + }, + }; + const dependencyNames = Object.keys(dependencies); + const factory = Function( + ...dependencyNames, + `"use strict"; +let activeConnectionRequestSequence = null; +${extractLoadRegistrationStatusSource()} +${extractTestConnectionSource()} +return { testConnection };`, + ); + const { testConnection } = factory(...Object.values(dependencies)); + return { controls, effects, state, testConnection }; +} + +test("a newer preflight error owns connection UI and blocks an older delayed result", async () => { + const harness = createTestConnectionHarness(); + const delayedResponse = createDeferred(); + const preflightError = new Error("invalid replacement endpoint"); + let preflightCalls = 0; + + harness.controls.applyServerBaseUrlToApi = () => { + preflightCalls += 1; + if (preflightCalls === 2) throw preflightError; + }; + harness.controls.apiRequest = () => delayedResponse.promise; + + const olderRequest = harness.testConnection(); + const newerResult = await harness.testConnection(); + + assert.equal(newerResult, false); + assert.deepEqual(harness.effects.busy, [true, false]); + assert.deepEqual(harness.effects.feedback, [preflightError]); + assert.equal(harness.effects.badgeRenders, 1); + assert.equal(harness.effects.registrationLoads, 0); + assert.deepEqual(harness.effects.toasts, []); + assert.equal(harness.state.syncStatus, null); + assert.equal(harness.state.registrationStatusKnown, false); + + delayedResponse.resolve({ source: "old-endpoint", status: "ready" }); + const olderResult = await olderRequest; + + assert.equal(olderResult, false); + assert.deepEqual(harness.effects.busy, [true, false]); + assert.deepEqual(harness.effects.feedback, [preflightError]); + assert.equal(harness.effects.badgeRenders, 1); + assert.equal(harness.effects.registrationLoads, 0); + assert.deepEqual(harness.effects.toasts, []); + assert.equal(harness.state.syncStatus, null); + assert.equal(harness.state.registrationStatusKnown, false); +}); + +test("an older delayed failure cannot overwrite newer preflight feedback", async () => { + const harness = createTestConnectionHarness(); + const delayedResponse = createDeferred(); + const preflightError = new Error("invalid replacement endpoint"); + let preflightCalls = 0; + + harness.controls.applyServerBaseUrlToApi = () => { + preflightCalls += 1; + if (preflightCalls === 2) throw preflightError; + }; + harness.controls.apiRequest = () => delayedResponse.promise; + + const olderRequest = harness.testConnection(); + const newerResult = await harness.testConnection(); + delayedResponse.reject(new Error("old endpoint failed late")); + const olderResult = await olderRequest; + + assert.equal(newerResult, false); + assert.equal(olderResult, false); + assert.deepEqual(harness.effects.busy, [true, false]); + assert.deepEqual(harness.effects.feedback, [preflightError]); + assert.equal(harness.effects.badgeRenders, 1); + assert.equal(harness.effects.registrationLoads, 0); + assert.deepEqual(harness.effects.toasts, []); + assert.equal(harness.state.syncStatus, null); + assert.equal(harness.state.registrationStatusKnown, false); +}); + +test("a newer preflight error invalidates an older delayed registration result", async () => { + const harness = createTestConnectionHarness(); + const registrationStarted = createDeferred(); + const delayedRegistration = createDeferred(); + const preflightError = new Error("invalid replacement endpoint"); + let preflightCalls = 0; + + harness.controls.applyServerBaseUrlToApi = () => { + preflightCalls += 1; + if (preflightCalls === 2) throw preflightError; + }; + harness.controls.apiRequest = (path) => { + if (path === "/sync/status") { + return Promise.resolve({ source: "old-endpoint", status: "ready" }); + } + if (path === "/auth/registration") { + registrationStarted.resolve(); + return delayedRegistration.promise; + } + throw new Error(`Unexpected request: ${path}`); + }; + + const olderRequest = harness.testConnection(); + await registrationStarted.promise; + const newerResult = await harness.testConnection(); + + assert.equal(newerResult, false); + assert.equal(harness.state.syncStatus, null); + assert.equal(harness.state.registrationStatusKnown, false); + assert.equal(harness.state.registrationEnabled, false); + + delayedRegistration.resolve({ registration_enabled: true }); + const olderResult = await olderRequest; + + assert.equal(olderResult, false); + assert.deepEqual(harness.effects.busy, [true, false]); + assert.deepEqual(harness.effects.feedback, [preflightError]); + assert.equal(harness.effects.badgeRenders, 1); + assert.equal(harness.effects.registrationRequests, 1); + assert.equal(harness.effects.authModeRenders, 0); + assert.deepEqual(harness.effects.toasts, []); + assert.equal(harness.state.syncStatus, null); + assert.equal(harness.state.registrationStatusKnown, false); + assert.equal(harness.state.registrationEnabled, false); +}); diff --git a/web/tests/offline-core.test.mjs b/web/tests/offline-core.test.mjs index ea09452..6fbaca2 100644 --- a/web/tests/offline-core.test.mjs +++ b/web/tests/offline-core.test.mjs @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { + buildOfflineDatabaseName, buildConflictOverwritePayload, buildTaskMetaLabels, buildLocalMeta, @@ -10,14 +11,212 @@ import { getTaskPriorityLabel, getTaskStatusLabel, getTaskViewLabel, + hasOfflineQueueId, makeOfflineTask, makeTaskFromTemplate, matchesTaskView, + isOfflineProfileForApi, + normalizeOfflineApiBaseUrl, + normalizeOfflineProfile, + reconcileCachedTaskSnapshot, + resetEndpointScopedConnectionState, shouldConfirmTaskAction, + shouldShowConnectionBadge, } from "../offline-core.js"; const fixedNow = new Date("2026-06-05T10:00:00.000Z"); +test("offline resume profiles retain only the identity needed to open the correct cache", () => { + assert.deepEqual( + normalizeOfflineProfile({ + id: 42, + username: " owner ", + email: "owner@example.com", + api_base_url: "HTTPS://TaskBridge.Example.com:443/root/api/v1/", + access_token: "must-not-persist", + refresh_token: "must-not-persist", + role: "admin", + }), + { + id: 42, + username: "owner", + email: "owner@example.com", + api_base_url: "https://taskbridge.example.com/root/api/v1", + }, + ); +}); + +test("offline resume rejects profiles without a positive user id", () => { + const apiBaseUrl = "https://taskbridge.example.com/api/v1"; + assert.equal(normalizeOfflineProfile({ id: 0, username: "owner", api_base_url: apiBaseUrl }), null); + assert.equal(normalizeOfflineProfile({ id: "not-a-number", username: "owner", api_base_url: apiBaseUrl }), null); + assert.equal(normalizeOfflineProfile({ id: 1, username: "owner" }), null); + assert.equal(normalizeOfflineProfile(null), null); +}); + +test("legacy offline profiles are bound to the saved API during migration", () => { + assert.deepEqual( + normalizeOfflineProfile( + { id: 42, username: "owner", email: "owner@example.com" }, + "http://127.0.0.1:8000/api/v1", + ), + { + id: 42, + username: "owner", + email: "owner@example.com", + api_base_url: "http://127.0.0.1:8000/api/v1", + }, + ); +}); + +test("offline database names isolate equal user ids on different servers", () => { + const first = buildOfflineDatabaseName( + "taskbridge.web.v1", + "https://one.example.com/api/v1", + 42, + ); + const equivalent = buildOfflineDatabaseName( + "taskbridge.web.v1", + "HTTPS://ONE.EXAMPLE.COM:443/api/v1/", + 42, + ); + const second = buildOfflineDatabaseName( + "taskbridge.web.v1", + "https://two.example.com/api/v1", + 42, + ); + + assert.equal(first, equivalent); + assert.notEqual(first, second); + assert.match(first, /\.offline\.server\..+\.user\.42$/); + assert.equal( + normalizeOfflineApiBaseUrl("https://one.example.com"), + "https://one.example.com/api/v1", + ); +}); + +test("offline resume profiles only match their original API", () => { + const profile = normalizeOfflineProfile({ + id: 42, + username: "owner", + api_base_url: "https://one.example.com/api/v1", + }); + + assert.equal(isOfflineProfileForApi(profile, "HTTPS://ONE.EXAMPLE.COM:443/api/v1/"), true); + assert.equal(isOfflineProfileForApi(profile, "https://two.example.com/api/v1"), false); +}); + +test("cache reconciliation removes stale scoped records and preserves pending work", () => { + const cached = [ + { id: 1, title: "Removed remotely", list_type: "inbox", status: "open" }, + { + id: 2, + title: "Local pending title", + list_type: "inbox", + status: "open", + offline_status: "pending:update", + offline_queue_id: 7, + }, + { id: 3, title: "Other view", list_type: "later", status: "open" }, + { + id: 4, + title: "Old remote title", + list_type: "inbox", + status: "open", + offline_status: null, + offline_queue_id: null, + }, + ]; + const remote = [ + { id: 2, title: "Server title", list_type: "inbox", status: "open" }, + { id: 4, title: "Fresh remote title", list_type: "inbox", status: "open" }, + { id: 5, title: "New remote task", list_type: "inbox", status: "open" }, + ]; + + const reconciled = reconcileCachedTaskSnapshot(cached, remote, { view: "inbox", search: "" }); + + assert.deepEqual(reconciled.map((task) => task.id), [2, 3, 4, 5]); + assert.equal(reconciled.find((task) => task.id === 2)?.title, "Local pending title"); + assert.equal(reconciled.find((task) => task.id === 4)?.title, "Fresh remote title"); + assert.equal(reconciled.find((task) => task.id === 4)?.offline_status, null); +}); + +test("offline queue ids accept only positive integer IndexedDB keys", () => { + for (const value of [1, 42, Number.MAX_SAFE_INTEGER]) { + assert.equal(hasOfflineQueueId(value), true, `${String(value)} should be accepted`); + } + for (const value of [ + null, + undefined, + "", + "1", + 0, + -1, + 1.5, + Number.NaN, + Number.POSITIVE_INFINITY, + true, + [1], + { valueOf: () => 1 }, + ]) { + assert.equal(hasOfflineQueueId(value), false, `${String(value)} should be rejected`); + } +}); + +test("connection badge visibility follows local workspace and connection check state", () => { + assert.equal(shouldShowConnectionBadge(false, null), false); + assert.equal(shouldShowConnectionBadge(true, null), true); + assert.equal(shouldShowConnectionBadge(false, { status: "ready" }), true); + assert.equal(shouldShowConnectionBadge(false, { status: "degraded" }), true); +}); + +test("endpoint changes clear only endpoint-scoped connection state", () => { + const user = { id: 42, username: "owner" }; + const tasks = [{ id: 1, title: "Keep me" }]; + const offlineQueue = [{ offline_queue_id: 7 }]; + const state = { + serverBaseUrl: "https://one.example.com", + apiBaseUrl: "https://one.example.com/api/v1", + registrationStatusKnown: true, + registrationEnabled: true, + syncStatus: { status: "ready" }, + user, + accessToken: "access-token", + refreshToken: "refresh-token", + tasks, + offlineQueue, + }; + + assert.equal( + resetEndpointScopedConnectionState( + state, + "https://one.example.com", + "https://one.example.com/api/v1", + ), + false, + ); + assert.equal(state.registrationStatusKnown, true); + assert.equal(state.registrationEnabled, true); + assert.deepEqual(state.syncStatus, { status: "ready" }); + + assert.equal( + resetEndpointScopedConnectionState( + state, + "https://one.example.com", + "https://one.example.com/custom-api/v1", + ), + true, + ); + assert.equal(state.registrationStatusKnown, false); + assert.equal(state.registrationEnabled, false); + assert.equal(state.syncStatus, null); + assert.equal(state.user, user); + assert.equal(state.accessToken, "access-token"); + assert.equal(state.refreshToken, "refresh-token"); + assert.equal(state.tasks, tasks); + assert.equal(state.offlineQueue, offlineQueue); +}); + test("offline-created tasks preserve the full task payload used by sync", () => { const task = makeOfflineTask( { diff --git a/web/tests/service-worker.test.mjs b/web/tests/service-worker.test.mjs new file mode 100644 index 0000000..0a30314 --- /dev/null +++ b/web/tests/service-worker.test.mjs @@ -0,0 +1,103 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; +import vm from "node:vm"; + +const source = await readFile(new URL("../sw.js", import.meta.url), "utf8"); + +function loadServiceWorker(overrides = {}) { + const listeners = new Map(); + const scope = "https://tasks.example.com/web/"; + const self = { + location: new URL(scope), + registration: { scope }, + clients: { + claim: async () => {}, + matchAll: async () => [], + openWindow: async () => null, + }, + skipWaiting() {}, + addEventListener(type, listener) { + listeners.set(type, listener); + }, + ...overrides.self, + }; + const context = vm.createContext({ + URL, + Promise, + self, + fetch: overrides.fetch || (async () => ({ clone() { return this; } })), + caches: overrides.caches || { + open: async () => ({ addAll: async () => {}, put: async () => {} }), + keys: async () => [], + delete: async () => true, + match: async () => null, + }, + }); + vm.runInContext(source, context, { filename: "sw.js" }); + return { listeners, self }; +} + +test("offline guide navigation serves the cached requested guide before the app shell", async () => { + const guideResponse = { name: "local-trial" }; + const indexResponse = { name: "index" }; + const caches = { + open: async () => ({ addAll: async () => {}, put: async () => {} }), + keys: async () => [], + delete: async () => true, + match: async (request) => typeof request === "string" ? indexResponse : guideResponse, + }; + const { listeners } = loadServiceWorker({ + fetch: async () => { throw new TypeError("offline"); }, + caches, + }); + let responsePromise; + listeners.get("fetch")({ + request: { + method: "GET", + mode: "navigate", + url: "https://tasks.example.com/web/local-trial.html", + }, + respondWith(value) { + responsePromise = value; + }, + }); + + assert.equal(await responsePromise, guideResponse); +}); + +test("notification clicks focus an existing app window at the linked task", async () => { + const navigated = []; + let focused = 0; + const client = { + url: "https://tasks.example.com/web/", + async navigate(url) { navigated.push(url); return this; }, + async focus() { focused += 1; return this; }, + }; + const { listeners } = loadServiceWorker({ + self: { + clients: { + claim: async () => {}, + matchAll: async () => [client], + openWindow: async () => null, + }, + }, + }); + const handler = listeners.get("notificationclick"); + assert.equal(typeof handler, "function", "notificationclick handler must be registered"); + + let work; + let closed = 0; + handler({ + notification: { + data: { url: "https://tasks.example.com/web/?task=42" }, + close() { closed += 1; }, + }, + waitUntil(value) { work = value; }, + }); + await work; + + assert.equal(closed, 1); + assert.deepEqual(navigated, ["https://tasks.example.com/web/?task=42"]); + assert.equal(focused, 1); +}); diff --git a/web/tests/web-ui-contract.test.mjs b/web/tests/web-ui-contract.test.mjs new file mode 100644 index 0000000..123f3e4 --- /dev/null +++ b/web/tests/web-ui-contract.test.mjs @@ -0,0 +1,194 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; + +const [appSource, html, styles] = await Promise.all([ + readFile(new URL("../app.js", import.meta.url), "utf8"), + readFile(new URL("../index.html", import.meta.url), "utf8"), + readFile(new URL("../styles.css", import.meta.url), "utf8"), +]); + +test("account security UI exposes password and session controls", () => { + for (const id of [ + "accountSecurityTools", + "passwordChangeForm", + "currentPassword", + "newPassword", + "confirmNewPassword", + "sessionList", + "refreshSessionsButton", + "revokeOtherSessionsButton", + "accountSecurityMessage", + ]) { + assert.match(html, new RegExp(`id=["']${id}["']`), `${id} must exist`); + } + assert.match(appSource, /\/auth\/password/); + assert.match(appSource, /\/auth\/sessions/); + assert.match(appSource, /revoke-other-devices/); + assert.match( + appSource, + /function setBusy\(isBusy\) \{[^}]*renderAccountSecurity\(\);[^}]*\}/, + "leaving a busy account action must re-enable security controls", + ); +}); + +test("task requests carry the browser IANA time zone and map server views", () => { + assert.match(appSource, /Intl\.DateTimeFormat\(\)\.resolvedOptions\(\)\.timeZone/); + assert.match(appSource, /params\.set\(["']timezone["']/); + assert.match(appSource, /\/tasks\/meta\?/); + assert.match(appSource, /mapTaskViewForServer\(/); +}); + +test("same-origin defaults, degraded auth, mixed-content errors, and terminal refresh are integrated", () => { + assert.match(appSource, /location\.origin/); + assert.match(appSource, /isAuthHealthUsable\(/); + assert.match(appSource, /isMixedContentApiUrl\(/); + assert.match(appSource, /validation\.mixedContentApi/); + assert.match(appSource, /isTerminalRefreshStatus\(/); + assert.match(appSource, /enterReauthenticationState\(/); +}); + +test("signed-out header hides the non-actionable connection badge until a check has a result", () => { + assert.match(html, /id="connectionBadge"[^>]*hidden/); + assert.match( + appSource, + /nodes\.connectionBadge\.hidden\s*=\s*!shouldShowConnectionBadge\(hasLocalWorkspace\(\), state\.syncStatus\)/, + ); +}); + +test("changing a server endpoint invalidates its previous connection result", () => { + assert.match(appSource, /resetEndpointScopedConnectionState\(/); + assert.match(appSource, /const connectionRequestGate = createLatestRequestGate\(\)/); + assert.match(appSource, /const registrationRequestGate = createLatestRequestGate\(\)/); + assert.match( + appSource, + /function resetConnectionStateForEndpointChange\([\s\S]{0,500}updateConnectionBadge\(\)/, + ); + assert.match( + appSource, + /function resetConnectionStateForEndpointChange\([\s\S]{0,500}setStatus\(nodes\.authMessage, ""\)/, + ); + assert.match( + appSource, + /const syncStatus = await apiRequest\("\/sync\/status", \{ auth: false[^}]*\}\);[\s\S]{0,240}!connectionRequestGate\.isCurrent\(requestSequence\)[\s\S]{0,160}state\.syncStatus = syncStatus/, + ); + assert.match( + appSource, + /async function loadRegistrationStatus\(\)[\s\S]{0,240}registrationRequestGate\.begin\(\)[\s\S]{0,360}registrationRequestGate\.isCurrent\(requestSequence\)/, + ); + assert.match( + appSource, + /async function refreshSyncStatus\([\s\S]{0,420}connectionRequestGate\.begin\(\)[\s\S]{0,900}connectionRequestGate\.isCurrent\(requestSequence\)/, + ); + assert.match(appSource, /let activeConnectionRequestSequence = null/); + assert.match(appSource, /requestSequence === activeConnectionRequestSequence/); +}); + +test("mobile workspace keeps quick actions and tasks before secondary sidebar tools", () => { + assert.match( + html, + /