release: prepare v0.6.1

This commit is contained in:
Haitao Pan 2026-03-22 23:19:18 +08:00
parent f921feb636
commit 817efdb71a
13 changed files with 520 additions and 135 deletions

View File

@ -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

View File

@ -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

View File

@ -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 状态。
- 清理流程不删除已保存 secretsGateway token/password、AI Gateway API key、Vault token 等)。
### 4) 路径解析失败策略(默认)
- 默认策略为 `fail-fast`:当 `SettingsStore` 无法解析或打开耐久数据库路径时,直接抛错,不再静默降级为内存持久化。
- 这样可以避免“看起来保存成功、重启后全部丢失”的隐性故障。
### 5) 内存回退(仅显式开启场景)
- 仅在显式开启 `allowInMemoryFallback`(主要用于测试/诊断)时允许内存回退。
- 即使发生内存回退,也会在后续写入和销毁阶段尽力回写同步到耐久目录(若路径恢复可用)。
核心结论:
- `FlutterSecureStorage` 仍是长期 secret 的主存储。

View File

@ -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.

View File

@ -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 |

View File

@ -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

View File

@ -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 |

View File

@ -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

View File

@ -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();

View File

@ -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;
}

View File

@ -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()) {

View File

@ -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

View File

@ -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 {