release: prepare v0.6.1
This commit is contained in:
parent
f921feb636
commit
817efdb71a
13
CHANGELOG.md
13
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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 的主存储。
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -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 |
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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 |
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -106,10 +106,15 @@ class SecretStore {
|
||||
SecretStore({
|
||||
Future<String?> Function()? fallbackDirectoryPathResolver,
|
||||
Future<String?> Function()? databasePathResolver,
|
||||
Future<String?> 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<String, String> _memorySecure = <String, String>{};
|
||||
final Future<String?> Function()? _fallbackDirectoryPathResolver;
|
||||
final Future<String?> Function()? _databasePathResolver;
|
||||
final Future<String?> 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<void> _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<String?> loadGatewayToken() => _readSecure(_gatewayTokenKey);
|
||||
|
||||
Future<void> saveGatewayToken(String value) =>
|
||||
@ -308,6 +334,9 @@ class SecretStore {
|
||||
}
|
||||
|
||||
Future<void> 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<Directory?> _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<void> _syncMemorySecretsToDurableStore() async {
|
||||
if (_memorySecure.isEmpty) {
|
||||
return;
|
||||
}
|
||||
if (_secureStorage == null || _secureStorage is MemorySecureStorageClient) {
|
||||
final promoted = await _promoteToFileSecureStorageFallback();
|
||||
if (!promoted || _secureStorage == null) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
final snapshot = Map<String, String>.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<bool> _promoteToFileSecureStorageForTests() async {
|
||||
Future<bool> _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();
|
||||
|
||||
@ -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<String?> Function()? fallbackDirectoryPathResolver,
|
||||
Future<String?> Function()? databasePathResolver,
|
||||
Future<String?> 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<String?> _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;
|
||||
}
|
||||
|
||||
@ -17,10 +17,15 @@ class SettingsStore {
|
||||
SettingsStore({
|
||||
Future<String?> Function()? fallbackDirectoryPathResolver,
|
||||
Future<String?> Function()? databasePathResolver,
|
||||
Future<String?> Function()? defaultSupportDirectoryPathResolver,
|
||||
bool allowInMemoryFallback = false,
|
||||
SecureConfigDatabaseOpener? databaseOpener,
|
||||
Future<List<int>?> Function()? legacyLocalStateKeyLoader,
|
||||
}) : _fallbackDirectoryPathResolver = fallbackDirectoryPathResolver,
|
||||
_databasePathResolver = databasePathResolver,
|
||||
_defaultSupportDirectoryPathResolver =
|
||||
defaultSupportDirectoryPathResolver,
|
||||
_allowInMemoryFallback = allowInMemoryFallback,
|
||||
_databaseOpener = databaseOpener,
|
||||
_legacyLocalStateKeyLoader = legacyLocalStateKeyLoader;
|
||||
|
||||
@ -39,12 +44,16 @@ class SettingsStore {
|
||||
|
||||
final Future<String?> Function()? _fallbackDirectoryPathResolver;
|
||||
final Future<String?> Function()? _databasePathResolver;
|
||||
final Future<String?> Function()? _defaultSupportDirectoryPathResolver;
|
||||
final bool _allowInMemoryFallback;
|
||||
final SecureConfigDatabaseOpener? _databaseOpener;
|
||||
final Future<List<int>?> Function()? _legacyLocalStateKeyLoader;
|
||||
final Cipher _legacyCipher = AesGcm.with256bits();
|
||||
final Map<String, String> _memoryStore = <String, String>{};
|
||||
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<void> _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 = <String>{};
|
||||
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<String?> _resolveDatabasePath() async {
|
||||
Future<List<String>> _resolveDatabasePathCandidates() async {
|
||||
final candidates = <String>{};
|
||||
|
||||
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<String?> _resolveDatabasePath() async {
|
||||
final resolved = _resolvedDatabasePath?.trim() ?? '';
|
||||
if (resolved.isNotEmpty) {
|
||||
return resolved;
|
||||
}
|
||||
final candidates = await _resolveDatabasePathCandidates();
|
||||
if (candidates.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
return candidates.first;
|
||||
}
|
||||
|
||||
Future<String?> _readStoredString(String key) async {
|
||||
@ -630,11 +688,17 @@ class SettingsStore {
|
||||
''',
|
||||
<Object?>[key, trimmed, DateTime.now().millisecondsSinceEpoch],
|
||||
);
|
||||
if (_usingInMemoryDatabase) {
|
||||
await _syncInMemoryStoreToDurableStore();
|
||||
}
|
||||
return;
|
||||
} catch (_) {
|
||||
// Fall through to durable file fallback.
|
||||
}
|
||||
}
|
||||
if (_usingInMemoryDatabase) {
|
||||
await _syncInMemoryStoreToDurableStore();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _deleteStoredString(String key) async {
|
||||
@ -672,6 +736,21 @@ class SettingsStore {
|
||||
return File('${directory.path}/$fileName');
|
||||
}
|
||||
|
||||
Future<File?> _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<String?> _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<void> _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
|
||||
''',
|
||||
<Object?>[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<void> _deleteDurableStateFile(String key) async {
|
||||
final file = await _durableStateFile(key);
|
||||
if (file == null || !await file.exists()) {
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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(<String, Object>{});
|
||||
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<StateError>().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(<String, Object>{});
|
||||
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 {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user