From 817efdb71ac2cb452f51040661504d810e3b6681 Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Sun, 22 Mar 2026 23:19:18 +0800 Subject: [PATCH] release: prepare v0.6.1 --- CHANGELOG.md | 13 ++ config/feature_flags.yaml | 6 +- .../secure-local-persistence-architecture.md | 37 ++++ .../xworkmate-internal-state-architecture.md | 7 + docs/planning/xworkmate-ui-feature-matrix.md | 12 +- docs/planning/xworkmate-ui-feature-roadmap.md | 21 +-- docs/releases/xworkmate-changelog.md | 48 ++--- docs/releases/xworkmate-release-notes.md | 69 ++----- lib/runtime/secret_store.dart | 140 +++++++++++++-- lib/runtime/secure_config_store.dart | 49 +++++ lib/runtime/settings_store.dart | 169 ++++++++++++++++-- pubspec.yaml | 2 +- test/runtime/secure_config_store_suite.dart | 82 +++++++++ 13 files changed, 520 insertions(+), 135 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 82c99716..e3756ad8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## 0.6.1 — 2026-03-22 + +### Highlights +- 修复本地配置持久化链路:`SecureConfigStore` 增加标准目录 fallback,`SettingsStore`/`SecretStore` 首次启动自动准备耐久目录结构。 +- 持久化策略改为默认 fail-fast:当耐久路径不可解析或数据库不可打开时直接报错,避免静默内存化导致重启丢配置。 +- 在显式内存回退模式下补齐“尽力回写”机制:后续写入和退出阶段会尝试同步到标准耐久目录。 +- 关闭未完备账号入口:`mobile.workspace.account` 与 `desktop.navigation.account` 标记为 `experimental` 且 `enabled: false`。 +- 补充回归测试覆盖“路径失败报错”和“默认支持目录 fallback 跨实例持久化”。 + +### Dev +- `pubspec.yaml`: 当前版本更新为 `0.6.1+1` +- 本次按用户要求直接在 `main` 分支提交,预期 tag 为 `v0.6.1` + ## 0.6.0 — 2026-03-22 ### Highlights diff --git a/config/feature_flags.yaml b/config/feature_flags.yaml index b3be0dfe..4fc29f42 100644 --- a/config/feature_flags.yaml +++ b/config/feature_flags.yaml @@ -73,7 +73,7 @@ mobile: description: Mobile workspace AI Gateway launcher ui_surface: mobile_workspace_hub account: - enabled: true + enabled: false release_tier: experimental build_modes: [debug, profile, release] description: Mobile workspace account launcher @@ -246,8 +246,8 @@ desktop: description: Desktop settings destination ui_surface: sidebar_navigation account: - enabled: true - release_tier: stable + enabled: false + release_tier: experimental build_modes: [debug, profile, release] description: Desktop account destination ui_surface: sidebar_navigation diff --git a/docs/architecture/secure-local-persistence-architecture.md b/docs/architecture/secure-local-persistence-architecture.md index e6cb1da0..9ec656a2 100644 --- a/docs/architecture/secure-local-persistence-architecture.md +++ b/docs/architecture/secure-local-persistence-architecture.md @@ -7,6 +7,43 @@ - 本地配置和任务会话必须能跨重启、跨覆盖安装恢复。 - 持久化以前提 `secure storage` 为本地信任根,避免把可恢复状态明文落盘。 +## 当前实现基线(v0.6.1) + +### 1) macOS 标准持久化目录 + +默认目录按 Apple 常规结构落在: + +- `~/Library/Application Support/plus.svc.xworkmate/xworkmate` + +关键文件与目录: + +- `config-store.sqlite3`(`SettingsStore` 主库) +- `settings-snapshot.json`(durable mirror) +- `assistant-threads.json`(durable mirror) +- `gateway-auth/secure-storage/*`(`SecretStore` 文件型安全存储 fallback) + +### 2) 首次安装初始化 + +- `SettingsStore.initialize()` 会初始化并打开 `config-store.sqlite3`。 +- `SecretStore.initialize()` 会初始化 `gateway-auth` 与 `secure-storage` 目录结构。 +- 因此 DMG 首次安装后,重启前无需手工“触发一次保存”即可完成持久化目录与主存储文件的准备。 + +### 3) 升级与重启行为 + +- 应用升级 / 系统更新重启不会改写或重置既有路径。 +- 只在用户主动执行“设置 -> 诊断 -> 清理任务线程与本地配置”时清理本地 settings/thread 状态。 +- 清理流程不删除已保存 secrets(Gateway token/password、AI Gateway API key、Vault token 等)。 + +### 4) 路径解析失败策略(默认) + +- 默认策略为 `fail-fast`:当 `SettingsStore` 无法解析或打开耐久数据库路径时,直接抛错,不再静默降级为内存持久化。 +- 这样可以避免“看起来保存成功、重启后全部丢失”的隐性故障。 + +### 5) 内存回退(仅显式开启场景) + +- 仅在显式开启 `allowInMemoryFallback`(主要用于测试/诊断)时允许内存回退。 +- 即使发生内存回退,也会在后续写入和销毁阶段尽力回写同步到耐久目录(若路径恢复可用)。 + 核心结论: - `FlutterSecureStorage` 仍是长期 secret 的主存储。 diff --git a/docs/architecture/xworkmate-internal-state-architecture.md b/docs/architecture/xworkmate-internal-state-architecture.md index 152dce9e..2d195e37 100644 --- a/docs/architecture/xworkmate-internal-state-architecture.md +++ b/docs/architecture/xworkmate-internal-state-architecture.md @@ -41,6 +41,13 @@ Platform runtime matrix - Platform: standard browser runtime - Fixed work modes: AI Gateway, Remote OpenClaw Gateway +Persistence guardrails (v0.6.1) + +- Desktop persistence path is stable at `~/Library/Application Support/plus.svc.xworkmate/xworkmate`. +- `SettingsStore` and `SecretStore` initialize durable directories/files on first install. +- Path/DB resolution failure defaults to fail-fast instead of silent in-memory persistence. +- In explicit test fallback mode, temporary in-memory state will attempt best-effort sync back to durable storage when possible. + These work-mode arrays come from feature-manifest capabilities. They are not derived from gateway profile data. diff --git a/docs/planning/xworkmate-ui-feature-matrix.md b/docs/planning/xworkmate-ui-feature-matrix.md index 56052aa5..e75326bb 100644 --- a/docs/planning/xworkmate-ui-feature-matrix.md +++ b/docs/planning/xworkmate-ui-feature-matrix.md @@ -2,7 +2,7 @@ > Generated by `tool/render_release_docs.dart` > Source manifest: [`config/feature_flags.yaml`](../../config/feature_flags.yaml) -> Generated at: `2026-03-22T14:40:00.994323` +> Generated at: `2026-03-22T23:18:17.681830` ## Release Policy @@ -18,10 +18,10 @@ | 平台 | Flag 总数 | 已启用 | Stable | Beta | Experimental | Disabled | | --- | --- | --- | --- | --- | --- | --- | -| `mobile` | 29 | 28 | 19 | 0 | 9 | 1 | -| `desktop` | 28 | 28 | 21 | 1 | 6 | 0 | +| `mobile` | 29 | 27 | 19 | 0 | 8 | 2 | +| `desktop` | 28 | 27 | 20 | 1 | 6 | 1 | | `web` | 12 | 8 | 8 | 0 | 0 | 4 | -| `total` | 69 | 64 | 48 | 1 | 15 | 5 | +| `total` | 69 | 62 | 47 | 1 | 14 | 7 | ## Mobile @@ -38,7 +38,7 @@ | `workspace` | `mcp_server` | enabled | `experimental` | `debug, profile, release` | `mobile_workspace_hub` | Mobile workspace MCP launcher | | `workspace` | `claw_hub` | enabled | `experimental` | `debug, profile, release` | `mobile_workspace_hub` | Mobile workspace ClawHub launcher | | `workspace` | `ai_gateway` | enabled | `stable` | `debug, profile, release` | `mobile_workspace_hub` | Mobile workspace AI Gateway launcher | -| `workspace` | `account` | enabled | `experimental` | `debug, profile, release` | `mobile_workspace_hub` | Mobile workspace account launcher | +| `workspace` | `account` | disabled | `experimental` | `debug, profile, release` | `mobile_workspace_hub` | Mobile workspace account launcher | | `assistant` | `direct_ai` | enabled | `stable` | `debug, profile, release` | `assistant_page` | Mobile direct AI assistant mode | | `assistant` | `local_gateway` | enabled | `stable` | `debug, profile, release` | `assistant_page` | Mobile local gateway assistant mode | | `assistant` | `relay_gateway` | enabled | `stable` | `debug, profile, release` | `assistant_page` | Mobile relay gateway assistant mode | @@ -71,7 +71,7 @@ | `navigation` | `secrets` | enabled | `stable` | `debug, profile, release` | `sidebar_navigation` | Desktop secrets destination | | `navigation` | `ai_gateway` | enabled | `stable` | `debug, profile, release` | `sidebar_navigation` | Desktop AI Gateway destination | | `navigation` | `settings` | enabled | `stable` | `debug, profile, release` | `sidebar_navigation` | Desktop settings destination | -| `navigation` | `account` | enabled | `stable` | `debug, profile, release` | `sidebar_navigation` | Desktop account destination | +| `navigation` | `account` | disabled | `experimental` | `debug, profile, release` | `sidebar_navigation` | Desktop account destination | | `assistant` | `direct_ai` | enabled | `stable` | `debug, profile, release` | `assistant_page` | Desktop direct AI assistant mode | | `assistant` | `local_gateway` | enabled | `stable` | `debug, profile, release` | `assistant_page` | Desktop local gateway assistant mode | | `assistant` | `relay_gateway` | enabled | `stable` | `debug, profile, release` | `assistant_page` | Desktop relay gateway assistant mode | diff --git a/docs/planning/xworkmate-ui-feature-roadmap.md b/docs/planning/xworkmate-ui-feature-roadmap.md index 1f3c27ad..4b3893fa 100644 --- a/docs/planning/xworkmate-ui-feature-roadmap.md +++ b/docs/planning/xworkmate-ui-feature-roadmap.md @@ -2,7 +2,7 @@ > Generated by `tool/render_release_docs.dart` > Source manifest: [`config/feature_flags.yaml`](../../config/feature_flags.yaml) -> Generated at: `2026-03-22T14:40:00.994323` +> Generated at: `2026-03-22T23:18:17.681830` ## 规划规则 @@ -14,8 +14,8 @@ | 平台 | Debug Visible | Profile Visible | Release Visible | Suppressed | | --- | --- | --- | --- | --- | -| `mobile` | 28 | 19 | 19 | 1 | -| `desktop` | 28 | 22 | 21 | 0 | +| `mobile` | 27 | 19 | 19 | 2 | +| `desktop` | 27 | 21 | 20 | 1 | | `web` | 8 | 8 | 8 | 4 | ## Release Baseline @@ -23,7 +23,7 @@ | 平台 | 数量 | Flag 列表 | | --- | --- | --- | | `mobile` | 19 | `navigation.assistant`, `navigation.tasks`, `navigation.workspace`, `navigation.settings`, `workspace.skills`, `workspace.nodes`, `workspace.agents`, `workspace.ai_gateway`, `assistant.direct_ai`, `assistant.local_gateway`, `assistant.relay_gateway`, `assistant.file_attachments`, `settings.general`, `settings.workspace`, `settings.gateway`, `settings.agents`, `settings.appearance`, `settings.diagnostics`, `settings.about` | -| `desktop` | 21 | `navigation.assistant`, `navigation.tasks`, `navigation.skills`, `navigation.nodes`, `navigation.agents`, `navigation.secrets`, `navigation.ai_gateway`, `navigation.settings`, `navigation.account`, `assistant.direct_ai`, `assistant.local_gateway`, `assistant.relay_gateway`, `assistant.file_attachments`, `assistant.local_runtime`, `settings.general`, `settings.workspace`, `settings.gateway`, `settings.agents`, `settings.appearance`, `settings.diagnostics`, `settings.about` | +| `desktop` | 20 | `navigation.assistant`, `navigation.tasks`, `navigation.skills`, `navigation.nodes`, `navigation.agents`, `navigation.secrets`, `navigation.ai_gateway`, `navigation.settings`, `assistant.direct_ai`, `assistant.local_gateway`, `assistant.relay_gateway`, `assistant.file_attachments`, `assistant.local_runtime`, `settings.general`, `settings.workspace`, `settings.gateway`, `settings.agents`, `settings.appearance`, `settings.diagnostics`, `settings.about` | | `web` | 8 | `navigation.assistant`, `navigation.settings`, `assistant.direct_ai`, `assistant.relay_gateway`, `settings.general`, `settings.gateway`, `settings.appearance`, `settings.about` | ## Profile-only Lane @@ -38,7 +38,7 @@ | 平台 | 数量 | 相比 Profile 新增 | | --- | --- | --- | -| `mobile` | 9 | `navigation.secrets`, `workspace.mcp_server`, `workspace.claw_hub`, `workspace.account`, `assistant.multi_agent`, `settings.experimental`, `settings.experimental_canvas`, `settings.experimental_bridge`, `settings.experimental_debug` | +| `mobile` | 8 | `navigation.secrets`, `workspace.mcp_server`, `workspace.claw_hub`, `assistant.multi_agent`, `settings.experimental`, `settings.experimental_canvas`, `settings.experimental_bridge`, `settings.experimental_debug` | | `desktop` | 6 | `navigation.mcp_server`, `navigation.claw_hub`, `settings.experimental`, `settings.experimental_canvas`, `settings.experimental_bridge`, `settings.experimental_debug` | | `web` | 0 | - | @@ -46,8 +46,8 @@ | 平台 | 数量 | Flag 列表 | | --- | --- | --- | -| `mobile` | 1 | `assistant.local_runtime` | -| `desktop` | 0 | - | +| `mobile` | 2 | `workspace.account`, `assistant.local_runtime` | +| `desktop` | 1 | `navigation.account` | | `web` | 4 | `assistant.file_attachments`, `assistant.multi_agent`, `assistant.local_gateway`, `assistant.local_runtime` | ## Tier Inventory @@ -55,14 +55,15 @@ ### Mobile - `stable`: `navigation.assistant`, `navigation.tasks`, `navigation.workspace`, `navigation.settings`, `workspace.skills`, `workspace.nodes`, `workspace.agents`, `workspace.ai_gateway`, `assistant.direct_ai`, `assistant.local_gateway`, `assistant.relay_gateway`, `assistant.file_attachments`, `settings.general`, `settings.workspace`, `settings.gateway`, `settings.agents`, `settings.appearance`, `settings.diagnostics`, `settings.about` -- `experimental`: `navigation.secrets`, `workspace.mcp_server`, `workspace.claw_hub`, `workspace.account`, `assistant.multi_agent`, `settings.experimental`, `settings.experimental_canvas`, `settings.experimental_bridge`, `settings.experimental_debug` -- `disabled`: `assistant.local_runtime` +- `experimental`: `navigation.secrets`, `workspace.mcp_server`, `workspace.claw_hub`, `assistant.multi_agent`, `settings.experimental`, `settings.experimental_canvas`, `settings.experimental_bridge`, `settings.experimental_debug` +- `disabled`: `workspace.account`, `assistant.local_runtime` ### Desktop -- `stable`: `navigation.assistant`, `navigation.tasks`, `navigation.skills`, `navigation.nodes`, `navigation.agents`, `navigation.secrets`, `navigation.ai_gateway`, `navigation.settings`, `navigation.account`, `assistant.direct_ai`, `assistant.local_gateway`, `assistant.relay_gateway`, `assistant.file_attachments`, `assistant.local_runtime`, `settings.general`, `settings.workspace`, `settings.gateway`, `settings.agents`, `settings.appearance`, `settings.diagnostics`, `settings.about` +- `stable`: `navigation.assistant`, `navigation.tasks`, `navigation.skills`, `navigation.nodes`, `navigation.agents`, `navigation.secrets`, `navigation.ai_gateway`, `navigation.settings`, `assistant.direct_ai`, `assistant.local_gateway`, `assistant.relay_gateway`, `assistant.file_attachments`, `assistant.local_runtime`, `settings.general`, `settings.workspace`, `settings.gateway`, `settings.agents`, `settings.appearance`, `settings.diagnostics`, `settings.about` - `beta`: `assistant.multi_agent` - `experimental`: `navigation.mcp_server`, `navigation.claw_hub`, `settings.experimental`, `settings.experimental_canvas`, `settings.experimental_bridge`, `settings.experimental_debug` +- `disabled`: `navigation.account` ### Web diff --git a/docs/releases/xworkmate-changelog.md b/docs/releases/xworkmate-changelog.md index 2ceba035..1ccfb459 100644 --- a/docs/releases/xworkmate-changelog.md +++ b/docs/releases/xworkmate-changelog.md @@ -2,23 +2,24 @@ > Generated by `tool/render_release_docs.dart` > Source manifest: [`config/feature_flags.yaml`](../../config/feature_flags.yaml) -> Generated at: `2026-03-22T14:40:00.994323` +> Generated at: `2026-03-22T23:18:17.681830` ## Git Snapshot | 字段 | 值 | | --- | --- | -| Branch | `release/v0.6` | -| Head Commit | `7cf4957` | +| Branch | `main` | +| Head Commit | `43388e1` | | Head Tags | `-` | -| Latest Tag | `v0.5` | -| Previous Tag | `v0.4` | -| Comparison Range | `v0.5..HEAD` | +| Latest Tag | `v0.6` | +| Previous Tag | `v0.5` | +| Comparison Range | `v0.6..HEAD` | ## Recent Tags | Tag | Date | | --- | --- | +| `v0.6` | `2026-03-22` | | `v0.5` | `2026-03-20` | | `v0.4` | `2026-03-15` | | `v0.2` | `2026-03-12` | @@ -28,30 +29,11 @@ | Hash | Date | Author | Subject | | --- | --- | --- | --- | -| `7cf4957` | `2026-03-22` | Haitao Pan | Stabilize assistant composer shell sizing | -| `4ea4c06` | `2026-03-22` | Haitao Pan | Fix assistant execution target switch refresh timing | -| `50f38e8` | `2026-03-22` | Haitao Pan | Fix assistant composer shell height adaptation | -| `c6e077e` | `2026-03-22` | Haitao Pan | docs: add secure persistence architecture and release pack | -| `10717a0` | `2026-03-22` | Haitao Pan | fix(runtime): encrypt local settings and assistant thread persistence | -| `1b6710a` | `2026-03-22` | Haitao Pan | Add assistant thread IA docs | -| `0ca992f` | `2026-03-22` | Haitao Pan | Merge branch 'codex/fix-thread-gateway-status' | -| `09287cc` | `2026-03-22` | Haitao Pan | Fix assistant thread connection status | -| `6604711` | `2026-03-22` | Haitao Pan | Auto-import gateway-only discovered skills into available list | -| `77ab128` | `2026-03-22` | Haitao Pan | Persist assistant state and add local recovery cleanup | -| `d57ca31` | `2026-03-22` | Haitao Pan | Merge branch 'codex/web-chrome-db-parity' | -| `90e20a7` | `2026-03-22` | Haitao Pan | Harden web session persistence flow | -| `8f655d3` | `2026-03-22` | Haitao Pan | Fix web chrome test isolation and session persistence | -| `c24f2ab` | `2026-03-22` | Haitao Pan | feat: add ui feature flag release docs pipeline | -| `650071a` | `2026-03-21` | Haitao Pan | Merge branch 'codex/windows-parity' | -| `f2fb948` | `2026-03-21` | Haitao Pan | Merge branch 'codex/linux-gnome-desktop-parity' | -| `cbcfb90` | `2026-03-21` | Haitao Pan | Add Flutter web assistant shell | -| `de8710e` | `2026-03-21` | Haitao Pan | Add mobile-safe controls for mobile shell | -| `f65bb15` | `2026-03-21` | Haitao Pan | Adjust desktop sidebar default width | -| `dab77eb` | `2026-03-21` | Haitao Pan | Add multi-platform build and release workflow | -| `a4225d5` | `2026-03-21` | Haitao Pan | fix(windows): vendor secure storage plugin without ATL | -| `3bf71e9` | `2026-03-21` | Haitao Pan | fix(linux): unblock parity desktop builds | -| `89ed967` | `2026-03-20` | Haitao Pan | test(ai-gateway): keep secrets in secure storage | -| `40159bd` | `2026-03-20` | Haitao Pan | feat: make assistant composer height resizable | -| `0d3b9b1` | `2026-03-20` | Haitao Pan | refactor: align multi-agent workflow with real ollama cli | -| `7793e92` | `2026-03-20` | Haitao Pan | refactor: unify settings drill-in navigation | -| `04f3474` | `2026-03-20` | Haitao Pan | Synchronize assistant threads and markdown view | +| `43388e1` | `2026-03-22` | Haitao Pan | Clarify internal architecture documentation | +| `5cab0f5` | `2026-03-22` | Haitao Pan | Refactor work modes and gateway profiles | +| `5d49ae3` | `2026-03-22` | Haitao Pan | Refactor assistant page and gateway runtime integration | +| `72ecd1f` | `2026-03-22` | Haitao Pan | Unify legacy config pages into settings center | +| `abea2b4` | `2026-03-22` | Haitao Pan | Integrate gateway settings into integrations page | +| `ffced7f` | `2026-03-22` | Haitao Pan | Refactor settings persistence and upgrade recovery | +| `98409d1` | `2026-03-22` | Haitao Pan | Refine AI Gateway action buttons | +| `95ae875` | `2026-03-22` | Haitao Pan | Fix remote thread status fallback | diff --git a/docs/releases/xworkmate-release-notes.md b/docs/releases/xworkmate-release-notes.md index aa60d410..a3a4aa69 100644 --- a/docs/releases/xworkmate-release-notes.md +++ b/docs/releases/xworkmate-release-notes.md @@ -2,81 +2,50 @@ > Generated by `tool/render_release_docs.dart` > Source manifest: [`config/feature_flags.yaml`](../../config/feature_flags.yaml) -> Generated at: `2026-03-22T14:40:00.994323` +> Generated at: `2026-03-22T23:18:17.681830` ## Git Snapshot | 字段 | 值 | | --- | --- | -| Branch | `release/v0.6` | -| Head Commit | `7cf4957` | +| Branch | `main` | +| Head Commit | `43388e1` | | Head Tags | `-` | -| Latest Tag | `v0.5` | -| Previous Tag | `v0.4` | -| Comparison Range | `v0.5..HEAD` | -| Commit Count | 27 | +| Latest Tag | `v0.6` | +| Previous Tag | `v0.5` | +| Comparison Range | `v0.6..HEAD` | +| Commit Count | 8 | ## Feature Snapshot | 平台 | Debug | Profile | Release | Suppressed | | --- | --- | --- | --- | --- | -| `mobile` | 28 | 19 | 19 | 1 | -| `desktop` | 28 | 22 | 21 | 0 | +| `mobile` | 27 | 19 | 19 | 2 | +| `desktop` | 27 | 21 | 20 | 1 | | `web` | 8 | 8 | 8 | 4 | ## Current Focus -- `release` 当前面向用户暴露 48 个 UI feature flags,全部来自 `stable` tier。 +- `release` 当前面向用户暴露 47 个 UI feature flags,全部来自 `stable` tier。 - `profile` 相比 `release` 额外开放 1 个预发布条目: `desktop.assistant.multi_agent`。 -- `debug` 相比 `profile` 额外开放 15 个实验条目: `mobile.navigation.secrets`, `mobile.workspace.mcp_server`, `mobile.workspace.claw_hub`, `mobile.workspace.account`, `mobile.assistant.multi_agent`, `mobile.settings.experimental`, `mobile.settings.experimental_canvas`, `mobile.settings.experimental_bridge`, `mobile.settings.experimental_debug`, `desktop.navigation.mcp_server`, `desktop.navigation.claw_hub`, `desktop.settings.experimental`, `desktop.settings.experimental_canvas`, `desktop.settings.experimental_bridge`, `desktop.settings.experimental_debug`。 +- `debug` 相比 `profile` 额外开放 14 个实验条目: `mobile.navigation.secrets`, `mobile.workspace.mcp_server`, `mobile.workspace.claw_hub`, `mobile.assistant.multi_agent`, `mobile.settings.experimental`, `mobile.settings.experimental_canvas`, `mobile.settings.experimental_bridge`, `mobile.settings.experimental_debug`, `desktop.navigation.mcp_server`, `desktop.navigation.claw_hub`, `desktop.settings.experimental`, `desktop.settings.experimental_canvas`, `desktop.settings.experimental_bridge`, `desktop.settings.experimental_debug`。 ## Commit Highlights -### Features - -- `1b6710a` Add assistant thread IA docs -- `c24f2ab` feat: add ui feature flag release docs pipeline -- `cbcfb90` Add Flutter web assistant shell -- `de8710e` Add mobile-safe controls for mobile shell -- `dab77eb` Add multi-platform build and release workflow -- `40159bd` feat: make assistant composer height resizable - ### Fixes -- `4ea4c06` Fix assistant execution target switch refresh timing -- `50f38e8` Fix assistant composer shell height adaptation -- `10717a0` fix(runtime): encrypt local settings and assistant thread persistence -- `09287cc` Fix assistant thread connection status -- `8f655d3` Fix web chrome test isolation and session persistence -- `a4225d5` fix(windows): vendor secure storage plugin without ATL -- `3bf71e9` fix(linux): unblock parity desktop builds - -### Docs - -- `c6e077e` docs: add secure persistence architecture and release pack - -### Tests - -- `89ed967` test(ai-gateway): keep secrets in secure storage +- `95ae875` Fix remote thread status fallback ### Refactors -- `0d3b9b1` refactor: align multi-agent workflow with real ollama cli -- `7793e92` refactor: unify settings drill-in navigation - -### Merges - -- `0ca992f` Merge branch 'codex/fix-thread-gateway-status' -- `d57ca31` Merge branch 'codex/web-chrome-db-parity' -- `650071a` Merge branch 'codex/windows-parity' -- `f2fb948` Merge branch 'codex/linux-gnome-desktop-parity' +- `5cab0f5` Refactor work modes and gateway profiles +- `5d49ae3` Refactor assistant page and gateway runtime integration +- `ffced7f` Refactor settings persistence and upgrade recovery ### Other -- `7cf4957` Stabilize assistant composer shell sizing -- `6604711` Auto-import gateway-only discovered skills into available list -- `77ab128` Persist assistant state and add local recovery cleanup -- `90e20a7` Harden web session persistence flow -- `f65bb15` Adjust desktop sidebar default width -- `04f3474` Synchronize assistant threads and markdown view +- `43388e1` Clarify internal architecture documentation +- `72ecd1f` Unify legacy config pages into settings center +- `abea2b4` Integrate gateway settings into integrations page +- `98409d1` Refine AI Gateway action buttons diff --git a/lib/runtime/secret_store.dart b/lib/runtime/secret_store.dart index 51e4660a..3ce29721 100644 --- a/lib/runtime/secret_store.dart +++ b/lib/runtime/secret_store.dart @@ -106,10 +106,15 @@ class SecretStore { SecretStore({ Future Function()? fallbackDirectoryPathResolver, Future Function()? databasePathResolver, + Future Function()? defaultSupportDirectoryPathResolver, + bool allowInMemoryFallback = false, SecureStorageClient? secureStorage, bool enableSecureStorage = true, }) : _fallbackDirectoryPathResolver = fallbackDirectoryPathResolver, _databasePathResolver = databasePathResolver, + _defaultSupportDirectoryPathResolver = + defaultSupportDirectoryPathResolver, + _allowInMemoryFallback = allowInMemoryFallback, _secureStorageOverride = secureStorage, _enableSecureStorage = enableSecureStorage; @@ -137,6 +142,8 @@ class SecretStore { final Map _memorySecure = {}; final Future Function()? _fallbackDirectoryPathResolver; final Future Function()? _databasePathResolver; + final Future Function()? _defaultSupportDirectoryPathResolver; + final bool _allowInMemoryFallback; final SecureStorageClient? _secureStorageOverride; final bool _enableSecureStorage; SecureStorageClient? _secureStorage; @@ -146,6 +153,7 @@ class SecretStore { if (_initialized) { return; } + await _ensureDurableStorageLayout(); if (_enableSecureStorage) { if (_secureStorageOverride != null) { _secureStorage = _secureStorageOverride; @@ -164,6 +172,24 @@ class SecretStore { _initialized = true; } + Future _ensureDurableStorageLayout() async { + final fallbackDirectory = await _resolveFallbackDirectory(); + if (fallbackDirectory == null) { + if (_allowInMemoryFallback) { + return; + } + throw StateError( + 'Durable secret storage layout unavailable: cannot resolve fallback directory.', + ); + } + final secureStorageDirectory = Directory( + '${fallbackDirectory.path}/secure-storage', + ); + if (!await secureStorageDirectory.exists()) { + await secureStorageDirectory.create(recursive: true); + } + } + Future loadGatewayToken() => _readSecure(_gatewayTokenKey); Future saveGatewayToken(String value) => @@ -308,6 +334,9 @@ class SecretStore { } Future dispose() async { + if (_allowInMemoryFallback && _memorySecure.isNotEmpty) { + await _syncMemorySecretsToDurableStore(); + } _secureStorage = null; _initialized = false; _memorySecure.clear(); @@ -346,7 +375,7 @@ class SecretStore { return value.trim(); } } catch (_) { - if (await _promoteToFileSecureStorageForTests()) { + if (await _promoteToFileSecureStorageFallback()) { try { final value = await _readSecureValue(_secureStorage!, key); if (value != null && value.trim().isNotEmpty) { @@ -369,17 +398,49 @@ class SecretStore { return; } if (_secureStorage == null && - !await _promoteToFileSecureStorageForTests()) { - _memorySecure[key] = trimmed; + !await _promoteToFileSecureStorageFallback()) { + if (_allowInMemoryFallback) { + _memorySecure[key] = trimmed; + unawaited(_syncMemorySecretsToDurableStore()); + return; + } + throw StateError( + 'Durable secret storage unavailable for $key: secure storage and file fallback both failed.', + ); + } + if (_secureStorage == null) { return; } - if (_secureStorage != null) { + try { await _writeSecureValue(_secureStorage!, key, trimmed); _memorySecure[key] = trimmed; final file = await _legacyFallbackFile(key); if (file != null && await file.exists()) { await file.delete(); } + } catch (_) { + final promoted = await _promoteToFileSecureStorageFallback(); + if (promoted && _secureStorage != null) { + try { + await _writeSecureValue(_secureStorage!, key, trimmed); + _memorySecure[key] = trimmed; + final file = await _legacyFallbackFile(key); + if (file != null && await file.exists()) { + await file.delete(); + } + return; + } catch (_) { + // Fall through to strict fallback handling below. + } + } + if (_allowInMemoryFallback) { + _memorySecure[key] = trimmed; + unawaited(_syncMemorySecretsToDurableStore()); + return; + } + throw StateError( + 'Durable secret storage unavailable for $key: failed to write secure value.', + ); } } @@ -441,15 +502,23 @@ class SecretStore { } Future _resolveFallbackDirectory() async { - final explicit = await _fallbackDirectoryPathResolver?.call(); - final explicitTrimmed = explicit?.trim() ?? ''; - if (explicitTrimmed.isNotEmpty) { - return _ensureDirectory(explicitTrimmed); + try { + final explicit = await _fallbackDirectoryPathResolver?.call(); + final explicitTrimmed = explicit?.trim() ?? ''; + if (explicitTrimmed.isNotEmpty) { + return _ensureDirectory(explicitTrimmed); + } + } catch (_) { + // Continue to next fallback candidate. } - final databasePath = await _databasePathResolver?.call(); - final databaseTrimmed = databasePath?.trim() ?? ''; - if (databaseTrimmed.isNotEmpty) { - return _ensureDirectory(File(databaseTrimmed).parent.path); + try { + final databasePath = await _databasePathResolver?.call(); + final databaseTrimmed = databasePath?.trim() ?? ''; + if (databaseTrimmed.isNotEmpty) { + return _ensureDirectory(File(databaseTrimmed).parent.path); + } + } catch (_) { + // Continue to next fallback candidate. } try { final supportDirectory = await getApplicationSupportDirectory(); @@ -457,7 +526,38 @@ class SecretStore { '${supportDirectory.path}/xworkmate/gateway-auth', ); } catch (_) { - return null; + // Continue below to deterministic fallback. + } + try { + final defaultSupportRoot = await _defaultSupportDirectoryPathResolver + ?.call(); + final trimmed = defaultSupportRoot?.trim() ?? ''; + if (trimmed.isNotEmpty) { + return _ensureDirectory('$trimmed/gateway-auth'); + } + } catch (_) { + // Ignore and fall through. + } + return null; + } + + Future _syncMemorySecretsToDurableStore() async { + if (_memorySecure.isEmpty) { + return; + } + if (_secureStorage == null || _secureStorage is MemorySecureStorageClient) { + final promoted = await _promoteToFileSecureStorageFallback(); + if (!promoted || _secureStorage == null) { + return; + } + } + final snapshot = Map.from(_memorySecure); + for (final entry in snapshot.entries) { + try { + await _writeSecureValue(_secureStorage!, entry.key, entry.value); + } catch (_) { + // Best-effort sync for fallback memory mode. + } } } @@ -469,13 +569,18 @@ class SecretStore { return directory; } - Future _promoteToFileSecureStorageForTests() async { + Future _promoteToFileSecureStorageFallback() async { if (_secureStorageOverride != null || (_databasePathResolver == null && - _fallbackDirectoryPathResolver == null)) { + _fallbackDirectoryPathResolver == null && + _defaultSupportDirectoryPathResolver == null)) { return false; } - _secureStorage = FileSecureStorageClient(() => _resolveFallbackDirectory()); + final directory = await _resolveFallbackDirectory(); + if (directory == null) { + return false; + } + _secureStorage = FileSecureStorageClient(() async => directory); return true; } @@ -518,7 +623,8 @@ class SecretStore { SecureStorageClient _buildDebugSecureStorageClient() { if (_databasePathResolver != null || - _fallbackDirectoryPathResolver != null) { + _fallbackDirectoryPathResolver != null || + _defaultSupportDirectoryPathResolver != null) { return FileSecureStorageClient(() => _resolveFallbackDirectory()); } return MemorySecureStorageClient(); diff --git a/lib/runtime/secure_config_store.dart b/lib/runtime/secure_config_store.dart index 2a3fe357..567f80f9 100644 --- a/lib/runtime/secure_config_store.dart +++ b/lib/runtime/secure_config_store.dart @@ -1,3 +1,5 @@ +import 'dart:io'; + export 'legacy_settings_recovery.dart'; export 'secret_store.dart'; export 'settings_store.dart'; @@ -11,19 +13,32 @@ class SecureConfigStore { SecureConfigStore({ Future Function()? fallbackDirectoryPathResolver, Future Function()? databasePathResolver, + Future Function()? defaultSupportDirectoryPathResolver, + bool? allowInMemoryFallback, SecureConfigDatabaseOpener? databaseOpener, SecureStorageClient? secureStorage, bool enableSecureStorage = true, }) { + final resolvedDefaultSupportDirectoryPathResolver = + defaultSupportDirectoryPathResolver ?? + _resolveDefaultSupportDirectoryPath; + final resolvedAllowInMemoryFallback = + allowInMemoryFallback ?? _isFlutterTestEnvironment(); _secretStore = SecretStore( fallbackDirectoryPathResolver: fallbackDirectoryPathResolver, databasePathResolver: databasePathResolver, + defaultSupportDirectoryPathResolver: + resolvedDefaultSupportDirectoryPathResolver, + allowInMemoryFallback: resolvedAllowInMemoryFallback, secureStorage: secureStorage, enableSecureStorage: enableSecureStorage, ); _settingsStore = SettingsStore( fallbackDirectoryPathResolver: fallbackDirectoryPathResolver, databasePathResolver: databasePathResolver, + defaultSupportDirectoryPathResolver: + resolvedDefaultSupportDirectoryPathResolver, + allowInMemoryFallback: resolvedAllowInMemoryFallback, databaseOpener: databaseOpener, legacyLocalStateKeyLoader: _secretStore.loadLegacyLocalStateKeyBytes, ); @@ -147,3 +162,37 @@ class SecureConfigStore { return SecretStore.maskValue(value); } } + +bool _isFlutterTestEnvironment() => + Platform.environment.containsKey('FLUTTER_TEST'); + +const String _defaultBundleIdentifier = 'plus.svc.xworkmate'; + +Future _resolveDefaultSupportDirectoryPath() async { + final home = Platform.environment['HOME']?.trim() ?? ''; + if (home.isNotEmpty) { + if (Platform.isMacOS) { + return '$home/Library/Application Support/$_defaultBundleIdentifier/xworkmate'; + } + if (Platform.isLinux) { + final xdgStateHome = Platform.environment['XDG_STATE_HOME']?.trim() ?? ''; + if (xdgStateHome.isNotEmpty) { + return '$xdgStateHome/$_defaultBundleIdentifier/xworkmate'; + } + return '$home/.local/state/$_defaultBundleIdentifier/xworkmate'; + } + } + + if (Platform.isWindows) { + final appData = Platform.environment['APPDATA']?.trim() ?? ''; + if (appData.isNotEmpty) { + return '$appData\\$_defaultBundleIdentifier\\xworkmate'; + } + final localAppData = Platform.environment['LOCALAPPDATA']?.trim() ?? ''; + if (localAppData.isNotEmpty) { + return '$localAppData\\$_defaultBundleIdentifier\\xworkmate'; + } + } + + return null; +} diff --git a/lib/runtime/settings_store.dart b/lib/runtime/settings_store.dart index 9e2cab8b..47268edc 100644 --- a/lib/runtime/settings_store.dart +++ b/lib/runtime/settings_store.dart @@ -17,10 +17,15 @@ class SettingsStore { SettingsStore({ Future Function()? fallbackDirectoryPathResolver, Future Function()? databasePathResolver, + Future Function()? defaultSupportDirectoryPathResolver, + bool allowInMemoryFallback = false, SecureConfigDatabaseOpener? databaseOpener, Future?> Function()? legacyLocalStateKeyLoader, }) : _fallbackDirectoryPathResolver = fallbackDirectoryPathResolver, _databasePathResolver = databasePathResolver, + _defaultSupportDirectoryPathResolver = + defaultSupportDirectoryPathResolver, + _allowInMemoryFallback = allowInMemoryFallback, _databaseOpener = databaseOpener, _legacyLocalStateKeyLoader = legacyLocalStateKeyLoader; @@ -39,12 +44,16 @@ class SettingsStore { final Future Function()? _fallbackDirectoryPathResolver; final Future Function()? _databasePathResolver; + final Future Function()? _defaultSupportDirectoryPathResolver; + final bool _allowInMemoryFallback; final SecureConfigDatabaseOpener? _databaseOpener; final Future?> Function()? _legacyLocalStateKeyLoader; final Cipher _legacyCipher = AesGcm.with256bits(); final Map _memoryStore = {}; SharedPreferences? _prefs; sqlite.Database? _database; + String? _resolvedDatabasePath; + bool _usingInMemoryDatabase = false; bool _initialized = false; bool _recoveryAttempted = false; LegacyRecoveryReport _lastRecoveryReport = const LegacyRecoveryReport(); @@ -140,6 +149,9 @@ class SettingsStore { } void dispose() { + if (_usingInMemoryDatabase) { + unawaited(_syncInMemoryStoreToDurableStore()); + } final database = _database; _database = null; if (database != null) { @@ -151,27 +163,39 @@ class SettingsStore { } _prefs = null; _initialized = false; + _resolvedDatabasePath = null; + _usingInMemoryDatabase = false; _memoryStore.clear(); } Future _initializeDatabase() async { - final resolvedPath = await _resolveDatabasePath(); - if (resolvedPath != null && resolvedPath.trim().isNotEmpty) { + final candidates = await _resolveDatabasePathCandidates(); + for (final resolvedPath in candidates) { try { _database = await _openDatabase(resolvedPath); + _resolvedDatabasePath = resolvedPath; + _usingInMemoryDatabase = false; + break; } catch (_) { _database = null; } } - if (_database == null) { + if (_database == null && _allowInMemoryFallback) { try { final database = sqlite.sqlite3.openInMemory(); _configureDatabase(database); _database = database; + _usingInMemoryDatabase = true; } catch (_) { _database = null; + _usingInMemoryDatabase = false; } } + if (_database == null) { + throw StateError( + 'Durable settings storage unavailable: cannot resolve or open $databaseFileName. Candidates: ${candidates.join(', ')}', + ); + } await _migrateLegacyPrefs(); } @@ -317,6 +341,8 @@ class SettingsStore { final results = {}; final databasePath = await _resolveDatabasePath(); final fallbackRoot = await _fallbackDirectoryPathResolver?.call(); + final defaultSupportRoot = await _defaultSupportDirectoryPathResolver + ?.call(); String? supportPath; try { supportPath = (await getApplicationSupportDirectory()).path; @@ -339,6 +365,7 @@ class SettingsStore { } addPath(fallbackRoot); addPath(fallbackRoot == null ? null : '$fallbackRoot/xworkmate'); + addPath(defaultSupportRoot); addPath(supportPath); addPath(supportPath == null ? null : '$supportPath/xworkmate'); return results.toList(growable: false); @@ -553,27 +580,58 @@ class SettingsStore { } } - Future _resolveDatabasePath() async { + Future> _resolveDatabasePathCandidates() async { + final candidates = {}; + + void addPath(String? path) { + final trimmed = path?.trim() ?? ''; + if (trimmed.isNotEmpty) { + candidates.add(trimmed); + } + } + try { final resolvedPath = await _databasePathResolver?.call(); - final trimmed = resolvedPath?.trim() ?? ''; - if (trimmed.isNotEmpty) { - return trimmed; - } + addPath(resolvedPath); } catch (_) { // Fall through to the default locations. } + try { final supportDirectory = await getApplicationSupportDirectory(); - return '${supportDirectory.path}/xworkmate/$databaseFileName'; + addPath('${supportDirectory.path}/xworkmate/$databaseFileName'); } catch (_) { - final fallbackRoot = await _fallbackDirectoryPathResolver?.call(); - final trimmed = fallbackRoot?.trim() ?? ''; - if (trimmed.isEmpty) { - return null; - } - return '$trimmed/$databaseFileName'; + // Continue below to deterministic fallbacks. } + + try { + final fallbackRoot = await _fallbackDirectoryPathResolver?.call(); + addPath('${fallbackRoot?.trim()}/$databaseFileName'); + } catch (_) { + // Continue to default support directory fallback. + } + + try { + final defaultSupportRoot = await _defaultSupportDirectoryPathResolver + ?.call(); + addPath('${defaultSupportRoot?.trim()}/$databaseFileName'); + } catch (_) { + // Ignore and fall through. + } + + return candidates.toList(growable: false); + } + + Future _resolveDatabasePath() async { + final resolved = _resolvedDatabasePath?.trim() ?? ''; + if (resolved.isNotEmpty) { + return resolved; + } + final candidates = await _resolveDatabasePathCandidates(); + if (candidates.isEmpty) { + return null; + } + return candidates.first; } Future _readStoredString(String key) async { @@ -630,11 +688,17 @@ class SettingsStore { ''', [key, trimmed, DateTime.now().millisecondsSinceEpoch], ); + if (_usingInMemoryDatabase) { + await _syncInMemoryStoreToDurableStore(); + } return; } catch (_) { // Fall through to durable file fallback. } } + if (_usingInMemoryDatabase) { + await _syncInMemoryStoreToDurableStore(); + } } Future _deleteStoredString(String key) async { @@ -672,6 +736,21 @@ class SettingsStore { return File('${directory.path}/$fileName'); } + Future _durableStateFileForPath( + String key, + String databasePath, + ) async { + final fileName = _durableStateFileNames[key]; + if (fileName == null) { + return null; + } + final directory = File(databasePath).parent; + if (!await directory.exists()) { + await directory.create(recursive: true); + } + return File('${directory.path}/$fileName'); + } + Future _readDurableStateFile(String key) async { final file = await _durableStateFile(key); if (file == null || !await file.exists()) { @@ -689,6 +768,66 @@ class SettingsStore { await file.writeAsString(value, flush: true); } + Future _syncInMemoryStoreToDurableStore() async { + if (!_usingInMemoryDatabase || _memoryStore.isEmpty) { + return; + } + final candidates = await _resolveDatabasePathCandidates(); + if (candidates.isEmpty) { + return; + } + for (final candidate in candidates) { + sqlite.Database? durableDatabase; + try { + durableDatabase = await _openDatabase(candidate); + if (durableDatabase == null) { + continue; + } + final updatedAtMs = DateTime.now().millisecondsSinceEpoch; + for (final entry in _memoryStore.entries) { + durableDatabase.execute( + ''' + INSERT INTO $databaseTableName (storage_key, value, updated_at_ms) + VALUES (?, ?, ?) + ON CONFLICT(storage_key) DO UPDATE SET + value = excluded.value, + updated_at_ms = excluded.updated_at_ms + ''', + [entry.key, entry.value, updatedAtMs], + ); + final durableFile = await _durableStateFileForPath( + entry.key, + candidate, + ); + if (durableFile != null) { + await durableFile.writeAsString(entry.value, flush: true); + } + } + final previousDatabase = _database; + _database = durableDatabase; + _resolvedDatabasePath = candidate; + _usingInMemoryDatabase = false; + if (previousDatabase != null && + !identical(previousDatabase, _database)) { + try { + previousDatabase.dispose(); + } catch (_) { + // Ignore close errors during promotion. + } + } + return; + } catch (_) { + if (durableDatabase != null) { + try { + durableDatabase.dispose(); + } catch (_) { + // Ignore close errors while probing candidates. + } + } + } + } + } + Future _deleteDurableStateFile(String key) async { final file = await _durableStateFile(key); if (file == null || !await file.exists()) { diff --git a/pubspec.yaml b/pubspec.yaml index bf2fe100..9cf551f9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -2,7 +2,7 @@ name: xworkmate description: "XWorkmate desktop-first AI workspace shell." publish_to: 'none' -version: 0.6.0+1 +version: 0.6.1+1 build-date: 2026-03-20 build-id: 4183a40 diff --git a/test/runtime/secure_config_store_suite.dart b/test/runtime/secure_config_store_suite.dart index 31a071bb..99c8c748 100644 --- a/test/runtime/secure_config_store_suite.dart +++ b/test/runtime/secure_config_store_suite.dart @@ -148,6 +148,88 @@ void main() { }, ); + test( + 'SecureConfigStore throws when durable settings path cannot be opened and in-memory fallback is disabled', + () async { + SharedPreferences.setMockInitialValues({}); + final tempDirectory = await Directory.systemTemp.createTemp( + 'xworkmate-config-store-fail-fast-', + ); + addTearDown(() async { + if (await tempDirectory.exists()) { + await tempDirectory.delete(recursive: true); + } + }); + final store = SecureConfigStore( + allowInMemoryFallback: false, + databasePathResolver: () async => + '${tempDirectory.path}/settings.sqlite3', + databaseOpener: (_) => throw StateError('sqlite open failed'), + ); + + await expectLater( + store.loadSettingsSnapshot(), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('Durable settings storage unavailable'), + ), + ), + ); + }, + ); + + test( + 'SecureConfigStore persists across instances using default support fallback when primary resolvers fail', + () async { + SharedPreferences.setMockInitialValues({}); + final tempDirectory = await Directory.systemTemp.createTemp( + 'xworkmate-config-store-default-support-', + ); + addTearDown(() async { + if (await tempDirectory.exists()) { + await tempDirectory.delete(recursive: true); + } + }); + final defaultSupportRoot = + '${tempDirectory.path}/plus.svc.xworkmate/xworkmate'; + + final firstStore = SecureConfigStore( + allowInMemoryFallback: false, + databasePathResolver: () async => + throw StateError('primary unavailable'), + fallbackDirectoryPathResolver: () async => + throw StateError('fallback unavailable'), + defaultSupportDirectoryPathResolver: () async => defaultSupportRoot, + ); + final snapshot = SettingsSnapshot.defaults().copyWith( + accountUsername: 'fallback-user', + ); + await firstStore.saveSettingsSnapshot(snapshot); + await firstStore.saveGatewayToken('fallback-token'); + + final secondStore = SecureConfigStore( + allowInMemoryFallback: false, + databasePathResolver: () async => + throw StateError('primary unavailable'), + fallbackDirectoryPathResolver: () async => + throw StateError('fallback unavailable'), + defaultSupportDirectoryPathResolver: () async => defaultSupportRoot, + ); + + final loadedSnapshot = await secondStore.loadSettingsSnapshot(); + final loadedToken = await secondStore.loadGatewayToken(); + final databaseFile = File( + '$defaultSupportRoot/${SettingsStore.databaseFileName}', + ); + + expect(await databaseFile.exists(), isTrue); + expect(loadedSnapshot.accountUsername, 'fallback-user'); + expect(loadedToken, 'fallback-token'); + }, + ); + test( 'SecureConfigStore migrates legacy secret fallback files into primary secure storage', () async {