diff --git a/docs/architecture.md b/docs/architecture.md index 55c6947b..190e46ba 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -71,7 +71,7 @@ Tauri 在 `src-tauri/src/coordinator.rs` 构造 Core,`core_adapters.rs` 组装 Siri、Classic、Typeless 三种胶囊共用 Core 的 `CapsuleStyle`,窗口尺寸与点击范围在保存偏好时同步。胶囊按显示器工作区底部定位,避开未自动隐藏的 Dock/任务栏;可见期间重新检查工作区。带正文的浮窗使用不透明底色,聊天面板另叠加细噪点纹理,圆角外部仍保留透明区域。 -思考动画覆盖转写、润色和原生文字写入,输入完成后才收尾。macOS 流式键盘输入在可读取 AX 光标的控件上等待原控件光标到达本批文字末尾,再完成写入和恢复输入源;仅读选区范围,不读正文。不可读、提交型 Return 或等待超过 10 秒时回到按键发送完成语义,本次会话停止继续探测该控件。目标应用的实际输入表现仍需设备验收。 +思考动画覆盖转写、润色和原生文字写入,输入完成后才收尾。macOS 流式键盘输入由会话内串行 worker 维护原控件和累计 UTF-16 终点;每批发送后不再等待 AX 长确认,finish/cancel 在已接收写入之后等待最终屏障,再恢复输入源。AX 仅读选区范围,不读正文;采用 250 ms 无进展预算和 10 秒总预算。不可读、提交型 Return 或预算耗尽时明确降级到按键已发送语义,不重新粘贴已发送文字。目标应用的实际输入表现仍需设备验收。 选区直接润色在捕获文字和原输入目标后显示处理中提示,重复快捷键的 Busy 返回不覆盖该提示。已有语音选区入口在松开快捷键后继续显示思考动画,直到处理/替换完成,或交给确认和预览面板。录音提示音的 Web Audio context 在恢复超时或音频时钟冻结时丢弃并最多重试一次,重试沿用原请求的取消和迟到边界。 @@ -86,7 +86,8 @@ Siri、Classic、Typeless 三种胶囊共用 Core 的 `CapsuleStyle`,窗口尺 | 服务凭据 | Core `CredentialStore` 合同,Tauri keyring/Android Keystore 或 Linux `credentials.rs` 适配 | | 云端 ASR / LLM | Core provider 目录、选择与传输模块;平台本地引擎位于 `src-tauri/src/asr/local/` 或 Linux Host | | 风格包市场 | Core `marketplace.rs` 管理 HTTP、GitHub device flow 与本地安装;地址由 `MarketplaceConfig` 注入,内置默认值在该模块 | -| 私有云同步 | Core `cloud_sync.rs` 复用同一 GitHub 登录与官方服务地址,按版本同步词典、纠错、风格包和允许的个人偏好;`cloud_sync_transaction.rs` 在本地恢复失败时回滚文件,凭据和设备配置保持本机所有。合同及边界见 [官方云同步](cloud-sync.md) | +| 加密云同步 | Core `cloud_sync_e2ee` 复用 GitHub 登录并交换独立同步会话,通过 `/v1/...` 保存客户端加密快照;protocol/documents/store 分别负责加密协议、登记与合并、仓库及系统凭据的受控恢复。默认关闭,凭据、本地基线和回滚日志不交给 UI。详情与验证边界见 [加密云同步客户端](encrypted-cloud-sync.md) | +| 旧手动同步 | `cloud_sync.rs` 和 `/me/sync` 保留有限明文快照合同;旧入口不上传新加密文档中的服务密钥。已注册加密恢复 gate 的仓库拒绝旧多文件恢复,防止绕过受控恢复;未注册的旧 Host 保留原合同。见 [旧同步合同](cloud-sync.md) | | 风格图标 | React `src/lib/stylePackIcon.ts` 清理上传的 SVG 并转成 PNG;`set_style_pack_icon` / `read_style_pack_icon` 经 Core `style_pack_store.rs` 保存资源、校验读取范围并返回图片 data URL。图标沿用 ZIP 的 64 KiB 限制,与风格包一起导出 | | 局域网手机输入 | Core `remote_input_service.rs` 定义共享业务,Tauri `remote_server/` 提供本机网络入口和网页资源 | diff --git a/docs/cloud-sync.md b/docs/cloud-sync.md index a1b852eb..9d400cf0 100644 --- a/docs/cloud-sync.md +++ b/docs/cloud-sync.md @@ -1,6 +1,6 @@ # 官方云同步 -> 本文描述已实现的旧手动快照协议。新的全量加密云同步需求与接口在工作区 `5-cloud-sync/docs/`,尚未实现;不得向下述旧 `/me/sync` 上传服务 API 密钥或历史全量明文。项目入口由工作区文档总索引维护。 +> 本文描述旧手动快照协议。Beta 3 候选版的新客户端实现及验收边界见 [加密云同步客户端](encrypted-cloud-sync.md),协议合同在工作区 `5-cloud-sync/docs/`。不得向下述旧 `/me/sync` 上传服务 API 密钥或历史全量明文;已注册加密恢复 gate 的仓库拒绝旧多文件恢复,防止绕过加密恢复事务。 状态:手动快照同步,HTTP 合同版本 1。客户端需要官方服务同时提供 `/me/sync`;旧版服务返回 404/405/501 时显示暂不支持,不能将其当成空备份。生产同步地址为 `https://apic.openless.top:9443`(与市场后端同一服务器、独立端口),服务端连接细节见 [云同步服务端交接](cloud-sync-server-handoff.md)。 diff --git a/docs/encrypted-cloud-sync.md b/docs/encrypted-cloud-sync.md new file mode 100644 index 00000000..7d4a5c91 --- /dev/null +++ b/docs/encrypted-cloud-sync.md @@ -0,0 +1,42 @@ +# 加密云同步客户端 + +实现版本:`2.0.0-Beta.3+build.20260925`。协议版本为 `1`,密码学 profile 为 `argon2id-xchacha20poly1305-v1`。本页描述客户端实现和验证边界;正式发布状态以对应 GitHub Release 为准。 + +## 地址与协议边界 + +新客户端使用独立入口 `https://apic.openless.top:9443/v1/...`。启动同步前读取 `/v1/capabilities`,核对协议、体积上限及现有 OpenLess GitHub OAuth client ID,再使用原生层的 GitHub 登录令牌交换短期同步会话。数字 GitHub ID 是账号隔离依据。新请求不回退到旧 `/me/sync`;旧四个 `cloud_sync_*` 命令不改接新协议。旧 standalone 仓库仍保留有限明文快照语义;已注册加密同步写入门的仓库暂不支持旧 `cloud_sync_restore`,明确返回 Unsupported,避免绕过加密恢复事务。 + +2026-09-26 的不带凭据检查中,正式 origin 的 `/v1/capabilities` 返回了协议 `1`、匹配的 OAuth client ID 与加密 profile,备份保留上限为 7 天。这仅证明能力接口当时可用,不代表真实账号授权、私有数据往返或生产验收已经完成。 + +## 原生边界 + +- `crates/openless-core/src/cloud_sync_e2ee_protocol/`:严格 wire DTO、规范编码、Argon2id、XChaCha20-Poly1305、带身份与版本的 AAD、HTTPS/CAS/幂等收据。 +- `crates/openless-core/src/cloud_sync_e2ee_documents/`:逐字段登记、11 类逻辑文档、渠道和凭据一起合并、删除标记、脱敏冲突与加密恢复日志。 +- `crates/openless-core/src/cloud_sync_e2ee_store/`:受控仓库、持久化代次、写隔离、完整恢复与读回验证。 +- `crates/openless-core/src/cloud_sync_e2ee/`:默认关闭的状态机、密码解锁、创建、同步、改密、删除、原生凭据库中的记住密钥与加密本地基线。 +- `src-tauri/src/commands/cloud_sync_e2ee.rs`:仅主窗口的本地 IPC;`src/lib/ipc/cloud-sync-e2ee.ts` 为对应 typed wrapper。浏览器预览返回 unavailable。 +- `src/lib/encryptedSyncUiBridge.ts`:主窗口中实际语言/字号偏好的受控镜像,以及恢复成功后的界面更新。 + +上传只发送密文。密码和派生密钥不进入服务器请求;系统凭据库只导出登记过的服务账号,不复制 keyring/Keystore 文件。普通服务 API Key、接口设置、文本历史和风格内容纳入加密范围;OAuth 登录态、同步令牌、同步密码/派生密钥、设备私钥、远程输入 PIN、系统权限、录音、模型权重、缓存和程序不进入快照。 + +## 行为 + +首次开启需明确确认完整范围;已有云端快照必须验证 AEAD 并展示恢复预览,不能用新设备默认值直接覆盖。密码按 NFC 规范化,创建/改密执行本地强度策略。记住密钥由系统安全存储实现,并绑定服务 origin、数字账号、vault、key 和设备。 + +共同基线、未确认操作及恢复资料在本机加密。网络丢失响应时保留原始请求体和操作 ID,先核对收据,必要时仅重送同一请求。不能将 404 收据当作“未提交”。上传中发生的新本地代次仍待同步。删除状态会停用自动上传,不能由旧设备自动重建。 + +自动同步仅在已开启、已登录且已解锁时运行:一次启动触发,持久化变更后 750 ms 防抖、最长 5 秒;没有周期轮询或无限重试。关闭与删除是独立操作。跨设备路径和平台设置以设备档案保留,恢复不授予系统权限或自动执行 Agent。 + +完成有效服务配置后,本机首次引导只申请一次持久化提示。凭据读取失败、配置正在保存、录音/输入/恢复繁忙时不申请;提示不访问同步网络、不启用上传。欢迎流程可直接打开“从云端恢复”,无需先填写服务密钥。窗口隐藏、失焦或任务开始后会丢弃迟到的提示结果。 + +短暂的本机写入竞争最多触发 4 次有界延后重试;网络失败、结果未知、版本冲突不进入该重试。后续手动任务、锁定或停用会淘汰旧重试链。同步连接遵循当前系统代理偏好,策略变化后重建连接,回环测试始终直连。 + +系统自启动 `launchAtLogin` 不进入远端文档或设备档案;保留接收设备本机值,实际 OS 状态仍由现有 UI 自启动插件管理,恢复不宣称替用户重建自启动授权。 + +恢复必须绑定 `RestoreRuntimeEffects` 的真实 Host 适配器。所有仓库写入后,在同一加密 journal、独占写入门和运行态屏障内等待绝对目标设置生效;副作用失败按 before 镜像回滚。只保存在本地 journal 的 Agent 开关、provider 镜像和录音标记恢复后,再应用最终旧设置;若回滚副作用也失败,保留 recovery_required,不能提前放行录音或 Agent。未绑定适配器明确失败,不使用空实现代替平台确认。 + +## 验证边界 + +协议测试使用公开互操作向量;服务测试使用 loopback 模拟 OAuth/同步端和内存凭据库,不触碰真实账号或用户钥匙串。恢复层须单独覆盖文件/凭据故障、进程中断、账号/版本隔离及排除项。通过协议测试不能替代真实平台钥匙串、两设备恢复与正式 OAuth 的人工验收。 + +发布遵循 [RELEASING.md](../RELEASING.md) 的对应提交 CI、签名和资产检查流程。真实 GitHub 授权及两台设备的生产数据往返仍须单独验收,不能由本地模拟服务器的通过结果代替。 diff --git a/docs/index.md b/docs/index.md index 8838ee99..fe3f406d 100644 --- a/docs/index.md +++ b/docs/index.md @@ -17,6 +17,8 @@ ## 接口契约 +- [加密云同步客户端](encrypted-cloud-sync.md):Beta 3 新协议、受控数据范围、系统凭据库、恢复与验证边界。 + - [官方云同步](cloud-sync.md):可同步字段、GitHub 身份边界、版本冲突、本地恢复与验证。 - [云同步服务端交接](cloud-sync-server-handoff.md):同步服务地址与端口(apic.openless.top:9443)、客户端请求行为、状态码映射与服务端验收清单。 - [Linux egui 后端契约](linux-egui-backend-contract.md):`contract/backend-2.0.json`、启动快照、事件面与公开签名。 diff --git a/docs/structure.md b/docs/structure.md index 8d1ac96d..26569f11 100644 --- a/docs/structure.md +++ b/docs/structure.md @@ -41,7 +41,8 @@ | 共享业务入口 | `crates/openless-core/src/api.rs` | `events.rs`、`ports.rs`、`domains.rs`、`config.rs` | | 听写和服务 | Core `dictation_engine.rs`、`provider_*`、`asr/`、`polish.rs` | Host 的录音、插入和本地模型适配 | | 历史、词库、纠错、风格包 | Core `history.rs`、`vocabulary.rs`、`correction.rs`、`style_pack_store.rs` | Tauri `persistence/` 与对应 command | -| 官方云同步 | Core `cloud_sync.rs`、`cloud_sync_types.rs`、`cloud_sync_validation.rs`、`cloud_sync_transaction.rs` | Tauri `commands/cloud_sync.rs`;GitHub 身份、有限字段与版本冲突见 [云同步合同](cloud-sync.md) | +| 加密云同步 | Core `cloud_sync_e2ee/`、`cloud_sync_e2ee_protocol/`、`cloud_sync_e2ee_documents/`、`cloud_sync_e2ee_store/` | Tauri `commands/cloud_sync_e2ee.rs`、系统凭据适配及 `src/lib/encryptedSyncUiBridge.ts`;见 [客户端边界](encrypted-cloud-sync.md) | +| 旧手动同步 | Core `cloud_sync.rs`、`cloud_sync_types.rs`、`cloud_sync_validation.rs`、`cloud_sync_transaction.rs` | Tauri `commands/cloud_sync.rs`;仅保留旧有限字段协议,见 [旧同步合同](cloud-sync.md) | | Tauri 组装与系统能力 | `src-tauri/src/coordinator.rs`、`core_adapters.rs`、`tauri_coordinator_host.rs` | 窗口、热键、权限、平台输入与生命周期 | | Linux 原生接入 | `linux-egui/src/main.rs`、`lib.rs`、`backend.rs` | `audio/credentials/fcitx5/hotkeys/settings` 等 Host 模块;见 [交接](linux-egui-handoff/README.md) | | Android 集成 | `android/`、`src-tauri/src/android/` | `@android` 别名与 `merge-android-*.mjs` 生成链 | diff --git a/openless-all/app/Cargo.lock b/openless-all/app/Cargo.lock index c9456d7b..d0091d98 100644 --- a/openless-all/app/Cargo.lock +++ b/openless-all/app/Cargo.lock @@ -114,6 +114,16 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + [[package]] name = "aes" version = "0.8.4" @@ -238,6 +248,19 @@ dependencies = [ "x11rb", ] +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures 0.2.17", + "password-hash", + "zeroize", +] + [[package]] name = "arrayvec" version = "0.7.8" @@ -559,6 +582,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bit-set" version = "0.8.0" @@ -586,6 +615,15 @@ version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -824,6 +862,17 @@ dependencies = [ "libc", ] +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + [[package]] name = "chacha20" version = "0.10.2" @@ -835,6 +884,19 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20 0.9.1", + "cipher", + "poly1305", + "zeroize", +] + [[package]] name = "chrono" version = "0.4.45" @@ -854,6 +916,7 @@ checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ "crypto-common", "inout", + "zeroize", ] [[package]] @@ -1047,6 +1110,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", + "rand_core 0.6.4", "typenum", ] @@ -3243,16 +3307,25 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "openless-core" version = "0.1.0" dependencies = [ "anyhow", + "argon2", "base64", "bzip2 0.4.4", + "chacha20poly1305", "chrono", "ferrous-opencc", "futures-util", + "getrandom 0.3.4", "hmac", "log", "md-5", @@ -3268,8 +3341,10 @@ dependencies = [ "thiserror 1.0.69", "tokio", "tokio-tungstenite", + "unicode-normalization", "url", "uuid", + "zeroize", "zip", ] @@ -3370,6 +3445,17 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "pbkdf2" version = "0.12.2" @@ -3531,6 +3617,17 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + [[package]] name = "portable-atomic" version = "1.15.0" @@ -3764,7 +3861,7 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ - "chacha20", + "chacha20 0.10.2", "getrandom 0.4.3", "rand_core 0.10.1", ] @@ -4895,6 +4992,15 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + [[package]] name = "unicode-segmentation" version = "1.13.3" @@ -4907,6 +5013,16 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + [[package]] name = "untrusted" version = "0.9.0" diff --git a/openless-all/app/contract/backend-2.0.json b/openless-all/app/contract/backend-2.0.json index f8a0a832..053fd426 100644 --- a/openless-all/app/contract/backend-2.0.json +++ b/openless-all/app/contract/backend-2.0.json @@ -50,6 +50,9 @@ "history_changed", "vocabulary_changed", "style_packs_changed", + "cloud_sync_state_changed", + "cloud_sync_conflict_detected", + "cloud_sync_restore_completed", "download_progress", "permission_changed", "hotkey_status_changed", @@ -82,6 +85,9 @@ "history_changed": {"sequence": 1, "sessionId": null, "kind": {"type": "history_changed", "payload": {"revision": 1}}}, "vocabulary_changed": {"sequence": 1, "sessionId": null, "kind": {"type": "vocabulary_changed", "payload": {"revision": 1}}}, "style_packs_changed": {"sequence": 1, "sessionId": null, "kind": {"type": "style_packs_changed", "payload": {"revision": 1}}}, + "cloud_sync_state_changed": {"sequence": 1, "sessionId": null, "kind": {"type": "cloud_sync_state_changed", "payload": {"sequence": "1", "accountId": null, "vaultId": null, "taskId": null, "status": {"sequence": "1", "enabled": false, "authState": "signed_out", "keyState": "locked", "syncState": "disabled", "account": null, "vaultId": null, "keyId": null, "localGeneration": "0", "lastSyncedLocalGeneration": null, "remoteRevision": null, "lastSuccessfulSyncAt": null, "pendingOperationId": null, "lastError": null, "recoveryRequired": false, "hasCloudSnapshot": null, "taskId": null, "serviceOrigin": "https://apic.openless.top:9443", "consentVersion": null, "backupRetentionDays": null}}}}, + "cloud_sync_conflict_detected": {"sequence": 1, "sessionId": null, "kind": {"type": "cloud_sync_conflict_detected", "payload": {"sequence": "1", "accountId": "118526", "vaultId": "11111111-1111-4111-8111-111111111111", "taskId": null, "preview": {"previewId": "22222222-2222-4222-8222-222222222222", "unconfirmedOperationId": null, "observedRevision": "1", "localGeneration": "1", "counts": {}, "deviceSettingsToReview": [], "conflicts": []}}}}, + "cloud_sync_restore_completed": {"sequence": 1, "sessionId": null, "kind": {"type": "cloud_sync_restore_completed", "payload": {"sequence": "1", "accountId": "118526", "vaultId": "11111111-1111-4111-8111-111111111111", "taskId": null, "localGeneration": "1", "uiPreferences": {"locale": "zh-CN", "fontScale": "medium"}}}}, "download_progress": {"sequence": 1, "sessionId": null, "kind": {"type": "download_progress", "payload": {"resourceId": "model", "completedBytes": 1, "totalBytes": null}}}, "permission_changed": {"sequence": 1, "sessionId": null, "kind": {"type": "permission_changed", "payload": {"microphone": "unknown", "accessibility": "unknown"}}}, "hotkey_status_changed": {"sequence": 1, "sessionId": null, "kind": {"type": "hotkey_status_changed", "payload": {"adapter": "unavailable", "state": "starting", "message": null, "lastError": null}}}, diff --git a/openless-all/app/crates/openless-core/Cargo.toml b/openless-all/app/crates/openless-core/Cargo.toml index a95160f0..e58f79a9 100644 --- a/openless-all/app/crates/openless-core/Cargo.toml +++ b/openless-all/app/crates/openless-core/Cargo.toml @@ -9,10 +9,13 @@ publish = false [dependencies] anyhow = "1" +argon2 = { version = "0.5", features = ["zeroize"] } base64 = "0.22" bzip2 = "0.4" +chacha20poly1305 = "0.10" chrono = { version = "0.4", default-features = false, features = ["clock", "std"] } futures-util = "0.3" +getrandom = "0.3" ferrous-opencc = "0.4" hmac = "0.12" log = "0.4" @@ -35,4 +38,6 @@ tokio = { version = "1", features = ["sync", "time", "rt", "macros", "rt-multi-t tokio-tungstenite = { version = "0.24", features = ["rustls-tls-webpki-roots"] } url = { version = "2", features = ["serde"] } uuid = { version = "1", features = ["v4", "serde"] } +unicode-normalization = "0.1" +zeroize = { version = "1", features = ["derive"] } zip = "2" diff --git a/openless-all/app/crates/openless-core/src/activity.rs b/openless-all/app/crates/openless-core/src/activity.rs index 206abc36..474dbdd8 100644 --- a/openless-all/app/crates/openless-core/src/activity.rs +++ b/openless-all/app/crates/openless-core/src/activity.rs @@ -1,13 +1,11 @@ -//! Text-free daily activity aggregates shared by both desktop hosts. - -use std::collections::BTreeMap; -use std::path::{Path, PathBuf}; -use std::sync::Mutex; - -use serde::{Deserialize, Serialize}; +//! Text-free daily activity aggregates with stable per-device contributions. use crate::errors::{BackendError, BackendErrorCode}; use crate::persistence::{atomic_write, persistence_error}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; const ACTIVITY_RETENTION_DAYS: usize = 731; @@ -20,7 +18,6 @@ pub struct DayStats { #[serde(default)] pub duration_ms: u64, } - #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct ActivityDay { @@ -30,118 +27,291 @@ pub struct ActivityDay { pub duration_ms: u64, } +#[derive(Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct DayBucket { + #[serde(flatten)] + stats: DayStats, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + source_contributions: BTreeMap, +} #[derive(Deserialize)] #[serde(untagged)] enum StoredDay { CountOnly(u32), - Full(DayStats), + Full(DayBucket), } -impl From for DayStats { - fn from(stored: StoredDay) -> Self { - match stored { - StoredDay::CountOnly(count) => Self { - count, - ..Self::default() - }, - StoredDay::Full(stats) => stats, +fn totals(sources: &BTreeMap) -> DayStats { + sources + .values() + .fold(DayStats::default(), |mut total, source| { + total.count = total.count.saturating_add(source.count); + total.chars = total.chars.saturating_add(source.chars); + total.duration_ms = total.duration_ms.saturating_add(source.duration_ms); + total + }) +} +fn read_days(path: &Path) -> Result, BackendError> { + let raw: BTreeMap = match std::fs::read(path) { + Ok(bytes) => serde_json::from_slice(&bytes) + .map_err(|_| persistence_error("decode activity aggregates"))?, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => BTreeMap::new(), + Err(_) => return Err(persistence_error("read activity aggregates")), + }; + let mut stored = BTreeMap::new(); + for (date, value) in raw { + let day: StoredDay = serde_json::from_value(value.clone()) + .map_err(|_| persistence_error("decode activity day"))?; + if let StoredDay::Full(bucket) = &day { + crate::persistence::ensure_lossless_value( + &value, + &serde_json::to_value(bucket) + .map_err(|_| persistence_error("encode activity day"))?, + &[], + )?; } + stored.insert(date, day); } + Ok(stored + .into_iter() + .map(|(date, value)| { + let mut bucket = match value { + StoredDay::CountOnly(count) => DayBucket { + stats: DayStats { + count, + ..Default::default() + }, + ..Default::default() + }, + StoredDay::Full(bucket) => bucket, + }; + if !bucket.source_contributions.is_empty() { + bucket.stats = totals(&bucket.source_contributions); + } + (date, bucket) + }) + .collect()) } +struct ActivityState { + days: BTreeMap, + local_source: Option, +} pub struct ActivityStore { path: Option, - cache: Mutex>, + cache: Mutex, } - impl ActivityStore { pub fn at_data_dir(data_dir: impl AsRef) -> Result { Self::at_path(data_dir.as_ref().join("activity.json")) } - pub fn at_path(path: PathBuf) -> Result { - let stored = match std::fs::read(&path) { - Ok(bytes) => serde_json::from_slice::>(&bytes) - .map_err(|_| persistence_error("decode activity aggregates"))?, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => BTreeMap::new(), - Err(_) => return Err(persistence_error("read activity aggregates")), - }; + let days = read_days(&path)?; Ok(Self { path: Some(path), - cache: Mutex::new( - stored - .into_iter() - .map(|(date, day)| (date, day.into())) - .collect(), - ), + cache: Mutex::new(ActivityState { + days, + local_source: None, + }), }) } - - /// In-memory degradation for a non-critical aggregate store. pub fn in_memory() -> Self { Self { path: None, - cache: Mutex::new(BTreeMap::new()), + cache: Mutex::new(ActivityState { + days: BTreeMap::new(), + local_source: None, + }), } } - pub fn bump(&self, date: &str, chars: u64, duration_ms: u64) -> Result<(), BackendError> { - if !valid_date(date) { - return Err(BackendError::new( - BackendErrorCode::InvalidArgument, - "activity date must use YYYY-MM-DD", - )); + /// Memory-only Host identity attachment; it never attributes imported totals to this device. + pub(crate) fn bind_sync_device(&self, device: &str) -> Result<(), BackendError> { + if device.is_empty() { + return Err(persistence_error("missing activity source device")); } - let mut cache = self.lock_cache()?; - let entry = cache.entry(date.to_string()).or_default(); - entry.count = entry.count.saturating_add(1); - entry.chars = entry.chars.saturating_add(chars); - entry.duration_ms = entry.duration_ms.saturating_add(duration_ms); - while cache.len() > ACTIVITY_RETENTION_DAYS { - let Some(oldest) = cache.keys().next().cloned() else { - break; - }; - cache.remove(&oldest); - } - if let Some(path) = &self.path { - let bytes = serde_json::to_vec_pretty(&*cache) - .map_err(|_| persistence_error("encode activity aggregates"))?; - atomic_write(path, &bytes)?; + let mut state = self.lock_cache()?; + if state + .local_source + .as_deref() + .is_some_and(|old| old != device) + { + return Err(persistence_error("activity source identity changed")); } + state.local_source = Some(device.into()); Ok(()) } + pub fn bump(&self, date: &str, chars: u64, duration_ms: u64) -> Result<(), BackendError> { + crate::cloud_sync_e2ee_store::gate::with_optional_mutation( + self.path.as_deref(), + crate::cloud_sync_e2ee_store::gate::ChangeOrigin::User, + || { + if !valid_date(date) { + return Err(BackendError::new( + BackendErrorCode::InvalidArgument, + "activity date must use YYYY-MM-DD", + )); + } + let mut state = self.lock_cache()?; + let mut days = match &self.path { + Some(path) => read_days(path)?, + None => state.days.clone(), + }; + let bucket = days.entry(date.to_string()).or_default(); + if let Some(device) = &state.local_source { + if bucket.source_contributions.is_empty() { + bucket + .source_contributions + .insert(device.clone(), bucket.stats); + } + let own = bucket + .source_contributions + .entry(device.clone()) + .or_default(); + own.count = own.count.saturating_add(1); + own.chars = own.chars.saturating_add(chars); + own.duration_ms = own.duration_ms.saturating_add(duration_ms); + bucket.stats = totals(&bucket.source_contributions); + } else { + if !bucket.source_contributions.is_empty() { + return Err(persistence_error( + "activity source must be bound before mutation", + )); + } + bucket.stats.count = bucket.stats.count.saturating_add(1); + bucket.stats.chars = bucket.stats.chars.saturating_add(chars); + bucket.stats.duration_ms = bucket.stats.duration_ms.saturating_add(duration_ms); + } + while days.len() > ACTIVITY_RETENTION_DAYS { + let Some(oldest) = days.keys().next().cloned() else { + break; + }; + days.remove(&oldest); + } + if let Some(path) = &self.path { + let bytes = serde_json::to_vec_pretty(&days) + .map_err(|_| persistence_error("encode activity aggregates"))?; + atomic_write(path, &bytes)?; + } + state.days = days; + Ok(()) + }, + ) + } + pub fn snapshot(&self) -> Result, BackendError> { Ok(self .lock_cache()? + .days .iter() - .map(|(date, stats)| ActivityDay { + .map(|(date, bucket)| ActivityDay { date: date.clone(), - count: stats.count, - chars: stats.chars, - duration_ms: stats.duration_ms, + count: bucket.stats.count, + chars: bucket.stats.chars, + duration_ms: bucket.stats.duration_ms, }) .collect()) } - fn lock_cache( + pub(crate) fn sync_records( &self, - ) -> Result>, BackendError> { + permit: &crate::cloud_sync_e2ee_store::gate::ExclusivePermit, + ) -> Result, BackendError> { + let path = self + .path + .as_deref() + .ok_or_else(|| persistence_error("activity persistence unavailable"))?; + crate::cloud_sync_e2ee_store::gate::require_exclusive(path, permit)?; + self.sync_records_readonly() + } + + pub(crate) fn sync_records_readonly( + &self, + ) -> Result, BackendError> { + let path = self + .path + .as_deref() + .ok_or_else(|| persistence_error("activity persistence unavailable"))?; + let mut state = self.lock_cache()?; + state.days = read_days(path)?; + let device = state + .local_source + .as_deref() + .ok_or_else(|| persistence_error("activity source unavailable"))?; + let mut output = Vec::new(); + for (date, bucket) in &state.days { + let sources = if bucket.source_contributions.is_empty() { + BTreeMap::from([(device.to_string(), bucket.stats)]) + } else { + bucket.source_contributions.clone() + }; + for (source_device_id, stats) in sources { + output.push(crate::cloud_sync_e2ee_documents::ActivityRecord { + source_device_id, + date: date.clone(), + count: stats.count.into(), + chars: stats.chars, + duration_ms: stats.duration_ms, + }); + } + } + Ok(output) + } + + pub(crate) fn sync_replace_records( + &self, + records: &[crate::cloud_sync_e2ee_documents::ActivityRecord], + permit: &crate::cloud_sync_e2ee_store::gate::ExclusivePermit, + ) -> Result<(), BackendError> { + let path = self + .path + .as_deref() + .ok_or_else(|| persistence_error("activity persistence unavailable"))?; + crate::cloud_sync_e2ee_store::gate::require_exclusive(path, permit)?; + let mut days: BTreeMap = BTreeMap::new(); + for record in records { + if !valid_date(&record.date) || record.source_device_id.is_empty() { + return Err(persistence_error("invalid activity contribution")); + } + let stats = DayStats { + count: u32::try_from(record.count) + .map_err(|_| persistence_error("activity count is not representable"))?, + chars: record.chars, + duration_ms: record.duration_ms, + }; + if days + .entry(record.date.clone()) + .or_default() + .source_contributions + .insert(record.source_device_id.clone(), stats) + .is_some() + { + return Err(persistence_error("duplicate activity source")); + } + } + for bucket in days.values_mut() { + bucket.stats = totals(&bucket.source_contributions); + } + // Restoration is complete, independent of the normal rolling retention policy. + let bytes = serde_json::to_vec_pretty(&days) + .map_err(|_| persistence_error("encode restored activity"))?; + let mut state = self.lock_cache()?; + crate::persistence::atomic_write_for_sync(path, &bytes, permit)?; + state.days = days; + Ok(()) + } + + fn lock_cache(&self) -> Result, BackendError> { self.cache.lock().map_err(|_| { BackendError::new(BackendErrorCode::Internal, "activity store lock poisoned") }) } } - fn valid_date(date: &str) -> bool { - let bytes = date.as_bytes(); - bytes.len() == 10 - && bytes[4] == b'-' - && bytes[7] == b'-' - && bytes - .iter() - .enumerate() - .all(|(index, byte)| index == 4 || index == 7 || byte.is_ascii_digit()) + chrono::NaiveDate::parse_from_str(date, "%Y-%m-%d") + .is_ok_and(|value| value.format("%Y-%m-%d").to_string() == date) } #[cfg(test)] diff --git a/openless-all/app/crates/openless-core/src/api.rs b/openless-all/app/crates/openless-core/src/api.rs index 6236bb46..e47f932d 100644 --- a/openless-all/app/crates/openless-core/src/api.rs +++ b/openless-all/app/crates/openless-core/src/api.rs @@ -1360,7 +1360,10 @@ impl BackendRepositories { .unwrap_or_else(|_| StylePackStore::in_memory()), ); let mut preference_snapshot = preferences.get(); - if sync_style_pack_preferences(&mut preference_snapshot, &style_packs.list()?) { + if !crate::cloud_sync_e2ee_store::gate::recovery_pending_for_path( + &data_dir.join("preferences.json"), + ) && sync_style_pack_preferences(&mut preference_snapshot, &style_packs.list()?) + { preferences.set(preference_snapshot)?; } Ok(Self { @@ -1857,6 +1860,7 @@ impl EngineProgressSink for BackendEngineProgress { pub struct OpenLessBackend { config: BackendConfig, + startup_error: Option, deps: BackendDependencies, clock: Arc, events: Arc, @@ -1877,13 +1881,19 @@ pub struct OpenLessBackend { preferences_revision: Arc, settings_write_gate: Arc>, cloud_sync: Option, + encrypted_sync: Option, + encrypted_sync_store: Option>, pending_corrections: Arc>>, edit_observation_generation: Arc, text_insertions: Arc>>, voice_sessions: Arc, + runtime_start_work: Arc, less_computer_voice_controls: Arc>>>, } +#[path = "cloud_sync_e2ee/api.rs"] +mod encrypted_sync_api; + struct HistoryProviderAttribution { asr_provider: Option, asr_model: Option, @@ -2026,6 +2036,75 @@ impl HistoryProviderAttribution { } impl OpenLessBackend { + /// Construct an explicitly unavailable Core so the native shell can show + /// its startup error instead of panicking before any window is visible. + /// This path never opens the user's files or a credential store. + pub fn blocked_startup(mut config: BackendConfig, error: BackendError) -> Self { + if config.data_dir.as_os_str().is_empty() { + config.data_dir = std::path::PathBuf::from("openless-startup-blocked"); + } + let repositories = BackendRepositories { + preferences: Arc::new(PreferencesStore::in_memory()), + history: Arc::new(HistoryStore::at_path(std::path::PathBuf::new())), + activity: Arc::new(ActivityStore::in_memory()), + vocabulary: Arc::new(DictionaryStore::at_path(std::path::PathBuf::new())), + correction_rules: Arc::new(CorrectionRuleStore::at_path(std::path::PathBuf::new())), + style_packs: Arc::new(StylePackStore::in_memory()), + }; + // All fallible external dependencies are absent and the only required + // config field was normalized above. A failure here is a code invariant. + let mut backend = + Self::new_with_repositories(config, BackendDependencies::unsupported(), repositories) + .expect("memory-only blocked backend is constructible"); + let denied = error.clone(); + let guard: crate::domains::RuntimeRestoreGuard = Arc::new(move || Err(denied.clone())); + backend + .voice_sessions + .bind_restore_guard(Arc::clone(&guard)) + .expect("fresh voice guard"); + backend + .runtime_start_work + .bind(Arc::clone(&guard), Arc::clone(&backend.deps.task_spawner)) + .expect("fresh startup guard"); + backend + .deps + .services + .selection_voice + .bind_runtime_restore_guard(guard, Arc::clone(&backend.deps.task_spawner)) + .expect("fresh selection guard"); + backend.startup_error = Some(error); + backend + } + + pub fn startup_error(&self) -> Option { + self.startup_error.clone() + } + + pub fn bind_restore_runtime_effects( + &self, + effects: Arc, + ) -> Result<(), BackendError> { + if let Some(store) = &self.encrypted_sync_store { + store.bind_runtime_effects(effects).map_err(|_| { + BackendError::new( + BackendErrorCode::InvalidState, + "restore runtime effects binding failed", + ) + })?; + } + Ok(()) + } + + /// Called after a complete journal target has reached its Host effects. + /// This resets ephemeral interpreters only; it never writes repositories. + pub fn reset_restored_runtime_preferences(&self) { + self.hotkey + .lock() + .expect("hotkey interpreter lock poisoned") + .reset(); + self.disarm_edit_observation(); + } + pub fn new(config: BackendConfig, deps: BackendDependencies) -> Result { if config.data_dir.as_os_str().is_empty() { return Err(BackendError::new( @@ -2079,6 +2158,7 @@ impl OpenLessBackend { let vocabulary_revision = Arc::new(AtomicU64::new(0)); let settings_write_gate = Arc::new(Mutex::new(())); let voice_sessions = Arc::clone(&deps.services.voice_sessions); + let runtime_start_work = Arc::new(crate::voice_session::RuntimeActivityGate::default()); deps.services.selection_voice = Arc::new(crate::selection_voice_service::SelectionVoiceService::new( BackendEventPublisher::new(Arc::clone(&events)), @@ -2181,7 +2261,11 @@ impl OpenLessBackend { Arc::clone(&deps.credential_store), )); } - let cloud_sync = if let Some(marketplace_config) = deps.marketplace_config.take() { + let mut encrypted_sync = None; + let mut encrypted_sync_store = None; + let cloud_sync = if let Some(mut marketplace_config) = deps.marketplace_config.take() { + let github_client_id = marketplace_config.github_client_id.clone(); + let sync_config = marketplace_config.encrypted_sync_config.take(); let marketplace = Arc::new(crate::marketplace::MarketplaceService::new( marketplace_config, Arc::clone(&deps.credential_store), @@ -2191,6 +2275,19 @@ impl OpenLessBackend { Arc::clone(&style_pack_revision), )?); deps.services.marketplace = marketplace.clone(); + if let Some(sync_config) = sync_config { + let (service, store) = crate::cloud_sync_e2ee::build( + sync_config, + &config.data_dir, + repositories.clone(), + Arc::clone(&deps.credential_store), + Arc::clone(&marketplace), + github_client_id, + BackendEventPublisher::new(Arc::clone(&events)), + )?; + encrypted_sync = Some(service); + encrypted_sync_store = Some(store); + } Some(crate::cloud_sync::CloudSyncService::new( marketplace, repositories.clone(), @@ -2269,8 +2366,69 @@ impl OpenLessBackend { deps.services .remote_input .bind_event_publisher(BackendEventPublisher::new(Arc::clone(&events))); + if let Some(store) = &encrypted_sync_store { + let weak_store = Arc::downgrade(store); + let guard: crate::domains::RuntimeRestoreGuard = Arc::new(move || { + let store = weak_store.upgrade().ok_or_else(|| { + BackendError::new( + BackendErrorCode::InvalidState, + "runtime restore source is unavailable", + ) + })?; + store.ensure_runtime_available().map_err(|_| { + BackendError::new( + BackendErrorCode::Busy, + "encrypted sync restore must finish before starting runtime work", + ) + }) + }); + voice_sessions.bind_restore_guard(Arc::clone(&guard))?; + runtime_start_work.bind(Arc::clone(&guard), Arc::clone(&deps.task_spawner))?; + deps.services + .qa + .bind_runtime_restore_guard(Arc::clone(&guard), Arc::clone(&deps.task_spawner))?; + deps.services + .selection + .bind_runtime_restore_guard(Arc::clone(&guard), Arc::clone(&deps.task_spawner))?; + deps.services + .selection_voice + .bind_runtime_restore_guard(guard, Arc::clone(&deps.task_spawner))?; + let voice = Arc::downgrade(&voice_sessions); + let starts = Arc::downgrade(&runtime_start_work); + let qa = Arc::downgrade(&deps.services.qa); + let selection = Arc::downgrade(&deps.services.selection); + let selection_voice = Arc::downgrade(&deps.services.selection_voice); + // Each probe releases its lock before the next one. Starts check + // Store's already-raised flag while holding the matching lock. + // Both directions are weak, so neither binding owns the backend. + store + .bind_runtime_idle_probe(Arc::new(move || { + voice + .upgrade() + .is_some_and(|gate| gate.runtime_restore_idle()) + && starts + .upgrade() + .is_some_and(|gate| gate.runtime_restore_idle()) + && qa + .upgrade() + .is_some_and(|service| service.runtime_restore_idle()) + && selection + .upgrade() + .is_some_and(|service| service.runtime_restore_idle()) + && selection_voice + .upgrade() + .is_some_and(|service| service.runtime_restore_idle()) + })) + .map_err(|_| { + BackendError::new( + BackendErrorCode::InvalidState, + "failed to bind encrypted sync runtime barrier", + ) + })?; + } Ok(Self { config, + startup_error: None, deps, clock, events, @@ -2300,14 +2458,35 @@ impl OpenLessBackend { preferences_revision, settings_write_gate, cloud_sync, + encrypted_sync, + encrypted_sync_store, pending_corrections: Arc::new(Mutex::new(Vec::new())), edit_observation_generation: Arc::new(AtomicU64::new(0)), text_insertions: Arc::new(Mutex::new(HashMap::new())), voice_sessions, + runtime_start_work, less_computer_voice_controls: Arc::new(Mutex::new(HashMap::new())), }) } + /// Hosts can inspect settings during recovery, but must defer runtime + /// startup effects until this succeeds. Task admission itself is also + /// guarded under Core's runtime locks; this check is not a session lease. + pub fn ensure_runtime_ready(&self) -> Result<(), BackendError> { + if let Some(error) = &self.startup_error { + return Err(error.clone()); + } + if let Some(store) = &self.encrypted_sync_store { + store.ensure_runtime_available().map_err(|_| { + BackendError::new( + BackendErrorCode::Busy, + "encrypted sync restore must finish before starting runtime work", + ) + })?; + } + Ok(()) + } + pub fn repositories(&self) -> BackendRepositories { BackendRepositories { preferences: Arc::clone(&self.preferences), @@ -2377,6 +2556,7 @@ impl OpenLessBackend { /// passed to [`Self::submit_less_computer_with_session`], or released with /// [`Self::abort_less_computer_capture`]. pub fn begin_less_computer_capture(&self, session_id: SessionId) -> Result<(), BackendError> { + let _runtime = self.runtime_start_work.acquire()?; if !self.get_preferences().coding_agent_enabled { return Err(BackendError::new( BackendErrorCode::PermissionDenied, @@ -2420,6 +2600,7 @@ impl OpenLessBackend { session_id: SessionId, recording_control: Arc, ) -> Result { + let _runtime = self.runtime_start_work.acquire()?; let preferences = self.get_preferences(); if !preferences.coding_agent_enabled { return Err(BackendError::new( @@ -2894,6 +3075,7 @@ impl OpenLessBackend { session_id: SessionId, transcript: String, ) -> Result { + let _runtime = self.runtime_start_work.acquire()?; let preferences = self.get_preferences(); if !preferences.coding_agent_enabled { return Err(BackendError::new( @@ -3086,13 +3268,37 @@ impl OpenLessBackend { } pub async fn start(&self) -> Result { + if let Some(error) = &self.startup_error { + return Err(error.clone()); + } + // Native status warms only through the already-bound sync gate. A + // pending journal therefore uses the Host's read-only loader before + // startup recovery/auto-sync tries to capture credentials. + let before = self.get_preferences(); + let warmed = self.deps.credential_store.status(before).await; + if let Some(sync) = &self.encrypted_sync { + // Keep Settings available even after a denied/failed warm read. + // The service records its own retryable recovery/storage error; + // runtime admission remains fenced until recovery is complete. + let _ = sync.start(Arc::clone(&self.deps.task_spawner)).await; + } let preferences = self.get_preferences(); - let credentials = match self.deps.credential_store.status(preferences.clone()).await { + let status = match warmed { + // Recovery can replace both preferences and credentials. Recompute + // from the new in-memory target after it has settled. + Ok(_) if self.encrypted_sync.is_some() => { + self.deps.credential_store.status(preferences.clone()).await + } + Ok(credentials) => Ok(credentials), + Err(error) => Err(error), + }; + let credentials = match status { Ok(credentials) => credentials, - Err(error) if error.code == BackendErrorCode::Persistence => { - // Vault unreadable (e.g. Android Keystore temporarily unavailable) - // must not fail the 2.0 handshake. Dictation still gates on read(). - log::warn!("[core] startup credential status unavailable: {error}"); + Err(error) => { + log::warn!( + "[core] startup credential status unavailable: {:?}", + error.code + ); CredentialsStatus { pipeline_mode: crate::shared_types::effective_pipeline_mode( preferences.multimodal_pipeline_enabled, @@ -3101,7 +3307,6 @@ impl OpenLessBackend { ..CredentialsStatus::default() } } - Err(error) => return Err(error), }; let mut state = self.state.write().expect("backend state lock poisoned"); state.credentials = credentials; @@ -3136,6 +3341,9 @@ impl OpenLessBackend { } pub async fn shutdown(&self) -> Result<(), BackendError> { + if let Some(sync) = &self.encrypted_sync { + sync.shutdown().await; + } let (active_session, preserve_quick_note) = { let mut state = self.state.write().expect("backend state lock poisoned"); if !state.running { @@ -3399,12 +3607,12 @@ impl OpenLessBackend { }; let preferences = self.get_preferences(); let mode = preferences.hotkey.mode; - let modifier_only = crate::shortcut_types::is_modifier_chord_binding( - &preferences.dictation_hotkey, - ) || crate::hotkey_interpreter::modifier_arbitration_required( - crate::shortcut_types::legacy_modifier_trigger(&preferences.dictation_hotkey), - mode, - ); + let modifier_only = + crate::shortcut_types::is_modifier_chord_binding(&preferences.dictation_hotkey) + || crate::hotkey_interpreter::modifier_arbitration_required( + crate::shortcut_types::legacy_modifier_trigger(&preferences.dictation_hotkey), + mode, + ); let (intent, reservation) = { let mut hotkey = self .hotkey @@ -3953,6 +4161,7 @@ impl OpenLessBackend { options: crate::SettingsUpdateOptions, runtime: &R, ) -> Result { + self.ensure_runtime_ready()?; let _write_guard = self .settings_write_gate .lock() @@ -12758,8 +12967,10 @@ mod tests { ] { let data_dir = TestDataDir::new("modifier-chord-companion"); let engine = crate::testing::FixtureDictationEngine::successful("raw", "polished"); - let backend = - backend_with_dictation_engine(data_dir.path().to_path_buf(), Arc::new(engine.clone())); + let backend = backend_with_dictation_engine( + data_dir.path().to_path_buf(), + Arc::new(engine.clone()), + ); backend.start().await.unwrap(); let mut preferences = backend.get_preferences(); preferences.hotkey.mode = mode; @@ -13027,4 +13238,340 @@ mod tests { backend.shutdown().await.unwrap(); let _ = std::fs::remove_dir_all(data_dir); } + fn runtime_restore_backend() -> TestBackend { + runtime_restore_backend_with_polisher(Arc::new( + crate::testing::FixtureTextPolisher::successful("fixture"), + )) + } + + fn runtime_restore_backend_with_polisher( + polisher: Arc, + ) -> TestBackend { + let data_dir = TestDataDir::new("runtime-restore"); + let mut deps = BackendDependencies::unsupported(); + deps.dictation_engine = Arc::new(FakeEngine); + deps.credential_store = Arc::new(crate::credentials::InMemoryCredentialStore::default()); + deps.selection_runtime = Some(Arc::new( + crate::testing::FixtureSelectionRuntime::successful( + crate::domains::SelectionCapture { + text: "fixture".into(), + source_app: None, + }, + InsertOutcome::Inserted, + ), + )); + deps.selection_polisher = Some(polisher); + deps.marketplace_config = Some( + crate::marketplace::MarketplaceConfig::new("https://market.example") + .unwrap() + .with_encrypted_sync(crate::cloud_sync_e2ee::EncryptedSyncConfig { + service_origin: "https://sync.example".into(), + app_version: "runtime-test".into(), + }), + ); + let backend = OpenLessBackend::new( + BackendConfig { + data_dir: data_dir.path().into(), + ..BackendConfig::default() + }, + deps, + ) + .unwrap(); + // This fixture exercises local admission only; do not start the network sync worker. + backend.state.write().unwrap().running = true; + TestBackend { + backend, + _data_dir: data_dir, + } + } + + #[tokio::test] + async fn runtime_restore_actual_store_recovery_rejects_active_voice_before_repository_mutation() + { + use crate::cloud_sync_e2ee_documents::DocumentError; + let backend = runtime_restore_backend(); + backend.ensure_runtime_ready().unwrap(); + let mut preferences = backend.get_preferences(); + preferences.coding_agent_enabled = true; + backend + .update_settings( + preferences, + crate::SettingsUpdateOptions::STRICT, + &crate::NoopSettingsRuntime, + ) + .unwrap(); + let owner = SessionId::new(); + backend.begin_less_computer_capture(owner).unwrap(); + let gate = crate::cloud_sync_e2ee_store::gate::open_for_data_dir(backend._data_dir.path()) + .unwrap(); + drop(gate.begin_mutation().unwrap()); // model an interrupted save that requires reconciliation + let before = std::fs::read( + backend + ._data_dir + .path() + .join("encrypted-sync/generation.json"), + ) + .unwrap(); + let store = backend.encrypted_sync_store.as_ref().unwrap(); + assert_eq!( + store.recover_registered().await.unwrap_err(), + DocumentError::RuntimeBusy + ); + assert_eq!( + std::fs::read( + backend + ._data_dir + .path() + .join("encrypted-sync/generation.json") + ) + .unwrap(), + before + ); + assert_eq!( + backend.ensure_runtime_ready().unwrap_err().code, + BackendErrorCode::Busy + ); + assert!( + backend.get_preferences().coding_agent_enabled, + "settings remain readable for recovery UI" + ); + backend.abort_less_computer_capture(owner).unwrap(); + } + + #[tokio::test] + async fn runtime_restore_constructor_guard_blocks_all_configured_runtime_starts_on_recovery_latch( + ) { + let backend = runtime_restore_backend(); + let gate = crate::cloud_sync_e2ee_store::gate::open_for_data_dir(backend._data_dir.path()) + .unwrap(); + drop(gate.begin_mutation().unwrap()); + assert_eq!( + backend.start_dictation().await.unwrap_err().code, + BackendErrorCode::Busy + ); + assert_eq!( + backend + .begin_less_computer_capture(SessionId::new()) + .unwrap_err() + .code, + BackendErrorCode::Busy + ); + assert_eq!( + backend + .submit_less_computer("fixture".into()) + .await + .unwrap_err() + .code, + BackendErrorCode::Busy + ); + assert_eq!( + backend + .services() + .selection + .begin_polish(crate::domains::SelectionPolishRequest { + selected_text: Some("fixture".into()), + mode: crate::PolishMode::Raw, + instruction: None, + }) + .await + .unwrap_err() + .code, + BackendErrorCode::Busy + ); + assert_eq!( + backend + .services() + .selection_voice + .begin(crate::domains::SelectionCapture { + text: "fixture".into(), + source_app: None + }) + .await + .unwrap_err() + .code, + BackendErrorCode::Busy + ); + assert_eq!(backend.snapshot().dictation.phase, DictationPhase::Idle); + } + + #[tokio::test] + async fn runtime_restore_cancelled_apply_ticket_stays_busy_until_native_completion() { + let (backend, _) = backend(); + let service = &backend.services().selection_voice; + let session = service + .begin(crate::domains::SelectionCapture { + text: "fixture".into(), + source_app: None, + }) + .await + .unwrap(); + service.mark_processing(session).await.unwrap(); + service + .set_preview(crate::domains::SelectionVoicePreviewUpdate { + session_id: session, + owner_session_id: None, + text: "replacement".into(), + summary: None, + }) + .await + .unwrap(); + let ticket = service + .begin_preview_apply(None, "replacement".into()) + .unwrap(); + service.cancel(Some(session)).await.unwrap(); + assert!( + !service.runtime_restore_idle(), + "cancel does not complete a native apply ticket" + ); + assert_eq!( + service + .finish_preview_apply( + ticket.ticket_id, + crate::domains::SelectionVoiceApplyOutcome::Failed + ) + .await + .unwrap_err() + .code, + BackendErrorCode::Cancelled + ); + assert!(service.runtime_restore_idle()); + } + + #[test] + fn runtime_restore_bindings_do_not_keep_store_or_services_alive() { + let backend = runtime_restore_backend(); + let store = Arc::downgrade(backend.encrypted_sync_store.as_ref().unwrap()); + let voice = Arc::downgrade(&backend.voice_sessions); + let qa = Arc::downgrade(&backend.services().qa); + let selection_voice = Arc::downgrade(&backend.services().selection_voice); + drop(backend); + assert!(store.upgrade().is_none()); + assert!(voice.upgrade().is_none()); + assert!(qa.upgrade().is_none()); + assert!(selection_voice.upgrade().is_none()); + } + struct RestoreCancelPolisher { + entered: Arc, + release: Arc, + drained: Arc, + } + impl crate::ports::TextPolisher for RestoreCancelPolisher { + fn polish( + &self, + _: SessionId, + _: Arc, + _: String, + _: Arc, + ) -> BoxFuture<'static, Result> { + Box::pin(async { Ok(crate::ports::PolishOutput::text("fixture")) }) + } + fn cancel(&self, _: SessionId) -> BoxFuture<'static, Result<(), BackendError>> { + let entered = self.entered.clone(); + let release = self.release.clone(); + let drained = self.drained.clone(); + Box::pin(async move { + entered.notify_one(); + release.notified().await; + drained.notify_one(); + Ok(()) + }) + } + } + + #[tokio::test] + async fn runtime_restore_selection_cancel_retains_lease_after_waiter_drop() { + let polisher = Arc::new(RestoreCancelPolisher { + entered: Arc::new(tokio::sync::Notify::new()), + release: Arc::new(tokio::sync::Notify::new()), + drained: Arc::new(tokio::sync::Notify::new()), + }); + let backend = runtime_restore_backend_with_polisher(polisher.clone()); + let mut preferences = backend.get_preferences(); + preferences.selection_polish_output_mode = + crate::shared_types::SelectionPolishOutputMode::PreviewConfirm; + backend + .update_settings( + preferences, + crate::SettingsUpdateOptions::STRICT, + &crate::NoopSettingsRuntime, + ) + .unwrap(); + let selection = backend.services().selection.clone(); + selection + .begin_polish(crate::domains::SelectionPolishRequest { + selected_text: Some("fixture".into()), + mode: crate::PolishMode::Raw, + instruction: None, + }) + .await + .unwrap(); + let cancel = tokio::spawn(selection.cancel(None)); + polisher.entered.notified().await; + assert_eq!( + selection.snapshot().await.unwrap().phase, + crate::domains::SelectionPhase::Cancelled + ); + assert!(!selection.runtime_restore_idle()); + cancel.abort(); + assert!(cancel.await.unwrap_err().is_cancelled()); + assert!(!selection.runtime_restore_idle()); + polisher.release.notify_one(); + polisher.drained.notified().await; + for _ in 0..4 { + tokio::task::yield_now().await; + } + assert!(selection.runtime_restore_idle()); + } + #[tokio::test] + async fn restore_host_blocked_startup_is_visible_and_never_opens_or_mutates_real_stores() { + struct ForbiddenRuntime; + impl crate::SettingsRuntime for ForbiddenRuntime { + fn prepare( + &self, + _: &crate::SettingsEffectPlan, + ) -> Result { + panic!("blocked startup must not run Host effects") + } + } + let data_dir = TestDataDir::new("blocked-startup"); + let error = BackendError::new(BackendErrorCode::Persistence, "local storage access denied"); + let backend = OpenLessBackend::blocked_startup( + BackendConfig { + data_dir: data_dir.path().into(), + ..BackendConfig::default() + }, + error, + ); + assert!(!data_dir.path().exists()); + assert_eq!( + backend.start().await.unwrap_err().code, + BackendErrorCode::Persistence + ); + assert!(!backend.snapshot().running); + assert_eq!( + backend.ensure_runtime_ready().unwrap_err().code, + BackendErrorCode::Persistence + ); + let mut target = backend.get_preferences(); + target.launch_at_login = true; + assert_eq!( + backend + .update_settings( + target, + crate::SettingsUpdateOptions::STRICT, + &ForbiddenRuntime + ) + .unwrap_err() + .code, + BackendErrorCode::Persistence + ); + assert_eq!( + backend + .begin_less_computer_capture(SessionId::new()) + .unwrap_err() + .code, + BackendErrorCode::Persistence + ); + assert!(!data_dir.path().exists()); + } } diff --git a/openless-all/app/crates/openless-core/src/cloud_sync.rs b/openless-all/app/crates/openless-core/src/cloud_sync.rs index 7b186567..4a7a6c8f 100644 --- a/openless-all/app/crates/openless-core/src/cloud_sync.rs +++ b/openless-all/app/crates/openless-core/src/cloud_sync.rs @@ -296,6 +296,19 @@ impl CloudSyncService { } fn restore_local(&self, payload: &CloudSyncPayload) -> Result<(), BackendError> { + // The legacy four-command protocol has no encrypted crash journal or Host-effect + // convergence. It must never bypass an installed E2EE repository write barrier. + if crate::cloud_sync_e2ee_store::gate::gate_for_path( + self.repositories.preferences.persistence_path(), + ) + .map_err(|error| BackendError::new(BackendErrorCode::InvalidState, error.to_string()))? + .is_some() + { + return Err(BackendError::new( + BackendErrorCode::Unsupported, + "当前仓库暂不支持旧版云端恢复,请使用加密云同步的确认恢复流程。", + )); + } validate_payload(payload).map_err(|_| invalid_remote())?; let mut next_packs = validated_native_packs(payload)?; let dictionary: Vec = payload @@ -627,3 +640,94 @@ fn validate_base_revision(revision: u64) -> Result<(), BackendError> { Ok(()) } } + +#[cfg(test)] +mod encrypted_gate_tests { + use super::*; + struct Temp(PathBuf); + impl Temp { + fn new() -> Self { + let path = + std::env::temp_dir().join(format!("legacy-sync-gate-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&path).unwrap(); + Self(path) + } + } + impl Drop for Temp { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + fn service(repositories: BackendRepositories) -> CloudSyncService { + let marketplace = MarketplaceService::new( + crate::MarketplaceConfig::new("https://market.example.test").unwrap(), + Arc::new(crate::InMemoryCredentialStore::default()), + repositories.preferences.clone(), + repositories.style_packs.clone(), + crate::events::BackendEventPublisher::new(Arc::new(crate::events::EventBus::new(8))), + Arc::new(std::sync::atomic::AtomicU64::new(0)), + ) + .unwrap(); + CloudSyncService::new( + Arc::new(marketplace), + repositories, + Arc::new(Mutex::new(())), + ) + } + #[test] + fn registered_encrypted_repositories_reject_legacy_restore_even_when_gate_is_idle() { + for hold_exclusive in [false, true] { + let temp = Temp::new(); + let gate = crate::cloud_sync_e2ee_store::gate::open_for_data_dir(&temp.0).unwrap(); + let repositories = BackendRepositories::open(&temp.0).unwrap(); + let sync = service(repositories.clone()); + let mut payload = sync.capture(CloudSyncUiPreferences::default()).unwrap(); + payload.dictionary.push(SyncDictionaryEntry { + id: "legacy-extra".into(), + phrase: "must not persist".into(), + note: None, + enabled: true, + hits: 0, + created_at: "2026-09-26T00:00:00Z".into(), + }); + let generation = gate.generation().unwrap(); + let preferences = std::fs::read(temp.0.join("preferences.json")).ok(); + let styles = std::fs::read(temp.0.join("style-packs.json")).unwrap(); + let _permit = hold_exclusive.then(|| gate.try_exclusive().unwrap()); + assert_eq!( + sync.restore_local(&payload).unwrap_err().code, + BackendErrorCode::Unsupported + ); + assert!(repositories.vocabulary.list().unwrap().is_empty()); + assert_eq!( + std::fs::read(temp.0.join("preferences.json")).ok(), + preferences + ); + assert_eq!( + std::fs::read(temp.0.join("style-packs.json")).unwrap(), + styles + ); + assert_eq!(gate.generation().unwrap(), generation); + } + } + #[test] + fn standalone_legacy_repositories_without_an_encrypted_gate_keep_their_existing_restore() { + let temp = Temp::new(); + let repositories = BackendRepositories::open(&temp.0).unwrap(); + let sync = service(repositories.clone()); + let mut payload = sync.capture(CloudSyncUiPreferences::default()).unwrap(); + payload.dictionary.push(SyncDictionaryEntry { + id: "legacy-extra".into(), + phrase: "standalone restore".into(), + note: None, + enabled: true, + hits: 0, + created_at: "2026-09-26T00:00:00Z".into(), + }); + sync.restore_local(&payload).unwrap(); + assert_eq!( + repositories.vocabulary.list().unwrap()[0].phrase, + "standalone restore" + ); + } +} diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/adapter.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/adapter.rs new file mode 100644 index 00000000..16d826ab --- /dev/null +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/adapter.rs @@ -0,0 +1,248 @@ +use std::{path::Path, sync::Arc}; + +use futures_util::future::BoxFuture; +use sha2::{Digest, Sha256}; + +use crate::cloud_sync_e2ee_documents::{ + CryptoJournalProtector, DocumentError, DocumentResult, ExportedDocuments, JournalProtector, + RestoreContext, SecretJson, SyncScope, ValidatedSyncDocuments, +}; +use crate::cloud_sync_e2ee_protocol::types::{DocumentSet, Revision, SourceDevice}; +use crate::cloud_sync_e2ee_store::{ + extensions::{DeviceExtensionKey, ProtectedExtensionStore}, + gate::SyncChange, + CoreSyncStore, +}; + +use super::{document_error, local::LocalStorage, service::SyncServiceData, SyncResult}; + +pub(crate) fn build( + config: super::EncryptedSyncConfig, + data_dir: &Path, + repositories: crate::BackendRepositories, + credentials: Arc, + marketplace: Arc, + github_client_id: String, + events: crate::events::BackendEventPublisher, +) -> SyncResult<(super::EncryptedSyncService, Arc)> { + let origin = url::Url::parse(&config.service_origin) + .map_err(|_| super::error("unsupported_protocol"))?; + if origin.scheme() != "https" + || !origin.username().is_empty() + || origin.password().is_some() + || origin.path() != "/" + || origin.query().is_some() + || origin.fragment().is_some() + || data_dir.as_os_str().is_empty() + { + return Err(super::error("unsupported_protocol")); + } + let origin = origin.origin().ascii_serialization(); + let root = data_dir.join("encrypted-sync"); + std::fs::create_dir_all(&root).map_err(|_| super::error("local_storage_unavailable"))?; + let gate = + crate::cloud_sync_e2ee_store::gate::open_for_data_dir(data_dir).map_err(document_error)?; + let device_id = load_device_id(&root)?; + let device = SourceDevice { + id: device_id.clone(), + os: std::env::consts::OS.into(), + arch: std::env::consts::ARCH.into(), + app_version: config.app_version, + }; + let local = LocalStorage::new( + root.join("protected"), + origin.clone(), + device_id, + credentials.clone(), + ); + let store = Arc::new( + CoreSyncStore::new( + repositories, + credentials, + data_dir.into(), + device, + gate, + Arc::new(local.clone()), + ) + .map_err(document_error)?, + ); + let service = super::EncryptedSyncService::new( + super::SyncServiceConfig { + origin, + github_client_id, + }, + marketplace, + local, + store.clone(), + events, + ); + Ok((service, store)) +} + +pub(crate) fn load_device_id(root: &Path) -> SyncResult { + let device_path = root.join("device-id"); + let device_id = match std::fs::read_to_string(&device_path) { + Ok(value) => value, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + // Never invent another keyring/AAD binding for existing ciphertext. + for entry in + std::fs::read_dir(root).map_err(|_| super::error("local_storage_unavailable"))? + { + let entry = entry.map_err(|_| super::error("local_storage_unavailable"))?; + let path = entry.path(); + if path.is_dir() + && std::fs::read_dir(&path) + .map_err(|_| super::error("local_storage_unavailable"))? + .next() + .is_some() + { + return Err(super::error("recovery_required")); + } + } + let id = uuid::Uuid::new_v4().to_string(); + if super::local::durable_create(&device_path, id.as_bytes())? { + id + } else { + std::fs::read_to_string(&device_path) + .map_err(|_| super::error("local_storage_unavailable"))? + } + } + Err(_) => return Err(super::error("local_storage_unavailable")), + }; + crate::cloud_sync_e2ee_protocol::types::UuidV4::parse(&device_id) + .map_err(|_| super::error("recovery_required"))?; + Ok(device_id) +} + +impl ProtectedExtensionStore for LocalStorage { + fn read_scope(&self, scope: SyncScope) -> BoxFuture<'_, DocumentResult>> { + Box::pin(async move { + self.read(&scope_bucket(&scope)?, "extensions") + .await + .map_err(|_| DocumentError::JournalUnavailable) + }) + } + + fn write_scope( + &self, + scope: SyncScope, + value: SecretJson, + ) -> BoxFuture<'_, DocumentResult<()>> { + Box::pin(async move { + self.write(&scope_bucket(&scope)?, "extensions", value) + .await + .map_err(|_| DocumentError::JournalUnavailable) + }) + } + + fn read_device( + &self, + key: DeviceExtensionKey, + ) -> BoxFuture<'_, DocumentResult>> { + Box::pin(async move { + if key == DeviceExtensionKey::UiPreferences { + self.read_ui() + .await + .map(|value| value.map(|envelope| envelope.value)) + .map_err(|_| DocumentError::JournalUnavailable) + } else { + self.read("device", key.storage_name()) + .await + .map_err(|_| DocumentError::JournalUnavailable) + } + }) + } + + fn write_device( + &self, + key: DeviceExtensionKey, + value: SecretJson, + ) -> BoxFuture<'_, DocumentResult<()>> { + Box::pin(async move { + if key == DeviceExtensionKey::UiPreferences { + let envelope = super::local::UiEnvelope { + schema_version: 1, + revision: uuid::Uuid::new_v4().to_string(), + value, + }; + self.write("device", key.storage_name(), envelope) + .await + .map_err(|_| DocumentError::JournalUnavailable) + } else { + self.write("device", key.storage_name(), value) + .await + .map_err(|_| DocumentError::JournalUnavailable) + } + }) + } + + fn read_ui_revision(&self) -> BoxFuture<'_, DocumentResult>> { + Box::pin(async move { + self.read_ui() + .await + .map(|value| value.map(|envelope| envelope.revision)) + .map_err(|_| DocumentError::JournalUnavailable) + }) + } + + fn journal_protector(&self) -> BoxFuture<'_, DocumentResult>> { + Box::pin(async move { + let key = self.key().await.map_err(|_| DocumentError::Locked)?; + Ok(Arc::new(CryptoJournalProtector::new(key)) as Arc) + }) + } +} + +fn scope_bucket(scope: &SyncScope) -> DocumentResult { + // Changing the cloud encryption password keeps the same locally protected + // baseline/tombstone bucket; account or vault changes never share it. + let bytes = serde_json::to_vec(&[ + &scope.service_origin, + &scope.owner_github_id, + &scope.vault_id, + &scope.device_id, + ]) + .map_err(|_| DocumentError::InvalidDocument)?; + Ok(format!("scope:{:x}", Sha256::digest(bytes))) +} + +impl SyncServiceData for CoreSyncStore { + fn export(&self, scope: SyncScope) -> BoxFuture<'_, SyncResult> { + Box::pin(async move { self.export_scope(scope).await.map_err(document_error) }) + } + fn restore( + &self, + desired: ValidatedSyncDocuments, + context: RestoreContext, + ) -> BoxFuture<'_, SyncResult> { + Box::pin(async move { + self.restore_scope(desired, context) + .await + .map_err(document_error) + }) + } + fn recover(&self) -> BoxFuture<'_, SyncResult<()>> { + Box::pin(async move { self.recover_registered().await.map_err(document_error) }) + } + fn baseline( + &self, + scope: SyncScope, + documents: DocumentSet, + revision: Revision, + ) -> BoxFuture<'_, SyncResult<()>> { + Box::pin(async move { + self.record_baseline(scope, documents, revision) + .await + .map_err(document_error) + }) + } + fn generation(&self) -> SyncResult { + CoreSyncStore::generation(self).map_err(document_error) + } + fn device(&self) -> SourceDevice { + CoreSyncStore::device(self) + } + fn changes(&self) -> tokio::sync::watch::Receiver { + CoreSyncStore::changes(self) + } +} diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/api.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/api.rs new file mode 100644 index 00000000..b01b5b66 --- /dev/null +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/api.rs @@ -0,0 +1,231 @@ +use super::OpenLessBackend; +use crate::{cloud_sync_e2ee::*, BackendError}; + +impl OpenLessBackend { + pub async fn sign_out_account(&self) -> Result<(), BackendError> { + if let Some(service) = &self.encrypted_sync { + service.sign_out().await.map(|_| ()) + } else { + self.services().marketplace.logout().await + } + } + fn encrypted_sync_service( + &self, + ) -> Result<&crate::cloud_sync_e2ee::EncryptedSyncService, BackendError> { + self.encrypted_sync + .as_ref() + .ok_or_else(|| crate::cloud_sync_e2ee::error("service_unavailable")) + } + + pub fn cloud_sync_e2ee_status(&self) -> Result { + Ok(self.encrypted_sync_service()?.status()) + } + + pub async fn cloud_sync_e2ee_claim_setup_prompt(&self) -> Result { + let Some(service) = self.encrypted_sync.as_ref() else { + return Ok(false); + }; + let Some(store) = self.encrypted_sync_store.as_ref() else { + return Ok(false); + }; + let store = std::sync::Arc::clone(store); + let settings_gate = std::sync::Arc::clone(&self.settings_write_gate); + service + .claim_setup_prompt( + std::sync::Arc::clone(&self.deps.credential_store), + move || { + let _settings = match settings_gate.try_lock() { + Ok(guard) => guard, + Err(std::sync::TryLockError::WouldBlock) => return Ok(None), + Err(std::sync::TryLockError::Poisoned(_)) => { + return Err(crate::cloud_sync_e2ee::error("recovery_required")) + } + }; + store + .setup_prompt_state() + .map_err(crate::cloud_sync_e2ee::document_error) + }, + ) + .await + } + + pub async fn cloud_sync_e2ee_prepare_enable( + &self, + consent_version: String, + ) -> Result { + self.encrypted_sync_service()? + .prepare_enable(consent_version) + .await + } + + pub async fn cloud_sync_e2ee_create( + &self, + password: String, + password_confirmation: String, + remember_key: bool, + consent_version: String, + observed_revision: String, + ) -> Result { + self.encrypted_sync_service()? + .create( + password, + password_confirmation, + remember_key, + consent_version, + observed_revision, + ) + .await + } + + pub async fn cloud_sync_e2ee_unlock( + &self, + password: String, + remember_key: bool, + ) -> Result { + self.encrypted_sync_service()? + .unlock(password, remember_key) + .await + } + + pub async fn cloud_sync_e2ee_lock(&self) -> Result { + self.encrypted_sync_service()?.lock().await + } + + pub async fn cloud_sync_e2ee_set_enabled( + &self, + enabled: bool, + ) -> Result { + self.encrypted_sync_service()?.set_enabled(enabled).await + } + + pub async fn cloud_sync_e2ee_sync_now(&self) -> Result { + self.encrypted_sync_service()?.sync_now().await + } + + pub fn cloud_sync_e2ee_cancel( + &self, + task_id: String, + ) -> Result { + self.encrypted_sync_service()?.cancel(&task_id) + } + + pub async fn cloud_sync_e2ee_preview_restore( + &self, + observed_revision: String, + ) -> Result { + self.encrypted_sync_service()? + .preview_restore(observed_revision) + .await + } + + pub async fn cloud_sync_e2ee_apply_restore( + &self, + preview_id: String, + mode: RestoreMode, + conflict_choices: Vec, + ) -> Result { + self.encrypted_sync_service()? + .apply_restore(preview_id, mode, conflict_choices) + .await + } + + pub async fn cloud_sync_e2ee_change_password( + &self, + current_password: String, + new_password: String, + confirmation: String, + remember_key: bool, + ) -> Result { + self.encrypted_sync_service()? + .change_password(current_password, new_password, confirmation, remember_key) + .await + } + + pub async fn cloud_sync_e2ee_delete_remote( + &self, + expected_vault_id: String, + observed_revision: String, + confirmed: bool, + ) -> Result { + self.encrypted_sync_service()? + .delete_remote(expected_vault_id, observed_revision, confirmed) + .await + } + + pub async fn cloud_sync_e2ee_sign_out(&self) -> Result { + self.encrypted_sync_service()?.sign_out().await + } + + pub async fn cloud_sync_e2ee_begin_sign_in(&self) -> Result { + self.encrypted_sync_service()?.begin_sign_in().await + } + + pub async fn cloud_sync_e2ee_poll_sign_in( + &self, + authorization_session_id: String, + ) -> Result { + self.encrypted_sync_service()? + .poll_sign_in(authorization_session_id) + .await + } + + pub async fn cloud_sync_e2ee_cancel_sign_in( + &self, + authorization_session_id: String, + ) -> Result<(), BackendError> { + self.encrypted_sync_service()? + .cancel_sign_in(authorization_session_id) + .await + } + + pub async fn cloud_sync_e2ee_set_ui_preferences( + &self, + locale: String, + font_scale: String, + ) -> Result<(), BackendError> { + let store = self + .encrypted_sync_store + .as_ref() + .ok_or_else(|| crate::cloud_sync_e2ee::error("service_unavailable"))?; + store + .set_ui_preferences(crate::cloud_sync_e2ee_documents::ui_preferences( + &locale, + &font_scale, + )) + .await + .map_err(crate::cloud_sync_e2ee::document_error) + } + + pub async fn cloud_sync_e2ee_get_ui_preferences( + &self, + ) -> Result, BackendError> { + self.encrypted_sync_service()?.ui_preferences().await + } + + pub async fn cloud_sync_e2ee_get_ui_preferences_snapshot( + &self, + ) -> Result { + self.encrypted_sync_service()? + .ui_preferences_snapshot() + .await + } + + pub async fn cloud_sync_e2ee_set_ui_preferences_checked( + &self, + locale: String, + font_scale: String, + expected_revision: Option, + ) -> Result<(), BackendError> { + let store = self + .encrypted_sync_store + .as_ref() + .ok_or_else(|| crate::cloud_sync_e2ee::error("service_unavailable"))?; + store + .set_ui_preferences_checked( + crate::cloud_sync_e2ee_documents::ui_preferences(&locale, &font_scale), + expected_revision, + ) + .await + .map_err(crate::cloud_sync_e2ee::document_error) + } +} diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/dto.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/dto.rs new file mode 100644 index 00000000..30ea132c --- /dev/null +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/dto.rs @@ -0,0 +1,222 @@ +//! Public, value-free sync status. Secret document types never cross IPC. + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; + +pub const CONSENT_VERSION: &str = "encrypted-full-snapshot-v1"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AuthState { + SignedOut, + SignedIn, + Expired, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum KeyState { + Locked, + Unlocked, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SyncState { + Disabled, + SignInRequired, + UnlockRequired, + Ready, + Pending, + Syncing, + Conflict, + Failed, + OutcomeUnknown, + RecoveryRequired, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SyncAccount { + pub github_id: String, + pub login: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EncryptedSyncSignIn { + pub authorization_session_id: String, + pub user_code: String, + pub verification_uri: String, + pub expires_at: String, + pub interval_seconds: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde( + tag = "status", + rename_all = "snake_case", + rename_all_fields = "camelCase" +)] +pub enum EncryptedSyncSignInResult { + Pending { slow_down: bool }, + SignedIn { account: SyncAccount }, + Denied, + Expired, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SyncFailure { + pub code: String, + pub retry_after_seconds: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EncryptedSyncStatus { + pub sequence: String, + pub enabled: bool, + pub auth_state: AuthState, + pub key_state: KeyState, + pub sync_state: SyncState, + pub account: Option, + pub vault_id: Option, + pub key_id: Option, + pub local_generation: String, + pub last_synced_local_generation: Option, + pub remote_revision: Option, + pub last_successful_sync_at: Option, + pub pending_operation_id: Option, + pub last_error: Option, + pub recovery_required: bool, + pub has_cloud_snapshot: Option, + pub task_id: Option, + pub service_origin: String, + pub consent_version: Option, + pub backup_retention_days: Option, +} + +impl EncryptedSyncStatus { + pub(crate) fn initial(origin: String) -> Self { + Self { + sequence: "0".into(), + enabled: false, + auth_state: AuthState::SignedOut, + key_state: KeyState::Locked, + sync_state: SyncState::Disabled, + account: None, + vault_id: None, + key_id: None, + local_generation: "0".into(), + last_synced_local_generation: None, + remote_revision: None, + last_successful_sync_at: None, + pending_operation_id: None, + last_error: None, + recovery_required: false, + has_cloud_snapshot: None, + task_id: None, + service_origin: origin, + consent_version: None, + backup_retention_days: None, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum EnableStep { + Create, + Unlock, + RestoreReview, + Ready, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EnablePreparation { + pub next_step: EnableStep, + pub status: EncryptedSyncStatus, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RestoreMode { + Replace, + Merge, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SyncConflictChoice { + pub id: String, + pub side: ConflictSide, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ConflictSide { + Local, + Cloud, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SyncConflictItem { + pub id: String, + pub kind: String, + /// A fixed reason code, never a value, path, history excerpt, or API key. + pub reason: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RestorePreview { + pub preview_id: String, + pub unconfirmed_operation_id: Option, + pub observed_revision: String, + pub local_generation: String, + pub counts: BTreeMap, + pub device_settings_to_review: Vec, + pub conflicts: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EncryptedSyncEvent { + pub sequence: String, + pub account_id: Option, + pub vault_id: Option, + pub task_id: Option, + pub status: EncryptedSyncStatus, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EncryptedSyncConflictEvent { + pub sequence: String, + pub account_id: String, + pub vault_id: String, + pub task_id: Option, + pub preview: RestorePreview, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EncryptedSyncRestoreEvent { + pub sequence: String, + pub account_id: String, + pub vault_id: String, + pub task_id: Option, + pub local_generation: String, + pub ui_preferences: BTreeMap, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EncryptedUiPreferencesSnapshot { + pub preferences: Option, + pub revision: Option, +} diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/local.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/local.rs new file mode 100644 index 00000000..64a79129 --- /dev/null +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/local.rs @@ -0,0 +1,465 @@ +//! Encrypted local baselines and unresolved operations. The wrapping key lives +//! only in the host's system vault, never beside these files. + +use std::{ + fs, + io::Write, + path::{Path, PathBuf}, + sync::Arc, +}; + +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; +use serde::{de::DeserializeOwned, Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use tokio::sync::OnceCell; +use zeroize::Zeroizing; + +use crate::cloud_sync_e2ee_protocol::crypto::{open_local, seal_local, DerivedKey}; +use crate::credentials::SyncSecretAccount; +use crate::{CredentialStore, SecretValue}; + +use super::{error, SyncResult}; + +#[derive(Clone)] +pub(crate) struct LocalStorage { + root: PathBuf, + origin: String, + device_id: String, + credentials: Arc, + cipher: Arc>>, + io: Arc>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct ClientSettings { + pub version: u32, + pub enabled: bool, + pub consent_version: Option, + pub owner_id: Option, + pub remember_key: bool, + #[serde(default)] + pub key_preference_epoch: u64, + pub last_success: Option, + pub last_generation: Option, + pub vault_id: Option, + pub key_id: Option, + pub prompted: bool, +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct UiEnvelope { + pub schema_version: u32, + pub revision: String, + pub value: crate::cloud_sync_e2ee_documents::SecretJson, +} + +impl Default for ClientSettings { + fn default() -> Self { + Self { + version: 1, + enabled: false, + consent_version: None, + owner_id: None, + remember_key: false, + key_preference_epoch: 0, + last_success: None, + last_generation: None, + vault_id: None, + key_id: None, + prompted: false, + } + } +} + +impl LocalStorage { + pub(crate) fn new( + root: PathBuf, + origin: String, + device_id: String, + credentials: Arc, + ) -> Self { + Self { + root, + origin, + device_id, + credentials, + cipher: Arc::new(OnceCell::new()), + io: Arc::new(tokio::sync::Mutex::new(())), + } + } + + pub(crate) fn initialized(&self) -> SyncResult { + self.path("device", "client") + .try_exists() + .map_err(|_| error("local_storage_unavailable")) + } + + pub(crate) async fn read_ui(&self) -> SyncResult> { + let value: Option = self.read("device", "sync-ui-preferences").await?; + if let Some(value) = &value { + if value.schema_version != 1 + || crate::cloud_sync_e2ee_protocol::types::UuidV4::parse(&value.revision).is_err() + { + return Err(error("recovery_required")); + } + } + Ok(value) + } + + pub(crate) fn binding( + &self, + owner: &str, + vault: &str, + key: &str, + ) -> SyncResult { + let binding = serde_json::to_vec(&[&self.origin, owner, vault, key, &self.device_id]) + .map_err(|_| error("local_storage_unavailable"))?; + SyncSecretAccount::new(format!("cloud-sync.e2ee.key.{:x}", Sha256::digest(binding))) + .map_err(|_| error("local_storage_unavailable")) + } + + pub(crate) async fn key(&self) -> SyncResult> { + self.cipher + .get_or_try_init(|| async { + let aad = self.aad("device", "storage-key")?; + let account = SyncSecretAccount::new(format!( + "cloud-sync.e2ee.local.{:x}", + Sha256::digest(aad) + )) + .map_err(|_| error("local_storage_unavailable"))?; + let value = self + .credentials + .read_sync_secret(account.clone()) + .await + .map_err(|_| error("secure_storage_denied"))?; + let bytes = match value { + Some(value) => decode_key(value.expose_secret())?, + None => { + // Losing the system-vault key never silently resets encrypted recovery data. + let root = self.root.clone(); + let has_state = tokio::task::spawn_blocking(move || { + if !root.exists() { + return Ok(false); + } + let files = fs::read_dir(root) + .map_err(|_| error("local_storage_unavailable"))?; + for file in files { + let file = file.map_err(|_| error("local_storage_unavailable"))?; + if file + .path() + .extension() + .is_some_and(|extension| extension == "enc") + { + return Ok(true); + } + } + Ok(false) + }) + .await + .map_err(|_| error("local_storage_unavailable"))??; + if has_state { + return Err(error("recovery_required")); + } + let mut bytes = Zeroizing::new([0_u8; 32]); + getrandom::fill(&mut *bytes) + .map_err(|_| error("secure_random_unavailable"))?; + self.credentials + .write_sync_secret( + account.clone(), + SecretValue::new(URL_SAFE_NO_PAD.encode(&bytes[..])), + ) + .await + .map_err(|_| error("secure_storage_denied"))?; + let verified = self + .credentials + .read_sync_secret(account) + .await + .map_err(|_| error("secure_storage_denied"))? + .ok_or_else(|| error("secure_storage_denied"))?; + if decode_key(verified.expose_secret())?.as_ref() != bytes.as_ref() { + return Err(error("secure_storage_denied")); + } + bytes + } + }; + Ok(Arc::new(DerivedKey::from_secret_bytes(bytes))) + }) + .await + .cloned() + } + + fn aad(&self, owner: &str, name: &str) -> SyncResult> { + serde_json::to_vec(&[ + "openless.local-sync.v1", + &self.origin, + &self.device_id, + owner, + name, + ]) + .map_err(|_| error("local_storage_unavailable")) + } + + fn path(&self, owner: &str, name: &str) -> PathBuf { + let id = format!("{owner}\0{name}"); + self.root + .join(format!("{:x}.enc", Sha256::digest(id.as_bytes()))) + } + + pub(crate) async fn read( + &self, + owner: &str, + name: &str, + ) -> SyncResult> { + let io = self.io.clone().lock_owned().await; + let path = self.path(owner, name); + if !path + .try_exists() + .map_err(|_| error("local_storage_unavailable"))? + { + return Ok(None); + } + let key = self.key().await?; + let aad = self.aad(owner, name)?; + tokio::task::spawn_blocking(move || { + let _io = io; + let file = fs::File::open(path).map_err(|_| error("local_storage_unavailable"))?; + if file + .metadata() + .map_err(|_| error("local_storage_unavailable"))? + .len() + > 41 * 1024 * 1024 + { + return Err(error("recovery_required")); + } + use std::io::Read; + let mut bytes = Vec::new(); + file.take(41 * 1024 * 1024 + 1) + .read_to_end(&mut bytes) + .map_err(|_| error("local_storage_unavailable"))?; + let plain = open_local(&key, &aad, &bytes).map_err(|_| error("recovery_required"))?; + serde_json::from_slice(&plain) + .map(Some) + .map_err(|_| error("recovery_required")) + }) + .await + .map_err(|_| error("local_storage_unavailable"))? + } + + pub(crate) async fn write( + &self, + owner: &str, + name: &str, + value: T, + ) -> SyncResult<()> { + let key = self.key().await?; + let io = self.io.clone().lock_owned().await; + let aad = self.aad(owner, name)?; + let path = self.path(owner, name); + tokio::task::spawn_blocking(move || { + let _io = io; + let plain = Zeroizing::new( + serde_json::to_vec(&value).map_err(|_| error("local_storage_unavailable"))?, + ); + let sealed = + seal_local(&key, &aad, &plain).map_err(|_| error("local_storage_unavailable"))?; + durable_replace(&path, &sealed) + }) + .await + .map_err(|_| error("local_storage_unavailable"))? + } + + pub(crate) async fn remove(&self, owner: &str, name: &str) -> SyncResult<()> { + let io = self.io.clone().lock_owned().await; + let path = self.path(owner, name); + tokio::task::spawn_blocking(move || { + let _io = io; + match fs::remove_file(&path) { + Ok(()) => sync_directory( + path.parent() + .ok_or_else(|| error("local_storage_unavailable"))?, + ), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(_) => Err(error("local_storage_unavailable")), + } + }) + .await + .map_err(|_| error("local_storage_unavailable"))? + } + + pub(crate) async fn remember( + &self, + owner: &str, + vault: &str, + key_id: &str, + key: &DerivedKey, + remember: bool, + ) -> SyncResult<()> { + let account = self.binding(owner, vault, key_id)?; + if remember { + let bytes = key.copy_secret_bytes(); + self.credentials + .write_sync_secret( + account, + SecretValue::new(URL_SAFE_NO_PAD.encode(&bytes[..])), + ) + .await + .map_err(|_| error("secure_storage_denied")) + } else { + self.credentials + .remove_sync_secret(account) + .await + .map_err(|_| error("secure_storage_denied")) + } + } + + pub(crate) async fn remembered( + &self, + owner: &str, + vault: &str, + key_id: &str, + ) -> SyncResult> { + self.credentials + .read_sync_secret(self.binding(owner, vault, key_id)?) + .await + .map_err(|_| error("secure_storage_denied"))? + .map(|value| decode_key(value.expose_secret()).map(DerivedKey::from_secret_bytes)) + .transpose() + } + + pub(crate) async fn forget(&self, owner: &str, vault: &str, key_id: &str) -> SyncResult<()> { + self.credentials + .remove_sync_secret(self.binding(owner, vault, key_id)?) + .await + .map_err(|_| error("secure_storage_denied")) + } + + /// A non-secret, durable refusal to auto-unlock survives even when encrypted + /// recovery metadata or the operating-system vault cannot currently be read. + pub(crate) async fn set_lockout(&self, locked: bool) -> SyncResult<()> { + let io = self.io.clone().lock_owned().await; + let path = self.root.join("unlock-disabled"); + tokio::task::spawn_blocking(move || { + let _io = io; + if locked { + durable_replace(&path, b"locked-v1\n") + } else { + match fs::remove_file(&path) { + Ok(()) => sync_directory( + path.parent() + .ok_or_else(|| error("local_storage_unavailable"))?, + ), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(_) => Err(error("local_storage_unavailable")), + } + } + }) + .await + .map_err(|_| error("local_storage_unavailable"))? + } + + pub(crate) async fn locked_out(&self) -> SyncResult { + let io = self.io.clone().lock_owned().await; + let path = self.root.join("unlock-disabled"); + tokio::task::spawn_blocking(move || { + let _io = io; + path.try_exists() + .map_err(|_| error("local_storage_unavailable")) + }) + .await + .map_err(|_| error("local_storage_unavailable"))? + } +} + +fn decode_key(value: &str) -> SyncResult> { + let bytes = Zeroizing::new( + URL_SAFE_NO_PAD + .decode(value) + .map_err(|_| error("secure_storage_denied"))?, + ); + if URL_SAFE_NO_PAD.encode(&*bytes) != value { + return Err(error("secure_storage_denied")); + } + let bytes = + <[u8; 32]>::try_from(bytes.as_slice()).map_err(|_| error("secure_storage_denied"))?; + Ok(Zeroizing::new(bytes)) +} + +pub(crate) fn durable_replace(path: &Path, bytes: &[u8]) -> SyncResult<()> { + let parent = path + .parent() + .ok_or_else(|| error("local_storage_unavailable"))?; + fs::create_dir_all(parent).map_err(|_| error("local_storage_unavailable"))?; + let temporary = parent.join(format!(".{}.tmp", uuid::Uuid::new_v4())); + let result = (|| { + let mut options = fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let mut file = options + .open(&temporary) + .map_err(|_| error("local_storage_unavailable"))?; + file.write_all(bytes) + .map_err(|_| error("local_storage_unavailable"))?; + file.sync_all() + .map_err(|_| error("local_storage_unavailable"))?; + fs::rename(&temporary, path).map_err(|_| error("local_storage_unavailable"))?; + sync_directory(parent) + })(); + if result.is_err() { + let _ = fs::remove_file(temporary); + } + result +} + +fn sync_directory(path: &Path) -> SyncResult<()> { + #[cfg(unix)] + { + fs::File::open(path) + .and_then(|f| f.sync_all()) + .map_err(|_| error("local_storage_unavailable"))?; + } + #[cfg(not(unix))] + { + let _ = path; + } + Ok(()) +} + +/// Publish a fully written identity without replacing an existing install's ID. +pub(crate) fn durable_create(path: &Path, bytes: &[u8]) -> SyncResult { + let parent = path + .parent() + .ok_or_else(|| error("local_storage_unavailable"))?; + fs::create_dir_all(parent).map_err(|_| error("local_storage_unavailable"))?; + let temporary = parent.join(format!(".identity-{}.tmp", uuid::Uuid::new_v4())); + let result = (|| { + let mut options = fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let mut file = options + .open(&temporary) + .map_err(|_| error("local_storage_unavailable"))?; + file.write_all(bytes) + .and_then(|()| file.sync_all()) + .map_err(|_| error("local_storage_unavailable"))?; + match fs::hard_link(&temporary, path) { + Ok(()) => { + sync_directory(parent)?; + Ok(true) + } + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Ok(false), + Err(_) => Err(error("local_storage_unavailable")), + } + })(); + let _ = fs::remove_file(&temporary); + result +} diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/mod.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/mod.rs new file mode 100644 index 00000000..da39dce4 --- /dev/null +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/mod.rs @@ -0,0 +1,102 @@ +//! Encrypted sync orchestration. All private data stays in Core; the UI receives +//! only status, category counts and opaque conflict identifiers. + +mod adapter; +mod dto; +mod local; +mod service; +#[cfg(test)] +mod tests; + +pub(crate) use adapter::build; +pub use dto::*; +pub(crate) use service::{EncryptedSyncService, SyncServiceConfig}; + +pub const DEFAULT_SYNC_SERVICE_ORIGIN: &str = "https://apic.openless.top:9443"; + +#[derive(Debug, Clone)] +pub struct EncryptedSyncConfig { + pub service_origin: String, + pub app_version: String, +} + +use crate::cloud_sync_e2ee_protocol as protocol; +use crate::{BackendError, BackendErrorCode}; + +pub(crate) type SyncResult = Result; + +pub(crate) fn error(reason: &'static str) -> BackendError { + let code = match reason { + "busy" | "stale_preview" | "revision_conflict" => BackendErrorCode::Busy, + "cancelled" | "account_changed" => BackendErrorCode::Cancelled, + "sign_in_required" | "unlock_required" | "secure_storage_denied" => { + BackendErrorCode::PermissionDenied + } + "service_unavailable" | "unsupported_protocol" => BackendErrorCode::Unsupported, + "outcome_unknown" => BackendErrorCode::OutcomeUnknown, + "recovery_required" | "local_storage_unavailable" => BackendErrorCode::Persistence, + "transport_failed" | "rate_limited" => BackendErrorCode::Provider, + _ => BackendErrorCode::InvalidState, + }; + let mut error = BackendError::new(code, reason); + error.details = Some(serde_json::json!({"reason": reason})); + error +} + +pub(crate) fn protocol_error(value: protocol::Error) -> BackendError { + use protocol::Error as E; + let reason = match &value { + E::WeakPassword => "weak_password", + E::PasswordConfirmationMismatch => "password_confirmation_mismatch", + E::InvalidPasswordOrCiphertext => "invalid_password_or_ciphertext", + E::Transport => "transport_failed", + E::PayloadTooLarge => "payload_too_large", + E::AccountMismatch => "account_changed", + E::ContextMismatch => "revision_conflict", + E::UnsupportedProtocol | E::UnsupportedDocumentVersion => "unsupported_protocol", + E::RandomUnavailable => "secure_random_unavailable", + E::Api { + status: 401 | 403, .. + } => "sign_in_required", + E::Api { + status: 409 | 412, .. + } => "revision_conflict", + E::Api { + status: 404 | 405 | 501, + .. + } => "service_unavailable", + E::Api { status: 429, .. } => "rate_limited", + E::Api { status: 413, .. } => "payload_too_large", + E::Api { .. } => "service_failed", + _ => "invalid_response", + }; + let mut result = error(reason); + if let E::Api { + retry_after_seconds: Some(seconds), + .. + } = value + { + result.details = Some(serde_json::json!({"reason": reason, "retryAfterSeconds": seconds})); + } + result +} + +pub(crate) fn document_error( + value: crate::cloud_sync_e2ee_documents::DocumentError, +) -> BackendError { + // DocumentError's Display is a closed set of value-free protocol codes. + let reason = value.to_string(); + let code = if matches!( + value, + crate::cloud_sync_e2ee_documents::DocumentError::RuntimeBusy + | crate::cloud_sync_e2ee_documents::DocumentError::SourceChanged + | crate::cloud_sync_e2ee_documents::DocumentError::StalePreview + ) { + BackendErrorCode::Busy + } else { + BackendErrorCode::InvalidState + }; + let mut result = BackendError::new(code, reason.clone()); + result.details = Some(serde_json::json!({"reason": reason})); + result +} diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/service.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/service.rs new file mode 100644 index 00000000..640ac19c --- /dev/null +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/service.rs @@ -0,0 +1,2122 @@ +#[path = "setup_prompt.rs"] +mod setup_prompt; + +use std::{ + collections::BTreeMap, + sync::{ + atomic::{AtomicBool, AtomicU64, Ordering}, + Arc, Mutex, + }, + time::{Duration, Instant}, +}; + +use futures_util::future::BoxFuture; +use serde::{Deserialize, Serialize}; + +use crate::cloud_sync_e2ee_documents::{ + self as documents, ExportedDocuments, RestoreContext, SyncScope, ValidatedSyncDocuments, +}; +use crate::cloud_sync_e2ee_protocol::{ + crypto::{self, DerivedKey, EncryptContext, NormalizedPassword}, + transport::{Metadata, MetadataResult, OperationStatus, ProxyPolicy, SyncSession, Transport}, + types::*, +}; +use crate::events::{BackendEventKind, BackendEventPublisher}; +use crate::marketplace::MarketplaceService; +use crate::{BackendError, SecretValue}; + +use super::{ + document_error, + dto::*, + error, + local::{ClientSettings, LocalStorage}, + protocol_error, SyncResult, +}; + +/// The repository adapter performs the protected, exclusive restore transaction +/// and verifies actual read-back before returning. It never runs UI callbacks. +pub(crate) trait SyncServiceData: Send + Sync { + fn export(&self, scope: SyncScope) -> BoxFuture<'_, SyncResult>; + fn restore( + &self, + desired: ValidatedSyncDocuments, + context: RestoreContext, + ) -> BoxFuture<'_, SyncResult>; + fn recover(&self) -> BoxFuture<'_, SyncResult<()>>; + fn baseline( + &self, + scope: SyncScope, + documents: DocumentSet, + revision: Revision, + ) -> BoxFuture<'_, SyncResult<()>>; + fn generation(&self) -> SyncResult; + fn device(&self) -> SourceDevice; + fn changes( + &self, + ) -> tokio::sync::watch::Receiver; +} + +pub(crate) struct SyncServiceConfig { + pub origin: String, + pub github_client_id: String, +} + +struct Connection { + transport: Transport, + session: SyncSession, + github_token: SecretValue, + expires_at: Instant, +} + +#[derive(Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct Baseline { + revision: Revision, + vault_id: UuidV4, + documents: DocumentSet, +} + +#[derive(Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct Pending { + record: String, + operation_id: String, + vault_id: String, + key_id: Option, + local_generation: Revision, + deleted: bool, + remember_key: bool, + key_preference_epoch: u64, + base_revision: Revision, + old_key_id: Option, +} + +struct PreviewState { + public: RestorePreview, + remote: ValidatedSyncDocuments, + local: ExportedDocuments, + baseline: Option, + vault_id: String, + key_id: String, +} + +struct Runtime { + initialized: bool, + settings: ClientSettings, + connection: Option, + metadata: Option, + snapshot: Option, + key: Option>, + baseline: Option, + preview: Option, + retry_at: Option, +} + +struct Shared { + config: SyncServiceConfig, + marketplace: Arc, + data: Arc, + local: LocalStorage, + events: BackendEventPublisher, + runtime: tokio::sync::Mutex, + status: Mutex, + cancelled: Arc, + auto_started: AtomicBool, + shutdown: AtomicBool, + auto_suspended: AtomicBool, + sequence: AtomicU64, + wake: tokio::sync::Notify, +} + +#[derive(Clone)] +pub(crate) struct EncryptedSyncService(Arc); + +impl EncryptedSyncService { + pub(crate) fn new( + config: SyncServiceConfig, + marketplace: Arc, + local: LocalStorage, + data: Arc, + events: BackendEventPublisher, + ) -> Self { + let status = EncryptedSyncStatus::initial(config.origin.clone()); + Self(Arc::new(Shared { + config, + marketplace, + data, + local, + events, + runtime: tokio::sync::Mutex::new(Runtime { + initialized: false, + settings: ClientSettings::default(), + connection: None, + metadata: None, + snapshot: None, + key: None, + baseline: None, + preview: None, + retry_at: None, + }), + status: Mutex::new(status), + cancelled: Arc::new(AtomicBool::new(false)), + auto_started: AtomicBool::new(false), + shutdown: AtomicBool::new(false), + auto_suspended: AtomicBool::new(false), + sequence: AtomicU64::new(0), + wake: tokio::sync::Notify::new(), + })) + } + + pub(crate) fn status(&self) -> EncryptedSyncStatus { + let mut value = self + .0 + .status + .lock() + .unwrap_or_else(|e| e.into_inner()) + .clone(); + value.sequence = self.0.sequence.load(Ordering::Acquire).to_string(); + match self.0.data.generation() { + Ok(generation) => value.local_generation = generation.as_str().into(), + Err(_) => { + value.recovery_required = true; + value.sync_state = SyncState::RecoveryRequired; + } + } + value + } + + fn update(&self, update: impl FnOnce(&mut EncryptedSyncStatus)) { + let status = { + let mut status = self.0.status.lock().unwrap_or_else(|e| e.into_inner()); + update(&mut status); + status.sequence = self + .0 + .sequence + .fetch_add(1, Ordering::AcqRel) + .saturating_add(1) + .to_string(); + status.clone() + }; + self.0.events.publish( + None, + BackendEventKind::CloudSyncStateChanged(EncryptedSyncEvent { + sequence: status.sequence.clone(), + account_id: status.account.as_ref().map(|v| v.github_id.clone()), + vault_id: status.vault_id.clone(), + task_id: status.task_id.clone(), + status, + }), + ); + } + + fn begin(&self) { + self.update(|s| { + self.0.cancelled.store(false, Ordering::Release); + s.task_id = Some(uuid::Uuid::new_v4().to_string()); + s.sync_state = SyncState::Syncing; + s.last_error = None; + }); + } + + fn check_cancelled(&self) -> SyncResult<()> { + if self.0.cancelled.load(Ordering::Acquire) || self.0.shutdown.load(Ordering::Acquire) { + Err(error("cancelled")) + } else { + Ok(()) + } + } + + async fn current_account(&self, runtime: &Runtime) -> SyncResult<()> { + self.check_cancelled()?; + let connection = runtime + .connection + .as_ref() + .ok_or_else(|| error("sign_in_required"))?; + if self + .0 + .marketplace + .read_access_token() + .await + .map_err(|_| error("sign_in_required"))? + != connection.github_token + { + return Err(error("account_changed")); + } + self.check_cancelled() + } + + fn finish(&self, runtime: &mut Runtime, result: &SyncResult) { + let failure = result.as_ref().err().map(|e| { + let code = e + .details + .as_ref() + .and_then(|v| v.get("reason")) + .and_then(|v| v.as_str()) + .unwrap_or("sync_failed") + .to_owned(); + let retry_after_seconds = e + .details + .as_ref() + .and_then(|v| v.get("retryAfterSeconds")) + .and_then(|v| v.as_u64()) + .and_then(|v| u32::try_from(v).ok()); + SyncFailure { + code, + retry_after_seconds, + } + }); + if let Some(seconds) = failure.as_ref().and_then(|v| v.retry_after_seconds) { + runtime.retry_at = Some(Instant::now() + Duration::from_secs(u64::from(seconds))); + } + self.update(|s| { + if let Ok(generation) = self.0.data.generation() { + s.local_generation = generation.as_str().into(); + } + s.task_id = None; + s.enabled = runtime.settings.enabled; + s.key_state = if runtime.key.is_some() { + KeyState::Unlocked + } else { + KeyState::Locked + }; + s.consent_version = runtime.settings.consent_version.clone(); + s.last_successful_sync_at = runtime.settings.last_success.clone(); + s.last_synced_local_generation = runtime.settings.last_generation.clone(); + if let Some(failure) = failure { + s.sync_state = match failure.code.as_str() { + "sign_in_required" | "account_changed" => { + s.auth_state = AuthState::Expired; + SyncState::SignInRequired + } + "unlock_required" | "invalid_password_or_ciphertext" => { + SyncState::UnlockRequired + } + "outcome_unknown" => SyncState::OutcomeUnknown, + "recovery_required" => { + s.recovery_required = true; + SyncState::RecoveryRequired + } + "conflict" | "restore_review_required" => SyncState::Conflict, + "cancelled" => { + if s.enabled { + SyncState::Pending + } else { + SyncState::Disabled + } + } + _ => SyncState::Failed, + }; + s.last_error = Some(failure); + } else { + s.last_error = None; + s.sync_state = if runtime.preview.is_some() { + SyncState::Conflict + } else if !s.enabled { + SyncState::Disabled + } else if s.auth_state != AuthState::SignedIn { + SyncState::SignInRequired + } else if runtime.key.is_none() { + SyncState::UnlockRequired + } else if s.pending_operation_id.is_some() { + SyncState::OutcomeUnknown + } else if s.last_synced_local_generation.as_deref() + != Some(s.local_generation.as_str()) + { + SyncState::Pending + } else { + SyncState::Ready + }; + } + }); + } + + async fn initialize(&self, runtime: &mut Runtime) -> SyncResult<()> { + // Recovery precedes all ordinary mutations and backend-ready reporting. + // It also handles a dropped IPC future without requiring a process restart. + self.0.data.recover().await?; + if runtime.initialized { + return Ok(()); + } + if self.0.local.initialized()? { + runtime.settings = self + .0 + .local + .read("device", "client") + .await? + .ok_or_else(|| error("recovery_required"))?; + if runtime.settings.version != 1 { + return Err(error("recovery_required")); + } + } + if self.0.local.locked_out().await? { + runtime.settings.remember_key = false; + runtime.key = None; + runtime.preview = None; + } + runtime.initialized = true; + Ok(()) + } + + async fn save_settings(&self, runtime: &Runtime) -> SyncResult<()> { + self.0 + .local + .write("device", "client", runtime.settings.clone()) + .await + } + + async fn connect(&self, runtime: &mut Runtime) -> SyncResult<()> { + self.initialize(runtime).await?; + self.check_cancelled()?; + if runtime.retry_at.is_some_and(|at| at > Instant::now()) { + return Err(error("rate_limited")); + } + // Capture once after recovery has applied the current preferences. Both + // cache validation and client construction use this exact policy. + let proxy_policy = + ProxyPolicy::for_origin(&self.0.config.origin, crate::net::use_system_proxy()); + self.connect_with_proxy_policy(runtime, proxy_policy).await + } + + async fn connect_with_proxy_policy( + &self, + runtime: &mut Runtime, + proxy_policy: ProxyPolicy, + ) -> SyncResult<()> { + let valid = if let Some(connection) = &runtime.connection { + connection.expires_at > Instant::now() + Duration::from_secs(30) + && connection.transport.proxy_policy() == proxy_policy + && self.0.marketplace.read_access_token().await.ok().as_ref() + == Some(&connection.github_token) + } else { + false + }; + if valid { + return Ok(()); + } + #[cfg(not(test))] + let transport = Transport::new( + &self.0.config.origin, + &self.0.config.github_client_id, + proxy_policy, + ) + .await + .map_err(protocol_error)?; + #[cfg(test)] + let transport = if self.0.config.origin.starts_with("http://127.0.0.1:") { + Transport::for_test_with_proxy_policy( + &self.0.config.origin, + &self.0.config.github_client_id, + proxy_policy, + ) + .await + .map_err(protocol_error)? + } else { + Transport::new( + &self.0.config.origin, + &self.0.config.github_client_id, + proxy_policy, + ) + .await + .map_err(protocol_error)? + }; + let (token, account) = self + .0 + .marketplace + .sync_identity() + .await + .map_err(|_| error("sign_in_required"))?; + self.check_cancelled()?; + let session = transport + .exchange(token.expose_secret(), &account.github_id) + .await + .map_err(protocol_error)?; + if session.account().github_id != account.github_id + || transport.service_origin().trim_end_matches('/') + != self.0.config.origin.trim_end_matches('/') + { + return Err(error("account_changed")); + } + let backup_retention_days = transport.capabilities().max_backup_retention_days; + if self + .0 + .marketplace + .read_access_token() + .await + .map_err(|_| error("sign_in_required"))? + != token + { + return Err(error("account_changed")); + } + let owner = account.github_id.as_str().to_string(); + if runtime.settings.owner_id.as_deref() != Some(&owner) { + if runtime.settings.owner_id.is_some() { + runtime.settings.enabled = false; + runtime.settings.remember_key = false; + self.save_settings(runtime).await?; + } + self.forget_unlock_material(runtime).await?; + runtime.settings.enabled = false; + runtime.settings.consent_version = None; + runtime.settings.remember_key = false; + runtime.settings.last_success = None; + runtime.settings.last_generation = None; + runtime.settings.vault_id = None; + runtime.settings.key_id = None; + runtime.key = None; + runtime.baseline = None; + runtime.preview = None; + runtime.metadata = None; + runtime.snapshot = None; + } + runtime.settings.owner_id = Some(owner.clone()); + let expires_at = Instant::now() + Duration::from_secs(u64::from(session.expires_in())); + runtime.connection = Some(Connection { + transport, + session, + github_token: token, + expires_at, + }); + runtime.baseline = self.0.local.read(&owner, "baseline").await?; + let pending: Option = self.0.local.read(&owner, "pending").await?; + self.update(|s| { + s.account = Some(SyncAccount { + github_id: owner, + login: account.login, + }); + s.auth_state = AuthState::SignedIn; + s.backup_retention_days = Some(backup_retention_days); + s.pending_operation_id = pending.map(|p| p.operation_id); + s.has_cloud_snapshot = None; + }); + Ok(()) + } + + #[cfg(test)] + pub(super) async fn connect_with_proxy_setting_for_test( + &self, + use_system_proxy: bool, + ) -> SyncResult<()> { + let mut runtime = self.0.runtime.lock().await; + self.initialize(&mut runtime).await?; + self.check_cancelled()?; + let policy = ProxyPolicy::for_origin(&self.0.config.origin, use_system_proxy); + self.connect_with_proxy_policy(&mut runtime, policy).await + } + + async fn refresh_metadata(&self, runtime: &mut Runtime) -> SyncResult<()> { + self.current_account(runtime).await?; + let connection = runtime + .connection + .as_ref() + .ok_or_else(|| error("sign_in_required"))?; + let metadata = match connection + .transport + .metadata(&connection.session, None) + .await + .map_err(protocol_error)? + { + MetadataResult::Modified(value) => *value, + MetadataResult::NotModified => return Err(error("invalid_response")), + }; + self.current_account(runtime).await?; + let value = metadata.value(); + let owner = self.owner(runtime)?.to_owned(); + let seen: Option = self.0.local.read(&owner, "last-seen-revision").await?; + if seen.is_some_and(|seen| value.revision < seen) + || runtime + .baseline + .as_ref() + .is_some_and(|base| value.revision < base.revision) + { + return Err(error("revision_rollback")); + } + self.0 + .local + .write(&owner, "last-seen-revision", value.revision) + .await?; + let key_binding_changed = runtime.key.is_some() + && (runtime.settings.vault_id.as_deref() + != value.vault_id.as_ref().map(UuidV4::as_str) + || runtime.settings.key_id.as_deref() != value.key_id.as_ref().map(UuidV4::as_str)); + if value.state != VaultState::Active + || key_binding_changed + || runtime.snapshot.as_ref().is_some_and(|s| { + Some(&s.key_id) != value.key_id.as_ref() + || Some(&s.vault_id) != value.vault_id.as_ref() + }) + { + runtime.key = None; + runtime.snapshot = None; + runtime.preview = None; + } + if value.state == VaultState::Deleted { + runtime.settings.enabled = false; + } + self.update(|s| { + s.remote_revision = Some(value.revision.as_str().into()); + s.vault_id = value.vault_id.as_ref().map(|v| v.as_str().into()); + s.key_id = value.key_id.as_ref().map(|v| v.as_str().into()); + s.has_cloud_snapshot = Some(value.state == VaultState::Active); + }); + runtime.metadata = Some(metadata); + Ok(()) + } + + fn metadata<'a>(&self, runtime: &'a Runtime) -> SyncResult<&'a Metadata> { + runtime + .metadata + .as_ref() + .ok_or_else(|| error("metadata_required")) + } + fn owner<'a>(&self, runtime: &'a Runtime) -> SyncResult<&'a str> { + runtime + .settings + .owner_id + .as_deref() + .ok_or_else(|| error("sign_in_required")) + } + fn unlocked(&self, runtime: &Runtime) -> SyncResult> { + runtime.key.clone().ok_or_else(|| error("unlock_required")) + } + + async fn download(&self, runtime: &mut Runtime) -> SyncResult { + self.current_account(runtime).await?; + let connection = runtime + .connection + .as_ref() + .ok_or_else(|| error("sign_in_required"))?; + let snapshot = connection + .transport + .snapshot(&connection.session, self.metadata(runtime)?) + .await + .map_err(protocol_error)?; + self.current_account(runtime).await?; + Ok(snapshot) + } + + async fn decrypt( + &self, + runtime: &Runtime, + snapshot: SnapshotUpload, + key: Arc, + ) -> SyncResult { + let metadata = self.metadata(runtime)?.value().clone(); + let owner = GithubId::parse(self.owner(runtime)?).map_err(protocol_error)?; + tokio::task::spawn_blocking(move || { + let set = crypto::decrypt_snapshot(&snapshot, &metadata, &owner, &key) + .map_err(protocol_error)?; + documents::validate_sync_documents(set, metadata.revision).map_err(document_error) + }) + .await + .map_err(|_| error("crypto_worker_failed"))? + } + + async fn remember( + &self, + runtime: &mut Runtime, + snapshot: &SnapshotUpload, + key: Arc, + remember: bool, + ) -> SyncResult<()> { + self.0 + .local + .remember( + self.owner(runtime)?, + snapshot.vault_id.as_str(), + snapshot.key_id.as_str(), + &key, + remember, + ) + .await?; + runtime.key = Some(key); + runtime.settings.remember_key = remember; + runtime.settings.key_preference_epoch = runtime + .settings + .key_preference_epoch + .checked_add(1) + .ok_or_else(|| error("recovery_required"))?; + runtime.settings.vault_id = Some(snapshot.vault_id.as_str().into()); + runtime.settings.key_id = Some(snapshot.key_id.as_str().into()); + self.save_settings(runtime).await?; + self.0.local.set_lockout(false).await + } + + async fn try_remembered(&self, runtime: &mut Runtime) -> SyncResult<()> { + if runtime.key.is_some() + || !runtime.settings.remember_key + || self.0.local.locked_out().await? + { + return Ok(()); + } + let metadata = self.metadata(runtime)?.value(); + if metadata.state != VaultState::Active { + return Ok(()); + } + let vault = metadata + .vault_id + .as_ref() + .ok_or_else(|| error("invalid_response"))? + .as_str(); + let key_id = metadata + .key_id + .as_ref() + .ok_or_else(|| error("invalid_response"))? + .as_str(); + if runtime.settings.vault_id.as_deref() != Some(vault) + || runtime.settings.key_id.as_deref() != Some(key_id) + { + return Ok(()); + } + if let Some(key) = self + .0 + .local + .remembered(self.owner(runtime)?, vault, key_id) + .await? + { + let snapshot = self.download(runtime).await?; + let key = Arc::new(key); + // Merely retrieving/deriving a key cannot set keyState=unlocked. + self.decrypt(runtime, snapshot.clone(), key.clone()).await?; + self.current_account(runtime).await?; + runtime.snapshot = Some(snapshot); + runtime.key = Some(key); + } + Ok(()) + } + + pub(crate) async fn prepare_enable( + &self, + consent_version: String, + ) -> SyncResult { + if consent_version != CONSENT_VERSION { + return Err(error("consent_required")); + } + let mut runtime = self.0.runtime.try_lock().map_err(|_| error("busy"))?; + self.begin(); + let result = async { + self.connect(&mut runtime).await?; + self.refresh_metadata(&mut runtime).await?; + runtime.settings.consent_version = Some(consent_version); + runtime.settings.prompted = true; + self.save_settings(&runtime).await?; + self.try_remembered(&mut runtime).await?; + let step = if self.metadata(&runtime)?.value().state != VaultState::Active { + EnableStep::Create + } else if runtime.key.is_none() { + EnableStep::Unlock + } else if runtime.baseline.as_ref().is_some_and(|b| { + Some(&b.vault_id) + == self + .metadata(&runtime) + .ok() + .and_then(|m| m.value().vault_id.as_ref()) + }) { + EnableStep::Ready + } else { + EnableStep::RestoreReview + }; + Ok(step) + } + .await; + self.finish(&mut runtime, &result); + result.map(|next_step| EnablePreparation { + next_step, + status: self.status(), + }) + } + + pub(crate) async fn unlock( + &self, + password: String, + remember_key: bool, + ) -> SyncResult { + let password = SecretInput::new(password); + let mut runtime = self.0.runtime.try_lock().map_err(|_| error("busy"))?; + self.0.auto_suspended.store(false, Ordering::Release); + self.begin(); + let result = async { + self.connect(&mut runtime).await?; + self.refresh_metadata(&mut runtime).await?; + if runtime.settings.consent_version.as_deref() != Some(CONSENT_VERSION) { + return Err(error("consent_required")); + } + let snapshot = self.download(&mut runtime).await?; + let encoded = snapshot.clone(); + let metadata = self.metadata(&runtime)?.value().clone(); + let owner = GithubId::parse(self.owner(&runtime)?).map_err(protocol_error)?; + let (key, documents) = tokio::task::spawn_blocking(move || { + let (key, set) = + crypto::decrypt_snapshot_with_password(&encoded, &metadata, &owner, password) + .map_err(protocol_error)?; + let documents = documents::validate_sync_documents(set, metadata.revision) + .map_err(document_error)?; + Ok::<_, BackendError>((Arc::new(key), documents)) + }) + .await + .map_err(|_| error("crypto_worker_failed"))??; + self.current_account(&runtime).await?; + if let (Some(vault), Some(key_id)) = + (&runtime.settings.vault_id, &runtime.settings.key_id) + { + if vault != snapshot.vault_id.as_str() || key_id != snapshot.key_id.as_str() { + self.0 + .local + .forget(self.owner(&runtime)?, vault, key_id) + .await?; + } + } + self.remember(&mut runtime, &snapshot, key, remember_key) + .await?; + runtime.snapshot = Some(snapshot); + if !runtime.baseline.as_ref().is_some_and(|b| { + Some(&b.vault_id) + == self + .metadata(&runtime) + .ok() + .and_then(|m| m.value().vault_id.as_ref()) + }) { + self.make_preview(&mut runtime, documents).await?; + } + Ok(()) + } + .await; + self.finish(&mut runtime, &result); + result.map(|()| self.status()) + } + + pub(crate) async fn create( + &self, + password: String, + confirmation: String, + remember_key: bool, + consent_version: String, + observed_revision: String, + ) -> SyncResult { + let password = SecretInput::new(password); + let confirmation = SecretInput::new(confirmation); + if consent_version != CONSENT_VERSION { + return Err(error("consent_required")); + } + let observed = Revision::parse(&observed_revision).map_err(protocol_error)?; + let mut runtime = self.0.runtime.try_lock().map_err(|_| error("busy"))?; + self.0.auto_suspended.store(false, Ordering::Release); + self.begin(); + let result = async { + self.connect(&mut runtime).await?; + self.reconcile_for_review(&mut runtime).await?; + self.refresh_metadata(&mut runtime).await?; + let metadata = self.metadata(&runtime)?.value(); + if metadata.state == VaultState::Active || metadata.revision != observed { + return Err(error("revision_conflict")); + } + if runtime.settings.consent_version.as_deref() != Some(CONSENT_VERSION) { + return Err(error("consent_required")); + } + self.retire_reviewed_pending(&mut runtime, None).await?; + let context = EncryptContext { + owner_github_id: GithubId::parse(self.owner(&runtime)?).map_err(protocol_error)?, + vault_id: UuidV4::random().map_err(protocol_error)?, + key_id: UuidV4::random().map_err(protocol_error)?, + base_revision: observed, + operation_id: UuidV4::random().map_err(protocol_error)?, + kind: UploadKind::Create, + kdf: Kdf::fixed(Salt::random().map_err(protocol_error)?), + }; + let captured = self + .0 + .data + .export(self.scope(&runtime, context.vault_id.as_str(), context.key_id.as_str())?) + .await?; + let documents = captured.documents.documents().clone(); + let (snapshot, key) = tokio::task::spawn_blocking(move || { + let password = NormalizedPassword::confirmed_new(password, confirmation) + .map_err(protocol_error)?; + let key = + Arc::new(crypto::derive_key(&password, &context.kdf).map_err(protocol_error)?); + let snapshot = + crypto::encrypt_snapshot(&documents, &context, &key).map_err(protocol_error)?; + Ok::<_, BackendError>((snapshot, key)) + }) + .await + .map_err(|_| error("crypto_worker_failed"))??; + self.current_account(&runtime).await?; + self.remember(&mut runtime, &snapshot, key, remember_key) + .await?; + self.upload(&mut runtime, snapshot, captured, None).await?; + self.check_cancelled()?; + runtime.settings.enabled = true; + self.save_settings(&runtime).await?; + Ok(()) + } + .await; + self.finish(&mut runtime, &result); + result.map(|()| self.status()) + } + + async fn upload( + &self, + runtime: &mut Runtime, + snapshot: SnapshotUpload, + captured: ExportedDocuments, + old_key_id: Option, + ) -> SyncResult<()> { + self.current_account(runtime).await?; + let connection = runtime + .connection + .as_ref() + .ok_or_else(|| error("sign_in_required"))?; + let operation = connection + .transport + .prepare_upload( + &connection.session, + self.metadata(runtime)?, + &snapshot, + runtime.snapshot.as_ref(), + ) + .map_err(protocol_error)?; + let pending = Pending { + record: String::from_utf8(operation.to_pending_record().map_err(protocol_error)?) + .map_err(|_| error("invalid_response"))?, + operation_id: snapshot.operation_id.as_str().into(), + vault_id: snapshot.vault_id.as_str().into(), + key_id: Some(snapshot.key_id.as_str().into()), + local_generation: captured.generation, + deleted: false, + remember_key: runtime.settings.remember_key, + key_preference_epoch: runtime.settings.key_preference_epoch, + base_revision: snapshot.base_revision, + old_key_id, + }; + let owner = self.owner(runtime)?.to_string(); + self.0 + .local + .write( + &owner, + &format!("proposal:{}", pending.operation_id), + captured.documents.documents().clone(), + ) + .await?; + self.0 + .local + .write(&owner, "pending", pending.clone()) + .await?; + self.update(|s| s.pending_operation_id = Some(pending.operation_id.clone())); + self.current_account(runtime).await?; + // Once submitted, preserve the exact operation until a validated receipt. + let receipt = connection + .transport + .submit(&connection.session, &operation) + .await; + match receipt { + Ok(receipt) => { + self.accept_receipt(runtime, &pending, &receipt.receipt) + .await?; + runtime.snapshot = Some(snapshot); + if receipt.replayed { + // A replay proves that operation committed, not that its + // revision is still the server's current head. + self.update(|s| s.has_cloud_snapshot = None); + } + Ok(()) + } + Err(e) => { + if definite_rejection(&e) { + self.discard_rejected(runtime, &pending).await?; + if snapshot.kind != UploadKind::Snapshot { + self.0 + .local + .forget(&owner, snapshot.vault_id.as_str(), snapshot.key_id.as_str()) + .await?; + } + if snapshot.kind == UploadKind::Create { + runtime.key = None; + runtime.settings.remember_key = false; + runtime.settings.key_id = None; + runtime.settings.vault_id = None; + self.save_settings(runtime).await?; + } + return Err(protocol_error(e)); + } + Err(error("outcome_unknown")) + } + } + } + + async fn reconcile(&self, runtime: &mut Runtime) -> SyncResult<()> { + let owner = self.owner(runtime)?.to_owned(); + let Some(pending): Option = self.0.local.read(&owner, "pending").await? else { + return Ok(()); + }; + let connection = runtime + .connection + .as_ref() + .ok_or_else(|| error("sign_in_required"))?; + let operation = connection + .transport + .restore_pending(&connection.session, pending.record.as_bytes()) + .map_err(protocol_error)?; + if operation.operation_id().as_str() != pending.operation_id { + return Err(error("recovery_required")); + } + self.current_account(runtime).await?; + let receipt = match connection + .transport + .operation(&connection.session, &operation) + .await + .map_err(protocol_error)? + { + OperationStatus::Committed(receipt) => receipt, + OperationStatus::Pending(pending) => { + let mut result = error("outcome_unknown"); + result.details = Some( + serde_json::json!({"reason":"outcome_unknown","retryAfterSeconds":pending.retry_after_seconds}), + ); + return Err(result); + } + OperationStatus::NotFound => { + // 404 does not prove failure. Only an identical body/op ID can be retried. + self.current_account(runtime).await?; + match connection + .transport + .submit(&connection.session, &operation) + .await + { + Ok(receipt) => receipt.receipt, + Err(e) if definite_rejection(&e) => { + // This is an older unknown operation: rejection of a + // retry cannot prove that the original never committed. + let _ = self.refresh_metadata(runtime).await; + return Err(error("outcome_unknown")); + } + Err(_) => return Err(error("outcome_unknown")), + } + } + }; + self.accept_receipt(runtime, &pending, &receipt).await + } + + async fn accept_receipt( + &self, + runtime: &mut Runtime, + pending: &Pending, + receipt: &OperationReceipt, + ) -> SyncResult<()> { + let owner = self.owner(runtime)?.to_string(); + if receipt.operation_id.as_str() != pending.operation_id + || receipt.vault_id.as_str() != pending.vault_id + { + return Err(error("invalid_response")); + } + if pending.deleted { + runtime.settings.enabled = false; + runtime.key = None; + runtime.preview = None; + runtime.baseline = None; + self.0.local.remove(&owner, "baseline").await?; + if let Some(key_id) = &runtime.settings.key_id { + self.0 + .local + .forget(&owner, &pending.vault_id, key_id) + .await?; + } + runtime.settings.remember_key = false; + runtime.settings.vault_id = None; + runtime.settings.key_id = None; + } else { + if runtime.settings.vault_id.as_deref() != Some(pending.vault_id.as_str()) + || runtime.settings.key_id.as_ref() != pending.key_id.as_ref() + { + // Receipt reconciliation can complete a password rotation while + // memory still holds the old key from the failed upload turn. + runtime.key = None; + runtime.snapshot = None; + runtime.preview = None; + } + let documents: DocumentSet = self + .0 + .local + .read(&owner, &format!("proposal:{}", pending.operation_id)) + .await? + .ok_or_else(|| error("recovery_required"))?; + documents::validate_sync_documents(documents.clone(), receipt.committed_revision) + .map_err(document_error)?; + let baseline = Baseline { + revision: receipt.committed_revision, + vault_id: receipt.vault_id.clone(), + documents, + }; + self.0 + .local + .write(&owner, "baseline", baseline.clone()) + .await?; + self.0 + .data + .baseline( + self.scope( + runtime, + &pending.vault_id, + pending + .key_id + .as_deref() + .ok_or_else(|| error("recovery_required"))?, + )?, + baseline.documents.clone(), + baseline.revision, + ) + .await?; + runtime.baseline = Some(baseline); + runtime.settings.vault_id = Some(pending.vault_id.clone()); + runtime.settings.key_id = pending.key_id.clone(); + if runtime.settings.key_preference_epoch == pending.key_preference_epoch + && !self.0.local.locked_out().await? + { + runtime.settings.remember_key = pending.remember_key; + } + if let Some(old) = &pending.old_key_id { + if Some(old) != pending.key_id.as_ref() { + self.0.local.forget(&owner, &pending.vault_id, old).await?; + } + } + } + runtime.settings.last_generation = Some(pending.local_generation.as_str().into()); + runtime.settings.last_success = Some(receipt.committed_at.clone()); + let seen: Option = self.0.local.read(&owner, "last-seen-revision").await?; + self.0 + .local + .write( + &owner, + "last-seen-revision", + seen.map_or(receipt.committed_revision, |seen| { + seen.max(receipt.committed_revision) + }), + ) + .await?; + self.save_settings(runtime).await?; + // Deleting the pending record is last; a crash at any earlier step replays safely. + self.0.local.remove(&owner, "pending").await?; + // This is an unreferenced encrypted scratch copy after pending removal. + // Its cleanup failure cannot undo a confirmed commit or restore old keys. + let _ = self + .0 + .local + .remove(&owner, &format!("proposal:{}", pending.operation_id)) + .await; + self.update(|s| { + s.pending_operation_id = None; + s.remote_revision = Some(receipt.committed_revision.as_str().into()); + s.has_cloud_snapshot = Some(!pending.deleted); + s.vault_id = (!pending.deleted).then(|| pending.vault_id.clone()); + s.key_id = if pending.deleted { + None + } else { + pending.key_id.clone() + }; + }); + Ok(()) + } + + pub(crate) fn cancel(&self, task_id: &str) -> SyncResult { + { + // The identity check and flag update share begin/finish's status + // lock. A delayed cancel can never poison the next task's flag. + let status = self.0.status.lock().unwrap_or_else(|e| e.into_inner()); + if status.task_id.as_deref() != Some(task_id) { + return Err(error("stale_task")); + } + self.0.cancelled.store(true, Ordering::Release); + } + Ok(self.status()) + } + + pub(crate) async fn lock(&self) -> SyncResult { + self.0.auto_suspended.store(true, Ordering::Release); + self.0.cancelled.store(true, Ordering::Release); + let mut runtime = self.0.runtime.lock().await; + runtime.key = None; + runtime.preview = None; + runtime.settings.remember_key = false; + let result = async { + self.0.local.set_lockout(true).await?; + self.initialize(&mut runtime).await?; + runtime.settings.remember_key = false; + runtime.settings.key_preference_epoch = runtime + .settings + .key_preference_epoch + .checked_add(1) + .ok_or_else(|| error("recovery_required"))?; + // Persist the refusal to auto-unlock before asking the OS to delete + // a key; a denied deletion must not unlock again on restart. + self.save_settings(&runtime).await?; + self.forget_unlock_material(&runtime).await + } + .await; + self.finish(&mut runtime, &result); + result.map(|()| self.status()) + } + + pub(crate) async fn set_enabled(&self, enabled: bool) -> SyncResult { + if !enabled { + self.0.auto_suspended.store(true, Ordering::Release); + self.0.cancelled.store(true, Ordering::Release); + } else { + self.0.auto_suspended.store(false, Ordering::Release); + } + let mut runtime = self.0.runtime.lock().await; + self.initialize(&mut runtime).await?; + let result = async { + if enabled { + if runtime.settings.consent_version.as_deref() != Some(CONSENT_VERSION) { + return Err(error("consent_required")); + } + if runtime.preview.is_some() || runtime.baseline.is_none() { + return Err(error("restore_review_required")); + } + self.unlocked(&runtime)?; + self.0.cancelled.store(false, Ordering::Release); + self.current_account(&runtime).await?; + } + runtime.settings.enabled = enabled; + self.save_settings(&runtime).await?; + if enabled { + self.0.wake.notify_one(); + } + Ok(()) + } + .await; + self.finish(&mut runtime, &result); + result.map(|()| self.status()) + } + + fn scope(&self, runtime: &Runtime, vault: &str, key: &str) -> SyncResult { + Ok(SyncScope { + service_origin: self.0.config.origin.clone(), + owner_github_id: self.owner(runtime)?.into(), + vault_id: vault.into(), + key_id: key.into(), + device_id: self.0.data.device().id.clone(), + }) + } + + fn active_scope(&self, runtime: &Runtime) -> SyncResult { + let meta = self.metadata(runtime)?.value(); + self.scope( + runtime, + meta.vault_id + .as_ref() + .ok_or_else(|| error("cloud_deleted"))? + .as_str(), + meta.key_id + .as_ref() + .ok_or_else(|| error("cloud_deleted"))? + .as_str(), + ) + } + + fn baseline_documents(&self, runtime: &Runtime) -> SyncResult> { + runtime + .baseline + .as_ref() + .filter(|b| { + Some(&b.vault_id) + == runtime + .metadata + .as_ref() + .and_then(|m| m.value().vault_id.as_ref()) + }) + .map(|b| { + documents::validate_sync_documents(b.documents.clone(), b.revision) + .map_err(document_error) + }) + .transpose() + } + + async fn make_preview( + &self, + runtime: &mut Runtime, + remote: ValidatedSyncDocuments, + ) -> SyncResult { + let scope = self.active_scope(runtime)?; + let local = self.0.data.export(scope.clone()).await?; + let baseline = self.baseline_documents(runtime)?; + let merged = documents::diff_sync_documents(baseline.as_ref(), &local.documents, &remote) + .map_err(document_error)?; + let conflicts = merged + .conflicts() + .iter() + .map(|conflict| SyncConflictItem { + id: conflict.conflict_id.clone(), + kind: wire_name(&conflict.kind), + reason: wire_name(&conflict.reason), + }) + .collect(); + let mut counts = BTreeMap::new(); + for doc in &remote.documents().documents { + *counts.entry(wire_name(&doc.kind)).or_insert(0) += 1; + } + let public = RestorePreview { + preview_id: uuid::Uuid::new_v4().to_string(), + unconfirmed_operation_id: self + .reviewable_pending(runtime) + .await? + .map(|p| p.operation_id), + observed_revision: remote.observed_revision().as_str().into(), + local_generation: local.generation.as_str().into(), + counts, + device_settings_to_review: remote + .documents() + .documents + .iter() + .filter(|d| d.kind == DocumentKind::DeviceProfile) + .map(|_| "device_profile".to_string()) + .take(1) + .collect(), + conflicts, + }; + runtime.preview = Some(PreviewState { + public: public.clone(), + remote, + local, + baseline, + vault_id: scope.vault_id.clone(), + key_id: scope.key_id.clone(), + }); + self.0.events.publish( + None, + BackendEventKind::CloudSyncConflictDetected(EncryptedSyncConflictEvent { + sequence: self + .0 + .sequence + .fetch_add(1, Ordering::AcqRel) + .saturating_add(1) + .to_string(), + account_id: scope.owner_github_id, + vault_id: scope.vault_id, + task_id: self.status().task_id, + preview: public.clone(), + }), + ); + Ok(public) + } + + pub(crate) async fn preview_restore( + &self, + observed_revision: String, + ) -> SyncResult { + let observed = Revision::parse(&observed_revision).map_err(protocol_error)?; + let mut runtime = self.0.runtime.try_lock().map_err(|_| error("busy"))?; + self.begin(); + let result = async { + self.connect(&mut runtime).await?; + self.reconcile_for_review(&mut runtime).await?; + self.refresh_metadata(&mut runtime).await?; + if self.metadata(&runtime)?.value().revision != observed { + return Err(error("stale_preview")); + } + let key = self.unlocked(&runtime)?; + let snapshot = self.download(&mut runtime).await?; + let remote = self.decrypt(&runtime, snapshot.clone(), key).await?; + runtime.snapshot = Some(snapshot); + self.current_account(&runtime).await?; + self.make_preview(&mut runtime, remote).await + } + .await; + self.finish(&mut runtime, &result); + result + } + + pub(crate) async fn apply_restore( + &self, + preview_id: String, + mode: RestoreMode, + choices: Vec, + ) -> SyncResult { + let mut runtime = self.0.runtime.try_lock().map_err(|_| error("busy"))?; + self.begin(); + let result = async { + self.current_account(&runtime).await?; + self.unlocked(&runtime)?; + self.refresh_metadata(&mut runtime).await?; + let preview = runtime + .preview + .as_ref() + .ok_or_else(|| error("stale_preview"))?; + let scope = self.active_scope(&runtime)?; + if preview.public.preview_id != preview_id + || self.metadata(&runtime)?.value().revision.as_str() + != preview.public.observed_revision + || self.0.data.generation()?.as_str() != preview.public.local_generation + || scope.vault_id != preview.vault_id + || scope.key_id != preview.key_id + { + return Err(error("stale_preview")); + } + let desired = match mode { + RestoreMode::Replace => preview.remote.clone(), + RestoreMode::Merge => { + let choices: Vec = choices + .into_iter() + .map(|c| documents::ConflictChoice { + conflict_id: c.id, + side: match c.side { + ConflictSide::Local => documents::ConflictSide::Local, + ConflictSide::Cloud => documents::ConflictSide::Remote, + }, + }) + .collect(); + documents::diff_sync_documents( + preview.baseline.as_ref(), + &preview.local.documents, + &preview.remote, + ) + .map_err(document_error)? + .resolve(&choices) + .map_err(document_error)? + } + }; + let remote = preview.remote.clone(); + let context = RestoreContext { + scope: scope.clone(), + operation_id: uuid::Uuid::new_v4().to_string(), + observed_revision: remote.observed_revision(), + local_generation: preview.local.generation, + target_device: self.0.data.device(), + }; + let reviewed_operation = preview.public.unconfirmed_operation_id.clone(); + self.current_account(&runtime).await?; + self.retire_reviewed_pending(&mut runtime, reviewed_operation.as_deref()) + .await?; + // After journal prepare, cancellation must finish commit/rollback. + let applied = self.0.data.restore(desired, context).await?; + let baseline = Baseline { + revision: remote.observed_revision(), + vault_id: UuidV4::parse(&scope.vault_id).map_err(protocol_error)?, + documents: remote.documents().clone(), + }; + self.0 + .local + .write(&scope.owner_github_id, "baseline", baseline.clone()) + .await?; + self.0 + .data + .baseline(scope.clone(), baseline.documents.clone(), baseline.revision) + .await?; + runtime.baseline = Some(baseline); + runtime.preview = None; + runtime.settings.last_generation = + if same_documents(applied.documents.documents(), remote.documents()) { + Some(applied.generation.as_str().into()) + } else { + None + }; + if !self.0.cancelled.load(Ordering::Acquire) { + runtime.settings.enabled = true; + } + self.save_settings(&runtime).await?; + let ui_preferences = applied + .documents + .documents() + .documents + .iter() + .filter(|d| d.kind == DocumentKind::UiPreferences) + .filter_map(|d| d.value.as_str().map(|v| (d.id.clone(), v.to_owned()))) + .collect(); + self.0.events.publish( + None, + BackendEventKind::CloudSyncRestoreCompleted(EncryptedSyncRestoreEvent { + sequence: self + .0 + .sequence + .fetch_add(1, Ordering::AcqRel) + .saturating_add(1) + .to_string(), + account_id: scope.owner_github_id, + vault_id: scope.vault_id, + task_id: self.status().task_id, + local_generation: applied.generation.as_str().into(), + ui_preferences, + }), + ); + self.check_cancelled()?; + // A local conflict choice can differ from the current cloud head. + // Use the same CAS/merge path to upload it, never claim it synced early. + self.run_once(&mut runtime).await + } + .await; + self.finish(&mut runtime, &result); + result.map(|()| self.status()) + } + + pub(crate) async fn sync_now(&self) -> SyncResult { + let mut runtime = self.0.runtime.try_lock().map_err(|_| error("busy"))?; + self.begin(); + let result = self.run_once(&mut runtime).await; + self.finish(&mut runtime, &result); + result.map(|()| self.status()) + } + + async fn run_once(&self, runtime: &mut Runtime) -> SyncResult<()> { + self.connect(runtime).await?; + self.reconcile(runtime).await?; + if !runtime.settings.enabled { + return Ok(()); + } + if runtime.settings.consent_version.as_deref() != Some(CONSENT_VERSION) { + return Err(error("consent_required")); + } + self.refresh_metadata(runtime).await?; + if self.metadata(runtime)?.value().state != VaultState::Active { + self.save_settings(runtime).await?; + return Err(error("cloud_deleted")); + } + self.try_remembered(runtime).await?; + let key = self.unlocked(runtime)?; + let snapshot = self.download(runtime).await?; + let remote = self.decrypt(runtime, snapshot.clone(), key.clone()).await?; + runtime.snapshot = Some(snapshot.clone()); + let baseline = self.baseline_documents(runtime)?; + if baseline.is_none() { + self.make_preview(runtime, remote).await?; + return Err(error("restore_review_required")); + } + let scope = self.active_scope(runtime)?; + let mut captured = self.0.data.export(scope.clone()).await?; + let merge = documents::diff_sync_documents(baseline.as_ref(), &captured.documents, &remote) + .map_err(document_error)?; + if !merge.conflicts().is_empty() { + self.make_preview(runtime, remote).await?; + return Err(error("conflict")); + } + let desired = merge.resolve(&[]).map_err(document_error)?; + self.current_account(runtime).await?; + if !same_documents(captured.documents.documents(), desired.documents()) { + let context = RestoreContext { + scope: scope.clone(), + operation_id: uuid::Uuid::new_v4().to_string(), + observed_revision: remote.observed_revision(), + local_generation: captured.generation, + target_device: self.0.data.device(), + }; + captured = self.0.data.restore(desired, context).await?; + self.emit_restored(&scope, &captured); + } + if same_documents(captured.documents.documents(), remote.documents()) { + let baseline = Baseline { + revision: remote.observed_revision(), + vault_id: snapshot.vault_id.clone(), + documents: remote.documents().clone(), + }; + self.0 + .local + .write(&scope.owner_github_id, "baseline", baseline.clone()) + .await?; + self.0 + .data + .baseline(scope, baseline.documents.clone(), baseline.revision) + .await?; + runtime.baseline = Some(baseline); + runtime.settings.last_generation = Some(captured.generation.as_str().into()); + runtime.settings.last_success = Some(chrono::Utc::now().to_rfc3339()); + self.save_settings(runtime).await?; + return Ok(()); + } + self.current_account(runtime).await?; + let documents = captured.documents.documents().clone(); + let context = EncryptContext { + owner_github_id: snapshot.owner_github_id.clone(), + vault_id: snapshot.vault_id.clone(), + key_id: snapshot.key_id.clone(), + base_revision: self.metadata(runtime)?.value().revision, + operation_id: UuidV4::random().map_err(protocol_error)?, + kind: UploadKind::Snapshot, + kdf: snapshot.kdf.clone(), + }; + let encrypted = tokio::task::spawn_blocking(move || { + crypto::encrypt_snapshot(&documents, &context, &key).map_err(protocol_error) + }) + .await + .map_err(|_| error("crypto_worker_failed"))??; + self.upload(runtime, encrypted, captured, None).await + } + + pub(crate) async fn change_password( + &self, + current_password: String, + new_password: String, + confirmation: String, + remember_key: bool, + ) -> SyncResult { + let current_password = SecretInput::new(current_password); + let new_password = SecretInput::new(new_password); + let confirmation = SecretInput::new(confirmation); + let mut runtime = self.0.runtime.try_lock().map_err(|_| error("busy"))?; + self.begin(); + let result = async { + self.run_once(&mut runtime).await?; + self.refresh_metadata(&mut runtime).await?; + let previous = self.download(&mut runtime).await?; + let metadata = self.metadata(&runtime)?.value().clone(); + if !runtime.baseline.as_ref().is_some_and(|baseline| { + baseline.revision == metadata.revision && baseline.vault_id == previous.vault_id + }) { + // Another device advanced the head after run_once merged it. + // Never encrypt the older local image against the newer CAS token. + return Err(error("revision_conflict")); + } + let scope = self.active_scope(&runtime)?; + let captured = self.0.data.export(scope).await?; + let prior = previous.clone(); + let content = captured.documents.documents().clone(); + let (snapshot, key) = tokio::task::spawn_blocking(move || { + let (_, _) = crypto::decrypt_snapshot_with_password( + &prior, + &metadata, + &prior.owner_github_id, + current_password, + ) + .map_err(protocol_error)?; + let password = NormalizedPassword::confirmed_new(new_password, confirmation) + .map_err(protocol_error)?; + let context = EncryptContext { + owner_github_id: prior.owner_github_id.clone(), + vault_id: prior.vault_id.clone(), + key_id: UuidV4::random().map_err(protocol_error)?, + base_revision: metadata.revision, + operation_id: UuidV4::random().map_err(protocol_error)?, + kind: UploadKind::PasswordChange, + kdf: Kdf::fixed(Salt::random().map_err(protocol_error)?), + }; + let key = + Arc::new(crypto::derive_key(&password, &context.kdf).map_err(protocol_error)?); + let snapshot = + crypto::encrypt_snapshot(&content, &context, &key).map_err(protocol_error)?; + Ok::<_, BackendError>((snapshot, key)) + }) + .await + .map_err(|_| error("crypto_worker_failed"))??; + self.current_account(&runtime).await?; + let old_key = runtime.key.clone(); + let old_settings = runtime.settings.clone(); + // Both scoped key entries survive an unknown result; delete the old + // remembered key only after the password-change receipt is verified. + self.remember(&mut runtime, &snapshot, key, remember_key) + .await?; + runtime.snapshot = Some(previous.clone()); + let result = self + .upload( + &mut runtime, + snapshot, + captured, + Some(previous.key_id.as_str().into()), + ) + .await; + if result.is_err() { + let epoch = runtime.settings.key_preference_epoch; + let remember = runtime.settings.remember_key; + runtime.key = old_key; + runtime.settings = old_settings; + runtime.settings.key_preference_epoch = epoch; + runtime.settings.remember_key = remember; + self.save_settings(&runtime).await?; + } + result + } + .await; + self.finish(&mut runtime, &result); + result.map(|()| self.status()) + } + + pub(crate) async fn delete_remote( + &self, + expected_vault_id: String, + observed_revision: String, + confirmed: bool, + ) -> SyncResult { + if !confirmed { + return Err(error("delete_confirmation_required")); + } + let observed = Revision::parse(&observed_revision).map_err(protocol_error)?; + let expected = UuidV4::parse(&expected_vault_id).map_err(protocol_error)?; + let mut runtime = self.0.runtime.try_lock().map_err(|_| error("busy"))?; + self.begin(); + let result = async { + self.connect(&mut runtime).await?; + self.reconcile_for_review(&mut runtime).await?; + self.refresh_metadata(&mut runtime).await?; + let metadata = self.metadata(&runtime)?; + if metadata.value().revision != observed + || metadata.value().vault_id.as_ref() != Some(&expected) + || metadata.value().state != VaultState::Active + { + return Err(error("revision_conflict")); + } + self.retire_reviewed_pending(&mut runtime, None).await?; + let metadata = self.metadata(&runtime)?; + let connection = runtime + .connection + .as_ref() + .ok_or_else(|| error("sign_in_required"))?; + let operation = connection + .transport + .prepare_delete( + &connection.session, + metadata, + UuidV4::random().map_err(protocol_error)?, + ) + .map_err(protocol_error)?; + let pending = Pending { + record: String::from_utf8(operation.to_pending_record().map_err(protocol_error)?) + .map_err(|_| error("invalid_response"))?, + operation_id: operation.operation_id().as_str().into(), + vault_id: expected_vault_id, + key_id: None, + local_generation: self.0.data.generation()?, + deleted: true, + remember_key: false, + key_preference_epoch: runtime.settings.key_preference_epoch, + base_revision: observed, + old_key_id: None, + }; + self.0 + .local + .write(self.owner(&runtime)?, "pending", pending.clone()) + .await?; + self.update(|s| s.pending_operation_id = Some(pending.operation_id.clone())); + self.current_account(&runtime).await?; + let receipt = match connection + .transport + .submit(&connection.session, &operation) + .await + { + Ok(receipt) => receipt, + Err(e) if definite_rejection(&e) => { + self.discard_rejected(&runtime, &pending).await?; + return Err(protocol_error(e)); + } + Err(_) => return Err(error("outcome_unknown")), + }; + self.accept_receipt(&mut runtime, &pending, &receipt.receipt) + .await + } + .await; + self.finish(&mut runtime, &result); + result.map(|()| self.status()) + } + + pub(crate) async fn sign_out(&self) -> SyncResult { + use crate::domains::MarketplaceApi; + self.0.auto_suspended.store(true, Ordering::Release); + self.0.cancelled.store(true, Ordering::Release); + // Preserve the account API's existing fail-closed guarantee immediately, + // even while an earlier sync task is draining its native I/O worker. + self.0.marketplace.invalidate_authentication(); + let mut runtime = self.0.runtime.lock().await; + runtime.key = None; + runtime.preview = None; + runtime.settings.enabled = false; + runtime.settings.remember_key = false; + let connection = runtime.connection.take(); + // OAuth logout is independent of encrypted journal/key cleanup. Always + // attempt it; denied sync-key deletion cannot keep GitHub authorized. + let logout_result = self + .0 + .marketplace + .logout() + .await + .map_err(|_| error("secure_storage_denied")); + let cleanup_result = async { + self.0.local.set_lockout(true).await?; + self.initialize(&mut runtime).await?; + runtime.settings.enabled = false; + runtime.settings.remember_key = false; + runtime.settings.key_preference_epoch = runtime + .settings + .key_preference_epoch + .checked_add(1) + .ok_or_else(|| error("recovery_required"))?; + self.save_settings(&runtime).await?; + self.forget_unlock_material(&runtime).await + } + .await; + if let Some(Connection { + transport, + session, + github_token, + .. + }) = connection + { + drop(github_token); + // Local sign-out remains possible offline; the discarded session + // also has a short server-enforced expiry. + let _ = tokio::time::timeout(Duration::from_secs(5), transport.revoke_session(session)) + .await; + } + runtime.settings.enabled = false; + runtime.settings.remember_key = false; + runtime.metadata = None; + runtime.snapshot = None; + runtime.baseline = None; + self.update(|s| { + s.account = None; + s.auth_state = AuthState::SignedOut; + s.has_cloud_snapshot = None; + s.vault_id = None; + s.key_id = None; + s.remote_revision = None; + s.pending_operation_id = None; + }); + let result = cleanup_result.and(logout_result); + self.finish(&mut runtime, &result); + result.map(|()| self.status()) + } + + pub(crate) async fn begin_sign_in(&self) -> SyncResult { + use crate::domains::MarketplaceApi; + let flow = self + .0 + .marketplace + .start_device_flow() + .await + .map_err(|_| error("sign_in_required"))?; + if flow.verification_uri != "https://github.com/login/device" { + return Err(error("invalid_response")); + } + let expires = chrono::Utc::now() + .checked_add_signed(chrono::Duration::seconds( + i64::try_from(flow.expires_in_secs).map_err(|_| error("invalid_response"))?, + )) + .ok_or_else(|| error("invalid_response"))?; + Ok(EncryptedSyncSignIn { + authorization_session_id: flow.flow_id, + user_code: flow.user_code, + verification_uri: flow.verification_uri, + expires_at: expires.to_rfc3339(), + interval_seconds: flow.interval_secs, + }) + } + + async fn forget_unlock_material(&self, runtime: &Runtime) -> SyncResult<()> { + let Some(owner) = &runtime.settings.owner_id else { + return Ok(()); + }; + if let (Some(vault), Some(key)) = (&runtime.settings.vault_id, &runtime.settings.key_id) { + self.0.local.forget(owner, vault, key).await?; + } + // An interrupted password rotation can have two remembered keys. Lock + // and sign-out clear both while retaining the encrypted replay record. + if let Some(pending) = self.0.local.read::(owner, "pending").await? { + for key in [pending.key_id.as_ref(), pending.old_key_id.as_ref()] + .into_iter() + .flatten() + { + self.0.local.forget(owner, &pending.vault_id, key).await?; + } + } + Ok(()) + } + + pub(crate) async fn poll_sign_in( + &self, + session: String, + ) -> SyncResult { + use crate::domains::MarketplaceApi; + use crate::domains::OAuthPollResult; + match self + .0 + .marketplace + .poll_device_flow(session) + .await + .map_err(|_| error("sign_in_required"))? + { + OAuthPollResult::Authorized { .. } => { + let (_, account) = self + .0 + .marketplace + .sync_identity() + .await + .map_err(|_| error("sign_in_required"))?; + Ok(EncryptedSyncSignInResult::SignedIn { + account: SyncAccount { + github_id: account.github_id.as_str().into(), + login: account.login, + }, + }) + } + OAuthPollResult::Pending => Ok(EncryptedSyncSignInResult::Pending { slow_down: false }), + OAuthPollResult::SlowDown => Ok(EncryptedSyncSignInResult::Pending { slow_down: true }), + OAuthPollResult::Error { message } + if message == "OAuth 设备码已过期,请重新发起登录" => + { + Ok(EncryptedSyncSignInResult::Expired) + } + OAuthPollResult::Error { message } + if message.contains("拒绝") || message.contains("取消") => + { + Ok(EncryptedSyncSignInResult::Denied) + } + OAuthPollResult::Error { .. } => Err(error("sign_in_required")), + } + } + + pub(crate) async fn cancel_sign_in(&self, session: String) -> SyncResult<()> { + use crate::domains::MarketplaceApi; + self.0.marketplace.cancel_device_flow(Some(session)).await + } + + pub(crate) async fn start(&self, spawner: Arc) -> SyncResult<()> { + if self.0.auto_started.swap(true, Ordering::AcqRel) { + return Ok(()); + } + { + let mut runtime = self.0.runtime.lock().await; + let result = self.initialize(&mut runtime).await; + self.finish(&mut runtime, &result); + if result.is_err() { + self.0.auto_started.store(false, Ordering::Release); + } + result?; + } + let service = self.clone(); + spawner.spawn(Box::pin(async move { + let mut changes = service.0.data.changes(); + // One startup trigger. Only a bounded local-busy retry may schedule + // another attempt without a user change; no network polling timer. + service.auto_sync(true).await; + loop { + let mut forced = tokio::select! { + changed = changes.changed() => { if changed.is_err() { break; } false }, + _ = service.0.wake.notified() => true, + }; + if service.0.shutdown.load(Ordering::Acquire) { break; } + let first = Instant::now(); + let mut deadline = tokio::time::Instant::now() + Duration::from_millis(750); + loop { + tokio::select! { + _ = tokio::time::sleep_until(deadline) => break, + changed = changes.changed() => { + if changed.is_err() { return; } + let elapsed = first.elapsed(); + if elapsed >= Duration::from_secs(5) { break; } + deadline = tokio::time::Instant::now() + Duration::from_millis(750).min(Duration::from_secs(5) - elapsed); + }, + _ = service.0.wake.notified() => { if service.0.shutdown.load(Ordering::Acquire) { return; } forced = true; }, + } + } + let change = *changes.borrow_and_update(); + if forced || matches!(change.origin, crate::cloud_sync_e2ee_store::gate::ChangeOrigin::User) { service.auto_sync(false).await; } + } + })); + Ok(()) + } + + async fn auto_sync(&self, startup: bool) { + // A LocalOnly writer can overlap the debounce/capture boundary without + // emitting another dirty generation when it finishes. Retain this + // trigger briefly instead of losing it after SourceChanged. This budget + // applies only to local contention, never transport/CAS/unknown results. + let mut delays = [250, 750, 1_500, 3_000].into_iter(); + let mut retry_sequence = None; + loop { + let mut runtime = self.0.runtime.lock().await; + if self.0.shutdown.load(Ordering::Acquire) + || self.0.auto_suspended.load(Ordering::Acquire) + || !runtime.settings.enabled + || runtime.preview.is_some() + { + return; + } + if retry_sequence.is_some_and(|sequence| { + self.0.cancelled.load(Ordering::Acquire) + || self.0.sequence.load(Ordering::Acquire) != sequence + }) { + return; + } + if runtime.key.is_none() && !(startup && runtime.settings.remember_key) { + return; + } + if runtime.retry_at.is_some_and(|at| at > Instant::now()) { + return; + } + self.begin(); + let result = self.run_once(&mut runtime).await; + let local_busy = result.as_ref().err().is_some_and(|failure| { + failure.code == crate::BackendErrorCode::Busy + && matches!( + failure.message.as_str(), + "sync_documents_source_changed" | "runtime_busy" + ) + }); + self.finish(&mut runtime, &result); + let completed_sequence = self.0.sequence.load(Ordering::Acquire); + // Pause/lock/manual work must be able to acquire the runtime during + // the delay. Every retry revalidates the current service state. + drop(runtime); + if !local_busy || self.0.cancelled.load(Ordering::Acquire) { + return; + } + let Some(delay) = delays.next() else { + return; + }; + // A later manual operation owns its own outcome. In particular an + // old local retry must never reconcile that operation's unknown PUT. + retry_sequence = Some(completed_sequence); + tokio::time::sleep(Duration::from_millis(delay)).await; + } + } + + pub(crate) async fn shutdown(&self) { + self.0.shutdown.store(true, Ordering::Release); + self.0.cancelled.store(true, Ordering::Release); + self.0.wake.notify_waiters(); + let mut runtime = self.0.runtime.lock().await; + runtime.key = None; + runtime.preview = None; + runtime.connection = None; + } + + async fn discard_rejected(&self, runtime: &Runtime, pending: &Pending) -> SyncResult<()> { + let owner = self.owner(runtime)?; + self.0.local.remove(owner, "pending").await?; + let _ = self + .0 + .local + .remove(owner, &format!("proposal:{}", pending.operation_id)) + .await; + self.update(|status| status.pending_operation_id = None); + Ok(()) + } + + async fn reconcile_for_review(&self, runtime: &mut Runtime) -> SyncResult<()> { + match self.reconcile(runtime).await { + Err(e) if e.message == "outcome_unknown" => Ok(()), + result => result, + } + } + + async fn reviewable_pending(&self, runtime: &Runtime) -> SyncResult> { + let pending: Option = self.0.local.read(self.owner(runtime)?, "pending").await?; + if let Some(pending) = &pending { + // Once a newer head is observed, the original If-Match can no + // longer commit. Until then, only exact replay is safe. + if self.metadata(runtime)?.value().revision <= pending.base_revision { + return Err(error("outcome_unknown")); + } + } + Ok(pending) + } + + async fn retire_reviewed_pending( + &self, + runtime: &mut Runtime, + expected_id: Option<&str>, + ) -> SyncResult<()> { + let pending = self.reviewable_pending(runtime).await?; + if expected_id.is_some() && pending.as_ref().map(|p| p.operation_id.as_str()) != expected_id + { + return Err(error("stale_preview")); + } + if let Some(pending) = pending { + self.current_account(runtime).await?; + let head = self.metadata(runtime)?.value(); + for key_id in [pending.key_id.as_ref(), pending.old_key_id.as_ref()] + .into_iter() + .flatten() + { + let active = head.vault_id.as_ref().map(UuidV4::as_str) + == Some(pending.vault_id.as_str()) + && head.key_id.as_ref().map(UuidV4::as_str) == Some(key_id.as_str()); + if !active { + self.0 + .local + .forget(self.owner(runtime)?, &pending.vault_id, key_id) + .await?; + } + } + // Explicit restore/create/delete supersedes the old CAS only after + // review. Preserve the uncertain operation as protected evidence; + // never mislabel it failed or committed without its receipt. + self.0 + .local + .write( + self.owner(runtime)?, + &format!("reviewed-uncertain:{}", pending.operation_id), + pending.clone(), + ) + .await?; + self.0.local.remove(self.owner(runtime)?, "pending").await?; + self.update(|status| status.pending_operation_id = None); + } + Ok(()) + } + + pub(crate) async fn ui_preferences(&self) -> SyncResult> { + Ok(self.ui_preferences_snapshot().await?.preferences) + } + + pub(crate) async fn ui_preferences_snapshot( + &self, + ) -> SyncResult { + let Some(envelope) = self.0.local.read_ui().await? else { + return Ok(EncryptedUiPreferencesSnapshot { + preferences: None, + revision: None, + }); + }; + let preferences = serde_json::from_value(serde_json::json!({ "locale": envelope.value.expose().get("locale"), "fontScale": envelope.value.expose().get("fontScale") })) + .map_err(|_| error("recovery_required"))?; + Ok(EncryptedUiPreferencesSnapshot { + preferences: Some(preferences), + revision: Some(envelope.revision), + }) + } + + fn emit_restored(&self, scope: &SyncScope, applied: &ExportedDocuments) { + let ui_preferences = applied + .documents + .documents() + .documents + .iter() + .filter(|d| d.kind == DocumentKind::UiPreferences) + .filter_map(|d| d.value.as_str().map(|v| (d.id.clone(), v.to_owned()))) + .collect(); + self.0.events.publish( + None, + BackendEventKind::CloudSyncRestoreCompleted(EncryptedSyncRestoreEvent { + sequence: self + .0 + .sequence + .fetch_add(1, Ordering::AcqRel) + .saturating_add(1) + .to_string(), + account_id: scope.owner_github_id.clone(), + vault_id: scope.vault_id.clone(), + task_id: self.status().task_id, + local_generation: applied.generation.as_str().into(), + ui_preferences, + }), + ); + } +} + +fn wire_name(value: &impl Serialize) -> String { + serde_json::to_value(value) + .ok() + .and_then(|v| v.as_str().map(str::to_owned)) + .unwrap_or_else(|| "unknown".into()) +} + +fn same_documents(left: &DocumentSet, right: &DocumentSet) -> bool { + // Device/time metadata describes an export, not a user edit. + left.documents == right.documents && left.tombstones == right.tombstones +} + +fn definite_rejection(error: &crate::cloud_sync_e2ee_protocol::Error) -> bool { + // Only for the first submit of a freshly generated operation ID. A retry of + // an older unknown request cannot use a CAS rejection as proof of failure. + matches!( + error, + crate::cloud_sync_e2ee_protocol::Error::Api { + status: 400..=499, + .. + } + ) +} diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/setup_prompt.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/setup_prompt.rs new file mode 100644 index 00000000..2e42db06 --- /dev/null +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/setup_prompt.rs @@ -0,0 +1,61 @@ +//! One local claim after a usable pipeline is configured. This path never connects +//! to OAuth/sync servers, exports private documents, or enables synchronization. +use super::*; +use crate::{CredentialStore, CredentialsStatus, UserPreferences}; + +type Eligibility = Option<(UserPreferences, (Revision, u64))>; + +fn configured(preferences: &UserPreferences, status: &CredentialsStatus) -> bool { + match crate::shared_types::effective_pipeline_mode( + preferences.multimodal_pipeline_enabled, + preferences.pipeline_mode, + ) { + crate::shared_types::PipelineMode::Traditional => { + status.asr_configured && status.llm_configured + } + crate::shared_types::PipelineMode::Multimodal => status.omni_configured, + } +} + +impl EncryptedSyncService { + pub(crate) async fn claim_setup_prompt( + &self, + credentials: Arc, + eligibility: impl Fn() -> SyncResult + Send + Sync, + ) -> SyncResult { + let mut runtime = match self.0.runtime.try_lock() { + Ok(runtime) => runtime, + Err(_) => return Ok(false), + }; + if self.0.shutdown.load(Ordering::Acquire) { + return Ok(false); + } + let Some((preferences, stamp)) = eligibility()? else { + return Ok(false); + }; + self.initialize(&mut runtime).await?; + if runtime.settings.enabled || runtime.settings.prompted { + return Ok(false); + } + let status = credentials.status(preferences.clone()).await?; + if !configured(&preferences, &status) { + return Ok(false); + } + let Some((_, current)) = eligibility()? else { + return Ok(false); + }; + if current != stamp || self.0.shutdown.load(Ordering::Acquire) { + return Ok(false); + } + // Publish the in-memory claim only after durable encrypted persistence succeeds. + // Read errors and busy/changing inputs do not consume the installation's prompt. + let mut settings = runtime.settings.clone(); + settings.prompted = true; + self.0 + .local + .write("device", "client", settings.clone()) + .await?; + runtime.settings = settings; + Ok(true) + } +} diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/setup_prompt_tests.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/setup_prompt_tests.rs new file mode 100644 index 00000000..8e8cf319 --- /dev/null +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/setup_prompt_tests.rs @@ -0,0 +1,280 @@ +use super::*; +use std::sync::atomic::AtomicUsize; + +struct ReadyCredentials { + status: CredentialsStatus, + deny: bool, + reads: Arc, + barrier: Option>, +} +impl CredentialStore for ReadyCredentials { + fn status( + &self, + _: UserPreferences, + ) -> BoxFuture<'static, Result> { + let status = self.status.clone(); + let deny = self.deny; + let reads = self.reads.clone(); + let barrier = self.barrier.clone(); + Box::pin(async move { + reads.fetch_add(1, Ordering::AcqRel); + if let Some(barrier) = barrier { + barrier.wait().await; + barrier.wait().await; + } + if deny { + Err(error("secure_storage_denied")) + } else { + Ok(status) + } + }) + } + fn read( + &self, + _: CredentialKey, + ) -> BoxFuture<'static, Result, BackendError>> { + Box::pin(async { panic!("claim must use Host readiness, not require a local-ASR API key") }) + } + fn write( + &self, + _: CredentialKey, + _: SecretValue, + ) -> BoxFuture<'static, Result<(), BackendError>> { + Box::pin(async { panic!("claim must not mutate provider credentials") }) + } + fn remove(&self, _: CredentialKey) -> BoxFuture<'static, Result<(), BackendError>> { + Box::pin(async { panic!("claim must not mutate provider credentials") }) + } +} +fn readiness(asr: bool, llm: bool, omni: bool) -> Arc { + Arc::new(ReadyCredentials { + status: CredentialsStatus { + asr_configured: asr, + llm_configured: llm, + omni_configured: omni, + ..Default::default() + }, + deny: false, + reads: Arc::new(AtomicUsize::new(0)), + barrier: None, + }) +} +fn eligible( + prefs: UserPreferences, +) -> impl Fn() -> SyncResult> + Send + Sync { + move || Ok(Some((prefs.clone(), (Revision::new(1), 0)))) +} +fn reopened_service(fixture: &Fixture, server: &Server) -> EncryptedSyncService { + let repos = crate::BackendRepositories::open(&fixture.root).unwrap(); + let config = crate::MarketplaceConfig::new(&server.origin).unwrap(); + let events = + crate::events::BackendEventPublisher::new(Arc::new(crate::events::EventBus::new(32))); + let marketplace = Arc::new( + crate::marketplace::MarketplaceService::new( + config, + fixture.vault.clone(), + repos.preferences, + repos.style_packs, + events.clone(), + Arc::new(AtomicU64::new(0)), + ) + .unwrap(), + ); + let local = LocalStorage::new( + fixture.root.join("protected"), + server.origin.clone(), + fixture.data.device().id.clone(), + fixture.vault.clone(), + ); + EncryptedSyncService::new( + SyncServiceConfig { + origin: server.origin.clone(), + github_client_id: CLIENT.into(), + }, + marketplace, + local, + fixture.data.clone(), + events, + ) +} + +#[tokio::test] +async fn setup_prompt_requires_the_selected_pipeline_and_accepts_ready_local_asr_without_a_key() { + let server = Server::start().await; + for (multi, enabled, asr, llm, omni, expected) in [ + (false, false, true, false, true, false), + (false, false, false, true, true, false), + (false, false, true, true, false, true), + (true, true, false, false, true, true), + (true, true, true, true, false, false), + (true, false, true, true, false, true), + ] { + let fixture = Fixture::new(&server).await; + let prefs = UserPreferences { + pipeline_mode: if multi { + crate::shared_types::PipelineMode::Multimodal + } else { + crate::shared_types::PipelineMode::Traditional + }, + multimodal_pipeline_enabled: enabled, + active_asr_provider: "local-qwen3".into(), + ..Default::default() + }; + assert_eq!( + fixture + .service + .claim_setup_prompt(readiness(asr, llm, omni), eligible(prefs)) + .await + .unwrap(), + expected + ); + } + assert_eq!(server.state.lock().unwrap().requests, 0); +} + +#[tokio::test] +async fn setup_prompt_claim_is_once_per_installation_and_survives_restart_without_http() { + let server = Server::start().await; + let fixture = Fixture::new(&server).await; + let ready = readiness(true, true, false); + assert!(fixture + .service + .claim_setup_prompt(ready.clone(), eligible(UserPreferences::default())) + .await + .unwrap()); + assert!(!fixture + .service + .claim_setup_prompt(ready.clone(), eligible(UserPreferences::default())) + .await + .unwrap()); + let reopened = reopened_service(&fixture, &server); + assert!(!reopened + .claim_setup_prompt(ready, eligible(UserPreferences::default())) + .await + .unwrap()); + assert_eq!(server.state.lock().unwrap().requests, 0); + assert!(server.state.lock().unwrap().puts.is_empty()); +} + +#[tokio::test] +async fn setup_prompt_read_error_busy_or_stale_capture_never_consumes_the_flag() { + let server = Server::start().await; + let fixture = Fixture::new(&server).await; + let denied = Arc::new(ReadyCredentials { + deny: true, + status: CredentialsStatus::default(), + reads: Arc::new(AtomicUsize::new(0)), + barrier: None, + }); + assert!(fixture + .service + .claim_setup_prompt(denied, eligible(UserPreferences::default())) + .await + .is_err()); + assert!(!fixture + .service + .claim_setup_prompt(readiness(true, true, false), || Ok(None)) + .await + .unwrap()); + let calls = AtomicUsize::new(0); + assert!(!fixture + .service + .claim_setup_prompt(readiness(true, true, false), || Ok(Some(( + UserPreferences::default(), + ( + Revision::new(1), + calls.fetch_add(1, Ordering::AcqRel) as u64 + ) + )))) + .await + .unwrap()); + assert!(fixture.vault.secrets.lock().unwrap().is_empty()); + assert!(fixture + .service + .claim_setup_prompt( + readiness(true, true, false), + eligible(UserPreferences::default()) + ) + .await + .unwrap()); + assert_eq!(server.state.lock().unwrap().requests, 0); +} + +#[tokio::test] +async fn setup_prompt_durable_write_failure_and_enabled_state_do_not_claim() { + let server = Server::start().await; + let fixture = Fixture::new(&server).await; + fixture.vault.deny.store(true, Ordering::Release); + assert!(fixture + .service + .claim_setup_prompt( + readiness(true, true, false), + eligible(UserPreferences::default()) + ) + .await + .is_err()); + fixture.vault.deny.store(false, Ordering::Release); + assert!(fixture + .service + .claim_setup_prompt( + readiness(true, true, false), + eligible(UserPreferences::default()) + ) + .await + .unwrap()); + let other = Fixture::new(&server).await; + let local = LocalStorage::new( + other.root.join("protected"), + server.origin.clone(), + other.data.device().id.clone(), + other.vault.clone(), + ); + let settings = local::ClientSettings { + enabled: true, + ..Default::default() + }; + local.write("device", "client", settings).await.unwrap(); + let ready = readiness(true, true, false); + assert!(!other + .service + .claim_setup_prompt(ready.clone(), eligible(UserPreferences::default())) + .await + .unwrap()); + assert_eq!(ready.reads.load(Ordering::Acquire), 0); + assert_eq!(server.state.lock().unwrap().requests, 0); +} + +#[tokio::test] +async fn setup_prompt_service_busy_is_false_and_concurrent_claims_do_not_duplicate() { + let server = Server::start().await; + let fixture = Fixture::new(&server).await; + let barrier = Arc::new(tokio::sync::Barrier::new(2)); + let ready = Arc::new(ReadyCredentials { + status: CredentialsStatus { + asr_configured: true, + llm_configured: true, + ..Default::default() + }, + deny: false, + reads: Arc::new(AtomicUsize::new(0)), + barrier: Some(barrier.clone()), + }); + let service = fixture.service.clone(); + let first = tokio::spawn(async move { + service + .claim_setup_prompt(ready, eligible(UserPreferences::default())) + .await + }); + barrier.wait().await; + assert!(!fixture + .service + .claim_setup_prompt( + readiness(true, true, false), + eligible(UserPreferences::default()) + ) + .await + .unwrap()); + barrier.wait().await; + assert!(first.await.unwrap().unwrap()); + assert_eq!(server.state.lock().unwrap().requests, 0); +} diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/tests.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/tests.rs new file mode 100644 index 00000000..625087e1 --- /dev/null +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/tests.rs @@ -0,0 +1,1310 @@ +//! Service-level fault tests use a loopback fake OAuth/sync service and an +//! in-memory system vault. No user's data, account, or operating-system keychain. +use std::{ + collections::HashMap, + sync::{ + atomic::{AtomicBool, AtomicU64, Ordering}, + Arc, Mutex, + }, +}; + +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; +use futures_util::future::BoxFuture; +use serde_json::{json, Value}; +use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::TcpListener, +}; + +use super::{ + local::LocalStorage, + service::{EncryptedSyncService, SyncServiceConfig, SyncServiceData}, + *, +}; +use crate::cloud_sync_e2ee_documents::{ + self as documents, ExportedDocuments, RestoreContext, SyncScope, ValidatedSyncDocuments, +}; +use crate::cloud_sync_e2ee_protocol::types::*; +use crate::cloud_sync_e2ee_store::gate::{ChangeOrigin, SyncChange}; +use crate::credentials::SyncSecretAccount; +use crate::{ + BackendError, CredentialKey, CredentialNamespace, CredentialStore, CredentialsStatus, + InMemoryCredentialStore, SecretValue, UserPreferences, +}; + +const PASSWORD: &str = "FixtureOpenLess!2684"; +const OWNER: &str = "118526"; +const CLIENT: &str = "Ov23liyv3nEucG7oMHNE"; + +#[derive(Default)] +struct Vault { + ordinary: InMemoryCredentialStore, + secrets: Mutex>, + deny: AtomicBool, + deny_remove: AtomicBool, +} +impl CredentialStore for Vault { + fn status( + &self, + prefs: UserPreferences, + ) -> BoxFuture<'static, Result> { + self.ordinary.status(prefs) + } + fn read( + &self, + key: CredentialKey, + ) -> BoxFuture<'static, Result, BackendError>> { + self.ordinary.read(key) + } + fn write( + &self, + key: CredentialKey, + value: SecretValue, + ) -> BoxFuture<'static, Result<(), BackendError>> { + self.ordinary.write(key, value) + } + fn remove(&self, key: CredentialKey) -> BoxFuture<'static, Result<(), BackendError>> { + self.ordinary.remove(key) + } + fn read_sync_secret( + &self, + account: SyncSecretAccount, + ) -> BoxFuture<'static, Result, BackendError>> { + let result = if self.deny.load(Ordering::Acquire) { + Err(error("secure_storage_denied")) + } else { + Ok(self.secrets.lock().unwrap().get(account.as_str()).cloned()) + }; + Box::pin(async move { result }) + } + fn write_sync_secret( + &self, + account: SyncSecretAccount, + value: SecretValue, + ) -> BoxFuture<'static, Result<(), BackendError>> { + let result = if self.deny.load(Ordering::Acquire) { + Err(error("secure_storage_denied")) + } else { + self.secrets + .lock() + .unwrap() + .insert(account.as_str().into(), value); + Ok(()) + }; + Box::pin(async move { result }) + } + fn remove_sync_secret( + &self, + account: SyncSecretAccount, + ) -> BoxFuture<'static, Result<(), BackendError>> { + if self.deny_remove.load(Ordering::Acquire) { + return Box::pin(async { Err(error("secure_storage_denied")) }); + } + self.secrets.lock().unwrap().remove(account.as_str()); + Box::pin(async { Ok(()) }) + } +} + +struct Data { + documents: Mutex, + baseline: Mutex>, + generation: AtomicU64, + changes: tokio::sync::watch::Sender, + fail_restore: AtomicBool, + capture_gate: Mutex>>, + export_attempts: AtomicU64, +} +impl Data { + fn new() -> Arc { + let set = DocumentSet { + schema_version: 1, + exported_at: "2026-09-25T12:00:00Z".into(), + source_device: SourceDevice { + id: "11111111-1111-4111-8111-111111111111".into(), + os: "macos".into(), + arch: "aarch64".into(), + app_version: "2.0.0-Beta.3".into(), + }, + documents: vec![ + LogicalDocument { + id: "futureLayout".into(), + kind: DocumentKind::Preferences, + schema_version: 1, + value: json!("private-fixture-history"), + }, + LogicalDocument { + id: "llm:fixture-channel".into(), + kind: DocumentKind::Channels, + schema_version: 1, + value: json!({"id":"fixture-channel","namespace":"llm","providerType":"openai","name":"Fixture","enabled":true,"order":0,"active":true}), + }, + LogicalDocument { + id: "llm:fixture-channel".into(), + kind: DocumentKind::ProviderCredentials, + schema_version: 1, + value: json!({"channelId":"fixture-channel","namespace":"llm","accounts":{"ark.api_key":"sk-private-fixture-key"}}), + }, + ], + tombstones: vec![], + }; + documents::validate_sync_documents(set.clone(), Revision::new(0)).unwrap(); + let (changes, _) = tokio::sync::watch::channel(SyncChange { + generation: Revision::new(1), + origin: ChangeOrigin::User, + }); + Arc::new(Self { + documents: Mutex::new(set), + baseline: Mutex::new(None), + generation: AtomicU64::new(1), + changes, + fail_restore: AtomicBool::new(false), + capture_gate: Mutex::new(None), + export_attempts: AtomicU64::new(0), + }) + } + fn edit(&self, text: &str) { + self.documents + .lock() + .unwrap() + .documents + .iter_mut() + .find(|d| d.id == "futureLayout") + .unwrap() + .value = json!(text); + let generation = self.generation.fetch_add(1, Ordering::AcqRel) + 1; + self.changes.send_replace(SyncChange { + generation: Revision::new(generation), + origin: ChangeOrigin::User, + }); + } +} +impl SyncServiceData for Data { + fn export(&self, _scope: SyncScope) -> BoxFuture<'_, SyncResult> { + Box::pin(async move { + self.export_attempts.fetch_add(1, Ordering::AcqRel); + if let Some(gate) = self.capture_gate.lock().unwrap().as_ref() { + gate.coherent_generation().map_err(document_error)?; + } + let set = self.documents.lock().unwrap().clone(); + let baseline = self.baseline.lock().unwrap(); + let documents = match baseline.as_ref() { + Some(base) => { + documents::record_missing_tombstones(set, base, "2026-09-25T12:01:00Z") + } + None => documents::validate_sync_documents(set, Revision::new(0)), + } + .map_err(document_error)?; + Ok(ExportedDocuments { + generation: Revision::new(self.generation.load(Ordering::Acquire)), + documents, + }) + }) + } + fn restore( + &self, + desired: ValidatedSyncDocuments, + context: RestoreContext, + ) -> BoxFuture<'_, SyncResult> { + Box::pin(async move { + if self.fail_restore.load(Ordering::Acquire) { + return Err(error("recovery_required")); + } + if self.generation.load(Ordering::Acquire) != context.local_generation.get() { + return Err(error("stale_preview")); + } + *self.documents.lock().unwrap() = desired.documents().clone(); + let generation = self.generation.fetch_add(1, Ordering::AcqRel) + 1; + self.changes.send_replace(SyncChange { + generation: Revision::new(generation), + origin: ChangeOrigin::Restore, + }); + Ok(ExportedDocuments { + generation: Revision::new(generation), + documents: desired, + }) + }) + } + fn recover(&self) -> BoxFuture<'_, SyncResult<()>> { + Box::pin(async move { + if self.fail_restore.load(Ordering::Acquire) { + Err(error("recovery_required")) + } else { + Ok(()) + } + }) + } + fn baseline( + &self, + _scope: SyncScope, + set: DocumentSet, + revision: Revision, + ) -> BoxFuture<'_, SyncResult<()>> { + Box::pin(async move { + *self.baseline.lock().unwrap() = + Some(documents::validate_sync_documents(set, revision).map_err(document_error)?); + Ok(()) + }) + } + fn generation(&self) -> SyncResult { + Ok(Revision::new(self.generation.load(Ordering::Acquire))) + } + fn device(&self) -> SourceDevice { + self.documents.lock().unwrap().source_device.clone() + } + fn changes(&self) -> tokio::sync::watch::Receiver { + self.changes.subscribe() + } +} + +#[derive(Default)] +struct Remote { + snapshot: Option, + revision: u64, + receipt: Option, + drop_response: bool, + puts: Vec>, + requests: usize, + owner: String, + delay_upload: bool, + reject_upload: bool, + hide_receipts: bool, + drop_metadata_response: bool, +} +struct Server { + origin: String, + state: Arc>, + upload_arrived: Arc, + allow_upload: Arc, + task: tokio::task::JoinHandle<()>, +} +impl Drop for Server { + fn drop(&mut self) { + self.task.abort(); + } +} +impl Server { + async fn start() -> Self { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let origin = format!("http://{}", listener.local_addr().unwrap()); + let state = Arc::new(Mutex::new(Remote { + owner: OWNER.into(), + ..Remote::default() + })); + let shared = state.clone(); + let upload_arrived = Arc::new(tokio::sync::Notify::new()); + let allow_upload = Arc::new(tokio::sync::Notify::new()); + let arrived = upload_arrived.clone(); + let allow = allow_upload.clone(); + let task = tokio::spawn(async move { + loop { + let (mut socket, _) = listener.accept().await.unwrap(); + let mut bytes = Vec::new(); + let mut buffer = [0_u8; 8192]; + let header_end = loop { + let size = socket.read(&mut buffer).await.unwrap(); + if size == 0 { + return; + } + bytes.extend_from_slice(&buffer[..size]); + if let Some(offset) = bytes.windows(4).position(|w| w == b"\r\n\r\n") { + break offset + 4; + } + }; + let head = String::from_utf8(bytes[..header_end].to_vec()).unwrap(); + let mut first = head.lines().next().unwrap().split_whitespace(); + let method = first.next().unwrap(); + let path = first.next().unwrap(); + let headers: HashMap<_, _> = head + .lines() + .filter_map(|l| l.split_once(':')) + .map(|(k, v)| (k.to_ascii_lowercase(), v.trim().to_string())) + .collect(); + let length: usize = headers + .get("content-length") + .map_or(0, |v| v.parse().unwrap()); + while bytes.len() < header_end + length { + let size = socket.read(&mut buffer).await.unwrap(); + assert!(size > 0); + bytes.extend_from_slice(&buffer[..size]); + } + let body = &bytes[header_end..header_end + length]; + if method == "DELETE" && path == "/v1/auth/session" { + socket.write_all(b"HTTP/1.1 204 No Content\r\nCache-Control: no-store\r\nContent-Length: 0\r\nConnection: close\r\n\r\n").await.unwrap(); + continue; + } + let drop_metadata = method == "GET" + && path == "/v1/me/vault" + && std::mem::take(&mut shared.lock().unwrap().drop_metadata_response); + if drop_metadata { + shared.lock().unwrap().requests += 1; + continue; + } + let hidden_receipt = + path.starts_with("/v1/me/operations/") && shared.lock().unwrap().hide_receipts; + if hidden_receipt { + let body = serde_json::to_vec(&json!({"error":{"code":"operation_not_found","message":"fixture receipt expired","requestId":"dddddddd-dddd-4ddd-8ddd-dddddddddddd"}})).unwrap(); + let reply = format!("HTTP/1.1 404 Not Found\r\nContent-Type: application/json\r\nCache-Control: no-store\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", body.len()); + socket.write_all(reply.as_bytes()).await.unwrap(); + socket.write_all(&body).await.unwrap(); + continue; + } + let rejected = if method == "PUT" { + let mut state = shared.lock().unwrap(); + std::mem::take(&mut state.reject_upload) + || headers.get("if-match") + != Some(&format!("\"fixture-{}\"", state.revision)) + } else { + false + }; + if rejected { + let body = serde_json::to_vec(&json!({"error":{"code":"revision_conflict","message":"fixture CAS lost","requestId":"dddddddd-dddd-4ddd-8ddd-dddddddddddd","currentRevision":"2"}})).unwrap(); + let reply = format!("HTTP/1.1 412 Precondition Failed\r\nContent-Type: application/json\r\nCache-Control: no-store\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", body.len()); + socket.write_all(reply.as_bytes()).await.unwrap(); + socket.write_all(&body).await.unwrap(); + continue; + } + let delayed = method == "PUT" && shared.lock().unwrap().delay_upload; + if delayed { + arrived.notify_one(); + allow.notified().await; + } + let (status, response, etag, disconnect) = { + let mut remote = shared.lock().unwrap(); + remote.requests += 1; + let owner = remote.owner.clone(); + let mut disconnect = false; + let response = match (method, path) { + ("GET", "/v1/capabilities") => { + json!({"protocolVersion":1,"cryptoProfile":"argon2id-xchacha20poly1305-v1","githubClientId":CLIENT,"maxHttpBodyBytes":25165824,"maxCiphertextBytes":16777232,"maxPlaintextJsonBytes":15728640,"idempotencyRetentionSeconds":604800,"maxBackupRetentionDays":30}) + } + ("GET", "/github/user") => { + json!({"id":owner.parse::().unwrap(),"login":"fixture-user"}) + } + ("POST", "/v1/auth/github") => { + json!({"protocolVersion":1,"accessToken":"a".repeat(43),"tokenType":"Bearer","expiresIn":900,"account":{"githubId":owner,"login":"fixture-user"}}) + } + ("GET", "/v1/me/vault") => match &remote.snapshot { + None => { + json!({"protocolVersion":1,"state":"empty","ownerGithubId":owner,"revision":"0","vaultId":null,"keyId":null,"updatedAt":null,"payloadSchemaVersion":null,"ciphertextBytes":0,"ciphertextSha256":null,"lastOperationId":null}) + } + Some(s) => { + json!({"protocolVersion":1,"state":"active","ownerGithubId":owner,"revision":remote.revision.to_string(),"vaultId":s["vaultId"],"keyId":s["keyId"],"updatedAt":"2026-09-25T12:00:00Z","payloadSchemaVersion":1,"ciphertextBytes":URL_SAFE_NO_PAD.decode(s["ciphertext"].as_str().unwrap()).unwrap().len(),"ciphertextSha256":s["ciphertextSha256"],"lastOperationId":s["operationId"]}) + } + }, + ("GET", "/v1/me/vault/snapshot") => remote.snapshot.clone().unwrap(), + ("PUT", "/v1/me/vault/snapshot") => { + let value: Value = serde_json::from_slice(body).unwrap(); + assert_eq!( + headers["if-match"], + format!("\"fixture-{}\"", remote.revision) + ); + assert_eq!(headers["idempotency-key"], value["operationId"]); + remote.revision += 1; + assert_eq!(value["revision"], remote.revision.to_string()); + remote.puts.push(body.to_vec()); + let receipt = json!({"operationId":value["operationId"],"status":"committed","kind":value["kind"],"committedRevision":value["revision"],"committedAt":"2026-09-25T12:00:00Z","vaultId":value["vaultId"],"ciphertextSha256":value["ciphertextSha256"]}); + remote.snapshot = Some(value); + remote.receipt = Some(receipt.clone()); + disconnect = std::mem::take(&mut remote.drop_response); + receipt + } + ("GET", path) if path.starts_with("/v1/me/operations/") => { + remote.receipt.clone().unwrap() + } + _ => panic!("unexpected fixture request: {method} {path}"), + }; + ( + "200 OK", + response, + format!("\"fixture-{}\"", remote.revision), + disconnect, + ) + }; + if disconnect { + continue; + } + let body = serde_json::to_vec(&response).unwrap(); + let reply = format!("HTTP/1.1 {status}\r\nContent-Type: application/json\r\nCache-Control: no-store\r\nIdempotency-Replayed: false\r\nContent-Length: {}\r\nETag: {etag}\r\nConnection: close\r\n\r\n", body.len()); + let _ = socket.write_all(reply.as_bytes()).await; + let _ = socket.write_all(&body).await; + } + }); + Self { + origin, + state, + upload_arrived, + allow_upload, + task, + } + } +} + +struct Fixture { + service: EncryptedSyncService, + data: Arc, + vault: Arc, + root: std::path::PathBuf, +} +impl Drop for Fixture { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.root); + } +} +impl Fixture { + async fn new(server: &Server) -> Self { + let root = + std::env::temp_dir().join(format!("openless-e2ee-service-{}", uuid::Uuid::new_v4())); + let repos = crate::BackendRepositories::open(&root).unwrap(); + let vault = Arc::new(Vault::default()); + vault + .write( + CredentialKey::new(CredentialNamespace::Marketplace, None, "github.oauth_token") + .unwrap(), + SecretValue::new("fixture-github-token"), + ) + .await + .unwrap(); + let mut config = crate::MarketplaceConfig::new(&server.origin).unwrap(); + config.github_user_url = format!("{}/github/user", server.origin).parse().unwrap(); + let events = + crate::events::BackendEventPublisher::new(Arc::new(crate::events::EventBus::new(100))); + let marketplace = Arc::new( + crate::marketplace::MarketplaceService::new( + config, + vault.clone(), + repos.preferences, + repos.style_packs, + events.clone(), + Arc::new(AtomicU64::new(0)), + ) + .unwrap(), + ); + let data = Data::new(); + let local = LocalStorage::new( + root.join("protected"), + server.origin.clone(), + data.device().id.clone(), + vault.clone(), + ); + let service = EncryptedSyncService::new( + SyncServiceConfig { + origin: server.origin.clone(), + github_client_id: CLIENT.into(), + }, + marketplace, + local, + data.clone(), + events, + ); + Self { + service, + data, + vault, + root, + } + } + async fn prepare(&self) { + assert_eq!( + self.service + .prepare_enable(CONSENT_VERSION.into()) + .await + .unwrap() + .next_step, + EnableStep::Create + ); + } + async fn create(&self, remember: bool) -> SyncResult { + self.service + .create( + PASSWORD.into(), + PASSWORD.into(), + remember, + CONSENT_VERSION.into(), + "0".into(), + ) + .await + } +} + +#[tokio::test] +async fn disabled_start_and_changes_make_no_network_requests_or_key_entries() { + let server = Server::start().await; + let fixture = Fixture::new(&server).await; + fixture + .service + .start(Arc::new(crate::TokioTaskSpawner)) + .await + .unwrap(); + fixture.data.edit("changed while disabled"); + tokio::time::sleep(std::time::Duration::from_millis(850)).await; + assert_eq!(server.state.lock().unwrap().requests, 0); + assert!(fixture.vault.secrets.lock().unwrap().is_empty()); + fixture.service.shutdown().await; +} + +#[tokio::test] +async fn cached_connection_rebuilds_after_captured_proxy_policy_changes() { + let server = Server::start().await; + let fixture = Fixture::new(&server).await; + // Inject only this connection's captured setting. No global proxy setting + // or environment variable changes, and every endpoint is loopback/direct. + fixture + .service + .connect_with_proxy_setting_for_test(false) + .await + .unwrap(); + let direct_requests = server.state.lock().unwrap().requests; + fixture + .service + .connect_with_proxy_setting_for_test(false) + .await + .unwrap(); + assert_eq!(server.state.lock().unwrap().requests, direct_requests); + fixture + .service + .connect_with_proxy_setting_for_test(true) + .await + .unwrap(); + let enabled_requests = server.state.lock().unwrap().requests; + assert!( + enabled_requests >= direct_requests + 2, + "new policy must recheck capabilities and exchange a new session" + ); + fixture + .service + .connect_with_proxy_setting_for_test(true) + .await + .unwrap(); + assert_eq!(server.state.lock().unwrap().requests, enabled_requests); + fixture + .service + .connect_with_proxy_setting_for_test(false) + .await + .unwrap(); + assert!(server.state.lock().unwrap().requests >= enabled_requests + 2); + assert!(server.state.lock().unwrap().puts.is_empty()); + fixture.service.shutdown().await; +} + +async fn wait_for_auto_sync(mut ready: impl FnMut() -> bool) { + tokio::time::timeout(std::time::Duration::from_secs(15), async { + while !ready() { + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + }) + .await + .expect("automatic sync did not reach the expected state"); +} + +async fn start_ready_auto_sync(fixture: &Fixture) { + fixture.prepare().await; + fixture.create(true).await.unwrap(); + let before = fixture.data.export_attempts.load(Ordering::Acquire); + fixture + .service + .start(Arc::new(crate::TokioTaskSpawner)) + .await + .unwrap(); + wait_for_auto_sync(|| { + fixture.data.export_attempts.load(Ordering::Acquire) > before + && fixture.service.status().sync_state == SyncState::Ready + }) + .await; +} + +#[tokio::test] +async fn auto_sync_retries_capture_after_local_only_writer_without_another_user_event() { + let server = Server::start().await; + let fixture = Fixture::new(&server).await; + start_ready_auto_sync(&fixture).await; + let gate = crate::cloud_sync_e2ee_store::gate::SyncWriteGate::open( + fixture.root.join("capture-gate.json"), + ) + .unwrap(); + *fixture.data.capture_gate.lock().unwrap() = Some(gate.clone()); + let changes = gate.subscribe(); + let generation = gate.generation().unwrap(); + let local_only = gate.begin_mutation().unwrap(); + let before = fixture.data.export_attempts.load(Ordering::Acquire); + fixture.data.edit("user edit preceding a local-only write"); + wait_for_auto_sync(|| { + fixture.data.export_attempts.load(Ordering::Acquire) > before + && fixture + .service + .status() + .last_error + .as_ref() + .is_some_and(|failure| failure.code == "sync_documents_source_changed") + }) + .await; + local_only.commit(ChangeOrigin::LocalOnly).unwrap(); + assert_eq!(gate.generation().unwrap(), generation); + assert!( + !changes.has_changed().unwrap(), + "LocalOnly completion must not be mistaken for another user edit" + ); + wait_for_auto_sync(|| { + fixture + .service + .status() + .last_synced_local_generation + .as_deref() + == Some("2") + }) + .await; + assert_eq!(fixture.service.status().sync_state, SyncState::Ready); + assert_eq!(server.state.lock().unwrap().puts.len(), 2); + fixture.service.shutdown().await; +} + +#[tokio::test] +async fn auto_sync_capture_retry_is_bounded_while_local_writer_stays_busy() { + let server = Server::start().await; + let fixture = Fixture::new(&server).await; + start_ready_auto_sync(&fixture).await; + let gate = crate::cloud_sync_e2ee_store::gate::SyncWriteGate::open( + fixture.root.join("capture-gate.json"), + ) + .unwrap(); + *fixture.data.capture_gate.lock().unwrap() = Some(gate.clone()); + let local_only = gate.begin_mutation().unwrap(); + let before = fixture.data.export_attempts.load(Ordering::Acquire); + fixture + .data + .edit("pending while local writer remains active"); + wait_for_auto_sync(|| { + fixture.data.export_attempts.load(Ordering::Acquire) == before + 5 + && fixture.service.status().last_error.is_some() + }) + .await; + tokio::time::sleep(std::time::Duration::from_millis(1_000)).await; + assert_eq!( + fixture.data.export_attempts.load(Ordering::Acquire), + before + 5 + ); + assert_eq!(server.state.lock().unwrap().puts.len(), 1); + local_only.commit(ChangeOrigin::LocalOnly).unwrap(); + fixture.service.shutdown().await; +} + +#[tokio::test] +async fn auto_sync_does_not_retry_an_unknown_upload_outcome() { + let server = Server::start().await; + let fixture = Fixture::new(&server).await; + start_ready_auto_sync(&fixture).await; + server.state.lock().unwrap().drop_response = true; + fixture.data.edit("upload with a lost acknowledgement"); + wait_for_auto_sync(|| fixture.service.status().sync_state == SyncState::OutcomeUnknown).await; + let requests = server.state.lock().unwrap().requests; + tokio::time::sleep(std::time::Duration::from_millis(1_000)).await; + assert_eq!(server.state.lock().unwrap().requests, requests); + assert_eq!(server.state.lock().unwrap().puts.len(), 2); + assert!(fixture.service.status().pending_operation_id.is_some()); + fixture.service.shutdown().await; +} + +#[tokio::test] +async fn auto_sync_does_not_retry_network_failure() { + let server = Server::start().await; + let fixture = Fixture::new(&server).await; + start_ready_auto_sync(&fixture).await; + server.state.lock().unwrap().drop_metadata_response = true; + fixture + .data + .edit("pending after a metadata transport failure"); + wait_for_auto_sync(|| { + fixture + .service + .status() + .last_error + .as_ref() + .is_some_and(|failure| failure.code == "transport_failed") + }) + .await; + let requests = server.state.lock().unwrap().requests; + tokio::time::sleep(std::time::Duration::from_millis(1_000)).await; + assert_eq!(server.state.lock().unwrap().requests, requests); + assert_eq!(server.state.lock().unwrap().puts.len(), 1); + fixture.service.shutdown().await; +} + +#[tokio::test] +async fn auto_sync_local_retry_releases_runtime_and_respects_pause() { + let server = Server::start().await; + let fixture = Fixture::new(&server).await; + start_ready_auto_sync(&fixture).await; + let gate = crate::cloud_sync_e2ee_store::gate::SyncWriteGate::open( + fixture.root.join("capture-gate.json"), + ) + .unwrap(); + *fixture.data.capture_gate.lock().unwrap() = Some(gate.clone()); + let local_only = gate.begin_mutation().unwrap(); + fixture.data.edit("pause before a delayed local retry"); + wait_for_auto_sync(|| { + fixture + .service + .status() + .last_error + .as_ref() + .is_some_and(|failure| failure.code == "sync_documents_source_changed") + }) + .await; + tokio::time::timeout( + std::time::Duration::from_millis(200), + fixture.service.set_enabled(false), + ) + .await + .expect("local retry must release the runtime lock") + .unwrap(); + let attempts = fixture.data.export_attempts.load(Ordering::Acquire); + local_only.commit(ChangeOrigin::LocalOnly).unwrap(); + tokio::time::sleep(std::time::Duration::from_millis(1_000)).await; + assert_eq!( + fixture.data.export_attempts.load(Ordering::Acquire), + attempts + ); + assert_eq!(server.state.lock().unwrap().puts.len(), 1); + fixture.service.set_enabled(true).await.unwrap(); + wait_for_auto_sync(|| { + fixture + .service + .status() + .last_synced_local_generation + .as_deref() + == Some("2") + }) + .await; + assert_eq!(server.state.lock().unwrap().puts.len(), 2); + fixture.service.shutdown().await; +} + +#[tokio::test] +async fn auto_sync_local_retry_cannot_reconcile_a_later_manual_unknown_upload() { + let server = Server::start().await; + let fixture = Fixture::new(&server).await; + start_ready_auto_sync(&fixture).await; + let gate = crate::cloud_sync_e2ee_store::gate::SyncWriteGate::open( + fixture.root.join("capture-gate.json"), + ) + .unwrap(); + *fixture.data.capture_gate.lock().unwrap() = Some(gate.clone()); + let local_only = gate.begin_mutation().unwrap(); + fixture + .data + .edit("manual upload takes over a local-busy automatic retry"); + wait_for_auto_sync(|| { + fixture + .service + .status() + .last_error + .as_ref() + .is_some_and(|failure| failure.code == "sync_documents_source_changed") + }) + .await; + local_only.commit(ChangeOrigin::LocalOnly).unwrap(); + server.state.lock().unwrap().drop_response = true; + assert_eq!( + fixture.service.sync_now().await.unwrap_err().message, + "outcome_unknown" + ); + let pending = fixture.service.status().pending_operation_id; + let requests = server.state.lock().unwrap().requests; + tokio::time::sleep(std::time::Duration::from_millis(1_000)).await; + assert_eq!( + server.state.lock().unwrap().requests, + requests, + "the obsolete retry must not query/replay the newer pending operation" + ); + assert_eq!(fixture.service.status().pending_operation_id, pending); + assert_eq!( + fixture.service.status().sync_state, + SyncState::OutcomeUnknown + ); + assert_eq!(server.state.lock().unwrap().puts.len(), 2); + fixture.service.shutdown().await; +} + +#[tokio::test] +async fn create_encrypts_real_data_and_wrong_password_never_changes_local_data() { + let server = Server::start().await; + let first = Fixture::new(&server).await; + first.prepare().await; + let status = first.create(false).await.unwrap(); + assert!(status.enabled); + assert_eq!(status.key_state, KeyState::Unlocked); + assert_eq!(status.remote_revision.as_deref(), Some("1")); + let raw = server.state.lock().unwrap().puts[0].clone(); + let request = std::str::from_utf8(&raw).unwrap(); + for private in [ + PASSWORD, + "sk-private-fixture-key", + "private-fixture-history", + "fixture-github-token", + ] { + assert!(!request.contains(private)); + } + let second = Fixture::new(&server).await; + assert_eq!( + second + .service + .prepare_enable(CONSENT_VERSION.into()) + .await + .unwrap() + .next_step, + EnableStep::Unlock + ); + let before = second.data.documents.lock().unwrap().clone(); + assert!(second + .service + .unlock("WrongPassword!9876".into(), true) + .await + .is_err()); + assert_eq!(*second.data.documents.lock().unwrap(), before); + assert_eq!(second.service.status().key_state, KeyState::Locked); + assert!(!second.service.status().enabled); + assert_eq!(server.state.lock().unwrap().revision, 1); + let status = second.service.unlock(PASSWORD.into(), false).await.unwrap(); + assert_eq!(status.key_state, KeyState::Unlocked); + assert!( + !status.enabled, + "a successful unlock must still require first restore review" + ); +} + +#[tokio::test] +async fn lost_upload_response_reconciles_one_commit_without_another_put() { + let server = Server::start().await; + let fixture = Fixture::new(&server).await; + fixture.prepare().await; + server.state.lock().unwrap().drop_response = true; + let failed = fixture.create(true).await.unwrap_err(); + assert_eq!(failed.message, "outcome_unknown"); + assert!(fixture.service.status().pending_operation_id.is_some()); + let status = fixture.service.sync_now().await.unwrap(); + assert!(status.pending_operation_id.is_none()); + assert!(status.last_successful_sync_at.is_some()); + assert_eq!(server.state.lock().unwrap().puts.len(), 1); + assert_eq!(server.state.lock().unwrap().revision, 1); +} + +#[tokio::test] +async fn changes_during_upload_remain_pending_after_its_receipt() { + let server = Server::start().await; + let fixture = Fixture::new(&server).await; + fixture.prepare().await; + server.state.lock().unwrap().delay_upload = true; + let service = fixture.service.clone(); + let upload = tokio::spawn(async move { + service + .create( + PASSWORD.into(), + PASSWORD.into(), + false, + CONSENT_VERSION.into(), + "0".into(), + ) + .await + }); + tokio::time::timeout( + std::time::Duration::from_secs(30), + server.upload_arrived.notified(), + ) + .await + .unwrap(); + fixture.data.edit("new text while upload in flight"); + server.allow_upload.notify_one(); + let status = upload.await.unwrap().unwrap(); + assert_eq!(status.last_synced_local_generation.as_deref(), Some("1")); + assert_eq!(status.local_generation, "2"); + assert_eq!(status.sync_state, SyncState::Pending); +} + +#[tokio::test] +async fn denied_system_key_storage_aborts_before_private_upload() { + let server = Server::start().await; + let fixture = Fixture::new(&server).await; + fixture.vault.deny.store(true, Ordering::Release); + assert!(fixture + .service + .prepare_enable(CONSENT_VERSION.into()) + .await + .is_err()); + assert!(server.state.lock().unwrap().puts.is_empty()); + assert!(fixture.vault.secrets.lock().unwrap().is_empty()); +} + +#[tokio::test] +async fn review_rotation_lost_response_must_not_keep_old_key_after_receipt() { + let server = Server::start().await; + let fixture = Fixture::new(&server).await; + fixture.prepare().await; + fixture.create(true).await.unwrap(); + server.state.lock().unwrap().drop_response = true; + let failure = fixture + .service + .change_password( + PASSWORD.into(), + "FreshOpenLess!8436".into(), + "FreshOpenLess!8436".into(), + true, + ) + .await + .unwrap_err(); + assert_eq!(failure.message, "outcome_unknown"); + let reconciled = fixture.service.sync_now().await; + assert!( + reconciled.is_ok(), + "receipt of password rotation must load its matching key: {:?}", + reconciled.as_ref().err() + ); +} + +#[tokio::test] +async fn review_lock_must_drop_memory_key_even_when_recovery_fails() { + let server = Server::start().await; + let fixture = Fixture::new(&server).await; + fixture.prepare().await; + fixture.create(true).await.unwrap(); + fixture.data.fail_restore.store(true, Ordering::Release); + assert!(fixture.service.lock().await.is_err()); + assert_eq!( + fixture.service.status().key_state, + KeyState::Locked, + "lock must not preserve unlocked key on recovery error" + ); +} + +#[tokio::test] +async fn review_pending_receipt_must_not_undo_a_later_explicit_lock() { + let server = Server::start().await; + let fixture = Fixture::new(&server).await; + fixture.prepare().await; + fixture.create(true).await.unwrap(); + fixture.data.edit("changed data"); + server.state.lock().unwrap().drop_response = true; + assert_eq!( + fixture.service.sync_now().await.unwrap_err().message, + "outcome_unknown" + ); + fixture.vault.deny_remove.store(true, Ordering::Release); + assert_eq!( + fixture.service.lock().await.unwrap_err().message, + "secure_storage_denied" + ); + assert_eq!(fixture.service.status().key_state, KeyState::Locked); + let _ = fixture.service.sync_now().await; + assert_eq!( + fixture.service.status().key_state, + KeyState::Locked, + "replaying a prior operation must preserve later explicit lock intent" + ); +} + +#[tokio::test] +async fn review_first_submit_explicit_cas_rejection_must_not_stick_as_unknown() { + let server = Server::start().await; + let fixture = Fixture::new(&server).await; + fixture.prepare().await; + fixture.create(true).await.unwrap(); + fixture.data.edit("local edit racing another device"); + server.state.lock().unwrap().reject_upload = true; + let rejection = fixture.service.sync_now().await.unwrap_err(); + assert_eq!(rejection.message, "revision_conflict"); + assert!( + fixture.service.status().pending_operation_id.is_none(), + "a first submit definitively rejected by CAS must allow a fresh metadata/merge cycle" + ); +} + +#[tokio::test] +async fn cancelled_local_write_cannot_overwrite_a_later_lock_record() { + struct Paused { + entered: Arc, + release: Arc<(Mutex, std::sync::Condvar)>, + } + impl serde::Serialize for Paused { + fn serialize(&self, serializer: S) -> Result { + self.entered.notify_one(); + let (mutex, cv) = &*self.release; + let guard = mutex.lock().unwrap(); + drop(cv.wait_while(guard, |released| !*released).unwrap()); + json!({"enabled": true, "rememberKey": true}).serialize(serializer) + } + } + let root = std::env::temp_dir().join(format!( + "openless-e2ee-local-cancel-{}", + uuid::Uuid::new_v4() + )); + let store = LocalStorage::new( + root.clone(), + "https://sync.example".into(), + "fixture-device".into(), + Arc::new(Vault::default()), + ); + let entered = Arc::new(tokio::sync::Notify::new()); + let release = Arc::new((Mutex::new(false), std::sync::Condvar::new())); + let old_store = store.clone(); + let old = tokio::spawn({ + let entered = entered.clone(); + let release = release.clone(); + async move { + old_store + .write("device", "client", Paused { entered, release }) + .await + } + }); + entered.notified().await; + old.abort(); + let _ = old.await; + let current_store = store.clone(); + let mut current = tokio::spawn(async move { + current_store + .write( + "device", + "client", + json!({"enabled":false,"rememberKey":false}), + ) + .await + }); + let early = tokio::time::timeout(std::time::Duration::from_millis(30), &mut current).await; + { + *release.0.lock().unwrap() = true; + release.1.notify_all(); + } + let was_blocked = early.is_err(); + if was_blocked { + current.await.unwrap().unwrap(); + } + assert!( + was_blocked, + "new settings must queue behind the actual old blocking worker" + ); + let value: Value = store.read("device", "client").await.unwrap().unwrap(); + assert_eq!(value, json!({"enabled":false,"rememberKey":false})); + std::fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn device_identity_is_create_only_and_never_replaced_over_existing_ciphertext() { + let root = + std::env::temp_dir().join(format!("openless-e2ee-device-id-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&root).unwrap(); + let first = super::adapter::load_device_id(&root).unwrap(); + assert_eq!(first, super::adapter::load_device_id(&root).unwrap()); + assert!(!super::local::durable_create(&root.join("device-id"), b"replacement").unwrap()); + assert_eq!( + first, + std::fs::read_to_string(root.join("device-id")).unwrap() + ); + std::fs::create_dir_all(root.join("protected")).unwrap(); + std::fs::write(root.join("protected/state.enc"), b"existing ciphertext").unwrap(); + std::fs::remove_file(root.join("device-id")).unwrap(); + assert_eq!( + super::adapter::load_device_id(&root).unwrap_err().message, + "recovery_required" + ); + assert!(!root.join("device-id").exists()); + std::fs::remove_dir_all(root).unwrap(); +} + +#[tokio::test] +async fn expired_unknown_receipt_can_be_resolved_by_explicit_review_of_a_newer_head() { + let server = Server::start().await; + let first = Fixture::new(&server).await; + first.prepare().await; + first.create(true).await.unwrap(); + first.data.edit("first device change"); + server.state.lock().unwrap().drop_response = true; + assert_eq!( + first.service.sync_now().await.unwrap_err().message, + "outcome_unknown" + ); + let second = Fixture::new(&server).await; + second + .service + .prepare_enable(CONSENT_VERSION.into()) + .await + .unwrap(); + second.service.unlock(PASSWORD.into(), false).await.unwrap(); + let preview = second.service.preview_restore("2".into()).await.unwrap(); + second + .service + .apply_restore(preview.preview_id, RestoreMode::Replace, vec![]) + .await + .unwrap(); + second.data.edit("second device change"); + second.service.sync_now().await.unwrap(); + assert_eq!(server.state.lock().unwrap().revision, 3); + server.state.lock().unwrap().hide_receipts = true; + assert_eq!( + first.service.sync_now().await.unwrap_err().message, + "outcome_unknown" + ); + let preview = first.service.preview_restore("3".into()).await.unwrap(); + assert!(preview.unconfirmed_operation_id.is_some()); + assert!(!preview.conflicts.is_empty()); + let choices = preview + .conflicts + .iter() + .map(|item| SyncConflictChoice { + id: item.id.clone(), + side: ConflictSide::Cloud, + }) + .collect(); + let status = first + .service + .apply_restore(preview.preview_id, RestoreMode::Merge, choices) + .await + .unwrap(); + assert!(status.pending_operation_id.is_none()); + first.data.edit("a reviewed new operation"); + first.service.sync_now().await.unwrap(); + assert_eq!(server.state.lock().unwrap().revision, 4); +} + +#[tokio::test] +async fn round2_lock_must_remove_key_of_retired_unknown_password_rotation() { + let server = Server::start().await; + let first = Fixture::new(&server).await; + first.prepare().await; + first.create(true).await.unwrap(); + server.state.lock().unwrap().drop_response = true; + let second_password = "MiddleOpenLess!3827"; + assert_eq!( + first + .service + .change_password( + PASSWORD.into(), + second_password.into(), + second_password.into(), + true + ) + .await + .unwrap_err() + .message, + "outcome_unknown" + ); + let second = Fixture::new(&server).await; + second + .service + .prepare_enable(CONSENT_VERSION.into()) + .await + .unwrap(); + second + .service + .unlock(second_password.into(), false) + .await + .unwrap(); + let preview = second.service.preview_restore("2".into()).await.unwrap(); + second + .service + .apply_restore(preview.preview_id, RestoreMode::Replace, vec![]) + .await + .unwrap(); + let third_password = "ThirdOpenLess!4829"; + second + .service + .change_password( + second_password.into(), + third_password.into(), + third_password.into(), + true, + ) + .await + .unwrap(); + assert_eq!(server.state.lock().unwrap().revision, 3); + server.state.lock().unwrap().hide_receipts = true; + assert_eq!( + first.service.sync_now().await.unwrap_err().message, + "outcome_unknown" + ); + first + .service + .unlock(third_password.into(), true) + .await + .unwrap(); + let preview = first.service.preview_restore("3".into()).await.unwrap(); + assert!(preview.unconfirmed_operation_id.is_some()); + first.vault.deny_remove.store(true, Ordering::Release); + let denied = first + .service + .apply_restore(preview.preview_id.clone(), RestoreMode::Replace, vec![]) + .await + .unwrap_err(); + assert_eq!(denied.message, "secure_storage_denied"); + assert!( + first.service.status().pending_operation_id.is_some(), + "failed key deletion must preserve the pending cleanup index" + ); + first.vault.deny_remove.store(false, Ordering::Release); + first + .service + .apply_restore(preview.preview_id, RestoreMode::Replace, vec![]) + .await + .unwrap(); + first.service.lock().await.unwrap(); + let secrets = first.vault.secrets.lock().unwrap(); + assert!( + !secrets + .keys() + .any(|key| key.starts_with("cloud-sync.e2ee.key.")), + "explicit lock must forget even the retired uncertain password key" + ); +} + +#[tokio::test] +async fn round2_refuses_previously_seen_revision_rollback_before_local_changes() { + let server = Server::start().await; + let fixture = Fixture::new(&server).await; + fixture.prepare().await; + fixture.create(true).await.unwrap(); + let old = server.state.lock().unwrap().snapshot.clone(); + fixture.data.edit("newest local value"); + fixture.service.sync_now().await.unwrap(); + let before = fixture.data.documents.lock().unwrap().clone(); + let puts = server.state.lock().unwrap().puts.len(); + { + let mut remote = server.state.lock().unwrap(); + remote.snapshot = old; + remote.revision = 1; + } + assert_eq!( + fixture.service.sync_now().await.unwrap_err().message, + "revision_rollback" + ); + assert_eq!( + fixture.service.status().remote_revision.as_deref(), + Some("2") + ); + assert_eq!(fixture.data.documents.lock().unwrap().clone(), before); + assert_eq!(server.state.lock().unwrap().puts.len(), puts); +} + +#[tokio::test] +async fn round2_sync_key_deletion_failure_must_not_prevent_account_sign_out() { + let server = Server::start().await; + let fixture = Fixture::new(&server).await; + fixture.prepare().await; + fixture.create(true).await.unwrap(); + fixture.vault.deny_remove.store(true, Ordering::Release); + assert_eq!( + fixture.service.sign_out().await.unwrap_err().message, + "secure_storage_denied" + ); + let oauth = fixture + .vault + .read( + CredentialKey::new(CredentialNamespace::Marketplace, None, "github.oauth_token") + .unwrap(), + ) + .await + .unwrap(); + assert!( + oauth.is_none(), + "a sync key cleanup failure must still attempt ordinary account token removal" + ); + assert_eq!(fixture.service.status().auth_state, AuthState::SignedOut); +} + +#[path = "setup_prompt_tests.rs"] +mod setup_prompt_tests; diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/export.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/export.rs new file mode 100644 index 00000000..39ca280b --- /dev/null +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/export.rs @@ -0,0 +1,385 @@ +//! Explicit source-to-document registration. This module never scans user directories. + +use std::collections::{BTreeMap, BTreeSet}; + +use serde_json::{json, Map, Value}; + +use crate::cloud_sync_e2ee_protocol::types::{DocumentKind, DocumentSet, LogicalDocument}; +use crate::credentials::{CredentialKey, CredentialNamespace, CredentialStore}; + +use super::registry::{ + credential_accounts, excluded_extension_key, is_device_channel, preference_field, + PreferenceClass, +}; +use super::types::*; +use super::validate::validate_sync_documents; + +pub async fn export_sync_documents( + source: &dyn SyncDocumentSource, +) -> DocumentResult { + export_snapshot(source.capture().await?) +} + +/// Export an already coherent capture. The caller must not substitute empty stores on errors. +pub fn export_snapshot(mut snapshot: ExportSnapshot) -> DocumentResult { + let mut documents = Vec::new(); + let mut device_preferences = Map::new(); + let preferences = snapshot + .preferences + .expose() + .as_object() + .ok_or(DocumentError::InvalidDocument)?; + for (key, value) in preferences { + match preference_field(key).map(|field| field.class) { + Some(PreferenceClass::Excluded) => {} + Some(PreferenceClass::DeviceProfile) => { + device_preferences.insert(key.clone(), value.clone()); + } + Some(PreferenceClass::Portable) => documents.push(document( + DocumentKind::Preferences, + key.clone(), + value.clone(), + )), + None => { + if excluded_extension_key(key) { + return Err(DocumentError::ExcludedField); + } + documents.push(document( + DocumentKind::Preferences, + key.clone(), + value.clone(), + )); + } + } + } + for (key, value) in snapshot + .ui_preferences + .expose() + .as_object() + .ok_or(DocumentError::InvalidDocument)? + { + if excluded_extension_key(key) { + return Err(DocumentError::ExcludedField); + } + documents.push(document( + DocumentKind::UiPreferences, + key.clone(), + value.clone(), + )); + } + + let mut credentials = BTreeMap::new(); + for record in std::mem::take(&mut snapshot.provider_credentials) { + if credentials.insert(record.document_id(), record).is_some() { + return Err(DocumentError::DuplicateId); + } + } + let mut seen_channels = BTreeSet::new(); + let mut device_channels = Vec::new(); + let mut device_credentials = Vec::new(); + for channel in std::mem::take(&mut snapshot.channels) { + let id = channel.document_id(); + if !seen_channels.insert(id.clone()) { + return Err(DocumentError::DuplicateId); + } + let credential = credentials + .remove(&id) + .ok_or(DocumentError::InvalidReference)?; + if is_device_channel(channel.namespace, &channel.provider_type) { + device_channels.push(channel); + device_credentials.push(credential); + } else { + documents.push(document( + DocumentKind::Channels, + id.clone(), + to_value(&channel)?, + )); + documents.push(document( + DocumentKind::ProviderCredentials, + id, + to_value(&credential)?, + )); + } + } + if !credentials.is_empty() { + return Err(DocumentError::InvalidReference); + } + + for (sort_index, mut entry) in std::mem::take(&mut snapshot.dictionary) + .into_iter() + .enumerate() + { + entry + .expose_mut() + .as_object_mut() + .ok_or(DocumentError::InvalidDocument)? + .insert("sortIndex".into(), json!(sort_index)); + let id = record_id(entry.expose())?; + documents.push(document(DocumentKind::Dictionary, id, entry.into_value())); + } + for preset in std::mem::take(&mut snapshot.vocabulary_presets) { + let prefix = match preset.origin { + PresetOrigin::Custom => "custom", + PresetOrigin::Override => "override", + PresetOrigin::BuiltinState => "builtin", + }; + documents.push(document( + DocumentKind::VocabularyPresets, + format!("{prefix}:{}", preset.id), + to_value(&preset)?, + )); + } + for (sort_index, mut entry) in std::mem::take(&mut snapshot.corrections) + .into_iter() + .enumerate() + { + entry + .expose_mut() + .as_object_mut() + .ok_or(DocumentError::InvalidDocument)? + .insert("sortIndex".into(), json!(sort_index)); + let id = record_id(entry.expose())?; + documents.push(document(DocumentKind::Corrections, id, entry.into_value())); + } + for mut entry in std::mem::take(&mut snapshot.style_packs) { + let id = record_id(entry.pack.expose())?; + let pack = entry + .pack + .expose_mut() + .as_object_mut() + .ok_or(DocumentError::InvalidDocument)?; + pack.remove("iconPath"); + pack.remove("active"); + documents.push(document(DocumentKind::StylePacks, id, to_value(&entry)?)); + } + for (sort_index, mut entry) in std::mem::take(&mut snapshot.history) + .into_iter() + .enumerate() + { + entry + .expose_mut() + .as_object_mut() + .ok_or(DocumentError::InvalidDocument)? + .insert("sortIndex".into(), json!(sort_index)); + let id = record_id(entry.expose())?; + // Media never travels. Availability is recomputed against this device's recordings. + let history = entry + .expose_mut() + .as_object_mut() + .ok_or(DocumentError::InvalidDocument)?; + history.insert("hasAudioRecording".into(), Value::Bool(false)); + documents.push(document(DocumentKind::History, id, entry.into_value())); + } + for entry in std::mem::take(&mut snapshot.activity) { + documents.push(document( + DocumentKind::Activity, + entry.document_id(), + to_value(&entry)?, + )); + } + let profile = DeviceProfileRecord { + device: snapshot.source_device.clone(), + preferences: SecretJson::new(Value::Object(device_preferences)), + channels: device_channels, + provider_credentials: device_credentials, + window_positions: std::mem::take(&mut snapshot.window_positions), + }; + documents.push(document( + DocumentKind::DeviceProfile, + snapshot.source_device.id.clone(), + to_value(&profile)?, + )); + + // Retained documents must be explicit source profiles or unknown preferences. Never let + // an archive silently replace live source fields or resurrect a deleted record. + for retained in std::mem::take(&mut snapshot.retained_documents) { + let permitted = retained.kind == DocumentKind::DeviceProfile + || retained.kind == DocumentKind::Preferences + && preference_field(&retained.id).is_none() + || retained.kind == DocumentKind::UiPreferences + && !matches!(retained.id.as_str(), "locale" | "fontScale"); + if !permitted { + return Err(DocumentError::InvalidDocument); + } + if let Some(existing) = documents + .iter() + .find(|doc| doc.kind == retained.kind && doc.id == retained.id) + { + if existing != &retained { + return Err(DocumentError::SourceChanged); + } + } else { + documents.push(retained); + } + } + let set = DocumentSet { + schema_version: DOCUMENT_SCHEMA_VERSION, + exported_at: snapshot.exported_at, + source_device: snapshot.source_device, + documents, + tombstones: snapshot.tombstones, + }; + Ok(ExportedDocuments { + generation: snapshot.generation, + documents: validate_sync_documents(set, snapshot.base_revision)?, + }) +} + +/// Reads only registered logical accounts. Each error aborts the entire export. +/// Omni IDs must enumerate every stored provider entry; None would read only the active one. +pub async fn export_provider_credentials_for_sync( + store: &dyn CredentialStore, + channels: &[ChannelRecord], +) -> DocumentResult> { + let mut records = Vec::with_capacity(channels.len()); + let mut seen = BTreeSet::new(); + for channel in channels { + if !seen.insert(channel.document_id()) { + return Err(DocumentError::DuplicateId); + } + let namespace = match channel.namespace { + SyncNamespace::Asr => CredentialNamespace::Asr, + SyncNamespace::Llm => CredentialNamespace::Llm, + SyncNamespace::Omni => CredentialNamespace::Omni, + }; + let mut accounts = BTreeMap::new(); + for account in credential_accounts(channel.namespace) { + let key = CredentialKey::new(namespace, Some(channel.id.clone()), *account) + .map_err(|_| DocumentError::InvalidDocument)?; + if let Some(secret) = store + .read(key) + .await + .map_err(|_| DocumentError::CaptureFailed)? + { + accounts.insert((*account).to_string(), secret.expose_secret().to_string()); + } + } + records.push(ProviderCredentialRecord { + channel_id: channel.id.clone(), + namespace: channel.namespace, + accounts, + }); + } + Ok(records) +} + +/// Converts a registered store's typed vector to secret rows, without dropping any row. +pub fn secret_rows(rows: &[T]) -> DocumentResult> { + rows.iter().map(SecretJson::from_serializable).collect() +} + +/// Convert all preset state, including enabled builtins, to independently mergeable IDs. +pub fn vocabulary_records( + custom: &[crate::types::VocabPreset], + overrides: &[crate::types::VocabPreset], + disabled_builtin_ids: &[String], + builtin_ids: &[String], +) -> DocumentResult> { + if disabled_builtin_ids + .iter() + .any(|id| !builtin_ids.contains(id)) + { + return Err(DocumentError::InvalidReference); + } + let mut result = Vec::new(); + for (origin, entries) in [ + (PresetOrigin::Custom, custom), + (PresetOrigin::Override, overrides), + ] { + for (order, entry) in entries.iter().enumerate() { + result.push(VocabularyPresetRecord { + id: entry.id.clone(), + origin, + name: Some(entry.name.clone()), + phrases: entry.phrases.clone(), + enabled: true, + order: u32::try_from(order).map_err(|_| DocumentError::PayloadTooLarge)?, + }); + } + } + for id in builtin_ids { + result.push(VocabularyPresetRecord { + id: id.clone(), + origin: PresetOrigin::BuiltinState, + name: None, + phrases: Vec::new(), + enabled: !disabled_builtin_ids.contains(id), + order: 0, + }); + } + Ok(result) +} + +/// Read icons through the repository's bounded ID-based resource reader, not iconPath. +pub fn export_style_packs( + store: &crate::style_pack_store::StylePackStore, +) -> DocumentResult> { + store + .list() + .map_err(|_| DocumentError::CaptureFailed)? + .iter() + .map(|pack| { + let icon = match store + .icon_data_url(&pack.id) + .map_err(|_| DocumentError::CaptureFailed)? + { + Some(data) => { + let (mime, encoded) = data + .strip_prefix("data:") + .and_then(|s| s.split_once(";base64,")) + .ok_or(DocumentError::InvalidDocument)?; + Some(IconAsset { + mime: mime.to_string(), + base64: encoded.to_string(), + }) + } + None if pack.icon_path.is_some() => return Err(DocumentError::CaptureFailed), + None => None, + }; + Ok(StylePackRecord { + pack: SecretJson::from_serializable(pack)?, + icon, + }) + }) + .collect() +} + +pub(crate) fn document(kind: DocumentKind, id: String, value: Value) -> LogicalDocument { + LogicalDocument { + id, + kind, + schema_version: DOCUMENT_SCHEMA_VERSION, + value, + } +} + +pub(crate) fn to_value(value: &impl serde::Serialize) -> DocumentResult { + serde_json::to_value(value).map_err(|_| DocumentError::InvalidDocument) +} + +pub(crate) fn record_id(value: &Value) -> DocumentResult { + value + .get("id") + .and_then(Value::as_str) + .map(str::to_string) + .ok_or(DocumentError::InvalidDocument) +} + +/// A stable per-source aggregate, not a sum of two already merged device totals. +pub fn activity_record( + source_device_id: &str, + day: &crate::activity::ActivityDay, +) -> ActivityRecord { + ActivityRecord { + source_device_id: source_device_id.into(), + date: day.date.clone(), + count: day.count.into(), + chars: day.chars, + duration_ms: day.duration_ms, + } +} + +/// Convenience for UI bridges; unrelated localStorage keys must never be passed here. +pub fn ui_preferences(locale: &str, font_scale: &str) -> SecretJson { + SecretJson::new(json!({"locale": locale, "fontScale": font_scale})) +} diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/merge.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/merge.rs new file mode 100644 index 00000000..bdc8ac06 --- /dev/null +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/merge.rs @@ -0,0 +1,416 @@ +//! Three-way logical merge. Timestamps never choose a winning value. + +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt; + +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +use crate::cloud_sync_e2ee_protocol::types::{ + DocumentKind, DocumentSet, LogicalDocument, Revision, Tombstone, +}; + +use super::types::*; +use super::validate::{timestamp, validate_sync_documents}; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ConflictReason { + BothModified, + DeleteModify, + NoCommonBaseline, +} + +/// No local/cloud values, value hashes, excerpts, channel names, or secret lengths reach UI. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct RedactedConflict { + pub conflict_id: String, + pub kind: DocumentKind, + pub reason: ConflictReason, + pub is_credential_unit: bool, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ConflictSide { + Local, + Remote, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ConflictChoice { + pub conflict_id: String, + pub side: ConflictSide, +} + +#[derive(Clone, PartialEq)] +enum Entry { + Live(LogicalDocument), + Deleted(Tombstone), +} + +type Unit = BTreeMap; +type UnitMap = BTreeMap; + +pub struct MergePreview { + source: DocumentSet, + revision: Revision, + accepted: UnitMap, + unresolved: BTreeMap, Option)>, + conflicts: Vec, + active_choices: BTreeMap>, + active_conflicts: BTreeMap, Option)>, +} + +impl fmt::Debug for MergePreview { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("MergePreview([REDACTED])") + } +} + +impl MergePreview { + pub fn conflicts(&self) -> &[RedactedConflict] { + &self.conflicts + } + + pub fn resolve(mut self, choices: &[ConflictChoice]) -> DocumentResult { + let mut selected = BTreeMap::new(); + for choice in choices { + if selected + .insert(choice.conflict_id.clone(), choice.side) + .is_some() + { + return Err(DocumentError::ConflictChoiceRequired); + } + } + if selected.keys().collect::>() + != self + .unresolved + .keys() + .chain(self.active_conflicts.keys()) + .collect::>() + { + return Err(DocumentError::ConflictChoiceRequired); + } + for (id, (key, local, remote)) in std::mem::take(&mut self.unresolved) { + let chosen = match selected[&id] { + ConflictSide::Local => local, + ConflictSide::Remote => remote, + }; + if let Some(unit) = chosen { + self.accepted.insert(key, unit); + } + } + for (id, (namespace, local, remote)) in std::mem::take(&mut self.active_conflicts) { + self.active_choices.insert( + namespace, + match selected[&id] { + ConflictSide::Local => local, + ConflictSide::Remote => remote, + }, + ); + } + self.source.documents.clear(); + self.source.tombstones.clear(); + for unit in self.accepted.values() { + for entry in unit.values() { + match entry { + Entry::Live(doc) => self.source.documents.push(doc.clone()), + Entry::Deleted(tombstone) => self.source.tombstones.push(tombstone.clone()), + } + } + } + for (namespace, choice) in &self.active_choices { + let mut found = choice.is_none(); + for doc in self + .source + .documents + .iter_mut() + .filter(|doc| doc.kind == DocumentKind::Channels) + { + let mut channel: ChannelRecord = serde_json::from_value(doc.value.clone()) + .map_err(|_| DocumentError::InvalidDocument)?; + if channel.namespace == *namespace { + channel.active = choice.as_deref() == Some(channel.id.as_str()); + if channel.active { + channel.enabled = true; + found = true; + } + doc.value = serde_json::to_value(channel) + .map_err(|_| DocumentError::InvalidDocument)?; + } + } + if !found { + return Err(DocumentError::ConflictChoiceRequired); + } + } + validate_sync_documents(self.source, self.revision) + } +} + +/// Different settings/record IDs merge independently; a channel and all its credentials do not. +pub fn diff_sync_documents( + baseline: Option<&ValidatedSyncDocuments>, + local: &ValidatedSyncDocuments, + remote: &ValidatedSyncDocuments, +) -> DocumentResult { + let base = baseline.map(|set| units(&set.set)); + let left = units(&local.set); + let right = units(&remote.set); + let keys: BTreeSet<_> = base + .iter() + .flat_map(|map| map.keys()) + .chain(left.keys()) + .chain(right.keys()) + .cloned() + .collect(); + let mut preview = MergePreview { + source: local.set.clone(), + revision: local.revision.max(remote.revision), + accepted: BTreeMap::new(), + unresolved: BTreeMap::new(), + conflicts: Vec::new(), + active_choices: BTreeMap::new(), + active_conflicts: BTreeMap::new(), + }; + for key in keys { + let ancestor = base.as_ref().and_then(|map| map.get(&key)); + let local_unit = left.get(&key); + let remote_unit = right.get(&key); + // v1 never garbage-collects tombstones. A missing baseline identity is not a delete. + if ancestor.is_some() && (local_unit.is_none() || remote_unit.is_none()) { + return Err(DocumentError::MissingTombstone); + } + let chosen = if equivalent(local_unit, remote_unit) { + Some(prefer_tombstone_revision(local_unit, remote_unit)) + } else if baseline.is_none() { + if local_unit.is_none() { + Some(remote_unit.cloned()) + } else if remote_unit.is_none() { + Some(local_unit.cloned()) + } else { + None + } + } else if is_deleted(ancestor) && (has_live(local_unit) || has_live(remote_unit)) { + // A stale device cannot revive a deletion already present in the common baseline. + None + } else if equivalent(local_unit, ancestor) { + Some(remote_unit.cloned()) + } else if equivalent(remote_unit, ancestor) { + Some(local_unit.cloned()) + } else { + None + }; + if let Some(chosen) = chosen { + if let Some(unit) = chosen { + preview.accepted.insert(key, unit); + } + continue; + } + let conflict_id = conflict_id(&key); + let reason = if is_deleted(local_unit) || is_deleted(remote_unit) || is_deleted(ancestor) { + ConflictReason::DeleteModify + } else if baseline.is_none() { + ConflictReason::NoCommonBaseline + } else { + ConflictReason::BothModified + }; + preview.conflicts.push(RedactedConflict { + conflict_id: conflict_id.clone(), + kind: key.kind, + reason, + is_credential_unit: key.kind == DocumentKind::Channels, + }); + preview.unresolved.insert( + conflict_id, + (key, local_unit.cloned(), remote_unit.cloned()), + ); + } + for namespace in [SyncNamespace::Asr, SyncNamespace::Llm, SyncNamespace::Omni] { + let local_active = active_channel(&local.set, namespace)?; + let remote_active = active_channel(&remote.set, namespace)?; + let base_active = baseline + .map(|set| active_channel(&set.set, namespace)) + .transpose()? + .flatten(); + let chosen = if local_active == remote_active { + Some(local_active.clone()) + } else if baseline.is_some() && local_active == base_active { + Some(remote_active.clone()) + } else if baseline.is_some() && remote_active == base_active { + Some(local_active.clone()) + } else if baseline.is_none() && local_active.is_none() { + Some(remote_active.clone()) + } else if baseline.is_none() && remote_active.is_none() { + Some(local_active.clone()) + } else { + None + }; + if let Some(chosen) = chosen { + preview.active_choices.insert(namespace, chosen); + } else { + let id = format!( + "active-selection-{}", + match namespace { + SyncNamespace::Asr => "asr", + SyncNamespace::Llm => "llm", + SyncNamespace::Omni => "omni", + } + ); + let conflict_id = conflict_id(&DocumentKey { + kind: DocumentKind::Channels, + id, + }); + preview.conflicts.push(RedactedConflict { + conflict_id: conflict_id.clone(), + kind: DocumentKind::Channels, + reason: if baseline.is_some() { + ConflictReason::BothModified + } else { + ConflictReason::NoCommonBaseline + }, + is_credential_unit: true, + }); + preview + .active_conflicts + .insert(conflict_id, (namespace, local_active, remote_active)); + } + } + Ok(preview) +} + +fn active_channel(set: &DocumentSet, namespace: SyncNamespace) -> DocumentResult> { + for doc in set + .documents + .iter() + .filter(|doc| doc.kind == DocumentKind::Channels) + { + let channel: ChannelRecord = serde_json::from_value(doc.value.clone()) + .map_err(|_| DocumentError::InvalidDocument)?; + if channel.namespace == namespace && channel.active { + return Ok(Some(channel.id)); + } + } + Ok(None) +} + +fn units(set: &DocumentSet) -> UnitMap { + let mut result: UnitMap = BTreeMap::new(); + for doc in &set.documents { + let key = DocumentKey { + kind: doc.kind, + id: doc.id.clone(), + }; + result + .entry(unit_key(&key)) + .or_default() + .insert(key, Entry::Live(doc.clone())); + } + for tombstone in &set.tombstones { + let key = DocumentKey { + kind: tombstone.kind, + id: tombstone.id.clone(), + }; + result + .entry(unit_key(&key)) + .or_default() + .insert(key, Entry::Deleted(tombstone.clone())); + } + result +} + +fn unit_key(key: &DocumentKey) -> DocumentKey { + DocumentKey { + kind: if key.kind == DocumentKind::ProviderCredentials { + DocumentKind::Channels + } else { + key.kind + }, + id: key.id.clone(), + } +} + +fn equivalent(left: Option<&Unit>, right: Option<&Unit>) -> bool { + match (left, right) { + (None, None) => true, + (Some(left), Some(right)) if left.len() == right.len() => { + left.iter() + .all(|(key, value)| match (value, right.get(key)) { + (Entry::Deleted(_), Some(Entry::Deleted(_))) => true, + (_, Some(other)) => value == other, + _ => false, + }) + } + _ => false, + } +} + +fn prefer_tombstone_revision(left: Option<&Unit>, right: Option<&Unit>) -> Option { + let mut selected = left?.clone(); + if let Some(right) = right { + for (key, entry) in right { + if let (Entry::Deleted(candidate), Some(Entry::Deleted(current))) = + (entry, selected.get(key)) + { + if candidate.base_revision > current.base_revision { + selected.insert(key.clone(), entry.clone()); + } + } + } + } + Some(selected) +} + +fn is_deleted(unit: Option<&Unit>) -> bool { + unit.is_some_and(|unit| { + unit.values() + .any(|entry| matches!(entry, Entry::Deleted(_))) + }) +} +fn has_live(unit: Option<&Unit>) -> bool { + unit.is_some_and(|unit| unit.values().any(|entry| matches!(entry, Entry::Live(_)))) +} + +fn conflict_id(key: &DocumentKey) -> String { + let identity = serde_json::to_vec(key).expect("fixed conflict identity serializes"); + format!("{:x}", Sha256::digest(identity)) +} + +/// Materialize explicit local deletes relative to the last accepted baseline; keep all old marks. +/// Call after a coherent capture, never use a remote snapshot as the local deletion baseline. +pub fn record_missing_tombstones( + mut current: DocumentSet, + baseline: &ValidatedSyncDocuments, + deleted_at: &str, +) -> DocumentResult { + timestamp(deleted_at)?; + let mut live: BTreeSet<_> = current + .documents + .iter() + .map(|doc| (doc.kind, doc.id.clone())) + .collect(); + live.extend( + current + .tombstones + .iter() + .map(|doc| (doc.kind, doc.id.clone())), + ); + for old in &baseline.set.tombstones { + if live.insert((old.kind, old.id.clone())) { + current.tombstones.push(old.clone()); + } + } + for old in &baseline.set.documents { + if live.insert((old.kind, old.id.clone())) { + current.tombstones.push(Tombstone { + id: old.id.clone(), + kind: old.kind, + deleted_at: deleted_at.into(), + base_revision: baseline.revision, + }); + } + } + validate_sync_documents(current, baseline.revision) +} diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/mod.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/mod.rs new file mode 100644 index 00000000..1a445493 --- /dev/null +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/mod.rs @@ -0,0 +1,29 @@ +//! Client-side logical documents for encrypted sync. No network or UI effects live here. + +mod export; +mod merge; +pub mod registry; +mod restore; +mod types; +mod validate; + +pub use export::{ + activity_record, export_provider_credentials_for_sync, export_snapshot, export_style_packs, + export_sync_documents, secret_rows, ui_preferences, vocabulary_records, +}; +pub use merge::{ + diff_sync_documents, record_missing_tombstones, ConflictChoice, ConflictReason, ConflictSide, + MergePreview, RedactedConflict, +}; +pub use restore::{ + apply_sync_restore, prepare_sync_restore, recover_sync_restore, CryptoJournalProtector, + JournalProtector, JournalStore, RecoveryOutcome, RestorePlan, RestoreReceipt, SealedJournal, +}; +pub use types::*; +pub(crate) use validate::validate_scope; +pub use validate::{validate_credential_set, validate_icon, validate_sync_documents}; + +#[cfg(test)] +mod restore_tests; +#[cfg(test)] +mod tests; diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/registry.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/registry.rs new file mode 100644 index 00000000..5c821db5 --- /dev/null +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/registry.rs @@ -0,0 +1,929 @@ +//! Explicit sync classification for every persisted UserPreferences field. +use super::types::{DocumentError, DocumentResult, SyncNamespace}; +use serde_json::Value; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PreferenceClass { + Portable, + DeviceProfile, + Excluded, +} +#[derive(Debug, Clone, Copy)] +pub enum PreferenceShape { + Boolean, + Unsigned8, + Unsigned16, + Unsigned32, + Number, + Text, + TextList, + OptionalText, + OptionalUnsigned32, + Object, + OptionalObject, + ObjectList, +} +#[derive(Debug, Clone, Copy)] +pub struct PreferenceField { + pub rust_name: &'static str, + pub key: &'static str, + pub class: PreferenceClass, + pub shape: PreferenceShape, + pub reason: &'static str, +} +pub const PREFERENCE_FIELDS: &[PreferenceField] = &[ + PreferenceField { + rust_name: "hotkey", + key: "hotkey", + class: PreferenceClass::DeviceProfile, + shape: PreferenceShape::Object, + reason: "device_bound", + }, + PreferenceField { + rust_name: "dictation_hotkey", + key: "dictationHotkey", + class: PreferenceClass::DeviceProfile, + shape: PreferenceShape::Object, + reason: "device_bound", + }, + PreferenceField { + rust_name: "default_mode", + key: "defaultMode", + class: PreferenceClass::Portable, + shape: PreferenceShape::Text, + reason: "portable_setting", + }, + PreferenceField { + rust_name: "enabled_modes", + key: "enabledModes", + class: PreferenceClass::Portable, + shape: PreferenceShape::TextList, + reason: "portable_setting", + }, + PreferenceField { + rust_name: "active_style_pack_id", + key: "activeStylePackId", + class: PreferenceClass::Portable, + shape: PreferenceShape::Text, + reason: "portable_setting", + }, + PreferenceField { + rust_name: "style_system_prompts", + key: "styleSystemPrompts", + class: PreferenceClass::Portable, + shape: PreferenceShape::Object, + reason: "portable_setting", + }, + PreferenceField { + rust_name: "custom_style_prompts", + key: "customStylePrompts", + class: PreferenceClass::Portable, + shape: PreferenceShape::Object, + reason: "portable_setting", + }, + PreferenceField { + rust_name: "launch_at_login", + key: "launchAtLogin", + class: PreferenceClass::Excluded, + shape: PreferenceShape::Boolean, + reason: "local_os_autostart", + }, + PreferenceField { + rust_name: "show_capsule", + key: "showCapsule", + class: PreferenceClass::Portable, + shape: PreferenceShape::Boolean, + reason: "portable_setting", + }, + PreferenceField { + rust_name: "capsule_style", + key: "capsuleStyle", + class: PreferenceClass::Portable, + shape: PreferenceShape::Text, + reason: "portable_setting", + }, + PreferenceField { + rust_name: "capsule_transcript_enabled", + key: "capsuleTranscriptEnabled", + class: PreferenceClass::Portable, + shape: PreferenceShape::Boolean, + reason: "portable_setting", + }, + PreferenceField { + rust_name: "capsule_transcript_font_size", + key: "capsuleTranscriptFontSize", + class: PreferenceClass::Portable, + shape: PreferenceShape::Unsigned8, + reason: "portable_setting", + }, + PreferenceField { + rust_name: "mute_during_recording", + key: "muteDuringRecording", + class: PreferenceClass::Portable, + shape: PreferenceShape::Boolean, + reason: "portable_setting", + }, + PreferenceField { + rust_name: "stable_transcription_enabled", + key: "stableTranscriptionEnabled", + class: PreferenceClass::Portable, + shape: PreferenceShape::Boolean, + reason: "portable_setting", + }, + PreferenceField { + rust_name: "audio_cue_on_record", + key: "audioCueOnRecord", + class: PreferenceClass::Portable, + shape: PreferenceShape::Boolean, + reason: "portable_setting", + }, + PreferenceField { + rust_name: "silence_auto_stop_enabled", + key: "silenceAutoStopEnabled", + class: PreferenceClass::Portable, + shape: PreferenceShape::Boolean, + reason: "portable_setting", + }, + PreferenceField { + rust_name: "silence_auto_stop_seconds", + key: "silenceAutoStopSeconds", + class: PreferenceClass::Portable, + shape: PreferenceShape::Number, + reason: "portable_setting", + }, + PreferenceField { + rust_name: "microphone_device_name", + key: "microphoneDeviceName", + class: PreferenceClass::DeviceProfile, + shape: PreferenceShape::Text, + reason: "device_bound", + }, + PreferenceField { + rust_name: "active_asr_provider", + key: "activeAsrProvider", + class: PreferenceClass::Excluded, + shape: PreferenceShape::Text, + reason: "selection_in_channel_unit", + }, + PreferenceField { + rust_name: "active_llm_provider", + key: "activeLlmProvider", + class: PreferenceClass::Excluded, + shape: PreferenceShape::Text, + reason: "selection_in_channel_unit", + }, + PreferenceField { + rust_name: "pipeline_mode", + key: "pipelineMode", + class: PreferenceClass::Portable, + shape: PreferenceShape::Text, + reason: "portable_setting", + }, + PreferenceField { + rust_name: "multimodal_pipeline_enabled", + key: "multimodalPipelineEnabled", + class: PreferenceClass::Portable, + shape: PreferenceShape::Boolean, + reason: "portable_setting", + }, + PreferenceField { + rust_name: "active_omni_provider", + key: "activeOmniProvider", + class: PreferenceClass::Excluded, + shape: PreferenceShape::Text, + reason: "selection_in_channel_unit", + }, + PreferenceField { + rust_name: "llm_thinking_enabled", + key: "llmThinkingEnabled", + class: PreferenceClass::Portable, + shape: PreferenceShape::Boolean, + reason: "portable_setting", + }, + PreferenceField { + rust_name: "use_system_proxy", + key: "useSystemProxy", + class: PreferenceClass::DeviceProfile, + shape: PreferenceShape::Boolean, + reason: "device_bound", + }, + PreferenceField { + rust_name: "restore_clipboard_after_paste", + key: "restoreClipboardAfterPaste", + class: PreferenceClass::DeviceProfile, + shape: PreferenceShape::Boolean, + reason: "device_bound", + }, + PreferenceField { + rust_name: "paste_shortcut", + key: "pasteShortcut", + class: PreferenceClass::DeviceProfile, + shape: PreferenceShape::Text, + reason: "device_bound", + }, + PreferenceField { + rust_name: "allow_non_tsf_insertion_fallback", + key: "allowNonTsfInsertionFallback", + class: PreferenceClass::DeviceProfile, + shape: PreferenceShape::Boolean, + reason: "device_bound", + }, + PreferenceField { + rust_name: "windows_insertion_mode", + key: "windowsInsertionMode", + class: PreferenceClass::DeviceProfile, + shape: PreferenceShape::Text, + reason: "device_bound", + }, + PreferenceField { + rust_name: "windows_sendinput_newline_mode", + key: "windowsSendInputNewlineMode", + class: PreferenceClass::DeviceProfile, + shape: PreferenceShape::Text, + reason: "device_bound", + }, + PreferenceField { + rust_name: "macos_newline_mode", + key: "macosNewlineMode", + class: PreferenceClass::DeviceProfile, + shape: PreferenceShape::Text, + reason: "device_bound", + }, + PreferenceField { + rust_name: "windows_sendinput_insertion_only", + key: "windowsSendInputInsertionOnly", + class: PreferenceClass::DeviceProfile, + shape: PreferenceShape::Boolean, + reason: "device_bound", + }, + PreferenceField { + rust_name: "windows_show_openless_in_keyboard_list", + key: "windowsShowOpenlessInKeyboardList", + class: PreferenceClass::DeviceProfile, + shape: PreferenceShape::Boolean, + reason: "device_bound", + }, + PreferenceField { + rust_name: "working_languages", + key: "workingLanguages", + class: PreferenceClass::Portable, + shape: PreferenceShape::TextList, + reason: "portable_setting", + }, + PreferenceField { + rust_name: "translation_target_language", + key: "translationTargetLanguage", + class: PreferenceClass::Portable, + shape: PreferenceShape::Text, + reason: "portable_setting", + }, + PreferenceField { + rust_name: "chinese_script_preference", + key: "chineseScriptPreference", + class: PreferenceClass::Portable, + shape: PreferenceShape::Text, + reason: "portable_setting", + }, + PreferenceField { + rust_name: "output_language_preference", + key: "outputLanguagePreference", + class: PreferenceClass::Portable, + shape: PreferenceShape::Text, + reason: "portable_setting", + }, + PreferenceField { + rust_name: "qa_hotkey", + key: "qaHotkey", + class: PreferenceClass::DeviceProfile, + shape: PreferenceShape::OptionalObject, + reason: "device_bound", + }, + PreferenceField { + rust_name: "quick_note_hotkey", + key: "quickNoteHotkey", + class: PreferenceClass::DeviceProfile, + shape: PreferenceShape::OptionalObject, + reason: "device_bound", + }, + PreferenceField { + rust_name: "selection_polish_hotkey", + key: "selectionPolishHotkey", + class: PreferenceClass::DeviceProfile, + shape: PreferenceShape::OptionalObject, + reason: "device_bound", + }, + PreferenceField { + rust_name: "selection_polish_style_pack_id", + key: "selectionPolishStylePackId", + class: PreferenceClass::Portable, + shape: PreferenceShape::Text, + reason: "portable_setting", + }, + PreferenceField { + rust_name: "selection_polish_output_mode", + key: "selectionPolishOutputMode", + class: PreferenceClass::Portable, + shape: PreferenceShape::Text, + reason: "portable_setting", + }, + PreferenceField { + rust_name: "selection_voice_enabled", + key: "selectionVoiceEnabled", + class: PreferenceClass::Portable, + shape: PreferenceShape::Boolean, + reason: "portable_setting", + }, + PreferenceField { + rust_name: "selection_voice_intent_mode", + key: "selectionVoiceIntentMode", + class: PreferenceClass::Portable, + shape: PreferenceShape::Text, + reason: "portable_setting", + }, + PreferenceField { + rust_name: "selection_voice_manual_intent", + key: "selectionVoiceManualIntent", + class: PreferenceClass::Portable, + shape: PreferenceShape::Text, + reason: "portable_setting", + }, + PreferenceField { + rust_name: "selection_voice_edit_keywords", + key: "selectionVoiceEditKeywords", + class: PreferenceClass::Portable, + shape: PreferenceShape::TextList, + reason: "portable_setting", + }, + PreferenceField { + rust_name: "selection_voice_edit_plan_format", + key: "selectionVoiceEditPlanFormat", + class: PreferenceClass::Portable, + shape: PreferenceShape::Text, + reason: "portable_setting", + }, + PreferenceField { + rust_name: "selection_voice_edit_system_prompt", + key: "selectionVoiceEditSystemPrompt", + class: PreferenceClass::Portable, + shape: PreferenceShape::Text, + reason: "portable_setting", + }, + PreferenceField { + rust_name: "qa_save_history", + key: "qaSaveHistory", + class: PreferenceClass::Portable, + shape: PreferenceShape::Boolean, + reason: "portable_setting", + }, + PreferenceField { + rust_name: "custom_combo_hotkey", + key: "customComboHotkey", + class: PreferenceClass::DeviceProfile, + shape: PreferenceShape::OptionalObject, + reason: "device_bound", + }, + PreferenceField { + rust_name: "translation_hotkey", + key: "translationHotkey", + class: PreferenceClass::DeviceProfile, + shape: PreferenceShape::Object, + reason: "device_bound", + }, + PreferenceField { + rust_name: "switch_style_hotkey", + key: "switchStyleHotkey", + class: PreferenceClass::DeviceProfile, + shape: PreferenceShape::OptionalObject, + reason: "device_bound", + }, + PreferenceField { + rust_name: "open_app_hotkey", + key: "openAppHotkey", + class: PreferenceClass::DeviceProfile, + shape: PreferenceShape::OptionalObject, + reason: "device_bound", + }, + PreferenceField { + rust_name: "style_pack_hotkeys", + key: "stylePackHotkeys", + class: PreferenceClass::DeviceProfile, + shape: PreferenceShape::ObjectList, + reason: "device_bound", + }, + PreferenceField { + rust_name: "coding_agent_enabled", + key: "codingAgentEnabled", + class: PreferenceClass::Excluded, + shape: PreferenceShape::Boolean, + reason: "local_consent_or_secret", + }, + PreferenceField { + rust_name: "coding_agent_provider", + key: "codingAgentProvider", + class: PreferenceClass::DeviceProfile, + shape: PreferenceShape::Text, + reason: "device_bound", + }, + PreferenceField { + rust_name: "coding_agent_model", + key: "codingAgentModel", + class: PreferenceClass::DeviceProfile, + shape: PreferenceShape::OptionalText, + reason: "device_bound", + }, + PreferenceField { + rust_name: "coding_agent_permission_mode", + key: "codingAgentPermissionMode", + class: PreferenceClass::DeviceProfile, + shape: PreferenceShape::Text, + reason: "device_bound", + }, + PreferenceField { + rust_name: "coding_agent_workdir", + key: "codingAgentWorkdir", + class: PreferenceClass::DeviceProfile, + shape: PreferenceShape::OptionalText, + reason: "device_bound", + }, + PreferenceField { + rust_name: "coding_agent_exe", + key: "codingAgentExe", + class: PreferenceClass::DeviceProfile, + shape: PreferenceShape::OptionalText, + reason: "device_bound", + }, + PreferenceField { + rust_name: "coding_agent_voice_hotkey", + key: "codingAgentVoiceHotkey", + class: PreferenceClass::DeviceProfile, + shape: PreferenceShape::OptionalObject, + reason: "device_bound", + }, + PreferenceField { + rust_name: "coding_agent_panel_hotkey", + key: "codingAgentPanelHotkey", + class: PreferenceClass::DeviceProfile, + shape: PreferenceShape::OptionalObject, + reason: "device_bound", + }, + PreferenceField { + rust_name: "coding_agent_quick_hotkey", + key: "codingAgentQuickHotkey", + class: PreferenceClass::DeviceProfile, + shape: PreferenceShape::OptionalObject, + reason: "device_bound", + }, + PreferenceField { + rust_name: "remote_input_enabled", + key: "remoteInputEnabled", + class: PreferenceClass::Excluded, + shape: PreferenceShape::Boolean, + reason: "local_network_consent", + }, + PreferenceField { + rust_name: "remote_input_port", + key: "remoteInputPort", + class: PreferenceClass::DeviceProfile, + shape: PreferenceShape::Unsigned16, + reason: "device_bound", + }, + PreferenceField { + rust_name: "remote_input_pin", + key: "remoteInputPin", + class: PreferenceClass::Excluded, + shape: PreferenceShape::Text, + reason: "local_consent_or_secret", + }, + PreferenceField { + rust_name: "remote_input_default_mode", + key: "remoteInputDefaultMode", + class: PreferenceClass::DeviceProfile, + shape: PreferenceShape::Text, + reason: "device_bound", + }, + PreferenceField { + rust_name: "local_asr_active_model", + key: "localAsrActiveModel", + class: PreferenceClass::DeviceProfile, + shape: PreferenceShape::Text, + reason: "device_bound", + }, + PreferenceField { + rust_name: "local_whisper_active_model", + key: "localWhisperActiveModel", + class: PreferenceClass::DeviceProfile, + shape: PreferenceShape::Text, + reason: "device_bound", + }, + PreferenceField { + rust_name: "local_asr_mirror", + key: "localAsrMirror", + class: PreferenceClass::DeviceProfile, + shape: PreferenceShape::Text, + reason: "device_bound", + }, + PreferenceField { + rust_name: "local_asr_keep_loaded_secs", + key: "localAsrKeepLoadedSecs", + class: PreferenceClass::DeviceProfile, + shape: PreferenceShape::Unsigned32, + reason: "device_bound", + }, + PreferenceField { + rust_name: "local_asr_models_base_dir", + key: "localAsrModelsBaseDir", + class: PreferenceClass::DeviceProfile, + shape: PreferenceShape::Text, + reason: "device_bound", + }, + PreferenceField { + rust_name: "foundry_local_asr_model", + key: "foundryLocalAsrModel", + class: PreferenceClass::DeviceProfile, + shape: PreferenceShape::Text, + reason: "device_bound", + }, + PreferenceField { + rust_name: "foundry_local_runtime_source", + key: "foundryLocalRuntimeSource", + class: PreferenceClass::DeviceProfile, + shape: PreferenceShape::Text, + reason: "device_bound", + }, + PreferenceField { + rust_name: "foundry_local_asr_language_hint", + key: "foundryLocalAsrLanguageHint", + class: PreferenceClass::DeviceProfile, + shape: PreferenceShape::Text, + reason: "device_bound", + }, + PreferenceField { + rust_name: "foundry_local_asr_keep_loaded_secs", + key: "foundryLocalAsrKeepLoadedSecs", + class: PreferenceClass::DeviceProfile, + shape: PreferenceShape::Unsigned32, + reason: "device_bound", + }, + PreferenceField { + rust_name: "sherpa_onnx_model", + key: "sherpaOnnxModel", + class: PreferenceClass::DeviceProfile, + shape: PreferenceShape::Text, + reason: "device_bound", + }, + PreferenceField { + rust_name: "sherpa_onnx_language_hint", + key: "sherpaOnnxLanguageHint", + class: PreferenceClass::DeviceProfile, + shape: PreferenceShape::Text, + reason: "device_bound", + }, + PreferenceField { + rust_name: "sherpa_onnx_keep_loaded_secs", + key: "sherpaOnnxKeepLoadedSecs", + class: PreferenceClass::DeviceProfile, + shape: PreferenceShape::Unsigned32, + reason: "device_bound", + }, + PreferenceField { + rust_name: "update_channel", + key: "updateChannel", + class: PreferenceClass::Portable, + shape: PreferenceShape::Text, + reason: "portable_setting", + }, + PreferenceField { + rust_name: "update_channel_explicit", + key: "updateChannelExplicit", + class: PreferenceClass::Portable, + shape: PreferenceShape::Boolean, + reason: "portable_setting", + }, + PreferenceField { + rust_name: "history_retention_days", + key: "historyRetentionDays", + class: PreferenceClass::Portable, + shape: PreferenceShape::Unsigned32, + reason: "portable_setting", + }, + PreferenceField { + rust_name: "polish_context_window_minutes", + key: "polishContextWindowMinutes", + class: PreferenceClass::Portable, + shape: PreferenceShape::Unsigned32, + reason: "portable_setting", + }, + PreferenceField { + rust_name: "start_minimized", + key: "startMinimized", + class: PreferenceClass::Portable, + shape: PreferenceShape::Boolean, + reason: "portable_setting", + }, + PreferenceField { + rust_name: "theme_mode", + key: "themeMode", + class: PreferenceClass::Portable, + shape: PreferenceShape::Text, + reason: "portable_setting", + }, + PreferenceField { + rust_name: "streaming_insert", + key: "streamingInsert", + class: PreferenceClass::Portable, + shape: PreferenceShape::Boolean, + reason: "portable_setting", + }, + PreferenceField { + rust_name: "streaming_insert_default_migrated", + key: "streamingInsertDefaultMigrated", + class: PreferenceClass::Excluded, + shape: PreferenceShape::Boolean, + reason: "local_consent_or_secret", + }, + PreferenceField { + rust_name: "streaming_insert_save_clipboard", + key: "streamingInsertSaveClipboard", + class: PreferenceClass::Portable, + shape: PreferenceShape::Boolean, + reason: "portable_setting", + }, + PreferenceField { + rust_name: "cursor_context_enabled", + key: "cursorContextEnabled", + class: PreferenceClass::Excluded, + shape: PreferenceShape::Boolean, + reason: "local_consent_or_secret", + }, + PreferenceField { + rust_name: "show_overview_activity_heatmap", + key: "showOverviewActivityHeatmap", + class: PreferenceClass::Portable, + shape: PreferenceShape::Boolean, + reason: "portable_setting", + }, + PreferenceField { + rust_name: "stacked_row_layout", + key: "stackedRowLayout", + class: PreferenceClass::Portable, + shape: PreferenceShape::Boolean, + reason: "portable_setting", + }, + PreferenceField { + rust_name: "conservative_layout", + key: "conservativeLayout", + class: PreferenceClass::Portable, + shape: PreferenceShape::Boolean, + reason: "portable_setting", + }, + PreferenceField { + rust_name: "auto_update_check", + key: "autoUpdateCheck", + class: PreferenceClass::Portable, + shape: PreferenceShape::Boolean, + reason: "portable_setting", + }, + PreferenceField { + rust_name: "history_max_entries", + key: "historyMaxEntries", + class: PreferenceClass::Portable, + shape: PreferenceShape::OptionalUnsigned32, + reason: "portable_setting", + }, + PreferenceField { + rust_name: "record_audio_for_debug", + key: "recordAudioForDebug", + class: PreferenceClass::Portable, + shape: PreferenceShape::Boolean, + reason: "portable_setting", + }, + PreferenceField { + rust_name: "audio_recording_max_entries", + key: "audioRecordingMaxEntries", + class: PreferenceClass::Portable, + shape: PreferenceShape::OptionalUnsigned32, + reason: "portable_setting", + }, + PreferenceField { + rust_name: "quick_note_export_directory", + key: "quickNoteExportDirectory", + class: PreferenceClass::DeviceProfile, + shape: PreferenceShape::Text, + reason: "device_bound", + }, + PreferenceField { + rust_name: "marketplace_base_url", + key: "marketplaceBaseUrl", + class: PreferenceClass::DeviceProfile, + shape: PreferenceShape::Text, + reason: "device_bound", + }, + PreferenceField { + rust_name: "marketplace_dev_login", + key: "marketplaceDevLogin", + class: PreferenceClass::Excluded, + shape: PreferenceShape::Text, + reason: "local_consent_or_secret", + }, + PreferenceField { + rust_name: "android_insert_strategy", + key: "androidInsertStrategy", + class: PreferenceClass::DeviceProfile, + shape: PreferenceShape::Text, + reason: "device_bound", + }, + PreferenceField { + rust_name: "android_overlay_trigger", + key: "androidOverlayTrigger", + class: PreferenceClass::DeviceProfile, + shape: PreferenceShape::Text, + reason: "device_bound", + }, + PreferenceField { + rust_name: "android_overlay_activation_mode", + key: "androidOverlayActivationMode", + class: PreferenceClass::DeviceProfile, + shape: PreferenceShape::Text, + reason: "device_bound", + }, + PreferenceField { + rust_name: "android_overlay_left_swipe_action", + key: "androidOverlayLeftSwipeAction", + class: PreferenceClass::DeviceProfile, + shape: PreferenceShape::Text, + reason: "device_bound", + }, + PreferenceField { + rust_name: "android_overlay_cancel_swipe_direction", + key: "androidOverlayCancelSwipeDirection", + class: PreferenceClass::DeviceProfile, + shape: PreferenceShape::Text, + reason: "device_bound", + }, + PreferenceField { + rust_name: "android_overlay_gesture_actions", + key: "androidOverlayGestureActions", + class: PreferenceClass::DeviceProfile, + shape: PreferenceShape::Object, + reason: "device_bound", + }, + PreferenceField { + rust_name: "android_overlay_size_dp", + key: "androidOverlaySizeDp", + class: PreferenceClass::DeviceProfile, + shape: PreferenceShape::Unsigned32, + reason: "device_bound", + }, + PreferenceField { + rust_name: "splash_seen_version", + key: "splashSeenVersion", + class: PreferenceClass::Excluded, + shape: PreferenceShape::Text, + reason: "local_consent_or_secret", + }, +]; + +pub fn preference_field(key: &str) -> Option<&'static PreferenceField> { + PREFERENCE_FIELDS.iter().find(|field| field.key == key) +} + +pub fn excluded_extension_key(key: &str) -> bool { + let normalized: String = key + .chars() + .filter(|c| c.is_ascii_alphanumeric()) + .flat_map(char::to_lowercase) + .collect(); + [ + "oauth", + "githubtoken", + "accesstoken", + "refreshtoken", + "sessioncookie", + "privatekey", + "remotepin", + "remoteinputpin", + "syncpassword", + "synckey", + "synctoken", + "syncenabled", + "syncconsent", + "syncqueue", + "syncbaseline", + "synclock", + "permissiongrant", + ] + .iter() + .any(|item| normalized.contains(item)) +} + +pub fn validate_preference_value(field: &PreferenceField, value: &Value) -> DocumentResult<()> { + use PreferenceShape::*; + let valid = match field.shape { + Boolean => value.is_boolean(), + Unsigned8 => value.as_u64().is_some_and(|n| n <= u8::MAX.into()), + Unsigned16 => value.as_u64().is_some_and(|n| n <= u16::MAX.into()), + Unsigned32 => value.as_u64().is_some_and(|n| n <= u32::MAX.into()), + Number => value.as_f64().is_some_and(f64::is_finite), + Text => value.is_string(), + TextList => value + .as_array() + .is_some_and(|items| items.iter().all(Value::is_string)), + OptionalText => value.is_null() || value.is_string(), + OptionalUnsigned32 => { + value.is_null() || value.as_u64().is_some_and(|n| n <= u32::MAX.into()) + } + Object => value.is_object(), + OptionalObject => value.is_null() || value.is_object(), + ObjectList => value + .as_array() + .is_some_and(|items| items.iter().all(Value::is_object)), + }; + if !valid { + return Err(DocumentError::InvalidDocument); + } + let choices: &[&str] = match field.key { + "defaultMode" => &["raw", "light", "structured", "formal"], + "capsuleStyle" => &["siri", "classic", "typeless"], + "pipelineMode" => &["traditional", "multimodal"], + "chineseScriptPreference" => &["auto", "simplified", "traditional"], + "outputLanguagePreference" => &["auto", "zhCn", "zhTw", "en", "ja", "ko"], + "selectionPolishOutputMode" => &["directReplace", "previewConfirm"], + "selectionVoiceIntentMode" => &["prompt", "auto", "manual", "heuristic"], + "selectionVoiceManualIntent" => &["question", "edit"], + "selectionVoiceEditPlanFormat" => &["xml", "json"], + "updateChannel" => &["stable", "beta"], + "themeMode" => &["system", "light", "dark"], + _ => &[], + }; + if !choices.is_empty() && !value.as_str().is_some_and(|s| choices.contains(&s)) { + return Err(DocumentError::InvalidDocument); + } + if field.key == "enabledModes" + && !value.as_array().is_some_and(|items| { + items + .iter() + .all(|v| matches!(v.as_str(), Some("raw" | "light" | "structured" | "formal"))) + }) + { + return Err(DocumentError::InvalidDocument); + } + Ok(()) +} + +pub const ASR_ACCOUNTS: &[&str] = &[ + "asr.api_key", + "asr.endpoint", + "asr.model", + "asr.vocabulary_id", + "asr.advanced_config", + "volcengine.app_key", + "volcengine.access_key", + "volcengine.resource_id", + "volcengine.service", + "volcengine.auth_mode", + "volcengine.api_key", + "xfyun.app_id", + "xfyun.api_key", + "tencent_cloud.app_id", + "tencent_cloud.secret_id", + "tencent_cloud.secret_key", +]; +pub const LLM_ACCOUNTS: &[&str] = &[ + "ark.api_key", + "ark.model_id", + "ark.endpoint", + "ark.extra_headers", + "ark.temperature", + "ark.request_format", + "ark.messages_thinking", + "ark.max_tokens", + "ark.thinking_budget", +]; +pub const OMNI_ACCOUNTS: &[&str] = &[ + "omni.api_key", + "omni.endpoint", + "omni.model", + "omni.extra_headers", + "omni.temperature", +]; +pub fn credential_accounts(namespace: SyncNamespace) -> &'static [&'static str] { + match namespace { + SyncNamespace::Asr => ASR_ACCOUNTS, + SyncNamespace::Llm => LLM_ACCOUNTS, + SyncNamespace::Omni => OMNI_ACCOUNTS, + } +} + +pub fn is_device_channel(namespace: SyncNamespace, provider: &str) -> bool { + namespace == SyncNamespace::Asr + && matches!( + provider, + "apple-speech" + | "local-whisper" + | "local-qwen3" + | "local-qwen3-mlx" + | "local-qwen3-c" + | "foundry-local-whisper" + | "sherpa-onnx-local" + ) +} diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/restore.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/restore.rs new file mode 100644 index 00000000..c44dc72b --- /dev/null +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/restore.rs @@ -0,0 +1,527 @@ +//! Crash-recoverable logical restore with authenticated, encrypted before/after images. + +use std::fmt; +use std::sync::Arc; + +use futures_util::future::BoxFuture; +use serde::{Deserialize, Serialize}; +use zeroize::Zeroizing; + +use crate::cloud_sync_e2ee_protocol::{ + crypto::{self, DerivedKey}, + types::{DocumentSet, Revision}, +}; + +use super::export::export_snapshot; +use super::types::*; +use super::validate::{uuid_v4, validate_scope, validate_sync_documents}; + +pub struct RestorePlan { + desired: ValidatedSyncDocuments, + context: RestoreContext, +} +impl fmt::Debug for RestorePlan { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("RestorePlan([REDACTED])") + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct RestoreReceipt { + pub operation_id: String, + pub generation: Revision, + pub committed: bool, + pub journal_cleanup_pending: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RecoveryOutcome { + NoPending, + Committed(RestoreReceipt), + RolledBack(RestoreReceipt), +} + +#[derive(Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SealedJournal { + pub operation_id: String, + pub ciphertext: Vec, +} +impl fmt::Debug for SealedJournal { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("SealedJournal([REDACTED])") + } +} + +pub trait JournalStore: Send + Sync { + fn load(&self, scope: SyncScope) -> BoxFuture<'_, DocumentResult>>; + /// Save atomically and durably before returning. Never accept a plaintext journal DTO here. + fn save(&self, scope: SyncScope, journal: SealedJournal) -> BoxFuture<'_, DocumentResult<()>>; + fn clear(&self, scope: SyncScope) -> BoxFuture<'_, DocumentResult<()>>; +} + +pub trait JournalProtector: Send + Sync { + fn seal( + &self, + scope: &SyncScope, + operation_id: &str, + plaintext: &[u8], + ) -> DocumentResult>; + fn open( + &self, + scope: &SyncScope, + operation_id: &str, + ciphertext: &[u8], + ) -> DocumentResult>>; +} + +/// The key is a separate OS-protected local key, not a password or ordinary file secret. +pub struct CryptoJournalProtector { + key: Arc, +} +impl CryptoJournalProtector { + pub(crate) fn new(key: Arc) -> Self { + Self { key } + } +} +impl fmt::Debug for CryptoJournalProtector { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("CryptoJournalProtector([REDACTED])") + } +} +impl JournalProtector for CryptoJournalProtector { + fn seal( + &self, + scope: &SyncScope, + operation_id: &str, + plaintext: &[u8], + ) -> DocumentResult> { + crypto::seal_local(&self.key, &journal_aad(scope, operation_id)?, plaintext) + .map_err(|_| DocumentError::Locked) + } + fn open( + &self, + scope: &SyncScope, + operation_id: &str, + ciphertext: &[u8], + ) -> DocumentResult>> { + crypto::open_local(&self.key, &journal_aad(scope, operation_id)?, ciphertext) + .map_err(|_| DocumentError::Locked) + } +} + +fn journal_aad(scope: &SyncScope, operation_id: &str) -> DocumentResult> { + validate_scope(scope)?; + uuid_v4(operation_id)?; + serde_json::to_vec(&("openless-sync-restore-journal-v1", scope, operation_id)) + .map_err(|_| DocumentError::InvalidDocument) +} + +#[derive(Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +enum Phase { + Prepared, + Committed, + RolledBack, +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct Journal { + schema_version: u32, + scope: SyncScope, + operation_id: String, + observed_revision: Revision, + phase: Phase, + before: DocumentSet, + after: DocumentSet, + #[serde(default)] + local_before: SecretJson, +} + +pub fn prepare_sync_restore( + desired: ValidatedSyncDocuments, + context: RestoreContext, +) -> DocumentResult { + validate_scope(&context.scope)?; + uuid_v4(&context.operation_id)?; + if desired.observed_revision() != context.observed_revision + || context.target_device.id != context.scope.device_id + { + return Err(DocumentError::StalePreview); + } + Ok(RestorePlan { desired, context }) +} + +/// No caller cancellation branch is allowed after the durable journal exists. +/// If the task/process nevertheless dies, recovery chooses the durable decision below. +pub async fn apply_sync_restore( + plan: RestorePlan, + backend: &dyn RestoreBackend, + storage: &dyn JournalStore, + protector: &dyn JournalProtector, +) -> DocumentResult { + if storage.load(plan.context.scope.clone()).await?.is_some() { + return Err(DocumentError::RecoveryRequired); + } + let mut lease = backend + .acquire( + plan.context.scope.clone(), + Some(plan.context.local_generation), + ) + .await?; + let before = export_snapshot(lease.capture().await?)?; + if before.generation != plan.context.local_generation { + return Err(DocumentError::StalePreview); + } + let mut after = plan.desired.set.clone(); + // Foreign device profiles are preserved, but importing them cannot erase the receiving + // device's own profile or make its paths/programs active. + let local_profile = before.documents.set.documents.iter().find(|doc| { + doc.kind == crate::cloud_sync_e2ee_protocol::types::DocumentKind::DeviceProfile + && doc.id == plan.context.target_device.id + }); + if let Some(profile) = local_profile { + if !after + .documents + .iter() + .any(|doc| doc.kind == profile.kind && doc.id == profile.id) + { + after.documents.push(profile.clone()); + } + } + materialize_receiver_fields( + &mut after, + before.documents.documents(), + &plan.context.target_device, + )?; + preserve_applicable_asr_selection( + &mut after, + before.documents.documents(), + &plan.context.target_device.id, + )?; + after.source_device = plan.context.target_device.clone(); + let after = validate_sync_documents(after, plan.context.observed_revision)?; + lease.preflight(&after).await?; + let mut journal = Journal { + schema_version: 1, + scope: plan.context.scope.clone(), + operation_id: plan.context.operation_id.clone(), + observed_revision: plan.context.observed_revision, + phase: Phase::Prepared, + before: before.documents.set.clone(), + after: after.set.clone(), + local_before: lease.capture_rollback_state().await?, + }; + lease.mark_recovery_pending(&journal.operation_id)?; + save_journal(&journal, storage, protector).await?; + lease.mark_journal_ready(&journal.operation_id)?; + if replace_and_verify(lease.as_mut(), after).await.is_err() { + let before = validate_sync_documents(journal.before.clone(), journal.observed_revision)?; + if replace_and_verify(lease.as_mut(), before).await.is_err() { + return Err(DocumentError::RecoveryRequired); + } + restore_local_rollback_state(lease.as_mut(), &journal.local_before) + .await + .map_err(|_| DocumentError::RecoveryRequired)?; + journal.phase = Phase::RolledBack; + save_journal(&journal, storage, protector) + .await + .map_err(|_| DocumentError::RecoveryRequired)?; + let _ = finish(&journal, lease.as_mut(), storage).await?; + return Err(DocumentError::RestoreRolledBack); + } + journal.phase = Phase::Committed; + // A failed/unknown commit-decision write cannot safely be reinterpreted as rollback. + save_journal(&journal, storage, protector) + .await + .map_err(|_| DocumentError::RecoveryRequired)?; + finish(&journal, lease.as_mut(), storage).await +} + +fn materialize_receiver_fields( + after: &mut DocumentSet, + before: &DocumentSet, + target: &crate::cloud_sync_e2ee_protocol::types::SourceDevice, +) -> DocumentResult<()> { + use crate::cloud_sync_e2ee_protocol::types::DocumentKind; + for old in &before.documents { + let required = matches!( + old.kind, + DocumentKind::Preferences | DocumentKind::UiPreferences + ) || old.kind == DocumentKind::VocabularyPresets + && old.id.starts_with("builtin:") + || old.kind == DocumentKind::StylePacks && old.value["pack"]["kind"] == "builtin"; + if !required + || after + .documents + .iter() + .any(|doc| doc.kind == old.kind && doc.id == old.id) + { + continue; + } + let deleted = after + .tombstones + .iter() + .any(|mark| mark.kind == old.kind && mark.id == old.id); + if deleted { + let native_required = old.kind == DocumentKind::Preferences + && super::registry::preference_field(&old.id).is_some() + || old.kind == DocumentKind::UiPreferences + && matches!(old.id.as_str(), "locale" | "fontScale") + || old.kind == DocumentKind::VocabularyPresets + || old.kind == DocumentKind::StylePacks; + if native_required { + return Err(DocumentError::Unsupported); + } + } else { + after.documents.push(old.clone()); + } + } + if let Some(profile) = after + .documents + .iter_mut() + .find(|doc| doc.kind == DocumentKind::DeviceProfile && doc.id == target.id) + { + let mut value: DeviceProfileRecord = serde_json::from_value(profile.value.clone()) + .map_err(|_| DocumentError::InvalidDocument)?; + if value.device.os != target.os || value.device.arch != target.arch { + return Err(DocumentError::Unsupported); + } + if let Some(previous) = before + .documents + .iter() + .find(|doc| doc.kind == DocumentKind::DeviceProfile && doc.id == target.id) + { + let previous: DeviceProfileRecord = serde_json::from_value(previous.value.clone()) + .map_err(|_| DocumentError::InvalidDocument)?; + let preferences = value + .preferences + .expose_mut() + .as_object_mut() + .ok_or(DocumentError::InvalidDocument)?; + for (key, old) in previous + .preferences + .expose() + .as_object() + .ok_or(DocumentError::InvalidDocument)? + { + preferences + .entry(key.clone()) + .or_insert_with(|| old.clone()); + } + } + value.device = target.clone(); + profile.value = serde_json::to_value(value).map_err(|_| DocumentError::InvalidDocument)?; + } + Ok(()) +} + +fn preserve_applicable_asr_selection( + after: &mut DocumentSet, + before: &DocumentSet, + target_device_id: &str, +) -> DocumentResult<()> { + use crate::cloud_sync_e2ee_protocol::types::DocumentKind; + let foreign_local_active = after + .documents + .iter() + .filter(|doc| doc.kind == DocumentKind::DeviceProfile && doc.id != target_device_id) + .try_fold(false, |found, doc| { + let profile: DeviceProfileRecord = serde_json::from_value(doc.value.clone()) + .map_err(|_| DocumentError::InvalidDocument)?; + Ok::<_, DocumentError>( + found + || profile + .channels + .iter() + .any(|channel| channel.namespace == SyncNamespace::Asr && channel.active), + ) + })?; + let mut active = std::collections::BTreeSet::new(); + for doc in after + .documents + .iter() + .filter(|doc| doc.kind == DocumentKind::Channels) + { + let channel: ChannelRecord = serde_json::from_value(doc.value.clone()) + .map_err(|_| DocumentError::InvalidDocument)?; + if channel.active { + active.insert(channel.namespace); + } + } + if foreign_local_active && !active.contains(&SyncNamespace::Asr) { + if let Some(current) = before.documents.iter().find(|doc| { + doc.kind == DocumentKind::Channels + && doc.value["namespace"] == "asr" + && doc.value["active"] == true + && !after.tombstones.iter().any(|mark| { + mark.id == doc.id + && matches!( + mark.kind, + DocumentKind::Channels | DocumentKind::ProviderCredentials + ) + }) + }) { + if let Some(existing) = after + .documents + .iter_mut() + .find(|doc| doc.kind == DocumentKind::Channels && doc.id == current.id) + { + existing.value["active"] = serde_json::Value::Bool(true); + existing.value["enabled"] = serde_json::Value::Bool(true); + } else { + after.documents.push(current.clone()); + if let Some(credential) = before.documents.iter().find(|doc| { + doc.kind == DocumentKind::ProviderCredentials && doc.id == current.id + }) { + after.documents.push(credential.clone()); + } + } + active.insert(SyncNamespace::Asr); + } + } + if let Some(profile) = after + .documents + .iter_mut() + .find(|doc| doc.kind == DocumentKind::DeviceProfile && doc.id == target_device_id) + { + let mut value: DeviceProfileRecord = serde_json::from_value(profile.value.clone()) + .map_err(|_| DocumentError::InvalidDocument)?; + for channel in &mut value.channels { + if active.contains(&channel.namespace) { + channel.active = false; + } + } + profile.value = serde_json::to_value(value).map_err(|_| DocumentError::InvalidDocument)?; + } + Ok(()) +} + +pub async fn recover_sync_restore( + scope: SyncScope, + backend: &dyn RestoreBackend, + storage: &dyn JournalStore, + protector: &dyn JournalProtector, +) -> DocumentResult { + validate_scope(&scope)?; + let mut lease = backend.acquire(scope.clone(), None).await?; + let Some(sealed) = storage.load(scope.clone()).await? else { + lease.recover_without_journal()?; + return Ok(RecoveryOutcome::NoPending); + }; + uuid_v4(&sealed.operation_id)?; + if sealed.ciphertext.len() > MAX_JOURNAL_BYTES + 128 { + return Err(DocumentError::RecoveryRequired); + } + let plaintext = protector.open(&scope, &sealed.operation_id, &sealed.ciphertext)?; + if plaintext.len() > MAX_JOURNAL_BYTES { + return Err(DocumentError::RecoveryRequired); + } + let mut journal: Journal = + serde_json::from_slice(&plaintext).map_err(|_| DocumentError::RecoveryRequired)?; + if journal.schema_version != 1 + || journal.scope != scope + || journal.operation_id != sealed.operation_id + { + return Err(DocumentError::RecoveryRequired); + } + let before = validate_sync_documents(journal.before.clone(), journal.observed_revision) + .map_err(|_| DocumentError::RecoveryRequired)?; + let after = validate_sync_documents(journal.after.clone(), journal.observed_revision) + .map_err(|_| DocumentError::RecoveryRequired)?; + if let Some(mut receipt) = lease.completed_restore(&journal.operation_id)? { + // New user mutations may exist after this receipt. Never replay an obsolete image. + receipt.journal_cleanup_pending = storage.clear(scope).await.is_err(); + return Ok(if receipt.committed { + RecoveryOutcome::Committed(receipt) + } else { + RecoveryOutcome::RolledBack(receipt) + }); + } + lease.mark_recovery_pending(&journal.operation_id)?; + lease.mark_journal_ready(&journal.operation_id)?; + let committed = journal.phase == Phase::Committed; + replace_and_verify(lease.as_mut(), if committed { after } else { before }) + .await + .map_err(|_| DocumentError::RecoveryRequired)?; + if !committed { + restore_local_rollback_state(lease.as_mut(), &journal.local_before) + .await + .map_err(|_| DocumentError::RecoveryRequired)?; + journal.phase = Phase::RolledBack; + save_journal(&journal, storage, protector) + .await + .map_err(|_| DocumentError::RecoveryRequired)?; + } + let receipt = finish(&journal, lease.as_mut(), storage).await?; + Ok(if committed { + RecoveryOutcome::Committed(receipt) + } else { + RecoveryOutcome::RolledBack(receipt) + }) +} + +async fn save_journal( + journal: &Journal, + storage: &dyn JournalStore, + protector: &dyn JournalProtector, +) -> DocumentResult<()> { + let plaintext = + Zeroizing::new(serde_json::to_vec(journal).map_err(|_| DocumentError::InvalidDocument)?); + if plaintext.len() > MAX_JOURNAL_BYTES { + return Err(DocumentError::PayloadTooLarge); + } + let ciphertext = protector.seal(&journal.scope, &journal.operation_id, &plaintext)?; + if ciphertext.is_empty() || ciphertext.len() > MAX_JOURNAL_BYTES + 128 { + return Err(DocumentError::JournalUnavailable); + } + storage + .save( + journal.scope.clone(), + SealedJournal { + operation_id: journal.operation_id.clone(), + ciphertext, + }, + ) + .await +} + +async fn restore_local_rollback_state( + lease: &mut dyn RestoreLease, + expected: &SecretJson, +) -> DocumentResult<()> { + lease.restore_rollback_state(expected).await?; + if lease.capture_rollback_state().await? != *expected { + return Err(DocumentError::RecoveryRequired); + } + Ok(()) +} + +async fn replace_and_verify( + lease: &mut dyn RestoreLease, + expected: ValidatedSyncDocuments, +) -> DocumentResult<()> { + lease.replace(expected.clone()).await?; + lease.reload().await?; + let actual = export_snapshot(lease.capture().await?)?.documents; + // Source metadata and capture time differ; logical records and deletions must not. + if actual.set.documents != expected.set.documents + || actual.set.tombstones != expected.set.tombstones + { + return Err(DocumentError::RecoveryRequired); + } + Ok(()) +} + +async fn finish( + journal: &Journal, + lease: &mut dyn RestoreLease, + storage: &dyn JournalStore, +) -> DocumentResult { + let mut receipt = + lease.finish_restore(&journal.operation_id, journal.phase == Phase::Committed)?; + receipt.journal_cleanup_pending = storage.clear(journal.scope.clone()).await.is_err(); + Ok(receipt) +} diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/restore_tests.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/restore_tests.rs new file mode 100644 index 00000000..66014091 --- /dev/null +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/restore_tests.rs @@ -0,0 +1,457 @@ +use std::collections::VecDeque; +use std::sync::{ + atomic::{AtomicBool, AtomicUsize, Ordering}, + Arc, Mutex, +}; + +use futures_util::future::BoxFuture; +use serde_json::{json, Value}; +use zeroize::Zeroizing; + +use super::*; +use crate::cloud_sync_e2ee_protocol::{ + crypto::DerivedKey, + types::{DocumentKind, Revision}, +}; + +struct State { + data: ValidatedSyncDocuments, + generation: Revision, + failures: VecDeque, + pending: bool, + journal_ready: bool, + receipt: Option, + writes: usize, +} + +#[derive(Clone)] +struct Backend(Arc>); +struct Lease(Backend); + +impl RestoreBackend for Backend { + fn acquire( + &self, + _scope: SyncScope, + expected: Option, + ) -> BoxFuture<'_, DocumentResult>> { + Box::pin(async move { + if expected.is_some_and(|g| g != self.0.lock().unwrap().generation) { + return Err(DocumentError::StalePreview); + } + Ok(Box::new(Lease(self.clone())) as Box) + }) + } +} + +fn captured(state: &State) -> ExportSnapshot { + let mut snapshot = super::tests::snapshot(); + snapshot.generation = state.generation; + snapshot.base_revision = state.data.observed_revision(); + snapshot.preferences = SecretJson::default(); + snapshot.ui_preferences = SecretJson::default(); + for doc in &state.data.documents().documents { + match doc.kind { + DocumentKind::Preferences => { + snapshot + .preferences + .expose_mut() + .as_object_mut() + .unwrap() + .insert(doc.id.clone(), doc.value.clone()); + } + DocumentKind::UiPreferences => { + snapshot + .ui_preferences + .expose_mut() + .as_object_mut() + .unwrap() + .insert(doc.id.clone(), doc.value.clone()); + } + DocumentKind::Channels => snapshot + .channels + .push(serde_json::from_value(doc.value.clone()).unwrap()), + DocumentKind::ProviderCredentials => snapshot + .provider_credentials + .push(serde_json::from_value(doc.value.clone()).unwrap()), + DocumentKind::Dictionary => { + snapshot.dictionary.push(SecretJson::new(doc.value.clone())) + } + DocumentKind::Corrections => snapshot + .corrections + .push(SecretJson::new(doc.value.clone())), + DocumentKind::VocabularyPresets => snapshot + .vocabulary_presets + .push(serde_json::from_value(doc.value.clone()).unwrap()), + DocumentKind::StylePacks => snapshot + .style_packs + .push(serde_json::from_value(doc.value.clone()).unwrap()), + DocumentKind::History => snapshot.history.push(SecretJson::new(doc.value.clone())), + DocumentKind::Activity => snapshot + .activity + .push(serde_json::from_value(doc.value.clone()).unwrap()), + DocumentKind::DeviceProfile if doc.id == snapshot.source_device.id => { + let profile: DeviceProfileRecord = + serde_json::from_value(doc.value.clone()).unwrap(); + for (key, value) in profile.preferences.expose().as_object().unwrap() { + snapshot + .preferences + .expose_mut() + .as_object_mut() + .unwrap() + .insert(key.clone(), value.clone()); + } + snapshot.channels.extend(profile.channels); + snapshot + .provider_credentials + .extend(profile.provider_credentials); + snapshot.window_positions = profile.window_positions; + } + DocumentKind::DeviceProfile => snapshot.retained_documents.push(doc.clone()), + } + } + snapshot.tombstones = state.data.documents().tombstones.clone(); + snapshot +} + +impl RestoreLease for Lease { + fn capture(&mut self) -> BoxFuture<'_, DocumentResult> { + Box::pin(async move { Ok(captured(&self.0 .0.lock().unwrap())) }) + } + fn replace(&mut self, desired: ValidatedSyncDocuments) -> BoxFuture<'_, DocumentResult<()>> { + Box::pin(async move { + let mut state = self.0 .0.lock().unwrap(); + state.writes += 1; + if state.failures.pop_front().unwrap_or(false) { + // A first file became visible; the following credential write failed. + let value = desired + .documents() + .documents + .iter() + .find(|doc| doc.id == "themeMode") + .unwrap() + .value + .clone(); + state + .data + .set + .documents + .iter_mut() + .find(|doc| doc.id == "themeMode") + .unwrap() + .value = value; + return Err(DocumentError::CaptureFailed); + } + state.data = desired; + Ok(()) + }) + } + fn reload(&mut self) -> BoxFuture<'_, DocumentResult<()>> { + Box::pin(async { Ok(()) }) + } + fn mark_recovery_pending(&mut self, _operation: &str) -> DocumentResult<()> { + self.0 .0.lock().unwrap().pending = true; + Ok(()) + } + fn mark_journal_ready(&mut self, _operation: &str) -> DocumentResult<()> { + self.0 .0.lock().unwrap().journal_ready = true; + Ok(()) + } + fn recover_without_journal(&mut self) -> DocumentResult<()> { + let mut state = self.0 .0.lock().unwrap(); + if state.pending && state.journal_ready { + return Err(DocumentError::RecoveryRequired); + } + state.pending = false; + Ok(()) + } + fn completed_restore(&self, operation: &str) -> DocumentResult> { + Ok(self + .0 + .0 + .lock() + .unwrap() + .receipt + .as_ref() + .filter(|r| r.operation_id == operation) + .cloned()) + } + fn finish_restore( + &mut self, + operation: &str, + committed: bool, + ) -> DocumentResult { + let mut state = self.0 .0.lock().unwrap(); + if let Some(receipt) = &state.receipt { + return Ok(receipt.clone()); + } + if committed { + state.generation = state.generation.checked_next().unwrap(); + } + let receipt = RestoreReceipt { + operation_id: operation.into(), + generation: state.generation, + committed, + journal_cleanup_pending: false, + }; + state.pending = false; + state.receipt = Some(receipt.clone()); + Ok(receipt) + } +} + +#[derive(Default)] +struct Storage { + value: Mutex>, + saves: AtomicUsize, + fail_before: AtomicUsize, + fail_after: AtomicUsize, + fail_clear: AtomicBool, +} +impl JournalStore for Storage { + fn load(&self, _scope: SyncScope) -> BoxFuture<'_, DocumentResult>> { + Box::pin(async { Ok(self.value.lock().unwrap().clone()) }) + } + fn save(&self, _scope: SyncScope, value: SealedJournal) -> BoxFuture<'_, DocumentResult<()>> { + Box::pin(async move { + let number = self.saves.fetch_add(1, Ordering::SeqCst) + 1; + if self.fail_before.load(Ordering::SeqCst) == number { + return Err(DocumentError::JournalUnavailable); + } + *self.value.lock().unwrap() = Some(value); + if self.fail_after.load(Ordering::SeqCst) == number { + return Err(DocumentError::JournalUnavailable); + } + Ok(()) + }) + } + fn clear(&self, _scope: SyncScope) -> BoxFuture<'_, DocumentResult<()>> { + Box::pin(async { + if self.fail_clear.load(Ordering::SeqCst) { + Err(DocumentError::JournalUnavailable) + } else { + *self.value.lock().unwrap() = None; + Ok(()) + } + }) + } +} + +fn context() -> RestoreContext { + RestoreContext { + scope: SyncScope { + service_origin: "https://sync.example.test".into(), + owner_github_id: "42".into(), + vault_id: "12345678-1234-4234-8234-123456789abc".into(), + key_id: "22345678-1234-4234-8234-123456789abc".into(), + device_id: "device-a".into(), + }, + operation_id: "32345678-1234-4234-8234-123456789abc".into(), + observed_revision: Revision::new(3), + local_generation: Revision::new(7), + target_device: super::tests::source(), + } +} + +fn setup(failures: &[bool]) -> (Backend, RestorePlan, Storage, CryptoJournalProtector) { + let data = export_snapshot(super::tests::channel_snapshot()) + .unwrap() + .documents; + let mut desired = data.documents().clone(); + desired + .documents + .iter_mut() + .find(|doc| doc.id == "themeMode") + .unwrap() + .value = json!("dark"); + let desired = validate_sync_documents(desired, Revision::new(3)).unwrap(); + let plan = prepare_sync_restore(desired, context()).unwrap(); + let backend = Backend(Arc::new(Mutex::new(State { + data, + generation: Revision::new(7), + failures: failures.iter().copied().collect(), + pending: false, + journal_ready: false, + receipt: None, + writes: 0, + }))); + let protector = CryptoJournalProtector::new(Arc::new(DerivedKey::from_secret_bytes( + Zeroizing::new([37; 32]), + ))); + (backend, plan, Storage::default(), protector) +} + +fn theme(backend: &Backend) -> Value { + backend + .0 + .lock() + .unwrap() + .data + .documents() + .documents + .iter() + .find(|doc| doc.id == "themeMode") + .unwrap() + .value + .clone() +} + +#[tokio::test] +async fn restore_verifies_all_logical_data_and_advances_one_generation() { + let (backend, plan, storage, protector) = setup(&[]); + let receipt = apply_sync_restore(plan, &backend, &storage, &protector) + .await + .unwrap(); + assert!(receipt.committed); + assert_eq!(receipt.generation.get(), 8); + assert_eq!(theme(&backend), "dark"); + assert!(!backend.0.lock().unwrap().pending); + assert!(storage.value.lock().unwrap().is_none()); +} + +#[tokio::test] +async fn credential_failure_rolls_back_the_already_written_file_without_false_success() { + let (backend, plan, storage, protector) = setup(&[true, false]); + assert_eq!( + apply_sync_restore(plan, &backend, &storage, &protector) + .await + .unwrap_err(), + DocumentError::RestoreRolledBack + ); + assert_eq!(theme(&backend), "system"); + assert_eq!(backend.0.lock().unwrap().generation.get(), 7); + assert!(storage.value.lock().unwrap().is_none()); +} + +#[tokio::test] +async fn rollback_failure_leaves_only_encrypted_material_and_recovery_resumes_it() { + let (backend, plan, storage, protector) = setup(&[true, true]); + assert_eq!( + apply_sync_restore(plan, &backend, &storage, &protector) + .await + .unwrap_err(), + DocumentError::RecoveryRequired + ); + let bytes = storage + .value + .lock() + .unwrap() + .as_ref() + .unwrap() + .ciphertext + .clone(); + assert!(!bytes + .windows(b"fixture-secret-original".len()) + .any(|part| part == b"fixture-secret-original")); + assert!(backend.0.lock().unwrap().pending); + let result = recover_sync_restore(context().scope, &backend, &storage, &protector) + .await + .unwrap(); + assert!(matches!(result, RecoveryOutcome::RolledBack(_))); + assert_eq!(theme(&backend), "system"); +} + +#[tokio::test] +async fn unknown_commit_journal_write_is_resolved_by_its_durable_decision() { + for persisted in [false, true] { + let (backend, plan, storage, protector) = setup(&[]); + if persisted { + storage.fail_after.store(2, Ordering::SeqCst); + } else { + storage.fail_before.store(2, Ordering::SeqCst); + } + assert_eq!( + apply_sync_restore(plan, &backend, &storage, &protector) + .await + .unwrap_err(), + DocumentError::RecoveryRequired + ); + let recovered = recover_sync_restore(context().scope, &backend, &storage, &protector) + .await + .unwrap(); + assert_eq!( + matches!(recovered, RecoveryOutcome::Committed(_)), + persisted + ); + assert_eq!(theme(&backend), if persisted { "dark" } else { "system" }); + } +} + +#[tokio::test] +async fn obsolete_committed_journal_never_replays_over_new_user_data() { + let (backend, plan, storage, protector) = setup(&[]); + storage.fail_clear.store(true, Ordering::SeqCst); + assert!( + apply_sync_restore(plan, &backend, &storage, &protector) + .await + .unwrap() + .journal_cleanup_pending + ); + backend + .0 + .lock() + .unwrap() + .data + .set + .documents + .iter_mut() + .find(|doc| doc.id == "themeMode") + .unwrap() + .value = json!("light"); + let writes = backend.0.lock().unwrap().writes; + storage.fail_clear.store(false, Ordering::SeqCst); + recover_sync_restore(context().scope, &backend, &storage, &protector) + .await + .unwrap(); + assert_eq!(theme(&backend), "light"); + assert_eq!(backend.0.lock().unwrap().writes, writes); +} + +#[tokio::test] +async fn wrong_scope_or_ciphertext_cannot_mutate_recovery_data() { + let (backend, plan, storage, protector) = setup(&[true, true]); + let _ = apply_sync_restore(plan, &backend, &storage, &protector).await; + let before = theme(&backend); + let writes = backend.0.lock().unwrap().writes; + let mut wrong = context().scope; + wrong.owner_github_id = "43".into(); + assert_eq!( + recover_sync_restore(wrong, &backend, &storage, &protector) + .await + .unwrap_err(), + DocumentError::Locked + ); + { + let mut stored = storage.value.lock().unwrap(); + let data = &mut stored.as_mut().unwrap().ciphertext; + let last = data.len() - 1; + data[last] ^= 1; + } + assert_eq!( + recover_sync_restore(context().scope, &backend, &storage, &protector) + .await + .unwrap_err(), + DocumentError::Locked + ); + assert_eq!(theme(&backend), before); + assert_eq!(backend.0.lock().unwrap().writes, writes); +} + +#[tokio::test] +async fn journal_prepare_failure_never_starts_a_data_write() { + let (backend, plan, storage, protector) = setup(&[]); + storage.fail_before.store(1, Ordering::SeqCst); + assert_eq!( + apply_sync_restore(plan, &backend, &storage, &protector) + .await + .unwrap_err(), + DocumentError::JournalUnavailable + ); + assert_eq!(backend.0.lock().unwrap().writes, 0); + assert!(matches!( + recover_sync_restore(context().scope, &backend, &storage, &protector) + .await + .unwrap(), + RecoveryOutcome::NoPending + )); + assert!(!backend.0.lock().unwrap().pending); +} diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/tests.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/tests.rs new file mode 100644 index 00000000..b6c6fb57 --- /dev/null +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/tests.rs @@ -0,0 +1,604 @@ +use serde_json::{json, Value}; +use std::collections::BTreeMap; + +use super::*; +use crate::cloud_sync_e2ee_protocol::types::{ + DocumentKind, LogicalDocument, Revision, SourceDevice, Tombstone, +}; + +pub(super) fn source() -> SourceDevice { + SourceDevice { + id: "device-a".into(), + os: "macos".into(), + arch: "aarch64".into(), + app_version: "2.0.0-Beta.3".into(), + } +} + +pub(super) fn snapshot() -> ExportSnapshot { + ExportSnapshot { + source_device: source(), + exported_at: "2026-09-26T00:00:00Z".into(), + generation: Revision::new(7), + base_revision: Revision::new(3), + preferences: SecretJson::new(json!({"themeMode":"system","showCapsule":true})), + ui_preferences: ui_preferences("en", "medium"), + channels: Vec::new(), + provider_credentials: Vec::new(), + dictionary: Vec::new(), + vocabulary_presets: Vec::new(), + corrections: Vec::new(), + style_packs: Vec::new(), + history: Vec::new(), + activity: Vec::new(), + window_positions: Vec::new(), + retained_documents: Vec::new(), + tombstones: Vec::new(), + } +} + +pub(super) fn sample() -> ValidatedSyncDocuments { + export_snapshot(snapshot()).unwrap().documents +} + +fn change( + set: &ValidatedSyncDocuments, + kind: DocumentKind, + id: &str, + value: Value, +) -> ValidatedSyncDocuments { + let mut set = set.documents().clone(); + if let Some(doc) = set + .documents + .iter_mut() + .find(|doc| doc.kind == kind && doc.id == id) + { + doc.value = value; + } else { + set.documents.push(LogicalDocument { + id: id.into(), + kind, + schema_version: 1, + value, + }); + } + validate_sync_documents(set, Revision::new(4)).unwrap() +} + +fn dictionary(id: &str, text: &str) -> Value { + json!({"id":id,"phrase":text,"note":null,"enabled":true,"hits":0,"createdAt":"2026-09-26T00:00:00Z"}) +} + +pub(super) fn channel_snapshot() -> ExportSnapshot { + let mut data = snapshot(); + data.channels.push(ChannelRecord { + id: "llm-one".into(), + namespace: SyncNamespace::Llm, + provider_type: "requesty".into(), + name: "original".into(), + enabled: true, + order: 0, + active: true, + }); + data.provider_credentials.push(ProviderCredentialRecord { + channel_id: "llm-one".into(), + namespace: SyncNamespace::Llm, + accounts: BTreeMap::from([("ark.api_key".into(), "fixture-secret-original".into())]), + }); + data +} + +#[test] +fn all_current_preference_fields_have_an_explicit_registration() { + let source = include_str!("../shared_types.rs"); + let fields = source + .split("pub struct UserPreferences {") + .nth(1) + .unwrap() + .split("\n}\n") + .next() + .unwrap(); + let names: std::collections::BTreeSet<_> = fields + .lines() + .filter_map(|line| { + line.trim() + .strip_prefix("pub ") + .and_then(|s| s.split_once(':')) + .map(|(name, _)| name) + }) + .collect(); + let registered: std::collections::BTreeSet<_> = registry::PREFERENCE_FIELDS + .iter() + .map(|field| field.rust_name) + .collect(); + assert_eq!( + names, registered, + "new persistent fields require a reviewed sync classification" + ); + assert_eq!(registered.len(), registry::PREFERENCE_FIELDS.len()); +} + +#[test] +fn controlled_export_keeps_real_service_keys_but_excludes_local_secrets_and_grants() { + let mut data = channel_snapshot(); + data.preferences = SecretJson::new( + json!({"themeMode":"dark","remoteInputPin":"fixture-pin-never-export","codingAgentEnabled":true,"cursorContextEnabled":true,"marketplaceDevLogin":"fixture-login","splashSeenVersion":"2","activeLlmProvider":"llm-one","codingAgentExe":"/fixture/never-run","futureAppearance":{"accent":"violet"}}), + ); + let exported = export_snapshot(data).unwrap(); + let raw = serde_json::to_string(exported.documents.documents()).unwrap(); + assert!( + raw.contains("fixture-secret-original"), + "ordinary service credentials must not be masked" + ); + assert!(!raw.contains("fixture-pin-never-export")); + assert!(!raw.contains("codingAgentEnabled")); + assert!(!raw.contains("cursorContextEnabled")); + assert!(!raw.contains("fixture-login")); + assert!(!raw.contains("activeLlmProvider")); + assert!(raw.contains("futureAppearance")); + let profile = exported + .documents + .documents() + .documents + .iter() + .find(|doc| doc.kind == DocumentKind::DeviceProfile) + .unwrap(); + assert_eq!( + profile.value["preferences"]["codingAgentExe"], + "/fixture/never-run" + ); + assert!(!format!("{:?}", exported).contains("fixture-secret-original")); +} + +#[test] +fn local_asr_channels_are_archived_with_their_credentials_not_globally_activated() { + let mut data = snapshot(); + data.channels.push(ChannelRecord { + id: "local-one".into(), + namespace: SyncNamespace::Asr, + provider_type: "foundry-local-whisper".into(), + name: "Windows model".into(), + enabled: true, + order: 0, + active: true, + }); + data.provider_credentials.push(ProviderCredentialRecord { + channel_id: "local-one".into(), + namespace: SyncNamespace::Asr, + accounts: BTreeMap::from([("asr.model".into(), "fixture-model".into())]), + }); + let exported = export_snapshot(data).unwrap(); + assert!(!exported + .documents + .documents() + .documents + .iter() + .any(|doc| matches!( + doc.kind, + DocumentKind::Channels | DocumentKind::ProviderCredentials + ))); + let profile = exported + .documents + .documents() + .documents + .iter() + .find(|doc| doc.kind == DocumentKind::DeviceProfile) + .unwrap(); + assert_eq!(profile.value["channels"][0]["id"], "local-one"); + assert_eq!( + profile.value["providerCredentials"][0]["accounts"]["asr.model"], + "fixture-model" + ); +} + +#[test] +fn history_never_claims_that_remote_audio_exists_locally() { + let mut data = snapshot(); + data.history.push(SecretJson::new(json!({"id":"history-one","createdAt":"2026-09-26T00:00:00Z","source":"quick_note","rawTranscript":"note","finalText":"note","mode":"raw","insertStatus":"notRequested","hasAudioRecording":true}))); + let exported = export_snapshot(data).unwrap(); + let row = exported + .documents + .documents() + .documents + .iter() + .find(|doc| doc.kind == DocumentKind::History) + .unwrap(); + assert_eq!(row.value["hasAudioRecording"], false); + assert_eq!(row.value["source"], "quick_note"); +} + +#[test] +fn validation_rejects_duplicate_ids_versions_dangling_credentials_and_excluded_preferences() { + let base = sample(); + let mut invalid = base.documents().clone(); + invalid.documents.push(invalid.documents[0].clone()); + assert_eq!( + validate_sync_documents(invalid, Revision::new(3)).unwrap_err(), + DocumentError::DuplicateId + ); + let mut invalid = base.documents().clone(); + invalid.documents[0].schema_version = 2; + assert_eq!( + validate_sync_documents(invalid, Revision::new(3)).unwrap_err(), + DocumentError::Unsupported + ); + let mut invalid = base.documents().clone(); + invalid.documents.push(LogicalDocument { + id: "remoteInputPin".into(), + kind: DocumentKind::Preferences, + schema_version: 1, + value: json!("forbidden"), + }); + assert_eq!( + validate_sync_documents(invalid, Revision::new(3)).unwrap_err(), + DocumentError::ExcludedField + ); + let mut invalid = export_snapshot(channel_snapshot()) + .unwrap() + .documents + .documents() + .clone(); + invalid + .documents + .retain(|doc| doc.kind != DocumentKind::Channels); + assert_eq!( + validate_sync_documents(invalid, Revision::new(3)).unwrap_err(), + DocumentError::InvalidReference + ); +} + +#[test] +fn credential_batch_validation_rejects_oauth_cookie_accounts_and_omni_id_aliases() { + let mut data = channel_snapshot(); + data.provider_credentials[0] + .accounts + .insert("github.access_token".into(), "fixture-oauth".into()); + assert_eq!( + validate_credential_set(&data.channels, &data.provider_credentials).unwrap_err(), + DocumentError::ExcludedField + ); + data.provider_credentials[0] + .accounts + .remove("github.access_token"); + data.channels[0].namespace = SyncNamespace::Omni; + data.provider_credentials[0].namespace = SyncNamespace::Omni; + data.provider_credentials[0].accounts.clear(); + assert_eq!( + validate_credential_set(&data.channels, &data.provider_credentials).unwrap_err(), + DocumentError::InvalidReference + ); + data.channels[0].id = "requesty".into(); + data.provider_credentials[0].channel_id = "requesty".into(); + validate_credential_set(&data.channels, &data.provider_credentials).unwrap(); + let mut cookie = channel_snapshot(); + cookie.provider_credentials[0].accounts.insert( + "ark.extra_headers".into(), + r#"{"Cookie":"fixture-session"}"#.into(), + ); + assert_eq!( + export_snapshot(cookie).unwrap_err(), + DocumentError::ExcludedField + ); +} + +#[test] +fn unknown_preference_values_roundtrip_and_merge_without_application() { + let base = sample(); + let local = change( + &base, + DocumentKind::Preferences, + "futureAppearance", + json!({"value":"preserve-me"}), + ); + let remote = change(&base, DocumentKind::Preferences, "themeMode", json!("dark")); + let merged = diff_sync_documents(Some(&base), &local, &remote) + .unwrap() + .resolve(&[]) + .unwrap(); + assert!(merged + .documents() + .documents + .iter() + .any(|doc| doc.id == "futureAppearance" && doc.value["value"] == "preserve-me")); +} + +#[test] +fn independent_stable_ids_merge_but_simultaneous_same_key_changes_do_not() { + let base = sample(); + let local = change( + &base, + DocumentKind::Dictionary, + "local-id", + dictionary("local-id", "same name"), + ); + let remote = change( + &base, + DocumentKind::Dictionary, + "remote-id", + dictionary("remote-id", "same name"), + ); + let result = diff_sync_documents(Some(&base), &local, &remote) + .unwrap() + .resolve(&[]) + .unwrap(); + assert_eq!( + result + .documents() + .documents + .iter() + .filter(|doc| doc.kind == DocumentKind::Dictionary) + .count(), + 2 + ); + let local = change(&base, DocumentKind::Preferences, "themeMode", json!("dark")); + let remote = change( + &base, + DocumentKind::Preferences, + "themeMode", + json!("light"), + ); + let preview = diff_sync_documents(Some(&base), &local, &remote).unwrap(); + assert_eq!(preview.conflicts().len(), 1); + assert_eq!( + preview.resolve(&[]).unwrap_err(), + DocumentError::ConflictChoiceRequired + ); +} + +#[test] +fn channel_and_credential_changes_are_one_redacted_conflict_unit() { + let base = export_snapshot(channel_snapshot()).unwrap().documents; + let mut channel = base + .documents() + .documents + .iter() + .find(|doc| doc.kind == DocumentKind::Channels) + .unwrap() + .value + .clone(); + channel["name"] = json!("private-local-name"); + let local = change(&base, DocumentKind::Channels, "llm:llm-one", channel); + let mut credentials = base + .documents() + .documents + .iter() + .find(|doc| doc.kind == DocumentKind::ProviderCredentials) + .unwrap() + .value + .clone(); + credentials["accounts"]["ark.api_key"] = json!("fixture-secret-remote"); + let remote = change( + &base, + DocumentKind::ProviderCredentials, + "llm:llm-one", + credentials, + ); + let preview = diff_sync_documents(Some(&base), &local, &remote).unwrap(); + assert_eq!(preview.conflicts().len(), 1); + assert!(preview.conflicts()[0].is_credential_unit); + let ui = serde_json::to_string(preview.conflicts()).unwrap(); + assert!(!ui.contains("fixture-secret")); + assert!(!ui.contains("private-local-name")); + let choice = ConflictChoice { + conflict_id: preview.conflicts()[0].conflict_id.clone(), + side: ConflictSide::Remote, + }; + let merged = preview.resolve(&[choice]).unwrap(); + let channel = merged + .documents() + .documents + .iter() + .find(|doc| doc.kind == DocumentKind::Channels) + .unwrap(); + assert_eq!( + channel.value["name"], "original", + "must not combine half of each channel unit" + ); +} + +#[test] +fn deletion_marks_prevent_offline_resurrection_and_conflict_with_edits() { + let base = change( + &sample(), + DocumentKind::Dictionary, + "entry", + dictionary("entry", "original"), + ); + let mut deleted = base.documents().clone(); + deleted.documents.retain(|doc| doc.id != "entry"); + deleted.tombstones.push(Tombstone { + id: "entry".into(), + kind: DocumentKind::Dictionary, + deleted_at: "2026-09-26T01:00:00Z".into(), + base_revision: Revision::new(4), + }); + let remote = validate_sync_documents(deleted, Revision::new(5)).unwrap(); + let merged = diff_sync_documents(Some(&base), &base, &remote) + .unwrap() + .resolve(&[]) + .unwrap(); + assert!(merged + .documents() + .tombstones + .iter() + .any(|t| t.id == "entry")); + assert!(!merged + .documents() + .documents + .iter() + .any(|doc| doc.id == "entry")); + let edited = change( + &base, + DocumentKind::Dictionary, + "entry", + dictionary("entry", "edited"), + ); + let preview = diff_sync_documents(Some(&base), &edited, &remote).unwrap(); + assert_eq!(preview.conflicts()[0].reason, ConflictReason::DeleteModify); + let mut missing = base.documents().clone(); + missing.documents.retain(|doc| doc.id != "entry"); + let missing = validate_sync_documents(missing, Revision::new(4)).unwrap(); + assert_eq!( + diff_sync_documents(Some(&base), &missing, &base).unwrap_err(), + DocumentError::MissingTombstone + ); +} + +#[test] +fn no_common_baseline_never_treats_a_missing_local_id_as_a_delete() { + let local = sample(); + let remote = change( + &local, + DocumentKind::Dictionary, + "cloud", + dictionary("cloud", "remote"), + ); + let result = diff_sync_documents(None, &local, &remote) + .unwrap() + .resolve(&[]) + .unwrap(); + assert!(result + .documents() + .documents + .iter() + .any(|doc| doc.id == "cloud")); +} + +#[test] +fn limits_reject_deep_values_and_oversized_full_snapshots_without_truncating() { + let mut value = json!(true); + for _ in 0..65 { + value = json!([value]); + } + let mut set = sample().documents().clone(); + set.documents.push(LogicalDocument { + id: "futureNested".into(), + kind: DocumentKind::Preferences, + schema_version: 1, + value, + }); + assert_eq!( + validate_sync_documents(set, Revision::new(3)).unwrap_err(), + DocumentError::PayloadTooLarge + ); + let mut set = sample().documents().clone(); + set.documents.push(LogicalDocument { + id: "futureText".into(), + kind: DocumentKind::Preferences, + schema_version: 1, + value: json!("x".repeat(MAX_JSON_BYTES)), + }); + assert_eq!( + validate_sync_documents(set, Revision::new(3)).unwrap_err(), + DocumentError::PayloadTooLarge + ); +} + +#[test] +fn tombstone_materialization_preserves_all_old_deletions() { + let base = change( + &sample(), + DocumentKind::Dictionary, + "remove", + dictionary("remove", "old"), + ); + let mut current = base.documents().clone(); + current.documents.retain(|doc| doc.id != "remove"); + let updated = record_missing_tombstones(current, &base, "2026-09-26T02:00:00Z").unwrap(); + assert_eq!(updated.documents().tombstones.len(), 1); + let mut next = updated.documents().clone(); + next.tombstones.clear(); + let preserved = record_missing_tombstones(next, &updated, "2026-09-26T03:00:00Z").unwrap(); + assert_eq!( + preserved.documents().tombstones, + updated.documents().tombstones + ); +} + +#[test] +fn scope_and_restore_preview_require_the_exact_revision_and_device() { + let scope = SyncScope { + service_origin: "https://sync.example.test".into(), + owner_github_id: "42".into(), + vault_id: "12345678-1234-4234-8234-123456789abc".into(), + key_id: "22345678-1234-4234-8234-123456789abc".into(), + device_id: "device-a".into(), + }; + let context = RestoreContext { + scope, + operation_id: "32345678-1234-4234-8234-123456789abc".into(), + observed_revision: Revision::new(99), + local_generation: Revision::new(7), + target_device: source(), + }; + assert_eq!( + prepare_sync_restore(sample(), context).unwrap_err(), + DocumentError::StalePreview + ); +} + +#[test] +fn simultaneous_active_channel_additions_are_a_resolvable_redacted_selection() { + let base = sample(); + let mut local = channel_snapshot(); + let mut remote = channel_snapshot(); + local.channels[0].id = "local-new".into(); + local.provider_credentials[0].channel_id = "local-new".into(); + remote.channels[0].id = "remote-new".into(); + remote.provider_credentials[0].channel_id = "remote-new".into(); + let local = export_snapshot(local).unwrap().documents; + let remote = export_snapshot(remote).unwrap().documents; + let preview = diff_sync_documents(Some(&base), &local, &remote).unwrap(); + assert_eq!(preview.conflicts().len(), 1); + let choices = preview + .conflicts() + .iter() + .map(|conflict| ConflictChoice { + conflict_id: conflict.conflict_id.clone(), + side: ConflictSide::Remote, + }) + .collect::>(); + let merged = preview.resolve(&choices).unwrap(); + let channels = merged + .documents() + .documents + .iter() + .filter(|doc| doc.kind == DocumentKind::Channels) + .collect::>(); + assert_eq!(channels.len(), 2); + assert_eq!( + channels + .iter() + .filter(|doc| doc.value["active"] == true) + .count(), + 1 + ); + assert!(channels + .iter() + .any(|doc| doc.id == "llm:remote-new" && doc.value["active"] == true)); +} + +#[test] +fn collection_order_is_preserved_independently_of_stable_id_sorting() { + let mut source = snapshot(); + source.dictionary = vec![ + SecretJson::new(dictionary("z", "newest")), + SecretJson::new(dictionary("a", "oldest")), + ]; + let exported = export_snapshot(source).unwrap(); + let docs = &exported.documents.documents().documents; + assert_eq!( + docs.iter() + .find(|doc| doc.kind == DocumentKind::Dictionary && doc.id == "z") + .unwrap() + .value["sortIndex"], + 0 + ); + assert_eq!( + docs.iter() + .find(|doc| doc.kind == DocumentKind::Dictionary && doc.id == "a") + .unwrap() + .value["sortIndex"], + 1 + ); +} diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/types.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/types.rs new file mode 100644 index 00000000..eb872e56 --- /dev/null +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/types.rs @@ -0,0 +1,430 @@ +//! Logical, secret-bearing data boundaries. None of these snapshots is a UI DTO. + +use std::collections::BTreeMap; +use std::fmt; + +use futures_util::future::BoxFuture; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use zeroize::Zeroize; + +use crate::cloud_sync_e2ee_protocol::types::{ + DocumentKind, DocumentSet, LogicalDocument, Revision, SourceDevice, Tombstone, +}; + +pub const DOCUMENT_SCHEMA_VERSION: u32 = 1; +pub const MAX_JSON_BYTES: usize = 15 * 1024 * 1024; +pub const MAX_DOCUMENTS: usize = 100_000; +pub const MAX_DEPTH: usize = 64; +pub const MAX_ICON_BYTES: usize = 64 * 1024; +pub const MAX_JOURNAL_BYTES: usize = 33 * 1024 * 1024; + +/// Stable, value-free errors. Never include serde, provider, path, or secret error bodies. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)] +#[serde(rename_all = "snake_case")] +pub enum DocumentError { + #[error("sync_documents_unsupported")] + Unsupported, + #[error("sync_documents_invalid")] + InvalidDocument, + #[error("sync_documents_invalid_reference")] + InvalidReference, + #[error("payload_too_large")] + PayloadTooLarge, + #[error("sync_documents_duplicate_id")] + DuplicateId, + #[error("sync_documents_excluded_field")] + ExcludedField, + #[error("sync_documents_capture_failed")] + CaptureFailed, + #[error("sync_documents_source_changed")] + SourceChanged, + #[error("runtime_busy")] + RuntimeBusy, + #[error("sync_documents_missing_tombstone")] + MissingTombstone, + #[error("sync_conflict_choice_required")] + ConflictChoiceRequired, + #[error("stale_preview")] + StalePreview, + #[error("sync_journal_unavailable")] + JournalUnavailable, + #[error("sync_documents_locked")] + Locked, + #[error("sync_restore_rolled_back")] + RestoreRolledBack, + #[error("recovery_required")] + RecoveryRequired, +} + +pub type DocumentResult = Result; + +/// JSON may contain API keys or private history. Diagnostics expose neither keys nor values. +#[derive(Clone, PartialEq, Serialize, Deserialize)] +#[serde(transparent)] +pub struct SecretJson(Value); + +impl SecretJson { + pub fn new(value: Value) -> Self { + Self(value) + } + + pub fn expose(&self) -> &Value { + &self.0 + } + + pub fn expose_mut(&mut self) -> &mut Value { + &mut self.0 + } + + pub fn into_value(mut self) -> Value { + std::mem::replace(&mut self.0, Value::Null) + } + + pub fn from_serializable(value: &impl Serialize) -> DocumentResult { + serde_json::to_value(value) + .map(Self) + .map_err(|_| DocumentError::InvalidDocument) + } +} + +impl Default for SecretJson { + fn default() -> Self { + Self(Value::Object(Default::default())) + } +} + +impl fmt::Debug for SecretJson { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("SecretJson([REDACTED])") + } +} + +impl Drop for SecretJson { + fn drop(&mut self) { + erase_json(&mut self.0); + } +} + +pub(crate) fn erase_json(value: &mut Value) { + match value { + Value::String(text) => text.zeroize(), + Value::Array(values) => values.iter_mut().for_each(erase_json), + Value::Object(values) => values.values_mut().for_each(erase_json), + _ => {} + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SyncNamespace { + Asr, + Llm, + Omni, +} + +impl SyncNamespace { + pub fn as_str(self) -> &'static str { + match self { + Self::Asr => "asr", + Self::Llm => "llm", + Self::Omni => "omni", + } + } +} + +/// Local readiness/test results and OAuth login state are intentionally absent. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ChannelRecord { + pub id: String, + pub namespace: SyncNamespace, + pub provider_type: String, + pub name: String, + pub enabled: bool, + pub order: u32, + pub active: bool, +} + +impl ChannelRecord { + pub fn document_id(&self) -> String { + format!("{}:{}", self.namespace.as_str(), self.id) + } +} + +/// Exactly one controlled account map per channel, including an empty map. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ProviderCredentialRecord { + pub channel_id: String, + pub namespace: SyncNamespace, + pub accounts: BTreeMap, +} + +impl ProviderCredentialRecord { + pub fn document_id(&self) -> String { + format!("{}:{}", self.namespace.as_str(), self.channel_id) + } +} + +impl Drop for ProviderCredentialRecord { + fn drop(&mut self) { + self.accounts.values_mut().for_each(Zeroize::zeroize); + } +} + +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct IconAsset { + pub mime: String, + pub base64: String, +} + +#[derive(Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct StylePackRecord { + /// Full StylePack serialization; export removes iconPath and derived active. + pub pack: SecretJson, + pub icon: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PresetOrigin { + Custom, + Override, + BuiltinState, +} + +#[derive(Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct VocabularyPresetRecord { + pub id: String, + pub origin: PresetOrigin, + pub name: Option, + pub phrases: Vec, + pub enabled: bool, + #[serde(default)] + pub order: u32, +} + +/// One source device/day contribution. Never export an imported aggregate as a new local one. +#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ActivityRecord { + pub source_device_id: String, + pub date: String, + pub count: u64, + pub chars: u64, + pub duration_ms: u64, +} + +impl ActivityRecord { + pub fn document_id(&self) -> String { + format!("{}:{}", self.source_device_id, self.date) + } +} + +#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct WindowPosition { + pub window_id: String, + pub x: i32, + pub y: i32, + pub width: u32, + pub height: u32, +} + +/// These are data to preserve/review, never executable paths or permission grants. +#[derive(Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct DeviceProfileRecord { + pub device: SourceDevice, + pub preferences: SecretJson, + pub channels: Vec, + pub provider_credentials: Vec, + pub window_positions: Vec, +} + +/// A coherent capture under the Host's write barrier. No directory/keystore enumeration. +#[derive(Clone)] +pub struct ExportSnapshot { + pub source_device: SourceDevice, + pub exported_at: String, + pub generation: Revision, + pub base_revision: Revision, + /// Raw preferences plus preserved unknown keys, before UserPreferences drops unknown data. + pub preferences: SecretJson, + pub ui_preferences: SecretJson, + pub channels: Vec, + pub provider_credentials: Vec, + pub dictionary: Vec, + pub vocabulary_presets: Vec, + pub corrections: Vec, + pub style_packs: Vec, + pub history: Vec, + pub activity: Vec, + pub window_positions: Vec, + /// Other-device profiles and unknown preference keys from protected local extension storage. + pub retained_documents: Vec, + pub tombstones: Vec, +} + +pub trait SyncDocumentSource: Send + Sync { + /// Capture all registered stores/credentials at one persisted generation. + /// Missing capabilities and failed credential reads must return an error, never an empty set. + fn capture(&self) -> BoxFuture<'_, DocumentResult>; +} + +#[derive(Clone)] +pub struct ValidatedSyncDocuments { + pub(crate) set: DocumentSet, + pub(crate) revision: Revision, +} + +impl ValidatedSyncDocuments { + pub fn documents(&self) -> &DocumentSet { + &self.set + } + + pub fn observed_revision(&self) -> Revision { + self.revision + } +} + +impl Drop for ValidatedSyncDocuments { + fn drop(&mut self) { + for document in &mut self.set.documents { + erase_json(&mut document.value); + } + } +} + +#[derive(Clone)] +pub struct ExportedDocuments { + pub generation: Revision, + pub documents: ValidatedSyncDocuments, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct DocumentKey { + pub kind: DocumentKind, + pub id: String, +} + +/// Origin/account/vault/key/device binding for protected local state and restore ownership. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SyncScope { + pub service_origin: String, + pub owner_github_id: String, + pub vault_id: String, + pub key_id: String, + pub device_id: String, +} + +#[derive(Clone)] +pub struct RestoreContext { + pub scope: SyncScope, + pub operation_id: String, + pub observed_revision: Revision, + pub local_generation: Revision, + pub target_device: SourceDevice, +} + +/// Exclusive lease blocks every normal repository/credential mutation until released. +/// After a crash, startup must recover a pending journal before admitting ordinary writes. +pub trait RestoreBackend: Send + Sync { + fn acquire( + &self, + scope: SyncScope, + expected_generation: Option, + ) -> BoxFuture<'_, DocumentResult>>; +} + +pub trait RestoreLease: Send { + fn capture(&mut self) -> BoxFuture<'_, DocumentResult>; + + /// Local-only side effects never enter cloud documents. The encrypted journal may retain + /// the minimal receiving-device state needed to roll them back exactly. + fn capture_rollback_state(&mut self) -> BoxFuture<'_, DocumentResult> { + Box::pin(async { Ok(SecretJson::default()) }) + } + fn restore_rollback_state(&mut self, state: &SecretJson) -> BoxFuture<'_, DocumentResult<()>> { + let empty = state + .expose() + .as_object() + .is_some_and(|value| value.is_empty()); + Box::pin(async move { + if empty { + Ok(()) + } else { + Err(DocumentError::Unsupported) + } + }) + } + + /// Validate native representations before journalling or mutating any local data. + fn preflight( + &mut self, + _documents: &ValidatedSyncDocuments, + ) -> BoxFuture<'_, DocumentResult<()>> { + Box::pin(async { Ok(()) }) + } + + /// Replace registered logical data, preserving stable IDs and all history. + /// Do not import OAuth/permissions, execute device paths, or run retention trimming. + fn replace(&mut self, documents: ValidatedSyncDocuments) -> BoxFuture<'_, DocumentResult<()>>; + + /// Reload file-backed caches and invalidate credential/test readiness before verification. + fn reload(&mut self) -> BoxFuture<'_, DocumentResult<()>>; + + /// Must persist the pending marker; dropping a cancelled future cannot admit ordinary writes. + fn mark_recovery_pending(&mut self, operation_id: &str) -> DocumentResult<()>; + + /// Persist this only after the encrypted journal is durable, before the first data write. + fn mark_journal_ready(&mut self, operation_id: &str) -> DocumentResult<()>; + + /// Missing journal is safe only for a pending Preparing marker; Applying must fail closed. + fn recover_without_journal(&mut self) -> DocumentResult<()>; + + /// A durable receipt prevents replaying an old committed journal over newer user changes. + fn completed_restore( + &self, + operation_id: &str, + ) -> DocumentResult>; + + /// Idempotent for operation_id. Commit advances one Restore-origin generation; rollback does not. + fn finish_restore( + &mut self, + operation_id: &str, + committed: bool, + ) -> DocumentResult; +} + +macro_rules! redact_debug { + ($($name:ty),+ $(,)?) => { + $(impl fmt::Debug for $name { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(concat!(stringify!($name), "([REDACTED])")) + } + })+ + }; +} + +redact_debug!( + ChannelRecord, + ProviderCredentialRecord, + IconAsset, + StylePackRecord, + VocabularyPresetRecord, + ActivityRecord, + WindowPosition, + DeviceProfileRecord, + ExportSnapshot, + ValidatedSyncDocuments, + ExportedDocuments, + SyncScope, + RestoreContext, +); diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/validate.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/validate.rs new file mode 100644 index 00000000..0f51ec36 --- /dev/null +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/validate.rs @@ -0,0 +1,812 @@ +//! Validation after authenticated decryption and before any local side effect. + +use std::collections::{BTreeMap, BTreeSet}; +use std::io::{self, Write}; + +use base64::{engine::general_purpose::STANDARD, Engine}; +use serde::de::DeserializeOwned; +use serde_json::{Map, Value}; + +use crate::cloud_sync_e2ee_protocol::types::{ + DocumentKind, DocumentSet, LogicalDocument, Revision, SourceDevice, +}; + +use super::registry::{ + credential_accounts, excluded_extension_key, is_device_channel, preference_field, + validate_preference_value, PreferenceClass, +}; +use super::types::*; + +/// Input must come from the protocol's duplicate-key/UTF-8/depth checked AEAD decoder. +pub fn validate_sync_documents( + mut set: DocumentSet, + observed_revision: Revision, +) -> DocumentResult { + if set.schema_version != DOCUMENT_SCHEMA_VERSION + || set + .documents + .iter() + .any(|doc| doc.schema_version != DOCUMENT_SCHEMA_VERSION) + { + return Err(DocumentError::Unsupported); + } + if set.documents.len().saturating_add(set.tombstones.len()) > MAX_DOCUMENTS { + return Err(DocumentError::PayloadTooLarge); + } + timestamp(&set.exported_at)?; + source_device(&set.source_device)?; + let mut seen = BTreeSet::new(); + for doc in &set.documents { + identifier(&doc.id)?; + if !seen.insert((doc.kind, doc.id.clone())) { + return Err(DocumentError::DuplicateId); + } + value_depth(&doc.value)?; + validate_document(doc)?; + } + for tombstone in &set.tombstones { + identifier(&tombstone.id)?; + timestamp(&tombstone.deleted_at)?; + if tombstone.base_revision > observed_revision { + return Err(DocumentError::InvalidDocument); + } + if !seen.insert((tombstone.kind, tombstone.id.clone())) { + return Err(DocumentError::DuplicateId); + } + } + normalize_collection_order(&mut set)?; + let mut limit = SizeLimit { bytes: 0 }; + serde_json::to_writer(&mut limit, &set).map_err(|_| DocumentError::PayloadTooLarge)?; + validate_references(&set)?; + set.documents + .sort_by(|left, right| (left.kind, &left.id).cmp(&(right.kind, &right.id))); + set.tombstones + .sort_by(|left, right| (left.kind, &left.id).cmp(&(right.kind, &right.id))); + Ok(ValidatedSyncDocuments { + set, + revision: observed_revision, + }) +} + +struct SizeLimit { + bytes: usize, +} +impl Write for SizeLimit { + fn write(&mut self, data: &[u8]) -> io::Result { + self.bytes = self + .bytes + .checked_add(data.len()) + .ok_or_else(|| io::Error::other("limit"))?; + if self.bytes > MAX_JSON_BYTES { + return Err(io::Error::other("limit")); + } + Ok(data.len()) + } + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} + +pub(crate) fn value_depth(root: &Value) -> DocumentResult<()> { + let mut stack = vec![(root, 1usize)]; + while let Some((value, depth)) = stack.pop() { + if depth > MAX_DEPTH { + return Err(DocumentError::PayloadTooLarge); + } + match value { + Value::Array(values) => stack.extend(values.iter().map(|v| (v, depth + 1))), + Value::Object(values) => stack.extend(values.values().map(|v| (v, depth + 1))), + Value::Number(number) + if number.is_f64() && !number.as_f64().is_some_and(f64::is_finite) => + { + return Err(DocumentError::InvalidDocument) + } + _ => {} + } + } + Ok(()) +} + +pub(crate) fn identifier(value: &str) -> DocumentResult<()> { + if value.is_empty() + || value.len() > 512 + || matches!(value, "." | "..") + || value + .chars() + .any(|c| c.is_control() || matches!(c, '/' | '\\')) + { + return Err(DocumentError::InvalidDocument); + } + Ok(()) +} + +pub(crate) fn timestamp(value: &str) -> DocumentResult<()> { + let time = + chrono::DateTime::parse_from_rfc3339(value).map_err(|_| DocumentError::InvalidDocument)?; + if time.offset().local_minus_utc() != 0 { + return Err(DocumentError::InvalidDocument); + } + Ok(()) +} + +fn source_device(device: &SourceDevice) -> DocumentResult<()> { + identifier(&device.id)?; + for text in [&device.os, &device.arch, &device.app_version] { + if text.is_empty() || text.len() > 128 || text.chars().any(char::is_control) { + return Err(DocumentError::InvalidDocument); + } + } + Ok(()) +} + +pub(crate) fn deserialize(value: &Value) -> DocumentResult { + serde_json::from_value(value.clone()).map_err(|_| DocumentError::InvalidDocument) +} + +fn object<'a>(value: &'a Value, allowed: &[&str]) -> DocumentResult<&'a Map> { + let object = value.as_object().ok_or(DocumentError::InvalidDocument)?; + if object.keys().any(|key| !allowed.contains(&key.as_str())) { + return Err(DocumentError::Unsupported); + } + Ok(object) +} + +fn required_text<'a>(object: &'a Map, key: &str) -> DocumentResult<&'a str> { + object + .get(key) + .and_then(Value::as_str) + .ok_or(DocumentError::InvalidDocument) +} + +fn optional_text(object: &Map, keys: &[&str]) -> DocumentResult<()> { + for key in keys { + if object + .get(*key) + .is_some_and(|value| !value.is_null() && !value.is_string()) + { + return Err(DocumentError::InvalidDocument); + } + } + Ok(()) +} + +fn optional_unsigned(object: &Map, keys: &[&str]) -> DocumentResult<()> { + for key in keys { + if object + .get(*key) + .is_some_and(|value| !value.is_null() && value.as_u64().is_none()) + { + return Err(DocumentError::InvalidDocument); + } + } + Ok(()) +} + +fn optional_bool(object: &Map, keys: &[&str]) -> DocumentResult<()> { + for key in keys { + if object + .get(*key) + .is_some_and(|value| !value.is_null() && !value.is_boolean()) + { + return Err(DocumentError::InvalidDocument); + } + } + Ok(()) +} + +fn choice(value: &str, choices: &[&str]) -> DocumentResult<()> { + if choices.contains(&value) { + Ok(()) + } else { + Err(DocumentError::InvalidDocument) + } +} + +fn validate_document(doc: &LogicalDocument) -> DocumentResult<()> { + match doc.kind { + DocumentKind::Preferences => { + if let Some(field) = preference_field(&doc.id) { + if field.class != PreferenceClass::Portable { + return Err(DocumentError::ExcludedField); + } + validate_preference_value(field, &doc.value)?; + } else if excluded_extension_key(&doc.id) { + return Err(DocumentError::ExcludedField); + } + } + DocumentKind::UiPreferences => match doc.id.as_str() { + "locale" => choice( + doc.value.as_str().ok_or(DocumentError::InvalidDocument)?, + &[ + "system", "zh-CN", "zh-TW", "en", "ja", "ko", "es", "fr", "de", + ], + )?, + "fontScale" => choice( + doc.value.as_str().ok_or(DocumentError::InvalidDocument)?, + &["small", "medium", "large"], + )?, + _ if excluded_extension_key(&doc.id) => return Err(DocumentError::ExcludedField), + _ => {} + }, + DocumentKind::Channels => { + let channel: ChannelRecord = deserialize(&doc.value)?; + validate_channel(&channel)?; + if channel.document_id() != doc.id + || is_device_channel(channel.namespace, &channel.provider_type) + { + return Err(DocumentError::InvalidDocument); + } + } + DocumentKind::ProviderCredentials => { + let credentials: ProviderCredentialRecord = deserialize(&doc.value)?; + validate_credentials(&credentials)?; + if credentials.document_id() != doc.id { + return Err(DocumentError::InvalidReference); + } + } + DocumentKind::Dictionary => { + let row = object( + &doc.value, + &[ + "id", + "phrase", + "note", + "enabled", + "hits", + "createdAt", + "sortIndex", + ], + )?; + if required_text(row, "id")? != doc.id { + return Err(DocumentError::InvalidReference); + } + required_text(row, "phrase")?; + optional_text(row, &["note", "createdAt"])?; + optional_bool(row, &["enabled"])?; + optional_unsigned(row, &["hits"])?; + } + DocumentKind::VocabularyPresets => { + let preset: VocabularyPresetRecord = deserialize(&doc.value)?; + identifier(&preset.id)?; + let prefix = match preset.origin { + PresetOrigin::Custom => "custom", + PresetOrigin::Override => "override", + PresetOrigin::BuiltinState => "builtin", + }; + if doc.id != format!("{prefix}:{}", preset.id) { + return Err(DocumentError::InvalidReference); + } + if preset.origin == PresetOrigin::BuiltinState { + if preset.name.is_some() || !preset.phrases.is_empty() || preset.order != 0 { + return Err(DocumentError::InvalidDocument); + } + } else if preset.name.as_deref().is_none_or(str::is_empty) { + return Err(DocumentError::InvalidDocument); + } + } + DocumentKind::Corrections => { + let row = object( + &doc.value, + &[ + "id", + "pattern", + "replacement", + "enabled", + "createdAt", + "source", + "sortIndex", + ], + )?; + if required_text(row, "id")? != doc.id { + return Err(DocumentError::InvalidReference); + } + required_text(row, "pattern")?; + required_text(row, "replacement")?; + optional_text(row, &["createdAt"])?; + optional_bool(row, &["enabled"])?; + if let Some(value) = row.get("source") { + choice( + value.as_str().ok_or(DocumentError::InvalidDocument)?, + &["manual", "learned"], + )?; + } + } + DocumentKind::StylePacks => validate_style(doc)?, + DocumentKind::History => validate_history(doc)?, + DocumentKind::Activity => { + let day: ActivityRecord = deserialize(&doc.value)?; + identifier(&day.source_device_id)?; + let parsed = chrono::NaiveDate::parse_from_str(&day.date, "%Y-%m-%d") + .map_err(|_| DocumentError::InvalidDocument)?; + if parsed.format("%Y-%m-%d").to_string() != day.date || day.document_id() != doc.id { + return Err(DocumentError::InvalidDocument); + } + } + DocumentKind::DeviceProfile => validate_profile(doc)?, + } + Ok(()) +} + +fn validate_channel(channel: &ChannelRecord) -> DocumentResult<()> { + identifier(&channel.id)?; + identifier(&channel.provider_type)?; + if channel.active && !channel.enabled { + return Err(DocumentError::InvalidDocument); + } + if channel.namespace == SyncNamespace::Omni && channel.id != channel.provider_type { + return Err(DocumentError::InvalidReference); + } + Ok(()) +} + +/// Common native-vault batch contract, including device-local ASR entries. +/// Provider-specific headers/temperature/protocol representability is checked by the Host. +pub fn validate_credential_set( + channels: &[ChannelRecord], + credentials: &[ProviderCredentialRecord], +) -> DocumentResult<()> { + let mut channel_ids = BTreeSet::new(); + let mut credential_ids = BTreeSet::new(); + let mut active = BTreeSet::new(); + for channel in channels { + validate_channel(channel)?; + if !channel_ids.insert(channel.document_id()) { + return Err(DocumentError::DuplicateId); + } + if channel.active && !active.insert(channel.namespace) { + return Err(DocumentError::InvalidReference); + } + } + for record in credentials { + identifier(&record.channel_id)?; + if !credential_ids.insert(record.document_id()) { + return Err(DocumentError::DuplicateId); + } + if record + .accounts + .keys() + .any(|account| !credential_accounts(record.namespace).contains(&account.as_str())) + { + return Err(DocumentError::ExcludedField); + } + } + if channel_ids != credential_ids { + return Err(DocumentError::InvalidReference); + } + Ok(()) +} + +fn validate_credentials(credentials: &ProviderCredentialRecord) -> DocumentResult<()> { + identifier(&credentials.channel_id)?; + for (account, value) in &credentials.accounts { + if !credential_accounts(credentials.namespace).contains(&account.as_str()) { + return Err(DocumentError::ExcludedField); + } + if account.ends_with(".extra_headers") && !value.trim().is_empty() { + let headers: BTreeMap = + serde_json::from_str(value).map_err(|_| DocumentError::InvalidDocument)?; + if headers.keys().any(|key| { + key.eq_ignore_ascii_case("cookie") || key.eq_ignore_ascii_case("set-cookie") + }) { + return Err(DocumentError::ExcludedField); + } + } + } + Ok(()) +} + +fn validate_style(doc: &LogicalDocument) -> DocumentResult<()> { + let style: StylePackRecord = deserialize(&doc.value)?; + let row = object( + style.pack.expose(), + &[ + "id", + "name", + "description", + "author", + "version", + "kind", + "baseMode", + "selectionPrompt", + "voiceEditPrompt", + "prompt", + "examples", + "tags", + "createdAt", + "updatedAt", + "enabled", + "recommendedModel", + "compatibleAppVersion", + "originPackId", + "originAuthorLogin", + ], + )?; + if required_text(row, "id")? != doc.id { + return Err(DocumentError::InvalidReference); + } + for key in [ + "name", + "description", + "version", + "kind", + "selectionPrompt", + "voiceEditPrompt", + "prompt", + ] { + required_text(row, key)?; + } + choice( + required_text(row, "baseMode")?, + &["raw", "light", "structured", "formal"], + )?; + optional_text( + row, + &[ + "author", + "createdAt", + "updatedAt", + "recommendedModel", + "compatibleAppVersion", + "originPackId", + "originAuthorLogin", + ], + )?; + optional_bool(row, &["enabled"])?; + if !row + .get("tags") + .and_then(Value::as_array) + .is_some_and(|tags| tags.iter().all(Value::is_string)) + { + return Err(DocumentError::InvalidDocument); + } + let examples = row + .get("examples") + .and_then(Value::as_array) + .ok_or(DocumentError::InvalidDocument)?; + for example in examples { + let fields = object(example, &["title", "input", "output"])?; + required_text(fields, "input")?; + required_text(fields, "output")?; + optional_text(fields, &["title"])?; + } + if let Some(icon) = &style.icon { + validate_icon(icon)?; + } + Ok(()) +} + +pub fn validate_icon(icon: &IconAsset) -> DocumentResult<()> { + if icon.base64.len() > MAX_ICON_BYTES.div_ceil(3) * 4 { + return Err(DocumentError::PayloadTooLarge); + } + let bytes = STANDARD + .decode(&icon.base64) + .map_err(|_| DocumentError::InvalidDocument)?; + if bytes.len() > MAX_ICON_BYTES || STANDARD.encode(&bytes) != icon.base64 { + return Err(DocumentError::InvalidDocument); + } + let extension = match icon.mime.as_str() { + "image/png" => "png", + "image/jpeg" => "jpg", + "image/webp" => "webp", + _ => return Err(DocumentError::InvalidDocument), + }; + crate::style_pack_archive::validate_icon_content(extension, &bytes) + .map_err(|_| DocumentError::InvalidDocument) +} + +fn validate_history(doc: &LogicalDocument) -> DocumentResult<()> { + let row = object( + &doc.value, + &[ + "id", + "createdAt", + "source", + "rawTranscript", + "asrTranscript", + "finalText", + "mode", + "stylePackId", + "translationActive", + "polishSource", + "appBundleId", + "appName", + "insertStatus", + "errorCode", + "durationMs", + "dictionaryEntryCount", + "hasAudioRecording", + "asrProvider", + "asrModel", + "llmProvider", + "llmModel", + "pipelineMode", + "asrMs", + "polishMs", + "sortIndex", + ], + )?; + if required_text(row, "id")? != doc.id { + return Err(DocumentError::InvalidReference); + } + for key in ["createdAt", "rawTranscript", "finalText"] { + required_text(row, key)?; + } + choice( + required_text(row, "mode")?, + &["raw", "light", "structured", "formal"], + )?; + choice( + required_text(row, "insertStatus")?, + &[ + "inserted", + "pasteSent", + "copiedFallback", + "failed", + "notRequested", + ], + )?; + if let Some(source) = row.get("source") { + choice( + source.as_str().ok_or(DocumentError::InvalidDocument)?, + &[ + "voice", + "quick_note", + "selection_polish", + "selection_voice_edit", + ], + )?; + } + optional_text( + row, + &[ + "asrTranscript", + "stylePackId", + "polishSource", + "appBundleId", + "appName", + "errorCode", + "asrProvider", + "asrModel", + "llmProvider", + "llmModel", + "pipelineMode", + ], + )?; + optional_unsigned( + row, + &["durationMs", "dictionaryEntryCount", "asrMs", "polishMs"], + )?; + optional_bool(row, &["translationActive", "hasAudioRecording"])?; + if row.get("hasAudioRecording") == Some(&Value::Bool(true)) { + return Err(DocumentError::ExcludedField); + } + Ok(()) +} + +fn validate_profile(doc: &LogicalDocument) -> DocumentResult<()> { + let profile: DeviceProfileRecord = deserialize(&doc.value)?; + source_device(&profile.device)?; + if profile.device.id != doc.id { + return Err(DocumentError::InvalidReference); + } + for (key, value) in profile + .preferences + .expose() + .as_object() + .ok_or(DocumentError::InvalidDocument)? + { + let field = preference_field(key).ok_or(DocumentError::Unsupported)?; + if field.class != PreferenceClass::DeviceProfile { + return Err(DocumentError::ExcludedField); + } + validate_preference_value(field, value)?; + } + let mut channels = BTreeMap::new(); + let mut active = BTreeSet::new(); + for channel in &profile.channels { + validate_channel(channel)?; + if !is_device_channel(channel.namespace, &channel.provider_type) { + return Err(DocumentError::InvalidDocument); + } + if channel.active && !active.insert(channel.namespace) { + return Err(DocumentError::InvalidReference); + } + if channels.insert(channel.document_id(), channel).is_some() { + return Err(DocumentError::DuplicateId); + } + } + let mut credential_ids = BTreeSet::new(); + for credential in &profile.provider_credentials { + validate_credentials(credential)?; + if !credential_ids.insert(credential.document_id()) { + return Err(DocumentError::DuplicateId); + } + } + if credential_ids != channels.keys().cloned().collect() { + return Err(DocumentError::InvalidReference); + } + let mut windows = BTreeSet::new(); + for window in &profile.window_positions { + identifier(&window.window_id)?; + if !windows.insert(&window.window_id) + || window.width == 0 + || window.height == 0 + || window.width > 100_000 + || window.height > 100_000 + { + return Err(DocumentError::InvalidDocument); + } + } + Ok(()) +} + +fn validate_references(set: &DocumentSet) -> DocumentResult<()> { + let mut channels = BTreeMap::new(); + let mut credentials = BTreeSet::new(); + let mut active = BTreeSet::new(); + let styles: BTreeSet<&str> = set + .documents + .iter() + .filter(|doc| doc.kind == DocumentKind::StylePacks) + .map(|doc| doc.id.as_str()) + .collect(); + for doc in &set.documents { + match doc.kind { + DocumentKind::Channels => { + let channel: ChannelRecord = deserialize(&doc.value)?; + if channel.active && !active.insert(channel.namespace) { + return Err(DocumentError::InvalidReference); + } + channels.insert(doc.id.clone(), channel); + } + DocumentKind::ProviderCredentials => { + credentials.insert(doc.id.clone()); + } + DocumentKind::Preferences + if matches!( + doc.id.as_str(), + "activeStylePackId" | "selectionPolishStylePackId" + ) => + { + let id = doc.value.as_str().ok_or(DocumentError::InvalidReference)?; + if !id.is_empty() && !styles.contains(id) { + return Err(DocumentError::InvalidReference); + } + } + _ => {} + } + } + if credentials != channels.keys().cloned().collect() { + return Err(DocumentError::InvalidReference); + } + // A channel cannot be deleted while a separate credential half remains live. + for tombstone in &set.tombstones { + if tombstone.kind == DocumentKind::Channels && credentials.contains(&tombstone.id) + || tombstone.kind == DocumentKind::ProviderCredentials + && channels.contains_key(&tombstone.id) + { + return Err(DocumentError::InvalidReference); + } + } + Ok(()) +} + +pub(crate) fn validate_scope(scope: &SyncScope) -> DocumentResult<()> { + let origin = + url::Url::parse(&scope.service_origin).map_err(|_| DocumentError::InvalidDocument)?; + if origin.scheme() != "https" + || origin.origin().ascii_serialization() != scope.service_origin + || !origin.username().is_empty() + || origin.password().is_some() + { + return Err(DocumentError::InvalidDocument); + } + let github_id = + Revision::parse(&scope.owner_github_id).map_err(|_| DocumentError::InvalidDocument)?; + if github_id.get() == 0 { + return Err(DocumentError::InvalidDocument); + } + for id in [&scope.vault_id, &scope.key_id] { + uuid_v4(id)?; + } + identifier(&scope.device_id) +} + +pub(crate) fn uuid_v4(value: &str) -> DocumentResult<()> { + let id = uuid::Uuid::parse_str(value).map_err(|_| DocumentError::InvalidDocument)?; + if id.get_version() != Some(uuid::Version::Random) + || id.get_variant() != uuid::Variant::RFC4122 + || id.to_string() != value + { + return Err(DocumentError::InvalidDocument); + } + Ok(()) +} + +fn normalize_collection_order(set: &mut DocumentSet) -> DocumentResult<()> { + for kind in [ + DocumentKind::Dictionary, + DocumentKind::Corrections, + DocumentKind::History, + ] { + let mut indices: Vec<_> = set + .documents + .iter() + .enumerate() + .filter(|(_, doc)| doc.kind == kind) + .map(|(index, _)| index) + .collect(); + for index in &indices { + if set.documents[*index] + .value + .get("sortIndex") + .is_some_and(|value| value.as_u64().is_none()) + { + return Err(DocumentError::InvalidDocument); + } + } + indices.sort_by(|left, right| { + let left = &set.documents[*left]; + let right = &set.documents[*right]; + ( + left.value + .get("sortIndex") + .and_then(Value::as_u64) + .unwrap_or(u64::MAX), + &left.id, + ) + .cmp(&( + right + .value + .get("sortIndex") + .and_then(Value::as_u64) + .unwrap_or(u64::MAX), + &right.id, + )) + }); + for (order, index) in indices.into_iter().enumerate() { + set.documents[index] + .value + .as_object_mut() + .ok_or(DocumentError::InvalidDocument)? + .insert("sortIndex".into(), Value::from(order)); + } + } + for origin in ["custom", "override"] { + let mut indices: Vec<_> = set + .documents + .iter() + .enumerate() + .filter(|(_, doc)| { + doc.kind == DocumentKind::VocabularyPresets + && doc.value.get("origin").and_then(Value::as_str) == Some(origin) + }) + .map(|(index, _)| index) + .collect(); + indices.sort_by(|left, right| { + let left = &set.documents[*left]; + let right = &set.documents[*right]; + ( + left.value.get("order").and_then(Value::as_u64).unwrap_or(0), + &left.id, + ) + .cmp(&( + right + .value + .get("order") + .and_then(Value::as_u64) + .unwrap_or(0), + &right.id, + )) + }); + for (order, index) in indices.into_iter().enumerate() { + set.documents[index] + .value + .as_object_mut() + .ok_or(DocumentError::InvalidDocument)? + .insert("order".into(), Value::from(order)); + } + } + Ok(()) +} diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_protocol/crypto.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_protocol/crypto.rs new file mode 100644 index 00000000..2eac760a --- /dev/null +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_protocol/crypto.rs @@ -0,0 +1,393 @@ +//! v1 cryptography uses RustCrypto primitives, never a custom cipher/KDF. +//! Argon2 is CPU/memory intensive: callers must run derivation off UI/async workers. + +use super::{types::*, Error, Result}; +use argon2::{Algorithm, Argon2, Block, Params, Version}; +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; +use chacha20poly1305::{ + aead::{AeadInPlace, KeyInit}, + XChaCha20Poly1305, XNonce, +}; +use std::fmt; +use unicode_normalization::UnicodeNormalization; +use zeroize::Zeroizing; + +pub(crate) struct NormalizedPassword(Zeroizing); +impl fmt::Debug for NormalizedPassword { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("NormalizedPassword([REDACTED])") + } +} +impl NormalizedPassword { + /// NFC only: neither trim nor case-fold changes the password sent to the KDF. + pub(crate) fn new(input: SecretInput) -> Result { + // Bound work before Unicode normalization, then check the normative NFC limit. + if input.expose().len() > 4096 { + return Err(Error::WeakPassword); + } + let mut text = Zeroizing::new(String::with_capacity(512)); + let mut count = 0; + for character in input.expose().nfc() { + count += 1; + if count > 128 || text.len() + character.len_utf8() > 512 { + return Err(Error::WeakPassword); + } + text.push(character); + } + if !(12..=128).contains(&count) + || !text.bytes().any(|c| c.is_ascii_lowercase()) + || !text.bytes().any(|c| c.is_ascii_uppercase()) + || !text.bytes().any(|c| c.is_ascii_digit()) + { + return Err(Error::WeakPassword); + } + Ok(Self(text)) + } + /// Apply local password policy v1 only when creating/changing a password. + /// Unlock remains compatible if a later release expands the weak-password list. + pub(crate) fn validate_new(&self) -> Result<()> { + let lower = Zeroizing::new(self.0.to_ascii_lowercase()); + let signature = Zeroizing::new( + lower + .chars() + .filter(|c| c.is_ascii_alphanumeric()) + .collect::(), + ); + let weak = [ + "password", + "qwerty", + "12345678", + "abcdefgh", + "abcdefg", + "letmein", + "welcome", + "administrator", + ]; + if weak.iter().any(|word| signature.contains(word)) { + return Err(Error::WeakPassword); + } + let mapped = Zeroizing::new( + lower + .chars() + .map(|c| match c { + '@' | '4' => 'a', + '0' => 'o', + '$' | '5' => 's', + '3' => 'e', + _ => c, + }) + .collect::(), + ); + if mapped.contains("password") || mapped.contains("qwerty") { + return Err(Error::WeakPassword); + } + let chars = Zeroizing::new(lower.chars().collect::>()); + let unique = chars + .iter() + .enumerate() + .filter(|(i, c)| !chars[..*i].contains(c)) + .count(); + if unique < 4 + || (1..=8.min(chars.len() / 3)).any(|period| { + chars + .iter() + .enumerate() + .all(|(i, c)| *c == chars[i % period]) + }) + { + return Err(Error::WeakPassword); + } + Ok(()) + } + pub(crate) fn confirmed_new(input: SecretInput, confirmation: SecretInput) -> Result { + let password = Self::new(input)?; + let confirmation = Self::new(confirmation)?; + if password.0.as_bytes() != confirmation.0.as_bytes() { + return Err(Error::PasswordConfirmationMismatch); + } + password.validate_new()?; + Ok(password) + } +} + +/// Not serializable or implicitly cloneable. Secure-store/journal callers must bind +/// exported bytes to service origin + GitHub ID + vault ID + key ID themselves. +pub(crate) struct DerivedKey(Zeroizing<[u8; 32]>); +impl DerivedKey { + pub(crate) fn from_secret_bytes(bytes: Zeroizing<[u8; 32]>) -> Self { + Self(bytes) + } + pub(crate) fn expose_bytes(&self) -> &[u8; 32] { + &self.0 + } + pub(crate) fn copy_secret_bytes(&self) -> Zeroizing<[u8; 32]> { + Zeroizing::new(*self.0) + } +} +impl fmt::Debug for DerivedKey { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("DerivedKey([REDACTED])") + } +} + +pub(crate) fn derive_key(password: &NormalizedPassword, kdf: &Kdf) -> Result { + kdf.validate()?; + let params = Params::new(65_536, 3, 4, Some(32)).map_err(|_| Error::UnsupportedProtocol)?; + let argon = Argon2::new(Algorithm::Argon2id, Version::V0x13, params); + let mut key = Zeroizing::new([0; 32]); + // The caller-owned Argon2 working memory is also wiped on all exits. + let mut memory = Zeroizing::new(vec![Block::default(); 65_536]); + argon + .hash_password_into_with_memory( + password.0.as_bytes(), + kdf.salt.bytes(), + key.as_mut(), + &mut memory[..], + ) + .map_err(|_| Error::InvalidPasswordOrCiphertext)?; + Ok(DerivedKey(key)) +} + +#[derive(Clone, Debug)] +pub(crate) struct EncryptContext { + pub(crate) owner_github_id: GithubId, + pub(crate) vault_id: UuidV4, + pub(crate) key_id: UuidV4, + pub(crate) base_revision: Revision, + pub(crate) operation_id: UuidV4, + pub(crate) kind: UploadKind, + pub(crate) kdf: Kdf, +} +impl EncryptContext { + pub(crate) fn validate(&self) -> Result<()> { + self.kdf.validate()?; + self.base_revision.checked_next().map(|_| ()) + } + fn header(&self, nonce: Nonce) -> Result { + self.validate()?; + Ok(SnapshotUpload { + protocol_version: PROTOCOL_VERSION, + payload_schema_version: 1, + owner_github_id: self.owner_github_id.clone(), + vault_id: self.vault_id.clone(), + key_id: self.key_id.clone(), + base_revision: self.base_revision, + revision: self.base_revision.checked_next()?, + operation_id: self.operation_id.clone(), + kind: self.kind, + crypto_profile: CRYPTO_PROFILE.into(), + kdf: self.kdf.clone(), + aead: AEAD_NAME.into(), + codec: CODEC_NAME.into(), + nonce, + ciphertext: String::new(), + ciphertext_sha256: Sha256Digest::of(&[]), + }) + } +} + +/// Explicit ordered JSON array, independent of object/map serialization order. +pub(crate) fn aad(snapshot: &SnapshotUpload) -> Result> { + snapshot.kdf.validate()?; + if snapshot.protocol_version != PROTOCOL_VERSION + || snapshot.payload_schema_version != 1 + || snapshot.crypto_profile != CRYPTO_PROFILE + || snapshot.aead != AEAD_NAME + || snapshot.codec != CODEC_NAME + { + return Err(Error::UnsupportedProtocol); + } + let fields = serde_json::json!([ + "openless-cloud-sync", + 1, + "snapshot", + snapshot.owner_github_id.as_str(), + snapshot.vault_id.as_str(), + snapshot.key_id.as_str(), + snapshot.base_revision.as_str(), + snapshot.revision.as_str(), + snapshot.operation_id.as_str(), + snapshot.kind.as_str(), + 1, + CRYPTO_PROFILE, + "argon2id", + 19, + 65536, + 3, + 4, + snapshot.kdf.salt.encoded(), + AEAD_NAME, + CODEC_NAME + ]); + serde_json::to_vec(&fields).map_err(|_| Error::InvalidWire("aad")) +} + +fn padded_length(json_len: usize) -> Result { + if json_len == 0 || json_len > MAX_PLAINTEXT_JSON_BYTES { + return Err(Error::PayloadTooLarge); + } + let len = (json_len + 4).div_ceil(PAD_BLOCK_BYTES) * PAD_BLOCK_BYTES; + if len > MAX_PADDED_BYTES { + return Err(Error::PayloadTooLarge); + } + Ok(len) +} + +pub(crate) fn encrypt_snapshot( + documents: &DocumentSet, + context: &EncryptContext, + key: &DerivedKey, +) -> Result { + let json = documents.to_secret_json()?; + context.validate()?; + let nonce = Nonce::random()?; + let length = padded_length(json.len())?; + let mut plaintext = Zeroizing::new(Vec::with_capacity(length + 16)); + plaintext.resize(length, 0); + plaintext[..4].copy_from_slice(&(json.len() as u32).to_be_bytes()); + plaintext[4..4 + json.len()].copy_from_slice(&json); + getrandom::fill(&mut plaintext[4 + json.len()..]).map_err(|_| Error::RandomUnavailable)?; + seal_frame(context, key, nonce, plaintext) +} + +fn seal_frame( + context: &EncryptContext, + key: &DerivedKey, + nonce: Nonce, + mut frame: Zeroizing>, +) -> Result { + let mut snapshot = context.header(nonce)?; + let cipher = XChaCha20Poly1305::new(key.expose_bytes().into()); + let associated = aad(&snapshot)?; + cipher + .encrypt_in_place( + XNonce::from_slice(snapshot.nonce.bytes()), + &associated, + &mut *frame, + ) + .map_err(|_| Error::InvalidPasswordOrCiphertext)?; + snapshot.ciphertext_sha256 = Sha256Digest::of(&frame); + snapshot.ciphertext = URL_SAFE_NO_PAD.encode(&frame); + snapshot.validate()?; + Ok(snapshot) +} + +/// The authenticated account and the exact metadata read are mandatory, not an +/// optional caller check. Authentication is verified before parsing any plaintext. +pub(crate) fn decrypt_snapshot( + snapshot: &SnapshotUpload, + metadata: &VaultMetadata, + owner: &GithubId, + key: &DerivedKey, +) -> Result { + snapshot.validate_against_metadata(metadata, owner)?; + let mut frame = Zeroizing::new(snapshot.decoded_ciphertext()?); + let cipher = XChaCha20Poly1305::new(key.expose_bytes().into()); + cipher + .decrypt_in_place( + XNonce::from_slice(snapshot.nonce.bytes()), + &aad(snapshot)?, + &mut *frame, + ) + .map_err(|_| Error::InvalidPasswordOrCiphertext)?; + decode_frame(&frame) +} + +pub(crate) fn decrypt_snapshot_with_password( + snapshot: &SnapshotUpload, + metadata: &VaultMetadata, + owner: &GithubId, + password: SecretInput, +) -> Result<(DerivedKey, DocumentSet)> { + // Validate fixed KDF parameters, ciphertext bounds/hash and identity before + // allocating Argon2's 64 MiB memory, even when a server supplied this header. + snapshot.validate_against_metadata(metadata, owner)?; + let password = NormalizedPassword::new(password)?; + let key = derive_key(&password, &snapshot.kdf)?; + let documents = decrypt_snapshot(snapshot, metadata, owner, &key)?; + Ok((key, documents)) +} + +fn decode_frame(frame: &[u8]) -> Result { + if frame.len() < PAD_BLOCK_BYTES + || frame.len() > MAX_PADDED_BYTES + || !frame.len().is_multiple_of(PAD_BLOCK_BYTES) + { + return Err(Error::InvalidPasswordOrCiphertext); + } + let count = u32::from_be_bytes( + frame[..4] + .try_into() + .map_err(|_| Error::InvalidPasswordOrCiphertext)?, + ) as usize; + if count > MAX_PLAINTEXT_JSON_BYTES + || count == 0 + || count > frame.len() - 4 + || padded_length(count)? != frame.len() + { + return Err(Error::InvalidPasswordOrCiphertext); + } + DocumentSet::from_json_bytes(&frame[4..4 + count]) +} + +// Two independently bounded 15 MiB snapshots plus transaction metadata fit here; +// this local-only ceiling never changes the remote v1 15 MiB JSON limit. +pub(crate) const MAX_LOCAL_PLAINTEXT_BYTES: usize = 40 * 1024 * 1024; +const LOCAL_ENVELOPE_MAGIC: &[u8] = b"OL-E2EE-JOURNAL\x01"; + +fn local_aad(scope: &[u8]) -> Result>> { + if scope.is_empty() || scope.len() > CONTROL_BODY_LIMIT { + return Err(Error::InvalidWire("local journal scope")); + } + let mut bytes = Zeroizing::new(Vec::with_capacity(64 + scope.len())); + // Separate domain: a network snapshot can never authenticate as a journal. + bytes.extend_from_slice(b"openless-cloud-sync\0local-journal\0v1\0"); + bytes.extend_from_slice(&(scope.len() as u32).to_be_bytes()); + bytes.extend_from_slice(scope); + Ok(bytes) +} + +/// Local-only journal encryption. The caller supplies canonical scope bytes +/// including service origin, owner, vault/key IDs and operation identity. +/// No journal bytes may be submitted through the snapshot HTTP API. +pub(crate) fn seal_local(key: &DerivedKey, scope_aad: &[u8], plaintext: &[u8]) -> Result> { + if plaintext.len() > MAX_LOCAL_PLAINTEXT_BYTES { + return Err(Error::PayloadTooLarge); + } + let associated = local_aad(scope_aad)?; + let nonce = Nonce::random()?; + let mut buffer = Zeroizing::new(Vec::with_capacity(plaintext.len() + 16)); + buffer.extend_from_slice(plaintext); + XChaCha20Poly1305::new(key.expose_bytes().into()) + .encrypt_in_place(XNonce::from_slice(nonce.bytes()), &associated, &mut *buffer) + .map_err(|_| Error::InvalidPasswordOrCiphertext)?; + let mut envelope = Vec::with_capacity(LOCAL_ENVELOPE_MAGIC.len() + 24 + buffer.len()); + envelope.extend_from_slice(LOCAL_ENVELOPE_MAGIC); + envelope.extend_from_slice(nonce.bytes()); + envelope.extend_from_slice(&buffer); + Ok(envelope) +} + +pub(crate) fn open_local( + key: &DerivedKey, + scope_aad: &[u8], + envelope: &[u8], +) -> Result>> { + let prefix = LOCAL_ENVELOPE_MAGIC.len() + 24; + if envelope.len() < prefix + 16 + || envelope.len() > prefix + 16 + MAX_LOCAL_PLAINTEXT_BYTES + || !envelope.starts_with(LOCAL_ENVELOPE_MAGIC) + { + return Err(Error::InvalidPasswordOrCiphertext); + } + let associated = local_aad(scope_aad)?; + let nonce = &envelope[LOCAL_ENVELOPE_MAGIC.len()..prefix]; + let mut buffer = Zeroizing::new(envelope[prefix..].to_vec()); + XChaCha20Poly1305::new(key.expose_bytes().into()) + .decrypt_in_place(XNonce::from_slice(nonce), &associated, &mut *buffer) + .map_err(|_| Error::InvalidPasswordOrCiphertext)?; + Ok(buffer) +} + +#[cfg(test)] +mod tests; diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_protocol/crypto/tests.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_protocol/crypto/tests.rs new file mode 100644 index 00000000..1cb43fc5 --- /dev/null +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_protocol/crypto/tests.rs @@ -0,0 +1,341 @@ +use super::*; +use serde_json::Value; + +fn vectors() -> Vec { + serde_json::from_str::(include_str!("../fixtures/v1.json")).unwrap()["vectors"] + .as_array() + .unwrap() + .clone() +} +fn unhex(s: &str) -> Vec { + (0..s.len()) + .step_by(2) + .map(|n| u8::from_str_radix(&s[n..n + 2], 16).unwrap()) + .collect() +} +fn metadata(s: &SnapshotUpload) -> VaultMetadata { + VaultMetadata { + protocol_version: 1, + state: VaultState::Active, + owner_github_id: s.owner_github_id.clone(), + revision: s.revision, + vault_id: Some(s.vault_id.clone()), + key_id: Some(s.key_id.clone()), + updated_at: Some("2026-09-23T00:00:00Z".into()), + payload_schema_version: Some(1), + ciphertext_bytes: s.decoded_ciphertext().unwrap().len() as u64, + ciphertext_sha256: Some(s.ciphertext_sha256.clone()), + last_operation_id: Some(s.operation_id.clone()), + } +} +fn context(s: &SnapshotUpload) -> EncryptContext { + EncryptContext { + owner_github_id: s.owner_github_id.clone(), + vault_id: s.vault_id.clone(), + key_id: s.key_id.clone(), + base_revision: s.base_revision, + operation_id: s.operation_id.clone(), + kind: s.kind, + kdf: s.kdf.clone(), + } +} +fn test_key(v: &Value) -> DerivedKey { + DerivedKey::from_secret_bytes(Zeroizing::new( + unhex(v["derivedKeyHex"].as_str().unwrap()) + .try_into() + .unwrap(), + )) +} + +#[test] +fn independent_reference_argon2_libsodium_vectors_match_every_byte() { + for vector in vectors() { + let snapshot: SnapshotUpload = serde_json::from_value(vector["snapshot"].clone()).unwrap(); + snapshot.validate().unwrap(); + let password = NormalizedPassword::new(SecretInput::new( + vector["passwordInput"].as_str().unwrap().to_owned(), + )) + .unwrap(); + password.validate_new().unwrap(); + assert_eq!( + password.0.as_bytes(), + unhex(vector["passwordUtf8Hex"].as_str().unwrap()) + ); + let key = derive_key(&password, &snapshot.kdf).unwrap(); + assert_eq!( + key.expose_bytes().as_slice(), + unhex(vector["derivedKeyHex"].as_str().unwrap()) + ); + assert_eq!( + aad(&snapshot).unwrap(), + unhex(vector["aadUtf8Hex"].as_str().unwrap()) + ); + let json = unhex(vector["plaintextJsonUtf8Hex"].as_str().unwrap()); + let mut frame = Zeroizing::new(Vec::with_capacity(PAD_BLOCK_BYTES + 16)); + frame.extend_from_slice(&(json.len() as u32).to_be_bytes()); + frame.extend_from_slice(&json); + let padding_len = PAD_BLOCK_BYTES - frame.len(); + let padding = (0..padding_len) + .map(|i| (i % 251) as u8) + .collect::>(); + assert_eq!( + padding_len as u64, + vector["padding"]["length"].as_u64().unwrap() + ); + assert_eq!( + Sha256Digest::of(&padding).as_hex(), + vector["padding"]["sha256"].as_str().unwrap() + ); + frame.extend_from_slice(&padding); + let encrypted = + seal_frame(&context(&snapshot), &key, snapshot.nonce.clone(), frame).unwrap(); + assert_eq!(encrypted.ciphertext, snapshot.ciphertext); + assert_eq!(encrypted.ciphertext_sha256, snapshot.ciphertext_sha256); + let recovered = decrypt_snapshot( + &snapshot, + &metadata(&snapshot), + &snapshot.owner_github_id, + &key, + ) + .unwrap(); + assert_eq!( + serde_json::to_value(&recovered).unwrap(), + vector["recoveredJson"] + ); + } +} + +#[test] +fn nfc_confirmation_preserves_spaces_and_applies_local_weak_password_policy() { + assert!(NormalizedPassword::confirmed_new( + SecretInput::new("云同步Cafe\u{301}-Vector2026!".into()), + SecretInput::new("云同步Café-Vector2026!".into()) + ) + .is_ok()); + assert!(NormalizedPassword::confirmed_new( + SecretInput::new(" Good-PasswordA9! ".into()), + SecretInput::new("Good-PasswordA9!".into()) + ) + .is_err()); + for input in [ + "12345678", + "Password123", + "Password1234", + "Abc123456789", + "Qwerty123456", + "Aa1Aa1Aa1Aa1", + "P@ssw0rd12345", + ] { + assert!( + NormalizedPassword::new(SecretInput::new(input.into())) + .and_then(|v| v.validate_new()) + .is_err(), + "weak password was accepted" + ); + } + assert!(NormalizedPassword::new(SecretInput::new(format!("Ab1{}", "中".repeat(126)))).is_err()); +} + +#[test] +fn identity_revision_kdf_and_aead_tampering_are_rejected() { + let vector = &vectors()[0]; + let snapshot: SnapshotUpload = serde_json::from_value(vector["snapshot"].clone()).unwrap(); + let meta = metadata(&snapshot); + let key = test_key(vector); + let mut bad = snapshot.clone(); + bad.owner_github_id = GithubId::parse("54321").unwrap(); + assert!(matches!( + decrypt_snapshot(&bad, &meta, &snapshot.owner_github_id, &key), + Err(Error::AccountMismatch) + )); + let mut bad = snapshot.clone(); + bad.revision = Revision::new(1); + assert!(decrypt_snapshot(&bad, &meta, &snapshot.owner_github_id, &key).is_err()); + let mut bad = snapshot.clone(); + bad.key_id = UuidV4::random().unwrap(); + assert!(matches!( + decrypt_snapshot(&bad, &meta, &snapshot.owner_github_id, &key), + Err(Error::ContextMismatch) + )); + let mut bad = snapshot.clone(); + bad.kdf.memory_kib = 8; + assert!(derive_key( + &NormalizedPassword::new(SecretInput::new("Str0ng-Other-Phrase!".into())).unwrap(), + &bad.kdf + ) + .is_err()); + let mut bad = snapshot.clone(); + bad.nonce = Nonce::new([0; 24]); + assert!(matches!( + decrypt_snapshot(&bad, &meta, &snapshot.owner_github_id, &key), + Err(Error::InvalidPasswordOrCiphertext) + )); + let wrong = DerivedKey::from_secret_bytes(Zeroizing::new([0; 32])); + assert!(matches!( + decrypt_snapshot(&snapshot, &meta, &snapshot.owner_github_id, &wrong), + Err(Error::InvalidPasswordOrCiphertext) + )); + let mut changed = snapshot.decoded_ciphertext().unwrap(); + changed[42] ^= 1; + let mut bad = snapshot.clone(); + bad.ciphertext = URL_SAFE_NO_PAD.encode(&changed); + bad.ciphertext_sha256 = Sha256Digest::of(&changed); + let mut malicious_meta = meta.clone(); + malicious_meta.ciphertext_sha256 = Some(bad.ciphertext_sha256.clone()); + assert!(matches!( + decrypt_snapshot(&bad, &malicious_meta, &snapshot.owner_github_id, &key), + Err(Error::InvalidPasswordOrCiphertext) + )); + changed.truncate(changed.len() - 1); + bad.ciphertext = URL_SAFE_NO_PAD.encode(changed); + assert!(bad.validate().is_err()); +} + +#[test] +fn new_encryption_uses_distinct_random_nonce_and_padding() { + let vector = &vectors()[0]; + let snapshot: SnapshotUpload = serde_json::from_value(vector["snapshot"].clone()).unwrap(); + let key = test_key(vector); + let docs = + DocumentSet::from_json_bytes(&unhex(vector["plaintextJsonUtf8Hex"].as_str().unwrap())) + .unwrap(); + let a = encrypt_snapshot(&docs, &context(&snapshot), &key).unwrap(); + let b = encrypt_snapshot(&docs, &context(&snapshot), &key).unwrap(); + assert_ne!(a.nonce, b.nonce); + assert_ne!(a.ciphertext, b.ciphertext); + assert_eq!(a.decoded_ciphertext().unwrap().len(), 65_552); + assert!(decrypt_snapshot(&a, &metadata(&a), &a.owner_github_id, &key).is_ok()); +} + +#[test] +fn malformed_padding_and_oversized_json_fail_without_truncation() { + assert_eq!( + padded_length(MAX_PLAINTEXT_JSON_BYTES).unwrap(), + MAX_PLAINTEXT_JSON_BYTES + PAD_BLOCK_BYTES + ); + assert!(padded_length(MAX_PLAINTEXT_JSON_BYTES + 1).is_err()); + for declared in [0u32, u32::MAX, PAD_BLOCK_BYTES as u32] { + let mut frame = vec![0; PAD_BLOCK_BYTES]; + frame[..4].copy_from_slice(&declared.to_be_bytes()); + assert!(decode_frame(&frame).is_err()); + } + assert!(decode_frame(&vec![0; PAD_BLOCK_BYTES - 1]).is_err()); + assert!(!format!( + "{:?}", + DerivedKey::from_secret_bytes(Zeroizing::new([42; 32])) + ) + .contains("42")); +} + +#[test] +fn rfc9106_section_5_3_official_argon2id_vector() { + // Independent published KAT for the primitive, not an alternative wire profile. + let params = argon2::ParamsBuilder::new() + .m_cost(32) + .t_cost(3) + .p_cost(4) + .output_len(32) + .data(argon2::AssociatedData::new(&[4; 12]).unwrap()) + .build() + .unwrap(); + let argon = + Argon2::new_with_secret(&[3; 8], Algorithm::Argon2id, Version::V0x13, params).unwrap(); + let mut out = [0; 32]; + argon + .hash_password_into(&[1; 32], &[2; 16], &mut out) + .unwrap(); + assert_eq!( + out.as_slice(), + unhex("0d640df58d78766c08c037a34a8b53c9d01ef0452d75b65eb52520e96b01e659") + ); +} + +#[test] +fn local_journal_has_independent_scope_and_local_size_limit() { + let key = DerivedKey::from_secret_bytes(Zeroizing::new([7; 32])); + let scope = br#"["https://sync.example","123","vault","key","operation"]"#; + let plain = vec![b'x'; MAX_LOCAL_PLAINTEXT_BYTES]; + let envelope = seal_local(&key, scope, &plain).unwrap(); + assert_eq!( + open_local(&key, scope, &envelope).unwrap().as_slice(), + plain.as_slice() + ); + assert!(open_local(&key, b"different scope", &envelope).is_err()); + let mut corrupt = envelope; + corrupt[LOCAL_ENVELOPE_MAGIC.len() + 24 + 3] ^= 1; + assert!(open_local(&key, scope, &corrupt).is_err()); + assert!(seal_local(&key, scope, &vec![0; MAX_LOCAL_PLAINTEXT_BYTES + 1]).is_err()); + assert!(open_local(&key, scope, b"invalid").is_err()); +} + +#[test] +fn maximum_remote_json_roundtrips_with_bounded_pad64k_frame() { + let vector = &vectors()[0]; + let snapshot: SnapshotUpload = serde_json::from_value(vector["snapshot"].clone()).unwrap(); + let key = test_key(vector); + let mut docs = + DocumentSet::from_json_bytes(&unhex(vector["plaintextJsonUtf8Hex"].as_str().unwrap())) + .unwrap(); + docs.documents[0].value = Value::String(String::new()); + let overhead = docs.to_secret_json().unwrap().len(); + docs.documents[0].value = Value::String("x".repeat(MAX_PLAINTEXT_JSON_BYTES - overhead)); + assert_eq!( + docs.to_secret_json().unwrap().len(), + MAX_PLAINTEXT_JSON_BYTES + ); + let encrypted = encrypt_snapshot(&docs, &context(&snapshot), &key).unwrap(); + assert_eq!( + encrypted.decoded_ciphertext().unwrap().len(), + MAX_PLAINTEXT_JSON_BYTES + PAD_BLOCK_BYTES + 16 + ); + let recovered = decrypt_snapshot( + &encrypted, + &metadata(&encrypted), + &encrypted.owner_github_id, + &key, + ) + .unwrap(); + assert_eq!(recovered, docs); +} + +#[test] +fn wrong_nonce_length_and_permuted_aad_never_authenticate() { + let vector = &vectors()[0]; + let mut wire = vector["snapshot"].clone(); + wire["nonce"] = Value::String("AAECAwQFBgcICQoLDA0ODxAREhMUFRY".into()); + assert!(serde_json::from_value::(wire).is_err()); + + let mut snapshot: SnapshotUpload = serde_json::from_value(vector["snapshot"].clone()).unwrap(); + let key = test_key(vector); + let cipher = XChaCha20Poly1305::new(key.expose_bytes().into()); + let nonce = snapshot.nonce.clone(); + let mut frame = Zeroizing::new(snapshot.decoded_ciphertext().unwrap()); + cipher + .decrypt_in_place( + XNonce::from_slice(nonce.bytes()), + &aad(&snapshot).unwrap(), + &mut *frame, + ) + .unwrap(); + let mut reordered: Value = serde_json::from_slice(&aad(&snapshot).unwrap()).unwrap(); + reordered.as_array_mut().unwrap().swap(3, 4); + // Public deterministic fixture only; production never accepts injected nonces. + cipher + .encrypt_in_place( + XNonce::from_slice(nonce.bytes()), + &serde_json::to_vec(&reordered).unwrap(), + &mut *frame, + ) + .unwrap(); + snapshot.ciphertext = URL_SAFE_NO_PAD.encode(&frame); + snapshot.ciphertext_sha256 = Sha256Digest::of(&frame); + assert!(matches!( + decrypt_snapshot( + &snapshot, + &metadata(&snapshot), + &snapshot.owner_github_id, + &key + ), + Err(Error::InvalidPasswordOrCiphertext) + )); +} diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_protocol/fixtures/v1.json b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_protocol/fixtures/v1.json new file mode 100644 index 00000000..4c6e42d9 --- /dev/null +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_protocol/fixtures/v1.json @@ -0,0 +1,129 @@ +{ + "formatVersion": 1, + "vectors": [ + { + "name": "unicode-0", + "testOnly": true, + "passwordInput": "云同步Café-Vector2026!", + "passwordNfc": "云同步Café-Vector2026!", + "passwordUtf8Hex": "e4ba91e5908ce6ada5436166c3a92d566563746f723230323621", + "derivedKeyHex": "c96ca4c352b1f4812e6e79f31a0b47ad10de613cf9a31bfc54c0d31a2fd32c4a", + "aadUtf8Hex": "5b226f70656e6c6573732d636c6f75642d73796e63222c312c22736e617073686f74222c223132333435222c2262653430366361352d333766612d346232362d396139612d373463316161643438656165222c2237323530643162642d356133652d343262302d393164632d646630636230363038323231222c2239303037313939323534373430393932222c2239303037313939323534373430393933222c2265316438633332652d643230392d346535362d383733352d626336366238363834633731222c22736e617073686f74222c312c226172676f6e3269642d786368616368613230706f6c79313330352d7631222c226172676f6e326964222c31392c36353533362c332c342c2241414543417751464267634943516f4c4441304f4477222c22786368616368613230706f6c79313330352d69657466222c226a736f6e2d70616436346b2d7631225d", + "plaintextJsonUtf8Hex": "7b22736368656d6156657273696f6e223a312c226578706f727465644174223a22323032362d30392d32335430303a30303a30305a222c22736f75726365446576696365223a7b226964223a22666978747572652d646576696365222c226f73223a2274657374222c2261726368223a2274657374222c2261707056657273696f6e223a22302e312e30227d2c22646f63756d656e7473223a5b7b226964223a226d61696e222c226b696e64223a22707265666572656e636573222c22736368656d6156657273696f6e223a312c2276616c7565223a7b226c616e6775616765223a227a682d434e222c227465737454657874223a22e4ba91e5908ce6ada520636166c3a9227d7d5d2c22746f6d6273746f6e6573223a5b5d7d", + "recoveredJson": { + "schemaVersion": 1, + "exportedAt": "2026-09-23T00:00:00Z", + "sourceDevice": { + "id": "fixture-device", + "os": "test", + "arch": "test", + "appVersion": "0.1.0" + }, + "documents": [ + { + "id": "main", + "kind": "preferences", + "schemaVersion": 1, + "value": { + "language": "zh-CN", + "testText": "云同步 café" + } + } + ], + "tombstones": [] + }, + "padding": { + "pattern": "index-mod-251", + "length": 65250, + "sha256": "736a3902822ea691871b34455e9081075270deca4ce13a7c4da362121c02353c" + }, + "snapshot": { + "protocolVersion": 1, + "payloadSchemaVersion": 1, + "ownerGithubId": "12345", + "vaultId": "be406ca5-37fa-4b26-9a9a-74c1aad48eae", + "keyId": "7250d1bd-5a3e-42b0-91dc-df0cb0608221", + "baseRevision": "9007199254740992", + "revision": "9007199254740993", + "operationId": "e1d8c32e-d209-4e56-8735-bc66b8684c71", + "kind": "snapshot", + "cryptoProfile": "argon2id-xchacha20poly1305-v1", + "kdf": { + "name": "argon2id", + "version": 19, + "memoryKiB": 65536, + "iterations": 3, + "parallelism": 4, + "salt": "AAECAwQFBgcICQoLDA0ODw" + }, + "aead": "xchacha20poly1305-ietf", + "codec": "json-pad64k-v1", + "nonce": "AAECAwQFBgcICQoLDA0ODxAREhMUFRYX", + "ciphertext": "WbAldhT2zzHbU8rtTov5M52mleQHMWHypydZqCFdplXlHlubHmYZiH_1DSntJ5WwicmswO7X48Tdro39WS92_nwxZDTo6_ghp8fIdDT05UTYTrMN_RRgXUBX9pBtLuqfo7p3bn1eo1KrQtNbuu-V0r7ba7mp_fjbgC9A3LBsVNQEBB5W3LtBqqFk2LaRyzDwZY3j0ne5LJ7q0QvhGgY21hzGqcGFvwSD7EVDVo25Rsy5FPFcpSJkL1ZnIat4L9h0vf_eY1iTeLVoG1d2VTaEVU1wasJfR2K0f_uS3KYl1oaBSJPLPTESglHFyzhVwhyrE6hxQlx42s-Afyfpk2hEBJnfB3APLM5pqO1jy5RLgZkoY3tPhWKSD7bgmMlfmQPzOTV8xQ1KqUCceILN65WM116USeLd3Vau6V6OpwWSA67D0k-pv26dNizI2TMu4zP7NW-g2W_N0wI2fwHvArFOMLaXpBE4uJreNT3WFf95bPpxjxSMjBXUrKPSoVHUgXbb2p3udZn51Wc_iouWJBJoEPzpyFTCxXVVuycLSL_-Owl0HBRR3r-BU9MjoHviO-vMx9NoxxlMd9-hDMITG3zqUjyIT6RxtRfH-1clMZDcEm0SDaeAdvXRvQFxxV4zX22HQJxVbSvP-Z1CS1IqoMvor-vJJ36fMrsHHuGxLyzyXwlxYW4lUSgmjv3UOZX_k9-TgjLQPq6Y4Pc81IYydtgwHSIdZQ7AAb5MxoR3xtVgM7cq8xWN97adNca2cGGWNJjvtJfk6hCe3GwUijvEJPKmubYRkqXeeihmS1tB55GkyegjlZlA9hsC8fXXRd9wuURc7qmao8cGrK9syInTSP8ozFSNvom6td5aJpLm-4n5567auDuVlfVXszgszjdDekEqwdXVkD0rlGXrTpAMRm0-RNUvHOaR-PiUVXPdDXp1hmaCJb5HHAUD0jIvwkJfwGloymNhBYJ9988rz8P9ainCPD8R31ZVoTjHDyzyBRKGC3Tyv5SJwCs78wQbz6KmLam4kKUbaMOLj0fxrhP_ZPajI5h_7JUmB-0jTpAE8ti38pLxPhFwlmEi94mbkH0V0PvYKa-d32nG7jBTZEjUURYSe-dPXx1jqmCHjqi7VgwrBTtnhQC3UumgMnOci8hmXcI4T6Bq-dpGuu4IcjGDq8Jwy3VjL70a2fslGQdnJ5_kbcrxy3KtEoODRW9iveyAaA2tZFMXlJxAoEG1Pa_657gj7-iEWsVz-rEU7M2UcAAibGJtkz9tsPAbHCinOoRqRg8lX-ndbOhCRmlxPOS8U97LGAiZw1htts-4cPXPuR3BKcILAtGrj6v8-DraMl54lVjz-6aWjkGHRfY0t2dlqG9jLFS1nJ_xwiX5aRxTxabOZU0_Wch0UPCjLAKa3q42sBTjTAS_zRC-4Q2eokKoRo8FpAAoKgv-476emu4ysHqnRJrd0pIUMgBrhKhgqjl0s89n47ccGJnES6ih9iKfqffmml7WJ28v8lrTnXMK4AiOluONMGnK0L-OHIV3FEt9x1IMH_WPGlydn6CqHNclDOWmr9a4NfMM6yeWzNXLFnYlkzidI9iyOFbhKHrT9rdxUihz1KzUxgxzprJspVrxA7sUk7dj7ytXYMuK-go7uuL7fviU_Py-vplAehvztKneajfnRJITUqr6BIvqrGJgZ050J1VzBuPoHgEFU247QbCSVF2zm6HT_u2Ebb81tD4fgvFV3pxRtbToIeQajbnmEVUYKHHq8BYetwX3O97_cLF2_uHNbooqS9cxEGdKi1nYuHOU_jlGWWEJ5SNzt3tvPI3jN2rNKJADPcblzsK8f6jy9ysOTgbGMYb4D2GM1QP5cyX-ye72umQqDAB30UpwNU5gDor1dMp-tybPiIIutszuleo8UhHVz3Em5GLdARmnEpxWo7NeN74pZ47lswUzDhWVy0bXOpHmeR-Q0YeIjb5StJioX-TnOgjhOkULEi95Tfw4zD__QMegdKhm4WNE_aQzefAMUMc49nn9UNGJmfIjm3P4aPq3hEvnjqvQa_YeKTsSqFiqpv5sIwFL1s98aPw0Qnr9VXSGGbSwzgQsBV8RR9yDPkOsI2Yc9ppoNt7LPEa_GVv947oTeeISWl4MbXaUaujSvQIudVJ7gIk4tovl5p2py4Z7fc_5KD-WuBS3tPkQxs2_H-U59KEUF_Vats8gdNKE6tvwLJ932erTj7qa1iDsIYoa-DTvs8-0xL4jt87AEC5lC1u1Fac5wicYvjnG3GJEIAv6Y1xgt781w4roIwXMNlYL4qvyM2dbQrZLVw_39K19FMPqWGbutv3uH_lPFYJMVN6_ma85SDt2OBtxcM9UnNQ_TF7KxfEGZGoo6eLo-fi35RWJcsqL9LQ4-TZZz3lzG8nurqOBpKbUmX9xu8L90oqQO4RqfODmT872mgJW98qjW6xNt3wdcSz7kk6viTFzw_WOESmpaKono_NL7hsPYfYnsbYo-R52q7kVNfsV2j2WVmmmCJAsS1RDDN2Z6rwLuLqxh9l9y_FfgOoczWWkp_ikDPP61hhjVXJVZ6wGHgHctcG-ai_gl7GQXxk-dWDb32quSzIB_uCOuBnG-Ap_6M_x7KMAE4B4Wx8DIiOrm5Zulb-W8ucVbFqVmSS4dGv2TblxjOK6FLk1-MnuI0AgIcegMXZ63MFRPvqSsYgNdsiIbNeSlyq7Rn_SdoerhkFOJiKzx6vPqsx_gJx3QcGgDgYMC39fuXhrrUphVu-5hB6FVyT14d-vDjIyHwq2zgZNHGtaMmwTxZJeRrbvO_hejh7TW2joeozpuDQlmBBseV4H4ICI7Ra2R9Hou8YrfLMZ6zmTQUeU3upxvCb1fggZAjwXX7biBsfhz-Ez5MZvdHm6O98tL7ZTDjZ_R-_IAGTLnyk22-I2UCexRc3ejpWVDjQkhy8UDFXoX2IcM8MOZ86X3szNVjyPhIBtXUQQVNPwpJ-QoZzoi3627XfDX7GA60U4RAdBGiL8aK2_gSGV30F-21vYTw3zIYGgR79M9xej3Qr4geLHx3c64Dr51arJebVpMviobCHUfO9OMgYOEX_QOTDIHAB1yB2QSFQaN6Z5O5VchRgZHqnqaMAs-OWo2qol9GbPR2hH3FlFL_fbTQAR6cL4dPNT1hqd6hLo12k7XlCoMqNHFaoASpNjGzwrXhcmYC-w0pdvtdmoShij39pp9C85DwLKURo4dpEnP89KKt4LxfhtePDv-5AFU4-EsCr0tTpHZXgscByYU1a5h1E-WitM6KIg_Dqxkidf3dpEY1fF5hjY81am8itK4P_FSWhGeeorFSeA4O__GK9l86GzbIMJd-iddV6Pc6jF2OUhwml3-OTm1irSeNxQYV1j1whF_LsmgdyIBxtx8XVHo1VO_5fuEk0h40uZA4fAQXY3Xe0xnYeZ6xOs9jRBLIICASRuAb0e4BYs2_c0djY3GYLaEj0lYlpELDdIDKfa4trR3EMInyQkMw2u7M7D1Zurd6FSMbdAyLHIAUv9LKl41JOh1-7Gkc772v12SM_BWogrIHHXOcqeHDm2m0Kl7h06cfO8axkk0eNvsOr48hYqzUbOzCOWF5InUNi9G9_YpOYiYl_lZsDWXyEK0GwbSK61EF9lycS5yr3dedMex9jvB1aKji8FgnZgSnZSqN5t8F9Q66adjNVWVa0-U6VU_z_IBLJT_LbY48o58YV25zUAOpUFAP27UtKtnCU70397UcjHtS3-x5BCU3koxzh3ZAapktf_3bNb8iOEmUSCCuV5j7S3DGDmuTzgFx3MUDGhclvZr4_JBiVgwZFHxD0IzdNMvzKnJzmZ5tmrXJZOObx7RMw5OV016eLcXNJ7uqNsxG8fjVBpWNiC82opwHOM1OPZUI-PG6XblKGPBFjEdO_d4f1my5olKPrGnPmHFL_t4aHTDyHwULTRNkK22-Y4PxNOyhdyqStFSi_iMYy7GI8ig7UsmBvp8PgEZig4WhX0mrPI5APXT_daiIwEk9XlpssI1SZNCBtqx7IctspZ9EqtfCEg7pM6Gve3njgx5teWoRAkOd-kczBmX-FQMbrBk5-MJ-WTTUNV8BqKIkqxioQ1tFEfgR7cKesdHk7ONU_If0sji3Dxyst8lfxBa8CFNPRzDTiNlYLT5iFhuJP_0otRoL4pjHFzCylX1DSb2zJ-bFCJtOmXA2pCdXyFQ6JxqbE5de6dnW02r_fjHf31sdleZcJVufhqGfzD5Px7SsOIkC2Gbp_-rsPi8FZOh6QFSbPtr01iFzVj2nGb7m5F6B4MFvkBky0V6nGoiURivPN4vEr2_G9CUK7ORfED3rBG9H2kv4_CMIDJD5NsOXdRwkW4CXXVuVwL8wSmCpHDVEEnVgbT7woi9yaijREvK7IMZENIYPVee0M1KczifTGZaRodpudq138HOtGP8Hb2ObhSyvNuOfBC26Oltd0W21Rf4fj-lQP0b56x6b3qvw7dobLqCDHa3TcgcJ1sUlmiCkpQTqfyr6Igpwqc3oRwTcgJO152XJlm-A6TW0AvbMts2hg8KKgpchUmrvWrDIWJTcRxoMz-m7zzPLLZty1euZNbffp4Wn4i8ZqsfLPhqDGK420wQ-n4aZEcW6woGpAq2FiVRzUn3QbUaeh9ykD900APu_xLG5dJdU074hN7K5bFGf_5zudznbfUjjRay0RNUgza75Qxt1OvFYzdBm-MclaXhvpAF1kyTRNAh6Vea_hU9G2z3S1zWylAMJBnQmBhsAMhjtdICZeae3nbW61ntvJiX-12AncrRm-JYG8E1_IuQMrICK0sugaQYzfiGxdgitU5M85hjSLkvDMVN23Z1EtNuSgZ479bGfA5x42QuJU2aMUaprLMOhNjaW2gWj5lImm9hI_Q6Tg7vF05CNk5cKVdhoNwEeXbEiPbWz_MoRz-6lnhElTxVJ6I0zb0nTillEMbDUlWWO-p_djISP8crmd0vTnxhMtYHtyIX_ZEOxPeEO3AhY1Bap1MwtUDqyYPVxlzUeshEo13xdVS4MCopGPoeR9wnSebW2cUu0oY8rhlJ1AyMC3fHC2G7yl49qzg4M-0hzi0jH89rVnarzjLXO6vkCBbK3EjZtPME7LzTMxrzUURz2m7rMw_jXMKaD5mIR_wpPBsCb8B5T_Zxbfbg7OTyLBDhLewn0Y0RtY7wO8TTIIiacdueArzNLOnY3MroZy6gzTdW01Iy8U75K_12xXxUa66HY6VHctlG8YFekfHwCf_i3lC1RkwqciLd2Z9-F3OEuaXrqzmcczvMUNTKABXhtXA40KyW3R70HRw_Prkam8iKF9jU-DwhGCxXUpuvMFsFbPG8ASVNQLfzX9nKasuBygE-PFy5LmlDmG-xDDLfeuEAib1R-xnwvPpAd0ZI1gZClH4qknynn_caKf5X4KrQPjEgu34BNZXBfcbVQsb8w5PA2lDgsi-OXI6ezZBsJ4P9tu8gxX3GIVlKKuva4QD-GbogNYxIgCk7nmaMIrvHKecvxS_ZZlJzHBONdUBTwi-AGA4ed8auWZbuZTAeMRx5yNm4FZIYgNcx-p8ZPtdZSGiu1tVypUiUZugN68ym-PolSHAZBOL_Ssp9LV2qWcYcV3M4Q4QV1zx_TJy782wqO6FQQQJyqQfWCKUhedSYRmXr0CtlTr6GYc1ijn1h5PlsWh_SzalhqkKTgRApIEJ_s6ghXIjzx-KmuHGIB7sxzV7y0KpQ8jqDtoPFnp5M5hzeZEO_7h8-LU7zNg-Q8tMiU85R0KnMWLJM65T5xWKykS7HZy7cjT-8rXnuWMj3s7wDl7HW1IqzquBK1vh2N632X0_nZP-XrL7iHc1H6-tmD5pKExH2oL1aMTeWVOvN8c7JdJmAYU7XLU0Zh843f11488NG8sgJysIhKcquJGwlmcHicEBtQfOuNU5I6bzZ-Q55aRm0g4ggjG32zDATrC4X3zABr3MBt4cKeFinanTWaorkh0Si3UCQKYrIBXmEeD6LEPhwuuLLsYfIs65YL9-bjCcuBrOBP_5ThemcC_ClHMQOeTTSiY-oKlrpdVx_kpXCHYKciOj-cGsgLe2tPhue8g7h_FQG7e-HlYpJOu0Ib457GE3p9uqG-GYY0jlsE4Jt6l35U9jpfGKfDE7bnPclYGrMr3s4HHCrD9yQg-CSjqlMmShb-uzry7iJDH04SXGxpps9E6_hNcqmCjV008Jj_vgW7iRaCercLezMjWKDIadDGgvyjhlfswhN86VFDxonIcIcBMXtnhGJ5i1TsAQm0FONqigUiBo92cp0afju98QCz7aQ7ZN9941JzON1LD_B0vklRWiK4RxstcdcXu9q3VIJmSTW_UMzfqiTADmiMmTormC1OvAVOaMKRpekXoagVr_Y21FkfAMB1no0mydDrW6oTMuZOfvJL_NWBNg_-XBekNNA8VKotei1Jpi8eJXA8MAF_gZyVPb7zaD-Fd44jUaQrZulmom_kYjP-Dt9Zsxa_YRFdvw_V2Uk8RiD5VrWpZxNn9t0ZYBnpB8m7JXGXgWEFLpSYrIXP_fBbDxF7q5TI_Cek4ljijVC7PfK0ocoOTHVoCcp5-ByKVo-g09zHcIhDnvmIO3PWL3Gcou-E6f5XinxzdLD1vYZA8b4yoz7E-R8CIsyjP2B_GJbSEXCJs14XBtBlzi3f4ILrDHgVDqVA84UTv0J2O8kSORi19Y0Y7yS0cxf8rwl_t-A1EdDH6gxUKZsk8q00ddadKoKLPW93AdpRMjVHFC_xM-L3XsDUsaCikY1beaOxbJ9B4Y5vv61-yBPee9g9pLuSV-sZdLDWlF4-1VsVk08DvUA4vaphAfN4WEOY-2QYPLjMXw-Keqh-dbma2-qnKpaBb1SDdf9W213osd_P12hehS6CSQB9GPUP0TqtMIdjG019Gfzm_WYOWrBk_E-twn9A46Ij_bz0ixpT8ujFxgKIdj1BslH-YophBeOWvUiVwMBtIB28MCq5nCKaEzkjSPKoxlBEoYGY9Gir-6C4r9kN1dwHRPTKIn_IPSSqdQqQuxodmHJA57_Os1jAai99LkbHk94iJrfSxa8MjXiaWpCv4v5B8PkqGqxkdiFlEFwqb6oyBS7x1IL4lmWjM2uViNlYf4kP13vaFiRsZsplAJImexWyj3Ohort0fnzOeBPq3We3kRctYG277mSrbqWp0Gav_sSJHLGqX4XTyC3BOao6MlgRdh3RLgcnMx6ITgw7QQhDgaywkvUeGR7CRcD1lWGqTW2c2gtLE10uUFjEYJNFQHcZQPSw2Bg5L_GYFTe0U8RJBXSNe6udyGJAXWLNfMwgelkZwNMn0bfy5azfCns9SHqzJ3-1HYF5R6tJHISi9ItT2UWRQ7eg-Ch-Cy67j89FdsvCRH5I1czMppOIvu5yW4VkrL0SiAgeYapODrsDVyVCQgDyQxUfNZuP82OZk9h4icfuhyCW6d-5OspEtiUrg6doVjNWeGCMbfMlYDUHbAfHFJPkyJLo-pmaSLk9VM7RzryH-Wc6RLThJDPlTa08BB3q3v67Rr29ZI03QsO-OzYlczjdh_slDJFAd8JydFtvLtbFdsY8Hxxsc1-tO0V4-YnLYT0MtYmnuxbIqtjjHDx53XsfkpRfEK79b2sB186oqmGYaBZoCJyg3Z51ADwb7PyT-gza_3m8319vhbNxYlTSCzDcC-qSWHArwWsgFrn9qp0eM48ZcogYy4LIlyiWCNa1Xv_qhrvDqPzovbdQcArDZhNuBJjM9lTTvdZ25b3-TQ5UVTk4g8lWXkjKHb9HcGcQVvAqVCk9TWw82kUY98sLa6THH2ji5L_lAQSXV6o5XAftBBiW8U3BLPpQvYi1jy8CrczdsZe-vdMUuP8KUWhdacbIUo3UZRGv2NPfc2p-mqADYcJFsvjvehVNrDYY4UAV3IVpkKCBMaf5tgK8b0HKqeLvFwHiJPMtA6sRS8tA2qfWoqKc_pJIwwA0bhQqYrx3FjovHPyaZpN1MV9aaOA0npDwWbLrOplkymexP_GT4etSDYln6CVYF4PcnLN4xKZKX578fF8iU2R_fcab6LDzLbYyPpC9iTuASF5fDpuS0pH_OIzmqyiZrrZcoHrdr3s0OvCdpf7Es-KOmYKFw07qFhjVDK_hkHXZHnr3NH3Ta0OO1FACVkfM171FUh684vGJeRUsGFgOFo_M1tFAAMER5deFPcaFQdNKIj46NeNTX1FftaWi06qVOz4nca2LlowjK1tpxfZaT8mQpwhwVNIHVV0BCGAsrArHuiy6mlPZujFB1k3Gt7Bb0_VSIg5yghbm26CzXVnNBad6vLAsvFLwvB_o9KG6f8I9ZFFrLhc6vlcFzC1Z7rV8WyTj3RGCLJHN-6nNZ_VhL3Re984pjnBUqnGuh-1ygKlAGbV4GnUXfahlFn4aeer_NJ3epy2n8Qho68vTOTHkoLNZij1e-qPRg9okoMi8mMr8I5gqIHVzNvBUSBXtOttZylRRBuNXayup7ilmmteVlHuCv432B9V_G4O9xVvwgzMMvI9Ffj0CJ0wghVfBwEeEi0kuwrrsQMuDdAYg7HU4QO2Z0g4gMyylgmaaJq8GiXL9YUbm8-jYdj4kDVabv7pc_Fgh0_vbZyqm8JvBW8V1xnQEIMmZNKeeDin5bDXSB52y88v1-mlEk0PG0nkbayFWk6tgj8eHdQnA7Ky_bkJgi-99VFH9oQQF7HN1fm3UduC1CohWj4uhf2_Edykrm5htZzdIRJAh3ni5inA5aK2CRpjQkpUv7leCcAkVRL7HYWV_yBE4QtSvpsaOJMiyYrny9N185aVz35AE2mRq0_7-EsadEkHVWiequru_SGWmPyQBVz_4GNKXYbYaV9NVXT3zOARWFZSZJA07aAttgJ6H7QK-puC33ynB8cArScAb2sPyARfsvEIM9TShskXK0uuVRWEZ-49iCQES7Nw9vdYBrWZTnpJuUYwCslWyKeixTNF_xUH-1JUWf3b47V5gprqk4LEYtvmabTD0patnlR0wlXB-h7zlpqtVTcb91apcDuMgVcrrMb12MPk4HeMlAbO9wp_Gzqac0klBfEcf4zOcuDPXQjf7AFuJSeQdVDMNaDzOP-qZ3bvr4ljaY1YfWJJ6ArXaW_85lRc9udR_VbFIZFtscABPDBq8FA7IyV23X6u9-oNY44Rnv6cx2pr6V3L-GsXscxB9QnHrGBghv0k7B_2UtpuZ13JnkYmTvg9ijZB46DSIdKHoitjui0Iq0U4y3NGsvH_6bXT0Xqxx8_IPUdHoWwqjKfzv44-HMWUZ2OzYNnlZR1rkl0ofx4J_N_w1FHZ633ibIzHdXkTileQwg140zYkMacHDD2hnfD1H6aG0SXM178B8KuJjRtKzIe2tNl0tKa1HM02uwTq7p9CXrJgl9umjT9f1Ahfeq8ju7IsUi1slksM6hj898EVp8iBdMe4PMS-A3NFeaFQjKJRlZ0Vl31TmMHhV2En83-6dUngd1KaWtZbf7RGmrPxCgS51CaO4FSSL-5hR5wkYdOQP4qMtWSPP8OWC2Xo1YG5LpxsjzviM4BuhfVKIaVX4zqHL8CH7x6DatP0q069WqYJjLqjIi8kYWdzjrLj1QP1RSA_wo_XLV2XoQAE0s69448xo3xS8cO0yDnSshcgd-2E5Gw6IPQj5hs8tJmPOfJU0phMOi_ctk5DD2zrseK1XjtTbVXhHMVgQ8kvDPhqWxw89sodfDHGbzdeiqEUS138xkfPtEdW2Gf1h0hdSAGwFWqpmL8T_kqFEzN7NxHILpVc5FZU2NDnhl_hxwcM6GFBnLBsIatTk0hkEUKn829zC-zDyGouBUbkZZ9GAFH5ikBSTGzSqsjkKsxXez2F0BWhL3oljWhBzrczIxIjMhuw3PAiPo40_6r4ubE8TnDS-4U-UzdSiU0YPgE5e6nGkrKyZTqueRdPT-MPNhPKdolJQ2uRZm_Qq6e3yR8oYJZRSItlx4nCKSMMBMpGrLGF_fHmjSVK6EJbnoVWAOmzeu0xxtRyZERCrhR4lklmqnX2BEv1NSh93G4lpSAVDbEkUiyjRU6nkweXGt_pJhxk9k3_0mHyODdJMphIZV_uoXEEM7dRRcj5V32VzunvZ7SJVBOrx9LMLKXyDiG-D97-GbqU6b0_itZljgrRhHaVQIMNJ6Kb8Eb9EZepti7nknGu1r6lFnuIM3BJislEKhfSNOqwG3B2ZvubZbSvszbeOT_e1GkyBTt8rdqpG4dFWTqZcH89jVQArGIcM5PlwI_VKak2IOTyWpDLpHrzngSieOxuG4nRMqlm8WseO7W0i-yCiwYqkrCjnNcyMIAvJJzsUG4kP-VGApThVw6QTIcReEtkoz-deSzp7AyLnMAKfjCJ_kZHPAx6FEWARJ3YXcF0Ng9QYLJ9hCoZNEWgBPhOsF4ouR6qzGMPgrSzDfRln7ssDSHKAYwN9IrqhSyoRb_-f7WQYyGEhX8g6xCJy-3jQiJYLHYiUbANTPsuQEprGrppWlAg_kzDU_Te1fZ_ni2zusJikMBgJyhtsCUvKe8DM1QOkzmTLPWAIXJrPphpSHG_uz5ryGZu0KEeoSNVmVOnBmEk88nE2PWPDOoAUwMwpVBQkoScnGTG-Sj8bVyp_fVdWANLUPqJQNinyWlrgIh_NBbsHcd-qkNWd-Q2rDWF5Uo3ELLJu3M9SQMCta5mQiEZ7IZhTjh7rwNvd7s2jXsfBEUH6wrpdQhP2-2OOCff2emgAm1AWeiq_rupEJ0h3DCIa9CmgLVwsu05rHEmSa7MMRDvTEzQImIylaVY2Yatl1C3hQgRGNmjXc5lZn2GGjAkeCZMguIgd_TBCJH5D6Lf7Hyz37wJ3ksQAuk2EofO-AxKwGuWqe_oz7UTkF78yq4Wu3fmTkUwVpPm8nH7e7fr0rdfoBVL1TLD9dmjZdcM_sfT8RqfQzxUgJ90b1rcd0UPlJLHvvWc9pi0Jnn0tN519LvEcBtO3AtJ1gJA0gBk0rmBQ9p_rGE2saabaeyHIrjBLBKnet3pNUdi8zpKCaA5jOlOIufJDWB9jnYiDMh4Q7JaSO_3ThQUAe-FbRplhKiQMdSZAU_A9c6ERAnffZhcEGjWAZqOZ523hiCrW0Y9CyjELTCLr2JFiis2CH12u47e-GhfZgwuALk46h_ns7RzXETiGREpuoahydBLElWOnlAETj0tHDOmsNoGytM9zYA6MPOgTPYu1Or3bON_s32HkrYp4zToYFx6_tu7Wf9-cImUEkX_7QFESoidlyPF5F6-udaYPNOLY6yDFwNIrUtHlbLa0hFKGVPNYzz2DOIln1Yad_RunL4hXJiK09VlrmrXqGiegMJrE6bJ791qvxcCod6nJZI8J7bzcfX3XdMATiJVqMus6LtS-EjHiTFwXeJR4IpgbYyp_KLtBEyFugrKdtJq6gf5qeIdPqLrgSL8BeLKkUPSMC5jovdSHegWv8ppa4xj9BtGTZrzEdSI_saL0z1tB_M8bojkscxUmTsy_r-NVhttIK7I-1nqF3zh3mdAXLhN5wyPWg1yODHppxQbJpZHxPQauhrxKVr_fK0b0lkOVG2juvroNG2Q3pfNMXvwg3NOiQykb-bsL8keXFlaabAhzetUCDX1yFFCanxHLj-iNtLst41f4AWj4gozbHtfhdivPZrb29z9hFXwYe3Of7wlD_qwnMA3QSEyc0FXqqAQP3SLFIHDYQvyKi2kSrpV_aYIxYSNa3VQzflZ0dF1LY7ZMTw-zl60dv5ecIshk4Stwc2mzzatGrkaxBhuH7RTaNR1gct-mBT4Mi9899Y9urAFeBr2Ed6-vRov6Kns9NE4Uahu7khvYRt006AY_CLO9OId9KNEa8DsJmCN-zyTYyljb4Z7YYb-7uMJII3-4fvnSzWjZm9h8A5bxiE-_k0l7s3AcDCNrcCL9pyey7sJADhfYiPPXAXzGg3jLhZbkqpZiFVSbxIABXGQuKjqeQmoo1KDS65UomvuuCcaAHJGmBX_xHo5D3PfFdJC5sc5IGUeYO21xkUslhoLR_MiJ_2ORHEtYh4lxKdBexkytcDGoWKqINIRiDljaZriVpqQqOr0uO_Wk35ozkLx0q52SxwSkj_XiejwtLwt4JjNWAysuiTnMcZkkO9CSFRgoXe_eI-m70CsnytabJtSBW5TB0FEaFgsPMZCcb15hpTc9pEO4P6iFLktedeTXtawmoFo__gSGY4k432KCnNBpLWNsNITv0DBYObricii9fG-Mug6cu98d5PJcFritWF7cXcOl_0oia8VO0_OXFebVNEfQbi5h4jLJ-q05HF15oARBAPRgO37OF4E3FCA9wd3gRG0zcr9De9HjsHRzDl5G56LoY95zcZsikFUZ9UnH0C75M01WVBB1Mwe4FloiBqrBZ0HpBzh6G0SVjTKYNVJu-ZMZYRCNWcqgyzDUUV0pv187teEWPOZS7uV6YBLh9_6l_vd8YZOuOo095gyY3w7GwomNjFuR5YrOJzlZ9MnUcto_WxlAwIGV_P9nf1a4pRsMsWQQ_ZM1SPSDeWUuO-b1C0gc1b3Fd2a8I9vNdZKimnawy1g9kVAzoGmTm2BB8HZG3iEhFmqZM05FXUYkLBLzBz4qRM6AKhabCgtwBeWAOJ7BHWD3OKy4qCQ0L_iXLHLsvl6bmRl96Kx13AoAOwdC2g2st_jZv0zSFV_y7A2tgBUCFCg43LLCQVtMbHofuA9kiCHkNNpNy6SBuH16micUheMn2npM7SIbFyA53MuF1EwRJZVwEppAhhbAIuo2ophso2SMRa739wEyWRgreZRPomgm9F_KMUpzvDJeMIRGZ97oZLWyDwzWMlPBAZL9sDiM-ejvI8f_njsX6EGCv2qGngtnzdyWu0atq7rRD36dMtuTEqgodz954pCQLEdiFidFboIKOCMZqTywQYXJQriw-zGhAWUWWlNfJhF-tzJhFbesLr2pQ14CsgbnwMD7CVJWb6dwASFCBy8GX7WXwFrtBu6Wkvvavz4koXp3U14gpkcJq1DX52OnrW0OA8vpjWbS8Gz2hozSjVCHizUWJq_a30y6VNPgRK2vIoolv4Hp3UuPAerzR24UnEbatYX6WdsmaI7sueKpOVn0EgenXsNdRodNa6TA6CyfDq3V2zq_Gu9S3g_NIPDObT9eNkD9mysS9VqEew08pTxcN3rWcRNrjWpex_9e10EEjibpmzORxCeTdn9MkHRzhOtQ7aN_hQLhTRjX51tMhwi1jVWjQY2W0N6WmKEsky57DAnrC5J_Ytxo8fIgN991c3crqu6h01mRXDyh_ImlVh6Utd9i1BDyPEDwb7ad9eeBnVx8J2irllibF7OQmlnizX-Cr1m8ivIJINwo_AaRgI08ErjnJOYD1i6z2W1GwoTyUo1sd2sNN0Hyk2C_ojtZKwxFzYeURuLwK-EYHpoo0GUm0sOEaJkSQAUhuUNJk8iaPsdpkHD1QlQFnh8lsN7RbFEwlFdODawkvWufFRXiFN9GUPjkPlVCNRXIXstXmoF0Uh5j0j3PZ3hAnz5a_-WqbwBtkOOmFEdn7Hi95UN5YyBM5qaCjvSaxE73IHJJgL64Wvv3oLH3rJubSVqucj1yFxxR41iKfgUHOBofYA5WARe2nzrm673szeCjOLplqjBb0n6bXBbeVSMPWUrug1e1UOZPkzMYru7dxJkF1sxN30dzOMDRMKGa2d0nB1YOnP006tK-eFRmK5tZIQNx5iKpIMQonZ_Gmbjj0QPnFQH5O4BXBOT5mUO-ORzIz0yrJJSH_WQJaJEVRq35hr8vL_B01qiYRQZvEiXanZ3z0Mt5Xf5yaiJxs5Jl6jjB2gaU9SFZBv1iYHt-NNP9zf0i2S7s9yF0_Wg3qNZDoUbA3RESZsM6Dz72VBs-APrqJepcKA1SDMFlyAqsuzMASZmQdfDg_axlU8hcVNPI1KxPONuIUWiqwBP4-JUspZb7p9sun18fnkFI3aH8nze_yQCI3R2_yQlgBUyDuHxFDTxchVMTc_LB3seUP7_jQ9wE7X5zHmz5lJN9ZhLJt4_EnFO6WoZQtpH2Vg7oXwFCQ4bFLbDEQIeA7iEOt-qX0gecMehrFCm3BlC1Lw9ssuGLX-EhjEWdQAc3M4DKeqbHouKjKiNUgBfWZaob8-V8g_OqV9hi6yGqB3mY_VqfpQ0cD2PQccVjEu2dKc01_tjXV4ubcrK3MIdTa_t25UUNPWyErjFVgZBVsT7so7pH9os935NsdkzUMze8e2LguKApI_xOxCjXvO3CE3aPK63B-C7LHYeS09v8aTMnrqp_vUHbpgj9Zhg0ena_aOxoWau0xYDDUcLG0b21713KYbfi3H431wbDIqsxOexU3KPzsUSP7MTyqOWQdZygYSlzcUZQjdpJIG7DeFGe_UrNGaAC4Pjzr-bwp6DdkPCfzVCeWupgpE3CHA4ZGLr6OPcbKnICmMWgei-e5bytWjIPQh9zIPWkNiev246MEb_XOnSeTyccJI59MeKK1_3ANf3kBmSP58ltSEqTKhv04npzbZ9QdrqdcY8xdLocrs-mt2khoRkEfb1DOcDjxbUjIGrB1jrY-dgNOZlhQqKCQYYbrg_E7gipRGv3YNtIAEBS7hDFeDeE20UrxBSsNj-0HJbiMitPT0HAAZXN_J_Ea1nFsPvPc-rxMb48Zs02o5qfGVZcAjz_AzDzw1-impv-2SRhUQCEsEBI7xInyg7NuuPNd0pAn89l9NkARhHGgK9GLhkJni9qsE2D_7Ge0v4BFSoXjOygU2BRmcxIzRyGXuUDmWqR0o0uzsWZEw31NI6XbTyQzKCr7pAQt__bxjhlEML9fTVsyocmrw1fwImGhU6h2RfJ-ub032PrYqgodTU35sJv1YFkRqEcvTyyWswmjdihq4DqiSs8ATiIRVpgyPFAqMpXrVn269NJEdXUSNCw4hRzWsiNlM--t3M_jaxDc9aAUSLERvScDo_3REne7sjM1pmUAJuxaPzc2hABrWr2H4q-z02SstiDwbu22cycDm8u3W13W-rmzwMISHHbelzdcEhJjRXGslDO6JG-DMEQOIsR5d0ASUtqrZz_L3ayz4YxkChD01dC5IOmLoe_S_pB55HhzxP_eifathvz8S8NIliHyqk5yXXRLfmqpZ_m45fLJI0-KDNb7N7-V7d7zvOoUcUc6Oot601YdU7zr7kyVsvuB3IhSZX1fmARJu7LUsnVlWLYxQvJHCV23JZ6b6AWnJJZjHPcjZTgV6FLO6qBv5iFkm5xyxEGm4I4ljs4DHPHInmvJbRgkUvMADsirF70VyBRERs0Jy3dmbWrhB8WyEZg_3NkzIsPLKMXwdvAc0tQZg_3er2AowaKb8NaDhyYEeTNdrgj-4wjgHrIC98iDsXG3VSxxMK3irdVOrpeSURnPQTLFiB0iwBNTBbQNX4GDC70ijDWAXw8JCy6ymtWzWyQiurM-KtKTiZ8koR-1ll4udLPPpPQGlIBkCo08FckIDjCZkIq3surCqNzjEQedBl5SvmHu2L6VgSmOl8GrycuIP61u8yfc5NFbDInQRofv2JMQU6txpUkzwGFMCD4G1BtZ9LBIAMhbDAFiVpvixIVsoaZgqP-3vpUf-1F-mqFhwov9CMLcKGB4pVjlaqf7tOh5sbB1V8gcnRk_n3u-jFy5EphWtJSWBZ2-7pDY0usINfT_1SqrxV9FRORrNF8zZ9iSoTbELyPRgjOWHa_yEccYfIoi8tyaSPO4YNGJTV9KJ3b-1umfOtgphSYRmyyc3vBdbS-ciAktD8oEidCeoofuPqYJQkzWP1j3c5PbvY_qp499--jIwhHICWD734_qAcZryIPfGD4TPEvgsUidWFelVQgvvo2DMUmoAwEmJfDvyk3_14wJaXuBz1cQOkOmyFMWstkh6x4HJ633fugLKTFZTfLmmKa0tdKo2pyWEi6EYn-sHRq8yAtBS-kwj_CUbBVcGIdZsj7trmYnBCJm11e-SRRPn-hpwX6QXEa77uDKYbvWecMYnSO_x004uewUXusaGKgaMr-dVxM3ETYxmX-irKbIhYQNSF0mwVG-ByA7VVHsuOuRH9eqeURt_gjCFUgyh1RKPpUeBLZoneCZMW2-aoeMasM4CfwGMe3XIwGunqwQ_JOsR4m51Ae1jWp5lX70N4n1oi_7iphIWxp_FndVhl7RgXuVs64plFafDHszDEOCm1Tx0qRDA-hMhF0KDvOdHbvNPPcttueqf0zR9m-nCbcv8bHZ0oZq1zrtmexJq4Di0Vuf4NksMYUpot4gfTt2qntuJDspQn63X_nRL4bcZe15FQk6cPae3WxtXb3m8dVnVi49ViQ2zsNaFi0V9fL6LN5mc2M7gyYKNWWrjtZ3MGA3KlA3B7jcx306qCpHvWyHjgXIDru562jBv-NoCg-SEiROCXNE0Ntzx3ZAnl9S4D9WVrF7TOKgbT0LAktybCzosNnY3FWwuhTQVgw2U8LhluGqnJf8NhzUXAvjB7fhr9QjxPU8zxKwmXAhTj5Aem33bVLh6FiZMTqHxIvDYj_mJlsEpGkBKpSb6bTq04u4mlBX5zGJPFX2HfeO97J_v6X594lsstjQjD33BOixXjWltWc8o2Lk21_SSRmroKZiH3P2cKAkJ1w-Iw0c6fpbbc_trKfw-q1qreF1pDCvVlU8MnvpfvWurToe71Tb5YLNC35jGcKarCMp_cBlb-qjBbYdfPb88Ke6RlOIVdhgdVGfDbZoVSgjOdck0T5i8NLkBlECH7YH3y1IECIawQvzfbfAmRncgKuxfFJqx99QZ4LyJMekiQno9gqGAD4z24287k0Wgc01LhQj3vWHsUVVeTSyaX9TtqBeh2uEt-JeipPKEP1USDaRGWBNvAoAHUKkyPTw0NYe4teHIxB2vc0U_HLCTO6DDDTQx5B7x9L82ArCMdQGaNx8Eu0MWUubYOoxA0aJawNpvz6Lw7mSlyarv9W2AQqsbd7A18CYOGX5dzvGrfpxeOncKWtYGZsAQtiy_7sPc5aZhJ7Owa7JIyswrBeLNNtLTM787JPCJNX1wqZaw7JJ5m6YJtUhr6vlrOAGXWk46sOn1YKUvXZW4pPX4evlUv1H5gGIt3a7iafiPBC1gqGUOpvRwVr5CB0HsVEojxiqi11a18ausHy5OOp0fn_XemnKxIX8AwCj-6oO7UYV7VgnbBgrKhGHHKn6oHXsZpatTfzrLwRl3RwbIuctj7ZBhfdgoy_SHAexXg3_Nu8mg3fE923VekKqo9skEQTjfN_NOH5iwWEjiv8KR9A1Ym56ZigjdJ5lHkfv4zL6gG_xncZ6zq6QjxbF3tGkStDGVsvwqWqe3udNHfIjSX-lFn-wX022PXWa1DIXoQLfSt--6nhfLHb8GoasMPcRTMd0jFXPLbsCupYElLJhM0xSQXVq010hnu5aCe7N78CKadWekhm_TrUEPcS9AigsPEEtoPG_HkyrH7oN0ePrxNGCBfwyks_QwqFH_mt4qyxJkP4wH8uU4yI1AfzpPOXkelOIMUPQpwbaZ3xQ8-NATCOk8pUYS9UeMifpxfv-kKkTaCeRHxnZDv1fWSZjHmmDrbBvtBNYp6_3eQaX8SCSj3sv2ijLDYy3T2clecLutRIe5V3zDEiZ9h_O4M2Bv12F_aQAiORCA1TVCuOlbCZU0XdsNs8sk7HaAQ_kcrRySwbxxvDNIkLST1anhnCqOr8KJayD-b1LgSdiyjBd9LMKBVNXP2kjy3cOdMxIwpN6zT6SEUnEoQKl0fgLLP7f_yGWxleE4GQcRRo7jvgag5r8EDb0PSAL4BiFEva4s5VdeJpz_TrQ4ZZEooEwrbQCXUng7qkCo52f-EWV_Yx-d2lvLc4ksEpNAhuACCbLfZgOmSnnR8s4Q5i4ZbsmiEOaQm09ERCwwkUL_LyJ3J2tarA_PKVMrGV5vb73V1XBOmZxhWKoVi9uMBNUO5q1b8TuqC-ZR9RMLtWf-su3lDq5PTgpWY_lgYZ6ijTAWiafeexzTQ1D5Sl6F-z6VsLt1-xK42mzwnzg3_A7FFwCNwVc8S_4FugsbOOpAandHV5YnyPwUkwIovw_aGY7ziGuiRP1VJVkQ38IypmNASfvpkay1ZcDwMrN6Nv7T2BHMJ9MRaR9unxDjkBihJEzC_GuhmJ1Bf3NGt1v3DN0riUiEIeCBpNmzzHccwgtz1cHM3HZvSgtEh2SrMAlx7v3w3xljHzY03m_aKJbTlF3shzI984kRjUcTrG4ZTjKnVlC2RWryoLkGTBurM8h2kzCSWPr7B29yEYGDpujzu81Xsw7Hn7rbB6UQxvx1r5DiV_LQ3YTFRIy8XqbosCe5mhifNftRApcTHNLxUwOyVjLjBWfOTQGZJUAKqZdyBYgXRwA-jTT2y_2A3aseGYLW1WWmRj6vKiawWnfvgWyJ-2WdKtMOPL68mjMysocqnUiIdUDpNcP3lr000VJVJTkVG-rnxCP3zgEhi4WIMrL7JTrs9UD_X6n2p0QSdPkOJg9YGb7YNJ03TKoz0FTYk0dF00gzmLzy6iUxl-RAbd4gyrtTJ9U1hLqXt3et_zeM2Byr2RIWzzWKduT4GU89DvbgQf0BDRy87vj2jURy1bqA-zmDEgMw0ZW_a7ohOVm6wQeJZfCKiqDbjTSSO622XR7oMSJodabZ6us-iYuACkQf7GPNsCddo5nNwXw3HHbdNDf_GfwBplUybtVtiej0QDNyyeWAd5tTpKgga0mDV884Z3D1SDYQ_3-G-r2EgISixYYdY_Pkg7T1BUcuBNAKcKBEhgepa_e7_01pvwFKHaO2cXl_p4I5z72b1Tpz3La9g1gh7x_h5LKg2L2JRTWREtyxUlooLscJ9PIXwNVfJKxPnniMDn-Kwu4jXIfrnAq3RzAOESzMgXz19rmMQmY7_pjfaVFOE2lb-04xXRO-58M6wQ5fRfYZul4IeUJOX5Oq5u_WyPPQSuadY7x4_mlI8X9_ekggziG_YynuTvbHbpPLuh-wkMJwCDKzCxWkcG25qHJFz0raehb200olhe6C5HUI1xTeRDuoge-S049_NZCGFN_BtD2ctbAW-uiFi4mfs5YA8SvuP9wmrnO49s0KvggM5UDY03LFf-IcLptCl7cx8Ipvq-0XUIZn9qVWKDHHCLZZ3Rr2pPILx8OC99U-l-6_SLIWe_wj2J68iYNn258FfPsQlu9FzDsjoPLnOIGpK5uy_VPoGQLOaMCjb5MZDssf80fElzbDNYgC2fU-xWz9VAoQch52GPd-7WU4fI_Tt8MTrFue1DRO0Y4S9Fb0f7W3LjEZv8A4hjmZWb2WqfZGI11XhDQ_9KO6EJtadMHQPvZUuE14besbC23kaGG6jAEo_oIMK9pf72FFVKCtMDZ1_ILHCQyjyI7g4gi85OSoxQRwt50ULdRo3a_HUS9rtv8GpMKmHTc1ATkFqrIZapQPltvmxQvLlgD5MGk_LZTe3yK4uEoNGF1l4m5NZPgLspZYq4sPAawNSBOCxX9z4KwcCPko3tWj7Rgw8hGbuHwSHmeN93HQGMdbiinmidEQePEExDREvxL4c1KlCmVaTs41PaUKOr04-8z8DeH5fxf9VO4NBGKvGHjevKCWidJJbwK92uLAJUSkvF2ncd3ZadQD1DE3JTZE3u9u9KZ2bKD_kbUj0tskiBC0uABJhyeSAtTPeaI8IRI4Cbhlo5N9eQbPX99dTowmZxQhx0zhRorQUXctGNKyoSQLSD0021rbPSwtQLXWmSLm8SmdfX-QIZpZkIrnEgyVO-L9uMjQEODvJX9hL9Xkl1KhC1hdnnJuF_YD5Nw9RYqz262F7keqIBi0-TYqsuLFEfoZ_pJzu4_tQWlreqeubx3aHks9Ni_UBPxVT-UwMh3XAGHDaGcosUql4d3zDWQLX61rRRq4dGObHxGUIjhq3JzET4EE9IoFvJSFEEVPbmDiI5YctYz5-kBQUMMk-37oUEOcdNzHxN0cOcS2EtENsxImkuCLhkJfOcIGGpt8xButgKePYkL6h7Ptgz0k8ZU7TgiNmlqUrO0Tzsw6tYAXEqCZwCRSPLjb9Rs3F7AACDlMyyyn5Ub0rLtUT-8esCYZPhF4XTNMtheObrXryJ4wJaHbLVxukArlptXPcyVhRAk65CjgJn8d89j4AY9yZBevPjMSZRHcA5xVsjlPy3ciWb8KteC3_YCScAwHz3HooRlndVdbOTz2z-Rq83paHveHn2p_Ta-s2LKiUQT4UeDqYJslwQ6qnOZuodJr8gA2iEqjRvbdGd2w40EM5522zcK0LrD4BhBniv4fwekjnMLsGdBUpwNV8MUBr6ejuB4huXsii22_1yjUnwi8qt5z-1ap4_DBMJex5bhv3b-U5_dV5BU13f_xlaBABHekuhJLEVJF0RQm858Uz9ve27cD11EjgF50FGWZw_ZQwMOMlCXg3WMPokXvOtGtwYD-ro8NGOoHPojlhwn9hftIfb8v67LOXPIUj1ttcxlb6NjubU0yuCaceLRNBRxuWFleTcsHulf9yQ09SpQ2kMh4555k0UckTLNd_qiXK8mOh08aYgJ-EenxWoMf7nSB92D97jtC-eEEkGGWEk9xrErIrLvabr9Ad0kxOmaD81COyEw5k5ZB76q4pDSETxiYryuxdYxUBOk4r5fj83YHgzTOh6Uz4EGJftQmJRR7HDS54O0ZwwTeR0wBRgfBUvXn7d07Oz7PKULlBDv7dvi7Wi-Cann6KjKHvb4T3PDo1A63yqmiElN0tEyP6dxc9PUXPD4n2BxDAGdFsdL5Fnkyn7KvJqDHjT9j6z6mKv090Q4OlvsH4CPAqPPQ6NpJz5_Z103mUR3jzFuBpNZV7pdTch_IIFsSelSBFm0kHO1Yz8-YD3rtuj3-xqOXBBn6lzN6r3RYYbvdN1s6tKxjwpywE6F-ryiVYQRcM98-g3lmF4DClSfhDKsMCn8YXvEhPVoTUWpPfwrfhgVBjgn0pEsr0JSnt9BWc1cjC7mz76ZNZPVPP5PUN0hSEoDQJXAs_QNsxHOjIVn7IU2ljiR_rEHePUkqRXhBUVF8hIj5mAPVEm8BJxy--V-WQtTR1IRRG2kUFC2NEbXiqic6AAUKToEJK7mDNrS-OjgZj8JtcShlGF-asoxAsIFART41IquQuA_BAFzhTeWDrpDrF30W7mdfTes92bULZkgDl5jmZeSeZfEynwaNvjoEJaNfAMtaeBVp2NkLm4oPB7256WZpALqXFr7arkpePXNVsmwWR6ZPIjCr9XBVkve3016blyUhJnc4c2O5QV2M8zy9JHFmvlerRomYf38XlLAP5HuwwuGdRpKpUgpPRmOLIEn0fJ_G_i5kvNbA3MhVWxDXwYe8wNK_O-JXb4pY_RidV9u0pvlWlDFUeWb5Bq0KBNHIHIeOvJ6H7FntVNIuTvBNh_395w7IkhOswlyqQAnMqhYMLmsho978ZIQ0PwNuzCzQESe3sos8YsgCk6602Zam5d3XkJvcSf9aJ825mPVKrUc2yKpfPtZobzinB9Q2PfzWM55KeJhVyLP6ctx_l1yQH5d6JmYnmYm9m1-UD9ij75Z6TlB-Enw4sq__aTTl6I1ebPMZk5aSq1mA2YkEdZ1YQy1uSefDNaLMpanDVXoZ3cm_lENRUBi8tcCzaDjfcXA3Qray8BlAfBERJb5M9sRnczkxOFXa0fUfKkc6xzKY_Ykzc1VP_ODPIFPJleDyb8QtBYrgVqpGnWdmnUc5IlUIQWVi2Mle6T6b4hldZt3fVWji5nP70Eyjpt_uawfwQf7m2gY9ToGKYKFh-D9ief_9_HiZfCCmwW3dD8LG1uCgUimxpNZbPadYhXF_FW2-9FvXlZsEb43aJbOtbP8NZSr1Qkt5HjIA-pWgL6gjtC9UEqUShQtuM14CDlepxYFataqeW0EwpkpHrdxYtFNGuuN1a-ptNXhbiT3pO7csK8d-waUKWab4QgfPReM19wswonuC_7R2giDzR3-CromJ0O2ujKL76qyfpmw2249p62yjGoi3yuQ7jAIyCTlUkZ-ZwllC-RV2Jke5SCJL1XQ2s0BiIIkDiK26z8P3VlYfN6yfHDkVF1VUpqII5c6SMzOHnAXwZ3rC_SkKaY2JTxGYduYlLrptGRXFR9TKvaCPT_i-LJxWe-K6f1wAuBjiHguEYEc7KhR3zL4cF2kjk-6Dvnt2TWhXygIOyyEw3iTX6vk0DJ0z7luOkQY0yuB9AXZIRSejjtaKobsQToq9TePj3rfURSfq5cNl_QoGctKRqZDnuk2_3ysAf9ipy7hH5VJWl65NRj-4swoo-z-nvML2uF5KYFA8Y4_9Fpu733v4LQVC_LMTXpkreIhEGQfS8WSLsQTPy-p2nvPw_ZthHDFNhafN58C4GfUDAYI4Cjq1g4vYvmDivKUC4dHlh-bvXgTnMhcH1wBPQ3DostPhJfd_mYL5ULZYYAQkrU4GQhHyxQPY_hCX8V4WlWRWpOXhsCON9scXy3ZxldEjAoU6fctxmE9X3ZyeS6I4HzRAbEf7i0w4k8lLrqPewMG3QzZkt7wbg55vwdna9CZQipreqlWUTDUbLYD-MM_yv6nr5fYb-0B8QaAKRdWbjqWe25nd-7i_wgCO7NKwXhsUmS7KvlI8qOOqG90gPdAaofevuPiPU2JlohVLgLuvpyic94mNf-UwAXCGqSppB0UGNrIMr7YC08QjNllKne3ltJXiSpkNifYUI6BKvukdBmVaRi22JvIL0VwALPPJlLD0mbjX4Y3UfdssL2SO2zs_dX5_5WvOvJ-w7-416YMnwKcPEWt1CvBdc5F3Clo9koLY448FgOpxqu4-3JQ-QuMRHnZMMFqMX6eGuuS-Ep_gkQSSO3MYFt-kZa2iPvRnWShXpafmVZftHNEr7gSNN9SN7I9X7bduojqNT44BE5YzpgJbRpy4NNfRtFd_vIGbuREgpdQLUqbM_2WrWegCfDMMca3tTfDzOpnTM2j_0NWsSeXsOOteCpbNoa9mRuZuw12dlHxSx9R0wbvRJukTOjmsMlkryJFbO55XQkwVwi-1iXgP1mDClOw-WTju5nl-vcPe5dKq82sp1LSjVVCxmaWh0FL4k5qviK865bt7bEAiy0GgtQ6n8OV0gnpZroXFewi2HH8aBktkR1Rfi3WORJpySwvX4QeHC6TJu0rSJhJTIvwg0pTuJCEzyOaircdhdaJrPg6LHAWVVwQTpEHXQUkph3uWwIf4x_Gt9gtTvTTXvr_Xoa_vtNrh4dL4EM4UvB3BN-Yia6c0tvljL07XUmx6PNsaFJuAl8ec35YvN8tXrRhbq2BJgqn2a_yZtejGV0XcEB0zNbkGVSykoaS_4EEil_8Il76PS5edh11TtbHHHRbh5JqZaBJ1w40K5RUJN8aAsIqMk5z9tnxeWU6ABTQp2shm3ozmYowOAi-A7777EJMUAZ1U_jyW72vxB12lVK-lKgilkz5k1ytS5wBGGAACZ6iwGdJOETfdxxS7xKSCVWHV1nle2TaXjs8rnuGbt1DdJvs8QiMTv_yTSUG8_PFhX9WfOhJLWgJHY5KFZOQgXdxuAFK1lI_hphriNIa-2JAmO7_OCsMYecivF5CiDR_5wKSbbjin58qp5hbpdqUNqHiMPNgxwpcFheCZBi1cTil2y-We39n0PuPS2xynsndjAxodn7Il9OkXjZJyD5zvxsdgXMHrcePO184BqpZD68gKksxdcJBfw6uhUSyUa2InOIvKttM40Wm1-fJOksn7Xn16w4-sPIJ1rlwgDf6b2vEUde32S6Apfg1ANlBftUOtzMUDSNNVT15rXME7kQ_96ZFhwO0aM3_A2OpumG5DO5AofAe02hnz-gb_E5Mgk826hsXoj3xYHnMSJMr07xnlqt0EQg-SaQyBfGbc2xoGAPfmzCdj5sBenrdC_F-POrd9Zc84GbJzSVonwhy95iYNvVvUMVjO5OXctfxUxGSfMVOG-lpHWaGrzOZIt_iZ7_XJjthAZMo_-vtupyF9VDhgG7ZG0Drmv3_i1NKtS7aFmT236Fs85X6i8xUO6wK6WqC2hqXiAQOODHeEKd7ajfO1sJ-PgKceK2lOA7NA2--Q4zjY6Nn2XUGdzNz08OVMr1FNX8aDhVZVAUSNdLUVK_9iFJ7Wo8tp9NErXqlHaROKxklLzQeDyYWyf1z3tfFxYGJfB8ANjIApEzepu57_gPAVIzJEA7uVY0BTGnPoKIOmhbg2CtaRLRTUkbnHllcPw8COAn4qa3i2j9yVeKwo5jaFZV0O9qIeI5FbhV2NjTF4Ui07480kL9btrz3z4HuQyguf0YfTIIC0S5QyWjh8kslHVdwYFwfwO9doo8PTGoHi8I4OtE7QtEVg20uaY_jM7bg5Xlv3ENMQkqYXvRZOmV319B60Z1crn-Yuqi1kYXLBPSTfDKFCrWQ8R8y4wYvUPzNalGs_4IiDihZSEV7vDUtvTLorf_8SfFAmaPpQnyCImIZrktiVAL2JBpZDm2jIzr2pSehvCiK1-RDeRVFCdzGKOKOCsEta4zmaWsIDSnQylSxTVHCLCFISpN4Zozbv49PHJ2kO5_G7v0Zq9sunsyOuqTy1AZq4HLGjXuX40UElZEz_LV83_-02f0Kon89yV_qh5O0knvt3WpbqCCJqteCSimK-gOFoyxGs6xw19zeWExQ3no-OTrwHHTspsecUhKBSEcx13XUVyh1cDokHZFFNdDM6ousEajmkAgTuf995gwDOmJDhRLzeXDPm5eylR6z3q8Mg9jlmbuT_PCeAJXw5K28jCIQX9xDudM83Twtepg_d41wvV6gqnhKFUQTm550Z7ybDkAw8U-MsXC7qi6clMaMEZNPGVgRxglH7-uYXv3YlxdseMvqDxxAQm4BGwjQFrwnS5MyQqnUc9hNvBpphDQXCM8t0PuSoWZLD5xewPviF6q6MlhY0gmNTv5kOByH9bVdfLT-YCBqHr5z1e-HFw_v3f_Y3m0FdI2Lk5cD9Q-Xmf4if05PADqOsO1MRUYDreN6GiDvT5AOj1QI4yHn5BBXKI2RGbwgGZA12G-A_Kph7Xf1CWqcwIRm-8yXVBvukktdtlRchl_x03feFVwQCzOMdtwS0pANWGd2YMtzcdqYKMfxeooFZ3OE5bigU2BbyQoyQrXXTBnq4HcLTP_hE3eS1E963ZmSsEnvXE1jFQk_Yfbf5dnMqjYWTFe8jaWKmZpupJiUxgteBr3-wZnc2T5xXmlGOaFdZCMVJ-ztpa0Xl2KQbFtty1uKGyOix5bSOuT66J0raWRtAjCjCVz8s5GDs11UK_1FsbI9sRbHbiSMhYo-SrdxH3wMp2cLu_snT6Pqo-4MGcqdPLYS7049u8pOX93Wn7QYHktn4OFrulBRnVfcubqFP-p5QfCB8qe7KEUjBWfYTjK9uMCTjGW5NwzUca3xfDOK3hHsYnI5IK_xOullQth1HvGSr4WUOaPC331WVexG2Eg3JFhwT3grLW1skgFlACZzSjZiTW5-ZvJNIESsk1zpwoT7mI9_gn6IVZy1-Myjy8WW1b9ZKBdTRSpxt5pQDYvmAxTxOcTpka6p1YtyzFC6vF4rd-nmxGgWwEHK5DgiOoW1tmTdYhx4DFKLWNyqmiak2gyf8kqrwNhfUCyRfD-Qy0re_Wy4dHtHhYdPgvuFRCtcpdX5zuy0kOd3Bp9Zd1ZyI210kN9FkQYZMDhbcKyEpTaM_Ze37sC2r5g5QHIvHql2F1ENf_NnOx0hQtmqWGLgmqA1kVtBc-e1U4-SLdB4tSNJjQ0PXpeRuXwvsAALNLcCe1wbvZyovox2Mj4zw3W2xC-KnD8o7CuaVKuRQ98u0kzvs-QMxOcc1jMCeKicPH4c3ZmYEMPrLA6tIWLJCHtxmVl1ZwnfdWWi0-TylsWRgUGCTUT1Ygx4MwWolj55CHy_V2XOABfHxmpk4Gx13oCJSCfLu7A1k3jY7nwaLOOnpA9NU171AflCk6sO0FbEELq8ycsIXOUViRI_JMHcR2bAGhUCIjjiw0OZvjxP1NN0mPYQQDTkMV4inQfM-9n39CXdjJA4iQWPBC617pbt8nxj2OFyix_-Ouqv1ra4dy50m_4AKuoZSXWHQb1tgfauT_Isf9AkzgF0Zh5u0ON70y64ZD44w6PYmWZCvPKiRiPmfRaDJUoO-D0ToBLJvhCchrFjmeMYf2-cF-QjL_9D_2p0mEieJu7d_afpiNJ0xt9WScwkRS5NB4yyC0egAH_MMZFIH0G7uEokNjIvSrEIlog6miS1ZOt7jGMlkV7mroeBA6rcYUSmbPx7kFFtC-Bzk_hHQbgkPvcbEY8O5yq0i_L47N6E3rKqa8_8uJEJ84Qe6uy2naCD9KVSCbyfeDW2vVcG1spDXEsxSFTy3I6bZ9jvxN8t7Dw4DEs91v2Ir2c4HQvxjLSrqlrMjjsKoLT7pKVMwcnonR475eaFU7iWn067viGDcEQ0jirYyLAWP9z---N_Ti8fdZuW5i9Y5NxENNiiir0vPWqpgdA7i90BUiZiJ48kptq5nOLrCKjtLiWtkTav2Zg3NTk6Qgogwcovobe2dMVg93jBDAGLpcrvsZp8W7-N9u5A6xKTC7PsR_mA303Bo_L4qfX_IzrdezAIqNoEGxn-jgfjk1RciA3O_vnorsa909cHIe-gISLpLaJWYxjORKfhthhH0pHzBHWZuRlJsjzGGZVHK63lEorY3v0PNTpkap9vdW9067cFmE6fKKaD02lGXUK3NLCuc2CVyCfXFw_ETLDfdL23PT20x6M1GFMxDoJQGEebwljgcqopBMb_IUTSD583mOq75-Mae9f_dLJidLLd2KVoSKMQcj3uAvetCjps419-ASKqJaM4VJPy6DraQwEeJFq9hJ6MLNYbIomqhSxmVXSeNWEfy3WzA_BgpqFXhlbTtK5E9ZdA4K0LcQVE1ngcbS_PQjmcSJAzxvO3JRN-YouDc1PGYGQX7k_nVJ188wpV3fQhBQGhpeRQqv9dKM25sw9i-QzcpjQ3MZ5YnyXRCm22fxFUoQW_9vcBnKjHtD3mN6OcBRxwAf22qTPnm6Z_PRtXLilz5aKyLSixOIQomoRzv5rBOIAVEn0ht42rlj6fp2eYYP66UYuNXtFZX6OM_YhR8EUmfSxlPowdBqSSOqn155SLFulbIaTHwHse0zHJPKJ9QZ3Hb8xHr4FsRPjsITYqOFAh8l27StrOYV6iu1nQ-mBU0uDyE5G_t5wQiV-kJ0Kry4cAqSBWUUazGBN0OAXAuAAqlJboUcNwPeTK5DPF2e2xJ5CNFBOTe2gpVUJRxEZ1k8qzTZ6NehuBbNnAlmrIBs9OaOvM2JsFpbFNKejG6dfCd5ZV0kzVlAGVwMEKHLrnw4CZDthlFN9m4m32eA-uYlihNUsv3Yi8f0seuQ5gY7JcrmNxq8yaOCECbLUbaQQlilplyxv7PF70jJgbhXSC00hHBZOrxrm6jBOA2OR_XWYBXA9PSwth63gZFUG4yroTkSXt7xmKyxcvzWc-jrAW3pSS8kBp2LM_nmWBYlI9hZ1ctRZKvlUX1PCvZ16qO-RT7sDXYFeqnM94iM6ctXJp1RWq9fiT9XBYBWHIjb25W7lpNKeQ_4qZb9b8wTaSIVViLtMG5HlaZD3VQpg4Ayndsv-Jp5uMYJfAiR5lc9mG3Wkrp4aZhslEbjpGOA5nL954BedfhUHzeRAjHc5BWS6OwoPIC-IDDvqzgKMF_So7NmZFQUvXstd1oSgWBuqhVJMhOOtnF0K8fpmlaKDu5v_ban6LxpwvNLcVMXgWIp8q1mH2bMSOlXru4k0crQ7y0FdaAiUP6ApF8vRnRsYwSUcA5c_OcA2ejk5I26dP6xTsVIds7GVCBPUTHPrsosGfqaj2h4LuP_sHF-5-on3g0lUAIdwiRmefQ-o21kGeVG8YvaNfV69z2YM7O8oTzCdUKpvKqvBFYO15Unxd9B4aUt18iw2K5g92DTThbQ3V1N9D0vk1FUhj1QbmYSJlXH6DydqfDFiyZQx1GBUo9UbdnmSzwdZpwnh9-qxF_DpZrbTDLEe4ydZDhPnIIhU4O502DEvkLVyV9KbYFUMbRakkv21xnLO9ylezWPhRyL89x_oNC9mJf0YzITMKW9VXApapR-mWV8kzs14XVkmTHPAqUDTnRjJGciVbsNbNLRhvb-sA8jU4upd1S1KuPa8r2xS4UYhjBOzW3A08XSE6Qxj4V7Zley-JdYIIhbmVF1B67-_yiuAmwUdF9Ms6Cai2YaPPmegYziajGETJHOGQKt3OwpS4V62EKcCWnQQ-9X_gbnR_vyV0d0BMKbZ8p3-kZj24V71CLpt6_WC74zuVuJQh8onMtZNeGGpx1g9qABh41QRLW0asz42cXeikWlvq4gqBlAtDZ-s7_aXIBgowrVE-iedxhperFEB9j5VqudZarWUtykM_Hy7-jCfdCG4_91M6UAeH8KpXUx5rJskqVnY7FmMoBqlKU2AM6GZr_00c4wGn425NxnJWZ8iUyb08UPOThwpPf5uGx_NgsQDci5aIMhcvKsiK5Bhuw7BMingnok62_SwWU_aFJnSmAnyMqk4NYe0KCIRVnMvzFVsPHQZN8bS4z6uCAmqyo4vaxQXtEoUpzbKMVz7m9SB9EaFTiLiOBpeHsg_nzyqGJvk1mWFtj2CQn7Y-udtM7FkFO_mVuZhaNMUSroLYbItF_D5Pnd-57inKwyITB8nRIWKQZWf4Tecf3bRUdYELyK0jvxb6PUWPTi1lGoajc6jQRG62qjr5oKroQN18CaVKVW_t6CK1o47VYJObNpnYu33uAgG_TkupHwAdNC2qwYSLpMGtfLgRDwhhj5ZblTEnf37iZyKJYomN7-t5T5IkbZoiNBlV-NYfJXFcAQsJaXEO1MtNwgdjbAbHGejUIfzUyc83z0fsVDEp_21usjdyFEHq_FjPDy6QjHsc0Njpd9ZCrnyvRc6EsdvA1AizdMJqe10CHkFdzysD2nqhJ7CJV5khoj_BbtuAGj6cc_xijlCVbC-meYYccxJJMtJezAeccw5PXvhiqxferNnZF-oOwVQG9b-xlhv6c8xj48YE2TtZiPdEbhBmCb36gtN_ZC68ThgPYDJpZU6cUfjJyyJcnLXf7aZSPXHVXxQ91u-nWG5ObAxDkV83kFadl6yv9Pz4trYuEqNx8PYAZsrXfihuzKzmDSmlnHK7o4L7-LngWKJ6vGrphJGTo8wkABEu5OWbmVS0vh5QBcgdyqnnQjNqaM2Nf8DHCRFVHlYJ7MFtpNbm_8T06wU1WZAtHcHDS79o86ETZNaiH4MR3LxfJyp5E8QPhhpYpIEX3Si7FPYYzvRCCFoL8Gy6EXqnNMXnYhDTYbpT2u0xNf8cbGkdFLJvFiaL1U-eCXxUjtAh9yId0IAHcdSgn7s23oAUJ4FXUDrJ7IcoudyUksIxEzIRxJ-6H5_Tm4Hyq05e1eSPMWuI8j_hOXXJt2-yg4G_UktzlLNIHTTwLDwavlT1mp6uBYGA-PFT5C_e1SzX4MbRhb7hH8uhA8o8X61jv2944nDdz6fiV_dF-aa-pKWKBA4Jh1aJSUd3eh_zHEyTqzpwdd3DvFtY59ycTm47DbO50WAHtOkaVshtuSSynUB9yfBaAw7DQSW4ew8eyHChzIN70SS2OcVTM90pTrSfkpefRrf6LxNkkx57g580KFBa6gWpGwpSIT8Ut2AR8FMNtv2LBMVPzNyu1YfCVj-3-9ZBNJFl3O8-quuTfY6wav0hkncA5m-nXEyTbnmuSZZ57mq5DkdzVUlN6dvDPx5YdAgEqakpB2_59bXbovmTOkhXKZ3CvbW7MvbuYflOfELqp9Hp34f25OWue6KtlEZQN5BKpScuu49wr3I1o9NiYDAeMHf_qXVQyhr9BzSaKv9qOS-DgrTfMSm4qHDIOlvARtj95x0UqqE4x-QmVv3Rd-GCPIzYIpmt7hwnR9h4frf9cpC0WT7Xo2uUdAbd3VDg24N50kyjsTyqeZ-4Tubt8F6Z_tsA4pG76apGUuK3S6T7znT7-6sLBzAYk5iP6pkeYPRb_Wrg9tQtEn5hpPS3lTvO8atxjhxJ4xliAwNNa7AqoAd2Ar1tRUrQHj3S7ZVcbcAC-llP57zg67bKnuObo1oPbtQFqj3WkOF1MZCgcuuP8MZgQzut-AXyy_ONfBS-vaWO_GDkO0XoaSqMzcwcsJqBC_wc4_1_zyN518ASfM5qN1h_FKVisv6_LH94p1LOoQkDyf5c3oYyK8fZBrIprz1A_h7TXcWqIllEqajlorVcfm-jClqqsZwUYUqAPt08yE8VJkwKHMEiY5SDINLbudBxq0FfOp5uUqCOvatD43mNWldDNW3g-AJu2TvFy5fkByBPSqQzk33BHXQCNcrXpHQMT5cnNQv60kA0bbtYPXM2Lyu8aTHfKUqMz2ac68OfkqpJEitcQ4vNT-UvN0R2yDw377HhC8sXS7krDKjp85J8pseu1QkGxWf5Q0tbWIRQgmokcfP4mAWNtfOlHm3bUspAGkBJilgeyi5A85WiUSMj6OQ8EAeQmZfDRUGK1Hxn2UMo1NakabYsA7fUClgGldDV9V71tcSOXYkiIj8cG_lmvE7N1kDab40t4YcweVxEBKVpedptbNPUB2JtYviEga5qQW2aMKAf_zPPv80cvGCd9FeY41GDC63t2Z1vnVDeq-15ndlbJO83wI66PhFSLX3dXK4jEwPSDHcwid4itsLOEXDDtvdFf2VCNCMzilFENfXSqVvsFHX455n6ZqMPLHJKZn4mQpp5c3EJSsgsjORmaovJSNlO6Bjy9K5VsWSP2MMKTYRiHon_HwJ1I0ImnZWMSAVYIHzGHRNkhiQobn1FZ523c-qyEX4aWvj-xtyFaxLgSK14kZFruw_YP6De5NwCjbLampYo85GrhXnAdkpg9cz5xQ_orzwx07568alPPsdXd4JvWSz-m-CSWHG6qduHkVUJ2aCtlb4K4tf0KM1lMkGD-LQ8oSQ3T9_daIG_5lXaNpERmgeFfHuHrDIEeaOP9GHiDqpZVHHSCi-c6Oqhx62dcb9UyymEAFKkbOIlB_gaIYIANlXVrLkDmNf2si_7gfeDG5UHLTt-Xlo2shZhrsxRG0KzZGPnQ6fc1C_4z9zGzrOAo0iP1ZFuOsLR3oNPHg9Bhv1gJgNseKEzZuLb3xRwO_NpC1yJ5B_TTr_XqtQOsoTFFJ2kJCX0SbBn3tX-swx62t7iO7QhqCd7gKewJmpBHVJfzDu7SgcDJ_dwE8CXIY-qb9Y_H2KDSYYlcOTRm4fPSx77uRVyUq3ex14_6fVPRnZZE_AWc6Y1fHdcXHMWu5z7CI0jXY2oqDePQXjdB8LKH1GeZzdXWq9lhaHQLa5gY2siggOuF48PQcD7CFTYXk-P17GjkPRHrgTsPDE8cY3vhoRDzNUWRyRhT1jXNtDCFg-N6ij2A65SJcmwwj3fneabl5vnnj4ahO4aSHh2aFZO1KbD8P8xVCHAr07U9HFTjvZu7un2eEWL14pm7t_Kdhyzb1qNqXAKhHgRmgCg6XOeR8056DRKD6DtPuLJwysX8TExMpeHch5AZ0UA2H9rUFKo5po9lFg5yuQ2RnfYttfvwPizBDFefj8A8ZAABpGEinZvVG0WB5bIPxHxgoDrCao8w-TJ23FkpzXJeuHc7tfHP6gNiWw5cbcJ4rgMVm8Z9O4A1NM05ClOfIFqUlGLbM-sk7ia8okuIVY6lqbMbYAfj7yJoNMEB2wefBB2YcZ9VWQ-lTzoOyDviwOmbQJYy5VwVHaiIEkZBzY34xxAq31vrK2EXW306ZYp9rwnyKx1luaWnznyoSrRh4mAMH_Vj1LZteWrCxS5dO6n54d3bkCGXEGVJ-ePQfeVpO13HVjmkAnhf3QJ3n5vFQudgKNb8VTmlqAkDfoMOoIcKg59jhRwgOW1JIAkdkFomR4-Jqgf06tG88iYMur2M3hGNMg5nmQN6fnmEuNPiFVzU-skJtDptBlJcbi0ijwn7gsMyqjJgaSh_7kx5ZxEFSrspiLUKnRzOzomXpnt98Kv1FZzXwjmFyW2_Bs0m9OALx7CkgTS-HO6gMHsjyo07s5GKttU3SkZ6tukWeeUTVmA1JJMUvek9ZIcmKY1ACgStJU1T_XruFTxWmoTpY5W2TDlSW9vlFVPChD8kaeB8048aNRSaVM81sOupUP59WrdWT7KTNdu60JorI_IKpL489iLgxs6oTLFSZAEnMP8OE8qdBGkvT51pzRd5FXt5hGIX3aCw6e10yNwAZEedE1X4nGurNzs47DCK0fFsAmBpuwQVxt_JNP2uJ-3blbj6Fbt-gsw0HlWBs-7_GJgcOLDFivcF_cciWTB9xSz38xOp47oPAh6E6vuJQ1YcZQHrOFl6-0aopM7o5SDoZgNwWrAnZBAfXXjkX67BtrFzf9f0cmASyx-0dt3dB1XwdzJR-l_eoQiuaidsm6iJLOLDtaN-RnZxfPSq7jppGZJuT9Gz_PWnsj8h2_ayS18cC-hQDYzN2gTTaxBZ4KxFGEzO5UeO6P9JKY0_Doj2YNB3IguKafcW9i8Fztv1Wm575O13-z4NYNIahJHaurF9bVVhZUwZkjnA08QNkV4mqgGVFC1cvY4VtoFpIk9aCjfk2ToMxHkBqaSwoYYOMkSrfWbTQFfQhIaF_lif_Em3aYKe1nW9hyknXLQ96KrpopT1NHYJsvuUe6HsEbuWU0fnSxfB3LPjnM-h5tF-zCsEqbbgP4joxAJYrUfKssqd1FztC3fNt6P99V7rY1Y8jzJ9hAPll-yCy2-HmzSWFJCoBEPZOfCnYmzUJ3gv6uaztRiFNrNYKGxNkvP_WgB3hDVppFG11OclQ-jruvhGKr-rPHDAYhiCgfwm50RjaCEuwgQdVSmZ5Yys9mlmkUTqXa_7LM4wUxjv_lSF4MX-g1kni6mbxT77_5lSBYKpAyOVo9W-WPOD8bsQSm_TroHLVSBjQNUOHUg7BFNrzFxKYbwc7whJ1wgdfBvsEHI5NBvyAbYGQwuG3ycJEnBvIPuCZixep9xh3YkMc_EC_jiNACr8BC_3f5k2edb8_N73Y21So27w-Nn79r8P6S_ihnlXHoYL8KRQYooxxUtt8aaKvzSQ4heoW8wOg3kO8z9Fy8JBMwK_HG7NFtc_gRYCRpIEK2wkNYVvU8v87uoXtthOIQsf2RyIH-FakHrg5cWuD52k5vyoHDHd5w3Csvd2bEMruuDPsk2ZPibMUHGPhiO7goz7RdpqcNYYMdfgWoIRWNjQ2MPIrd8cvzhL0n2wzZN-vcwQ6OwuJhjbkDJ51ZZUhlrUCXR3FHOZVz9O4bkzgoxB5De2LlOTSW7Vw971LRXZDoqhx1Olq9jUECre3lG-HawYh46cvHMoFpX_KsVu3ezIKruqpca1T1qQiuCGf4wLaaiS4VUawA1000p7Ms0pBuWn2a4y67t4AP5US87m_EcQ-K6jZ0HfhPw1jE-_tWtBaOkL-TOrum8AQdT-T4--VxaexGyYAuBu2p0kIWNm44zOcpk42nWXpLtH-WvL68l6ZDguBygHEACbnU-urJG8aWG6AijpeFdSyPWYSajVNX5hdpZf7pCe3VN2QvNYWVYFzf3Xf_YloNI08MG6ATnH42xkfKKSuL0--TB3fm_gPGFnjP57XojYjHbMo4QG2ppnt_NlGGs6GQ_WA0L10G4rLbdHJAP6vPIDoaRi4JoW5IKWX7FwTNG4NJY-nN8tlOlPbhunohEuvglzl2km85dNHtci0-6OhyuWsF5yHo2KrgOClN3jSN1YUpKSVVtGgsCgp9I2ecJqKXZkTFfWJKyASKNtpIgHR64hO3_uC_fzwzxwUYl0vzmy8ZtdjBjwCltPsyw384-_tMYBE2QoiADJ56AN5aez3kuo9XZ1fyR1x3zG9ne62flWtFl3y-3WIa3-rOEtNc6woOdZGR7Yl1bUv0iXqyM4rpgW9dQUa6BcQ37FIO8T2nmGXHbxzUr5pCjLdlNPntT1q3HYu8u3QyWtWLBr8TX8ug_whj8rxNNxdeE_DpwbpcvGfCedLFX17PeBfH5qKIOSSwaLKtCz_NoZjGHiqmSwxEbabTyoLwgj8132yN1Psf06jWfU4yrjOIBXT7ykLeCNUV8f5dHaz9lbEaFJnlK5uUGKsF5Qn3UGEJhBk9lXmrhwMnYF-P5a6_p4vcQ-xo_4NDROJmmDjbxDcvAx_Nb4-1-seFYg3t1w5wrJs5bM2xqe8HcVeISRc6zKPezdOSFAw3aK1BBBLSvI8h311sySEWayPiQx-wEXPdz9uWGFvOFSAdYNJmKswqIWhaVF70fqzSnw_zF_sBfJEr08zatn46memCxCkv6uA9jx67-hPXxMFfoeF8Azhq9QiHQCZ69ZThPx69XrSIkGQk9KSAVwqyurd7ePLxb3LD1RRT0442UR0oIy4yQRU0Ee4374f1bBl_j-UBgY1b0WSl7Prv0lHE84AU_kgQ_ZtW05dKEBXXDRzR5paEMbYC8PEM4A3cefQ1DXky5ym6DeTI6TDr0Yo1qmpkLKxq0xODVqmgMDbFT_2Ed0p-Ukup6tc84CuFBJnyPjZXluYpbhXQnmniq0LPxjTEGgy2QuWJ8BGtuoJ9vv8rggsIPWcRVXHoTuTyPUxK-AHSDfjpYzcqO2h5CICYgEx0O9MK5HmuhTYswioeqbd3PAT-Ij-xqO3nUPO6VVVWg9IYyBbSeNUYKCJ0_XSbtIYincViYKuJDx6h9EAYHeqgC5y9DfFtnTt2WCHSkkQh79_8aPanW-GR8ei5pKeAV622hf7pNO8OpxSz6nSPOFPw48aWAHecO6NiJbAFl_C4GCaZIW1X5T-tV5U8rUgwCRe5YrEBrWwsOoXpA5UKoGHRTuX1BRwsqAOV5-8SD64uRLe56NvOo7ILzR_eocgJiMB98Y_KSgTcxDyKhx5IRahw280_Pk4XG_QPJoiXAlR2QLMQP7DB2lmK84fA1TBZkfEX5snMUhp9LTXC9W63O5jY0CbI_EQ0a4htFiVuIM1eQa1h-iV5kkTT7d3UCalUTanYufqAIn9LmRVGw4FNGUKzEVSpcreM9CXSE_YJt-m1cz9u9Zn8DXhNK9wvTzEs2xM9VBgH_LJhIIPrqi1cSEG-BfnzccTY4mj_02-4EO3sTIH32d8DTsQSPxVuYRpw398NO0uhTSQontLe_huJhesK8YVkSa7whzsREREA7ZXljy3oqnJiA_PLBckiFwkSPumdME8cOdQ8VuPt9gnyyvObe2a4aWTywNbJplpkUJpqwf_8X68G1bJVXR9OGPr7_pW7Bsj1wspF_GzfOyGecxk8Oh_EDkgExb6zj1Kf74jneCTNtq159y5R_Q-kaRPH0ZggyncGrPEasMtv7QXNih8dmEAoYM5iYFgPiYXOnkA3Ewt-vU4qBI02FKtqo_kcCEJJCK1Eh8h3kwjv4kClHB2XrYDbHcy0nx2HLs8b2QrY2CvU9Umx1sK9oRj5PZmXUJfrkx70zGex1eloXloscC5hBiChTRql_l7rzTxWc6USkqdymhZdR6wjdWp48WGzSz78dS89bxuLmn6VvTwGkO-gmuQh_B19TwDAQzaUwD5o0RZbyLSh38hUMEnb9d130cfXbnjmbvi8sa_fiTIW5MArTGN1mwSM1T6xvf2cSkAZixnYyFNMNUobvYBBGoizSPdHT92vAt0OhOicUpVwb4bhotsDpnj2yqE9ulrI-UAkHIUspsq3bgnwmYwRu2lImdH6A2ktjrdrX-dZgc9IiGFnxEl-8rd9BzYtVxvhqcqr5_OsFDVvRnFYTVc_OaNhxCTJvwPILA4o5Rw-VC2F-ASCphs-Pmri61qk285uROYp7J3aVR9jVLDWLM5UnATxMrxUJ0cIanRZh3ugAq9e2Le2W1r92SK7kBZxi_yPYrH01aWdJZv3W8LRlZRcysG5Lj0DaIh7Sn6S8eCplfYxCZSWjNhXnwV_iSIK-ZyTOvodyPBsJbI8gotw5X-o_MjJ_ZwlMrHRndJSPlvb6kksjdKoqtMYEwMQ3kfu3zJd7ayohf8boEmBP-MJHp1P8YkH1kI9HsmFrwEV0GfH9cFlKqw7SvyL1P5iZ8nXyTU-a8x5OtI2yOk1Q9N-EQCo2t8lVKlcKh1iJcCFzztfVKF2q6OFaptmnZJL5-mzdk4Hns9j1EAXqvICBOj4jBuBMS081-xz5FZt4S5YiPWTeapY0QBNZFjF7u2Fc-6M-mi-eIS5QoXFQb_eYogr9-UKXSUz8YYAjDrwckIIQDWBPZzz3t1RsaT4FYovrqUxIDS1tcwbZ5qL7dOjnhJkhvq-jCHdQ8Pu1Nd4CmKdz_QI7uJ8H3LtaNEJ8dJtEos5hCB70owtnWxsHjCEdTR-WLmDwSz-aozXzbIzsR5uQ1YIV0Idq7xdGNigXJmEJgQDVHzcRrC2T-J_uOSKDl36A5ClCahJYH8GkC0DBfQzqGsw8-RGWm3CS2GwItfkms_a8umiuXgAXLgGjckYsoZizYXbqFNB_vs1p5XJBZYBRWCnunuySQHSedb4csy-MnjD0t4DquuyI0Gc_4YZg-SbcsVJ_xLJDpVc-J8N_N8tATLXYPNEVPUa4Ed5s3Ykk0NLGUOOebndP-9C_D4LXLOJ5u5GupoEusmSJZo5pj0rpmdW2r7hMyICVN9fg-cs0jBUihf1xh6rZpZ7pt858RJDwBRinrFZ_hO9ZdmP8klasOxcZveWFa9ceVdbmAaFL8PijKUwdgzjYeBN6826lL7qObgZgO1mhLzqb3vTNO5zUTFK29KpnWkqVgkLOejlCD0JRlRMETCGiZwqP-SlR1ypHSbYEaR7u9JpsDGiSbgrHF-f7rwEfU2AlWOPylmG0Hgq-3CkR-NsB0we_AHJ0Cql-9DqP1B7QN_-8d736PphQPYOUQs6EAVpGJo_l3hqL8b2i2T-EB9wQdiBOrvAdGCzKXkytUI5pu5RmzpmL8hPdfczgYMbBEQGn5uaqY4M_xmNZAMwmbrjLnazQ6lO0WM0IGCmZEfDo2RZ-sUp6SsyDfHHmq1ZPmJUBegB8TiI9Vle11py7NsofWRHO46Av0cAnNLYZEFxxfbGLNpYsVX7lys_bdwEuPuruIWrnickbRCZdvpZGxIJew2RU5VBYHGoB6PvauerxSa-dZWEQjb7O3f2w4RjgOcCtWS82Xm-Vnz7OeNVm44hCDi_zczgYTAj25NYVt7sHkfjXFVUuZFuGSbDl_kpwlUiAuJuE50Vt-kzQOGDV4i6yG8cPdBte1oEqRGOjqDdvV15GyldcCa_XHBH0Z4rFdk415TaLbKz--vfg41vPU_UCnpqL5Jm45lcN2sE4HupG1sunlSczKxdq68y978jmKAIRMAorjBkhbtLs9szFZGiHeiI_INi4OKKtpq7yxDiW4z8P7YBqOqhzpNVEapP-86uJJBMKxSiQ2IE48wpzdpIOshtQPBdzT-1q8RsN4Fs4TtsBsoodsZhUHrJ73iUY1_ZhcflMg1Astiev1xoIHHDZSS33wG8mHxYNfsBlD8o5SimrpkvUaM6VtIqOn7LYJFmGtJddzQ4SIzi9osOPxkkew3gcd3ZFElwqy0kmQCbxEnhYlrqJrrOlsO_6HWnb74T9XiL2z-uqbRe0dRTxYranjDv_jjM-8NSlaPt_ZNbM4ngzEDAPw558wvpSbYsmDUS2ymo5gpSkxZwuxnkVz4pn7m9L9dUGIR3teP94F08SLbVn02CvOJdtirqClqTywEpTCFf0xTou7g5NX9Phy-FyAR-Xom6y3mtE5G_P57mJf1a26IQN8p2dLDP0lac-a8u4ehB7ijbo--VCVO-q0Yfd7nJA9XpFnZGuORDFpZ0KseK3t0GxGpZgvmv7B4o5gmySFhjNvjjAYS_hhhRRzE8q3Q5RaIhKIasexuKaAPNpxuC534mWecsHNb2UCCz9TJvHJE9ALdQJRB6T6hWQTfwPhmEDuXsKRAlHni5amW5p28QE4PwoVYmI-AUcdLOMW_6CD-l0TwDbBFwFa5i7mev_Gjwd1HVDYHgCcd_5LKwH2_Ap1CtunGKny4cp2RWRbalD2tVkp-XErnn6LBifoN-uCtuH4fWmo-2ETrIO7mLOD-K76pX1WXBP7aWRx2bsDgkcHNJ2mz7X_yIb2g2jZYj0GtjkimMe_zbf_vyI8KKwn_U6OKaW2nGXFERd4uSnil_da7CMYcoKd7yR5wy30MsyBw4zLtT_1JhesZWdEit2wurCZMVA0chNMiwJOuIu2dbE12bvJ3LWtd9p1YQaS12w5AnmVTs9sRxUXxpFcBYdpyx2yEiQCqqwGVh313bP8LI20IwEn0ZTtXCtkSGc5CR7UYeNn5qq2OTDfrvN8s07HievXH5WU5wWqBgkfFhXNO5s9zkaAfHlkVqv4a6QaOW-j4vnmKYZNT8ylSpsq12yKu3lWOXgzfMpizmCR96vOVmkS2uw4Hu4sMoLvAXhAmmKgu9jAjHSBFukMIoVSCIhqqt4sk3z8tGGjqK4Fd9_4lsXk5c1XX0BffqXKcyhul_0FShPvM0PDyqAktrElGFMEDJNx59Z0c1mevEqgnslRvYdHKLV07n2alOpTHPcqMqERxtxhRDfKpUqMpkNQ0GteCwyWunl_8on4v942opRMXNrro4bSoNYmJF_SpFlqL8M9VOBnbfgRWTWxZZ67gjtuypKuKf_GPX1vGy8R80MNHdSyc6mAMRPotkbPShqKua6Z6CDzYQfOTia4wD3gok2rXCGQ4y7nhH0mjbK9Gr18wgAt-i32DnO2BGoiiNVPgvLBHTrqsOwsVXhaYoWRUgxuP9YkkmeSTec2OQbvN9oDeBGcS-br14_OnOxquhdaLl1smIs6mqpCoWhSmbIU4yn5uUYNztf_wTai3WL5u3aUNNvQBvShsyEn9KeE2JyKucpHHMn6SCkmnpuApwNoQ-xdkfn3avuZ3MHuJ2mLAPnIvFY9_7iSgaWlr-W1KvV3QBmk7176Ww5yvNYNii4UpJyQ_4HgRaoBNCHusbvGWwZwG7tm1U-LkB54V6Nuh6Fz-ABpcTgyhwd2jaIKd5OymUScKPuYIELOf4HqttI6dEdt1-gL3qhHajHfdKBIat-vXQBR9vyoyiEIWfQdzZkN5Tv4TMgGPT4Cuicg8jzIsuyR3prGh0PVEfHVFRSTOFOdfgDa6IF5HsM2pV8aJlljUiQIifjRWnWcSIUlh-hQDH9ydYnuTBk46y2gtTYYRLAalLFmo5Ut9hVBWppGx_j-FftdJZ7cL_Qk35xukguW-BkbWhjSW4bw6mVb1qlWPFMEGzL1lu9S4y1fsAjZZ6CvnQ0-gocopD2QnG2D8UtlGFNME5tNhlGoou5-2XA6k8usC0AFh-4pwQbWh7K9oAT7Pvzm_idqoO6i39SgOGUwuQtJG-1hidbglZvgruwY1hfrzMjYEzSwymhiBCrVJv6u6nsfMDFOh8XeWfCDAiR6QlZBmGhuF2BQtGOyXgqosLGlzuqQFFydQBVRTNupyq_nbTjcxi0t9HALq10kn7Q-RJXrQPeruSfHtI7F2Km2_2dJEYC_BseDWZmWTCf8ZMNTnXjxTZFwF_G0G9z9T39gIrutMIeJ9CL0y1jbvi9xUEAvtmIFmsSzm6pXOZSOn1_SQVgBm3Of5ctwiFu0y5FFb3mPcdf5uaFggirc9RazRW4p73SksB4Qd96OZ_QhVI-5w_Oino5HpgWOij3MlVZQ_O0EAQgL8LxCgDBo3OA-lXybPzVvrY35aLhLG6f4KTCBQlXCcBa_jaGVA5GJeS4UMVMO_DnPjdEaH2wy6Pzr7zx9njMl0DQN35KqofEaBGErjvSIKvtnWdPL87SXCrIxXnnow1hvrWfnMrP5-vIkyJ2BIuGdI1taubqzVaiXjf0la3ufHZvxNVhrcveXpdxkGOgQ4EUuAiNW8l_edYINPrkbkZJuNA_4SPEsKRBVDjkWjmdQ_N5IZY3nHC9lwQFnMUwrkuXf9Zyb4v6Ayl_iNANgtu8N4I7jLbX3kHTHvnPCWCJjX4R_Y-NUJ5HHx-9Ji6whz_6rvH1IxFquN3qYAgyiekanyPawxnUhwa16zUVw7IRNsSwPAH9cz1SiW5KMOBxmm0tCz0GiLocZJKVXaADTOFAPzm7B2-IgVl-m3QfV87k8w-mi5Kd9zyXv6uoHjPVu3RmGBt3ql00o9hirVZIKalSTQTAP4wEFVQ3znwTKfFneLgFk0ClZzED5EV5Qh5cUNRlw73pj8ssCMNOUqiC0lMTuHjQ088UeFPE_6bF3WZiKuI_Lf9wZ8Djf0a_iN14xBk6qOHagXtx6iVbX0xFyMikMSWfdaJGv_D2qTmmnFGQDX_xnzzbJGQ9w6sppKu1HFVxKFJ1NyoPpBv1Cy8qgwsxkYPbox7lPsmvKzUGkObCPjxPnhXCCEgI5MJO_0lT_5Y6bBllCOwYOtFCGsK1CHvCmE2LUbqhDOMrEbFsleJ4gFIdeL_ROPkaBkGZ5yen7eRB2KdmCae1XrDCmlPw9feP61EaN-62_PWkQidfWAcqL8HTRC49USb4Yp1iTLBxMPqkZZMD9UoV6mSZuzVVVSfCmni7GhvvjzH2N_gK4DxW-Y5ixspP5THZf2A13eqwqhw2RrgmRVCKI3FtbxC7gdK-qS3_gQ4islXNm8gNGz5GO-WdOHaV-3bdaBHhzlbD-eL4J7CCGsvATZA-4_wVedQVgbN813aBjkq4AhUmTtftortdBwiHsySB7-CmvejqNw8SuZRYrozCW--JFPB0Cgym0ZGsirWILleHJFOUep-oNZlAdcGjv7YIHQkmKouFVKPubeTUdycx9T30FcQBw2lxF88JmtFYRW_582wNJEGECwfTxb69CZvGgmX6ogupffV1LHTqutckBhH3iJwxik7UtcRy4UoHgifpP9e9X0LAXPvahko3RNtKbCApquwWZH-pvETFOuanWsMYhlvLjRsxPhkmas3fFlCe6ElPulAwpIYDiNlMD5nhaONXg-FEgNpEjc8qlC5KSP8imdx6OfiKsIVudH6d-ELeUU9eoTUa6ckoH9f9GtXBnItcs5alRaXKn76WGTKhKccEQyX9uc9b7lKDDBXIW4hlebojcDhtW7MqT5dmkyzBCgxdKIq4PjuK6GP71R52CWOMaba-M11tZySVf1FP7x79rlC_Md9mAgt69F7vdWMYCQGbKQKyr_miMdb7fE3xH6Ay60sDfIETtuPnkHioxd_w0FBZcNuwZaEAOII0irBBiCPVTK_Z7ZinVeONnbi_Ecszp0-8dIvtZXKTJCwDVBE1Fh9FdBEdXtH16CB6ablDtNASp4yhlxSGwExbCccIVqiLYFj2MUGHVeSXRkpUp2I0ttSvpIIYAnHwCEb-3Qi3lMa0FAGprSK-zGVsxtcgiCMYUad_4Yq-Bm0rX1BOuT5ejO0KnlUVJFvZ78Fan0-aERX5SdV-U6PQWiXaTOiwTTdgCY6iPdH5O7X4gFatr1nH92Bb6hQD0WEHmAF-ht5oqWRebYMEjZEX746RocOpEUkDx6Nd_dWqiSwS3Ko0dLy2MAypfF1IQck-lJS3KtoIvS3DLmNQet6xtw0wk_0HJQmIvS9Y51XuMwErpXrPrv_62OKlnIWC2m7Ju89MvpymozoVGlZQAbQkgH_KngffWLNx-MA0yzkcLJyjPY3prfbBC-7slrqHGoq-q1teGihVSGxb9ZKflfVR10WK7viC6m-817zBpUdyKIN9KmBXY4VbrMI2-OQ0cy-_WZqOame1zj_Y-vdhqCShWpdLaO-NNTbpn_kzgMgt_lgP2LIemj3YfzjGOjM712H1fmPTwKBuQ-02AnLbYaxV4QyHUkxzBaAnq9YweqMbZ9APlE452q4co_ovQg4uDIcHyVO7jBkA8ArZXqV_H-wiAmuFqtxQIlmvnwIdvKA63XpWhBUyxcAyXkSS_KMSwwSoI0ZxMIwJgNnfETGDRe2gGSBxNGkMCxdHoPHaVa-UK6GVVq6ivQB0rYPuOIQehQQSt68oxrdeF5nXM7w1xWr2nNr1vVv0dt_s5ovx9qCcbC5zGo9Hzr7BJz9NNVM9s_K1lbKRHvtbOGJl1QrQQ9S3hbYWaT_pWyIlYqHwfuXTnqPnR5zrpXlFBpV63gHLQT_UVqv1NZKa-Ig7z0ervV-dEpm1vJghxsQLX7D1ARBhgv-jvSAbuyD4PCTj7-NNItvokk5PxrfXSX1C-wxad8VwdJ8YpBOCY4xtAhAYm0_IXs0B6nQ9BVNF21of3O0C0CI0wOk2IjsdM501-RZg1wluHYGXxxqB8pYDL0ZKluM5JXn-WQEVF1tlrRpMnUjVeWBO7-SDfpOwZGCU4mzfSY4W_Knn4y_jEH-jzz2mcfja47entdWyFQEyq2wGPvWHfhmhDodozlY-6sFJ2knoWR6C1KaysbeKjq3jz8oJvQrX8p_rCEek93fOReGvMocExN9FJVhYnLYfKokZXxxsryATOuU3LleRnq_2taSf5L8XLSI9vswcHa9GFVRak09cx13x0du4DyzVToNROidpcchcnCaTQAdzUWxcni8yCDpgAY49tU7NoCdXcQtkX7WFsY9qPEC-0hcnOGoSO6tewKQFuxbWUCY-aWK6v1fNvkz2Sx28fZy4yykRsrFDe_Q_8y2QWcYSiOf_M4XEUee0jdS-oRRPa8fg2xHomfwFSWOlEapj6Pjt5tDFYBsXNc0bBdBsj3SwnK857oeVKjNa6YzjA_U4uN4STA_317UN0EYWHrcEdLtQcljCvPByPqWZpADRAyCqL5pUUGQ6RPNGl_NrtPtNssLXjWeXMl0AZRthCSb6t-7lNW4s_qmG3F_qqr_UL5-r6FRKq65qCn01ERkjZG6tRnMK_Y-80bIJqIYf7bXeNH-whsDsyKLi0d0C-riWWH9Dt4iBD1M6BcrjG4z4hTMlZZ7qW8kCOCfGOt_1iXpK3Mlo2WsyBGr1h9Ml0hz6LSl1ccs-MjBh1Tpiddg0ea8mvqiC5lzqPZ1yw8RTSwZJYrI5hCN22YdLBLeMKeLLux-wPSNYZ2c9KQKu1diigIS3nmO65FSveagZtazQamFkcK8mCTbzqonMh8L0TZVKT5MTn96u1Gts4mDnLRcp5_KkRESg-Eskbghf_etNIy2eBPsCBkDvVnu5-GQ0HHFlKyN_xDFIcpZB7wfWoJ0pDyL7w9wi3De5wabfw4kru3GKodTwDtSgizLQY_NxqH0qOA1UyMJQS6wVDnbe_zaJYroluzzPsHkehEGdG-p1D0UslX7zBSzpnbLcrRG4ma5pD5tnsgIflWvxnVyjTHazxwYMb_3HVVBjU_9jmo_H7WAvtQXwq047OYR3Mj_AbV781BVy5AWF-69miUJIc2sdo6gfxiunpIEM9JGxKkcn7H7wBeUfgFqJQJ3ZFrMlw4JLn_3cTeffV620ADw04HYShYJV36IUMxoiS_CzngDqY5p3U3ibeBKaa8QgsEqusF5NlewXrVg6GeDeMtNbJDZO4bb0QYed9VM4SdJdHgfBFotnCBST5AcHUIWCThRFo2rIw7-lTKRe_8a2ZLDbhYsBlqnozdk89udmeea20GEPPNBohefs-nM2_L_kn33rSariw6QgylMNG8sxNKETEihzO4wFT3HtniM00hsFOjujAHzvklwClihe_ow-XUjyNu5V6gD9NkvRJy4CXnutFe9JRZxAm8Yjfx_5EB-Icbi9-7nW-6lHRMJ87hz-HdQXgSkAL82f6kkEApyIw_fV-GYlPEx3CD0dkkaXH7BRnEwxoTunjjK39UoPs6jjcQk51rXUxv_r9VfPWgdFJVVHeUKg4IRqiOyQxl9z_ZB0GSV-nMPVPHgHK04Sv3vrXeyiYqzvjhZty2xSdosVrBXrD87Sdh-MqThjTKqBIGATA3g7ob4E7J67odCLMDYbc6Uqblq1_a3XIHSib1wm7q_ApBdc9spqJlIP7JxjNqXnq89csJku9qlxIme2HxIESNgLiUdeP6xo1TGQqdLjuVjZ50DkTxf0Ou8z1ZJU2Hf2pT_VhIgZI0rqm_s6IahZhYEe1j_LR6ijyYIAn4P4-Omg0SELJewGL1Vg_P4C9QKlKj2SqO1P9tMc4YZqNeDK--OEKOgvtjZp-Oyq-Gj1kxEnDxQT8JV-6tfiyEdLfvgJzfC8WnR6MCAvROpjjppTGI198Z2DVpgn0LvYGA6Zx9ZMwa9kkj5iQJZmEO2axG642E7ae0SyXgX0bAt-C58ZXiVggIApJpfNmjUx_VNTNMytheMNrBz9ZrNCgIFowH7QbhklFqZkhY2ABj3WfS7ZibFWqbEki9yq11X19tsqwnwd8uEx_cyfyTnZzwd-VyC3Qo9Hngaxh3qzhXJTY0mqM5-AoBiV-0Ufus7YEGoZR6bT9UMlKsCoFOFyPrKbt3o-kypR3zUyaL8tUbFAA8BbRNTQOmyu5_p7wsOJGK_ph12zz_lOrklCvtuXSNW_Q38Ytfy910z3tFp7u2MN4laNFiqBGTLU1v5vFARQZgjWRTR3DHEiV08dpIKdKMdBvUT4_qARYKXOXz09wxLQMMDFjDO4YlaIQDVCvjWbtvBsyduze6E-dm6DYRe7NUalu_QzzIZA-F8C46Xj84C-f_NBccvhXrbgrJG7csRGeEfy9ANsZooHNSmNnu5rcwM1k0cfVw9waHtzkqj7T-tTXcv2Y4XG-18wkG8ajQZU1OYAJQzmDCjGK-WVo85Sizkle4-DVlwZGGcXzDiQDbKNdT0hJWATXfAjQ8mL7wFvMsA7XbSgoEl_E1hqMlNwknTRg_qItZIab_DneskEbe2kmsMcOjtovg5NlXYOZfKWSPuxAhvNcxBWnyWnVRetVB9Y32D57QbW3-h-IaQceXJZ5TgXP2KD4OT7tLhhFs3c31m77rAftw9LicvvShgdmNyDqWRq-NqZpCm4kRRu0GbW7D4IrTAyFwV0HYfqEfRpwdLNVQt3-aRdsAzr9NwBwSUAlOZ40CWjkh5gVjTZMBWOKj9RFkVeNLuljH7_naZcSeP83Z7qdxRJxPoa6Mq70t-nFhR9CqzAhX6UKzSQfHjqfVEaT92mfK42ss7WYcznmiAmU1Dhqi_cmrZfTNiuJp1g9EmFGh-uyq9LjzX5eAjMpMgc2oyq2sT_ZBxZjBis96BCdC3HFHJeznYNauBLOlxefARWDxU_922fDCYwllMkZHphSwQLX72idYouE3K1ic7DTdh8lS2su8IUzXyaXd3HpeB4gMuLoJpQP0lZf9bPqGX5grzQljyKm1t532niHOsRJeCWWwED05G0NsO0ksqNKcj39h1ECca7k0xFs_-TpXIv0oQrzSQddtKgYr3C0fxj3iKOFhMVEUd8EWOb-EbcxqcyR9LABdv7YHRJ4vJIDZ7GcP9ekr0myxiOAFhMkeNANYe1N_vx9UaoAdVR7o5mwH68M8ztpMmYOCd85KYTKk4DmcRmFLwboT8vdc6NAeI_QuLJ6L1NMaHAo6AXAb-6k_SNGK9kMPMWSx_xjAG3zXhY91b6XF9aZuAhSjkXNZ3n32R4ERqwTNvhqHEqnDi-Zz7sbb4h8BboD3tdapW5phKpb1a2p9NG5CFCYGqpb5Qko2AEqwCTWyP1XexxWUKmqWJoDgbKrICHXoxKlJNalUy93IRArX_ZfrSvZKDkp0hAdyGQNs6vU0ltCVcsZ3Whcs10kri6wPhVu0K1tt8yy8VEUcB1att_X1UJs3e16L_ouO_baqCmlrVaj2NAnWh2wAhn9ilz0MZAWoyz16OCo9wQE_mDq-9fC6v9w4DQ12GAR7D5iJ0z4guORTmdKVuyt9TSWD2t9DUGIfcZqJGdKahx4aGcoH2SZrHjLfV78yyCm3AmhPGRk2R74efRVMOS2_ASII2NlEZRRV93lDsQqJXkYDHBRe315GJ9sKmUERDAso1ch3WeMVK1fukIYyWsyWQa5Ted_cRadpo_4cvU_xTon8sTYATafJ8oQLXMVpJKcqhq5xqziuOYhdJ2-VUyapqD6sL6yJ-NA-Xizk3wPwcoLGxe70xQcNAvbPQjuak_4QPScYTNiARqFJtzZf1Y4108-xV17TECZqxLIZg5dwCKNqBXNX2rhPMi8JCXMV8lGwu_xJFjafSHGnmlfVag9bWR2HCUG4sdYlHmcwU_YaCKi1z5tIps1JfrMlX2SbLSWRJkzNiIC1ei5J3XT2AwfxYLrSoAR1x2wSt1UIriRVruJ9BsQhS1hlOWsR1gwyq-RWU7PQK7x_22SXhVG_7J2xmubwtDWngorao_vB3se_ZM2fczX6kk1R6KfukkBUp7rmgWF9-AZQYMyOkUhwhq-OCdksoYOpJZ354CalafLeQsrzk0ZyKCSiQScMcHLVz4PYSF4TRF6VQofkumjg0PQVj1LGvu5qcm5Ptvaxr8Qo8MGImB67U_BpOsG_Jh0Agxk_F-jFb2Mh0t1Ppgl-FRZ52qq2IvSKKN8fPmOxMMxjaXEbGV3Ks-rox6Q5fAJhb3JnMq2NJh5IhAwPrIXCcIRJrp76e1Yra1L5_AvHpo4JAbIPmWTLs93CCyRNZvGeasZIic7XoMxRl5WODm-UH1HUQRPP8Y_R3OT-A8SMzMED8B3L10OAdsO3hIcR_DVtIzl6m5OqekZuLE08pgnrDANfHMZYW_Jfidx_-14K0M-xkvM3mGwNiMQfjD7cEdF7LksQmX_VD7fS5Ao_k9wxSMknifVN8A3CSFGODy_WgZXlqhQo3zSP-i_M99ymKNAIlLHtWY3kkh7ZrWzzTY_dXQ62umF5bV604BuAPSm-96PBfyRYVZMBKuY6rmI1c8mIvkHsvx1lQjGWTtw-yNBL_9KKfefVB4FTO0KukU2YuIyTCuVMADIbepdsmVfV8FKlNfZBjTXOKJk1IxAKMZ9DvftpJp_vQehrw6ygPpGIohpyVTBONVjnbyhsniHl5Io73vl3CLbtzKKNhmznKR2ZfaP_zTfP2X8ay0Hg27WxeJSUeZs0TNtCqbnMd_WunCePAY2afevtcmD4fw60JAXxPAjelF3i7hoYu0RzUt6rt9OHMZz-Rrbiq-h5w0kesuewL6p-XuFzL_b9BUjQ5qYMl39ZmPyQfdnupLppQXx4CIV_ZKKXDj56GXQsCymuBOjvwhcIXyEQPb5OliBI3xMg5fp017Px3Z3aupxjsfRB1AxBMGCMfFJn5HdnskYYK3a6WXq667YYnyvsnGqPoAsCa0Fk6jpb_yRcqJBQQKe8to-S5Ho2nk-r_vUZesVqoCEnU8_M5bMas4WEMQItwPS4OJ4lziB5zB3VarkAregiwtydjKkX5IqPdwkg_VdobzIvEY9ox1iWwQaCxi8pVYgOzmD_MdlpWBM2tztD6UP8ozKC2lsPk3qJIIPDNBjTBfFYOtqw8ocVCSls4UqYyjOgs1o638ZowQC56lvd1YfZwO8StTbP8XZeRkZYScG2AFHd8g-p8PuYF7Gjm-l9y240c_RfuJlXRtORkquqJBVg4ZkKCHgmvD-kIXWNNjclEvY2xvnJleJ-BBWpvzUpGrNz4EfuRaYy_9oqIVGjQCs2Hw3DFHbpUCHbnWb7PUYAz3pd9nWfqpPPgwRngSwv6ghQ2n9LIBr7x9oJ5-e7pIm1mfQG59q8JbLAb8XaZtY_ode7OEELmrnpROFFFqI1wv1fossJ7qYQ6btiIaJOtQ_keIVYlX71m_Rojwyu1qW8q89zlbJ8ZIVMUdoBKwe3C5wVxRU9s0dO0hmcJsDtCpdWaqW4qBjCY0VSp7eMTEyF_ZzDvAgBt-Vdby5wR3TMPG-qEnRpVnge3qf0e34TJmHJ5QZWOFHRlLBW1P6VqjTS7DUJkpKpsVoBKQXxpyWfMYcPmUdCOjCP6M0SWpKkzrzdS8j3zn3l7UkEMUKhos-d5amO2E2balmcmyroCPbWXFcLNI7RfRTge_YQKGA6bwsVm7Luo3KXfb2OxRacl3K4kKHTKPJXCqc8iGm-PRXGUkaqxds1O6kyU4x_YwGacsUYcf_Kfu5QFiCbPfSY6UEn_h6NVMPQB7NTEo5PS9Zjird-vDsimwWkeNWLCd4oCo-pG-Zj-_dA_fbf1gdY30-FapMi0DHf-TA8pxsHmLnqi8_0c7rlG0Few5WJvyAUzodFA5Ba9dqkmkn1u8dFoSifOudSQp64Re5HLj5K1pd88asKx4MXyBxnvMzu4RiJKpKG8XDgPhMYbtjW-QZQKbG9rdjNVos69h3ngGxfRzyT_JyUts5-KmimEyf7VHq6h7AmTTvdnIRmoOY0MW9cPq3_mQRQyEgAf6BqekOxNLvGYoqa0efivsmEghxB1AzZZljAQ7xBPWHo9ZfzfW68w-m9Y6wjNQHlM4JVBWfqzM-Qv15JeYJ-WriXSI0DkqSw-LlNfBveR3RII4r3ZCRWtdU0M9KP1CRCM98uhVQuzXzDumECBD8VPoTR2U0BSC2hpuPHqP5nEGwlkJKNZvs68qoeIHbCPyl27d1ElwA6QMxqpJ4UaW4nE2zH9S_HgUO9OY3GKK4c_ULEq7ePYMGyzxzKReawqsPklrJ52RbRQT_LmIltuvwTqg5iu8zLcBR4FLC3iAT3FTxsNFVe87nDwanxfAblLb4A6QHRmwXMo4BqRkFME9KiXkTI9GSnSy3sKcWAQ8EfEz9FkRCd82MkyBn4FovC0mUox8WIyV3149yzs0KlENP_HPDt3Up0AYHkNbLJHDgpcVrRlvz3Ha8CLURM3fpQqZ0MZgpTynnrH8HVuQmdf2uTKuLrm9E-2Us3So2RrSctmzbvFBTDYvZKBsxLczZrjLV1SB4nq_gW3AC3PtW-NCX_NtRZIZyE_ntZsOpUDoF_PUOVLHwelo7gAq139-mJSLmkVnm77pojVfuChnOZHj6_HDwH8JUTA6mg4_uMqhfd1z568fPgfLxhqcM8S2x0tLlQ2A6_TuY5dLnXEEoYKF0AwfbOlJsLpihqPsDPcZsKxBw1EnkUf1p7Ipfq1r47mNK2XIg21AD_e_3ZZ7fMAcY77_HENhve0LygItxz0MGXOUkMudfg7z-OlzdwPPKcSzQtDrYyaU-XOiJc5NyoE67Ydkqcm_kwH9eT1rdVRyyJ2cznIcgLT9lUNAVQ1NSJx-_gxq0SXXOf4FNret67cFmdjaIRcHzUuqTOgZBEUVlL-QILLjL219mpKx42jk_TXmuktW3uVDyoGdiqs0zUTFy0QsvjnOG_i0qD3rFL45dGpqyUH8walXpYvj53UnIU36mfIUH34ooOCOBnXN60PwxoY-0l9l9P3vRCn7wA2w-k97Ar3HufxXUL5TqTQ4hD6jxFib-ioAc-nhKVcVq2TrMSKshRflLAHudC8Xh9khQoS_DR8_xaqaAaVeMEw_8Q-aWs-ve8sy141fHT4mYuDq5KHE5VgM3OcHpG-S9s57dDmmglqWLOmSdYlEreUtPH5DO5GTVshYkdt536LODUoQNW8LzgLeMSw8MRnEF-PlzfvpSjbiBcDu5kD44fe9Kt0jNx96AC9kxCCXXLDjWFZn7bHQ6G9WX3Bg8V4DzW3N1uExW36UJ66nNxWUlP3Kxayn4LwgkjKK6745jOiwnhkKOUkvHsbrl-JVDQD85zOGPaV4VMXzL1Cpp1Z9GxMV5Ukmf0nVJM5PNsLBL9QirZutTAE0tG1JkZwAJ4cBDyHe8rn9QkNpgQOlPz2lUtbSuky524qJ5q43-QIWbMciSyGDx6ehx6ezxhytutFdzq9wJn5vsDpA0yIjX9p0pmxIs6czOgqWi0JIWfzFhNLft3BW8MOt9YPBWS-_-D1jQsDnIioI6gD9zFJ5tQMiPj01iiK5HPgOV3rZyErbqoOWH-GCa1S74O7rVZxGpYVdEUrJbE5NRsoW3VtTbunz8ehpDmak-wx2oHLv6s9mfXtgjwqIKqEWSKIZOuehVMQ27GFU7BpDX6emZWJj2M3CxO3fXL_NoRH5UGNvDaQ-bshkgn7RvmrEoh-gkxkR7g0tKlBqkmY456VvhltHQvjz3r2u95vd8nn32NOfqyg4rUqiaOT019hDP-JKXvcF88DlJaE_bZ1MK07lpDXbO1aGfZkzRYy1es5nc98KhdoD43lS3N9QT7_qnxuxFuvv-o3QXJOZL4dkIEv8Xt7rhjwPrzOh5HzBYdn4eQHNd_iYQYn_ngRq2EjOYj4ONLRTX0Giy9ErMm0l6FW5ZvHvVV_RdUob_Eqal9ItEQwaycs9NudGhiO6cjskhONU5IXjRq5tlH2ltTfCSNxauEZjRpBLtdZ-xHaFxs2NePGr9auctWLDQPr20ODcmiIUO7xqL6bVj5WoH09oIVrMHBSEeHiXUkjWm655Uhfx5PB4hFgmTEJbMEMp7We2sjHoUtGMay9rCt9VbvQFSeehW5EBiaoPnw_Fe06rMs_boKos2TQPfR4PcNMji-Xe_VrwwjRZUGBY1TD2n2s2Q1_g2UOrL4_byw5X61olFwWottLFXup9F5HtAlIqmoUQAwB4v--C_Nh7Xwg5RGF5Nc-AwKicpGbGhJ2lmfTK54t2M2zhCTbZqgDC2Zz13O9RXxOaFlkwQQk_I1BSCSrQ1KQMUfJSnx8DQRMsWmUNgjtpV-NZPRIHPoAO1IP1Af8Dctu-sgzFErQY5w5BtyFXkb63GgjucBrLT83XEMZ_j0fLkK5s8ZBsg4Zl54qnePiT7TAOXZ4NQzUiMToO5gsceVyF-ryWyflyr2-LURrhXMN887i0-f0XyPlvurrssXuu_0j5nG-6fva2-6cIV0axOyLblkZc4fnQ5vX1R317WnmVS_e8VG5GAq20aUkTssdEuswBPdJBFsdmFotxCwdyKJiVIh0j-o23v9f2Ow4FcdwpT3OJBTZsGj4F453-QpoCAxCvukDlHanchdbO0jjeRUJGDtSAjuJDG1UNPHQQRMDpbXBozLZFH_F1DT1uAu1YTcYMO_ORpd8Mknth71wUOUzybi8EHYPj1fICVZAHt5GNr7MjS0LthLh62t38Plygu4lGRKgJjJIWxMGwCXywKKazrHqqp3MBKMzuSY19jrkaz2fk20mP3WFNjs_CMYgIp2HUNMrYIsybaprNiG-O9nvYc8vaK5ug1KkQ0MJvzGd9X6wTMJoWtML6F2SRoHLsqv5YpAoi2jGXhWbenudV2BMMyHOFj5iJvxtuZ_PdI2L7Hx8xrw0NCSd5NVfe726F_FK9Vnk0vLlRvPlvMfrA6BaAzSifL6iZw8Sl_yF47xijm6svAdI2_8XoRF8j_scMtlSeYBmhdHL652_rG9_QZwyZvFHQ7s5i2aag8qUhYysZcbTYToo92QQJtrBuK37pXIg3pjAD6r2_X1w6SotfxEiQd8WnQL5epCkzBIQGxtQYYJsOwi8u3pUcVOQh9f6gAW0SbJ7qx9qMBbT3kLRcls6DMFsi7LCOcxmLovNfpoC7P2d1r5bt-XAWVrdpaeeBZlmqZBbnt2y8bYL3l8dCDwCNLqdkAbjVEl-PbKjpvJrSvRBdyzdVaDaEAF16hKjYe_NHNTNCtS1IgRlzveH-b25mK9UPUGsvp2L4haMvIqkIiHHtChZwXY5oUQc7tU7s5cqidUyX4SEfRJ4rUyD1QnqCm-uy5HMyS9f76ETteHuJ7WFUHQ1YDOPpXyKGlICu3ojE6l2Cfc2fOgwrzQZlh5OQTgha_daSsZIBeHOx2j3l8Cn-eW1LggDddUFgxZVD32-mgG5ARknobuoK2MW6HEr-McFm86ZIZUq2S1el8Cq6IDpKAa5YuASybgsWnE-bwsggEYvsLQ3caEHTJzciQBI2vE1CzBhe5XPqd3brKah_tXz5ZRxDtC9GtvR_U5pwFQrCU7vMQ3kPeNQC35EvHJks6mHezd58ysCvGr58g-6_P92Tnz7wJ6QVvnwqhYwiOtr4g9h9w7_lUaP4Sui9YVdN5_iPlJTfbc3TMwocv4usoqEIg5H8N3c_8hwjFtRBP_OQOqD6Lzm155jHeEF7ITnCBaMMjVwdF2aPybjwTstSC67T73-axigNfrDilbrSVvqfX62RryBOlVwDpeY15Ax2_u_jdpVmCZtRV6ZlXiBiWd1aEK5aMjRauqu3Vijm-_aYqOs5UIePjpy6zY-rzfxyMp96vRenHrdqdIuPmh69AURNt7X2rGoBEHROYB1UhRCR7DMEeFvjGtmLfVot7bYwacFa6-6BCV8j78mnKxuQaQahZFR99pHV4Li-KHkI8WBGsJSHITiwo3cwB04JfWSH-ubOvcaso1l5MBx5JF2Y0DMueGRraxp31eKgPjoY6np1zT8A5M0MnPfW0M-R7paS8m2pUXCk8xjrrC5GIp5xrDmXzoHlOcK0v5G3us64pcNGm12ncbFgqiIwsCnf_aB-tVAnGC-S-JQfNvyggUdWbUND2HExNWSj92zml80jk7uZOXjvT0Gp2yb4kek98NT1yd6NddKlQ2mxZhWx1edw3wiocHnfvq9Eq9wkRBzlb8scxLojIrtWfAqGJs0wl2PoxdE4X5ujxa2igae4riYxk2QupfUTOXTkNMdQqLGqynutcxYmJyJ7TMXr8HGbNVKB4hg2PdI0q_m0yAII9RzEThv6ImQRjWgDwAC8D9FD4WqBCwuf1SimVG_id2PdIwlA78FEF1V-ami0D_GaW4NEr9fDQMtF3lHjUkSp9TZncNA5izx3AzwCaAEtJVEWj6yk3ESf7ktDP7amdznTaiidL-fCl0eeMVYeY5_vvzEXxUOM9XabYeyDeyJlfxUhq0LQazVBW3Vxu-rDjBcASBL0T_xhXAFnKdB25B7S8Q3mAEoTk8-gOY4zpRv2ncZNIqmmZDoQsC1aYOchkWzpbKBmCcv-jEZCDzzYyjNwbSB1ODIyqChvo8AcrfGBd2TohZS8giNzjw1BsQj6bpa-KxVJhwi28z54RiTP7fQPR1J2M_DXGQK6StV7Bta-fL2vSiOjYlXy78qgJlsj0XWdUpC89Grlu4hqfLaKjpbCy7KKDl0P6q_v2YtGu8bk5uHU9iOS1b362LPLk_0_ZueIXNgh6BD6amCCH-KlJ1bm4N4y-_yowEzpd9Mt9lX5xHF7632RsiDIG3nB8452FrOeOQ28RjGByPbyJt3uHm_TOXKFjFiNn92ZTdhEgldsi5mSoAQ-Ya-Z8WkWGaaBOaP2UdV-PWW_Njlus2nVfkwq1QWIgJDV8KxFonzL01xu0YkUPP4vo3FsdOZ2G_6qS-wd3RYXaeAD08bu1PZNPJ8IDaSU7-gHNdMiK8OjS7RitP2xtMz5P5LBOQ9h1DcSFyfLRHEKzpbCYRoVTdMVe-EQqgASkR74tD4wDJkqry-RKZGFyjWA68TDWXlLXyj00025D8AQjiUfhT16n9BKoWa-XwTVhux_YgFBYD9ilKdwd4Jl-K1dG_saq5cgHsgQB37Y1KefAih-uAQAUc2Kgjw97sStj6OpjAhbv4GqckoSX4raW5KjYwv5mRzptB0OJHzE-q_Krem-AGdkO3hW8yoc2EL2wK578uKXRP2XQnq-lscoJEOjixWeSG3tl0MdbsFRg4HoV_KV67RAeyoA1fE93QQENVR8cx_cluITFTi0WRdO2UEyeK7hkCVPl4PE_eQB1Yv5rwj7QXE3EtEFzlYd5nvtTuyqYYudNdOk-Ll4icJhAItwfOMGHcbi7jm0i8OvedqAn-xItCVdnod_bBtSPdR0I9V3zV4JyQb6MOTdGSqIo_KEclSULxaUlWtSMil1FVQ_0hxwXd_CyPWXpDAuTy6Pn7-M7ItLppdoVt1hejkHt9h_zAICxNLafmn-d4EJjBsNDvsVSYFTls_zU6517auK-cISSb1yw74gyNy-SCsxebT2aE8BmXyyni4N1Bp-uI5ysT80nuLpQBSNBqsy85iicE54-tonjvu3A8Wp96QWrMz01d25cbTL0roe6AGyvLlbN8UTgXK52WiL_7AhNH_nLsfKItU-HRTnoASFUutPmiDcvTnIo6hhk5npUfi09UVnpApDi6ijaquLzHodq36OqYxC0uWvPOBEAWOc-h2EXmqoFyVtl9TeVwzrkkkgCatIKEzZnVe2-S8Z7bhGfGHeMyZKHHcwBaNbtGAyt4-4RPvjvqNghg9cfVWn4bclkqczEnGk2kXxMD7s7seRsV6F5MyXqfBmZdAu-GXdEY5POwPOoll_EIu78rZp8BfxPcmxhnETuvRPJMAtNpXdAZpeo3mYozb4lD0F6CIMDUJtoSJyh3kd6nAxlJKhcNb-vQ3IsGGLE8lF8R_pg-AdG2U8s0ncqit4dnuQPY8kIJSmZzPmvd90yph4vzIQqCwfLBqGnfIH1s9euvAUkFTcExAgoNROh9GDNghKe15rlWP3qLYFGVaPXDb2L_mJLSz3q4IZkwUYzkw8yPu9dNAS4bunLxi4BvmJC6zkaFtu7ZTqrQ4QAXg3BmIs4xCFM7ZJmhDskwr_gs5iCl9YI8ZEp1ik-8n-xcrXd0D58M5N1T2MDLejspI0zddyLBBsTZhMACzbD9fCg3SkohMjXni21wcgIJr92QRDxDUGXtcj9ZyDJonxKRsaj70RlUZvMSsoqgNLN4p8UhHmo8jorS8rOmHFO6M0aBtPjw4xgg1Y-tdjMtFWCMeyHnVupWIySAwMroZUN4_YG_XKWdDK02t-LRjRuAEXiOLRhCLfOPRqxo7b54Y9DeHrewpkkHQWa87fQCNU7D6cEjdnQj5yuDaH20wI0m4PckQn54kgGbakTv45uw8IjrBspDcWTdAPFF7InKRAqtlbAK6_u9A_qj1Vvrrvn5UVnFK3iqW2Y0_8hCbmUGF9a_SBilGwsP6xOeXA2nh3T1uIjZp__zvwph7B_FfAxfL4gsKXgmq-ZJS-GAlcT0aM_z0jTMLOF9K9z32jknGz0Bak8OleqwJXH-Dd8pvNRbwojwu84JHsI68op8Tt9zdcIyRFSSz240c9YS-saT4KpkayUNpG2gjQiZ83Bb7V364i8Osz8ca7PB0SyAf3i-yrbJvFrWkVuCuc619eDrimy288cpJfBzPzgpDS9qF-sA6MGpcJmA3P9LibxI6OOtPont__oi-ZpL2fu3eYtydipsuJd3SqAORzoFk2WGoo6g3Al10N-trhNPpaVw3GtjJi34khdNXMhjeLQj1HnbzAYlkpJHEJczY_HOxxY3aCL_6VH-NEED8j_O0o3XqNWEWZQHufRDdgt-Y_4on4IqtN2DFk2SJkqqrQvZynNX_NX0SvGfE8rWZf5-XtUI8p-_zTUoMHGjq1gbvjoYeXJciSm5kbVj56y022tL0i-ELjdbixWg-8Qmo4mNTrv7JLF8KOEHRdcBsjzDXdcXOrz3k-K2t0c1qM_JTcmuqYqa7u0fDdU9iwqHCdMhLsaxEXuu-9ShnwqoQHaMnCoHL0cVnl06TZ7ubceLPi0vPt8CsxtY0_KjuVDGLa9EsK-aaDVOfrDYQah6qTiGNR8ZOzaWIRgZjmLqW8o_LFLZRgOPeo0sfp5pLYShBH25l_k3Ncs71icRU6z2C510kcV1YBNMbqpfOVQhKDYIN1-8T6sdEjel4CV976IZOPLd-0z8U9qO6Upo9ri4uCr4kc-Qda12jwe3Lyw5hogdaMHyzj_Ye1gtGwqlojY0mlh4nAvbsmtZrLV3HFZIWKzC49iEHQ-drgD4riHkvhjQU1WL0T57uDKFC8icptSt4KFgsewe7b1dlFYFxic3NfnJvuj9bZM2wKRacV3bdErN5_-2wtJDFJNb9Zv4la3tfS8ajLqVSaezBxsbF8mnEIkNUB0G3zGwh9_9zpG4ZayQvLtMv14PKbq961ocNbVrOGTd4qDWU1sTOof6d2oTLfHj7kxZA_I1Xyp0q2YekjjqHn3Nk8I798cE9BPvU3XKowIKtwqfu79ub6DEVS8lz46MjfhA4M1DP1rDkWf-9fnG72U82yFiIjzmz53eDEdMdZq11vc--CPFiTyNVgnf-wHJneMVYu9qRlkXO1Y3oeS4M3kPSXNNxgfH6fZG-8we0M7-ZgxxFR9OzYy-Em1QQfBG65iy9tWDhidQyUi-cY3Eyx9pOnU-2QeaVgWnApbfk9VRbYbZkcu6GjMli38ixlFiOsGa-ZuEa5NiEQ6CxJpVIKtwgldnq-cdneoMOG4QJYJGJ8lhioXi6QqJEjuKsLOWSwu8Rkoq8TXvwmChLIXkQKAbUQy6RL_evDkTUoKeklIwCLR3uhUW9huR5xgFxYyHwHikKM2MHX0Y1TTUt_F-3Mc7lY0lw5w0azXNYpB3xO-JY1Z6R9-wJ2J78vRFYXWvo99MsX7EofzytyU83F_t6VcCbYBl3equ_ugEzpzDgXEMbi67p2q2urEip3Kav95Xd7x_jJ5QLhIa0EyZfSyOPZuNmpCorhRu18hEr9CqGndh9a0S_GWKmnX1S6iEcq86Q5xWzvCzUSYPtjkH7ww32hUQfYITsk1E9V8i5flPiZNGemATjz30YNvwFQAeBlOwWEcTw2lFWXfnVnCpDbOm4jU4qvZ1OwTSP5OO0COknJ4yzMYGmchKeayolXVGdkgOIJQ9EzAwiTDPV70evaYT8W0MHPgVKGSF_vKTvi6o3S_nhd2OAsX2JiftWVy1kRNB-zru1BtAxVsGq7pEiVE5kPogGwYuIQwDsU1SbZbGmas0ARz7UHYzTpezUW9H4R73twfNnAKwIrGrlvSBimDR6F0snyBhXyo76pNNQ_Be432Cua2Mh36vg5UYrK8T9qWF0PdccRIihwwF2zIEMkgNpWcjpdH_qvTTorhOcSe5XUIGc1vHEM4YdGcUjFRp-LdQtJG7wVoj7nHab3TnbFeEESOqWocvy2Ljo2j3TGlatT9t-_U1NB0F3zLcGSlCYIrH-7pw70KdoD6CbIcoL1X1Ll5jc6lfvMyvE-o2ygAVVwlkZ9cVrafYqpqjxeMAn7X3OVSXJHzUEUTAw_Gm31QEYULABNTbYg_gYfwRn8vgfqgDd5bBuU5Ob45KZ3HxVwhChi2L6yuY667BYiOCAN7d-2ou9OhL4vqVKoMwYdVBJAAYj-SZYd1SFlmJLwrg2z1y-8az0vezqUcDk9CZL72M2mKT4Jo_DaOlLV36GXe5zjJxNOvBB_JMEHc6KkHgXomqsmVLEyAEsLONnbbFqaRpTDlxCI41H_yhjuQRQQ8Y-50lR1_RCrUwbZxS3JmwUzLIwqrGqKTuGYPg-uGBIS7CgqfFDOYZZu9v6pSYjsCebj6dSyRoRKfRR05YVvWF9KGeNvRUT9uuJB_3nLSp2rPJk14eYnW4z0asxDqfWZh5At7px9ANpdZfmoIc5ku7Ztoat9nR9q_35_dC_n8JOq-Sr14WTcXRDdjPSOwrKHW8XdQMaMF38dez7c6Ct6ncEQztUkyowipqNEaxxsRLNZSOivf4Rfqm2N4UXP18D5Gu385QjbfmHfv8HtqYVUWXj42ZRx_jONfD2HWjPYegm05qte3oUNMnC7meJzob6D39WBkyJGHbMCzkvnvuh9NDo4UKMQVHHalQf8cASf2Wm5n-T2_lqnbshEeVAVevRhdIu1Un9iWvEdS4WHPjarRVz8-Eocj86xjebkvosZJ2y7OMVFJ_HzWUx-wuAJus3jGYS-fckLd2lnk_MEjETJ82W6pxS1LAfnhwOEBYQbEvCJ_HEZ0k2m2ELnGAunreqzQ6STGnfnpN0bv1qsodrMkgeyYKyO678nYLsIlw7dnkuaERUmt1HgMpD-Fhiu2fs4Z3FSFGRz2CExNjm7mWHIs2SrnhPCSE25oWQSSiUBMz4SliAOITobxnIRwpD1JDAziPc6WEZU-TEtBw4ksT-PcL7rb47gWseNAEcVNogFR-nXHygErIGmjuDMZSg_uIy43gmn5x6mBvBh8iDlrGZgDWjMlryjQN0XgFfjFKalwgSqYYElGXPr7xsS8aulTD2D0MderybfXWVfBKvr6kpx8IQr-Ait94ur5tmW8SEZhbC1Ne0clSU_XgBtZY3OuhwmQDnRNNUx0vi0H80-XfjU26kWAIKy4q1E68II3U2FrW1id7RJfjo9OqpMbUHomXgZsBNDEJ_6tcSq29LveayKyRJQvRmSR3bI1_iuN0NPVEcPIl0jnl1wRSoF85Yj9J6sGLeU73omTAGMISSFoPO6z2nnwpOSpBiDJ3U5SX6kDFkCkopbc6emD_6SbrDPliQYz51GQ9pBFQpzMjn7bmPjyiFZ6Xq4KL-twiUJRDGlXG321kZ92Nu-KP96vN1aFs7Icw8pzO87xMYmZqZEz5_CsHxxRATgB99CH4K4NfZOpN0qRCheizgzB4a_FxrjUi_LHsFHAWtgKGTcjVXVDMWuHXnSjhizu7rJhj_e-ZiJjHkKmmKSQRWQGoMVDe3J4_T9COOGdlOaHOrlLW_7oW0_2BE3WsX475mcGT7pooyGY8xDWCf1AWJSI2NVgUVTHeBhZYl-mGHk-5uQdtNQts5vv4h6KEVZK6cEWiA6FPB26OihRpOzieqIrxAqb3zzE5r0P_4BBx06nzAftS4uEA-ch5WUjVHaqv4s8wECcsusMI8XD_y5CHVX40ASUzcRUFKsZnQafl7upXgLG3u73Q1gGPcSMyxhK0UcAu90Qk-_4xfEFSbXMcSNxWgO505kGJT8eTXvSLfqSWJTEXtyb71Kw0WhNnicnCODumPfPyUNyd54XhogcqGePPqSBIIScy-ySbmvOhCtyVrmNPIw_ZBBZSR9kggBpkOKs9-2Iy1b6tA6Nbj2H33K7PK-GlofCs7sBpTY0jHBiM8y-JONpZuh1qA9mbH_T0AWFX-tYN2wkrHnD2gi4FtAK4KMSQzCiDuP3MIEWhjbExJHTlvefs-9djwdbdcP3D2V-2V2Jm-NQsHooW5hbBtxezz40rEQbT2mzV7ReaYXS37V53bim1WiY2Ech3SOrWAnoqIxfWNGbuxK3G1BDN2PnelkOCZqjUWFbrBrSoY5hgzeDuROXX3qJ1MMYY7KtW2N1CgI5k8LFxeBMetzKEaxX73yE1nQFrXUJ1ltbSAibFWLx_9OwHBiLO2V7pmj4aJsliMtyJ8R4FFNnS_lMC4yVMmfZEfzmbkie0jPUIA9MBMBBnjyodOW-l8EWP5-7_6ZtInAKUrkJL6itPfyT3mTnu2xhxu59COtkcW4KlWjlklLr-IOsTjm_fHaBq2srW3-0K7-sL_yVSwV2bzqv0mLK_ha-jdH-bA3syMmEAahCFZY6xz_2PBNQptlrbBvY8vmNEnK4PBCzjrNrYClieic2l6I4pFi24ayBNMjmjoDpAYxXS_ZcPuFBwIfCz_sVZix2lBTLRWRPJf8g5VLh5ctYr399nvVZItEgtbAeQKElOooz7V-Rdn3cWnqXG3gnzmzkEp89iVHHDILrS9ggcQ_9zX3Lw85E3T816GLPIGkF9PVmR7iInhaXQJpRduGMT2SZ76JijVyHcBxRXzS9EmakgJgJnHlsBdK2mtJ2RZSnkv_i9ZCB32qcvCLo1vYRDWEojUPxMW9-ZsLzNH8vzwcMQ-mwLCE_28WvDkj6BQ7u7Lr6snOmIYIhOsokd3WwJBdm8flqRSeD0vYv0C334eUVRCiydPJie20EiZ6BviATKKnYcGPjAxcnCKTxd6HQ1unFcnNDbBio8sWML1VoOdbIUwhtP68k9L30Nn9YVw2lhvKmuTdYUkEPo0y30tfKTG6mQpC49nFa6-ZU0RTBDlhIexz7tZabwNh6gdz9dJwk263hHgaIRCLEUZrTnImkzezKY-jnjNLY5v91mQFsGVbNh1fx2F6sUxvq-FytWjozQ-30bBax77gbnnCzw6o14ia7GNPcu1U9LdAwkYDy6fMVROdRAuz57i55ZvrYIut_04c3uq79GEQChSZt-GFRAb1OXUQzU2mnyg5MCCnTNmBUHs0N0MRPrSNWnJppJxb7xSu_J_XK2T7mZwPp3rpDbKLeaSjV80saj6yhim0Qb5KG095taFCUsO02vqzPKQV62eQsKVH6wBdsyIE4I1cXlmr3qeXM1UYTqKxMypBaO52MLkFzUwzU9tYr-3-PFOsRL5JV3j2qJFsPyL53hZTEnnIbpd3OSVupKcc46pOQg_pMzTHW-i5OFjidKMkFkKqqeA_Jewn_zq8G0BjSBHT679twi_fO89gwjyB_Yw5YY0gwoOemz9pdHRt9Xf_6iXIC5fPDixU4WYFAS17PYcrJLbH-mUgjW3U8stWfn0o8g3X024_S9IJe0B8aencLw5Pu8yXcnDEVt2MiUoXW1_6JC8NJObHjxI3X1cIBI-xHcXqU954tZb3gFxOPi0NPv3imInxJUrzk6nXXI4tL063RIm5a7kfnIIXcKuJVwEUpYCQE6uy2XqQUKInWUhPgXpMK6lJ2AgIeid3lfEkaUWyiVeiGuwmNlFvFnSYsVaYHV68TiE57jAlOjvp4CwE1mpJkr3QmHyn7PTqJPAo7RQ94TvIcjfC7Y6iNKMm3OK8Cunphf4P5AzsVWfWbf5TLmbgBBElAtC4X8cWPT8-eX-qRJroSkG7QNgMFKEoP-H2crbSnmHt-Tqnkrx6A5QE6QoOIR-ITNpX4zTI2B3906ooZU8ScjCOmbTeCe-2bNE1YLg_ipOxGDnyrt_tvblMpRz2NpHPbf3Kxv1SueilxQclZeuApG0PgtJtlCxMrO41Pylz2TG-2zvksfBWcPnNkhcSUkpI8zWVayS3Fyj4RIfWDP7C0XHtKi2X8Q_RDQhCnWXQmBrYPLYlseZTPi5rrAh38fYSTCxAxJHY4Qi18JO5dB010zWcQDtTLqJk9baY3vJk8hvDKUtX19yhyjPuYK6rEfiTAusrrzI08WFFMw_U-V4XxSDp6tviMPrNt33zRCXRvo7yXHLv65HRVwRyLj01mRvI952Tk6dfP25vqX2fPnpFrBmsmpqV5tmIp5Cc9_kaoyTqXU42t6rJ6iCYpYhUFkOopFUtT94oFYoAkgbi3smh15UCt_7m506NLF_zu7Y2AR78tJALWhPlcYI8UVUYf05G2SuVZeZKbyjcyDeX-i3HdX60ToJd-c4uC0Io7iF19Ds8Ecj6-z87-3AJphwqJW9_c0coI5Lc5Lp46Mkz389ToJiDMyT5JxDcfc_IuIB_rPRbVtL3uXVt--L2MamVnXzBh5kL0qjeH_bohNr0MBJ8v5uPw-r5oea3u9573XFemq1zSOg5Uy527ztPngMBR166h5If2qbY0iTf9a3PAX_4AeUfZl5GJVlbqZZsjpMj4y1pO-KRXsIUELaNP8aCoeGfeRy_sn9LeKjvFY7deuaj8sKfjjuk6DCPSeqeMNFmhpN4FfmxAmuHurUCTZ-_iEsIoLnuIAV1Q0KX61YoNmZtCTYB0Vsub8uRoxDsvHtReaTU78YDRUk_odOuJIl8IY6nDrz5Zs3jrNtbCwOxAvj5ITx-DY9tSzm94ICI9wOwDVm_9-tCcZmpK_1WKCXJABzj99UzFdrvkrgZI-io4N51NLtWemnPqIiP6Usjj7WITmy-NlTwPYVw-78yx50Uw_lYkC2w1UqxCwdqgk8eIGQeJLzo9QanEjHlZJdm8C2S-iQYXkL_D_fqId2Ihveg0aBfn9u-9g4m3_5bfW10EUtpM790p4obxNTx2IH85fFkG0cx6xxV4bPdjLck2UTkX-f4V1Oh4jOZUp7gFV1otFNV1J36Cmi5y2KvmfOq0fWAf1q-pmbI0yg2o30c-JW47gBhAxpW5v9o_3ZMb4FKeelM1f0_RZVGeagI592oJ22nzJLoYBvQzDS1UItw6hupBsoPiF9hMleC4_vtsHLPS0rjlpq39l0P6J9o-KuuwhChH41PGbq0bbctYXmyqfMD2fTreIeBKGby0WkdqIecbn1UW9TVlZLIi8KR002LlnBa69kQDmFYj_JD9UQihW-emhkwNqtAnQBb-m19IrBLggiv8HS9yB_q2sN0PD97yfimdM_4hpLeF7RgmcJ6_Xq0ODeyVkdCifhpqf39B78KBTxK7NSF75AEAOrPCHuqEvK7-5GfU8jhCvXHgHkmW9d2CujlGA7ThsJocuSmOCA5Eowlc7Z5L_yF0-SQ7yV5g_39wgC8emt1G-sIdA-1KG12eHz43xPVcWOE1LOE5_VNVKoKfvpPFraDTE6z1ITNxedWCJz9vQzsZVaXlEFD-aA-KBP8FuEFX2wAPXOLpIo_zqhzp_13NdqRXqzt8Ke_ZqgjE43MrWZmPAVNSgCxxlw8ZzB58FAmIHJLMS8JO09TFS5DVg-e3mDOWnvawidozCUG00vuZIHcEPbOOpJN3nK4tjUa03vGl_vtpJbEhDWwbEegbWmSyl93nHt-nYvuBCqDKB0n6boV0ltp3j18EVgDMkCTzx4n53YEM6bCNg_V6cJcCPR23ax_Vx1gDQrhNop-Z2YLTWJIyU0oTcuOKL5bhqtAZrXAp1TDlEjpt7ghe9ZiIJbL-YukDU2qMR_-avypKWEYTBlZoVhCtY_LHrAHMIcltgUhi-0nsCKagJpAh-2xFrNX-Uu2XpKQftJsp0JQeSkfD8z7SLWXJiiQjlkoqRIhYixOiX2aGgJGVmcLjoR2UmwRYCe0aNVM84QW5HUpKEmI6etwxgWDc8jkIkOZyP7lCEi0tIviePUjWOdv7tXMr-pLajW8bz0FAb7kRPatq_HTflWTU4BVSWaav8LmvHysQTWuJnXFlnfzj2aNlo_Nu0pX0guvHZMGPN0P2rkH89c-LqXnrQXbZrtCt7Jk_A17cODKGU--yvDdhXolX0vrfCoEGb8FTsDBkOIsRlYRXMV2Uyyrk62cEBBt6UMo_9w1JtsSFMQGD_6u9w7UDBJstBH7mFO4HcXtYAioyZ9obVZC6vjq5Hm69-wKH0_hF5peJnKcIYxt2CumCL6PLsyuiEJ21XlRyMqm4zibboMyvqv11EcPfhRROLTquxdnP2qjEV0tdUI5TOUKcKgz_F9HqkNAgjDOO62cnXt96J4intIdHbTCt_lNvU3kKVkX8J9ePt2rgA_pqzSwGj7_e4TLC6yU1A0CU7S8EfA4oLPli9YTSgcsck3Xu5AE4lOH7Y6v7v63Fj3PrQYzrMRzhzxNkKQVjP0PcC_yOT_shDHkAjbB1gvLl1P0BE-bU-CX6Bx5DzC-vmmsBCaacjxqtWY-BOzogsfRx2l5uneA3Vx4koxieMa1N5PZUrG_KGT1sGxtSMTMhTnEuZa0AVbfKnBWVgLOwkeSU0Bl536-BkXUo3KF9Ybfzrv57iy36R3i2-2JO7NZK4A_50_VXz4yYlXMdCsXY2-DmMqjqzlagaJY27vLhnmpHEjNGk0FfKOozs-4pVm84KLVPVXqYNyqX_qKOBrIYcL6ypZu8bWx03yiz7MBFG6ot3j-PWk8LO1bTkB3_YwHIq27PrY1qpd-tJF6U0qlQ78zeV0tgFvt-u6ILaKlkRyiJPYyabH28VvEa3wSaDTDJ90vUZ8lZ0kTVxx0OtczgK_KKshuulYTmlrdwEeukrfp5zPcNsPqIBYhJiAm-p2wFgfxPeWHtiqheVC4MYLdE_zj8jpSSjeGNw8Z7h0d7PHE8rieDkiAM5KENp4Pc4LIuK4s3EDcuYSCwULkZZqgGNOWamTDmuvv0ih8MyJBm6oOt8GqEEb2lFaWYZCQsIFZwzSUAkWFkwZ_JyiprFSvEXagi53OLX7jpeiVACyNrqo9JbpBKCID9WVMaAjrHrAHst3KvMnEZp9jECR7fhDLe_V-8RuCpX8A8rDqm2m_nYGTHTIe8hyKyCDiyI9RPMzslshN4TO4RK3c1Kp5ZDVGPPAdV7v0JY--VFzh11jyMZB3lB8LWFZZoVu2SQiJyr5nwv9r33kEsq9-PE7RKBbfkZPyeMzKzzfTbBmm4QS9QUMKwNEk6NRCJtBAno4cKJcE3C71IlTd5M6cLh5rgWo-BZ2fGuhKc02KgryLXPj8DfuveB9Vmum5iKw33Gexe0aDJz4Whxl-0aghCAafEufteUrFFTw8M-KH0KD9fiNiBW3nSfla6R_1s3WeyFxjc0ajrurAr4S3qKIN8HLp5STrXdmkDQSBvqXo-qNl7h3m9xMiHD9lKZYaRxXmjTkyjXC1CStEpvLc76JXoSJITKIogXtYbv6gmMxSBSRpsEGBDVYrq5pKVBrjhBBu1V-bD1GUx1GQ-CJ1y1nsYSltiKIAbtUALUCk5XGIYZk0DNEPFxs5HjljH_JFTeXwshxax4UBBwDCUym-lKjMBjsRo36ikaU9co5Ii3PaK0hQsyDob6OliRyN74Ou_UMAlDFuG3EDde4QVYUNsfePR8WlEirjKh5k2EI850hyuaHG3NIfcuXGhezJu5Dr7xHwQiBsO3lJwMRyZQ6bhgF7IGRmFDmdFywD_cr3_hXUL4RUUGeF0v3J6FmEucR2Cm6ILxg729giq6Rr39IbkoY9U0u-fJxZg5RpJewn3D8147pofjJibx9qiIfiCmKjfij9y8opzdC51pYM4VyOxdcZkti8bfDRPmQjcCAgvKPZp2geBUn9YqWfHw8ta86n1-CqV5qojtU5e3piTJiayrcEsWlMlESpV05POoivHuvigKPGXxocxn23RJ4X0wEVloc76hxhxbRalm2t_szlumUbOhAvX-aMo3RZJIBPLxZvOGZTrgFo5fjPrWr8qG2UPuNCQqFHQ7jOLVN0KMIUpTjqr8Jipa8L7wKFKmjjRbeYiKEQr-uh1UKsZzee4zhmFz6rHSoJ1WcUVfcQ5W4lx3RMqyiM89rZyhtRAtmxUEtzLij9-drApxNrrvm8E19OQo1iZ5neWBII32twUN4BcTLDLO4Q8Bsab0zKC3WqT0QrmhIgUxeehpLhKDJzYgogMRv9u6GiDDhMR6wdBYu7z5_8lmCAih6DxuDFAhyCWp-72RlKgBT4bIP1j1X5ZCF1y1wEd_1DUETMXkmsWuiyIVnyBb0dW3FMwhoahlwSGg1s-Q6xIx8A_4vBLi3zAYIQ9nyYRiv-xRCeUVUjk8iOucogMTy4PaJmqHgI4TCDRLq8pXYSZpM61COrIJS4UxmBhZg8hxJbovzAGSfCkIqDw1aEHzX1Fr7_QyefFrqA5oC8IUyd9dhkIAuhk7krwJfC3eacnciNsQjNnSXGrAccBF8Ip4z8u5hNR-ymAdYDbZW86ACZBl-h7BMngXA9d8FJYoE-7saUNcDc9UKnE7g5XDW76N5giGs0r3iS06eFi3s4fR8qSICLr3ffBf0MNkOGQjFZ_UiLbHJ-wNXfgJQRD7PYF8nXz2dgoJuKeXqnUyniPCoz2bdEAYUUzh1TJOgRfmo0HNED8CSrVr85nIw6nJiyKvso8NiNb5PRurduhJP1QBJn4CNfOuJ6EMSX61XB_ADJPt4jcyj_NlS62_sqUXLkoGjc0AfAPA_GZudnZH_KZAqMeK1Uf-65fwBJeUwzfv32lOM8vYxv5z5hLZ0O2Sh5CEKNrNqaC_UaQS3OmKhAB8LvPqHzqm9JEgTS3pk9BbQMWWy8XzkUvhMmOAIKW9PAVbZcuh8Xi_2Kha6co9OeemobF4AadiXXiEVVbPGWcgefWm5wT8xw-WfMJZ1D4QrQvgfJ43w78XllSE7tpfk0jWnXP1Xn_NcRY6mikvO0ckJ-wnfyVtg-YKiTPCG3GjJ9RS0xFx6wAnWjAhZkwLHfOVua_sqjjukB4qUAYdNhUkcdQt7NQ2j_pJOIBwGG8SYjECidXfVvqowFZRvv6vCK3rVGeuBC6GrNPGLeU_SYTTt5ikee8JmUTLwNV1TOi9bKWbKabqi3JZdkIKrSUsd7vsg2yFZCbGK8AnbF6svcdNINU0B4a0CRZsnl0ggr07_QqW6KG8bROXX6Hipz4vNKlxdUqpv0RvgvInTp7hoS26tIwO-OwhgpfsY4K-nR00gwJBcsYZtz02rVu9v9pbx9khgIf-DFfoCu1Tq6dwOg4kk5T5vAmGMXKUamb4xclYn8_q7jDFi7ayAbYtVGRjDbMc0S6KtqzTIqvqmwm-ztHjXmiOhZ_6PhISJBGO16j-fzYEy7UE0hV1rCRTK9JhgJQsxI7Ucnlh-pp0DcGLu4966GJZ4ot1QdekZZG4gp8R7sL3zeuto-FD5rV4YmqJBuEv-pcReH-EoJtTQqJ_M_W1vTFplH8tSh7D3mm_pt-ue5SSrDChu1XE8ZhuMipxmj7MqnMQ4rCItUvyFjpu7849nB7M4SgI60HdO2L4AOvJSWrXh5tzKYNZhpMPLk1sLAifVSksxZdqRbztDMTswyxL5KtPM-U6o-eRrWXQ1nCfZe2kcxt0x13CVtykXcJKf21_zjg1yKaSHW1cSGmMviYJ-Fy6jUDVQs5PJVgMUqHkFWVCJniswh7FwfhRwXCeqgsSGHTZ2SxHnBJiSq8PkZ_TUgQBem5Tbo0hqod_vE1fe5NmGJ38cT9CrhcEjSGMCfyjqH5Vx1Q1dBfnJvNfyjgiV0Kjp5B27UrzV3qsMI7n-6x4GJGw3cmOoUP7mY_C8KBMdT0w8tRRi9GaNAwmwvmNBznwQ7mpU5LkGTz8_GywK7HjevyEX9iNu_mqFbZcUXj3s6lQdICAYFh54ot6KUwRduftovjSgoYWkf1--4sm4qMfxhd_bH6NlMfMCJdGSC_tVj3Vmx6qo0PbjFeAB7SOm6ZQchoaZDXyRGKcRtvGHUBlGVXow0AWFwcAfmpa6Tmn373QITS2eFYhAHXzNrpLiinFPwifhZ1c-3DJ9GA6pD5v7E2_6Lw7zy-Rb7LyJYnnAJna2OZ7DUKekuQgJ10A6l_GVTB3tHacKt8Bnb_gvR2m7_auzBgjQ-U1kKQcH7iiyFASZUj-U4TjRKQCPpSYcClL-UwuScgnbCG7ufuno-WORA6E63vidBEfW6k1XWWacOenAwTThHQkoCndVTMOaqHYTfYJBgcCOiT3hf-Z2d3Ory8w-GqEpRXYF4hIMlXc7Y9zCz2qXpNCZxd6tmbtlEFivSR9Wp9IJmNiY3Lm7KmeCTcueXnHjPXwU2bazx9BFNlVQOg9q69be2vYSzbtOlc9YptVvm-TZml_ZrS732q7cqCRbyBntkA_mKdKDC7KZDfLknm-lHZE4EAbEPW3Q6AFS9yww8YhZ_YzTEziD4TUBNetydcVE0zz142n7REGNpM7NuEDKZbkf6WjOF3oma6p482VftJwCbuhdSG9xz95Wy3mkT7xwewW0cxWi9QQwBdXKkfPVWmaJmO7rsZqtDClhJVeMAqp20_pZFB9JrF1xz8BDdc65ZyCNT9PcYm9z30wFA6ysV0fCkK8lEcFjNAAL7sw2puE4hXfded9k6Ptz6fFVNF5ua5TCS1vPRGPf-BDU45K5HhmWsmJNm6VJnaNcoYmVr5J9oIv0eZPRmOO0be2mdCgQbboncbNXYuzfLP8Z-qQI56DtMlCoctV7mLyDpnE9u-lt8_U56P_nzs46hiaTKCDRD5NcBk4BhnnQ_89fHH77NY11SEfSGfvkQPToGWU9sd1mV9IKYKujnxKPjlhsgDJCi4areinrolfraWDMOnyFbSNEuJXRJNql6RfLRXFPteQvVNx4H3KhULBtbX-VVf_J20UHsb8FbBKq2sJkHQ_kYxh7fBd1RxULYJ_bxUwBLNZgqsQLJZmqwRAagrtsxbnBhNjXtMBhWfog1JOEijw0Zz8BdU6zmwyD--1DSNkK2d0srrmuTOalIsjEAjfNN810kMZAiElo8Px_J7Mk_zNi0-oZb3tap2IGQ69Iisp40xmOk-gyQ_YAWZQfdQJfsIZOq_RqyL0TrVz3D5S-En_BihqZd1Hot-ubxJ_LtXSaMilcn9_dMpfeJKJ7vkRcIWPjNlL68Dqyk6ysGdWpdzxG5YVAU538BK-V5LkD1vgDQm9rMv_qXbAT3KgYTE5d3VzJ5yQuIhp_R-LJtdd2dHk-UglNIO2epG0gP8X-SoZf3FdcIA-7IcnaoLLbCRjhUPWAqylQ9NwDLNGAAgJflD8AENH3UA0sgMt0hRz0cvNr_CLFCLKrEVtY-_O8QYrxth7GSoI5suYLiHG41EDeffwQJk_4dowgVEq2BLm2TEQyEGc0Oxf6Agdg-dV00Tl5ZGGTK_cKP_e49vSKU9ReaaRENHnXFN2JnIseesSynVgdThc2auxjth96i54KQ--jPl8oebuk6_c0BbQ2oa8-hpynfURDjujCuZ2TjcokkUr8K7F6-cVVCfgiDNteuqepPHtH2wqdtFJ0XlX-pvVIcIphwBCf6xqwTnQY_Og_ZN3DDt2s6ArPo14kWPjk2NCproSZJ2bpPUEfCxlaU6FUJQpeIjFHSwzp-gDuJDXM-lppr8kgb_ZUBUvA1fX3dHqZHXaoFrQoabzTIcs_o6Xuc1oohJX7M9tL9jFVjn4jxLS8qY9ul9Niqi2iUXov5HHFGHh6q0OJCZ87lozGWl_KX2DjDdUVEDopxQamXXW3aegHgTl-lHqUcbtIAvQiHDwjZhGNGDvGAymLo-hSlG8exDDdIecP688fGv2UiqmhHP7cJvip-puTTmNhUIAGflB8KOkbQAF2i8YOKkBfEeFJ54V5FnAWiYQv3Uu1YSJ02Vzh90vD2ZndyukFKQBYjmpVeqlWeGFpcpEff6dARROPAxBvIO32zAYPsEVVIYI81b8BbHWeqsUN21zrcJSIRj_l0AlORvpk881bhVVyYhw--WvXHvtLW7wpz9Ui0PZULXoXK6XDF3MSyYVrIgp56pJKLOZNVthMM_-SbgngfP5wacrSx8TeALAwKO0W6EJU_tcQvQgxJWCrgBeZ86WrgwQ3gNGrawIeB8meiYwy7yPby8vwtLbAq_Y0dLt2YVUtHMJmXqz3h0ppJDAoJF3nLnrNXQ0pmnkrtdpZnmcBXMl_ETD8vTtXrsq0qBbP-Lrb2KwHtsClfQjzLU6TDzJ4-k8T6dYTxWcBmBVbO_vnG2YF2uQwfvRBe__hVxhZj7ypdIdjTu9O9wO5MFoMZRvhM3xrLbHtt6oLc5u_IQGRVFvGMEYGhBmXhbTJbB_C70eyHWOTjMNaHfVVoev929MPtgmAo3bh0AED4spK_8E83VljQdIVl4WdQdMw7qKZzBsg1jBKtMKa1r3bsirIHu1x6IfDUZEKlwGPPZRyrrfrKvn0SWJg4wPsfrqhAKGlQSMpvZSX_1i8Nu-LgqA3oV3ZeeHxcmKMAK4SLD2EJKNjhdCtY1WGAFKacYGIOQSo6zTdxozJMR9M2qvQvmqesDLTMkyDmqe1gGdL1oH6Dmq3UCNOgzgeRQ2_tYIBBUKP_TYbZBnFrsnL9xeZqTQxCkWrN2Gcuz1TGuUUAXrlRa62fps73LaV1yXcHNedbxHsRhnBmx2zkF3eCanq0nCbhgVQekyR_USJRbm73Y2rGMgK0MrE_uu7D-3S53gbLygJY-AsRAJoX19G6tnuoctsamn0Tv_xxGrSV4NXuCLaUdjf2gwSFyqxTjcCnFgR7or-wZ5fXqBar3oNiCDOenY07k5Ft2YrqQIkOQ9j_fS9L-RivuJjQ4d3UfvyFjVi6FAILiVqpHfyBtT135BXURzLtmhhCnHT0Wj0oiYRB0gu1aCq8DK7OU7nE9Q86K1SL4Lqo67IZVnAKEjrBHrWZQB7wKLJoTkiGzbEVdEef6Su_dthXFwizQgQI9UsAbLOhdL1sdjG11d6t_KA1ljIODPHN9AAiW4vLM8V5guXhx6pdIlEnchWWl2S2QOh56z0szwdBGq1a1cQapqQL6NwZvi3Gyr_nT3Cn-t1a7mSt7L11hM2RONLxIwB3Q7zUFwkG5WGU_I9uCmsMPH36AtHq42Hdzz6Khac80UkQcV_qjL46mDQ6OtuCnZKUJUufv9ICcC4BBHfkHFimZzcOFl6EiGaDnbdSH3vr1f6M7ONQbdZXLDrBnwu5M9Uq6MbV8jixEh5JIUb1HDcFbyAH8iGLd7dRAFmpgRwQQV3ZBlOs2BSdp6GR26hFNb8PbcB_cL3ibiTmda0umxYpj8cGoAxC_yYcj-zF0kjrvcfk-jSDMveslk4zz6btsgHtU7JKmOjwMelJ_P-aPKg39boQn_r1XgbBEsZSjQFKiIVltgfuU3-Pv7H5Gm_pQJ8BeF4svsFjJFBfdNfH0yz8AWl8Rp_nIbjmHyE96xw-TBsJEdByEtwIqSy1FEpSTStOpmK5rrhWeNmudIKZTVCqFUf6txBrwZgNqbGSC3mbvwrNMIFCew4o-ezq-A4Gb2CUdCmDSeEpGw3D8C9__8lIRQkKCdt6Hykg7u6z-kEhGVajqjSua6QvEolpGu6LUwTs1zlwrf1JCuKqT6u_LfCG7XacSULhdW0Zv13RHlfAkBqAVU0VlweSEP7h7WySpBEwjM229NOEj55-FWMKb_se08CIhcODs7T5MQ5ny2MssGC5vVYCiqzGdbG14KF_fvPrEcE5gyA7SaHb8Sru9t8jvXTm0OTgQJfSNCvlJtj_4NnEMZiPkLEFWij6RJWWifKEwjdQQjSKyEcny7rbv0LkXPdS6jm4Gwf-sDXJf9AKMIgv34bBgw8HVAjkOTlHUbneRedvVPwhdYrF9HxfFKSff-Gg49h810bTudRId0vTZoQ0N0OkikcKxLB16xukAHWxlxtE1uMC9av0nAESNM_NJY13neifMm5RpIazg_YuixrJxIoqiKBR07VNwoH-0RzRVJjYPs_KnSrGEjHlMJ58GqtmZllXvvlobI24n20q49Sok5dotkmLiG7z8_KihU1tYtnoWG5SxVMi05eTodHfvU1fG4fxkSwmjpcVd3nXg45d7wcGDwzeyRh4hSwlCuxwuDz_P-EApZnswi2xoCVVznKfnasnJ0K3c0MCDVRfo4YnAkjUrW-yumCoafLWtS_QnlYc3hlH9CPfjpefFAOyE1boyHyokOCizh5FH4r-XFFv44C1BnAGmktclx9M2lwr13GXbmC5wQjXHuFchNxsNNS8Vpv2UNATRaUYD9qtADx4bnZ5ccm-KqChfDECCHZe_Zxr_fQaEI0EX0qC9oMe5tm5vHBoL0nRnz7GyHk8z-Qo3ZRjoL-_ujxifI8RSMLtTM5cSzpdWtNmOwkgKpf8mjnfzWubwbwlbLMxryjT1Td7hq6ftaThyhhKibbzYjfcpHGVKs7CEA6dR_3A0Lsh59UBiOd4n8006R3wRAoHwVZxW23jIK4DnJ5VpUJ9pk4eFQkYj-R69FRw2MIIaIyVvGPFGWDVxb1B8D7daMarvkWp87Tr9QiGMn8p3jkCeKJau7FNOYYvKvTfqnuppLpZw0j9UohlWOF0uwnw3Up19KkEkrE3Z9od9K_y7lE_rESl_tt49v9QCKP2FyMr2nRM-3vE133Vm193shsQe7VUw4U9K_Cn62ZtWmL6g1H8qRKOAbwP6-m2SNS-cmHmwjPn1xUnYLO7t6VF2ecmN3JNhyEumvm31U7RXB6CVNw9SPUUf8mcXi64j-OnCyeyJgR-AdghM_QbqXSmxTaZp9NKo2znVAAbdYTA0sEG7mELz9mXpnXnUO4E85sCvm-jqBQhuxK5kN1nzFScAbU7sx87jl9bO6wbvMX_nZqtrp078aK0nC0KJqQ09IvJF6zPn3q3kbYdRf6nOVQUOV0YPih4aqyTpdlNnp8Q7zAMaYWIsbeh35LxAncjNHE32I21Q2JKZKbQALxP8ZzqxRulXV8s932TxLj1GCQOJnw5nN-vjOKtDNBM9JoS82wvuBpUOltzv8dxVZoLRlyZ3hukczKJ0jMsuT9UC10QMTe1qQi83ZjbC8ACqzZhcRY4OvPv9kS2Uyrh4AMt1BMpUHcFpbosfHQycWRNx2svdemszwQnwhXGC4X-3uJrji-42dQmaB3niNWYRSeaa4YTK7znTenPG61R_rqVOYNjCHHDb0WspHBBU3L_pg1KoSrEOHHODGBBCAwXYh7GJileFEtAIhc23FMacF6T4NJjwLhJ4dBwJqkLUlnfwbCxQcTGqboM6ee3KsSJKfnWy_CZAA4bm5l31yOEOTpXVrrmOnemK_A2Z7G7WDpncNN6K4wlh-caKHZUsyOSUfxzqCIUMlnRFLwO-x7IdjdpLJg0LW-D_IgfWzp2fK_eVbOM-vSDCsS89-CRGda74pl-kTw9DhrdsWTeoTclucVyuihKlQD8xG-vwVzcEYcTSqffP9NvbPYsxgTnX9CRBZeBqgBMi_e5CsX-g2kzb8R8woy71P3psT8SnpUrJw7SIfrLTgB-9ljZIAx0OUkSVnlteg8GlEUYQtUBBPb9Vrr73w3ytg-1cHzoEcH_OAiXqlnwjyMutWu7VKNO3S72BbuujQ2RILNyKbcG9IFWFNQui0ZOKd4nRC67wFSw_cXiJq8WT3Oft9PW-9qLcTnjK6YC9-6DgJ70SgvAVoblUzX6WNaqhNeVpIcNEpt0NpGsIKUwLj9a8tkJla-MRPkUwB-VxbkI_IsLFU7SMsEkWOoSoR6w3gaILucJKHWYj1mY5z8TuDi3L0UdbsV-AGlnE-gnwJh-hSAgw-DibxR_S8QzRMHnbic9ZajGYjJz-L-Q2RH-dEK7gQnp0NeqaUdEP9DiTlTH4uIZMNueRHMcSi-altZo7PozPnYnCwrWuCKrs-n8RlKYHqQj5wqKuBnRjJwrSM1RhBui2Lsr_Tj-Cv9MOtwX7hWvy2ViLO301PTL41ZwOGeTXA7dBoJ2phSU-cxkzq0zTzesTsvadrOhVaUzgidLv_7IxdYEqaPSPA0SMd5QT7PXusQRvA8iZYmRI465sTEicRZk6Zib-WDrDuviEGBNL6MxJZbXH_ttQzodoRVUTkvcccI32CGjmTrBI8Mr7JDwirZoKggxQi6N7LYc56qHTPhilkyYzu2Oiti_HATmPcTx0o6lXZxk-EKkCkLFzgtjUvcGwfxlkxuMOZ9yussN_vTH6ZkO1wuY1YzEwD-eGey8yrfWqsvxSoYVFvn9fn53csuCTEQxD6b0eF-6t6O8QbY9-58bDxvEJ0wnEEPkU1XXrYHj4xDLLW-g6_tpcCcBiqR-81H8JBs5_oWzcZQ7MIcJavIBUMF1rvGOx6WxTU6jZs2FR9r_Tk3hwxRRn3M6V2kOZtYoBJBEA1-YWmlQoB5vfk_xm2pjkH9GY11EvIW9CB0VvTm-KW8I5_1S-zS6EpQVouEEOGdgPIgnj3nvSlBv8a7RL50sNwEByqrXc2EZ09yjI5qKvCaxx6wAF_nnk_eXWm0Q5oR3x7dgrbhXAgHafHPSa9dkGhwCVyKQhsUoc5rUjQqRfKfiefF2xtjoa0Tyac5Djfcpz5Wzs_Uu8OK2rvmvcR4Qi0K8krkixwsSXoDc863YuB1rjzRKlx8ywlAxJZZrYWB2eNCO277iwf2y2UAkOGLLY1X-tX7eMXqdIe_zO1_OzuLgDH15_aKX6LjgYDgSCwoSxh6P4swx1avoDyl2ME6KrWBe4I99ORLh83PwuVWBpulAmfXWOTgUuJYuljBwK1VrWvyBworKU2mTcDAvCTB0I0nhnecCtzXN7QHHJCEzGECLDkF5-6Da1hrOi22HGItMa4_GG81T6WRlqbecDOKccwYQrlalqtrO-XfRZjfx4dwC4UXODQu_EYRJykZ7siTiRV3fH_T220g7L3K7ahUR8qVsotPSSRSzB32Q4WOwY1egUhW57feSjmY6-NZmKiAJp4GxpaCOhp8zAyFkUBaCKiuF0BI6r14Ccif4e-0ye8SWrfJuy8fjTdTp_CR33S9OHKcEBkSFKWszidZSXut7NDNa-KiOiPLckDdXEHl-WLCPlGx8F2umnP_Rmee54YoCj_l5SVNxvsSu2DPssyifOPP0Dzvu1mkWIPDIDSctjoTrLuZ3zdev1nlRXx-naLgGiM_O6lsNDTZh0quZ61GoG0-gikQp66W9fiSeEOMB3BKQ262qMZDZ7NROQyTLPWaX6ev23fxxXIJB9fbm_ZDIGBKhSlLEerJcYFWDJA1WuepNZVtfVX4FeQBh5mNnOTFi8uLJMFT9DcM6MFYSZqy9pYu5pMff1CitFmN61zloDXJ4CYJgwZSK3rhVWAxo-cJtBrA8equcrcY142yuu9d5vrxHktF73DaO6vvZeZoiG4hZ9PGrjgQgjWveDzo59Y1Ac4baqdMqh7Evjq3tyzRCnXitRuHLhbUtCD8cy7RNjcOPVKsg6xtIV3a80Gkc2w_wmVgfsUgWfB7ju-WWE41bwlU3qf0b6R2IhMrOU2O__4oXKz7Y_U-xGU34bwy63I9TAbmi5H_CZ-IE2WNPli8cOI1LdMfkdw2ZEmu9MMy-pzngXaK1g02HDeRo5f6Fe3ko4ulD5HhN0Wg9CA2pG1hJSc78yJyCiYrY-8dPzQWiRH9H-5jiL9T_Ne_12LJBRKPkfM20ZW-DGocLNp-aWUoujI7wEAypGH2miYkfpTioejK2Tk_H3A_Uv8BgH2fM8awKM7EhemvmprW-zDT9n5lIaQDdUbZIn9Wk6vGzi96eoSEgvamJZCh2ZpkfVUxU7Od8wg0m7_CcHbhxG1W0o65NSD8ExhmBt5mfS6iE_a3GCXUwG5pB9Collmt0OAeJ6GWhR_1ChYw_KKyEl6shSEOor62JP7YcyqVuzZcn_cej7rbczTGkCj8yxuoyviR33nN-CJ-TXQU1rr-1EjuqaLSIe_BCIy9uCDW4b_bnjl71fJmXYZhV6SVkUdycudmIilTHvlZ7xF4C8RwnGgqlRJFU69b7Ic0uQ4C2wh2l322U4VuWx_TF8hHKM4mc8GphNr5EpW15ft-CnUOgDrf8ZkaTOc5L84jGNTtxQvetNAlEnR6HmIr3DWc9mV9UIskZHvR7uKsks8zVnUfLZ9r1WZl0K-L8ayrQx_38A0ZmhdGABcYmWMfOLPsPsPooOVLLqG29q_4WhUFN8aDiP7WkKBdK_9FsbM0jwGZbdUBqt0_zQbjDgG-JL83rKJp4cqDEYqIWNkostjZAz9bcBZL9kb0yMNJdiP3eNdf2Wumm1GuYAZURRIgczx5s9vclDoXlwmPJmJb-_6dVqi14W96Z1eoUi9PWW1AEv5cuV9CJn4Lh3m9tEDmsQb7V5m_jGwG1SSAG5WfergvQ32J93pWC4b-L-wn8Lt9QDtYWsDV1XLaCb21nWbQuwjsHIgGXshssXsu0xJEkMUOxfLF2j6wxC8TI4G-flJS0zeVkxL6qzq5O5LMJb7spu3pjCoiIRg96-TD02lwWMNmTgdtNY3DtH-VpcxqDSEvlOwq2cFQbnVpaITC_OQ_tKZ62mKumI1ZKJlJpCTAc6JyW-Xl82yloXoUp14IoEjbraEXfXYRdCXjd05qPpQorT9Pt6f5n0eiwzh1uYSQh9CDypLQC6fi4EgIgI2zYFM4jbB6PfcQxSCS4wkV2O20LL8fU4qXu-ENNB82sMVi7LEga2fs0MHCQW00rEK5YgE4f88XEqYrEFLj8kuygBI1biMeLfZFGxyaxo0CyoOiF4C8zIcEGh7JF6XjzqU3Re1vnEv9jA1pMmqr-uw0tAc14z9ouAd5s__kKnHl-4YUcxGBANRqWhjvwNljKzKJJTzzV6l3TedaIPJgi7yLfQsK2e0qW41dY9p0EcR9GqZdFt0PKUfVqA7eYCvjvX6KLZD7t13U4VXU2mJEyr-F_XSLrVpKjatqCuiSuoSEA6RDxLUOspNhkJcEHSZHOoHnnxCeV04swMWda10YM5qkmnse2_xxJ33rpQzCKeyF50RHmtwU40GGLEFVgu_uixRmGHNNitiAkSABpn3UGNmwmv3nCduZuhmHOcrHbTBBXqk1FmXRSLlE7WU27HAs5MYMBboD2Smspwp9wj3LlZkBICaIU_tz6yEWRIU39TLZ10eo5BEr7flBDhx30WcSWwq4vYqs90btwbPQFUf_HwWa5FG6QOVkSnLpGbknxgfHZmLH-LPqPUC8qaMUhIOJVn64cLWyXE4Tw0oMXEvm9emhGF1I01CERiazUiF2GI8ZvtAmVaYxGKxHEfJkd_7nKrke6lIIneqg69HPx-sa1ERhwAcRuKVUUsrQowK3wytjFE7ZPd59oxWn_7UjlRQInr8kK3w0exyIqzhLJ38aP3YWIhMNW1GrqldzPUxuuGC_oPmv1VrW1QWjeaTCcJNUfQ7nnsE2-CusoFOf4uhyYtXm7aE1TgRJiqkFYlXJcsq4fZl1toGOSml36TyNBOu3-kTivC9OGsfC49lJYzrOPJhAPJ01U77z3KYxUAu6MVfyZj5LL3pX9o2jFym-VuGm0magNd714BE1nWy5z9_xe-fZkpwrOR9KpDwdJ71jG7B86uv4pFtOgK4JWW0a2c1ZJf-YpxktmJslEf0aU9jcZbXrXPUROEHITTEhzxWvdAH56_39h48TQ1_4kJK6ygv9VRj38Oor7dO9YP7jcVFDqieQrQYmhazFQ3pTZJCC8GP_NkpbXZA8YUwpcUlha74pH5IXO_6EvM-OZSOxaaW_SowW9D0McoReyINzwwNL2bA74w-SIpLO63zzomZnp7iKO9DDfHEG0p25LbnIKp6pxay10xThsr6lvEZsCDIvJ52yEwsH_5YZ7KdMEvhuCvjud5q9iK7is7ek2I73gcEON4VAmlGccmm6oawGfR5EzlVNEVdXNfeLkRXc_TyY5uYmNrIZRC1dQwL7cOqNm5LxNhs4c7CnV_4uuBs_o08ClKZqb3Rm8nuXan-4Fpe43Ikh6-JpGaaMa7X8BwoQXvNwj5AAxVFZhI5YQHdCSH2HABRuByZibPaYEbfC9MnyxmMhQmhOpQXqVZg68pdpsvh36gnzS-NgHl3bT0laFcp4ifOsba3F4fDgSY7tKUbfl_NxcM9Ru_rB5VIWxJKRAbIEFWzEE1eh0fHp6A5DjLLBYpKOBUGs_W6bE_sqjbvVXi77zJXwtIm1S0hHNBmA2_i6WcX2SFildXas5ilb70U6AMS_f57XQhGOKoSk41AQd3SHbCbQeufTGSDS12E2RPSDBlxgqqo11wcuHlF5Wefn2B-tQ0Dg74e_sns3qel9kI2-6IVZXcV14Z3wyqnbwdRqzbmIOyZJ1sm6hcsHRdnB04FLXh43qrCFnkLs_JOFpco6Ix_WMb7_sBAwBpsyIaJDzmSy3VDvLEEsHpbCwICM1MaSvpT8r2Y59XKFSKS3umFgSqD3gvb0ELxyH17ZZAKIUHQBqNaALlXKt_Ag563dXWCGzbqjkMBl9ts1MwCWkd5rjnUVGF77WetD0ZhArvgVk4YM2Qv82PP8G4FGdSBYihsDIKADqXPxC23vluZZwvZmfiZGLkf2jVdrLzrU4m_XUQZ98HkPRDNL3RPr9Euw4RtoIWbjCs5gHRxATnHqfjm4C1hKOU4ck89AAw3LKQU0hLbTU9trH9Aq4-QLCe4Tth4US6H-wFxUTlhA4XQq-LXxzEJIk7yetY5YBIfO86H8au5qjAbhlhmnSuyP6dVp_sK2uNjhGssz5_X5WVpO1nq-ktRDrFjbn7Fef7NIv3dUY6Y37E-ZeQf6x86OvyOTnQUeIaVhBYJlx-VH0sE6Umd_8N20WuFJ2IazrEKe7Vr850vcwalBpSL-vIKAowbKSMleeyJW-66qQd5xoW_C2Dl7YD7P2QB67yH7ZLQFKYCsz7Q8-r8TGBTjDHggESjt4uOEEBgHZjqg7M865wA2AhScq8VmGlMOW-AOJaocCrN4EmS8ZvOl6KflWkcvdZ5Qlyz_PeS425OMj7CmR_voQMEBSqeLMa1jXihQIdOn7Hlz9HqbkLpf45mPCt9lEEqJ2w_-2C3z3llyvu_RJRA84cau2AMWOqkYR62PjYYoz1NJSovgnyQQVgPe_TZJETbkrY_aKjkWFAA85kqVBUYyChXaeqfUdikV6yzDubogwRRbwxF136MSkmFRAvziZwAj3TuvLK1qFX15Hbzn9pjzKFBF5ho5PqN--6X2Y-55KMqVKNIPP2IXAJun-oPYG2N_2-Dy3yp-ZM1oub3acjPAe6dO-P0flTOdTsQ4Y0JiNKPbnbcLloA8VJjVocRXuczhHHGO_bz-q2ajpkEu4Esppfz0NF8Wi__ySfVgGvx3i5JGOyaJNQhC6TglEu8vF0mtrwpcoMFF3KqfMQ2X80ekxQM5i_h0C1QU--cWwI6mUCVKqKJEGyKsHCy8Mhr-0NwOwilgWnxghUjRDU9hppQYIDN8iiLq3SMFgSuLcp6O65YLzNivBedmLHxCzUonx0YmvaNIpvXUyPCxFDe-BjW9Wc8L3vNqWBUoCVI0zHUCdDOLPqmCJlVWbxw7zLKG1zfNlPp9udE3zX_wlnr4Q27DxB2MdQ4onqGUDGfNeFN_jW7XbjPM0O6a6yQ700AjMdvcbcjYNezdhvdPu2VO9TaXs2cArslKvwsbTP-zeNP4M5FdNZT1xfgpxnThl2c0Nwmx8m-Tv0fBMdPKc5bGadxbOWDOOLSTRO6A1FOR7mX_lBH1U6qrK6YOsYrwwcaxhX_3FoRCeOWsEeQaTpLZ_dUH7AkfmwlvYPPKg0G2fupyrOnNZLzkb5dduAvDYBOoksEAe6fYRwVWVVlr5Rl-s-gn9vIlbSPq7Xc5bg2sw-_GlsJTTI2c67YDdUgC3DWdvFI6JiD5nyDDA_jik3n9WSFPvK0PImlYnBxO4XG7_i1G9nffNGgjIgav5LkAHxutBzAZCzkXUr8-RZNJV9MyQcvWEoel6g_wFcOmU0XLqyqhA3nlqvA_n5Rz4l9y14FWQjbwWHYCnHRowel_77b1t6_6662Cb2LCgrqjDWjmfP76SPlklULnCbhhmaCpYaLMFVWN_DDMXFnHPm5TzrnoLfrUi9oQTuOeQdgA_-7D7ovRvOgNU38DPac5LHdidwvGwoSFNWFp6et0aObdQe3rZ7NYCMTgAGFcj98T2zIokEdvAjKogkyHtzeksaEsG08BzLQB8mMiW70MIG9huG-onD_B27ENt-KiADuy81pODD0br23hX9GL3Hkazz1Q11HU8WgBPfFbEt-m900Szo54iYkDP7GKk3bw8dZQcjM1lqUrc030w1bEaz4qC8S7MnVcbe3VXNunPy6NHHI7mcNbrCyeGgYxo_4_DZx3TBeRnAwrZjxjWqWBFPUhyLKTrVO5gJoR-n0KtkqRoUdTXM32K7ECKxQR6EvpxKY8tsIusLUF9bLzfX2yea6ePBEJe4RFZ5h4aGXO8Rxp2b3PvKoP67hopheDTfxFDdWG2DO2bseOFD7sCXycJ-q3KitF0XcLMwZYIVVPSWVp2yVoc4e-qAHNMzszV9dYmvdjLSmqsaG58CK4JTZO2OOVZY-Ais-UHxa0ALRLf7FdAPzjH3pofCvR4UYdrdQxMa7qaiOyJB3RKfDFeIAOk_KalqRpdGXP06ekGorbyFjosm5WPYrAXlgZ9u_gUKpQUY3ubJQI2iV2N_QhfAPoeo4IjDg4Ed8Sx7ld7BRfMpQLVcYSByGSQviD3rW9Spc6zutc97g3_xBGHmF0Am7AObJep2s9oR35_1d3sxCv_YBzjCnt87YIF5TRboRPqSXNrOLTqU0MqUkRVamGh07bHwse9jiGurK_rwf2uokpwHg7wuPBa0mxuy7URgWQWWotKG6XKfLx_Qk5nArwcn6LlkEcRhyIfB6fdqNmoHf6CR32o_koT4SoCjbfT11inmrrKhgz6wbC7Zy4Q5qTGijKLVe9_161W_rgP7a5OZiimbxrunLlSHCSZDUW3VxLJMY5p5F3X6VrvvvzJ1kj3f1oTcQKC9EsLgrWvypCwNmO4XFG0icYFWsHBLnMPr-K3t3m2vWQZ9loBWWp-xPGWUf_D8z30y1vIh5iNcc4GMOqwyKZGY3WvS6jMbVQgCbMkL4QX4u8aIrNRxI3IH_MUoTZ2FAlgl6T6MhMwgNsYlvndymkY_P5e3KlC9FBA47XbCCb3J-7pWJGYVs1WxCj22Ob7Y5UELMt8Sm5ygkDWRjMWkUkl06Lv4ZB3lNIO0K_XkVSySPgE2kubFlMJIObRBzL5X7-lL4NGx64w8JrIwDFppc-vkCq3_20IlBC0sTFwZA1kKu6xOFovmTgN60_HOmwqQ4Cchs6oGiAd7F3V2c7ZZY4k3kZLAvp23mIvYMi_QclgBm0xkRfSvKIjq3RL4Ogb7JpehLlQPXGz4kAxHuC1O3dXVaPjK7zffIh7aIsZqqrq3B34BjEKOn25P2wyOPnMZ4SFOTTv7oe0rzZZZ1VqSxu88IaitOj6vC4JkaEUWQ33Xs1ZfYLsH-NuqKWetS1QwNzW6K1DbjNXTIdVxvQoECbBZkb21ey8_plhgWzkOv5DKbGmW70fFOtKjNFzXwZGsfFrRcwZF6YYfYHPqFFs_P8SFVe9-k-VOXpux1kvOOpA9nBCdfFLmbX6O2_7PO9zjcQRMrxaW-YdU0PE-sG6CtXaSWGjSQKY1LWQsux0wepH9KlG6lCZ6srxJ1FKhrPKN9gms5ytLoZIbqpRIy7ojSVvG-tOUA4VWkz7eAXSUvsDw6YutoO2X6zqI5EOQSscTKKskWNz1d3COFgFZYxz-r2hs4CLEph8qiQqri0w1EqWQ5OoZj0pxH6Pwo2qSN2mHLCEWzA1WcddNJMqNr5lf_8TcuhQnQa3L_hPaq7hycswqBxUHfYP-_pTSeRldT3rlNPWaY9QR7cnjz0EfPUtzZEUCv-RaGyQPET7O2U98HWSwl7TRWmEeQEBMAndEBoCQ4RIbW9A3UCE3feFtpAVB9iss8QU2i_wFXC-3t-RmQGTB2bqsl464ytbSH1pdToXAZPbumXsqN2FyK7fuU-JSCy7Om9D4aMvoUCPbFIbQwq_1VaOizUFrjZ16y2KoYV9ZioTklCew1xP_bgxe5QgWqh87rCTSxSOHnbkTwohN_ieSB796pcUe75ZihPF29nH9ixGvtyRvLVIT01ikheEvO9O0-XzHcUjqQbIbXridMI0j70ydBjl5s1HaPSzKjKKe_A7g1K5YZP73aYleNiW5h0CIjZEbj2uPTkL2Fi9INUtRuW8w0H4wLbq1bk82xf_43nfgVvAdc4N0UM17PsZ31Iiva6AQqGnNZzOKLEAoUDHG3PVdZLO1SuVQwlZvNOcoNjoUIo78Jz1F11LFpz1_CrN-U1Yfv_75RBGMb7v4vUw6bE_AbM2hQSbJso3BQXh_4kOFW2J7olDVaxuA641Q58CJFk6vAH_zelp5HGoptLjrQFOyok2LSInSMBJ9SPGvXy_pqQHmrUUnjsBQgB1GDlYazHGLsx0g378l04PRO7tUycC7iCJTRbJTejXHhKnhnO9RqypB8pAxsPiddhCGCG160V1bJ87QZKq2pHGE6EKuGy_pcbJgSURucQxES8qYcqhkfyOF5xIj7MwHYQwNS9nh2e3mEfmED5ETFZU_sbteclZX7_HDkVjdjWg6u23dUG4LjorQtMZo4bRFXRVoEQn7u68Dl13bv3YcyHd075HyM0SwH28XMA6NznejSvy83g5XMEUcB6NfjktGC11TXvgOeDjfVSFdE_SJqAUGvok6Qd47cJkJcgDebXsTha6mGyWbRrAjAWD23gplwdrTp7eDcjZjWIkR9hEAMaW7DH3fobY4M3OVqCluQ2hRKl-fOzoLrcEEBFUff4ULn-5uMOm4aLe8QP4H--tjC820Hsal7lo2k-9WQINAqpGqriLhe5VeUcBWyxWGAaWA2uPQ1N2afJ-vq0lkzZBsfgjcKuMH7IkhIlEgqemSax19DuCJXjJJzBjKEQ6BGdExTDfz8OcwaNs-zP3IR3QFztg1aWHMARoSaDhKhzfFXBfa7NhIadmRTI5Nrw7MJu6u8jyZVFMJ_xHs9yk-GdudsA4Fu6MiHjgHkO-nf1-spfRsoB04rtqNCN4Q1e1Xe6MA56qq53OrhELKIrUGu8So3-U2VVlQ4VaSayyrK-l-U3gq1Jt1FuYwf9QCCzvM4tBp15HcS668BEzxODeVCkErYuz1eXYTDQ4znbhMB6TnOhjQmhmdSovoavwxz3cftRnJjmVuxxQPq9cjHYSDeODkoFcG32srbMq1lLU1ZGRzP1-P7GkHND6x87QmWtMnqbE-akTCjnjpQXZ3NGIOCAT72Ubl03BtD6TUhFzh2Mhgw7lfrp813sjBa99ROhkj7bC__UpO6FZlq0eDMohJ8yYERP1FJm8Q5mRMKN9zkV4tNAfhSusFrkulfbU1vSOoDL32c7sRSkS38s_aUFYHdKO6kpEKoeXFrk6cRU21OVgclXHKWhKp6xXjc9tqB469PnMD1h3z8beY5zbRlL940riV_TK4YmWIN5cGRCKp9nhkS63RMS7F84XTSFLkxv0VBAdn_5U77FDGrzmqq5Sp0zCbus7aI-E2Ys_3wgTPdD1U32LB67ifVG43WmPvuITSooW32vAYdLjhDDAv6bsUAuqegjqKCkT8Br1TA0JMKOxtkwGrhNqM1LJFxE1Fa-TI7t15jk9_HP2Kr2Fa-XkP4CQMo4MHWBS3brIQC8f30ig6Ceo3c_o7lh_779ybGvdQJy4FF770bWqqfkkgytlywqrDwaKi4rr8Qg1MszGkF26vluZYa3IHYXEeVJmmqvFb5OdB2Dicdq_KHpQLNcauipnMPsL_j4veo8k77l4vBHuyeD8B_lImxArmpAFW5Voe5JsaYNk5joROIkijLy6t-laOPY50cA7T2DBMkWA-_PAa-9DEeJlLxf1Q8dC5tO8L1DzXzpmZSrMinIs0lmnOgk93bsAjFvZFeMK32T0MWsWNRXDDc0S3fpiYx5zZYFd_gsaX_6qykRDu51F28aIJLPy0QBA250uZ1QaweQz14cT1I2UbPmALaagTZjzD74G1mKKKsjaeDbiV1QXWzsn3jGiSTUuHC8E_i-HaltYWeBBDDeZvJp3-R4xZytqVhTzIUdeJiQzPTaWMsflM8z1NPcwv2f6tuhAgPYAvKMmdCK8suyQuNqPgtd4N1wDGaBFMaVrW_-ZgsOnDTkTUfbAzV-gaUMNVkpR1ZpAVQvWJJ0ilUHxxnoOVxVd3cvIOyMU5HVEcI3ls7P7KdWFgQk4uMx3tcV9YNMZToo0EKjs_keg8Wn3zi-3z5x1S25x7oVE4Vd-krsxux51t0Oys9hlrwHSmy0-r-q4Z27nLMdMplkpoEemuiR9PcNU6_yNB7r7eQk7rMNwd9J6Whk0ClvkfjJ3uQbAQQ2jxosafB4FD8F9EMj2hcSYYM_Zc-Jnr1YqC6cWeGQraSvWOrelpilt8scCCUaqNyuar3QCvMm3nPZHaV2qsVmsOaRFBxj6V4cDHa2dJyBaZrvXjYrsh-Im9k7l9cDC1ZQRC_eny-M7NFst1xslP4GbM3fjZE9YZC-I4UG40a1r_959IpO3QAsWCJTYFSvh1CSoUHrtiCbYxcPf94rg3HgvNE8FRvQMzbIg31CBZcM--ZTWsOwbLPndsvwkyO2xEduVtWsIzMzq8H9HuqITY1hEspZX6k4YZLMB1dLrVz79ulEe_0lv5W0vDUxVhMgxQlb-hq8TmEI_tZJ7PSHGTbyKoL3-wz6K842q4sUGsZHdgr-Td6T3MQSRi2GgKR2ABXo3Hc_XDEzsi6rHC5GdV6z90zMGk3w_DNQdqf7A2oGg_jeD4OSKPZhmdpewSY4997kNk_VQ-efWSNJ4c2umFGAcWJHre-1yxsKkljaU6GzyTSlQji7-f_1f4qeoJMXenfcchZJ-hysA3_l1oPnssSRT5c9XhUg18e4EENHe-kDtE3800ncUGOt8Pdvw3kkAZv_N6XPjMwZHEFnGbquZDqqVetBgASsf42HAHz8ULziPxULi3_h7_amC8F8z70HY2WJ3TiwltiFssnsel9THYOSoahRBrNJWSs7mzSRNFwxqVUlGFicprijVPVrgxrQA7USwIEceJJBnYjDdnBZecMhUuZeJCFX31k1DzZ6Ul89NkNRdD_G5_Pujgrz_po9y8ijRZ1h71QrP6rjs-4t8YLvcrqyAUOP_PsH9D7zLwUYfNXn1nTYONIAz45U7SiwySU3-5OM91qWtgBQWuk7k_3zPW6bUTGxV567PQTmV1P7r1gpSSAPs1mKhLY0IRI_NloL9q3kc7xCN0BVaCsu73AFu1bxrNl1_hiPky3eUtQZc25Pas9kf7niO5oZIOJlskLoDeZDqsmfrc8sikLqTB-T9n9bUnxjT7MDoOKXOfYEMIXBD-YWDvYE8vdVszjCNzX4JYiD_nycOuzHcaIBVRRHfLorE97B9lRCcXNrIzzU0BlwS5Nz6A28vj1av7TJ-WHbpqOBrMlpDfQGin42O7Xe-p91wAiNxSLvMrFx0kjtm2GUYxNTn1E7Cn7hcyirWglX7Xg0JmZRMNKJDgfn3ycSIsq3AL23Ncj7cnZDP0nBqZe_GBc9b0SYRfigcrN6sNeKM6sjHjqnEIJyi5VALEwaVeaNtlqj_DnfJKYdSQgT3p0_kz6yekRWLrC62ZP-A-ZshN5zy_nND65dgk752uC_YoNIdD0HtAnlbeotegnfZouEKQrXnyvPJXjvHfPENHKmAULl3sNMPA_rG9mG9ZlAuBR6ckYy96X-TguB-bmM67ZOVb9e2Hh5BGxjJdM21_krPKJCB8dYzYhafs_t0dvUZA5i7jrcI34G0i4oGYq9qO2QhyW1Nk6mbG2mC2s7nGfaEsulpKf36Zx2QASZ4UvRG20oI_tHbP6nHlfnjde3RLRO4jZDqUMECwOfquW6o9kD9XcMi2nTNaK3cmmALLifOji89L_QN2Vmh-jbQf-Ql9rMO9_lJkFJtAtji5mMHE5bRc2IgR2b4rEPsIQaecGX_DBXuDShb5H2m4CbjomYPex2ElERqywElxmIsgJkchRKBhjOR7xDUXgC0mewdb3OUJDJabc6MkEJzvpZb2vX2F-lljr9dIzXeE-8OT2gob5I6Cnx5bDZPlxU8V7GEPoC9y_2MugYOqaanZ_FO0nv6kBODirzrAQQCtASxT0pqM0kr35yWiM0HBsd-z4Y8V9hhwPxK6cM9AjfnmekVensj87MozySt0z_GUi12PWE3eVcCVNXkjIY-7t2BdIrGJJFLcV8QOLChyyb9wxN5mIrAZEC42u9F3X043MX9F8c69LfMsJ3Kx2I9Z7-M8VvebTZixeT80exkNBNBIcp4yu5B9wDQebsNl8N4gLJa3DlMAVhlzgT-KyWvGaxsDmKz0Fm5ysNab2rIzXDYG0a9jNRDdJ0ITM08UmUMYbKrhXh4sNMMtz5WX_Lh97g1kN8i2WJXBvUrS2_ofjSwGkTu5qqNuyYx_Heo2Wx67_rUCB_HI6oN-aBzgzYVBj4KZQ-P3UcWMcqWCZtPMPEr6Kn2XmE3bX4iwl25Kv_vBHPeld1g7sZM_mGZHcmOTxvq0GeWnac2tlgitXeNHz79-6keO7ydlGuUtUbSnoR21vVYSgyYsQBGTCx0hmPLsZAlKfMnqCfPBxlj1-4jfFO39ZfSjQgVXpTCGEberXepJGmLfpnCwZIaChrtJUgVFQ-tw0mhtR-_SBVUaGhKS4xvx38LT_12SCjpQJ71evHyMkBVeWlB49cRpy6M3uQ-AUWpvrB26ZkfidWFu2-b-kuJvnBY1b85fDbcOzWyVUkqfTD8-HO_OcXs4jBvJV8qKHqnLp1K889loP1bAFkM9esXmVfnyTTckzrAYviloUDuTUjyY__4u5asSbyHUaFD60Z_P7Cnn6DQal4uIM-1DQfkQmPhMSRQed8MMoJrLfIRxTagvSsRBE6irH57c7tGN6UR5LD4-fljr_itOvDcAFqNJ8D_Vqpm4LIPnOd1iululov8cEHqMUzV7g5pOZ8zP6SUmZ4F2h2FuV5nuOwlkBbLDD8BB-buU94ad-jzQth9QuTgZ7HIlH760Ulx9hIaPK2kdWuGGJElS3VJrR0UE6ciPgna4Qvqa_o_7FKhoMZgoCyyBkV8PGHquwhRzd3D6sSlVrn3Tvcu81Zu2pFQQPmPwNPKlJYYLzpLH8d1t4pG-Ni3qucazYgh0GywGgNL02UaiRcOMe49bTBYEGfGSShjng4CqJKk44l6SMpb9HQLsODXArHfIvhwE1hYuY5sIqOnRH40Ja2Em9Casxe1-WRLAMSwj9Iy-Jkg6A3DkLvqbE_-3t5pblhRVA4TwnVNCSL15a4prQUuKW3jzFlA2qceuVT659IN03TMkrr8SVzKOO4V3fXUU-xpNmYoWuezF3CYDwYnVdYg0wOljNcRoqHcKETO3_0AQqkG3UUSBsZ-lCDOF1LxNN7SW1rvF6l_VBf7FaL2ug0wCQyXXMNo47sgPHMkkRP_gPOfl-pS1Oy8sK68_1xlrVPZQCWvGoKKTHIpnsdj7mblEIjzU2XUgIVwsgAXGBP2UxSTvVi5LE3PAnU4d6X8k-ZFarUJDgQXsYGeX5hzqx5MGkzI-grnyKJ3FieCoymyL2zleT6VISH6kFPqqEi_K1E4HDis4RJHdXHlRH5N02zZdooLCwFVdJpirG9MdJLuSKStHFxYvx-88xjklcoSbbUhYDbDp10feuPc9DlENIeeLU6dJ3drK3esLNxW03OHiV-8LBCxGY-hN2J38f0ar7oPHEkGXFm9DD_10dM3Wyjh0leZtorFvpA7njwWQhz3igkTBvUmSz_yDx2w03w6sOQSQ_nDxCazJGJx5iMtFn3fVx1ZVavhmIHKBmMWFXztlGdyqySpMGNi_fhqU5GoW2n8ZXA49cHOEe5ImDc1ZrpxkJXizkYs3KBl2AHjnQl1KVY_RqgGnyqPC9RyahyJ1A1ImpT-wC05owPguM95lOREKVBBmUJcxjJQvgR-XXQrual_CTsFoE41JynPoXl0Gm_IyfcNaUvQahM5SVnnimcufkVnOpNZtUjccT89ljrP3znSA4_mQswVO7-PCEv3oRxVQclLFRxHKJ8G4x4dBNedbzgaPq4-UoOegUCsjpQuLcnaQtXmvzcEyysgYizI0A_bhL2V6tc9RnvzDtD2DHq45knO4K4BY8a0Z5FjdSDKg-cMr-1Sb8eTd9mTOleci8Hzy5XAHTHECZb7J1mBQWcIO_yPf6BJj3AOcl07Kq0gPhvggcxNnQRhujlnNJKrlkGXmfQAYopYk9lONjxRCTw6_2mmL3uIwTYAfX7CfP1Lz1mKi3IeBjyqcdTuJkHPO6GzzLT7097ueIGeiUc399kWQ02AffLIA5pJrvtIEwL0A-Jl0MgcLW6xyOlcWlbgbOeIcHV3GzU9m6etPdaDWBb-jrfQ56agR2O7h07TXBplDDzzTeIcVMzTK9LftlajjO7S6TCO2Eot7Q80eZ-F_5YtFMEdTumU1pqocipoXYEnU6tzgsQdUk_mZCoCltuU3dtqF8T7iBrSwD30dVDOEaOLaSLtltqKoQE3R2iwRbEkGws151i-9_dLrzBf8UNeaLOSpM3VRtERzIrNCCub-Jhv5zWX50qAiZf2REME", + "ciphertextSha256": "d7364b82b18e1eb36110f8bf494aeafbd168efb4ec042de385a374587e5561d8" + } + }, + { + "name": "unicode-1", + "testOnly": true, + "passwordInput": "测试A9-Interoperability!", + "passwordNfc": "测试A9-Interoperability!", + "passwordUtf8Hex": "e6b58be8af9541392d496e7465726f7065726162696c69747921", + "derivedKeyHex": "5028db15c37d656a05bcdb0766193a7f69e470b8c05cf37f2c5ec3aa0e204670", + "aadUtf8Hex": "5b226f70656e6c6573732d636c6f75642d73796e63222c312c22736e617073686f74222c223132333435222c2262653430366361352d333766612d346232362d396139612d373463316161643438656165222c2237323530643162642d356133652d343262302d393164632d646630636230363038323231222c2239303037313939323534373430393932222c2239303037313939323534373430393933222c2265316438633332652d643230392d346535362d383733352d626336366238363834633731222c22736e617073686f74222c312c226172676f6e3269642d786368616368613230706f6c79313330352d7631222c226172676f6e326964222c31392c36353533362c332c342c2241514944424155474277674a4367734d445134504541222c22786368616368613230706f6c79313330352d69657466222c226a736f6e2d70616436346b2d7631225d", + "plaintextJsonUtf8Hex": "7b22736368656d6156657273696f6e223a312c226578706f727465644174223a22323032362d30392d32335430303a30303a30305a222c22736f75726365446576696365223a7b226964223a22666978747572652d646576696365222c226f73223a2274657374222c2261726368223a2274657374222c2261707056657273696f6e223a22302e312e30227d2c22646f63756d656e7473223a5b7b226964223a226d61696e222c226b696e64223a22707265666572656e636573222c22736368656d6156657273696f6e223a312c2276616c7565223a7b226c616e6775616765223a227a682d434e222c227465737454657874223a22e4ba91e5908ce6ada520636166c3a9227d7d5d2c22746f6d6273746f6e6573223a5b5d7d", + "recoveredJson": { + "schemaVersion": 1, + "exportedAt": "2026-09-23T00:00:00Z", + "sourceDevice": { + "id": "fixture-device", + "os": "test", + "arch": "test", + "appVersion": "0.1.0" + }, + "documents": [ + { + "id": "main", + "kind": "preferences", + "schemaVersion": 1, + "value": { + "language": "zh-CN", + "testText": "云同步 café" + } + } + ], + "tombstones": [] + }, + "padding": { + "pattern": "index-mod-251", + "length": 65250, + "sha256": "736a3902822ea691871b34455e9081075270deca4ce13a7c4da362121c02353c" + }, + "snapshot": { + "protocolVersion": 1, + "payloadSchemaVersion": 1, + "ownerGithubId": "12345", + "vaultId": "be406ca5-37fa-4b26-9a9a-74c1aad48eae", + "keyId": "7250d1bd-5a3e-42b0-91dc-df0cb0608221", + "baseRevision": "9007199254740992", + "revision": "9007199254740993", + "operationId": "e1d8c32e-d209-4e56-8735-bc66b8684c71", + "kind": "snapshot", + "cryptoProfile": "argon2id-xchacha20poly1305-v1", + "kdf": { + "name": "argon2id", + "version": 19, + "memoryKiB": 65536, + "iterations": 3, + "parallelism": 4, + "salt": "AQIDBAUGBwgJCgsMDQ4PEA" + }, + "aead": "xchacha20poly1305-ietf", + "codec": "json-pad64k-v1", + "nonce": "AQIDBAUGBwgJCgsMDQ4PEBESExQVFhcY", + "ciphertext": "YIcww_6TeRxLreX4T3PWdQpYU6WzuUJhw4TCptzA9WtjRAu5C4XMiIGzASdcn-fev9kjthyLMLdUNutRSunjJnrIRDhQgC2D6hPDM9drcWCuWoliCmDJGOFSA5AQsCJaw0_0Z50yENh2-f9GfTiqduYvX-KZKgiCC5uSImT7gqlEqR9Jx58tr-rqiw1T4YCcDC_SfzB51jBX8ROiZR4W51uXLSLmGkdJmEnO_5sLUxhWE5QnZj_TN5ud2t6BFenXMzzi_pRBic8No87qSXDfpxcSY6jJ0A17w0zI0uWjgmZG39lixaDiVvWZx-X_LXgtC4pNZJeECyRKUzW__itZanEEwDAAjbZD9zLul7bQkJ279HBQ5Zkd-L1BxbempMf6J3Z7witUJvHJE3pxoA0DdiDS1HquOM6q9qoas3R1yfhzGFqG1UyXkwRVyi1_wpf-ZELIr0EOtDFC3Ie_llId3t0U5VCFYKgcW3BxBlEBy7qao5PZUbhpgIQxFvhJhL-rjTVekNalsZzVFqOCq-HHPbeJQ_gciv7Vb8MptTB8rY02gtxJ0cYGHpTmDmje2Uo4qQrAvwmP_6IhWDUGo0Lxkfj0haRsSEDCA7kA41lZs34Ttr3tmfkUqww9MOpT-_SVw0K5AdlsthQeNiZvjixj5refF0PTQR2yOTUbSea0Z31U67wvgxfQUtIWX2H9_TFzLXh6P7X1r4zP8e9f2V2ZKQdMOsF_1XKzkofft8C-siViEwzb2HGbVR7IR5BD6YtLvsqiW7jZVJedeET3u4SV1vKOp07xvj6EW_9OSuAhfZboraBnf-6KgErgK7z3DztAIJicnaCUEpisRi1l7_wd7P45mZx6gZ8azIulYkVx7sXHgt-37Z0DUmHDna6iyK_96OZkgm0rmAcgpdyLNemKjkJO2cSyHdBSZgAhkb3lM0GcgTBtCPuO_VRMPgf_tz1TKQ1OfWdoTq3n4ZuW3RVrzY1-UTgp4RQcHv9JeeBXkYF-h98yKFwNPvLdb5YJfbP5VdWKsFx4hECMsM3LyMVqE7LN4pSNq0w96mlixGef69ToPzPZaZSHHI5fxupEmlRjX8SMiUR1tjoP-5Z7SjpM-o7SpbPd0Z4EHzT0JE2o2LTa3ZZ2GjGlX3Ft6cQih4fxMzaVLepSn5D7HbIjKeIzCrv3KiJDijjJ0kEpE4Ko_Lx7mGUPCUZz1p9VIOk64MfwkwvGl9vGn0woL3RIRgBVckfxNVSYUEmY3oGkEg8RPbhEHusgkJotftRj-xkfnNOXH9U7UXrxsU1QfFcKvdyxYcZvXnmIayvFDUg-1n6L3sODqv0L--HnsJ5Iogv3BTLYVpj_Q-hErKcy6K0rqKRQRBjwPVPoAVHGylU5zwTtMemoVd7mOwehdDsmM19MHHgRXmxb0qT7vZU3mCTTqTI6UeDtrw-2sHolWOdoe7f9-mVkeVjPOEkHIalTtkLSUozHQ3d1POfAFFQPhFdez6keFm_iTlRNxhTHuxBf4O4a6gTdFQHaJave9xrSy_W3hdGh4EwxzC2IJZFRijQ7YNYxa44YJO9y_bLhEM_Co7yczI4AopIogMkYJ-B_jPr1EmG--74KKE3pmhe4cdBpeI0DXaWPiYWg8NFvAY2qPnCprmQ0wM74iAlpvDcD8eLRKpgjl71s-NUgMnIWJHk0_SNpbyRHXrRL85rBCqqJyPGNy0b8mtyhPwW2djyKU5auCYEs91gAEf3YLPuRz9sH9UmI8ZR18cNGj0vrYu7Ul5o4O2DAcfR4A4GRYMxc7aNmQGvMLMi5D7pcFelmPbMLq2hf1tda1Y8Uu90GMiOBK9Ccy3-sp0ebYLlacSH90HOmBHMhfqRLDc8Rk1e0VajLB5Tbfl-bvNG55ViXt37rCcK4JXxBO8FFdYD2Paq86NneSbWkFC8PLd3fIX8p3RZAsaeLOTzfO7NABhLLQpEFAVuE69sZ-LN95Q7TI9sOM-HrAcRb-UelUEG98RJjOY6aD6PyKy0ATkXp_pUSBjcbPkYZa-cHtNlMbvDnx6FN7yl9Y6CAoqjdA0xm_VsMp-qcVgF98TC1T-ddnhLua1pupLP986tye39F9YzmqgydkO4zbWh-fRuZXA_Bqx4M2VuSpsKCp0n2htNOcWS9KFPfAkWzeYr7U5q0eGtt4xchjYni9CFERV6el5gZa1gDS4uNLVuxY5qewuxiPFfNiAOvdPCjgQ5X2UKSxYbh9e5f-tSMZgsySly1DQSRGhKVZJLG3ETlfDh62L4c1TsXanf7o-cDuHOLzHm15IDOD-JFLRz2dwA1KUCtEflnhhggyHoaoeAoHQAsgK74WlFObqQ3rNAiu4FZqs7xp_jpc5H4F5Mp5NhNrzW-r5ElFv7P9r_irdvXWm81LIfCPrJ5d5c-XB61hCvuyO5ALW6dRb26912QVqiiDwG8zZrPgCrkero16LGhk4oNQxg8GAlLckSEk-OgauUatEq7I3AqTXy503ACatBbLutSbR593g2PcgaPei6r-SepbkmJOm8h2SU4bJwvOa1ARyGDQnuat_No-bN2FXp9kMc-bI8qj_U9m8aYrNhYdzDpmLStdGHjfy4hdU5lEr0BnTF9fnNLrd8rXrg7JfPuOvKYNVdfRs-o6iS9nrpoYIbVIgiKeQNKPdjM4UlxrI13spLiPwgVEdzE5QeGI_pNXM9bdEsOFiMaMMsWt-j6AxKg6Q8SQjNdY6a9mEc8BCAEzw2dYus9AsyLsgwOnCt_nS71NnOEv95H1TE1C3_NmoInpuRrplz7Km6_Az_4On812MJ_fu6Mf2M4nOh8Oxj4am9-0iRecDzWDmMsLwvFLvZBX1W0D85-stMDoRH3ycQA5YxCEHcjzlo-PHYqdtDHcc5K_KHRoQ5SbFiQAebV0GTFc1fooUiux4U-9Le1MtAuqOQwpGd4idSX7XephPGj54hOzUYJUlvAw58VIgsGLyt_dC79BRJllq8PcTiD3FwbOn3PdLsuxhIjY5yfncGZfjSTsEXLhaFowmfTwQ65SqS7mLtjBbZJh4-GcI3GB8KffAclisY2KNUB1ybvss4Z2R_IZLq-D7KYFiBVL4AUG94915NF9M1NEwt7kB6oCXsSE8Q6rjSHc2GD9elbe8dxFYwUiP5BmFAh4QUJQGfcbPUKjb4hvfF_cIfL7yeVVTCPwikvchLlwf0qdZTZn0K1qQU4iEIi8TjhgdKmrLjmlNqEKMPsog6RmqbBSe9dx7FJZzA2OC1u6om4La0LKy60jec_y1xxTKXpVjqN2iAg5_8YjYWLeYZESUHY-52i20801DcotvUiK20l099dlB_DP8RwMSYBInx7bljpnumrxRoaATkm3YqYsmZAbA7-PwkEYm7pJpCsWPQPre156mXin_7qbNG3Mzf1AxEk5GOVeZugAAFfsYFRCSKFoyoboG3TCUbUjc2udZG3krJ8iycwXqjBMx4iFtdgryxgKdLVNDnlWGXhKZkxAwtjqS7Nhulpb4oUPKt7OQZYp2J4NPGpPgXIvDHRjqQzCzkN05HqK1KMiUn1-aWO6aDNQW6oUzj_r2yfOL0g7NwdACDKG05ZIjS1_sbYQs5ufclkecsigAqx7-QsCe3g-0-aURiFSQbfgnOwGCa4sb4FmFR8DplPUjGeZw17ydWfmLG8KFs_sPaHSKNXV7nukohlSPUKElxnF_crSRBPHzdwCGnnyloqSVJy6NAIZ69FLd0I28exmN95dk9nIBHKL0FaX3xhOKa_MmkWKyw-rF2D93D7Ur6mQnSdO3Br6ixkKD2MUGVa-DGQ1SVwNK2H6oEdcWDlM4YaK2uDuNVMEkEgtP-PJBL1ib5cOI4ro0lI4v341QKI7L92IqLBfzz6gjhNvULMu5_dVdtHvaDQbJJnKf1JZ0ZdzpYEaoVzEFkpgy7-mPDGaPsYDoCExOKsZDL7lBdCbXZennrLIMUZeROgm9qXN75S0FKfqTMfSCJopbzZBI1Rk2kxYdl-_ybiXgUuqd2XM2GnEO1fmx_JeEsB3ndb368qP92XCbZN7DuyFHZQTMXnWkZH0HEYdrmofC0ptOA1wMWRHKGit0BsVrE6zNe1um3LnQ6HwJgM64UqroJrGBk0mn282L494rvK6QiuZjeB3eXPOwpIT_kG1AR_UdQKmM5feGLWMPlT0trz7F2mpk5sUoVjVJy95LASnCesHBE3dtvU43kCReAjHSLK_HvyQpW8MXEmnIbeEtHiKSKS4xh4To6MhrOnWqDrx8jmhzIiybMZGoZtLMnhhJxWSd_I6SdzgFMLrRqYdlY7D98yz07HmkncOf0aZEj_joAVVG1EXU3hAL01pV5EUBj2iKB3ElzPYzwlJylLo9HV3X-idgcghGpGGcp1gvAIQJCwkZU124WRZReMEG0vSb3xbVNb3XtSCNclwCOgkbSTqaXvzszVgxwXRpn8YbBHW7upN3QK8ADBGf6hje5WSio7TKAqplCBCXgPmtVeRRMIGd2EqyYXFAwjmyZ_IAlFRKVYq6-yu6YV_ZTB4Xf7nRzBlhhXCAGodjvF0r3u4bwBZsYAkv6IWLna9dl7kGjU67id-0Bdawz53XmmdPRVnJkEODCDYvFymOkwNsQKKKKARQ7UWyT7Ho71WibvWCnmz1Y3P5K4QBLzkW-zPzGihyPWd0a7YjbnrBKjDmiZBJPGk8B7jXSifWzkv3_A45eVzKjuuKj2VKU9bklZb-B2JGunscS_O2PXKw2wdXYszVVf9a2pcXkVT6G7s9Y6_69mnVkQYUiT2iKA4tvkO5YBQApfWP7NCbdSgwUR_z7MgE58CFirijDFKYcj8Tc-UYpGPCxCgKx2jeEKHDhB37qZt8Fm2veA8OdmwcmbjvnY7F5NCgw7R2zhHMfSYJHWNhX4bXrvuZvG8X0ODA8zlf1y8-JanfVcJ9QG9DsPOOuMY1ct_i0q-pIWPhgENFfbgaKQ_pvPnXgHBnbTJn0-wazM9wLTXu20qi8jOd7McwwbHOLd6QU9E6cAygRcYyUOZCvVo5zJphwoog6GFKI0uWE5rBNzb3dFhMSv8xf7DYKvD9BbUzv33PPicNxi3vfTHf5qpq-Ac4aKh9qA75dT4Qyh8VevKJnka3eMnN4o6X4d0IWHlyvoz-W_3KHFofHNPSpGEe2vsUyY_LttY6RwKclhb3PYVgMQTzrxjzWiyA54osp1Uvptua8bSTPakiXYuUPBsHMcsxSuRZnQfOuvdwyVEAiQQcf92fg_dPxZBUkuDkc8MLWbSMgJaMIgSN_UlFuAGyel0tBFnvUyKzZADE_3QKE-aa0YEz0WKh6a4thCP657Ohcg3RevMXPyCux2vLUhvwRNeLV5gffAOaBtskf8i6HWSb27sSAo9xMp-BJF_nGtQXX-Jpcx01ciqIWTiAVqI1hTt6wFUdmUTBA3dAOJCAI8Dr3ZxYHQ9UYLXoPx3dNtrm2Gggge-XTJXOZUfRDmb86sBoAkditWEnoILxX-ud2Y46vH21UKIybkrZ6N0pUFZRUTCULjqt0dYDRktiDpl7RBLiyrWvIyA8M5dwIKmST_t49_rQ2tAg8W4BZ1LaJynjPmu0pyeClV3httAuJJ4YZyhPzTvdIKBVNlURHBvG3-uwQv5gpqBpmXxRALopDvCKOMzfjRqntBpz4vXMWL05cKejPJB00Ob6LiGvtpCA60_4WEz8_zJxfZuxbT5nLbRma5sUyL6rBmD56wmW4aF06n6cJrdMh7y22dXSMe8gLgsC1TZU7XwIOibZ4sBjk8lx_xgoX8j-GNqYwN9fq8l8MfvoFvT_XwvWuW8XF6_3WnxxhdXgKGvZeg02QHZgLuo60DPcc_dyg1n2oHcWxIs3Vh6TZsyWyNb22uREVuqDtLJ5b6cQT7MQEU1Ksb4b6lpz5QA_fz1aLWnDLOqY6qyDLGXSADR55tllC2t6V0XeSWmfLIl8sjF9DnmfJBDuzoP-jLmGI0RdlZoKKM11yjZDrqVhAXh9hBOPTQR2ji9gWlOYYc9F6rJTJttgdx5u02HKulbuqyqy6SVPMJSyjAHPZ_j6qaAXoYLY9qejFhTeW3GE2RBMJP0KrUJsqvA4vHnrFVZYck4ozEvzSYJz7laippuxn0jpe4gjybFTg_WcU3588nS1CBFrHBjNnIhQ4vzlfpR8vfeI6wRFRwXUeA9xwBEg0-YQB0acBvra6jp2_8aZ1_S1Tgtz6PlBKjeXzBJo_kv3zvqR9CS4IRijT-x2zlU0hZUHOJjtmy7tt9-9B4Y9Wub6gtvtYtMo627zPuNwZZkosOucyYnJhJ_qzZ1UDLEz8h1P0xNFbkz-itS_X4FEPMEQMHycY55cnobeuSZ2AlxaJsHmioiSmCHl9axSTk1ETBP0tc0u1LvPI6upnfwwFf9hrEI97Fu3nJjI3xXysvk3eBCNSnC-AxK132HzfLygr26CDzkpgflLkU3uB97Nmg3s-UQzV-_NUtphEIkq_sYhxrz749tLMz-ySbspD2NkHS8QOek0m8FgekQYvPHkdfMVSfBXG4ZGARdC0r5a0rResHs9GM5H1djpJ3kVDJf2FW8irgcbmAMvGaFzEUhL1PDEA5358KiXYdzMNFdG8dp0b153Z6ZX6nKZm6n9MO9c66dWmrU_Z0fBZ3UPe3enGuKdGJx3Up4ZR3cpj8zO-LmNsVdi_YB98XYOZmera_Ci7l2Oq2qeFrUchIdZKv5QnCLBbsE0vuRouU72aLk0E_u4NJo4l5xeEzvYALhJjSjRLwju7WXMxynYXlZoBW-gVPu3MrDQS7A4Lj2Nf0s_JlvUishoaXNc53RYtwzRmJ-UREzhEz_102846sbF0xto7TYsxu-Wvp5eZ5tdCqfS5CYk1MrRDAndL9mhf0G_Go5vj17WDu-OHMimK5cmBn-nDqpZYVMPjJJ0nsLDmiKpexbwLPmxPCOfalSA_Pl_CY7lKpqQdzamigZHkDLdlLQzaZ_pqPjW65ijgvRpKCJa4oK-ZC8sDUitZb7r-GzwwZfiSDUhMv7skQT3svY5QvIroqOOATM6tmoDTTNPenMskp1ZCau_HXqO_OtRJO3D_SFjCw9hCBDeW4_qAPWkfacTAipih7qcrkkSxfas-tvsH2dUB7xDHtDb26IViVH-WH9hHKOKIsUd_7_cG2ErW-bsYody5pimMoCP1Mm5iu6phZtH6q2z409rV_eqv-p3o0foBs4QGcKNHOHYsAyHpPU6DaVHFz-dmD0CG3huoHb3l1Ly_N4V4Kk1IgSi2UpglO_KOKKnsL3bkBYwafyPhXlnTzSXA3IA8hWeGPoJNFsra1jwSKeNPT9wvhyz-UCuqmuvIsi8yCjmHfZk3ODw5FIStieVS0iDws16kjV_mnVgOGskn4R0ZkIAvh96LjJZaayDBL6oUoBeYPXCU_xPE9_OlNiNdcL_Z8UY23FG4v9iw5xpuplHqGQkMj82I01cowSyu27FbmTOpWyaPiF_MTckmj_Hky-oarnXF1faLZeazQrPd4eYdL6WOq2_FmCYLNhgfakjz5y-Hw7blGUrepHJiN1yj4nDw4ut13DaKSZKRsKLUBCY8JjHV2DuOwu6gEqnFVn6lDkfH_ItbjYiFZZMoDaMzodC4wNLNEwAsWjpmxel9avirxJZMXC0ZXVSsyp9wU4lZCXNsTto7hA_9rJDdVU9VuyTV5XL3NoFcZ_oX5jKFLOVP5wiDR_oy_YPW-5YJAHx5vEpUdqGSiCxbfGYGCIHiuDT5fgmuhHy0_td5H2EAPOmgmwePpDaiv1SQnE3YXzqhZWCyVWvKH9ra5NIlG9wEzedtL1k6gFRaJcFDpOpbKqG7KVzdqh3UieO9neqb6-6ZcpHckCpU7Z3r1qKd_AXbSx8RkmXTGuJdHGkIISeSqwHTCPFL7GeHftM7W7RFiz8r5X6WdNsCrc0RVaLVvpqS_3-Ld2CFi7k-pBfukWVl6RyJAnnSfo1L8F-clYSw2Ko9uZqaYNb4lgZlkRSmfpTdnzIHcJpjcP-XLIylEAZg1US5KWdHjZkOmeGMqLMKqvyyMnB-GBipVpUEV64lyz-x3DDuMu-kdWuJ8j2S2IV4M57Kda9DZuwT0IWM-pKGJT74k1l2TEOHtENcOYaoXEQTO900kxf6VB0Bocy65tAFOn3BoI5mRDtLe5gx-5cfztmtv6_bwwcZz-kN7AfToULmPzdxmdi96v9GNq0DZqYTY9qXaeTTZwTxq5orzYfDEDowm4VsgtKghkv8P0kvodxMcdnsnyvHAaJ53hjfvpGFWPcIl3NnKYTsV6ga_uVeU49lKOEFZdopMEtRI5_HCWY_71wG0V-JJKbEA7AsIgF3oCvmCeZvIJtPPFNQKvjm0fwea0kVhf3pmoXi5fU61TRqO6_Iw5URjRvU-4tgph341zyEg13cTrSRONpJWeD_PqtVEECGoizRN9Z2aplrIeoFxFd6QJorMXnKH5lCp7v3jhnl8bL4Mj8mknYQKagbSUxmumEDM_LVhLWWTQNCaRNp8zpArCLVy8z989X4uCMVyKo5cBPxOtZagcUfIGllyp1kYBy6bXtYLlX46l_Mvv9hKOorRhlXblZ7Ml7CAPtxyTM477mWxMGIF-w0s0K_Ll6Y3ASSdDcC4SsGXA2TvL4zGUmmHsbanXeOsclAKFzD9Clhw1ANsQeXExp-jOhVXZLlVl5UGPCor_F-rbUk5kYXVh6SalE3Q7u5wEIL6oAIQDriE-jy5r3K41OeFxxVxisvvyeCBxZ96FGmlM5fQH0CRle6Mp4a-J3YLl4BwAEyjjLcrr7w2X4y-ZcrlY01tIFBFCzhNAMu_MQnXNq1j6a7aRK9PKns90NZG_h0YN-qfWQ60J7vvVJCJ85aU0NY4gj8Fz7SBJdEVO4nmMbMs-uZgEjGmeCzVmoVVw3usnW7h67SKKjLsbV4Bgt1dLQCBh-MpDAX4RnBQSkfAJ234zkyWTWydvIXyJCQaZtLNLutUoYb5teHbwTJTyh3yi-9mqOM4noIK0rKbez2JF2_mdGJioKf1khdTSlP-rTK9jJsHMJnpi6Q5h8dSC1q6z5xQL6DQzEjrMKeqb3-v9XN5dw--StPAj02hZ15bBqlZqvkWUMNc5hSlZS6Q2vNKwkvUMmDjqJ9XoDSW_W7Woh4iruIzBjZr92PsIGrsAjPiIyBXQd9F887XqacwEIB2DSxHnFRWRvtGgIFhb_aVHacuUQ7yy2nYN4GrHdku_knu3zBarQv1zj5ofqOxzz5vLi-EkNXpxUvE_0Z4WCC6uY4geY72xRzhuMd1F3mP13h27GIEPpIo0j5QhHcb5QBQtyDg_c8STPnYYeLa6H7nG_7VNR6IHC72sTldCgqXcrm6ewL_6BrKKxsutEypmBp4HmG2IZ8UbLmN_VNE-zg-6SotjIkjwmi5SlU1nxpSgJTYpzq9yxzqmpKeiSZQgtklBWwzPxOV5ODP0p-u_fuifTlFBhNH0Wye7P7qWS5_ykj1sqFbRSzEw_DhZrFFtgaKm9jL5ItSwRPtswvViKk0-4z9I3Wco6qh002zDZ8_V0zem9r7qRK5nGMIQJrlaSo2wWZUizMMOXESWB3JmwZPF9C5hzOGkXGSHDaVaTWPbRrJyc-P5onRRXu7uOfEHdmQ3UfcDf15NKmO8ID_wHRKOEQZPww22k4V59HQlO8d_9ddv2se2nLoFSXgrc6Ekj_F_LRsWuuVo8QZ-mXorQTXHJX_JrZiTKhNm7VgIYFzxgIVWTeO0KzReI_n882yA9pKhYERY04mqLze5HiTyjmfXQXyS5yoF22OUGIKB_cdpebVQ-QQuDYZyfkQNXbtRqxHVKaTYTcAwCaUBZ3xyGmFAOsJOOiRFx1UIE__QY0NAKYBkPska23Mg9D1sqi7QDO36IAa7gfp25uau_e5IM6UyCU3C7g_Mis0UyDnwMELq4V0hA6pzK9wSzJISNtW_ck0pqasrji-0kPWpJKMVPhOvp2HaXcqdFwDwi1jJRRLor-m3EW6MG9Vgiul-S2kpvKJ7UUByDL_al2kMAN3A4ufNNqpiYyIJqwmODcQTCXP3s2uQWJYz61V5mkUykDU3fTyTUPCUvx8wwjd-1GeNqjcpzKHy1v7hat757I01Dq9ZcsYJ7-BuFQ70BatKz1CZ18VRRvIbDSlGKciDpm6TZeCjusvLs4HMfArCZe7UJ81fRJgl2vipjIcpaOoR796jwUfKYP50FBEF8sGZbQ2oMJCiXlgrN71ufCK55aN4hD0Vt57ONon6VQXNJlmcoToQUQLo03FnuQZ7-MMwOR3rE3M3M8ruCfW205PlO8jbGNSs56d9_-QitGhMtXeDqHWzcx4fXNI19RFX84QWu9vlSkQjkuIkJyP5lhawMO_GeYipzQ5Nvzi0Uo5-vDthrbo1pUhwm1UaK7Q280Lr4X1mCZtQqZ8nnKYMi1Grq8shrYAq2faUlAaaFdvCot6Lp2vWzO5Ou-zcm2mS_VUsng9xjm4M5MR_Yl4H0nObN7QZ0v4JnAhjRkgAFJDUEtl6LRAIXmy-3eEsidNTY952UdFlp5VSYVyqyzPfdUYERibBZl_alLhXIzOoFYonJ24fvtqVL5cJpsGNIgR2g2tL1nbzq_8siGmRYAazhsQakgPVG0peFiPzG8FU96WWxHMit2vzs_w7dWgyAxqj1_w_zXu5CI0-3zEd1zKWDE6QmKX-md5bh6tnW4iedLG4zIPKV7QEojsQMyQ6Mh8kdH2OZ7srVw58z1AvvlD99qROQwruyEzsXBREUuOe0pcxjgD1pmBPIherByZIBzizHMAn2kn29QCPrvwhLxiGuf78DJjv_FECBhMzSRqcj8EwwOHau4PuXPd1rEHNi05Y8FCQ985eGSM-1Dg9Pd0OwY8z-95LPSt-rWemPhXM6JuWOXKo5nd1K6FB1sibR6in2830sEv4MYVbfRmXYrAD1mnI7CkS_hthv5ysGGtwNfMu5GArtONw01pR2yHYye5zbJdapxLeRJQjtOhe9g-kQkvXBccyMrujfRoFXtJXU-bCJkmM9MAqWYZu4JpNW8_e6eQd8LVL5_Dpg58-qLl9IxPXnoDOZTU6PR4FqsRmEAfwLzp1Q9bOozAw5ct6AGQjc4MaVCvKqqf_dahJbmcNJsmzLBUuUASLwamASbGArS5ie-TktBFdoW6pVM1uoWTXfiOxu2cW6Tt26VYqlD8ov80RoNip-EksOn2a2-PTJNdhq-301DFwAdyqwLeUTn4QdlwRLaMey4mhxTaFigxWHcVoVGYaSs17NIa1eSgql4_GPFVmatu4ADo3w-ZeOShdfRbgKoI7_HcjxYlE2CAG01I4-xozjJVpI9V5Smubxc37sk8x7pNp6AcC05SpORa3RBAnfA6tc6SJATcxBJ_e2qBYFH4qS__ZlzmZ1aEqPrx6EmAy4-r8q0oHvRbE_c90D57XjuJVCwRgQ1gJcQ1-0Y5ILimBkYu8TkSGn2PWE1CBtmRrY64xDzdRA4W7_NKKmgaQ1YTWOwFaZDOFJ84URHks3Q5mh9U8RGocVMQ84XgSeNPUAlJ-R59FL-w6Y0jRpoUM-NAj0zc9GsI7m4qiOm6z5X-AJTFU1G5hzvs96_lBSZ9XWf3brUv8qhMIjr6bILBuP2KRAcoi0fxqJGyxx9mSKINRHQTbretI8RWQfEQQ-OMc5-PTIX-rUdnrX6jEaZF-qp2-dqWs2Z3SGCRmmKzOrC27OatUkSolcO6qOg14539yvMShLu1YXet7Xtr1eWfcsKGtua3lRxC2w3pACYMMLTv3Gv9fRfdA6-yWpjm0I1yTz4myd9sJmHIz4TBUTzN7s3ETU-ZHI7bsf2QDWU1E8Zsk0H6xEwVYsJY45iqwVc52H0c6eAnukMPnu--C8_a-1U2hrbAQh7vKW80PaRazUEIArhvvMJx4m7MGua-Z7Pk5Vn6gwNGlk0Nn-Bl1vp3HaPPyObMx2e9fUo871a4qfZZ9r555xc4PLF4b9i70uWeJDmQO_QLmQszdZ8u7AFCJnskN3Z6iYeyb56aTyYK44MFi-JqwVTdh2tHkjV1TJsqs53ErNKgq11MoPZ6BL9qkCBdE49oybOeolfEsfQmEAws3saSCcgDF6XW96-qCJzS6SQpnbIF0jCWBi4uhL6WmFmpJ3FYrl5_-utK_8cBTM-Rjlh6M-cZdbjOp_EoXlBU3Pw6uSULsUZuZVfBhQHVsnRAe9ZxbBoiFz4wKD_JegdqGVzxnuxpcfsg2nBoO90ppB17xAA69qUmu5W1ia_EnWaSg7qgxfmSIRUpySOTxdu7VX2XvnUH8LzVICR2ssHnm0VyBqj7BJE1IjO9saJ2b0MV_OXRRm2iKRIpEWQjW7OKms1QOJPb1cWTUUK-bsSWvbO6RDUshGVskE3ltTdutEhMPWILvkVLZhnCPF_iSsBwqf7TAj9qjT334hBoUxvnBH7heM6SrHV5MSKOHgkRz64EPaiq-9O4dB_t8x6hkEE4wHoOgzFga_cS8r_hib9Jun3zxK_LJfRg-2mOC3-7vY8OvqK_8UD1uUsX74ha2MekWFSuTHNcG8QnL-EjixLGAnOpoKMHD16wOxjNpuBtrJim7SmUlzQ8RcIabvmsDrom21QxMJrO8Fp61t661HJpNyfxx6ETC-ceCPmmOD3WHxtuIJL495fr3kuhUQp3p2Pn3RcXJUJJknY60l6P7ekdKMSjHbuHJrk5vWEAOxAWcFJVxGmgmCZfMHt6bJsxYeIDuamlPOovw16UYLMav0fnDLBbmiJYxWkFpyxEV9OL3GpxcNSsmMSCkdHu33-FaFAlbSHmi3126lthNgJN7Poge3HVPtHl8QDRVlx-H1kb-cl48p6XjVK96OuO0t5HeWxuYARUN3NU-B6ShnJ58003DcmJ8ZW719cXdpy7Am8Zhht8Ww6F21QxJc9ar0HUGvm8A2cd3J3eaD43wWmepZb4EmXFkJ3CeSKvz_87EqxkFbfKSVBvoWaZRFs2Zeufxfd5PSrVozgh7hAbAE-rvp8yHTzBNyEFUtuLwDV_AEqli27kToTpmgLEQ7-eX_uMufpgoJpbb7orxLoXrg7AaGuK1-SgWJzJuX1jo7EfyayW-ULr4yprW1Ityb18g3m_fyr9udhVKuXC5ikymIrlvQXqgugD3PdMXBL2e5mJuqKpXttK3TszPE5NovZMCIDncWNg21l5cezvDvY8d0ateiIBcty5af3p7kDfKpXPHfM-UZoxHscLAr52u9V0n7I1AfuavmgSCdO-IcIpoGPONeOxaVOSQj2BMsf3_uA-pDp3IjaF7KQuxFOJRvv9kf5Fneq5Zk6aUwtKUy34zIN7TKJry0bu4D6ivuXS70mjln3VSvq6qcCRRRcoVcy_CSkx_s2ARsdqMYwc-FgSTNZqSsP30WZ_4sSigv7e0slqWyzQQvZ-SVwO_BAdaefFtx9H8v2gfZrxHQL2XyYLQsQQ6Hs7RjI9jAPygW5trnxzoZzSLDyQAbTvwjqrvWGk3dPIXolMqnW_6yKUVHTUNJ93ia9ibh9Y8hrxkvVOEBZSY2VW9uynhZEn3E5te0C1Vd1wRu_TAP1mcw8eFWxmW2nFtW1VnvXak23fX5eoyHNCY7J-C0FRdKWLEmXjrkt8qQL0rPQBIaE2GQfc_nN3JueONocVZZt1mxTRwHomhW5Akhdo0o6EbX3TyFI9Z93ZEKg0uVMCa31D5Ya5KCuGFod0qKm9PWDFvwpEOlvo-m3SjsZSMVm1c2mJYo0s4JcJ3U3wDxqwsxX-O23AHzxEaqiXKNqnBUrjG_u7vxu_xJHtx7NuQ4ymPcbzr37D9CPDgJeZnImAx_DWBJBC8UiHSpJr_LzDaMKDBuxmegMZuVmOlW0kn7HP9UWyeILp_3Oqx8OIxeOFrcqJat9cFgW-7JFq5unUTRmcZucNZ4EruG7UEb_Qa3L4orhrBAQQSw_pdd9vkIXVVsJpitSKp19Gagco9hD_vdx1N81BwK2w9yTgKcy1TOI8w-nlV0-JSSTAYHxtN44LIiawsDzcaFZA4MTfaBO-NSAe2iLRj_8fYPNYybdsWmb9yM0nVXka1GeNeZrc-DdwjP2rukINixQBCMFmePB6q0EwuO7ioiD2G9myAQ085-ki7J9cIanCHQdhAVbKRVU_uWrrk67QO57vfgZnrGpkYx9cvJybUAYAglegnF9XYcKO1GdGckDOylTC2Owquq7BwSvJCLCTxMmW-ylGxyi9Q7MMCrmBnilNMEtipfFJ-nyvltTuY2425HdWtoFtg_fo7BsFlxCUFrUWraSt9nC87awlmHZw45RdRyuxbKv5IyWOmLhP8AzF-lsz-sZQtTFAFb_28xuwbRNc72Az4hgOutFzeEFH3r3YCllq1drQvxujzKjrDyng17gAIPV5KqUpE-QVdjnt0nCllAaPQMRFk1zJReO6GVgE98vA5B5dc95eN0REYFtzVWjYv37GwAsJIL9B53FY4RBAjri0XDtO8lv407YwIyvZLbnsS_BisG7B2Jm_84lxrX1Djitb8Ggmi_Z1zaaH02DCTzoC2tCTEpJxazRP94_HEP6V6CRr4qbQfiyAQu86gGJGsoRx-4cfRHgB9y7Nk92Fkzd3NXTeKJ2YlMbxcBmufom8cr_8-Hv6mbekEjcGyvi5GJTI_xk0dXTCq8hq7SgzdW17JWCh_NiZCuoBN7LT_3rVe4GJxt2jQ5QfzdZpJNT4w4mHtYGNEarXqz55eR5t_3dcUHMU4UmIEHM0PNJHvYNlyRpMQOJSqfvmP8_-T6W42ZoyQkypLBMhV8ZoFrr1mpz_ckAh9DyAbO_0mW85QeZzRb6FIloPCKBZ9YaVAaJL_T9oE2uigjKzaoDFg0VsExDdyhN4E8i-qFYdvKCdTZFaHS58C7bQY2SSFPZtFBzFQw-CP3cAL0u5KRMtUwaPDtg7vqlEo0aeFEszNZ6kBiLi6PxCvqX7e89MFtjbTdnh3JwcyJs9HS_tfpKVcEFK6eOJIzcdKtwBH-R0n1MueNLQBqyRI_w167Fu5Y8kXnwImtqy2cGIDlVITaV8S5T8-CudysSH60SjhRVR0dmg5cidGzHPCbMkrDstjfslHd6152kztNN-5iG3oeS2_Q_l1SAfEVi7CYlsPBeF_qfIgOMB7_OgBGQdDiqDxJ4pgCPsoVfXt9a5yCObINhCzGp6Y8vOnVr3TARo5IE16v28l8ZSYDCM5aExrSaFHjGje1-Kv1OQsfI5AE71bU2sbvP-IgjAd6G4EqLRzhM3ZKjigybHtjkxQ0aJPdWBCuPV7fyFseVwv42BAaoHyDOYj4W_tCiASsIn1ED1ZOSiTZ4j739pETRpO4ck0ssAcnKRjXn_4DC-KLnUw9rM0Khps4Aun_yt5qkXjZjc1AtWc3S7BVqZox2kNCN0KzV0DKo4HZV34GO2Zz0z4zV1d2CaqmvQRE7YAQC-JKxKyqnO7AZjSVTLvX2LK9lC2GoEnwFJFJ-SzhByM5ePj5XCDuyGdkAwDprfQtcZe_1WjS84Vzin00C5OnyE51rvU51jpOzFrbBAfjVH3KL0Y-rkfJ_oUnbjYdp5PW9czOv6KXhCwltwCGA3EMPv-jATsZ3XessnGOLfwUc6DM57_H2SrQNCsEi5yFES2TnRa0xN_k-BUmg6z6kKhBeSorN7AwqsjmIK-akFEt8vP4CFExwVKQgXkqJB0X3Kd1DtQl-ZUOchDzUmENJGLMEqtee-c1bX6T2n1x7gXc5VMFQ77RjiPq6bQf6uBdWwEpJGC8VRA6mR7WZ81J7iZ3tRSea6OJRDtpSqfdKslHxGHwbcmiEsEFFbzFoL77DXtRu4Lk1HOWxELo9qPqGCOGiB3cq5tchOXncHvqsqHAQdEbqyGTx4VJyQcqNJPdmOm6tQxDz6RTXuajkYOmx8aKWQq83pkDBIkvmVCcHZZ8iDrqHWSPmF5d5RFdVhUIf-hCygsSHRaFrajePTySAmhQdCrlXbPYcFkf6Vqo2oZP1WQoyn8LudIbv9qRhR2W_bljphmF4gEB4oEG65z2PtrlgkcqSEqDWe8N55CkCQGLqOsVvATDNzcGYCZ9cBZChNCTVnLtyrbUFRyALOVm6RJlke3D8CvGKFiC2HNVS4YHlJ8WbteQS-BtSMHaLFqh9xn1Jvzz3IkfpP6adUc5_bHpvVkm_BmGNPppdSrAUDFJ6fGQBoEXpdpflSHq4jg1mQC8FbZ_5cODkhN0Gfy1BRxegHAS6e21Nk9ST6Jyvyy63mIpkuBKIg-fulIyUXRz-Vy-C3BHOFDMZ3yEvp2d0njB_F_vO09GEAwGpcrKb4iMFbEsTuL--29PxLD-0ZBZm50-4XHDSbr-Kp83zzad6WgEHGSaaUJ-A0aW3UDB3rAWnFYxKBv3hTysZmnPPHprUR8JnJMJhR1MIW8lfyOzStk9vhCWLeEE8ERme1lSywtnopnp1kNAbNuA7WPtu0-j-Yn6YaYJ79n0xDPFDjwQNsjk7d0Qt-UHLfcpOx7udSPm64anEy5VPokGowVhkqT5_BWElcHMAtqEwEgXgBY2yEzICI6uJts40wb3z5fRSU1GG12ufOy1DO_-lSK9Gxmu_qdpk7F9gf5AuxXqLLLp0rPouRfEdTa4tPbJ3cLesGShY6tPIbhYCS8xPcUpH5sbcx_O6djQP6AFc6sZ1Oua9c2Se_MtcKMA5JkwURHQcJjHVaA3Ej8aALoRVGr6OUbjex-x5RwNeof04HC5b_Mn4eE8dxiCwmcSYXyfpAhS8cxxLaijdkHXrlMb_a6WLiyh77BHGaaWvD8s9M9hWAY04voDSlIcmjNjzDbcLQIdDkQoMShzj-Zo1kcswiytjrgX1WHD-9iQ71x794uHn7MQC1AaJV8ufB4sb8opei1D7r7N9aDaF3qmztqqPuIszv5ywlV2IlLbI5CiuBC49TA-siY3jk-xACINQwpGAuCTEBsB28CG2ffMA75U1xA4P9o7EpHpzJM8Gn9X_vKrUMTtr_-lv2dj8rLw9eHn2WIk44Y68EagdkHIEPaEQfuftKqk6V_6VAUoGimf_uE6VoVu1JDWcsF9J2A2t966_K4BHuQzzvs85z5lz_-jNM3ITLxdCkEiA0dB2ce_SyTCXC9h0yFfLSCDwA7NSIivZHntn8xiFcqEzv33gHZ6DrfryUwGJmhJ1anuXybss1X7abYk4GJpVz2Ney6d4Z-DXpKWEVLpaGsQp9q0cEk44NEaGT1lrcX2eq62DsKtwoBmFnMSWAXM2lBTLuYNz6CYFoek8ZCV07SZsqRZFCT-M7vd7UMzZRdwIG_1zl0JopXztSMVB1kautU6bhWdgcLE3LcD6bNdETLiMyfJtfN12LAjlS-Wh8_y0KTiU_6eHMzMqqCqZ0IpGyxk8rBf4j9KmYFpZOpSKs2K1uzDe06IqLSk8-PW2sB3Ag7ffY88_uQPnU7ONZ91ep0M_JCyB-PHfmuYdmvCA3ViTbKEPaWirnjZjPUmEz29KQsLBTaGrNgYyztLK92PZ3odxmV1r8NIz5C3qJZpzMHwPAkMyUAHThqjwQgfd46Z5E1-3TlOjOLf2cy9MsWEF0YZiJJNFd2KyTdoczF8ly9pn5GM6V4vFcX8SAEyRPSeziSXAbKT-ghU-GLQT7ZmSvMgiT7ByX5Fxna3BOLYIzMe2DERC7nCXDX0VAo3Dk8kL2UFl_dssX9vk80k3TfJStqPckPe-9WkACtiB93r4XTCrL08vq_g2kOpWBNZBqa_2lfIIWKmQSPsLX0VqLrsfQRTo95xJZ6gzm8c71kfyjTPRjfQA8zyZSepUDPXyZIhAA5GJfxsSBoRfv07YGvd6iXMcGCVnKZSJynLlvghADNCVuL_wt_57AecC2dKFEZpJC_pNrkhKS16wb3t13cDCWG2W_myPl_MFCnU6v3rXJ7NwawSPTeaRFdN-FMDOtAaYtxMd-sMRm7Uo1ATZpLVKrxaxFxXCzlKeEApxqOoYbXwiSl-N2KS0Gw_aOgB26Yw49nyPSGYIQR58HiaQVBdfqks4kG7b5tsB8rKJIfH1OZbLJKzIlK8oS6FMOoyH_E9VP3ykySBoUbN-bWOT2M9H0UwhL3Hm1lX2RUJlCqqS9vGot7VsXNFBZGXhOY3DVvEPLSB4DA11KaMkct_pglSXKBuKRCwhTHj3PeXkW77NEzyXYB64Owp5I7ASjHOc3eyko5OEigTa7grliVpw1INNfXS5qnH_CxjX_9MPqJsZgtGHdLlKZeAE68051J83dCXy6MIWjyZd8q34iGmpi_7N3E_nX8l29OeO3xqDEMWkVeqX-2S5OkMfM7n21EAAgltEQ5cPjuTHBQqB8GG6094OgA6uzyzkdM7WV4sbT0ifFTH5SEva1CwApfvXvK9a2Czc2CZG1cIvP9ImHVLYHe-18dAaGIhiVvAHGmlvgYepetL1oV17CX1Vqwe3FNkXTeNUUxyQr-54PEV3ptgu9XDi_TnBo_XEN_-k3g8zJPvk4IJ5biduk-vKZ-fzm5cNLGnFapdvrYkNIbM0vHRtf1AWliOcqnYhdINUDqiTTxmKXi40vmy3zyFOYh05BuQZXgx1zcb5kyfDEw8TiQuXW3BYank9fiG9lRHJy_vuqQXOXQGn5A_FWgONetRlUJaFPm_RYOx9JFhNlDRgq709Ob9WKNEd3-g0chahJfXq5FVrRGAkZIday2a47TxOOIFBy41j1t4Slw0tcVl9N1sPCvFd8lhp-sR74DU0bAtni5vt2qjES-73P1zX_OcWnQDZ5m4r0njL3ZhcYne7JztTSfEcyIlrV03UhYG7vest5Y-MPuAkdg6fV4-_LVuT4AfiYQ6muhF9Bmq35qFaHn4prfyINWcSYPFR4V3A_urc_R75oD-7fnFTPRSRsuXO05otTqBYC6Oc5cxW23nYZDDJ39AFj9xMxIAanbLW6DyVlLgywzPTLX2cZR6n_lQIziHB7BftXBgQaOFf7c77pW9RpCFPINB5yGX6wg1bJxBE08VBKIJH8agPjKyihIYdGOP1L0E2J53LL_bdBwo4Ek28KSO-qUhpSTsD6yGD0nG2v-jp7Ul86yCJXKPdzmS3WrUgxOoWoISx5HcAQOZNDbgzfZBnqgOOsUULI7RsLBBzsUYj5ZRQCbNFtgWcPTCQnGFYHUycx0f6I6-2n5SadrMT9tzcgUtIHeu-Qokj1R69B4RyZkr3K6W6GIHAATOsgBVzSNXLXKlto5nEOsxw5tveNgoc6XfOf43QxMvHtNV1h6rPNWlb7u2WJmYuTAqXathhD_QQ7te4o20U8ogiPmOS2dyAmgjeGFtk-inZKrRx3CRktMECGdBU6xWM7PA-FKk1SYeI3gMrubO3PKeNeolqXLrVnwyHgZysRbg62_Ch1TvBC4otsBa8fjY2sMKG2BZDN9_fpI4mHpZ2d5j2NFZnVEZFviaAw96MHOXfzZLOnwWK4RQgWz6V1UXZI3ag-zBX1vbo67Wld2uD1fSTnYYvvMXas_3d76ZhOyGo3su2PRuBmZenHCq2JLRlJOOlv6CC-0QhVCz-GPzlMl8lKAeQs3oLqqMno2iF8XEMEc_2ugZJf39s0psoERn6dYx0DxR4NMKZfHf1DrD-b0cmTbnSP0V-btE_r3mAYH1UXvK2JBuW4S-PfXRQkUGJcx6IlN1JB7L5RCP1p024VCy5pRnd0vKpKRXfxnE847nO1-hg24saWcDqZ8RUeQ6wM3GuQiA49E0gxfzkQia_XhITmejKxTAz_2HARPrWspm0ohOGX9tofcZGuyXzduPTQgDg2hld1ZzIPB_hk_A0wZ10cPTNfufDuoY-pnJ1bprLi1Ghg1Bq3zzjXTan59Gcbrja7JZj41TmSdnTmPeLiBUs5WAbzNGYhyTFoGW5tPFjhgcVLvvEGBk-vgYfRtZmBns2JXVP9URnHX1Vrh_z9tce3XWYHWQQcARf2AnYQehP-UqUOUkzW0TI7UptmMAfWhLT0UftshGgEoRbJjyEC0y_a6-pvOQRzh6a0DMPAwm761Pa7pDtkSaLrnJRiMyD1QZpEasoYBmMd_Lm11tVAeJ-yEKeGbCf1VzTTvwn8B892cQ60VjLM1ypTFVfPRDdKqxq9clTteSKNmo8wDOAErRrDkcCmiRfjS3KD4hKlD6Wt1YZSz-Reid97b_qE92m9AYWvDyAMYQ45yv7ABf2kCSm_GG-JsKVJSLUpHNib9tXgqlmRxZodix-SUgDBYZDF8_vZy3PEGTshw6oijQ9ewlW8mIArxtBY4haB4nLuvO26GqTKIuiQjH4t7mOKEzQh-z79d1XS_PstluayTeZ_tEvU-eMigLsf9p3y3ZsjRZwGa3Kxc5vq08dAs4PtNdp9c-uqiBGIgu42Bs6xiTEoVNB1NjIBDmC53wUkaWU1fEv1UisZ3JO9OHObnb0NVUH1mxm1VTlvilWToL9IVctOAYJZaKEJq6N3K0ZVF74LrOozQtfu8SmshsAhjMoFZV6heG49n9pTpPstrT5yZt0tHLuo4Ui5gKpp-2-o2ecoeWHhIDzwVC5unTeY4NgBkeZ9HH1yPQqSmj1wuFmP0X8V4SIWWXEE_bPl1RvEFCTsqZutTaXU-dG-T8iOxBTs0L9-DcH18bbQqzenmMLG5UZXGLLUoCTxnBGd5YPyF-2IC3vB7JD3teNfWIhyNjPhDTRCltyhH0_nE3KSfz0Dva-j0Mqlodo0HfVI96MI8xO_xAe5DyyKzJ3GevUk8n1OIalCXLQViLgcVpTQ0hx4GWJ575QJGRDfts65f7Gn4oYWUTH9A0N2JDP7PA9wDebQrwnZ_MEFm8N9qOw66Ph-YQxZxV342GJzkzrEIKzSnb6KrD-_AgsI9oi0nWZ2xwXDIUFQjCs3swxeDFkRQ5cmAHPB6a9OHxDwUCpjL8FzlMGOelptstCpQqe_He1HzY_Y6xeHyWO_SXQd2WfLe9nzOTOnNdHItXE_JKFfxd1BMhrAuGl3YlzKMcolsjBfADcj3IwzBz_9Xf4LS7KKtvOxV7LJkDKKEh6YVUqk4yP2BcMJOfScEMYMtEvKstksnVrf7Dq1urtn1ha65yDr3-73jSLIj_2FUV8otWfRtEoFJsRlFBX3sRsgdjLG-TVZoj4GHgsu7xFyBycNbxMWXBRmPy6jriHFLp13ypjHdHoK1PZShIOCAoTd-IPu4qiIFfbqHfcPwQrvzDaHxoQhxt4LvuYw2eo_LFpz1VA5-Z_drJlYVR-2M25v8CMRwtVu17r9PDILgFOWG-lt1UDhGJUHpPhjKnWyFnVT3zbwHw8-W5sVL-NJI6uMhnMpTV92mTNAWxbt63FNEoW3i4OA0VId7rvsR2aXY3JuGpcLqmmlZ_TT5rHeJ-30qLi_7i5KWXhCUsCDuZrz_i1WBpecwe3a5w9Gwi2jRolsXg-i4BwSASa3DXMkTqpcl9IvyWbNn3ufw9M3QKePzYmgt-SP7JP98NcGjRCkp-lNFKIvbhA8fyV_P106RSc0uSNwD8TBXRmmSqkunIQ0r3oV6p97fryGrSABcoBp4lEk8ZQ1XFtIoEGzmo0CuZWlotNMhjCsTSl3fEtQsB2Pl83YjT77WpydanWTNoqYZioULXW8ezEG3dpI-0oM7f-lNIHOu906-kbbXOJ-bx_lxowtpuD0aV676LBDBPGE817Ks52b5QirPf2G8xi2Mus88MZwK2YNsYC93hG6bPilyQOeh_o5mbiA1SwBdMiKMgOqfzHrQABS7T_PYDFfJbU5cPfQiWJUPfDmWoDs7PNdaOLMeKz63NN9zL6xebXPhV36CnNhfvas3zSG6LIM-igzbsdAo5YwsNEBrBaU7LylZTEQ6zl49zoJk0KIoSLGLSqPNmQ-PRxmlL1JYzV7xhLddGx59O4jI6d1jP2LULtMZU5FQ9qfkXzsPwCHK2oaUDuKEPcq7FrZwidKbQ5WNqvug4T7VA12BPrdYSSGJx4o-Fs-k6BKrQxTQo2l3Y-CFNbggk8lmeC5yIyFir32cQmoxD_YLRcDoDk5EhXf1bis3jKel_tEtEwiLB-pEYazR6vGrmhm25bmYas7ykIOFF_ItzHWTZzlhtfQpe8MSj-sZVUL-3PRyYw83Z2Jdm-cUzkjDelSw1Ck7WsjBnH4EjZG6lXhz7MUbjbekpdwwIKl8YrFfYPrwe3KaUJWn5sm7glMGw8ZJyhxFm2Rn4j3Jf5rvgql-9yRxll2n5tMHflCGJCF9K0cf4D189shWq6mN8BfsKiVpFJALqvagAx7NHKEd0viutQDuJQMoJc7pCXh_1wlWy2NmT0DdqEs0xn1Dp2mzzaPuJQQmefBZR_8tzuFAO6A6tgvfPSLl0liTs5w0UYvhZAC6ilo64SR2oTKrmjiNszFYtkK8Q_n6jdeHV2nm38uqQsI4AfMPKOFP0dhaBj175JDkq59Oi1qpTtzdJkJRY3ceSDAEutjnts-0sdaecY3YKXB5CMFPfrVMiiTLuEE5TfzR-JgV2ZThQoUuWFr6cDK1QhSoXLftoi1NwNpDBkq8IIa8YUCv_Lrvl-V7coGeHYHzX2w5viwkr8eggTaEhrDUuY638heSwKWGgxHludd0o1_uo89GUZOSR0-8-8F39kPchU0WaErM7ART9abPq1UPy_EpU11WuyHubDn_l86PF6N2JkCaO-y1RXeQrTJh1wHD3p_5_ljiFa037qLNh3YS0dn7oQpRuT6y8Z_6ZmZq9VCSlbb5Si-75I-RY3wZrgCO-AgPUQjBe6ciBSwPoafLXhC9G22GrK6mZ2_Ie-0hWkSCvyEypzZW8_leYxt50yKFt9sCYt1VrCO99yyvwYiN4xPP2iFoDrbUHhJRIk5NwtUvocIbn29YxxaQdFa8zvNP1s1a_6ZkP7Ck3QshRheH0hcruGXpDBo7uAVQ9Y-hAAA2Z1wRKJD1diz6FPg-rjreeH8MMfhCBUxqoAkaMOWD0zmrkekDNB1JDTIq9xNGUIW2Qxg2ZmLJ0vPNyqMqSEfELWiQEiSgqanQdEi9Qf9Zs5HsEdm5RemkZ3ejSVhdsp-dA5rFegR67_wgS6sKMdqCLih2GNWgQ6Q1UzjeLYw-b20PtUiJoVfMLod2hZKEWBmKQpt1Ehnd4WFJroezIjv1It4leJNDhNov3L4RO_6LrRJCgZkysUEScHcc_Uw8nPUMradyy8s5c4qk9aVKnxUGG2nwU916RXdLozpaahIW-TMgULUClMKJoXzdtS9_3WIPcp30ublMVyMJWJNhDQetK2I00CsoTVY8zENjvzlDtZDUzYhhFwh8mYiqCEBRlOQDt4g9WQPlWPADmOwy5McKr0312N3E4UMkO67jBAQrOR0f4XqZvTyJBf_oUg_QUn4FZFsf5iawPQ4lcy2Ln_0xMeXkzZn-bQaCjNy075YA4aTT3qcv1hjou9Yh2exuNL3mChO3splph5Ct6AJWJbaRZyOYd9EX-t5UDwT4NSImuJOeWyKcEh-JkHrBNTPSWFK-GbzvLIMz2NftniWNhjKvW7PemjY-NpbE1zFNmxTalcjT4oiEeNCaEdjg-F0cUOgxYhnnzs3fpAy5ZVp0JKspEUdAA_6mY1nt-4iSm9Xg0xQn5eCKALKX5wr2_eJdPGyb0nSfzSwj6EwswUm-OsAxuri2ympGKaBNOsLyKZkt_bg8QjFl0lhVnYOk0LdwbLW7GhfVYdaATSNyyt2av3kYotS9G_7qU8sfjkdeS9AigcHMKOdkxyeD5Q1sUsPTr8p0duvOY6GjxI7dUJJ5c0tsULxpMfwdDA3rHKfenHRHuwynmnMNUsgLZNlJMALFASR7otxgDJszDwmPXFgo9OQrM9gIInqzI51hyaFmIp-tk-Agn0yG1Kvfl-ZLcRrymENMnYwvyxgjPADw09RzljirQay2E1vd4cwOo623gRkr3ocHHOkp065b2s9FtL55fAVHtGkNeXstbozVahdmW7KBml_djf7356iAhnCQIKqqdbxWLn-4EHoOe1hmA_o7syP4rn7NXYISvvDORfuutzd8FR8KWdYSJDwN7a520iyrTW7JVWFjmRkDFCNM7VkqXiIumTWrdZAftxXFzQ0_PEyG_RbTCWBU5kpacVMSisxGWVOiBugtWaOeS_owruNDmEEOLzilREwI9FP2MwEuj-XyYwR7sRpZPsUXr1jCJOGpt05xC_qz-xmWMNhIQt9y8jxL0p8BLVL1ymWOvKonHO3FCtlv1QUy5wx7tbvMSanktBg8_rPE8sg0Ftej5Aaq55YfujXFvbtJWHFOKkN2PNX34R28bYroWR9WUeYKBtempY0XQY50IkgBbjVuCdzwvk26-dmZ3vig7NfoTNK6bhLy8ROT088UV5jDfd12gD6sVi0zeR28IFoiG1dFBYCm1uE5sOeJjJl3rTn3cwuNNNk1rAeQl6PbXULf98zR9lPPJHFOzr6EMe2cRFfP57jRbcbDqjpQvw64T1hMrByKPhmgEW_ZFVTUnIqm88hS6UtqM9JoM5O9UAUAQpzSU24a2_GDImDPrcs-uiOi4S9NbskF7ni0DONg7X9gFFYZlKat6D4rg9X8M3D7_Ccc_G05A7WGowSmxIez4FL47TC0hkh5PG7j3A-yawi_JagXXs_SDJrPslmK3ixlcBr9_QlxWXkU6f9YMlzUdiLnnpB9yfkiQVeiOemdWQp5yktPo2AMuey7OcYKJX9qVYwgLvsc9-uQLQ18sLxQ47WKDf7rV8H9UNNLMT-Katy48Kv-GUkYD7qtc379oMuQ3MZR67ajXryi61w25Qt3R81m34M81HUmlWpSIfaMutQi6qo_NS1qPKwqW2_3j3P1P0OT2VN0LRWyw3n4qJHg4uUM-sS6-nyQv3Ih7SytTHLNn83Ql4WNnbEd_9RcQUkYRrYA69Hm2cl4zAG5wW4NQqtKk_EFMimDFDUnTcmec-qgsMxzzCZkKb9AcRzr0qyPsX4Dwv8DkRmjk34l26CPQ084m1u5eZByOcR2j6GbwIrK-XixtUYKwZo-QkJ4lcJZxJydaB-uKETW7l88dHbhqmzWDt_9UMxn-G01jGJa2mGe7BW7c44xn0gzl0pwUCwwHYIu5zBXKD_tgiTGG6lyi6sdsDODwKzxmZwQmmbCgEwgJu9fUwzeGjSPa-sVFPKUEEZ59888Zr2cI8TwmkmYkNNWnJKPUUeBbTX28I4RlDqgkO_JU4oi0vK7vyyR2gVcfoCgM3Zkr4Wd2OTF3q_ZVbGGGGGHTwFFAMLXMcNuJGxGEU8gP0_gGwaRilDiPs7gHpkd-O0KdxRGJQJaLTvB1dWkj2ncbxyLjbEaI4dAFqO5jvvB-5_WDKyqbbJWLB4WS003Ff7GQusfplejI3KoO6X7V2kmoI6e_SysA7OtpxYf4du7yhMIe5aw3-APcKyXsE1uyvaqf5-v4Mu-K7q4IAv-6_H-lEf8pmRCOgjBx8fwdRdwVJ7R6SeixfhKjSNTd7AcgRyow3IslhHT7NRFOqXZYt4zTRbj7W81yJ9AyokJN43XMMho17Qk5l31z2GFxiVZ61wu-phxTL-K4WCcANpg_7uWHDj6xNxNAa8qgHCD22_BQ4L0AehaCOBF8KR8I2wvUfoNm1AM1YtWIg4X9fKZRGnQJo1YtZ3_TVtKO6aQM-RwHJhNhjThVZs3nYeq-MlxeLOsCZnNJ6JpUvxe6Sky03eeGGt9ZR13bCgxE0633KaOn6wga2Ws1y6LBzjA3cAcSc4kcGxJRE83sI9vCh20wK4rksKaYHHLel_oM0uE_sWYjSfh4Qh_3mBgRRSuJY3UPEMwGoAagiHG_yNAxxxlYRIPs5RGWuXyT60BaYHHSSbJbAGUsGoo2jEa8oynz-cUf0jagoJr3kdbnfrkIggKSz5AgxZUviyJ2viWa0e57x6pNb7IeVc2TNijts7QZGKL1ZVar-B7BbpfH4otKk_QXv9rjbKuNPh1CFrHPtckEW-kzIZYdwkZitRH8b_FXXh9N7sboJUumBn7iwbmeUbZSFdb88iQrfxYy-OIr6qJpgDKrs5BWMkT-L430QCOrpPXFlUq9uKK-PX_sE-Sk29UkBk572N-z3rT10r1Dxj-df-toXvrFXfSr24oXBuOFjnfmIAD78MEVRP2JHozHM8zuNE-Gf6WmLD62yWjs8N5pEELvtgRLvCpoAN28we-HpSXhckxcuaJzTzW1JYE9cFRZRkAKxKTuc5Od734wM5xBP-9NAahZ3yV_11rdHrCPbvbq-pJJx9NDKZ7rJUabR7-lgZI0zr1xv9kxArzbY-1Bd-kjZqjK3Zk5Q5bKaLLQfRk5a7ZWOMvNE6jByE87OPhWWDIT7uBjrU5g51o7yDAt_8ydTqhUkm_bU3e1noA-p2GHV0I-lDuijPn25k9sxLPfIE-UTp4nWGymuZe5ft3rgZCyqw1FCyN9ggsSRUEPqEWL132bkZ915LMAHVXKUzEUPnewJDoVubPLiWGeF9JXf6Az--Hx8TYHLJg2sak9X_rEVdZmK9IzsXDwGz3DFCvda-eEAUAvtV4z0RdgTs-ctDE1Swb-X1q0ntoAD-2fey36n9heEpT8nxYnq4izAQ2c85BVLCon4cqG7tE6c4Cc3DrNFS5dcppct6GrlGUodOL0zNW_muWGAxhpjlN4mt-l9NU_VUMyW_2pfzObDURG6qmn7haCUoAUYrwO6Nr025ljpbMyK-F5maRE_kS7tegCevwqIVc-CSgDuliRn5uJZSt1rvF1VT_y65HDnIPQA7PDsMtubG47UMg8bdYhJGvN002_ayi8ILcbYkUlTTB2Z_H14gVI4U6Xjh0XTU6prHLlsngmREVIFDdkXxs20Os4Li6Ir0rDg6EQ5KP8exXS9B3UcsUlH1OQ7HXVOU4acw4yk1r4SHRs4vjiZr4YFDsjCk3mO-hS9h-arKB9LntFSKdjQ06gDn3Laulo_gEwCcOyHbnJzdNNlgu7FqZpfxMR3vDwmD1f66GCXhfNjED5jh53uB-1Mqyo_J9yDsg_fxWZm6SBz0UQVzvL0cAsg_4Fz107X4zIi-oZFSTK_gEGozb6vuC0L348IpIC6bCz_ZDqcyPdb1Ca9YY4QFWPi3lCfW6dIhNBPIp9pdhXWDF0rQ51TsXKOVOaJwZBgo5PTqymfkC3gWiQXjoOvwQluNm2-0juJZwXuQaRoUKk6zbsvb_5KL7FlmFNIMCu7LfcLZw0Wkk9PQWh5B7cES9LbQwa0VRhX83sNAxUx-wiGrPwPEx4cjvU3fCWRUM-f23W0N02r3bOCUoZpH1_lTyLce3K3HivfXmwFZCU_0XGR8IGsENkpce1pzafepgoOP86bP_s4wwP8VSPf7lyPhK_Lxa5EIpdBIJahLJOLipUa386wjMQqz2jJ8mNTbUwnJ-xwCOQ8y7A056q1Y_L8fQE2wxMgoC2kzCyn8BnW0Jmr9njWbBZd6yVPErH-Rp4gZCDWYqnKAXuLT47gjL5bMhhP8KYWmArh7y2_AXEeeqPdj2fKmBAfNelPQ7eG9smevj9Y87GVbi5xzhNYkJpos0WQ82WW5BIap9sw0sO1daBubojw5ZjjU5IlzZ8BhFg3q6fZVdo-8IpT3OQlGh56e3HD6or2O8v5o0VvaVb3GpWZFuBQ18-SCycjaTMyr3DSVJRoWCI3lCR7pOge2fbX8_AA9iNUnUk1TzjDxZqi0iQ85qjCbySA0sj9x9DapcCH-U482A34ANL5jWV0dRYK2wUKAW2HIl9edqudqD8HAQxBQmJiIOzfulMMY6eS-JUjliZfwO-heAoaW6Q38V8XKBt-_Y3dpc_zFhWjDjTteJgleIzFeWE_Z869jHR5YubCPlPOHMTmC9CgF0IG0pDk_HQh_C0qsZ1PhJTcvKsvBPLNi_LfFSynS2mHrU2HahYyK3nLLtMhIMBXt5RutIJs8TDQ0279PVfQBsndSLt4CBByF0HimSOx3gqVxY8Dtp30GA8bO7l8V6Ez7SqCu14jWkZUmDrT06kPS3qlRKQwbMS3tBgqhGT-6zQANPhkqCSSzaNOREAFzQzGm4RD0uRh5V09VCLCcvQJ_4zOwy63uFqwB7YXeFLHHanT3EgukXFLheFfVruP9-eCUG_38SmbJl7oFlBw_8ZmNVFHbv4cFXdiSmKj-NG8a9vRpZaFgo2X5c1aT1IHYKMnSRf3K6Ae5TdpRzOpJUTheKvDD3xUlYh9E5W9iKz6QCyb_yMGNIgyayiapDtzpzW7fPYEuOXaIoU4QY50cdY9LalSGHKencKK5AAFjCcZ2haJzMHKUxcRyH7Wbl9BUUeqDvSfhmk5UvhrIgZERuIe1vFvyFyylf2ti0OwK0XpSsi0_6k4VoHyJmE0_HQpfOTZVQeXiKNB_T8sxH2Ix_IRgreDtl5bNTYPimQ4-PBYyYkx6EOj52NuZ0OTlELJrcjAd_0oOYYNMoPT6VR24XuQFOuNqgT05f-3h8_pIhZFikgxwEVjAKDCjWGg_uDh-pHWfpGmcz-B4b-fDvDWEkTjNyrtQivH5xkSbuR_ZPwm4bXHMbYhxm4yMtLOULCPzZ9c8Q7ZtIBLt2Jzx0zFH4aGVzxBIwnWZ5NPTRLRg0sw2y14n_CP9OTFneL_2hasgHOKzLdrVPJdz0XO-fONSwJDrcT2peSZdMgxW14x9IqHLvPZ_2JAfEFxm3L26SL_Sc_SqxRM9ZbiBOs5Gi49yK_xPQLepF9Pgu_Pzo6abv78wLAdQteYr5oblfg4LLSA48hyxH_6BCru0wVYIR2DVJsRS0KTTX6xgn6bJuqeNIuRUK8Dqm0jC8bh4sdJVanpoavgfWZzLSXbRY3ttz5-K5Ak777tBPimBCS_P6nmOdA_8X-wzWth8eC4wwlXJjzYneLpHeT6mQmXL4EuOHZ1byt8NorSUGyTteDMj-dR0b6gmrokxyM1gK2YTsyhu77qfrqNnNkvIebYMI8sgUlKVC1vOv6IcDhw6gVoKOdUIR0QPk5lQXYjMfCQhKA3rpAArgSE95r3ypGtFjPY1P0At1gyyAs_X69izZl44qCrdtyXzM8pWZPbDZGXlHFITQ5wJQDm61WkSP2FmN-nPxkUZH2_nF-3riDNHd3WvFVi-kOWw1gn66Dk21TF31moSiH1nQ8qrKTZTAo_nRpGL6JELhDslHe94HVGNBxnh5owls5DLCr4aYAUgZcscyoFnYVO3YxO2VkzB0lyzNdluSruEPYOGugEXUvMFYPwYRIO9hruY6nwUZogKkiBlPVzZCzzq0VakJ7jlN_34TOrNV-bmIC1ga1Rt1P_JoZ_cXppNXOXzqE_RqXp1goSBMP8FNVy7A518a-P6GZDLW1QBjWj7MLJ_LcqD3qTmut4OQ185cPAQtB7FbJLLzWvhYt9W9wjLZDxct2DMP0ldP7HYL1b0MUak6wAHQk0DHsa80t0dzUWF_WDWHf3IFO6xv--j9bPTNukciwqN3t3U5UbN579eB6ww6MdNAyek_2YE4CmTkZClvZ14LfyF0IvT44gDrtPN3eto9m-sDLO44bBbLMcOQ0T29UMoH8Eo5f3KC38ZlEWUnvmdkjhsajZrJjEBsGYmXEwrMPyINHZlfnTfhkIrarIAENyFg1YkzNW6oWjTJKniB0ehAyFNGEcrzyxWaIlWv1aLDejaWEsuhk1g3DzvhBvxuE0xiRfIuP4OEKk3-Xgfj7av3ZEcL-SR_z-j5n_1LER0FzYXi8gzbUklg3EKan8n-CXbuk_XkbM49T4snl6m0g12Klm9C4TVT_lVFYnm8tuJOz67SXO7yehYBa_QaF7g1VKGGOWB5i_CA-MMeeCUwuRK4R6lpXK9ZQvvbjvucB7I0pzYKINKrPNRxlDwOYCSwShqCNJI7XnfnBh7LiZDtXt8JHvV5fnuJn0Abw-6h0QJtoAruCMzfezyWQ3z3rSOB7rlmCbw-QzgsObS1_dvDuZQIRJ6_m7_CXGha1gxwzuvdIqjBDa6PpbdPMkQdf11nLc8JhQSpFlFfIMksyrH3iIY2tt9S2xgETjJXnA_UdGeTfpuLg-US-eETX-OzhGmQe2hSNzEvWaeTG9ppERiWXIgdAVWNH2kF8O_vDiijJ6GGDAPN4GSYxKt9MTySMeoaWQPOPMTzqrFNEBRTlmZ4_eT2KS2MsqtCbC6LMCjhko6yUzZx_zHatpvNaXQZqd_8Ii9OqOLuJmciXuLuhi_zKcml_0EcP8roxBlkDF66daUhiO24GTQN0N1-V0UNxjyJ0cmR4KxgrcAv_7MhbmLww0DHTvgfmnmKWso_5Q9Y_oaQKpKgjpjyDxE6glcP1E8KAVO_uCRUkBigp6zQxlz22xVn_c9CNqNtyTj79WlWQyqTQnscLwsAP4vx5gD64em1ZE4klM6M0FS4hKVG8LurehxMUwgeqSmcXRvLOj5n1hygy3JWjQ7C4FWZ-m91p2YvfdoJaxvDM-ESdIJAYD0-LdC5YVcF4IH0PHU-YIeSUtfb-FCMMvCihv_fPrctcoB9MWzFdcysfkVdN2yXBfPYayRGHpulSGXzZZ8sdubn0j4yLnh2pvX09oX-xJ1aSETIJIMfyWogBFR14aOytQX5BZg9OSb1EQ-05PB5Ih1dUdNsZB_6GAQnaTnqdd-TDEORS_-NSLbJ8BdbPhrn4W6g_7re8oGsanHvxGWht8REoj5piS9rJg4g5qx1W5YKAasFQOjlDwirVIgqbekkQBvAxoWuHDpg3VbFj0uWeNrcjFJ3WA1O6fenRYz-E7HBPPJTAUZEcZmJtRVYf4VfSEQB35l3EhaDtwKT9BF2Q2_3hZi6xBk2EOirq8HSpeGltTHY_Cmm15TYGHAx6RhNKwdJdBJp2y4RwjgcA7k2enkkC8OeN96ZbGCZ2v-jGDGy6AnEfDw68hgMuaweLcy_1ya4e3oaqVE8GAKkNEROSA2jc7Vgww8c08Geh8aB1RhIonP_TF9A2WBYbBZAfwbZm7iDxfFGtlyeXLD78ffAVCwyoCkq58EExBz_IGD3qBerPbs17DxVYPBMyxGm57bGzMJi8fMIPW4czA4hqszATCM9mhOVIAwd6VUQaG_P1P9kevFEJPtt-U-6y6nRGGPcutXk2LN0ylotvJJ1ejcN4j33THiww8vaycMtjZ6DL4SAWqPQqFOgZKeyO0ivMQXA7Xk-lGM65SMrBf-JbLf5K3Fh1YWeXD9QTnbUd9bt6lP4SNhylW5v786gQ63UIwkOuvGsVRbcwbPbKVHGoV7di948ooFFOIwrzwezxrIl11BiCVYCofUYpuxFYx3h7qoOWEPEULTcDFkd58aCR-wj3IF-RlVoNOkWBw29sfses4eb39Hix3Vj9ZVFvXhsv-bW7H06sXgLvFLe4DBWNdum_qXqUAwddlJusWHRVZC4D-KV0_s8F0xXF2aWQ0m5xuauZ0mdUk1EfzJ3K6saBAPVGmawmc0CHSFsSHcUISzar6mWIF1KNXWmxonXQm1hpLvsDSgk8m4VrUMHdegIs3Hv8zP93ZZZP0mH6UX3qHOg1VWrfIrEBc53dqFe8wd3SWcjFZcWMVJKy2K3CZgZP45EjHCBg0nA3Wr1ljZQLcEmYp_5SkFGPvCjJN4WzND57pTijHt5aJYpry5hNEVfu_gXzBjPHkkWStctZONioLB3ctg4jqx0mLKUS8yrtxPrJqn2YD_02zgBFyEHIWvtgoWeT_-u-E8Rdc9wkCqgwCZwrJ57SIoM5cUuMre-SLeeewTd7Cm36VUAsV-Gz0l3emzBPL_s7TfWzVYQI1lgKwMR6OYdZXq7zZyzslbYdzzKXA-06EOjUE2lFFqD6oQdhndOPlq2Xr6pgjAicVF4KlmOA6ghCF9p5V3daEeCtA_OuqIObyPv3Sxtsj2wKWMIyZFU89PyIwDw_gkIx5urUEQGcUL6-XCwG2TEFbEBTCxVbcM3X3D31RokN13M0q_LicSOpXUzVchoNTdk3o1z6Qf4aWpGANVq3I5M0z_7S4bl3MRbmDFn4fE0EJfw9uHSC6vrBF5LMMpWDH0FSMEQg5TwLztoHBMqFOl_Boey2JdD1DAyP2P7UZHbze2GPmhaa7_ETr7b1eLwLuTVpkBNnBI-5b1LrMxkKO5OF5KJ9InTS1K_fLBMy5QhpOVDHmLgIDCfiN1-YLXy0KzWWQKk3X9RDJ8-G9pE-M4BhuspETwsZp6tocqWCe-YyZvM5joSpbqzn-o16sS6WqE3fxkUYkpgRWjL89JG9JIbG-h_eoWMnqOIkOyhiwl-J9ez_3UfiAhsTYK1rvWFv457k4moIdl6xPJ_99oXaXUUfZhLpJ16fqP-M4tiGfwDakmZNQ4gjVzLWJ92y2ur7TLvFJ71GyFHbP5JQEElsYAsBxYj4YpyiM016tO4jWPYJ4MMUH0cmNPG9IKv8a-qD1vyMW631DwT_zLLftzJoSXIklTEAQSMqHRwBtHYuyOtG_6oyOTiWAaDbkdZrcyV71xZqayDhZaOKJr3SYyhdspwK_1c26lngRDn03SqfASRQ7B6GveZdy4L5tOWd0B-T7iVup1ERmV9yGXMn7_bnPhylhLQoFdGnRjn3N5tlhMfHKv-jP7wnwez6b3GfG9ktp8lnIU2X-D26gtiJVLm5WDcu_mg_jk47npaE14yfegEcxxv3rPzUn-iPhJZoKDSpp_s5IS9UrpsxVKqlxROSaZz-wHgTMKXa9gIm8fA030wdq9JuEUC4c3yPaqxyIJnOSpytBkKuqijex2f33BXuJUIR8uzO7P6dO1q_G5ExgY0xAjUCQXak7foV6uKzitQPEyB9kQ3uSE-1IG8wTxVUer6fZZu3i6jvojOPXY1j1UQexQJqmuXBbk82Bd6Iky3PlE36Ko82kZy_SE8StQrRUn8gPYPaQO0xOBppXwp08ibaRVDoe43HHJ4Cq01G5q6ydNROvq3t86b9dPfXeinyn5M5jhRIvavn_WcmZwdxGailHttk3BNJNXbStr0zhhOflfc9f_sxvFMD2hS2idTRIsTcA9c91zNxtrnJVGXiSlHdMw-iVXAymiwBV5wmKQerDO_CRQxLwNT4C6okBEmulgvUd197r8jHtyYOzYOccfV9KWqpw4MEh6P58Uvj6zerxwvDejQnTHPmdOZ_bzI9qyd-Q1pfjcm80zqQubNyEbb76VQ3VVXxYx4BtUymCLbi8AhU1EkwgkFKBp52Yh6vrGDycoBQ7JOdTFwapD0Ykw_EOjEmDL5F8Yqp6_gw5icx450lzKOB6oKRISINosSJX3AzEh8u3CRkXBUXybPMlGRr5L4H2qhig7fXW03_Hkj2eM2kAiBTdc1F60yXT4RyBlKUvpkxCiktNCyFi3R03bAhRSeqUAS_aO7oONdIjUouYv90jeDUPfAm941PTPTxiJxxSPrGj_2WGEjbTx8MjrgmNLlEpm7HCsAD4NR7KAKwyuE6UVY5C5H3uS6E-ZW-3kFUQT-rgoUUQII7cw1YW0l0EVq8p6WaqOa1QlpSgKweyrDlNT6YvKmnU3QkFzDOhj2VnuZ3y8TF3mszsDLFVGUNmafVN0sS0Qea6d3CmvJRbnk1CaSd7uHjk8hpojBMgbOGu4uakqMQw0-FciC3JHKpEMZ0_FDN5WPJRlfGp-N6t2w83Uyeu8cJO5_WVlt6avLKXms7hfrR2c_WcKu8VRpPvfaNo0tocddkOjluOV-rkNX1DyqvmNZHcmC6cdUsgcmS49RHAaQtgrwFVGGl8I5XZ4vIU9xH_ZqLBjUbYDcwQCfy7G2hyQCGWWIFnK2V6csX1INncHHFbSkq7WwHL1Ywpa-2jOxuzRgxiswXWQr_gWfHwU5NcJ-t97188ESPI_uyMU3sBzcgo51JXLVbMJiY9qk5MSVmot3QuIujOGicDHaKOhAvxnpHuugmVbzD9AQs9dPDoR0D0j04lyqBWtVFPRIOHlm8Tx8Y_PaDlsKF6zKASPJoiwFiTw805usjiCYdLO1fe3Jwz1h7K144uIPuXWZW_FvGKVHg40c2FfDuV0pyDjAEO4Y0DBPt9XkTEFtiAfLkmWsN4dSkMhVY-758h2L5cQ5G6ZV9cC6M80az-pyfRZQYZ4ZcU04ANT__Ycy3V5gH8crE-Zr5uz069QLuFqH-lvPEX_OgP0WUbzkTUjIirAbcWuJ3x3rGy2YqLOpC-COiMebW0jXo-KBKgBWT7VAnAu_nLndN9ODAI_afkzkIrWedx6QH_F_wN_ic8hzkrs6yASqO13A0440oH25rS86iY0lwK-OlNVJspFjnBlxTMRdAvhMwo4hvz-qUHHHvaJuNI8Ja4aOpW5f14XYJEyCsxi-I0C0nq9qPFXi-DrqQ95NQVWgY3EYzGBTWUPgTVwoYjyRtZU_Tui243jKAtD0LOsj7Scr0zAllAkhcJuWy1LwJgdUIaf2ny_SXvc84erICP0IZEDxKNSoGn2N1nCff9LtP1RNRHPeSWPByP4G02qPY4ob0OY5P-F_H1p2zG__rlQAdEcyHE9n5JATx72NN7bnSNA7b9Y0p_kDU9EhGcRhWhSxPpleRtMEbfeP4UyE6gTN4N3yLLXyyToN5t7_qcDX6O1Icc76nGx3RgJ4X2a2OpwFZ8aGP-XmdqXHNbzwjqmJc3KTm5axFWlcyyxFft3ipGAhYoM2sevrTkkGDONuQ_4o8eniEQxl8j6LFo-3mwUK0NY753JUQCvhxjQLhXzrBzUuG7oiSt7pMwkv4yzKS14q-MX2C8s1whpzX2gGYLkeIc95zMc--9n32Z-YF6QOuAOD5yIyFZK4dEIAJnnGsGNrJnbprn1Bg8geMAVmAe1i_olEKceLssIw0fr0dwXbM-Etw3PjvdjiUv4MD-aokIJSffMenIq7FNbDq10k9g_Egb1G-iQub8Hrssn__h7Nt0zSgQSsU-RFtEyC_ZicSNwP9Rmdvn0CL_qvkx7OZCqNHQb8xqyFreH681bNihvQ1tSK07Z6W01snJqI2NOQdtBJUByPy8FRNBZqjOHEBOzwoyvSgUDr32Ck2b2hJPM235kTZOkuGglAL2zg9T08nfbE-Iknw5p5BMYOgPgbWFJaG2FyTlHQu1FrrF4yLpA5suFzy6o9okMbH0TX0om7wVf4uulKBTqvbLQ5djN2GJaYVbZ2AdBhiGbyPdfNs6pzRkvU9JQoOEtx_TSXqVyFrqYEscghnxtil5_5GK9K6Lvv2dGXepJFNjzws3M_NptihePrr7HWc4XpY3vs0nIzx3is9sD3UTvH5PbTpY1KNR-YWDqZtaEmUJhZLxvxQKm3UrDvrUAz3V_ayECpLYNdOJIRCnnjtJIBK2ds4d4WN0Iv0ZCIaY_EHWFEuCM6uqM0QxwRi0v9CDnKuhUBVH1JfW0n8S57sRmbu1V7TdDqdoFUwUZblvb_kb2r8muqXeT6Q_5S_dPiPIyS2vetjyyqs8iFn0-5GM4ostQrTZgVN1N9gRE7Z0t7i97ew6HYQm2WTX0YOygbTH3wVXp9qi9HDObvBo0no3SARXFmVv0W1I_u3LT1kczWrKSbYF7uH9eMuqVuAzXlf8ym8uAK2quZhjR6t7YlYmkjIHRv70LojuKnqN5cNkBSAtmT3ptOLe02WjsfE0e2CBXG8O8TYw_z5FWb16Cag02eJ7asLM9WJYMoBLnjC9IkC-y8edfX-CYYrdm5s-dh_4ZNkPYYFHqTq1G8kSw7BPDubMm3NFXvVLsUC1S1BpeP6CEYhB-aZQWdl8wRCyVBGDmz2hrRh4AyfI1W6uaHgUdAp0Lrrizfm7dPfnUT7ioO2uK5J0QMrt2OU2G6t-W3ZTCLGDgRot7tj-uN7uNj4m-9Ykook7wkbNmWc_vv_KIdg-r7K4Hn0sT8a_e66Dme4NhLGQ8E0CT2v2WEBgNnIHBF7WjAYGYfMPCHw-HTPYAU81J2M7bijP_K9GbMMaDTLziqFB7HG7-GJqd24ep2-faYYL9GT_9DUXUTuL7KMMV4RBxnXmsJDSenBg1TG3R0CDd6joNwa_7vjtKEFV4m-l2n4H6EOrbW3s11ITp3M2khjBcJHokikCEybu6Bujyp7CzawGtsfGDPuoKqLYQip1X2S_ui1o0TH_o3j8PijRToboV6hK7Rnwqs53e3rnkug8Gm9GXoukCJoYZKJOnw78eDPFS2sKBEF7lhXZcs1cAR6xLOQc0sRSUnruunaBTNEr6RLesDs0Jcd30yfQpUifIdUsJ9ox9kzcE6t6lsygoL-8vvWMyABH6iVIp7j6dNlz8uLZqsSd1w2E-aOdEfSywNXWq_ufUXFyogaXpaZ2OnIdRymdDzVKrP86jSKmdTuM0-pGEHaiSe-1kaUaErMm6ONQ_ZhwcZYdC0gPSnOliwDvNf3YDT0oDUubES2LkW3r-tfULl6xrAzcs0w3EksHwVKj9bWyw0GNXX-jHrVqGY5rOeC_XzyIF4ZAk1l_LshqusFZ76ua6j3pUU9Gu4DSrGblILnyUZbM_TGa4i-Ey-isuraf91Hjs8_u95ZET87KMuRWmMGgtWVJ2sWOdn-N3aHkd-uT1eG0SLkr8y5t75U4d6jR2JOr9VzpAbsiWvybdOSh6hWs4ZuhL-PRnJ5xosht1GvaB-LAJkdX_R1GNVswKJt38irOI6aVk2ytDHjex7-k9Fc_KQNA62g9pD3B--XEBS2wxWAALAHhnLa7L63bx49SCVH1Xud3SDXFeJ3qWoDPTnrMagsAtMoseVzfJPFvI8prk1gbkN_FGHMcA7i2QAziOjL5LhAf6LyqssLa5NTPTCPNYFqAErjjKL_Ij9pKS28kcqQ1JPSf_c0Qm28-gkJYyfQ69l_keiwcsRYBAmNPV33xyBWDU32S6zvw0P0R5ZIY2U_SIWabGufr4SpRi48YNJmEYVtz--54egwfqPybxPPOs0VuqF9ZxMMQVFwUPHpvS2x0TYFTOHbm-C9Xcpv6INmg1dBMPmnD73QUyCv-Zf11llGJGKUJtTWnFLX4HeGZNXjbE6NwJqSn6zTVlHlO7ie7EOREXI0IzXqxxzRu_6a0htT3nvPagbqGES_3E08KhUTfZ7UU1DU_n0TOedX2nEPQYN7yYAeo74caVRKwumyglJnDMJGmD0fJ7rS3RxrJXQ38J5xXhKANL2wgh8bxCOcFmTtDRtnusoyl1ktJ94MvahaunNjpAm-g_OxBgNXmpbapMYFINkyAXya34ktqjxIE6rmjAzU_0E_AbXsXIUnPNXpJFX3CrFr1ytNYK76fnJ6fTDwOzkylmIuev2D_N6YjuTJcG8npJ9YFMln294ROYQYsS36hfyXUWuWJPsaAXyRcozaztdcCMVimHp6S3hzQL_RhvC7yGR4QJVggHdDwtdoxbM9qb1sS7PZvxyvPRTTN8JHWgZimpo2_wrRZZ55kbAQAlGkXuuvW5CcoKkMgWdaIJ8AiKW4LyzqukytIY_PSG2HWXWyRLMomNekrkyru8mBhe_GeU9fD3j3KgQyE4Lno0MF7jYWxmmJBEV616SblNi0C1tfVcwLXQThyhbmsau66ydLfWHK9OToHM72YJ3e9HDC65k3JlHscbJI7q0ZBEFF8VbXU1a1VPSM0nuDWtklwtHR8oGt7n5RXvvSt4FuTy45XX0wBY480QSz95dj1HKmuNSZc5dVk-EaoWRALCg_DbcoK9Su2ix4BB10AQ51UfOn_oLUgwhhGFhaTRo0SYM8DuACZi74ny_L2ZAKWMVKZi1e-hslIEObbO1dlUaWdLPHTuH8s7RslhOfi03Bjw9H-2mcBmIEDCo5PGrHLIoWt8HFPRvDT-vZKkX7eggT3QA0zWxw-4SgC3vjlJn6uvk_J-P71Dmr7XArrFkjsufeWK2Py8MGqWOtcFiJQtXSDhfYoAfzGZV9f1Z-V6JPSK5w1sfStyay9172ss9KAWMFlSDCB2uXY4t4Pb0kAho-5Ud0T8FqqhE1XbpMZaD8CYzMcDjYUCFpbZLXUr6lHeiMN_3Ng-CetRQ-FprVJ_wQVMhY81PvEiIXe1kq8BtmVPxXK_tFPgzOUTuF_O079IdEvV1XBX9mtGttNsUZPrLsZZcZ3p3tLwaowOX00k9QN9qNy_VQdbANg5VZ0q4zVTK1Hh2Shc6nxfA6LlC_aCt5T0vLVo8B1fbPwNIsDiipvH6Ojf0r7YbMTSCxsextAAzpJlXvYmyADdn65Hg-_4b-z9HaXifMCZRN_P9Cg-h7BvUfp5dFMUdIcTH54hMFyJOkBxh_WO1Y_7cFcevwm49V885szhE9c7XtByxp8SfrbKJ9-xYcwIx_nmZfnFr08IfW603lv7DHc0vHNWSyDoWLX5AFAsDv_mA7TmOVdikDhHXojxq2P5SjP-N3xqZU09-7VncLOHqKL6CbYLOC6kQ00p8dxICcNY7rc6sHCiSyCEKj15xpCJZ8I8FP33SjQLs2xQzDzVuxVBsD401XQRAagBn2zTUIrl8MIMpkh34KOpOO7nguchxRRc4TvNdEcCvraKR0RJHTYfW_N_LLvYHw52msohM_KySnwstzdWgAMPQEvG-AeL8iIiyCiI9NkMBvn0Um9x-zi26a5E42AFeg-pkEdh3ItnYyEIsMpKGQtfqkY4rkfKeTrfSTntDryzYyOc3xnCUmQSTSBg15ZTMtx4ZAmgROuMtCocuEQz-4MOrEm_7EIzrUUTX4_EZHwKOlCmh2rS2XdLJOH9tuQGECBtCkX5PqKXeAKqldAK_KhTSkIwuBDVqToqWidD7IjLat3DvRtWAnb_YFBQWzOa0L5Gwn0_0Y_WwMpxeONoNiNE_gAf9_ZHEf59PZbv9tO4mAVuv8OgYv25WeA4h9x5KcN6UNBqbA3OcIWwcmLm_214nz4K7gP9M9lyoI7cy-NcB8jYFyPEPq-e8lfvcsPXMjn2fw4BqXdIYCzwsLgXAHA0SR0CMiNtZyM2ehagBK63HCLUu3BcgPA-paBOFScdQcEoHOLN2FZo6O1ynbDauMc9mSvr28F__LfKTHeOIQjrhbZh17r2Scy4nsp1jvHSTj7BO7u2pXkYAHphONfxnp8e9eI-gzSGMUcFcDSyZH5Ft1Ab9UeOmpw923iYlDTdeeXuWJDQYEiRyaUiDDd11S-FTrJo29pj0WP-BZMqQ8LzSW_6YU4EGX-NW4riB7_i0Dxcfy3v15J7OhGY7P2ut_uFqWvIz8e4fg41N-jTk5XDxzFGlxpkBAWxSv3XwiyfudgTUqcYrscXBP8DbzDIHqbBcfGFEE8aVDrcOMZ1OTP4VlEv2xp4tVJ2GcFMBSAaF5qvZa43Xxuo-s2vXx9sf-KR0WiqQkkTKjh7hAr4Rj-FnOdWBJvqj470ETdfO8RD0lZqYJ8ENXcl1p5lNYAgARkF2P4Efv7H6_g2Ot9OBhbLdHpeZZSQnEO8yhPZE38aNJw0ii25Jpc4QY_FIMH2q53alc-UNktrZGgoCueF59i5RJljdeQtHu2hOdCjq0-dUa2q_QpQ70lE4EiE5KaWP3ewOxJnlkuJn61sh4JCviUO7XNfuf6IANxksOSOdFr2p1Khd35NHZwefGvKlNar4R32EFR-gxPRu-MfzKRd7WUIzG8yBoL1ksfyuG2gFTDb98BrgpkO9jywK6WGHysP2UQCwKkTN6eb3yA9JiD-BDM6t1MSzuw2LFbgC3_DYQPBAaO845SbXGEtD2kk4Q17yNvsDTsZHKva3fDG5VJu_-JHWQJN70HB00tbMDInDxXIKSi2YUhveWaukFskVSBZGWBehwrCgck3FYAUVakYMg8sA8rpqlQ08h37QzE8z2vC3wa98uDku8BHyut8752nYmBMYPdCdpG11zjFX0VvddqhwuyRzWzpLbFtUmSjS9_7OOGqqACON_I_AEooLm6WDtsl3sQOWiWmIshD_zWMjGHYLJ1ZCOJqhDmQ3bv-9r19adeq3NnnOh_0d59Db_Y7ORVZTiRrjASokRxj3nlAIPsA7LyfQw_vmm-qT4unKZnRZySv-HFAZIcokb7lJ6kyzSyRxzVtOwa1gArmwH90Gsa4qDBLPABn51Jq9pqcK7KXx5uAcn-FPMi1_qgbAJwqmihC1CJhPPgwY20ypVmK0_hjjwLXl92v8nwxpUzQkyraDP062Jj6yB62dB2wB79_c1cZLWrIJgKv-CG8dg3WmhUftWtatBIavMTXE98O2fEx1RfRwhuDQSoZvG4KZYJJxq3jxtNjNWHx_-0PsTLvg5xu4VHDeQNofm4dEJfzqvlu54lt8jIlnuKAimBuVOYXZ2CB8Dt4FilGtl2bLh455KNY5dn-6hLE-oFeuBQE6vdsRq-cc0WEhtpZxMwnLX70PdPvvTQcuV4KFOhnUwbG5sMuwIYJucDLx8uSBj2_wcM43ogLz-HKMx__QYGPgObXf-mNasQbPFrJIGad32qKla_Vw4sN6D2GLB7dgSFCHvh6kkbQdrh7T4fJ7lp3Si2hOK5T0wyybZ1Qt6--gA-czXpN9jvuMGHW7RFsY0RC0lPyrDGNHB_hGL7xoFxpDf-Xwx-o6nCq7aP2nj16_EBotYx19ZUrZ3jDezCr2u6B7E4xU6qSpRfIR3cd_z3rALpc0e-R6R-3Y0ArHiG83-SDMn-WnborGr3dO3u7wH3BHisJvCxN3aAAswl3ezqyCKIX9iFI4ASVsIkuGhUmUD941te7kclCoUkkaIR2n-zkr7Zr7afmLevBTvWplECmbSEIAzP_0dUb_6Xtv0eI3cp2hnCPf6L5C96wKisDRvF8ufcr14i5vqB-E-PMXptScK0bU_wU8gslZo_fpJlFpUct8C2Mln4DJFa4JEAUtVBXUPsTRdadh_duUYMAq1B5mcQ5VptGbW7qjA4Vk8lnnAI1ZVhpzM3LPB_e3YROFo9EqT7t5I5AbMt8RTsjVOp5MedRz7Kq9vP2HQPnl7uZDGmsZdLHnXUIpbzQqBIVk5T0j-S56HI1ozCBznCOhLXkTXAjJKlXZdImowKqe-U7hZ6jiuE_XKIhrm7NjYLzXsXQXMjapj-tqU3gqMKmKPa0E2cRr11VO_6546XgAJWHVA4wIGTp9DDW6Zi7RUv6PMbzfngP10REIopW7tPpUobFtaQbERgZMqoWPH8KizqItLiffNSCLKcnwYMBUhkzhoSxvxdwrmmkn_mr3tJKRTZK-3vHYmngNIrZCGwLZba4Y5Y8dk7GEHHOx4kRV3XibKuHUJPcIjDvGePFBZb-F-PbVkK4u3CJvvMIUXx8Ei8BAdmEKcoTfOQtGJcw2pCqev1dUiM1IOCTYorySOyHjTtjuTolRvGgV95rFY1lysFdxdQbuQhVyfikymrsJvgS8NWI3Dj4TzwW9UMpJCzXEzpZK8kbjw7UclOyHGcWgRpl21fg_VtIeyEEgK230qfCiTq7ZWd5Q3vPAh_aJPEIxmiL_b2JrerRFKrPRmrDnwGXexuRgHmXoi7bGGNTlNHd5L9LbqVYRn2LDuuM1VC9DSqLNBv1FJGn_sIQ3YsB8qU9rpg3uEaDkEQOsSLl-_7wsu5epF99b9mvt8FVWWxTRaBKYrvww_SKSRwtRo4a5tYuvhtsLTwUsBQdxrUne-7gwLFk1Rl63Od8P4-XMn7XuUYZ8MCI0XAbJfG8K5NCm5hFcp8EFOev9UGCcEfPCllazAXZWHTBuW_jOGN67Pky17Y_AXu5uI-dcj2I-0W1QUIx2TJDcFjsZaFw3lynGdKyimvx3GSPRCGVh-WUPvRg_LumqbjN2bkht_TPC_krg7GTJwO5BZS0kW889h0vGoBtE032FFJ3uFgPFRBF5LfcCklIOnABObOIo1NkLnptvVld2UtNSDfzJTkk6gYOGncCYQycIt5RdHzAB2GN-IiWWwPMZb65ehiJOjlRoyeIhveTKSJuGhnyRSHweTUDK8gfYhOFOJe4U0O1NN5CKUnl8JVJAPExkqhtsQd2hRTy5H7MUt6xKcCb1Uh9YSaCYTT-8ZFyKUhgs3gmQ3zf1l9-pIrpfjRoATxAb82_uwoXMyOLedV_vhFbqtFZjhKc3D3JbiHcUWrRm9YucEmdRb8SzyCI57jHVTlNu5M_jtR9KQdpI1LA6TU0u22hdWOtVm_gGa1aV7ADZoZLWuFbC9GSNtJhvBkXs3obNzPqCS__fJR2eBH_BpKSYh2J1rWBTQh-hOeiJNsVN2c6cJiggOElTIScRWh_WNEobCBGLpMx9C4buoOw4uvt0NCx1bJVMFeOnsMEfIxvaDhE_TCnluaVAROLZzRwxwvZRrOZZpxSEoo-uetN-BcAZGwuT4j-oTLPo0doB9BsQqABRSnQ6NPQ-BMctQE0aimgYZEu1IZaYDb_9Cqp7ujYWpFRi2pe2py93txSaRhbs55WJYkMMDtjXJL26Od5a46U_YuB0aFAaqluy0A85ir8Dq_9KWq3HrI1bwggpPjqJnORFitt3v-yr5pXDvXGaX5oDz0pfUH3KAlBLsjRvW_HCJM4goC3qgtArv_lhisp6jYXjbEKT3WN8SY86aWQJzdYKyuFUefKPCkuuI2dp1ucqST0L8SX2L3iyFBovVz4cL2lEVQ0i7fXYGK_gWcsSL6SI4QFdIYY5yh7avv0-cXDF8KMWAACHBwhmy9HXsPVIXkjDMj1OZxHjzBpwCQ2Bg8LllSzkeuGYDeM1FxhRdclgpCX5S1feARFUTZgGxVdx11LyEwmmQO5fnzu8S_i4_NDngpqqzvX704Q2ekKzJZc9wkrQ2gcvAe_tHY5Oum0zx4KG_TLpbr7WPr1PaF3LgMRs26WiEtzktda-ch3aJL5E8Y0B0q7YJP-YMAy1cuCVfpwj_cEK_TQiTLoQTgpCSqwiN4aZarciD3GUpAmmomMNRALg_pMkDWC7ZUd96goO2_84QpL8Gw02xkpQukqYA1lOwY_aYyhJ50ycXBjYQFazx-cXEC1nS_YkgbtCJS5tiZerecJT62V0HpWVZ7RCYGf4XE8X4FJC9GSGfCitjEn-JNZrcLUyJPjD91uTtZ7Yucrvr4rRLeoVLKCxqFYEt1UvjxhQXCf4ie51ncyf_mGxR_n8lVlOijiHqlgXZbzQR5cVX2UR77o1HlvZg7TZcin3F0abHNwlNp9HG6AVtROi4woh9DVqfIHvqhuIhkE9CREDc_gkV5E60F5rsUVudgpKnzqGrm_QhvK_1fmyTSMnvyqmCiBttN10nPvQe3rUy0X-nUTcQkEN2zfi4S2m53dboMiup30-1wQTdeiIEEQvd3CG2v11D64vD3V1zupx4W2_jBLzaJzDDvuacesH9Z646hcW4G7SMeYWJnEHfE5n23j61kbN842v7UV71aRtFBk6FFtkJckYREaVH8IK85A398vT-cT1sudspPaKFQz1SUl5PABsd1ppKhoWqlBgQbjJxub_vw0L8y4r_S7vBEow-wzlmy0DomIObzyzMA0Upz0DAGvjFHbKnxzPnvbpKVDEwm9uf_DkUjE7JQHNl8UW6t393LOgWdga9ToHGlCmtvjXfo0-DGAmk1E9A5hQ6nOjs3f9YpE9Zqe1WqCrFy-b3DcRKky1lrIhiGrorYxPZpaWt8GNCForqSCoo4R3G5Ky-JVBhjcfQJ_iy7Ihw_KNA7Ut8lSY3rnBR5xKKyCT0SoTI2szVIhUNQ9TlEPTAkgPTGTmUxW_dvVhACzF4IzEK-cFJKyuf_Wp_jPbvXujw6XFmSZ1OEwi4HxcmLsMljaF3aEvFcobIacbjd_2QFWw4MyS8Xxki975w7EGa7Trb_xfSUwpUafgoTkYK2emaVUNvyOOALTvfoKxHPXXPiPVGzhcL4IzEFD_wwlYdRWKCOsmjbA8bjfohFjk0T_19G3fxtl6VBZ9ldFGLxTkDzp7jGjfnBkDn9d33SqXEettOatPiPPSZIk6vuH7ST_Ppwp9z3reIxVH7aXITFYVaIq0b7Lx_R1rFs_utDiakOJ4DDTlYAZqbvdqrfEQgRspT7umKhjwUM69td8XhNn2RUq-nrp29gnyEkyGY0fqhCopMDhdqzHXTd9lLmpXGZLDykSpVRvorU38tg-5hOntZIt5XxNNalR3epgh5wHAoFngpi1XW4xbbtPzrl0FPYwlQAUSYuVywCpRaVyoNqO_KxergBQW7VvK34lOErjUYjb8NnaCl2dWMQ9cRiINVPiqF4LZvrBBzFVFK1fClie7ujixG7uanhbEEWgr1VNbbhpccqQK1XCKYwm1Ghv7NVT2E6ye1za_A2K7z7ihassEJUneQIMnVcD_ZgsI3wwtPFrVVqjQACws69v4tLWech6ppYE_V5L05VbwN7t2xYRA1Jws-mg7y8a4oSBYwsSRNW7yDQmtWm0KE0YtFe0XKqbg0zPFB0oPH4bAmmAbpVTQeyFyMJ2klZE9icqn7zXVne41e8FeGaBWnt1uebsyED2y8EAZmYZpWrEdf5q5rc7oMeBdzp3kDl8xdNJ44SAXZZuUMpKYU_oYl6a5_O82DFRYAQUw_H0LJ_9YiV7p1wWWcrknfd4weYoFB5MNjxF-wJo9UbEN3DI7PNbpCbCzyDu3OIvUdwk9_810a52_OV1uQgDYvanfLYfWViqGUKg6ICGArVUgg5qKoKrbVKk92ebsiT4HnjK4qWKx4nyIuVGPGO2m86aSi5CntjQq5kjjvLjRd8qAIfTd5snYFmHMQgxeuu636HkwkBNgRWwfKp2fQ5MH0y1x-Y_mborsHjYfoxRwC6nG-EqG2DZlGk_tLaRJm3aefOI6E9M9aorSG0Jy9eagetNios1fnt2wJ-nrXVGMmqP9aa5WkneA6evkf9BJ2wpzuwRyEBYdhM3eFoNrlyNfOq2HHOIJMgs08_yMZfW78_yodF6e_xw-4eVyEZ1sV28_jqCVOeQb5QX4q4e5Q3ITw0VbF6m1FyztGpZgBG-b873vyRPZzZmJSDaBlfTAq1MClNFYlPoO3W1RRb-9gsfLgZ749S8Z1GMZZUbs_zFnMvx3qEODVX_r9AgFPG9bNixWJC3BSNLwNcsS9LJoK8LTe_43BG27AX-wVR6G6P4o7KukLCAKOVV2kYYZ0Ngu99FJH0nV9gTk9KWDmZNeMtCaojrayGbm9E4XbrweagQO36opYKlsNZ0Tf0sZ9op3Mq5_6EXL4oD1RFlkhzoePPmcLLWiP0nXc7CQpTac5vaWXHKhMjOoi6vJ_XVHkAU36N7isDgaDPxmxYJqGnMQWHMbdMmSbC1xmWJ1cMsfDItucdR7ceqpTzHJRy1J60qqRb6mJ3dyZQuL2gd8hHKIjw7SNWQUU1mriv1NyVaIJJ3EB6TcLXyu7MCc0xipK1ZQIumLXYKUl1Vebo9Ld-_BDpY0iQqxSY9135824qmrWMd-PP6Coh7DhRr2OXbJ6MVn8tcUsBwa7GzlCB09mbEflzlHcRk8Yo18KYXsjtSy2w4Z7VBYGqfJqob4Ah914dXaiMoL9_HjkLW566fiUNlL97rlHVLycGEVqc0dPdrxODoguoXpRHZ9HiGo16uFnT9DAUgbOZd5a9aBwV5MZE10XmYcir1bNgg2crnxh4cM0ob0WQxQPXkpP4B5nmly2wprCAy9dyrLRtOXGYzuQqO54mY1UEjyZc4zgbpMGeojnyVWUW7KWfe9x5DNAAuvGoKLtdUu60qMTEADLptnpwu2yhIF6MDGvGSWEsOJ43v95VTq-Bhz1D9tDd9o_eyRa69v66XBILgV6Snf09uF8t8NZLp3OnGtKEuFjZluhHxBgAipKHSfkMrFXIqa8JWVYsxNYDgWuy3D9YDtZD2JikavId7kC_fmg6rPGAHxuYX1lDwbg79iyaHVqpCubeEjyKp4pLzUvZTeLTEBANZ9U_MdSZBEebAbb3H5xO7CuZQjJF8q1uNf38f2j-0idg6Oovn8R4z71Wq83RU0HmJMxHusfatKFAMWmqvFC1IkfX8jzlLizMlsZLlMyftvSrkqQQCnKCQ14_RUmWCGlNG4tTNMFHyK1HlBh4X7aHlHZrdXcbvpgWdaypeHhEF-kT3ylECSppvnhhG3zeC1qCU8eUkwlOLWmKLq16IvvXB8nmMi_tEF4PTijp1ds00zVtRGKaYLOhgA47g9j7q5W7MKgt61VGx7swjSCX31O5N41yRAxuoR4o2AfG8erFAKcnhsTYsssK26Obm6NjyPRBZDLkK54EF3wHoQDWfUZTJpyf-YBBiVRqBTQFizI8yaHwgYr-r3EW_Z-1zvXXK5eSmPDzVS2pN9ztiKLvySQQmJkyXif83XqtATaPFAn1r8GFF73KIK4Qt1H1PCDZQJYjOFVNeEElyKoouimi6FjovsN9tManN8R-Zn6_mlX6QkCaE96OZruT5ozlvqats-VBwkEhbNUqKiCUokvXGGCWFu9Uvu2_K1BmsOmvnGa6ejGW0UF-8zJ1lYmV4YG0KxhxIM1_GYeQlmDp4vTY2TH69Q5ejqPVAyF2Ub5-WsCQ-sM7L2JTtoelK3LpODpruMdX7vPXbw1mwhojeO6T1ApgOSDJAdLyyAbiG8Epc7UaT2-odYkoCJ4QUI3TAv6eeKATPZsuxqNeiBpKKOJwHCWVfnIOpuEy0QmP8-hwYmc4daFtQkGtoV83_U4DD857xx1va81S7TKM6cmwqtzZ3yIG7hGNe_LFwrPaSqGIqZ3iKH_CMhMaxKIl08fYEf92LeH990XAigNG6z7QeHlcoMuh4oK86FhWLXF-KlRm1GhwXRY9MHMrIbzGNN1uiQ47XzwjaRC2OChS0eygSvHeulQxhLKpGYOfcUErO3PknwB0nkUS2gkA8RLaBcVUxMxoICdekJ8YzzYsvujQSLR8C57XYGYsXRiKD3q8l9IFWPMeBw9p6tI2L7CubiZVVQqpi2adeLCD0ANpfOqIbpzEqw0yVk3ZeXeDRuTyX4RPgZ111UqicovkOU_79TtWlipgTWdlX7LEEDD8KmNwwdEaOk9UrJnO5AWFge-9-dgab2hICr885Q5XDkbCA1I9BTZwKalrJfzR8X_PP8gN9_wTmuCpYj0UWxX2pwx9xvNobKoGown0IP12ehWwJGOyNe627pAy8o-KJqw0p_hLNCQMyOc9i5ZURwzGD8PP--v3RzOsP57SA7deN-lHKq1Jl7_OMNrAloy2dKLJZ1RE4Xqk_GEj9C7KGKcWl-CunyL4NRx5KCo5wwL4sjdtGUC3Hq4Pi4jB6PIuc2xgCbB8nYs-ZeB9qGXSfLn_PQW550uEmgbOt7yLsUONQG6byzM95Cwdlxw-m78fO_CQQdm_5paoKc9hflyQRgAm803nzAEAojQdF0UISiKeWkkkDqFUsmQv_aWefobeQqWg5xxVmLyuiPvCDYg_aOL2bBgr9TAzd6rahSBcupIGJhjpRNsM3Mz01o_Pmz7Dl2eCwwA3DBZIQTnnA4qgekHUxtfItvolpSE9TfgIjkZIqFWLgGWoFIQPxpWmstb4u10jPIT-TLzDaNRa-7l9xfoZQYuHPPqVP4VcbwcvuHYdkTI1cpR53JHbprYPLr0YGTNC-7ObCfXV0aSvMk2By_qPz6AuErIhYdeL-eIN2Tnz6VZ5A1oj4V8pB5sfLtkw2gtik2Oo3JY2IzKtjrfo4K9FfcPZ1clNVCGnahUYs7JqS-d1aVTM-ms6TV-lD6Q8lJz-RMkZAd_CSf5-JhRyhGqA6M8D-Np139RvSapNAmorQVrn_9UPgD0dIRf5C_oxwGWmxKG0awCE3Y6QVB8z4camkWMvq-5kQcuXGV2_ep0vycv423NRh-chMRR2Ni1Sxma34Sgsgx_Bbq-2x87ot36qWJ2jRGvtivHqWPZI5o69Io5R-PEh8NBPnvRq9bFxqRUjfO5G1HsB4Urdj6U-E1r-MsRNKuk2V--kgBKyW-50BkoalXnsUzEbL5UMjL9Dr_G-u5Exq6O1aJi75o4HbBVhhVSL792ciSma-M4TJo6M1Pqe_45E1IXVpcmSEGEB3G_L9HsnuEpsxOa8yqIYNURYQOof_sJzG9bOYpnAYtUaHcaFIgxAzfXOZREaPH4rNMLnX_FVpiL1clmzyA2IdN7URZxp1onwrJvRNz86sfC14z9rtDDp7J9PPbkqxDjB6-OHRyM3EYA8VzRoFn4S9O8LMnFMK8VhenGWyNARbJdWCE4t0txgOJmYaIfReIZXigAhKGSEz-6jTBNLJ-KDTJUJWFM0hS5BPw56dXSt-TcwXkpJGDHgLkFNs6i7bozCn7M1mKKZciC0zY8iqyx0FYkTyyfhHLMUsJT_7W87n6quzdMUT0S7XOzzFjSCV0zwaQI-oxnzLICnVhh4lHMUiN8uWgE6OHDYgaFxCrI-reJBeGmm0uydh4yAJFwtfyouz_4z0C_T281lT79sP46T-9pHBxHYR8ns-qhArvk4N8I0V5huMJcAgx6fF-uAFhg7nq-uvYP-npVpvxtFYXJ4awpuC3XJWGidsbaN6-dtY2GvEMU9V42gN9XR0HEHjn99jHHtxIyj07HlDq7M78on7Wqp6zZPX0ktNyq2uctmi2F67NzG072tpjxPyJ33iGa655hxlXPQAMi6nsyizbscOchpw7kg7lZd7Ey44B4f2tsMe9g1G4amKx7fB4L25zo4qtMnLNCeXy5iMSJX5yc7B_-vn67tNAva9QVm-AHys9JIabG8lmgZhqGPS_D93C9nb5szg0ZhZryiqmZdjBWaW68s2pkY7Y22KUfVdzjZdtW_E6rCOKSBx1QvUcu-Ty7m50JuOQ1Jgcb1Q7jvjeTYQD6hNX2KMOQ3XqxgvYX8xukfTujW5MUuSQrZxdmkX7fdmNSdIeBYglVR8pX8iZJdXOxzXPfD0XskkVRRN7MtEZyxTV8LgpAJxbbSF7sPfquLDR7IvfHFDW_TGS_7lSDUSjfK0W99TKbj7pYAwdJpmrS_olt0JIJXJ8P_UqPNY0y8EnUsv3bzFw7IzqjdHljqamFxRMKMW1wXMKaPbaMeeSnEnxxK2PwbYzBBSt1lRNvYqDn7Ows0z5sxCoOA2nPlKDIxao9o5SW2mtxYv_xfGtk-HmNtJBxj91hPzZw8dirso8txN0ckT425JHT8Kx0nbBnTuOHsn5bCAALk3MvbSeJT6RkRUjOhZwDVzoBbK-eNFUhoaVKIncyQHDj_A5pmRkivO4JAKBc4W1F7W8tucnKtditIrPn2HFoY6Mccz9-SNnON8firt02HgtTG8atNWqdKQ2VIeXOeLi1GWVzj_UPQs0So4B6AlcDe2nig0u_GyJyWglfVX8s7ZVHlNiW9PzlQRWyU0CfBOyr8TD_VOarEnFASJ0zg2XEeWivCIyeNE4spu-sc9uU7-YtAcI0kzRsKfdEVN5QopuMp-hQ0dHjhAjjPmMJ94An4QsVViTvXQ29oCBiR5IftT1v5EtEU35upcreOmeKcRo517n-SbzNYdFIMKhS1TIdQ5NeSws3PkF4D-LfKBX2ebZARqF7Bksp4pY7mwP37KxQu1lYY5-ryP8fKo7R8Vd0YXNzo8JeznVzsFDI2j4bftp8vbAottd2iei9p4MGCetp5zv_Jnq4VnU1DDE6OCV6xwA0uvaFD5MJWTX7VyiRPfE6BnoU9nk8qyE2_olqKziRsmMTjUehjKHQ7lVZgU-jNc87xJYnFTt6KUbib6sVW750h1CfwMOeGqEeT82q6DFGeQsjl5DIKL1e3ZuCUOAjKX8CMXiYm7dlynBxKJPHm9TjP7xYCQgRWCIeeR2NLWAmUWtBgnRYoypQfkcegYbOEiRJpFznQ0RyzhnfR4yUsQWaR5VUAsC8F6bWGmIHZcqBufsmXppeGOTCld9PJ_LwmL8hIS5_BLtQbESvRJcmrF5SnGQQ8rHiFCiBhEEDh67_Hf-_Ib7atoT6yfV8HX2ft4sUGPCIwpCcK5M-JC-43yoBxcw-Rjy3PUo9wm3CYFb4zuaiY90BQUnK_Dq9guLxRqtTGX_7j7g6WzjNmifITa9h46wU0tUMQwQGl0d5IkmEQnWwOL9XR283-y3Bww20CUvYtngvPXnnk97L0kTrKjhjT4ZQSqBtjdMx2KBF0vmY2W8KEe2jgsf64KczsoeyVsrZ-8jkldMWf5QJ7MZI4t_qqO8MceWpJQk14hadD_Qk0C4DXz0El9YXuZkapI9SRrroWmFzRVr1G7ORR3P50W2yTG52XEsEvq08vAs8datlur4sLqb6s2DNozDjGB7V7pdl9_mNAjHml7_DJPP3YqNFtl4VtveFp8PevyNn77rWR9UThxEqbhgJYZF9ym-ZuCcDpZ8bujQvNfJnauX1ZDfpfvx5SXqjkBkRZOFIjWpyx7PnOSzW5sQ_PYMWhX_TxWjw22gVplPWAyRfepVYb8ju3Ih3W5trmkU2I7JGrTQ3r0vVRS5YoDgzq0MAY8PpoTlWFsZOgSx-JeoaqUeQ6x4zX6Bf8ker3A5JroE5w-fxh5euvlvCBtVs_geJoWYVgMhwjnMLptaBX1RbUA5XK5tRnG-XLoSRfpXaN-IgoSxh3TfEq23mppFicrlH4lESIdDmZvddzZsjitQ9uf2EL-1uf4JcQDRRLXQmMH-wBEiWpldY8Nsnstdctgi3Go33SL4Dty9xgopgk2iZwI2bz1z00mTszJ8iqznC3we-0PsarVaFhnaMQX-2WYoVn2-sevYAgGXBkgCEAIkYDH97EOWcWdWRKDJExpl1gAt-nrkQYmMDmfRDShdMAuNT5603VBlIIMxpXwU2_8EGUDNMFyD06khj0hok2TGhxxXVx1DNySbMqzaXivi_nhJ5gr22c6ZivOTeaKZF9qMHWB7zc7DFvfEeNmRFAlj2-UYUldU7NfAf7chbWXz9QzffptIcOmOc5EuWUd6oYi9xMb2UuumqfkpFrsGNi-thzLZAo0U0g8FRjvyxdLstClGiLXlwpyf_RrSaycGEQjwskXQM79ewkffN1JuTjHm34ycjqQeeQucDu8urxf1HQ6dL5rZ6tyl2jOOLbf1gWkVoIvwGXBT0SK_uzFh_AuGJPr25_C6yY_GMJ4tXxSnWbFY_orkkj8dy0KoUYcY7XjH5j_d6PzD0kVem0ZxINrZWtEzFzjBooIYjEJX2AS6P-bQZthShD5JceByEZP39n1zCDiXzcbl3lAyi9lkhB3Huf1idnmPLpjNFZUeqZJcsudAtVS8JANqJrsGLo6oaKNI2F8IFnlZeDgWvIoFQKUYIIjZPhjItB16U_NrisWiPYR2lIVnAG8Ko_YyiWpJr-bGwlxPm_yrS7pfExIaW_4Ym6nRk5lxtcDc29-JslTMGmG_SFI4w4AkipZO_biwUahXuVrK8SMZushftX2ItL7lX2pa5pNd_kE3jN6Fp7VA45Yp9xZJ7gjG72aam1FH-ZY7aNUv-t_P3OqhuGHCJAg0OsmFlzXP-6PB4nf5ipEX_8Rfp8qmMgKUMYtgxb_iXoSB4X4rWsiFvbNiEuX9z_wOoz227PJlwXIhgXYUiHHaqv48E3UorGOREXnrPWh3UbhyLrRxXpcVHme96SnlnsyyKKixl52eR8gg9pQe04XDjDoKT3nUP3LhXo_SC6zJjtgWj-dzqsAXtc_N-Rw0fRaE7nplmFdyYGi1PwRjTDXB3w3XVokFx57mAwTOeY5Eo4J8--WmRLj6F7_AsnlrGOrUjWwt09wNcDJIDDQHcs9nw5MXQHvnky0QUxTe_TZNcCbLA9V6-nr2q_0nQ-MEYp0xMk7SpoGkOJ8dEDlML2IJGpihxj88OE8TG2uP9n6-30mbI7Xk4n4uIxQk1EJfa6nnXYs2qRq7ESJkFowzfTdBFbUVIvR_GxbniE_mn4tYC4eCDYgfk0Rlyh7gQxBe5v8fO79bX2VbMU4ZZJ9mg8Pu-yqdEFwLnskqDfSGMJ35bzPonz3gqSWi3L4yikWjwOp_vzKy-FpRRKNIQyGtLvkqv19slWRtwFXlA_Myj0LeV6r7POtxejCGVn_lYEBaL4-wtXN-3QFKOtOCx7JUXb1tBm8hLw-uQ8hNeSPbMOGnNHMV6rrBe-K666GN2eaIWFSakbx93LHlm1WByRM9-jf40TdTM3ETCAX1GA7DUFnrwiv4XH-MJDg6xCnog7VUBNZesEnIgfLelYCrvCpqDdCH1dtvdjY-8MoTj4TUPFyeg25H_AA0u6BtK-T-i1lsA-gJ1fiycur7Ohvi8q3_AelisFMvAwHI3s2Q3eiPJzetkL4JJ01Sfp7I6GcZQ32BN6ACV57u5fXijCwQ6DekNnVJqOq6XjPpiAWvlaB7d7qbYzCWN0oCmkmK-m_m4ZtwrNQpCpUYCLdnGBzKdHsUTr7d7H4-amCQ1cnBf2At8tsflhfs8qebxs-XIDhYjinoXdfV7AikKuq05pkBzSZDSI6rJoU1oWhGC2DmKXb58ZlIn_HsN2idx9JnTlVAOjIyzyOz-Cp__x1bwlfLivJxKP7Sk7vSRorvirgKxzYpH4rXibF3w6jhQOv0rKBUEtDNijfMigo_5MMWhN1EKULSSdY0ybjR7rJTIlD8Szys7lhSA1DjjRDuUkixviPJxBjlf61hUBreizWrt7Trh3s-XVbrNqDg12CJshNSTlcgHnEjI-Iz-A_kNTX68PUbViASa3htUIQ-dNrJ6qONxm1OxeHJ_j1H_duNfYaAGB7hSJPtbl4Er1uMywoXc-SNFmxEC3kA63HNY7h_Xjzbx1f2BBar94whyeuLM2fN24Xm02E5QRrlkMTGhep69E_zrIOqTCSLFx-2XWvBPgUo_farZGzabNyDxlvWvffxitREfFED_s0L8dtrlD5rtA8wKv5cPxUbRxz75C4BpYb5Q-ICSOIJes4MHKwidZ8r32Duqm1CfxWfpkOoyWvOCJRi8A6RooRysyZ5GTwlG5Hg0FGZpxR4xnZBZ6GfYCzTsIHe8LPp8EJ8PMobLFszX0vvbxhF-mtE2d0SZ5JW7EiMyrfkkunriCq7SE895KNqRBprN_3Ho4vzcx05y-O66h43V0jy05p4fYrk1AO1ZwTS0djkj66pWFcMTlwky7EW6YlpUpcshixQBAMnJB6C7Nea_z_yx408JHzhffzG1Pw8iPeIBSI2018JwQzK-Js9duH3FIaw32VU9OyWSK4Q0kHe0qzrBNPC_q0BKPcbY71X8psK_8rryImdFkaxADLxOtut87IVj4Twcw3dBt3MgFTUp49Kzw6nOd6JogiUsfu1PA52OjMml2DZ0KMzPiNsaIiosgSYwPBrR4Fjz0_v_qiRWQK_lWY-uCiAOFZ5yL7dzool42y03Rbo-LmUOZVS4-TdyCibuHfuhFgWwjLjcsbEK-z6NiMKSYvpxs06WurY24tkToeVHwD1S1Vd4VjNHb0Pr659xkusRUmRPDdtgZQC21OuEy8bdjpQkuslr9TqimqEX4XsjoHbEk1ew8gjYmUxR3o4m2OgF7QIY-tp6oiUwpjilYnWjHc7grJlQMCfwWJ-t1Eh8JAHHc93HMn6rRIecnxzHmkPXh624rolmTPorK6GiTdZrr2FSyXoPPmhFcRcTQbcS4X90zIaIpLDuzdK5CuiYaIrnXAnDRcwlFl70N5Lk-AP6iQD_0EHgOARydgruvIum2oSydndCYQQpLEsisn9_KZCKGmmhhJkL5cjG3wjOAIWR5jP7naQp4UEXdEWlmh1b5mdHBNNhzQ7OBmphJ1Otd8iO53M-Ygcqa0qt5XSEx9WX604VM9v1_ahbVTleXabP3uZJGUTqiTkepVF_vtvW6_x7kZmx3Yurepgb3i9MYJSWy1lFwuwPZZtjW435o3isAsWcPQHdk4HMv1NuU2jTMcPgS2qqRHi1fTksB2E4atR8KF1hYkmAtX0nmm1yT3fulRYmhsRBcQIVmNpLVcqTz5lfDrejHQZwxhM5YO-BsaBCqJxgcSGiASopoJqfyVqlK5VhRSnW36Fubl_4qIz8QLZGnXfWg4IpgtMjLDRLPexooa2mHWGyNpnePIdH0kNfY1ryXZ6tFlrDutCuKEvZVa9YGC6WOqRVL4Wd1KUyl5Thgp-m0ge2LWwUP3Kjgy3UbJvcR0ATl7H7WAtHoRjUaFNZJKJkfRebfMw7GtndG9nr9umJDa-SVxPtgkcqUxZdt62n7Tz6QPPusBBf60J2Fl_KzuMOD40fDuOLU2ZGXfoOHMQwYyTcaY9t8f7Nt3fKiW2tq9zQEzbmdS98HvjuiGXCQn1uB3dRHY3Xf1BIQpXp79hEHD2HupiuXaW0uOYyX-a8zNCJ6eobf4bBdAhz1JLJ5lan1ggQDOBo1OK2HQV8VSCmV3s6KYLn8vl_zz5CvF8o3nI7PkyrHlYAFvdxw4AZKJF81tIiZVmNy7i4wzjugY1GdHTrWampVuiYovjV4QAXiMnQxDHZLJYtYZGhXaCnrDZOCo1xERRrcUWibawnnCS5SJOdJDCt_ULtdEnGr4n8hMhyL3EG2tezQgAXmIXMcMbGi9iQXWiyfOjoVC1XFCSOeyM0PsLcftgQ1QhN37ZMRGrG5LgomSLw5yQE6hoYgfQ0KvuZcR6d07S8Pw9IuiHznrRSGXJ_tcUflGvkHV-OlQDZB4Fv3LwxXGYvIhBRmF5C5V-lpAAFkGFg17osliFwHD6fcy07obYbVGmYJE3demRcdKiCH-II2zr6gwGxZOcC3ZCuqQJuKuCAO9tOE-MRXFbgHjPydh3ldt8yNEvc5IkkoJxVylEdO0B_vmLtMqF7GDetLGKHT8jz4EOIYheGvzUicetIY9N0JH4-o2RdRVj4_c1khkj5bTyNGDGgVcRAl-5aTRX5Etnj0lLTzWNng0lqLRkeLnsn2YtRj0oA9lhVBNCwoT8d05WRPkOMryF4LvhKZ3o9EbtMOYnesHN5jSLGj28Nocv8WnxIn5B5OOc4rCzWPhDxPo9EpTrXCL6Hl5ts22uMPQP5wx2q9-d47ZNdeEO9PsHRBtST_xC_emH15a7CWJBuhSey_7Ep_QIhw96eANxDrBcV0BXpMSLfyd3Ucy-aLbngk50W8dAj7f4XsSAaTOX_amW87edCBBT5rb3bBa22cwrp0azv8FPGDdMw1n5v55rzFJa4iXJYImK76vKyxemcWx-X2kPjcd8Pob3C9HA_Ms4obZeu9SUq7wCUY1noTTypHhEcdklm3IBfUtnc2NHlTFzDZFR2IMXvI80jxtVaVMOBW2fNmvmyow5X-WS-ZGIM5y2okxYuSA24TIPtaA0EE6UicaGBK4I-ijV-994HtuqewrQkPGUB1U4mISGL9Uk-tCIss_driH8auTqshPW2XvpJPVEusIVz97t7KXCoBymKtV8Ymvldx5SRjvRkuFw9uvtCYvU0on2bNv2gJd8OZa6mS5ceQB0zh04B29GVWUhzcB5pazCuoC1jBHHefmMo1zDmYMpeMED6TsOcU8F1H3QH2THvllvMwwMtsIM2HHn18wVVFj3rwqY1dqDHTDgBC-Pg35N8ou2gjtsosCeD-0MpnsM_fbJ69Udh4Gp5nfDrZsrY2F2-y8fTHQA8wJ_xsN6_RxCMV4-om_NWYCTzx5eOT-U3rx2TKlDD1Z2aPs10A9WRGJf2gvsVmEzn7HKa4BMMCIXCqOdec5GDnjm8UCQRP4Bn67JY15vo2Kldh7rl72vADhqxBP9YKOGoe1F9DZV1zH71SIuI2UiUSj7jgjBZ9DIC3aUodchJxAFCOAJLyDdDCyCxC0is1Ri_evjwtwIYDsJCJl-wAVJbQT7lfo_IyEC4x47LQSp1TsXMOqkFtXaEyozlupNMZI_3KoJH3NZBhXOmQatvtXUvFWaaT4R8MUi3qNWNdPP_-ExQk5nO5oxmxDCaQ3T5WBBRbUWwVDRYGmtHhs5qszcq_rkN5p1_gum3J31-lsqsiL0Ptu2Z4SNEhEkmaMzOynClyg_ujCyZUtw6OLgS4QuLxA7RUTBDPIuojJECWg4E89cVXR_bxmUqI0fevI9uksA7Hu1r-uC4FF7sAeDG3WAL8qRQAhBQJDvV1saeOtY9OK-mvkX1wlHH1XNsdrTgyE0KSeTBClQPVhXalh3Jlt0dtbf0FIYgoA6o23rwBrzvOwl_xxSaFlAqB-XwDfhFRWJk1IiDnMRG7VA4bFpmqKdCwrdsUTwxdOD_pRJKomXZ2214MQrTsVhbEYjBohzR-2oWgXFrV7vQyudDzimnUy07ddC6bjXxrj3_fYwPUtqjDLf6QvhltXQ8FfIyz25Zbz8ID7grtKj0OGi2y4ChSXTBeeiqc73mxA5uaDZaUj_TKlgcxJuA437vVLRiLeh1IQ_nvua0YwSBWoNDkzUwyy1wKeWteCRiPS0vcpuHFR3A2jD2fjBuawkZF7MmRK4Q5eO39gbGnbiDoyWZnxfsiYVqNX38suakrVXFznZkpDrS_qVpPnPA96HW33iZYn_R1jKDcnGevQRpWTifNN9G8aHImRkjb-AO_PnTx1dng5__tvkPT0oJhmtUjeKRHO_zJXAlx3foR7Hmr6B_qj4kWaLRzNP3gN-YIJVKM6HnDw-yJqx6j97ayOvdQjfJcX5YhZ_6KiIf-SftCc8P7m_xQDRMgN-T82wvkH34A-TgyY75KRepmIRFcEbgNPMEufKdt5wXRr8r0g8VKDup8vwtJIJFupgoayeoN5HsC5O3xoIfU58ooYu0s3jKWY9rLqe8R0hTCYCHuDUES2g5o-QPJp8UsD4Zo726Ycjlx2ve3chik4gMI252DXg-le1-h11c_h7hq0mVO0H-KBo7eFL76TgQg9iHU7cj4U5ht6QZJ0pDQ8lpZVZlXQBGewaLfmvcpFFQJ50c_jHzeSldrtmRemGSk0l0rKbb6a-e7Sz5vsFdhaQepyHDLTXIrMi1HqktHBLN1iW4ktObsnvibGoXAQqGxioMToOklM_ZGqaoH7_65OGOVyy8rTBwa-EzLg14qoqy1jwxGdw-GAzzR3v1bL_XnkU6V9Bb06Ro6ciGnUDxOiuW0QdvZuoyAA4I3ZYkxsOUw-yjuYfxpnrK9cYkJhLeP5ZDCWXkuFwOGzNDCuFuS85BWf5hy4N8YdwKNsWDI9ghcoYntvOirHXpHJ12RHTFdIC5YbJDfK0mj6ATrnKyWjGRaAxnofzFXoctBchtDYCvyQ_97yoOlKDw2hdPakrufWajcBRRNwcaBVOk-b2wcStXB-qJFAZiXUgGuL17HG_hmXHofjRAhT7CdNKjxgNpLwdelejXcgpwkDtEJ0_EZ61CjTMquF15L_I6JhJwmyak3FzCoYrcpCOjrAGqlZtUN9LEd8k96PVBmt8KM-ue2Y1rzA-bZKZyVwugb9I4Z8mi8GluMLcsME6eltWHoqnn4QudllLCbV2igivKy_xHUd2jToU6RUvIPjZvqSW4pl137pDmkCLptQgeVoA6fTUH7yW3zoQIQztSsj2OlFxwjD7Heikox2LeVqz2wXGf2fY1MkBbAiyPqB6gbV-ljp665Mn0kEy1OpgW33ZNG36T1hHDpeaLvpHbgjX1ZO1CEz0LBMU5uBFMYLLOVZytM4coiLhlQJneQmK4EpPtrcaObNS01-dD9shEOXlpQdtkYAOl5U283O0q0ZQzpro6IGhcbIgGRrcnT_HeL8OvGTuiSJJ6b7eBLb0h1HKKbq3MpWbZdfv1RbS77X5ZyVAMfzY2xnTzlEILXxTOIUEFGR1P1S8FH2a90nq63YqAF0JP-FqHG_JU7OCnanPeAcknPmtObhaZabCrkxVpCcTQO-glVdWUih75mItume27wzQKbBO7TKjNHMr3944XAVhkW4eqb1MLI4u5nxUvwks0rScZ4X3b5C8Xj5z6X8j64_PsM3KGMBNWmZBDhJsruzu1mQpXyrj9qqhbbTA4qeeOF9zGJ730KVqdTnYD8pmyUOclf3LeKjrKTsc88Q156UEhpMe9sC4u6ijc1Z-1Jtdun9yjDnRdVmsKv48Tep0-BY1lxVS-76ZjtA9GP-w2bbLCquox7ZKXkeSuSvMLEaGElzhOMs7AEtWnVl2_d3V0nfDB9duVLWgYM-ipz2rn1_vEpgvWkNUUpUHxJ5gTqLjqKhrd6L8lPqxsUSWjVen8QhiZMvqPWf48gTjQpydgpd4cgrWMl1PligB3Bgv-LIJxUndaaFnUFHGP-5yYFq298fJc19b5zefq9S0pozQnviVDeyUtMjcRD-rUlxuCRw4nBoiggNgtknRzV-rsOi9yu9gqVzWLo8KqEXZ3t-3u2ngAU-8YSUT68ond71mB7MJ_4pBi0Xq1pT1wFA-uQWjUFtTuzx1y06m9JSkskMt5qHFqP4G3IRMm-agpuqNJOvLB2KC3wcrx7KUGt7Vi85V8XGZO43qbb4BWNArDljdFjMKYnEwYGjlRR8PJpDK_8xnOnBJsytkqxweDYlrejLI0s6ZnzC4-zvsc36u7dxkWe0KnWkAJmjRnsls1egc5VnLo-pkMQkP-gjIRqHZXo_78af1L63iIjQ49ybXZvdIie1oSsMp_waQTqsnKADIu2nsLNhZ3jaz4FNFLY1RZAHee9crz5tn1bjPozCHNWDL0oo6HJVB-O93Vm1jTx347DrmPnMsNPThDbnRRo7K2UbPkEJ5idKTqjteyJ_H0Ubw-OiUHaXXUqBOj0dgchIZw1N-0XriEaTGtC_5rECuJTpBVeMdjZfc49ePxXdLg1Z616olRFTefWuTvn8zE5MKQ31D570kfjRlPwOwxzNaMEqBpmfVHRwMu4pUKWOIUognx4PRsbimrFN7GcICCg__-gSCvPXr1XNJFlN_Nfr7L-dV4YlKKsbmuaxu_oGS_x6gqpXfyJ0C9-5sl--bKgZChF40SgpebpANOpFs2dmNYHpzCIcGL0OpVe6o2ellhMYtTYDRgBxouO3uY8AajrinBWIIPvASAb0WyKuymIXDClyQ6huynMFS9MrNR3rfc0qMVmXgvfuxY9wpVkqYdVcC-rzfUeswOJCDgBiGmxrW-gmz-Xpgfsvtvj2ZO8h4AccVPBmMrB_zROgrFLSjYRs-OMHh3ahqrq-u2-8JWPomEx_JyYHyvtTjemWqMlx5EVJlx8TQtb0zzex1lStU0tHVe1s8JWrRkn80l8_OD4j11mL6M9nr64n3VknvqRO3hCtvO5A9XQtEnh2zbOOeKKQ2_oY-mSDywZajyU6aTnUVKk19uJJOAT57SedgHr4-hjT5xVqdCdr0x7URorhKKdRyCgQqXDjaMt5fmgjeH6jFcYl9boxZPUK38U8hL1H-AzxrcbJ9qLCLuEfn1E6BgfrUuszC8VvW3qq94HCGTc8cMC5GM2Z0gcRAOl6Um6kTIhIMDL7Uf_AtldsgwiXMbbukcFJH3cCNK7QEMrQRlCHVpklDN9CrYwuj1XXuANhLZg73pubCyj1xq4yd5-eDFioJj5yDHKmd-Dj8eLMAT58stHsKZHgKc7uP5O1qn5HIlSVxKvnRdegrsKj21FSSp73IjpTVbCO3x_iyQka7GdiWs5YQUWSNThYQ7ao6HMDeGmZPLxjyuDcSoA6m3QyE88dk-9-BCpx9M9fADzDbO1UdnmhQe-DLlNkI8gs3uD8J6jVgEW3W-0_JlYHra2xOYqZW1wY7pP-mCeZva1ovvBIwIQAIVxjdFkPkPg_ewGJ8BDFNpVScZdJXpxROyvOvYvlY2C2ScuS4IJ7CQyQ-VM6IScSORFkm_USvkTYvCSbAGs7ucKwISdnPYhOlvD90O1SZ_3OELrTS3-HJ9BZ-U0Kp1aDzCYX_Tzt67x_DrVt6N7RJMVjmyw3g24lR1x17-AW9UYpnGgHR7X-J_NZBteajF4DRYZ3VQKUtuiSxwr4XVlMpjEILJeQm2fa6jBNOTSSFV3L9C0JdFoQSS5t5atuoXmroIpbLz5z4eyITBZlMA447Q66gBs5JaU2BQ25uSNKceksf0Q4WTcra2Fg_uVsXbt3FthXSNJ9ZXjHWtZk4NvorsfrC7KcQncKlaE6pczqWfEZmrRLJ3kkE3BD7EVRWPHIwBYScV-6K9rgEenNnt-P5zZBqdySJ-r-XTopffmRlJviOQ5d5jb-f6vjoHoGKemfzPllinreUblCdH06tBQ9bLSINOWy4OD1bRrYNnvb4aYUS2-0vpLaNatAvLy26gI6enJgO7WkVkHdfNIR9wBRnIuWBLu2YQq1F1X-r6T4u3qREvCTqCKbUtXumuvGnsE8-7L4ySZmxjaic-nvLvltNUWWG2v_K4Swmgq7AD2w947owWwzBizTtJYsL3rTbTkozDuczlcxW27HFUVRneB9wf5_rcab5XI2l8unBnt3eKZuVAJpmi47VMy2xYPiQckA_EU0Q5CtIpFTB5ZdSdabBzB73mmDxmsagpZxebZdmOTSaJSIoyJe_2XHJsgqkonczb82aIs-Ui6neoTuPMHhWEiid6ULyQx6J_RrjUNsw0bzSukewk1gy1lEi0FrYar-eD3koRRvhrXvTYkCQe0AYo1CoH1iU5vBDp8X_tSo8bHGk9Db4HFWvv5k2Tn1tK-gWnYeGyMoSZckpImME3a903IqK77XP3QFikDcU_SfEoRqTJkGl0sz6ZXtpMReBrwr0mQIGrQFrcKUF7HExPAnyXK2vJM8Lxpi0qqSP9-h50P2v2dhR5dVMThDLrxD3r03-oDND1P0qXeXHQ7hSgX5TAGGBBmd2ZgBL0AukvdQMn4VtOcAcQHAu0FO2AWpXrcxodUo8BJVOtJ7F_JMMX7vhWXVQDPcSnvfkFw8KOuIk5pA-Rtc7WZzeIRMWxzV0fwBMPWk8ZVqEtLKdz-SO84URh8s06LoUcBpnh57U2pr8v8cvHTWzRLDfAhFDbiHu3FcDPYqN_cG4PiYPXW85hPoc_ykBDn1lzU8jdMSHPFZyw9LhIHOrN-vlnjZgWHNzeIKyGNjCj4n4m850hG7CQhgfHGjyTqxCmveX3OBi5u4oXqeLpiJJvujpLrPtaD1DZp8sJjd3DN2Mx0Ty5VQJ7BykRvEmayojF-knCbAZpVC9tt_2MwdZE5QqJDWYX8-xLQpIwmtyIGpzPW_-nf-zi2rA3I0C3h9dgRHFowyQaNrQBYKysp9Dx04T9Wy4SqwaqV7rJKAp4lnRa2slSEY8qpeD4ZXT-PlI1BSYdFb9ez2UOMKfobP03dXHjmddTQL0IQSYRREdT-1wXESDNCF9vg7q0X08jB82JxWzKr17cqfoHrc_51XIS-bjzu1x_44sO6asiqWPfV_XhhWs2jInmGyqsbOLWEDvajLuF5uMFgKFdN5qIqfpHeP1lrUi-psAiEjALKn-RNo20m4RDq1qJsg1ZET1Hw8G84xCOP0k5KF-bRm4O1uVtoPi1hHNjbtsu53PMt9RVC4CL-QvKvAp1xKN1PZYbxUFasjnzrCZXTVsE_OmhRj24JbOvlwdn4CYgUk682ZPm5h5IIvamXzyVDLMEG0a9uOcvyCoz3zDvN9ruGTaBm2zAy13Bc-R1tuQzETVXbLp_euZ_UskJbFIUCd_WAwPDbxJoPDkZIX6nuZomj3knqyLHMifPeetF_dW_qg9SPdi6-AIjSvdr_PmCZo6J635v5_K-Po_GNk6n-wosG1k0ng_rGkQe3510IbLhzZlHyudxDYRQND3vJxfyuuX8pQca416INDGjjYsFfbmW_Vrcy4ZcOtbRA_47YOKQANWknTTJkYMBNdqoHd3vBjZE-Q-rqFceAJfKTwpTzWFlPNlJQNfBO7OdsZuz-LOtTiAudE3L8yEHsAprSE_3UHHuQJj72PNhz8MLi7UGQWeM9svANU5oe9B9VmsXHZ5zvg9qjHnnnPDoUz7TL26khGkgT57daM1O7Bopvs8z2VtAZgxgXoNEgEkRn39MPOAFXTcts5BlOP53-TJ_ooOO51qCjBqCk2JFvROmtRG6AhyC796LGJzKD8vv1UrSajmgZSWji5VCfcU3KICRCRLoGn74VBMSNu0oO2zXOsNITdkmCPOMYelk_pVls4A9-7zbQk_X34Pnk8X-JS5e81Q_b3Azx_oh-ZAU66Ed94fhnI5Ev7NRssgBJxCZRMd6B78EvVkq6LQJm2FM3v20kDySbSkWfT3axHoaR4S-_S_Qy3yD4aQuCI8Vo6ytHECMJFlZdppXfuyoMQsFSIjcrGbo8XYDOL4WJOw0JdPNIlcp0DvPhjlVMU_3aBLgRG7jRgp_XNMifasdkKK-k4_T_1UW1UPvpEbnSYRykGzVdbM1wDadOSGSbFZNaTsbxnnQbCX3M3GNasEKdJSuuMtYAm5arZ1TBzSIzTwkNxxAf0OssR3H16moS1KzkumOmr6M6UIaidZ1ZW_a9jfC6jjAEYpDUleVdGNyfy1i8I1dodlNb-AEDXjukKsy79hpHLnCcg-k_tPoTvf0QKzFZga-7AQZNaTEYLbEwQRPI0nrOYZEESx-9ttLv6q6PNrJG1-QuZXsJVnldpaEAREUAgXN1Hf7DWaDID8XrzMZuXGG0-Ay4VnVt_0enFU2zTkcDvm9k0x9CbcAKdDmZvNwPRV7mGVsY2FnP5kHeA32qc2O-zz2OdiXjU6ubSZDU87a-o8MBbNaaUkOZp4BLQ4t3wa4BUmyhgJM72cebIe6osMvtANXU8lr5XJs9AWgwqsvE8Q3wY0WiZ_8-qZQLVEs5Flv8QiesRWH1OZ8zktHK_v7K_2Ewbb1erANre2T44NkyJ_MTcIe8kMOnIMx_T_nSQ2Mw3YKimaaqsXJf4uqVPQjBWtFHSeP6J6-D9oKY-pJ9_jZ8I7jqBF9AuqwAEpsfkzv-U3dX_zfd8M1v3ICEZbvz_FvtGQoUL2-kOmqwLTu445m5eCSQ88ImuupNDbp0aEl5z1767M_6810i1lUTHbT4qmP3nzun-5XEuza6VmU7zk_h0m9NWNY6_-2jHXXsPiwquzfIo_73JHg2OMWz1wToVB-ouc8ihcfIOwgj0nhEB9rLCG1ACd7O_YDn0njKVW-adVw_DRgSmpWPzC2x8RdMSsm0JJ8MUZTXP722HNw-DQcUFhqmHT7rK67kaS4zr7m9a8Ozk4A-jBEP0oWTynbU4r47COvXxB_dd_FbRYh_t4UXAZDTpVB4U02jNeLCrs17olOaz6K5VAXkeTxf3rnSFkbWABE8yT2z9n60G9dPYQS9D3avPE1O2qRyyLpCK6WcX21C5icdeEd0SQ_7l8uGhVMXylDc7FbNg19BaT9Bm-wACI5dc66b1jUcehwXazFP0YH-wNfq4aCBEhSCp52nCHQWuEmp-tZehqF5mWz9Yz_EgjjfXCqxUAPgPBKJMHb_p91rv-UrX-YYIS1RyDXHjYqsFeBJVA_HP3CGS7mqFQDxUCuKAxqGypILiRmMCdIVUetdm-6xEA-zJuuzOyk9WD1N4Xzlk9y3u8HRH046aX_FT52wSvIW4MNnEr5_JhI0bDxdJlRv2zaDaNwZIvxUiVkCeC4hqGqD0jIk67OStHlWEmoIHOsJctnSvXdBDY1gw-aHdm3STRxA0yQapNzXWZfDyMU_Z3_7WXlporQfApOqx-Sk8paT5T0YEEj1NM9LVjG63S6zxGhYi0TiM5gPQBLsT1FhNxUZkVHLFpYeAur8IcMXq-3iTUtw_uUsrTLdt7BGOAVbwI-SD5KhE0gNA5i0Uz-JjMTDAaFD5x_i8eN02FHWWShg3ORhm83Fw2JLXWBjRdrBzKA1h60fsSyRBApyrk5h91bnabs0yHqj4ECO5tVc02rpysDmXaueX4djYiVXw9ykUtx4eBvAVm67j1zoF2EzZNFMOXXM_deD1XBPYk-XXkVsZmjTbeWKfuLYfKoL4CUi-Fv9y_C8VC0eBibCHaLkLe9LaVHH2dPMm4Mzw7qc3lEfxxXWDnSYe1flOtwhKsMng4f_wmxPBkk1BbO4vVrbP2V3sWTTgPH0jWXFsdmXsuQrJOMbXNJfVhgpP0HZB-69tQ-gA4sqJFeltKRCTCsJBAU2XT1pGVJmFpigRWRCD-9i3mJyxNFztnwXWRouce70EaLmOXC0JFF9DyhEKTUgIbZ1LTHeaQNXk3Gue8B0I4Yx7HJo297GZX8D2hNy4330n4B_5pf1C9oghDIqqKmi19rL-jKpvj6tfWkhmmJw4fnjhmUVkItAcJnzICNeQZgA0AJ1VGcy42Err1tDL_IFXrPtH1XeWzmsVPgGokVG2WyYyKWShgSWtCgZG-s6QXaxK8Dr8ErLAeiQfosuxVdqX1-vOt7HDuxBRieMwmumEKP89clun9jhQu6Qh-j-x1Wfu7Qx_nRxbgledu6CDQiYHR-p7_IMGdOxYosHum3Y_ky-YVi93MtLmvv2ZxRjxlbycl9u3ejlIi_cszjl1wjQ-dL74lU9gwquipl3PaEkHI4wDCCeJJ3msJ1VE9SlRivgrUwGBvsShss6r59Jmbk4ElW94o9FyDRCX4sbGfjVB1EXir1joqBuX1_dZHWw3lBYNwB7UqtC5_GEKTBmVV5LysNbCQGAUjFWUJzLTbqTYBOfeGdr2NZYszMtP24LNi4Fu-EpV6eq-E9V2woUY6mDTRZNZ3zpSakdikdSOtXdSTLULtpzx_EOTqJjq5wgIUsMBdooDM22-pc0p2Mol_ZS1Q9pS7vSEE-0Y94vBlasfnCK8-yoDWJDAEcy5Jk5MRy7A5OZTdxcU1yW18svvq9TH8mNtqzMVVStcVhId2cV_GvukyV-xBgkAMfnKrgqaNh5iUyuiQ-TIPwZokuExAirO1FlXiHJuZ8Y984Vx1CvykD-fNo1iihSRQyNqkZIH4Xg5rCEPm6wWT-3J2eS2HKgNlIMohZRhRjC7MYXm_QbnGbdHPAeBfWkzgtfhSAklY_PVYP2IJ55SJyYrvXtsrfPxblY-OL6l8V7W8qy-stA3uX7mpqzmLpqmH5wk6nXItcrxAuzdfmLdjsXXs1h1Vdx1KTSA0lfRvpKeg18bfQ1ndyVLiNbQJLUURH2_hTg2jVcYVNhcfraN7WJu-DpPNy4qWL77HogFvdr-NelD3BB4gc4xCSeUXqXVu7LFFhcIsWQMWwl-83aIdkNmYanci_ilgcbwByK9f_lsp7iojyBbSloO_bh1_tjLsoSkdW231kQNrNcQC2GZLsadqHCYnQmGuw4SNAK5ArB5lVYy6Rinw3GeMAaZoqUgpv6nXRl2BfS5SPsjLMJCdRpvizOZkyrSFJ7zIu-CKhxlxQq9QkbHKKA7LJ1bF4hik_kiS3MUPNelRFTZDbX2IFVgQ_i2nKdQSROg9Qgp0KVGjoL8GM_wN3Es8pTIkdpP0ZPRarrzze8WvtDKqGFYWOci6VMF5QaGavlzUAATIc2MDEQDWXdiCEkLJ1fTTxURKiXERv327czH8LAlBN-ijthQ3_7jfs8iOM4ySwprJEWDMQBu_WPMhX8BHPT-eylR0Job78lWmJjbqLd9R2g3G-7dYFbyIYxRbdUjmckx-E1YE1CydJ4uEHmLl7WfGOHYalxT8ivJL7ibKGL1aiR3PWXcCX5YnQ14MMVkbyDk-GjiPuNJNR98cRS7Svk6fubmt3yy424txaNyXJNGRcxviCIHIMDyC1DrJO1oMBCQW2LRNyA_32YS1uAFnhuD5ZBxfihpHbErfyw7tflV0y_Qz5M9z8jqcWzct8l5i4yZmDGcNFUUGHTh6IaKuujV6YLFJFkLT9i4j7JUY8BvtQp9NdZdL5vZY8ZzfOgBPOs5HSK3FfuaKdroNRNZIwZi_x53-cVxKmZ7i8rH1GQShpEeIaqUL5YVEf44UmHXdumuYfCh3ClpVg1uVMXPAZb35iELYpk3A9aAdVvXybWE0_TlE8FikyoZuFYoa0PU3CgfE6Uv_OstT5n_3QB_0TB5EVcw5WIiCN2eF9JYcDFbsOjlFwF2QJSUQq7Ua2hvsgQHSpxLbGPHaL2B4wg0t4sX0RyCoc7ddluzRHd0G2ljDgAggyFJ_jAis_Iy8g1jIH0EgR7qZ657Czjx5cG-yUqmKbObjTzUIbC_Kwnbsh6tBKK5eTJsrxoGyIUS6PZJ5zXDLfGRtmOX66QOxyvo8E3Tj1rLW6acucmIkgqyoW5c8i1vSHoKVXMm_5mVkQ0N21ANvRYWStwHRmGrcMJXwZEsjZzyyPPzPp7LSP4Wl5ZPlq95ndfe2eKKXNua5JGQmLjpPla2vCwY5oYWjyPq2cPDGPvSBJ4hrLBhLPETj75ra1EFjy_T747Eg0TtgyK8Yid8HKmh_y4vH-ZLCfIgOjexUdDtbb-yq7OJEnW2lnJYInTJFEjyZZKwJ3r2PBKpEROK-wLd-vWkdC6RjeGa31WfKBFUZVWEgpRiCWCuuMn0sbfMGg7c1e0sAZHMIDAWi99T11GjwrVRpT6ZT19XzVZhyUP0CO9xKx0INRlkuJ5AdTSjbZAHAI98Xv1TbLygB74R87RjGS1-Dv1u33D-dAYm7agCNiOQm5NKwYI9EYqg4O0XaU61RSdkSmLtyBf48bZuaDL7qc_T6RN0Q3XDxyoMVpdFhBEuiS289zjssqFOfnxKg3dK1ppXSDTEB_3V4jfpNJypBuVBJOG3QcSLTIlWKuyls6wRDycae3VOMgnpX3xEoP6I_lN58y4dzoNXcJqea4LZDk76I0RKydV2-I5xJOgc52gItV0sMr360GQfAAAtmgC0yp2OLSgEsOlSsqY4UHD_SGB3Xe5_clkSpKswWdcyOqHdFk9WzB1bfc520wFaPt1olpFgygPz6EgrsIZqrRT9KMAZVOtklUbVgt6rmkYcZ-nkNVAk56wUjBgVW0FzDCHvZejdUdsq8LMTkM3DNonxUt_m_9vgUUAPP1Qorzz8J2dXHQkXifPgK1ygn0cPE2yxp_8c_9NpoIIBNE_ovTD6k68OfYfMwT_geVwzQgnbpT1jWjB-CbVA-Al0-QwhnD_u8cPtSOJM3tnvbyROLhK7_QBDw6O5NhfLplHeaxdrc-at6M2X8zgqAQs4PhLZ8NsUQYet4eQiSq3xojFwLLQjAwg57M51peyN4-cntLTBcCChT9ckB2j1pN4hsKWIlpdxMtzgHDfxwDQxn0MkrR75n6ZVRasEc7Kvh7ji3GVRNjl-Qv553hoKjmzjJ5MJFIVVwKMa8Au48RAD99KMu6O63bkXg5Ojro3negpNQ7xQ90vd6i6RXIDyPiBGZnAI9RQdZS0-Ur7sv_GRQc6VgwZjw8yWkkeijlrh3kYoY-vhhHQCFezDYt3SWdJ83G4XCrA0HK2UiQ_bMEg4C7D5bTd_AaI_FPfV0Yh-Jd1i8i60onHjohQQIWHqI3wkiJfi1yM9tEidRu48ExCd93iliPDxm1XHXabHkKFdxRetxNxfsbl_1pIyxn7Hz6R6LtsJb0Kzse-OPuSl970zKV3lSOGaUSyyoWO563_cmm7W1fB29zMi_rGKhK_Xn52GCqjzKnxz0rIxOrD9MTSgZ1PfHbe3CKHLifwYLu7yWWRYdr3OcEtEs3ScloHxpTEfIAbwAf27X3mf_HKiIqfPj_16gKYY7elNVhTTIFYnOqSNlAe6F3uh2BrHLxatvXdsJUimEVgA3iOD9BQef8Ghuk9nwJLHI2AfPHvzIm_t8x3zDcHGRvQfo5p1kEYLmsYf-DZhg5AdqjF7DNw3ne14XoFVIFXFUYcN9bDvkg5IrFwGb-9h63ybG3GwO829ZNSGbqzD0iBjs2hci7dC0fcZzRNOA26KlsS5vIk3PVmseeQFsi6wQdQ_atnxofi3G03FQ-7_qevlLom5251oQkoZLho4i5XtQR3y9cWS1OefU3x7QcBFZAIw0Tje0nGWEcPps7ytA_Tl00C2t8eviklbaNSz8jDcqVeqeUZZha55GE0IAjKMlRFrOlz1rZa90DsWnW1HmRz_aPO3-0dlKN7cvRiC4q0TYZC2flrhfLrts3DWoJwflZD9sfpN0q4KqHTIDROY2fDl4f-8gsvAtaCHxJmmpDQUWyRbGDa8WWdnxwSZKMt_XYMd1O6quLBqAx8Ku3iOythSHo5yHhHAzl3GNxnT0GlU0M3B-21MbMIKD63BbEAfPP4Z_48BlgXHcbHWwBBv5j_AL-DWWosf0zKI4UV07l0jteMnIjJzwtyBKoFgPB0BGHzYjzVU7a_GC3Bfup-nlf41zwj4_dKDE3Hk5WWKlWXmZVXu7Glgz73WNdub_jMtIi8Mnsr-UzYKwEao2YcVn_TgQED-RyUUnIlksEdmAzPSwkq_z-e73jugqNPqOmAlUzK9gDdUQfXHBTtkqoZIt0oKFE0xoi-xKlQV_K_UIQXmBVKlQPLi16HTDyHaKzn-S4bFf-lZkfe-y9JgPCFTI2OsMx3dJL1f58k17a0_p29Dqu13h9lPUIAG6I5wio2RnjQW_P7huZlCDeQXxzMe_2WUS6j1IGb-1vqU7Uq8PGNTmtLttbeky5h8e8dA6UWnBvA7j1WSfJMKMybkVwEGEDOVmJJrx9K6k2lQudp5FC8VEwnthninDy-aTz2pABv5JwtDZIiCAu1ylj-0WHPmuG8vmMh5fWn3NYFYM-zZYmBlsAaaiVz5VK6E-fFZj6wYzkJJ6UPSjsjiZte0A6jOgB_AMMg0cW_De0i2EYbXPAUVvCpTReRy5LZ_T3iA-fp5QaV819ppNWYMnsELbRMffMnQIdYz_E1BEbyjWf9dIOdwaaY0rsyv478mLbbZkSEWwiUBw38Y5SyXCKE-Kts_ftgVtWb-3ycYKoGh_4VIlofk5NjaRlnFTp2syxqQHtgAOSbv-443vr8kY09yA-e9bwPm65_eoDnKMLjeALVgPnuvgC9XMcDJQQF8SPabZpskKIH2CsTYCB1-vVOuSghqEXa4miOlFjLfe5rgEkXA1kY4zlxIWCaCiAMG-tHZNVAbv-BsSTSPKaNayISOdy2rI0mhW_qlcJuRunsl2wsmkYiy-ub60YMQPcz24O3kQ0lznRQLBdAtwYtHuBqBtYfhUpI930XsKWgFOf2tOwuDSYdZqFo2Hc2Dzlh97I1f2uVq-5SCd_CJKR5cjRSBuVkSLKXrR1Q8aXtKJVtQhYiO6IhF4bXl30ST_uum3FR-PAEH7u8lTLfzXrijei7wPcEiHSxbffbhg1g0PVuXyxQHSjtpKmSUNaYmEPnOT_oAgcSFLNWnChZT8FVtTLWnOhTxuUZwyIbE_VkwvCS25ssWV0UNWV3-RbmgSXqDmCHMTXoYH_ig3kgea0t_Nwp2Du60jTI5O_CCrK6yJc-aZL98IZAIz1R-E76Y0KiNv2k3rA0LrldNF8gM0NQ7wWRP74thdZYgBds1fdG6qssvnafQv5sWIBLRpIfnF18k068smokVCQanHEP1aPmgn5I3OHvH2pUB04oHMvkQj5nPgHAMh7SaQDkP025krpo6bkRtguR1z00gorPTF75gl5-W43WNDor5jwZhyWuV_75u2kLral05IETmMC-P5ft04thgxNc2uzPzXvSlCNY4r1SN9uMBCM6CVQnPzL1FM72QVSkx6nPWDPwVtLk9XlpF-Gow1KD23FpWP6cTYhETWCjCf51VKQnaRUC2qZ08ClMAgGF-i-mPYgEEtgTvx1jIVJ_Rki1I9Tetj3wp8QGIGWAuLOnZjvl36t-XR89t8Xka9xl2npX3sfnmkux2xrn3GviUkHcGe7eVIZmED88Gd0_s8J7Y4_KjJQjQlQ9FGik63dCi_lkHmnSJ9NJaIiTnBpxPP6TpsZ55ATT3RZCy-u2ooH5inbEoR5EM7yfdgMgIUG2IrtgDz79rp5p12guWPpxH7Rd8T9LGKHSnzZ4gCJG5QcI0u6qvcWsqCCWAukOTEWhcx3FctYjCMnweWrtCSUpTfJpE8iGw8tMkQt7XHVAh3TsEIu3mjUKCRb_yq_86GMiZMFeFtQZV85c1ugDyA0JT_WtO5Gj2IHpFFeNq4bk7KC_QaDEJARyAYxzKF8NsEXkU-C8j_Bxt4J4FyynIAsNXXEI2kKv5cxB6SOYqeCCcHe8nahlATrZ5PM9hUY407SNMffFynaaWGWD_J3G5RTpYIswio_CUY80MitoEsmXw1HesWwxESy_XsYBGKdr2o2Ad-ymzsIn7iTmG-O72hyoelNdYBjifNPbce4uDoI8AGgKwMfBA9NwPUMoSqfHbybXzoeDaa4xsrlv2T1Yxj4EKY6XiG_PvLWsGuPUR5mYaeCkkHUkr8WA5uXTtg9jONDGh6ro81QfTTFn8xNhsSTDszTYUBbjRlSJXFhytBroDhFM0sk-CcCfGsjRGuu7-qwnYLoZFwwMVAmYd8Wf75jMj2IPMkUrARqVGBuybC_sOueifMu9gQKB0Quxd-8jSYcz3pr7NuwZLPtFmUoa-NUZHLtrSI3ZVTTLSxJGVXdmCGVpf6hnYlqvrSSWFlwZKZ-bJornGSrSTzFnVXog-4CNNFuqm8Bvj27Uw5UGGgd-NWX8-GyNkW3-TcQr4oCC2GztGpDHSFbWSYPZ4LnrJR8xtRnoWpHsti8pP5eWoGy7KvaNhQKfrTOmt_bLS-9OQQFRqQc64PRNdoB7AAMNKaJPAPYQpt7oqJs7vA6dNsqIXjVUgopA8YOUGie-2cbvCcXgA5eE3TSzrRtcnql8E3j7BkWMMrj8QLPQWTtFO_c4dgwiBM3LxA8gUjbEULfv5lsLO2h-FtJUp8Xml_XIW0A2DTk5EV--_W8KHEMtD7-avusVLObcWgjMqn2zXmFBMABUICwG0khyfgei7SuUden7B2FoaGrW3cgXLCkDvialUh6NipEsbU2jZolqOmqNWuYPPsRO5jpmJbM0wmYQ0ogOn8Z59WQnOc9SRxelLtqHFubWjfOqZQ8MRhXYvhrgJPxDZ8pGro1O9gJBtSxYBFSVnG2Xj4_4zHxP1Zic5ZIE2yIVzNdR1booFlhioQ5EvqZy5MzXTcJv8_4c-W4rb6PWqtdqN5pUzE4vHUzj4uIkakA2WgbLxFx4aLYUxEI317zVwV6MGZMOL-YcN6hL_nuY91GigYKecUUtJYW4udw4eLWMr9qlaQB-W5BaE5Jb1i7I0IdS-WkqzVw5S17uzN1DyWDlIvB3o4rXdMsz6IrRNF8ol5YRfL16MkAVhu3rGIRq2B_KVh_Pl1rq0sdc91b1AsC40OdYidF_U-QVxgh9xyQwF9y2G09SDfRoTlJ3SPH64P9vnU6rN33PJK88js6obOhbDQ3nxvSY9Uuy_kpjCoodtuVF6kGiZFtGnTTjqbD-PMBJ_Zj6aHv3jkcqOnlDOS7snPg9ayCMfkptd_5UIEENXyuZ5Fbt5QBEz-0G88tNdUO4DlApPbBPVOFQglXEsdr7z2ZHBbpGXa8tFm4lxfgNpH0Yj6GiDjcJ040mUYDs1f8KhN69HO_FBTP888WcZK0IYy8ZZSuJMhZPQ_j8h5dQwg1NTuMAt5p4IrZBq3LEOZnVNxK-tH7mI2r8kCPiW43k0WcjfGaao85Mu0HXOTaVYL1JDRAfb5Ev-0EdwHeKCtmyek2hTFHf4Bf0h093aCca1sGiiwr7QXbK1bo9b0BORqWf_hlZCmt1Vb3IFrHmvNUerZoXMiaC0zMwtcmzSCE0VH78hZILGt7pjtc59aVN5foIz8XFPFFzAgWaTqCQ22A0fT9Js8czf0b4843h1KXVSEI-VZVPJLeeoPAfMTu66KNJc9cW1T4BUw1ZR84Fekk10CY6UVFJmwOG6URb9-wP-wfSXMBP89FEtHzosT0IUlQz1gUQxUxpR3i7kAvDkcoxKLRyp4DHwhfdn90mNeayW2kITBe4Lku_8ZypY5Qg9Bcbbk-dH7iYNQ5UbNp42W0L1b8ZLzaEkASK_IVMPWbLUYlhVdz963zD78KiyxuOckLJEZ25wykvpZ11Vh0MFc1jL3bTWg-v7jm5j-LHLmdt67_gi-oqeKHwWQzfCFQVN8D1ggiQf4_Yzc7bBgfN-wNy2TUgxH3hArpEnon6iLjVzvQUp0_WEz89mNya8R82mtYafbmQ9859jUsAPKM8fkOnIT_sDxNKTVyVV6Z295HcNt1R_RGvWPeephPd9iBxDdiJXbYzF91AmaZPBTnPAW4ad9bYrVugqNmlIYccMl5349IOL5-u8rEHYWt23ONnZeAuOsumMeshc-WGdyJMXmT8dqWvCnu8C4CMzq7q5fFSgHu5JozHx4ePu_s5U5bCO9bXMZmrzgq7bKiYnHEiSsyz923n1dl3L4Qy_5AjltLPGj5WZwSaXPycXs8jX0nG9Gk70ar80rRfjZgQrVzEZlqv-jo5cXioaITc0HFhhsimGjWyOZA80LwDT6ZOLXgY_rt2U6tA8PRGJ04TNpCBZ47GIMwFEY-lBfgzVXUzXzJ_z7q6Da-Zunz_xr81ACDv3WlDDItBDsUmbulY48JgvDqQ0zA3KxTA8gKAqR6SR68yuhPARm1LVrmPMpQK3uPwepqLPWiZpQaDBJ7366D44DRM8B_-kbth4SxV6_JU40c_xNYO0F6EcSRyRhSqf_AJSURoiy3DkR2vIaHBUAvJOE7hQHAPmHcMo0ncxErh1nVg7vlVoa2Lokcx1Tlo_cmOokxIvCix9ybt0GtUFz7O66I8WRyI8F6ZQAVcXfNt71RksbrhzvXptk3z0SuaWi9clUIBoAgLfSpUgWB9APLqN53NqFiO2Uelmit7EdKD_tlgyB_tIkCtN69Fi8brb7-TnEX03ZX_C07NIf3mrMJ3HJvvfZtD4yy5LYBF4jBYnPbV1YheuELwX20rL46J-NO0g72pZ4eNXTBsXJLF-KX4pebTIAxnpkuRJTQ8fCatUP4u_OVfWDTZOWoVeFmJxAgpO9o_foZVHgVGm-BHVwHLgifzsHWKVasCbzHCtaxo59c2Y00jcm-f4Pux1r8ZsT7LINHLdLKMnPRlXI2rIyU0YjKHHqpyoP50fUX7AKoPfDhJPQA4PU4N1lFMtNOZQ3PPFSd3gqQkwkqjOpfWDygeBpkUay_gnZPqsIvfi994SWViKa-AcvS-OddZ9zSLtcUWk6BQm9wCUuzniJk3MTXKVtsSiB5gpqce2uCoYkxDbNsh_0rpDP4taPXW9qVl0N2AUvXgdELNT7f159XS4TA-5n9UFIp5kvOmAjkmQqWbFhUa4eLE_uKw3iLrgTktZVJVood5wYOczRInGT-YUT7j82tsOwznsgnD9qFMmMbr_pQW96Z_o5uFRr5yKc1mXgf8iNd6oUiR3OiirujT71Lu3MKuXZrh6aV3pXyrofi_7_4_PGuQ2hGGEnQgvQgpfCj0EwN4p4av4g9b_x_x3A34xzV5b3B_iTKB0_zy2nbzBqDtpCBWDvVoGOIZXyHoEDIMqcSbJ9GkyAHIlC7gft1z8eWCC5wjm_Lt-TP2OfdIFStj-qzaquT7V13s94JuIbqS6Acy8hGDJPX0rOa-0KPuXnDD556L9gB8SbxZyCrsyEVFotwxYEvn0ZdNCumNZ8BMgP9VmiOg1-grEyE2W1vzkNA38SH-fO1wGxY3dnCpyn4PRpjU7XPGXnBtzRTgYyMJe9DUkTaxClKlJCb_5hTwM1w6qUPvoJFCigsJEMh_h5_O7UjUnQv_NRqnTQPYCVBSoaq42SoiR_D9yo0L8dbCE6gaKCAePJX7w1pE3sN7AdZdKkqd49-w4U4aLbjA2pmilY5qUk9DNHK4JeZ4tLyybP1CcmVt9DJR1swMxruHKPCkiKtwaTQV4WYb7A2Yoq0MgrGtinic3zRrPq8ZjUqiIn6NROSnBwbDvgLfW6hRzg7Cv9bK1w4T1T8YikOXg6afCpnSIUwW7-1smiTHoGwqalP15Os7hJtfIytDbJyAIABm3mlP2TCcsDJtlwRzicblsbh3inhuQyuBdTo6URcs6U_1tdp98q_fH1TuahI1la74XDeyoYIDD8lCkGE7Hj7nHE4OHcxebe9jOk3j0Jefh82zVpxvtaL80QEbhT-Vql6ivtKqR5Uz9TjYabtJTj3D_YneMkSHV1oPPqrBFE95btPskV3XbX1BCmnbyLJxy7RMTIjbbDq01kR9ULbl7qoUVbjXmO5mfCLq3ADpTdnrZ29P49nMP9I8PF-yg89U7bJjswYBmELpbXl7e9CpIXEbhsbo1HN4snELliCuY6qGMIsJik7geU4ionC0zTvtRX89eQJHIY1Kc10bSR5-mpOdeiUh_b7Tt6hJqG_yKZI5xTpDpAGI_HodzWBVAOEwfE8Y24oB1nHFPoQk-dxfyWQpYbl9YO5cuu6WSne0nWqOIYslO5B4Jd_OrQUqHBujVq0dhcLIO8_Ev8X_QLBa75ycTqTZTxXnUGuPDlq9IB9Ma5m3FM6rprs_rL-m0Ssn_JQOz7yP4YtFcyzuvYioyc0moy0Ec8KvsVDvI_CIxGlrmtWENkdzpl7DEES46tXPQrYvlp6M0Bwb3rqDyn3F1yuAVGlQwcVhw0S4yXzFQe_pJhd-R6vCZcK6Nif2FPWRBcaLBa9ku4OCZ8BU2ocMREMAfKIGRSI6Hq9hDtx4vWx2ROYLjVGLhRRmbHplRssDMfG5ZSpkk5RiShujytvDQtG74q1PbxLDjuVAed6BeHXDLh19Sbv502ZsX5g6bcgi-FKqB2igVMbVjiz2AvpAG7KqfVIyT9TRty6bRss8cYZxLxVdrbjVZvtX7hwJFspTIUr7LXzByZc42pT4-bpfu2J_4x9qwSz7WKHJuNOHgDbEsNuAxbpNfQHXqBNQd2xVuQKG9CSrjbbJKDW7STAqq60FJk6pw6Oo6VBtFtEkFsnzmdJTpS9sJ5tVr8rAz7bRt9q1OZ7n6t9Q3wJhiqiqcffK3E360oTiaYZjg4yhAiU6wqVP1quQMALIxycCFG86NSmF1nHUr28uktUIbFpzxpAc6QZWd2s9SuAlGpAezNB0JapCPCOwF0woYjNZpqyl2JTJKK277UCQjMp78tWb4BIx-ptv5HtyqeQHdGxXotOTc1LeyFT9LS9lnIfTvFMbD5PnF2bbcDBa2WrW4iHgMfBqVPtS7MRqb4cATeh_w1n6-eJRU5PkybGY-8kG3fwEFidWyX8WanigLmys2USrFGLC34vrPdr6SCPDbLyseCG5YX52D7wsFLHQZUDX1PwTpGCR37IYzok9XEgPSfNsGBw15hfc10QEMLV__ATM64Tdj1TnEu2_vQ2GY9Ix42P1hvbMvC77QU-P201YLBhD16K2pS7jEFderjpY8rwuRJeJCIibxYS74S4TvK5MlPX_vdpMOJrv7TyNP0Da9_2UenL42bcE0yQY3v5zor_g_Oy1ggAILYp5wbcd2NPtPgNg6xkq6qKxlBGnLm1ZF24AlLWYgFt2LRVuRp-VMdXxgftt5K7qoBP4f9IIEdpcXjIPO5hjEmr9j0aWcERCxhZGCvwCZPtEwJSgOx1kKIYO-e9HMe8jXZR0_Q4gH_4J3n2D9jx1XXPyfj3w7Sj_VQGLAOQxlrO-VKY9GFCqmqii1KFZYGGoaEUQAEgomJPCCV4wJBPCzceCNEPOWJTjrvM-ev6EICfQRIOkVWkeH5ynrTrcBSQFba_ErZBIbNnfN_bMZh92C2gJpIxGFIRh9RumoyJTQMyDmHaWoXhEZYXdlToyDKHz4KBbluIoJ9oFeNq36vewT4EvBAC1OY3w2kHDIu92jeL-N85troRQvI12MS2W5dhHvQNJuAhVGN8vBAe-2KFkP_RVOzyMCINTJyv12RPBA1J8t0ysHJ16hYqQMSRhQ5Fc6w4It0RneddvvuebTM5AojNVYnRtPATiAer8bptsGw9L_BYuiIcN2-4NeAWv5N8mkCmQjoGNKYGkXd04uDyudErXpQ1zwGa_GCUE_dn0Q4X_9SyevVSnIx5fNUBSJK5lIhrVK4I7GPkC6mmvp67KYh0UCla5rNlH52mMhce9UWmeu2POXs7sSPh2RstcsNeC3Jb3huuA-QqZWPpxgOfBvak_9dmBsAiV_Ifb5rZyOSsGlT5O0_tdrOnvx40CQ1xV2knWG7tpbehEQMMQCBLrUFk-a0crY7Pxjg_sCkznqWnO7gmIm1IhsIX4AAZj7I4Nuy7mKOMKsuWSRgIGYR0xyH0KtPExubewURTPK1-n0ECRQBS_xWUbrFeORui4YR1OuzNHwj-8vMda4-iKIyvcWlCwlrQOqCYvarnFH0zmBS4zLJh0kVc7QB9UGqGEw2KST8EB6Ml2idSeNEHW2dyNo5gbD7qxQL5ZTgDf7F_HgTV9mIql4eFFBu4NVn5ovNCi2BQ4eYeaRY5sZOryNzft7XGi9ts-XwJTjpUwgiaETk7GQTgJVrs9ADziqtVbCC-t_V8QojKNsIfnhdxPGLTWVURButSUhuBnlHkNuTiYC0FrtfgmQ-15e9E1xQx2JC6Y61GaF6dytMIw68ta35X0PBwaoi_yuE7ktP-BFZ8-oz3pec1fIGV5vDIqa3C1jcwFwH_VOjDx_AmecD6VOuSR6U2mJMGont8NTr1BLiIh_LQyklUC_jMdwA-z1vCbyDCfU5EFGjzOj16zRsY4uAVcS6zXXU4BCQgGKakqLzVb6hF--5iTAUuOympB4jYQSNk2pCRTCHsGtcJSa16zO4AGx3sQnYUdONFkJ3tVqhFW4LuaiNzo2RVrjmlIrNiqidwrWUpr1cWP9Pbp0qnaHXtPLOipuEh4_cDWpDAxqDsgYjOH_KFDrdwoELWmSBwavKUSGX6bj9rgUTsjvg0BwmRk10B7tNEJU9Ujq_x1Isz-JtJO7yQmnbaNxDJYC26AfGjixf6WNIs2uIUWD_Q8u_--xlJGKCAFtdMDCGG7oBRh9I82nBMIofvs3LNbw6hIdLxX5cRuQRiAvVYzqNK2oR3Ml_VYqvRAB_wDduj7ZiXY8eko1zY4stnyyJe9i6mdIVd2uY0Q6yQ-SLr-sGGdmCrz8W8AhF0Vf5eAzkgpzsIXhQTYTG1BB2sQv9Z-K_YO_WE3az7DRqOHAY_9jhoAOcqJPc8uAHpzZYTK6JHNiHMwMfe9g2S03I3HUpg0lI-6Iy23oFa9N1-zxmh61CZwhAvBZUeMnWQZrchTlia3VX8M8VVsvu5BkPAcXecf2qV0QBM2S9ZVSTjzvYAGYgnMIFkJLKVNnUJGSw3Ciom4K97NGgUc7U0EJGBKvhbd-uhlbUk7VhVOAz9T55Pzlzt5yAcqAeDhqy7-4aYed8PwEY-fFfg84dzM4puqNsxJJwyvlEdrC0izVL_IlGzpbMMAiNbJ7QutCSqgV_zjU8q5N0I-q0nbjmo-xGuqcePJgtbSZwDhbv1C7iai0kIHnemoKmlgOwnZVbPsE2VKDczbS0DDcFtVVWnswWPEKwLPHBIodeOGfnNGN1H1B5N-DIsFK7xSQnqFs0We0OU6pZTYeXKYDHMEqWVeR9sB9yrzlk_OxZjBoDvnxcfKnSO97DYwID9sEy6lmuAyhCmv1ez_sBhPBiN4MkS-TgSd8ixIVO6qPGGFmPMtLP5CBHeAQgCLs5QrEfpGN8Xc1sinncyuVkDrObFrc-3Nw-Hay9TTJkw2LrPwJY5rBw9fe9E6HGhOtA8lrH-AAy1HyySg0KhyYa7ZyH_yEB1IMiVgjKcvfcMJOT_vINnDTr3Iosb1fh96ZPuuqG08AwJ8N5p0vIaha5453sKbkY8Hqi4Tr4KwEOyETldjFzA7LmJo4dvxwN34Xo7BcQUuqnLLShRmtJONqcGNU4LeAOTAfijHB0hd7VPK7NxVUP6Cw-cY7GJQcmydQRTDGfhzTp151BZJBzFek4iTM0WJ2XCJ_wJ_NH8Ja0SVLsBSKXi7BAdY3RIru6BXTJfJZAD44gj_wC9is2u-KAPDZCHgf9pJ9wOxkNWtiLodbX4OPIfAb-rGx4fawGK1ytJoK2c9nhHrsfxoRW5LHd9kBrB_0nxOxOnDKqFzBYQmQs7ZeB-KiwLE9wJA3wYqC8WzwdOZ_BCPCWj1oZOmwBwQdV09eDPPw2-syIuFwXjSzYeWNkh8KdsJjhEsS4ndGaJHnEJDOhVZNGCBvwXGKFitS_vWC0dcpkMC6dljDcoI8X1rTVgPme0hCk7TxY-KN8uZ4Zzwcp2Gtk2i9YaH_1irAbpFbUCc_uGfj4olbrq0dbdRaO4L9rsIahOXYscYBh79L2Ifhb62zOgHbWe7KRtJfUdyJFgh0TFMgMZ5IN5sk_F05_5qkCFSEHFHKpEVWMIdrmVRGFK8Lh7vM2_n5K2ORL3eU9aB23uSqpkt3BLrVA56PUZAJXPc5rM6iF6KPQPMNrfA_VAQYICOeuYlF5-0LblULShE4YJZPIO45W3UxeB5DH4lgiRcGnpdY95rITRidDQ3Xw3nKU-2EWhtSYvrlLTZfSzbuNvQraMwmd87i5oM1KvGoKhKorVqcXRlQ1z4mBr5Ax8FV2A1ZB5cZmEfDf9o3r2VU4Q32B6-xF4Y0NKTtX4-T0xXtRAkHqg3WhUea5C5X-DNvebCSOEVuJHCJSYt6B35hGJuenNAZ70weugfNazOlcPQbRVib4ZPxPCam12VgUuiB4NIgZFnv2g9AlVDGoK1pqNxm7gRhN_hrQSWn9Fu8epoujg6pU2TKz22CCFct-TUcWUHkdvHg7empVvZKb2cVp_Igh3YpblU4KoNc7DoYWtkt8KrUqArM5vYH5fymJQnSVPQ1NgJpC5L4qP-CmMZU7vWUVuzyGgirWP5Sj9r7tuNA8Hf16fyxpuGrYqJMmxI5r6VE78Sn2lfAF4PG4cBBhfQQMKAPJDci6bzo6GAx6R3zqtaOdVbQpRNSvEV5vcKKRqNXoip_9Tt45XuhPPgysB5TikSPSKfaEoMXTKIcsOXTyFsc7uCrf8WSPeGSwbytBQODlL--wFlwQZGgxyD10ThCUJ8pp17MCSw-E6o-OIDWB4poMakkGzwLxKqK-axoG5CTzA2pY8ITV0ydhuN7Hx7EE7_3bVzeoDemiw5xYfLJQNskwwBaeSQDGTfGDP2FpB6C7UbqnvilkFdADF5SKULxEjS2jTEmLZDmYpXI_bZW8p8EIWE-7sCmTR9P1FPMQriMNY-cVUdJqBcm0u9xPQWIdHboMVrZLpYzTr95ZXFI0XEB13lT6UkiD26NMMbDGx-3F1aCmkPvsMBN7dhn7QIZq6xGqHmakqPpPS_QakzhBGHFLS5oEVWrZMljl1K32beLXcZ2Y6XLnJhlpLZRKCO4T8i0ZHUVo9ejMiO-eURxrMNIuBJGBWKq8uWxHxDXMM4Z-7MIBdaE5t4Dr03hg4KuR4d-x4EeOiJ4b15XnyHvRDUKq68wR6X8WggV9OHEbNx4FeiFJqXGyegvS-dDIs_qYPCcpOLs8Nu50KgyOsEfqiKdwNaAtxYx2AGZh8KqBbUfaewKfVZ07AXW-Ud99FZZwWUUIm5CmXnk7JzdDa4myrULkooHv5oJ1dBWgsi04Lfx71GzANanM_Y1uMh04_7t4AHJ-BJaQGxZkfL5S7gpP_6H6LyRQKNiK2ndOnC1jnSKTVIJSqdkvp2YmMoWaKC8khNqvcU3z7kb-e9sHn5TF6gn1QEpZdMXqARLJHJ6gLBpOJnDi4_4HmOcckjUNeGu9G2CUffKVALe7-Y2LTW_iCkVg5MLpYo2wLpOCg9m2dbPiYF9djrRalxRnIPenNVY9BvyZRpHoX1vUmbl-NE-z3WErqZ9EYiYjAnvWc4Es6E3t_cBQNRsibfKA-PNlYZoVxA7lofZsbEs6yyVWW0o6WileHKmuFCrlaocELkQcpGA0soJ_615WgI2XmGWtNmoFTIPIh_5UJA38IZ7n39kxTcMrQhAKCjLA5MiUTnGrn1Y2Z4XOYtm6qrbCniUn9YkXH0gttel96pDWAIVoDBA3au5xpQDmh1GCyG3_glxUm6W4Mrd-prXFt8PObshi8z1qvu-fv8qb0pZrfi5ifCuxPe1Qt7vYzGISpT0rFeICm6VLF4ISXpcm-OT_eDhnbXQKR-DwjriZuhWV198hMdxOWaWi0IADgT_F_D_-p7ZKg67Et7nQdJCCI5Xcw5TwHV1FCRDY_K0V1RKHU3iG5M2kN-e6B-0P7zmvdZzqpwVO2CTlKi9QFxS7TN0HPq0qrD863baVp9bqgrJEb5xjbFmLmpajl1PvVI_hl2y8IgyrwAEL0ewMJ3YwK9UttelQPX_Xw6iDLi_DfhCCa-tvrzcDlAuI_oisMcTZmLctcD6hWZcPEAX4NgLys2DOP6mGf3HFy7fPkEoYvJ0C1TFqONDlI1B39dWwVKLBaKeNWItS59y3b6AaCzXkWu2rAUyqYLI5citX7Ngu2lmjBmBhLsblqHk6Sh39e7amEMGT4W9wqMMkFiKG_MJZxHkdbb8PlX1Q2U9pF0qG3f0bGSULH7HJglXAhvA7rUzw1oPTSj_MXBEeq4feGnTaG3uVxUIQdtacxupqLP2UVFFw_uqH6hyuXVsIkIFX7BkKFZBusc7Nql0mNFHm5FcbPrvdqB16b1FO5CNiSJ_KdOn0Q8uYXNnEiQyu9N96SO3iWRgFUGsi7L4Zn1xpkeGjOzbTymyHMGcTJkH6Oktn7cnLyZQH67QLOYcIUAXZwXHQdtPRD241pJtTgNW6LdaZgbke_KIq2cZzADnxWxuROrnse1pqZ6p-1kjJwkXck-2rIiLrKXxQn3L5yLIO8TzfCalCK0P5y-9KPrZGvvrzJ6WLexBHSGWK44PJnY1-O7SskUmnusMNDtd9gS4ez6SM_YsViBeG_7PAeQDa_QmgW7N89zvfEfj1tnIkWRysAUY0yfoRdYbESabeQeEPuDfqkzp0Qr1nit1a4UOcNOlqFHb17dZKZGCVzwUoOM3XreNhCfkE7bpzINgbt47i7n5osfLlcA8QWZ1sYqTUJarVFFmkV2s5Ee3PWlEAC59kY0jzZoJOeD91TG9zIZCj0jX-pxfTj3l70xytolcmRHAFMB-tov5ZYulDYVs1LF7IGkVfO05J4A7cmMTQgwFZrqLTZRAXuwCcqDCH7iHSblbwF4lhrxV1S-4rrFrh2bIbTdrK_pWcW8Wt5WuwmpDScvMGnT8pVCGlPjXzVI5J3jEi4prfTHDglQHNhfXXwi04G5B6ENUanjAefd9fm59WmmHK3MeUIzzPUY8nVUwh2oKJve_YFshbt9De49664_TAPKZSxnOK_RHO-KJMmOcPNsmg8orQIprJp4wOpaPskELxunpRiasUK7wAbL0AbtqkjWCdJaKgBFXiCl_LMCcGMyEGyoMxojrM79Y66hV4HJv7HEYied_4U33M2InPvABbBN8pe48803SK9oMqnSCZQf_YlLINH5gBxM-fSfdBjBqqeD9k0GmalM7iHN1h4x2PEqIf6e0d2M77Ol7gSLmK00cEZ1lxsizxMX8OVKTMQkNIIaPpuKY-BJFORB_LfINotFXBiOqGd-dUW5bmmf9WqvnFeP0sHw3PZNlZAstY1m9A-IT1XEsdoJTCVk2moxZuS83xjr9F3tec7EAewxF25jIBlCEh9BZWJEa1KmlJz_C3rGZ5p2kQPCbNVIJa3RYeyJmYd7ZoU1Osf3tP-JKTYCUP9LYwLExl-6ubJRk_b1OT-zvtMxK5uPZuBOiiK-p9euioCxDHrEAcVvRLR3xL-62noRkHUyDDl_UZAxFN2QsShiVy4TwgzCFrfP5UY0C7D_piiS_qxs5fH5lAT20zqIVnjTtX8bf3m7iAQzjehfiQS5SO2qNePZ9SxCwhnK6anPskfwQfbvpZ2COMcsoy7eq4vV-o_ec7N2V29_MTiCJj5hXTxK8Kd8z3ZkId4mn9nZ56aLWRqtHt3oB1Q3xDA_P6rKbizQHrq5ht6Q1CwCRyMbFvvSqGse5QxykU4k6EH1KrjCRtFFbANYG9HNo4eJk1tqp0W5X6e4EijZtnZAGWFwVTiR8sYzkpbWpDLuq6KnRgU5MARHmZOkWWyKJeXzGNSFwu0NtRFrq7jTiHGrzbYxdMeS0GIyOq01877dvrO7ys6bpuErhT7TPNqWgwcitbuxMoHSyU6KOX2xsEf8pWov7cH1xVSGDYohLWUYv8d4ftKvsTg3BhHCRC6Zj3cv6BX0MW4HOOuAGT3n8N_gV5NT7BQwJiY98y8IwHWBaldUgjco63VLX5QwANrCE0rIALb0E6x7jQXP89vrWtbzgjfJKHCYKmDCdgDeSFnQzl0qEQItDpJLNbkwvSJuNWGXytxp8ch7mVYxB5DYP0hHgNjoQywiP_VILkIZRh9A2XIlRmIXzA661O9RD9UO2R3HP-nM6W4gW50iw0BhbBQk_jImf40IHnBRhiluUsTZCFeeX6pswcfVQ30V7kR0Pv85qBKxFDXsIOJBDtMlonckuW9cjVTKEbi6oM_68C10mIRQBZdxrtwkhRdU9w1bWXm2K54NpOlMDICl12sogNooTyhVYY_pI6hJEw9js3bkCBPbZTVxIGUCAaMWv4nV2EAVFSky3UG97qgXDAU30G57b7HbfB5ncLh4xaAMn-iigofzIpPLcr37BiheTpAnTvJIhvUP1fdgdTv5qFbA-OhBDbMM4AWQ5VmwFj84OxrMzEWTqDObDQ5IL11wwYi5sPkaSatlC8fEexXhOMFw-tGlVE7SdsHtbhPhUCcbY5xAdVDHT2a9zQFskvtVdgXtMDBA11ZzLxAEjIDvgGq90i9UFsmGqrx66zW6WAu8kwwBsBLMFn90CW43nTDsDGSnHWGln2Fgpvsj1dldRGKlBal1JWAHhC6qP6mjxCuPZMrbQGJlGeJyi41d14pog0cRSObiE2cSUiHKo2yqvfDdGp49FryA0V_653gUmwwYJ6pweU8tgD10OfM1N4bnH06e2IlidkcdRz3bhzT7GdmBdNDR4vENDB7KTRMoPK5F2oHChtB9SlN16r31tFTCjXeN3L7n47A2DaF-wrIhA9kUMRbAWPdvj9uZg1t80L_QsPz8Sot-eufKZoL1i8vfE5M8vef565Klzp6tXDx4l_os9LLG6_CcHrGZP2LNv_YTUi6ixZhm5OJO4t8ZN1kYlz0IT-yN3hISx9Puzt6yBmp8Cf0NwrBq0mO7C-lbzb8Hq-x9hlpfFtt-6Y9Oc-EP8nALUqOtKsNcz3-IQOMns4PHEeVWePBwljbHUYqhrL4rXI6DbO-NQTL2FqDT-ck7wBQsF4ehSxkfY_viKNTA2bVTqkX8mDtc4t-eI0nO2nBFTm4sCNH80IYwY3qh6P_sMcedTyREmZeESJuar0T16Kpj9UYlc_75WFz3dms3NNMtIdJSgryAnvly1zdzoCOEsZPApFPab_4ui6vFsewpOi3SV-vCgMiQCOZw_X9H-oj6xprV977Fyw1stySazc1WUHh78xL4XQLfY-XTG7qrb_sSK0JZoyl25tJ5BOHWA1AIxe7s4ha6BBnX2odj4ToYibPuzdD5f7JMAJKnjzOpuVO96Khu3hqC3XSBqoXP0wdLz0a0-Q43N0tl89lhoDzetfrM0sNLMZ2q_mLGxhD5b4WPaC655V6ctgfekXZGkt5srcfxJO2EMe_byEYBI6u1RvCNMfKmNbD7CQfM3z1oCj6SZC2rnoIdYpJBU6rBMXP5CpRHNiOJLL7H4m-TJtCjie237ZZvrh3S8P8gXibOMdm8oQZtgbiyR3w_UwUIiBBcGXE8REcbnuU1wLrl-_cvEmNkTIEQrE3PUHdBZQ7UHMullmcV6TqWVI-0tPDl1AFlZBVJ1Qxlns_QkUQD4l-5rIzkIS_fBituvOD23Ok52wGxV3xLkh3keqYMt4bKPGOvRMZ7zzbO27TWAF_jKAwrTR0-_wp9aBBEB7hN4dV8ARbS3BS-in3RZbjClW8BytAi5kI3_RHNt6A3PeyXbf1q0LoPbxfzFxVYe20TR38Fghms0e2S06Pnx-iWka3FaIfsv5vcL3QfGiMQ0VKQ46wE0T9LsNeJI6ZgAKQGLE3qfWQcRY5byjgiBglxekwMVee2NOYLYNKThfEZZ3MbMqdVcsJQGXzkCjB_qY9EYYT6PqSGUX7D1DEEmKbNhTE2ZdE-cNRZT1Uq87DSApyzt-c-RJMP_wFUS21xAoX_L6Vnyl2aITb1VeGv4KJPLvNaZMkBEyEYTBWyKk5-eNsE2ZtNrfD2ZOonWj9JQhuyOesZWXNrvhlOO_cK9DPajWyf1YcB7ysy0oL3qqfjYrnBEzNefhMAJkeO22OKGOTtXCIb5Wy__-ANXnsPK9P6yIr09RGOPrhqqhrcGCG0V2evm0ON5zm77kiKgffpnISuwhKX31zFwTLaTK-v_TCSno6ZgIn4XFgoF9sNVDevNwkiIm19nxEpscQh5ja5bNeTBqfC7Z2TXV9_4zQjAypJrGdH59ms8XunCLfOIwhR7C20QTcdtuBb4fXxpb4B0V7bww8W3pe6lDi3NYfoyxUn4GXeSfzVAaKo1LDdLlyNqG8PYgeSUdlLfnshTVy_JBiFQR8iAJIxgC1ulcM9zdj9B3pxaCRebimiia-B0SzKHErlLYNktZqhosSejMUQc79VosgToLhm5hXuyEDQN9AMpV6cYzhlJkGiVKl7nJCQauTh2kmVuPPX766OUSHQoDe1SpWVivMlOMWMvhyA4gPltW9mh26H2wG8LA5GnxHSi00t-XSgDFITR3FEwPPkEVHAwiTqaylRVqt7V3I0VoLT6dfo3YoN-lIyeeIQcO-s8hdGshFVnFsScUO3s7MQWYdVwWjmz0BdnxhXEhOmWBByIBA7PeghA8zFO1xjyKeL6SY1qeH2to5Jx1SdIh0fe0m6dsodnHppM1DzNx_iXFEtip0bVVey2UA83-aTZ3RMQa1NJiQkcnh9A-PT2G3svVkPIhL9FQvx0opx4qye3vCaakm6NAxvJq2DC1Y2sKAqKp4MlMR5X-LVQGGYHMjlvGqcxaVXQQ67dmoppwaK4dXjrmKoCOugQI3Q1nNVG-nGLlqvNncLFMo5LG82N3nqesZXhUxh20VHjprzXTBvUBbp0HKVahyN11UncaQSjM60RB9Jg7kKHhPI44eThItpxEJdlHC3OS7IABS6TKYywF1qzNqARMm-Ej64cpvZbND6ibs5xM3GJ_MeINgjHrv46u_RvFSWdPxHYtTuoJJ5rfr4kru4vZnZnyMCPFxDTSMQ9l-m-HJqWNHDaxaepfFomfmDmJK-yL_dwwsSHmjc-5rk0IDHCg5M_a_r0kt89gXZpeKj72_dWbjQNNi0lAz3q0OixsVlHiixj_B71yE46t5_eAmEODXdm6W7T97Xjdg9Oi1YiK0XGJk45CrA3cwdNHRc4j-vBaT8onM2msjBliaLBVeH6xxZAVSzW6c9Ketu8Ggc0qo37iA9rO54HtVvhUe8duOGp0eYFKw9INHdlckHEorxLOnwpslSJHMSKoBgE90nGUN9bSuOqsQUpGFRGrVUUgS1IHD0qyKOapoKZz8WD_fMIKPZV94fi0FimSc0r1scTKVFP7tMS8pLxVv0I30CeDKrER6-kt7AKboL1XomKvAv4n0vyMpit3oDieNuKu7UpFLJDeagND_0aZicyjzryjjLYm2usaueuGF0yxHyA6zDAYXL48EhoAUFnhvq7Fc5KkRdzIpGgTviHgeMJ9kcYvgZICA9-UN1zCtdOYar6so0hSboTHzcfkycypSMO9VHTtmqIsMLM1nA2v5mtspsHT_nupo_8sYWrbP20eNw6AEg5RFGs_nvAkPW2IQPTCwrKVMS3izgJXGbtrlEUq9Z4uLfSqlHyXogWU0Yk0ENdlNEnukR3kcpz9ZW0GK2TG1rki32c9QqDtsnT2f72aC_Q2YNWP13FZ4p0yiQM1o41mMmC6ZBoFbTwm0nwctdjEPmWcnVWWy4bLgWp1ySPs5eLtaV8wtIQJIsmZpfvHUYOSrxkyohWVmfq61-HNfdSJeFC5egM6HqhdOHYK1e4zSNyfR8e9YBY4UrqcfwtsnhUhbaRq8UYlrkvcBrdrpqetbJZCyNU1_QnhaRqIYsBTq6ZHhIw5gQCEpJ78W3FSxN7QgXbWTYRPsWIWKq6wdk_JwAyV0HHuNuN1WoGypCzjQoMoBEeTFhfMZ_r5hfNqTHfLpYBAJUIuZhhRwhmniv54KjzOhZDi1daEx3pm9etXrOIrHH21qOvLbDzBaY3blm03U6pcRtz4vNOUb0d8wLddyi4Z8FNw9fJPoqPqqsz0rOYuIv3O4P_A1sz0KPXiZsrl6AEsvkL9q5CnAT5QnL7PVU86YPNpmYZiPNNdn4KTHTcP0FHPUfZiw5NL8RxlRnk9fWJqLpvPjsuVxEQTN9ogbMya50oXF_DY6OoMTGi8vBNcI-jGJcto69A4nP9IGkMKD0brlXNpKB9F_K72an_IcLv6350t-gN9BEKHOo_Keofn9tIJs4UtxjccCtyk7Yf5HWEeqNxdreDUyaSrh__hdC0DQ4IOPduuO3RdM-EOowYleFfUnl68nzUaXXxupiurtvLZYwbYUTRaKn1yQhYjCj-9nvx-z-OKpvnGpC1q5iFRokD13zGD8cGQJcoxLhXi23wWvOTM1GYJkOKWh0jDACP9sw7ijNKja6fkWk4iD993XqWu0-KewbJSQlrgh7TyzD6XMF-k2Oi8rAb21Tbf9_xg9Uvz89O7VtDiZvV9tkLNIFrh-_54hOVu9N4E-dV0MSGYX9typwmawXaj5capi7oLEGB9IuF7OCRMPCfIznuNhMc4OYcKZmpMisnyRKc6kY_BEOvX8vvD1jSFR0-v50SkoIDZ7jYBXd5377ZhcZyqZo36NbFFv0RObbBTqkhrKPSWLMrYezF1UbEp2rd7ECOt5QHb62KUWPBrE8YZQCh0ZHHHj49JHqjLC4k6rPDflF3Tn3TX84RTQY9xTqMDzwdfzmXUcCLbMx-0F3_663Vwq-IB_H3dbZuQfArkK0G3Mit2nAmn0qQG9AZgONAdx4IcAfaqM8AY-udesmjliFcJEwISBYpEYcnaxiWH7F1weJNdGOVorISIOd8nWejYEqYI7_KRAViCeH0dijx3VWTOxsGNidVL8tGE9-Vd9xNFpOD2rCHUzWt-FO-5fbmoTgCtlTZprDsa5Swx8iLmN4xEi72yGGUg69YmuQ3BTYgxC1pTEL8TszZFuUBtcSnFBZOQzGSP8ld6ZPIk4_EFC_14b5xS3cMuDT5Aw2n1b_-ov-piFgm_GGKh0S2vGHG__DVkZl3J9ZRI3LHC-zXQ9AExlzLp1J05SfchCHVlsFHF0yckoEf41D6kFfnwBToQzzl9oO5z-MsnxuHTNjcpcS2AZlIm1xz1NpNYI4iwQDCNqckZJJnNpJXzKH6XpfcCSOErj9CpY_gHFHmwnBWaDh5oplOiYqOObu2ai7taz7Y1uMGSQnALALRlNqf8MOEyFNx0qhhNGY5J1r6Tmj6PXj_IeR8xZEG2cuWVedgn3e0QN19QiTcmCq3AXx_b2vASDkU5jq6Gg0AvVELIDJwMx2BVXBvvj9Y4PP4DNRhJIahTra0bCRlVDniLSRWXKQTELgOOT1EFAholaXkHBd4mJIAXZeGmlr8DiYgpnrYfLSxU1Pz3PCNMN0cU8TQB08Qi3vwvV2LUeGohRtjwjloOAhR3zM", + "ciphertextSha256": "2a443510ab94d3dfd75fec57d2e462ab91dead64147a5027361c75ec37fbe9e7" + } + } + ] +} diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_protocol/mod.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_protocol/mod.rs new file mode 100644 index 00000000..ea67efda --- /dev/null +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_protocol/mod.rs @@ -0,0 +1,50 @@ +//! Native-only encrypted sync wire protocol. Never expose these secret-bearing +//! types through UI status/events. Scheduling, persistence and restore belong to +//! the caller; this module never retries a write or applies a downloaded document. + +pub(crate) mod crypto; +pub(crate) mod transport; +pub mod types; + +pub(crate) type Result = std::result::Result; + +/// Only fixed diagnostic text and validated protocol codes may cross this boundary. +/// In particular, no reqwest/serde error, server message, token or payload is kept. +#[derive(Debug, thiserror::Error)] +pub(crate) enum Error { + #[error("invalid encrypted sync endpoint")] + InvalidEndpoint, + #[error("invalid encrypted sync response: {0}")] + InvalidResponse(&'static str), + #[error("invalid encrypted sync data: {0}")] + InvalidWire(&'static str), + #[error("encrypted sync transport failed; a write may have committed")] + Transport, + #[error("encrypted sync payload exceeds the protocol limit")] + PayloadTooLarge, + #[error("encrypted sync account does not match")] + AccountMismatch, + #[error("encrypted sync version or key context does not match")] + ContextMismatch, + #[error("unsupported encrypted sync protocol")] + UnsupportedProtocol, + #[error("unsupported encrypted document version")] + UnsupportedDocumentVersion, + #[error("encryption password does not meet the local password policy")] + WeakPassword, + #[error("normalized encryption passwords do not match")] + PasswordConfirmationMismatch, + #[error("invalid encryption password or ciphertext")] + InvalidPasswordOrCiphertext, + #[error("secure random source unavailable")] + RandomUnavailable, + #[error("encrypted sync revision exhausted")] + RevisionExhausted, + #[error("encrypted sync service rejected the request ({status}, {code})")] + Api { + status: u16, + code: types::RemoteErrorCode, + retry_after_seconds: Option, + current_revision: Option, + }, +} diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_protocol/transport.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_protocol/transport.rs new file mode 100644 index 00000000..85f21bcb --- /dev/null +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_protocol/transport.rs @@ -0,0 +1,2365 @@ +//! Bounded HTTPS transport for the encrypted sync v1 wire contract. +//! +//! A transport is usable only after its capabilities have been checked against +//! the native application's configured OAuth client ID. Mutations are prepared +//! before sending: cancellation, a network failure, or an invalid response must +//! leave that same prepared operation available for reconciliation or retry. + +use std::{borrow::Cow, fmt, sync::Arc, time::Duration}; + +use reqwest::{ + header::{ + HeaderMap, HeaderValue, ACCEPT, ACCEPT_ENCODING, AUTHORIZATION, CACHE_CONTROL, + CONTENT_ENCODING, CONTENT_LENGTH, CONTENT_TYPE, ETAG, IF_MATCH, IF_NONE_MATCH, RETRY_AFTER, + }, + Client, Method, RequestBuilder, Response, StatusCode, +}; +use serde::{de::DeserializeOwned, Deserialize, Serialize}; +use url::Url; +use zeroize::Zeroizing; + +use super::{ + types::{ + parse_wire, AuthSession, Capabilities, DeleteVaultRequest, ErrorEnvelope, GithubId, + OperationKind, OperationPending, OperationReceipt, RemoteErrorCode, Revision, Sha256Digest, + SnapshotUpload, StrongEtag, UploadKind, UuidV4, Validate, VaultMetadata, VaultState, + CONTROL_BODY_LIMIT, HTTP_BODY_LIMIT, PROTOCOL_VERSION, + }, + Error, Result, +}; + +const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); +const REQUEST_TIMEOUT: Duration = Duration::from_secs(60); +const IDEMPOTENCY_KEY: &str = "idempotency-key"; +const IDEMPOTENCY_REPLAYED: &str = "idempotency-replayed"; +// A valid JSON request stored as a JSON string needs at most twice its original +// bytes for escaping. The headroom covers the fixed metadata and bounded origin. +const PENDING_RECORD_LIMIT: usize = HTTP_BODY_LIMIT * 2 + CONTROL_BODY_LIMIT; +const PENDING_ORIGIN_LIMIT: usize = 2048; + +/// One captured Core setting and the effective routing decision for this origin. +/// Loopback stays direct even when the captured setting allows system proxies. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct ProxyPolicy { + use_system_proxy: bool, + bypass_proxy: bool, +} + +impl ProxyPolicy { + pub(crate) fn for_origin(origin: &str, use_system_proxy: bool) -> Self { + Self { + use_system_proxy, + bypass_proxy: crate::net::should_bypass_proxy(origin, use_system_proxy), + } + } + + fn apply(self, builder: reqwest::ClientBuilder) -> reqwest::ClientBuilder { + if self.bypass_proxy { + builder.no_proxy() + } else { + builder + } + } +} + +/// No caller-supplied HTTP client can weaken the transport's TLS or redirect policy. +#[derive(Clone)] +pub(crate) struct Transport { + origin: Url, + client: Client, + capabilities: Arc, + proxy_policy: ProxyPolicy, +} + +impl fmt::Debug for Transport { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.debug_struct("Transport").finish_non_exhaustive() + } +} + +/// Native-only, origin-bound credentials; never serialize this into a UI result. +pub(crate) struct SyncSession { + origin: Url, + session: AuthSession, +} + +impl fmt::Debug for SyncSession { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("SyncSession") + .finish_non_exhaustive() + } +} + +impl SyncSession { + pub(crate) fn account(&self) -> &super::types::Account { + &self.session.account + } + + pub(crate) fn account_id(&self) -> &GithubId { + &self.session.account.github_id + } + + pub(crate) fn expires_in(&self) -> u32 { + self.session.expires_in + } +} + +/// A metadata representation and its opaque ETag, bound to one service origin. +#[derive(Clone, Debug)] +pub(crate) struct Metadata { + origin: Url, + value: VaultMetadata, + etag: StrongEtag, +} + +impl Metadata { + pub(crate) fn value(&self) -> &VaultMetadata { + &self.value + } + + #[cfg(test)] + pub(crate) fn etag(&self) -> &StrongEtag { + &self.etag + } +} + +#[derive(Debug)] +pub(crate) enum MetadataResult { + Modified(Box), + /// The caller retains the same account's supplied cached metadata. + NotModified, +} + +#[derive(Clone, Copy, Debug, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +enum MutationMethod { + Upload, + Delete, +} + +impl MutationMethod { + fn http_method(self) -> Method { + match self { + Self::Upload => Method::PUT, + Self::Delete => Method::DELETE, + } + } + + fn path(self) -> &'static str { + match self { + Self::Upload => "/v1/me/vault/snapshot", + Self::Delete => "/v1/me/vault", + } + } + + fn body_limit(self) -> usize { + match self { + Self::Upload => HTTP_BODY_LIMIT, + Self::Delete => CONTROL_BODY_LIMIT, + } + } +} + +/// Local persistence envelope, never an HTTP request. Cow borrows the original +/// bytes when exporting; deserialization deliberately owns every string. +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct PendingRecord<'a> { + record_version: u32, + origin: Cow<'a, str>, + owner_github_id: GithubId, + method: MutationMethod, + body: Cow<'a, str>, + if_match: Cow<'a, str>, + operation_id: UuidV4, +} + +impl Validate for PendingRecord<'_> { + fn validate(&self) -> Result<()> { + if self.record_version != 1 { + return Err(Error::InvalidWire("pending record version")); + } + if self.origin.len() > PENDING_ORIGIN_LIMIT || self.body.len() > self.method.body_limit() { + return Err(Error::PayloadTooLarge); + } + StrongEtag::parse(&self.if_match)?; + Ok(()) + } +} + +/// Immutable retry material. Retain it before starting a mutation; durable +/// pending-operation storage belongs to the native sync coordinator. +/// +/// Never regenerate ciphertext, an operation ID, or an If-Match value because a +/// request timed out. A missing receipt also does not prove a write failed. +#[derive(Clone)] +pub(crate) struct PreparedOperation { + origin: Url, + owner: GithubId, + method: MutationMethod, + body: Vec, + if_match: StrongEtag, + operation_id: UuidV4, + expected_kind: OperationKind, + expected_revision: Revision, + expected_vault: UuidV4, + expected_sha256: Option, +} + +impl fmt::Debug for PreparedOperation { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("PreparedOperation") + .field("method", &self.method) + .field("body_bytes", &self.body.len()) + .finish_non_exhaustive() + } +} + +impl PreparedOperation { + #[cfg(test)] + pub(crate) fn body_bytes(&self) -> &[u8] { + &self.body + } + + #[cfg(test)] + pub(crate) fn if_match(&self) -> &StrongEtag { + &self.if_match + } + + pub(crate) fn operation_id(&self) -> &UuidV4 { + &self.operation_id + } + + /// Export retry material without credentials, filesystem access or network + /// access. The native coordinator owns durable storage and its lifecycle. + /// Body bytes are kept verbatim, including JSON whitespace and key ordering. + pub(crate) fn to_pending_record(&self) -> Result> { + let body = std::str::from_utf8(&self.body) + .map_err(|_| Error::InvalidWire("pending request encoding"))?; + let record = PendingRecord { + record_version: 1, + origin: Cow::Borrowed(self.origin.as_str()), + owner_github_id: self.owner.clone(), + method: self.method, + body: Cow::Borrowed(body), + if_match: Cow::Borrowed(self.if_match.as_str()), + operation_id: self.operation_id.clone(), + }; + record.validate()?; + serialize_body(&record, PENDING_RECORD_LIMIT) + } + + fn validate_receipt(&self, receipt: &OperationReceipt) -> Result<()> { + receipt.validate()?; + if receipt.operation_id != self.operation_id + || receipt.kind != self.expected_kind + || receipt.committed_revision != self.expected_revision + || receipt.vault_id != self.expected_vault + || receipt.ciphertext_sha256 != self.expected_sha256 + { + return Err(Error::InvalidResponse("receipt does not match operation")); + } + Ok(()) + } +} + +#[derive(Debug)] +pub(crate) struct MutationReceipt { + pub(crate) receipt: OperationReceipt, + /// This confirms an earlier commit, never that its revision is still current. + pub(crate) replayed: bool, +} + +#[derive(Debug)] +pub(crate) enum OperationStatus { + Committed(OperationReceipt), + Pending(OperationPending), + /// Keep the original operation. It may still be in flight or have expired + /// from the server's receipt retention window. + NotFound, +} + +impl Transport { + /// Check capabilities without credentials before allowing authentication. + pub(crate) async fn new( + origin: &str, + expected_github_client_id: &str, + proxy_policy: ProxyPolicy, + ) -> Result { + let origin = parse_origin(origin, false)?; + let proxy_policy = ProxyPolicy::for_origin(origin.as_str(), proxy_policy.use_system_proxy); + let client = client_builder(proxy_policy) + .https_only(true) + .build() + .map_err(|_| Error::Transport)?; + Self::check_capabilities(origin, client, expected_github_client_id, proxy_policy).await + } + + async fn check_capabilities( + origin: Url, + client: Client, + expected_github_client_id: &str, + proxy_policy: ProxyPolicy, + ) -> Result { + if expected_github_client_id.is_empty() + || expected_github_client_id.len() > 128 + || !expected_github_client_id + .bytes() + .all(|byte| byte.is_ascii_graphic()) + { + return Err(Error::InvalidEndpoint); + } + let mut endpoint = origin.clone(); + endpoint.set_path("/v1/capabilities"); + let response = send(client.get(endpoint).header(ACCEPT, "application/json")).await?; + let capabilities: Capabilities = success_json(response, CONTROL_BODY_LIMIT).await?; + if capabilities.github_client_id != expected_github_client_id { + return Err(Error::InvalidResponse("OAuth application mismatch")); + } + Ok(Self { + origin, + client, + capabilities: Arc::new(capabilities), + proxy_policy, + }) + } + + pub(crate) fn capabilities(&self) -> &Capabilities { + &self.capabilities + } + + pub(crate) fn proxy_policy(&self) -> ProxyPolicy { + self.proxy_policy + } + + /// Canonical HTTPS origin for native secure-store/journal scope binding. + pub(crate) fn service_origin(&self) -> &str { + self.origin.as_str() + } + + pub(crate) async fn exchange( + &self, + github_token: &str, + expected_account: &GithubId, + ) -> Result { + let response = send( + self.request(Method::POST, "/v1/auth/github") + .header(AUTHORIZATION, bearer_header(github_token)?), + ) + .await?; + let session: AuthSession = success_json(response, CONTROL_BODY_LIMIT).await?; + if &session.account.github_id != expected_account { + return Err(Error::AccountMismatch); + } + Ok(SyncSession { + origin: self.origin.clone(), + session, + }) + } + + /// Consume the local credential even when remote logout fails. A validated + /// 401 means the credential is already unusable and is treated as logged out. + pub(crate) async fn revoke_session(&self, session: SyncSession) -> Result<()> { + let response = + send(self.authenticated_request(&session, Method::DELETE, "/v1/auth/session")?).await?; + if response.status() == StatusCode::NO_CONTENT { + return empty_response(response).await; + } + let error = api_error(response).await; + match error { + Error::Api { + status: 401, + code: RemoteErrorCode::Unauthenticated | RemoteErrorCode::SessionExpired, + .. + } => Ok(()), + error => Err(error), + } + } + + pub(crate) async fn metadata( + &self, + session: &SyncSession, + cached: Option<&Metadata>, + ) -> Result { + let mut request = self.authenticated_request(session, Method::GET, "/v1/me/vault")?; + if let Some(metadata) = cached { + self.check_metadata(session, metadata)?; + request = request.header(IF_NONE_MATCH, metadata.etag.as_str()); + } + let response = send(request).await?; + if response.status() == StatusCode::NOT_MODIFIED { + let metadata = + cached.ok_or(Error::InvalidResponse("unsolicited not-modified response"))?; + if response_etag(response.headers())? != metadata.etag { + return Err(Error::InvalidResponse("not-modified ETag mismatch")); + } + empty_response(response).await?; + return Ok(MetadataResult::NotModified); + } + // This route always represents empty/deleted state with a 200 DTO. + if response.status() == StatusCode::NOT_FOUND { + return Err(Error::InvalidResponse("metadata route unavailable")); + } + if response.status() != StatusCode::OK { + return Err(api_error(response).await); + } + let etag = response_etag(response.headers())?; + let value: VaultMetadata = response_json(response, CONTROL_BODY_LIMIT).await?; + if &value.owner_github_id != session.account_id() { + return Err(Error::AccountMismatch); + } + Ok(MetadataResult::Modified(Box::new(Metadata { + origin: self.origin.clone(), + value, + etag, + }))) + } + + pub(crate) async fn snapshot( + &self, + session: &SyncSession, + metadata: &Metadata, + ) -> Result { + self.check_metadata(session, metadata)?; + if metadata.value.state != VaultState::Active { + return Err(Error::ContextMismatch); + } + let response = send( + self.authenticated_request(session, Method::GET, "/v1/me/vault/snapshot")? + .header(IF_MATCH, metadata.etag.as_str()), + ) + .await?; + if response.status() != StatusCode::OK { + return Err(api_error(response).await); + } + if response_etag(response.headers())? != metadata.etag { + return Err(Error::InvalidResponse("snapshot ETag mismatch")); + } + let snapshot: SnapshotUpload = response_json(response, HTTP_BODY_LIMIT).await?; + snapshot.validate_against_metadata(&metadata.value, session.account_id())?; + Ok(snapshot) + } + + /// Active uploads also require their downloaded predecessor, because metadata + /// deliberately does not expose the KDF salt that must stay in its key epoch. + pub(crate) fn prepare_upload( + &self, + session: &SyncSession, + metadata: &Metadata, + upload: &SnapshotUpload, + previous_snapshot: Option<&SnapshotUpload>, + ) -> Result { + self.check_metadata(session, metadata)?; + upload.validate()?; + if &upload.owner_github_id != session.account_id() { + return Err(Error::AccountMismatch); + } + if upload.base_revision != metadata.value.revision + || upload.revision != metadata.value.revision.checked_next()? + || metadata.value.last_operation_id.as_ref() == Some(&upload.operation_id) + { + return Err(Error::ContextMismatch); + } + let expected_kind = match upload.kind { + UploadKind::Create => { + if metadata.value.state == VaultState::Active + || metadata.value.vault_id.as_ref() == Some(&upload.vault_id) + || previous_snapshot.is_some() + { + return Err(Error::ContextMismatch); + } + OperationKind::Create + } + UploadKind::Snapshot | UploadKind::PasswordChange => { + let previous = previous_snapshot.ok_or(Error::ContextMismatch)?; + previous.validate_against_metadata(&metadata.value, session.account_id())?; + if upload.vault_id != previous.vault_id + || upload.operation_id == previous.operation_id + || upload.nonce == previous.nonce + { + return Err(Error::ContextMismatch); + } + let same_key = upload.key_id == previous.key_id; + let same_salt = upload.kdf.salt == previous.kdf.salt; + if (upload.kind == UploadKind::Snapshot && (!same_key || !same_salt)) + || (upload.kind == UploadKind::PasswordChange && (same_key || same_salt)) + { + return Err(Error::ContextMismatch); + } + OperationKind::from(upload.kind) + } + }; + let body = serialize_body(upload, HTTP_BODY_LIMIT)?; + Ok(PreparedOperation { + origin: self.origin.clone(), + owner: session.account_id().clone(), + method: MutationMethod::Upload, + body, + if_match: metadata.etag.clone(), + operation_id: upload.operation_id.clone(), + expected_kind, + expected_revision: upload.revision, + expected_vault: upload.vault_id.clone(), + expected_sha256: Some(upload.ciphertext_sha256.clone()), + }) + } + + pub(crate) fn prepare_delete( + &self, + session: &SyncSession, + metadata: &Metadata, + operation_id: UuidV4, + ) -> Result { + self.check_metadata(session, metadata)?; + if metadata.value.state != VaultState::Active + || metadata.value.last_operation_id.as_ref() == Some(&operation_id) + { + return Err(Error::ContextMismatch); + } + let vault_id = metadata + .value + .vault_id + .clone() + .ok_or(Error::ContextMismatch)?; + let expected_revision = metadata.value.revision.checked_next()?; + let request = DeleteVaultRequest { + protocol_version: PROTOCOL_VERSION, + base_revision: metadata.value.revision, + operation_id: operation_id.clone(), + expected_vault_id: vault_id.clone(), + }; + request.validate()?; + Ok(PreparedOperation { + origin: self.origin.clone(), + owner: session.account_id().clone(), + method: MutationMethod::Delete, + body: serialize_body(&request, CONTROL_BODY_LIMIT)?, + if_match: metadata.etag.clone(), + operation_id, + expected_kind: OperationKind::Delete, + expected_revision, + expected_vault: vault_id, + expected_sha256: None, + }) + } + + /// Recover an already prepared operation after a restart. This performs only + /// local validation: current metadata must not replace the original CAS + /// precondition, and receipt retention/reconciliation belongs to the caller. + /// Expected receipt fields are derived from the validated original body. + pub(crate) fn restore_pending( + &self, + session: &SyncSession, + bytes: &[u8], + ) -> Result { + self.check_session(session)?; + let record: PendingRecord<'static> = parse_wire(bytes, PENDING_RECORD_LIMIT)?; + // The HTTP exception is compiled exclusively for the loopback unit tests; + // production records must have an HTTPS origin just like Transport::new. + let origin = parse_origin(&record.origin, cfg!(test))?; + if origin != self.origin { + return Err(Error::ContextMismatch); + } + if &record.owner_github_id != session.account_id() { + return Err(Error::AccountMismatch); + } + let if_match = StrongEtag::parse(&record.if_match)?; + let (expected_kind, expected_revision, expected_vault, expected_sha256) = match record + .method + { + MutationMethod::Upload => { + let upload: SnapshotUpload = parse_wire(record.body.as_bytes(), HTTP_BODY_LIMIT)?; + if &upload.owner_github_id != session.account_id() { + return Err(Error::AccountMismatch); + } + if upload.operation_id != record.operation_id { + return Err(Error::ContextMismatch); + } + if upload.kind != UploadKind::Create && upload.base_revision.get() == 0 { + return Err(Error::InvalidWire("pending upload revision")); + } + ( + OperationKind::from(upload.kind), + upload.revision, + upload.vault_id, + Some(upload.ciphertext_sha256), + ) + } + MutationMethod::Delete => { + let request: DeleteVaultRequest = + parse_wire(record.body.as_bytes(), CONTROL_BODY_LIMIT)?; + if request.operation_id != record.operation_id { + return Err(Error::ContextMismatch); + } + ( + OperationKind::Delete, + request.base_revision.checked_next()?, + request.expected_vault_id, + None, + ) + } + }; + Ok(PreparedOperation { + origin, + owner: record.owner_github_id, + method: record.method, + body: record.body.into_owned().into_bytes(), + if_match, + operation_id: record.operation_id, + expected_kind, + expected_revision, + expected_vault, + expected_sha256, + }) + } + + /// Every error (including cancellation) leaves the borrowed operation intact. + /// The caller must reconcile it, never infer from an error that no commit occurred. + pub(crate) async fn submit( + &self, + session: &SyncSession, + operation: &PreparedOperation, + ) -> Result { + self.check_operation(session, operation)?; + let response = send( + self.authenticated_request( + session, + operation.method.http_method(), + operation.method.path(), + )? + .header(CONTENT_TYPE, "application/json") + .header(IF_MATCH, operation.if_match.as_str()) + .header(IDEMPOTENCY_KEY, operation.operation_id.as_str()) + .body(operation.body.clone()), + ) + .await?; + if response.status() != StatusCode::OK { + return Err(api_error(response).await); + } + let replayed = match single_header(response.headers(), IDEMPOTENCY_REPLAYED)? { + "true" => true, + "false" => false, + _ => return Err(Error::InvalidResponse("invalid replay header")), + }; + let receipt: OperationReceipt = response_json(response, CONTROL_BODY_LIMIT).await?; + operation.validate_receipt(&receipt)?; + Ok(MutationReceipt { receipt, replayed }) + } + + pub(crate) async fn operation( + &self, + session: &SyncSession, + operation: &PreparedOperation, + ) -> Result { + self.check_operation(session, operation)?; + let path = format!("/v1/me/operations/{}", operation.operation_id); + let response = send(self.authenticated_request(session, Method::GET, &path)?).await?; + match response.status() { + StatusCode::OK => { + let receipt: OperationReceipt = response_json(response, CONTROL_BODY_LIMIT).await?; + operation.validate_receipt(&receipt)?; + Ok(OperationStatus::Committed(receipt)) + } + StatusCode::ACCEPTED => { + let pending: OperationPending = response_json(response, CONTROL_BODY_LIMIT).await?; + if pending.operation_id != operation.operation_id { + return Err(Error::InvalidResponse("pending operation mismatch")); + } + Ok(OperationStatus::Pending(pending)) + } + _ => match api_error(response).await { + Error::Api { + status: 404, + code: RemoteErrorCode::OperationNotFound, + .. + } => Ok(OperationStatus::NotFound), + error => Err(error), + }, + } + } + + fn request(&self, method: Method, path: &str) -> RequestBuilder { + let mut endpoint = self.origin.clone(); + endpoint.set_path(path); + self.client + .request(method, endpoint) + .header(ACCEPT, "application/json") + } + + fn authenticated_request( + &self, + session: &SyncSession, + method: Method, + path: &str, + ) -> Result { + self.check_session(session)?; + Ok(self.request(method, path).header( + AUTHORIZATION, + bearer_header(session.session.access_token.expose())?, + )) + } + + fn check_session(&self, session: &SyncSession) -> Result<()> { + if session.origin != self.origin { + return Err(Error::ContextMismatch); + } + session.session.validate() + } + + fn check_metadata(&self, session: &SyncSession, metadata: &Metadata) -> Result<()> { + self.check_session(session)?; + if metadata.origin != self.origin { + return Err(Error::ContextMismatch); + } + if &metadata.value.owner_github_id != session.account_id() { + return Err(Error::AccountMismatch); + } + metadata.value.validate() + } + + fn check_operation(&self, session: &SyncSession, operation: &PreparedOperation) -> Result<()> { + self.check_session(session)?; + if operation.origin != self.origin { + return Err(Error::ContextMismatch); + } + if &operation.owner != session.account_id() { + return Err(Error::AccountMismatch); + } + Ok(()) + } + + /// Loopback HTTP exists only in unit tests, with the same redirect and body policy. + #[cfg(test)] + pub(crate) async fn for_test(origin: &str, expected_client_id: &str) -> Result { + Self::for_test_with_proxy_policy( + origin, + expected_client_id, + ProxyPolicy::for_origin(origin, false), + ) + .await + } + + #[cfg(test)] + pub(crate) async fn for_test_with_proxy_policy( + origin: &str, + expected_client_id: &str, + proxy_policy: ProxyPolicy, + ) -> Result { + let origin = parse_origin(origin, true)?; + let proxy_policy = ProxyPolicy::for_origin(origin.as_str(), proxy_policy.use_system_proxy); + let client = client_builder(proxy_policy) + .build() + .map_err(|_| Error::Transport)?; + Self::check_capabilities(origin, client, expected_client_id, proxy_policy).await + } +} + +fn client_builder(proxy_policy: ProxyPolicy) -> reqwest::ClientBuilder { + let mut headers = HeaderMap::new(); + headers.insert(ACCEPT_ENCODING, HeaderValue::from_static("identity")); + proxy_policy.apply( + Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .referer(false) + .connect_timeout(CONNECT_TIMEOUT) + .timeout(REQUEST_TIMEOUT) + // Keep byte limits meaningful even if another workspace dependency enables + // reqwest compression features through Cargo feature unification. + .no_gzip() + .no_brotli() + .no_deflate() + .no_zstd() + .default_headers(headers), + ) +} + +fn parse_origin(input: &str, test_loopback: bool) -> Result { + if input.chars().any(char::is_whitespace) { + return Err(Error::InvalidEndpoint); + } + let url = Url::parse(input).map_err(|_| Error::InvalidEndpoint)?; + let https = url.scheme() == "https"; + let loopback = cfg!(test) + && test_loopback + && url.scheme() == "http" + && url.host_str() == Some("127.0.0.1"); + if (!https && !loopback) + || url.host_str().is_none() + || !url.username().is_empty() + || url.password().is_some() + || url.path() != "/" + || url.query().is_some() + || url.fragment().is_some() + { + return Err(Error::InvalidEndpoint); + } + Ok(url) +} + +fn bearer_header(token: &str) -> Result { + if token.is_empty() || token.len() > 2048 || !token.bytes().all(|byte| byte.is_ascii_graphic()) + { + return Err(Error::InvalidWire("invalid token")); + } + let value = Zeroizing::new(format!("Bearer {token}")); + let mut header = + HeaderValue::from_str(&value).map_err(|_| Error::InvalidWire("invalid token"))?; + header.set_sensitive(true); + Ok(header) +} + +async fn send(request: RequestBuilder) -> Result { + let response = request.send().await.map_err(|_| Error::Transport)?; + if response.status().is_redirection() && response.status() != StatusCode::NOT_MODIFIED { + return Err(Error::InvalidResponse("redirect refused")); + } + Ok(response) +} + +fn single_header<'a>(headers: &'a HeaderMap, name: &str) -> Result<&'a str> { + let mut values = headers.get_all(name).iter(); + let value = values + .next() + .ok_or(Error::InvalidResponse("required header missing"))?; + if values.next().is_some() { + return Err(Error::InvalidResponse("duplicate response header")); + } + value + .to_str() + .map_err(|_| Error::InvalidResponse("invalid response header")) +} + +fn response_etag(headers: &HeaderMap) -> Result { + StrongEtag::parse(single_header(headers, ETAG.as_str())?) + .map_err(|_| Error::InvalidResponse("invalid strong ETag")) +} + +fn private_response_headers(headers: &HeaderMap, json: bool) -> Result<()> { + if !single_header(headers, CACHE_CONTROL.as_str())?.eq_ignore_ascii_case("no-store") { + return Err(Error::InvalidResponse("response must be no-store")); + } + if headers.contains_key(CONTENT_ENCODING) + && !single_header(headers, CONTENT_ENCODING.as_str())?.eq_ignore_ascii_case("identity") + { + return Err(Error::InvalidResponse("encoded response refused")); + } + if json { + let value = single_header(headers, CONTENT_TYPE.as_str())?; + let mut pieces = value.split(';'); + if !pieces + .next() + .is_some_and(|value| value.trim().eq_ignore_ascii_case("application/json")) + { + return Err(Error::InvalidResponse("response must be JSON")); + } + if let Some(parameter) = pieces.next() { + let Some((key, value)) = parameter.trim().split_once('=') else { + return Err(Error::InvalidResponse("invalid JSON content type")); + }; + if !key.trim().eq_ignore_ascii_case("charset") + || !matches!( + value.trim().to_ascii_lowercase().as_str(), + "utf-8" | "\"utf-8\"" + ) + || pieces.next().is_some() + { + return Err(Error::InvalidResponse("invalid JSON content type")); + } + } + } + Ok(()) +} + +async fn bounded_body(mut response: Response, limit: usize) -> Result>> { + if response.headers().contains_key(CONTENT_LENGTH) { + let declared = single_header(response.headers(), CONTENT_LENGTH.as_str())? + .parse::() + .map_err(|_| Error::InvalidResponse("invalid content length"))?; + if declared > u64::try_from(limit).map_err(|_| Error::PayloadTooLarge)? { + return Err(Error::PayloadTooLarge); + } + } + // Control responses can contain a sync token. Reserve their bounded capacity + // once so Vec growth does not leave an older allocation containing that token. + let mut bytes = Zeroizing::new(Vec::with_capacity(limit.min(CONTROL_BODY_LIMIT))); + while let Some(chunk) = response.chunk().await.map_err(|_| Error::Transport)? { + if chunk.len() > limit.saturating_sub(bytes.len()) { + return Err(Error::PayloadTooLarge); + } + bytes.extend_from_slice(&chunk); + } + Ok(bytes) +} + +async fn response_json( + response: Response, + limit: usize, +) -> Result { + private_response_headers(response.headers(), true)?; + let body = bounded_body(response, limit).await?; + parse_wire(&body, limit) +} + +async fn success_json( + response: Response, + limit: usize, +) -> Result { + if response.status() != StatusCode::OK { + return Err(api_error(response).await); + } + response_json(response, limit).await +} + +async fn empty_response(mut response: Response) -> Result<()> { + private_response_headers(response.headers(), false)?; + // HTTP permits a 304 Content-Length to describe the full representation, + // even though the 304 itself has no body. Do not mistake it for payload. + if response.status() == StatusCode::NOT_MODIFIED { + response.headers_mut().remove(CONTENT_LENGTH); + } + let body = bounded_body(response, 0).await?; + if !body.is_empty() { + return Err(Error::InvalidResponse("unexpected response body")); + } + Ok(()) +} + +fn serialize_body(value: &T, limit: usize) -> Result> { + let body = serde_json::to_vec(value).map_err(|_| Error::InvalidWire("serialization failed"))?; + if body.len() > limit { + return Err(Error::PayloadTooLarge); + } + Ok(body) +} + +async fn api_error(response: Response) -> Error { + async fn decode(response: Response) -> Result { + let status = response.status().as_u16(); + if !(400..600).contains(&status) { + return Err(Error::InvalidResponse("unexpected response status")); + } + let retry_after_seconds = if status == 429 { + let value = single_header(response.headers(), RETRY_AFTER.as_str())?; + if value.is_empty() || !value.bytes().all(|byte| byte.is_ascii_digit()) { + return Err(Error::InvalidResponse("invalid retry delay")); + } + let seconds = value + .parse::() + .map_err(|_| Error::InvalidResponse("invalid retry delay"))?; + if !(1..=86400).contains(&seconds) { + return Err(Error::InvalidResponse("invalid retry delay")); + } + Some(seconds) + } else { + None + }; + let envelope: ErrorEnvelope = response_json(response, CONTROL_BODY_LIMIT).await?; + if !error_status_matches(envelope.error.code, status) { + return Err(Error::InvalidResponse("error code and status mismatch")); + } + Ok(Error::Api { + status, + code: envelope.error.code, + retry_after_seconds, + current_revision: envelope.error.current_revision, + }) + } + match decode(response).await { + Ok(error) | Err(error) => error, + } +} + +fn error_status_matches(code: RemoteErrorCode, status: u16) -> bool { + match code { + RemoteErrorCode::InvalidRequest + | RemoteErrorCode::UnsupportedProtocol + | RemoteErrorCode::InvalidCryptoHeader => status == 400, + RemoteErrorCode::Unauthenticated | RemoteErrorCode::SessionExpired => status == 401, + RemoteErrorCode::WrongOauthApp | RemoteErrorCode::OwnerMismatch => status == 403, + RemoteErrorCode::VaultEmpty | RemoteErrorCode::VaultDeleted => matches!(status, 404 | 409), + RemoteErrorCode::OperationNotFound => status == 404, + RemoteErrorCode::IdempotencyKeyReused + | RemoteErrorCode::KeyEpochChanged + | RemoteErrorCode::VaultExists + | RemoteErrorCode::RevisionExhausted => status == 409, + RemoteErrorCode::RevisionConflict => status == 412, + RemoteErrorCode::PayloadTooLarge => status == 413, + RemoteErrorCode::UnsupportedMediaType => status == 415, + RemoteErrorCode::PreconditionRequired => status == 428, + RemoteErrorCode::RateLimited => status == 429, + RemoteErrorCode::InternalError => status == 500, + RemoteErrorCode::ServiceUnavailable => status == 503, + } +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; + use serde_json::{json, Value}; + use sha2::{Digest, Sha256}; + use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::TcpListener, + task::JoinHandle, + }; + + use super::*; + + const CLIENT_ID: &str = "test-openless-oauth-app"; + const OWNER: &str = "12345"; + const VAULT: &str = "be406ca5-37fa-4b26-9a9a-74c1aad48eae"; + const KEY: &str = "2b1889ab-7278-48e7-8d2f-284195de2484"; + const OLD_OPERATION: &str = "e1d8c32e-d209-4e56-8735-bc66b8684c71"; + const NEW_OPERATION: &str = "f1d8c32e-d209-4e56-8735-bc66b8684c71"; + + struct Reply { + status: &'static str, + headers: Vec<(String, String)>, + body: Vec, + chunked: bool, + disconnect: bool, + } + + impl Reply { + fn json(status: &'static str, value: Value) -> Self { + Self::raw(status, serde_json::to_vec(&value).unwrap()) + } + + fn raw(status: &'static str, body: Vec) -> Self { + Self { + status, + headers: vec![ + ("Cache-Control".into(), "no-store".into()), + ("Content-Type".into(), "application/json".into()), + ], + body, + chunked: false, + disconnect: false, + } + } + + fn header(mut self, key: &str, value: &str) -> Self { + self.headers + .retain(|(name, _)| !name.eq_ignore_ascii_case(key)); + self.headers.push((key.into(), value.into())); + self + } + + fn without_header(mut self, key: &str) -> Self { + self.headers + .retain(|(name, _)| !name.eq_ignore_ascii_case(key)); + self + } + + fn chunked(mut self) -> Self { + self.chunked = true; + self + } + + fn disconnect() -> Self { + let mut reply = Self::raw("200 OK", Vec::new()); + reply.disconnect = true; + reply + } + } + + #[derive(Debug)] + struct RecordedRequest { + method: String, + path: String, + headers: BTreeMap, + body: Vec, + } + + struct FakeServer { + origin: String, + task: Option>>, + } + + impl Drop for FakeServer { + fn drop(&mut self) { + if let Some(task) = &self.task { + task.abort(); + } + } + } + + impl FakeServer { + async fn start(replies: Vec) -> Self { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let origin = format!("http://{}", listener.local_addr().unwrap()); + let task = tokio::spawn(async move { + let mut requests = Vec::new(); + for reply in replies { + let (mut socket, _) = listener.accept().await.unwrap(); + let mut wire = Vec::new(); + let mut buffer = [0_u8; 8192]; + let header_end = loop { + let size = socket.read(&mut buffer).await.unwrap(); + assert_ne!(size, 0, "request closed before headers"); + wire.extend_from_slice(&buffer[..size]); + if let Some(index) = wire.windows(4).position(|part| part == b"\r\n\r\n") { + break index + 4; + } + }; + let head = std::str::from_utf8(&wire[..header_end]).unwrap(); + let mut lines = head.split("\r\n"); + let mut request_line = lines.next().unwrap().split_whitespace(); + let method = request_line.next().unwrap().to_owned(); + let path = request_line.next().unwrap().to_owned(); + let headers: BTreeMap = lines + .filter_map(|line| line.split_once(':')) + .map(|(name, value)| (name.to_ascii_lowercase(), value.trim().to_owned())) + .collect(); + let body_size = headers + .get("content-length") + .map_or(0, |value| value.parse::().unwrap()); + while wire.len() < header_end + body_size { + let size = socket.read(&mut buffer).await.unwrap(); + assert_ne!(size, 0, "request closed before body"); + wire.extend_from_slice(&buffer[..size]); + } + requests.push(RecordedRequest { + method, + path, + headers, + body: wire[header_end..header_end + body_size].to_vec(), + }); + if reply.disconnect { + continue; + } + let mut response = + format!("HTTP/1.1 {}\r\nConnection: close\r\n", reply.status); + for (key, value) in &reply.headers { + response.push_str(&format!("{key}: {value}\r\n")); + } + if reply.chunked { + response.push_str("Transfer-Encoding: chunked\r\n"); + } else if !reply + .headers + .iter() + .any(|(name, _)| name.eq_ignore_ascii_case("Content-Length")) + { + response.push_str(&format!("Content-Length: {}\r\n", reply.body.len())); + } + response.push_str("\r\n"); + if socket.write_all(response.as_bytes()).await.is_err() { + continue; + } + if reply.chunked { + for chunk in reply.body.chunks(4096) { + if socket + .write_all(format!("{:x}\r\n", chunk.len()).as_bytes()) + .await + .is_err() + || socket.write_all(chunk).await.is_err() + || socket.write_all(b"\r\n").await.is_err() + { + break; + } + } + let _ = socket.write_all(b"0\r\n\r\n").await; + } else { + let _ = socket.write_all(&reply.body).await; + } + } + requests + }); + Self { + origin, + task: Some(task), + } + } + + async fn finish(mut self) -> Vec { + let mut task = self.task.take().unwrap(); + match tokio::time::timeout(Duration::from_secs(5), &mut task).await { + Ok(result) => result.unwrap(), + Err(_) => { + task.abort(); + panic!("fake HTTP server did not receive all expected requests"); + } + } + } + } + + fn capabilities() -> Value { + json!({ + "protocolVersion": 1, + "cryptoProfile": "argon2id-xchacha20poly1305-v1", + "githubClientId": CLIENT_ID, + "maxHttpBodyBytes": 25165824, + "maxCiphertextBytes": 16777232, + "maxPlaintextJsonBytes": 15728640, + "idempotencyRetentionSeconds": 604800, + "maxBackupRetentionDays": 30 + }) + } + + fn caps_reply() -> Reply { + Reply::json("200 OK", capabilities()) + } + + fn auth(owner: &str, token_character: char) -> Value { + json!({ + "protocolVersion": 1, + "accessToken": token_character.to_string().repeat(43), + "tokenType": "Bearer", + "expiresIn": 900, + "account": { "githubId": owner, "login": "fixture-user" } + }) + } + + fn empty_metadata() -> Value { + json!({ + "protocolVersion": 1, "state": "empty", "ownerGithubId": OWNER, + "revision": "0", "vaultId": null, "keyId": null, "updatedAt": null, + "payloadSchemaVersion": null, "ciphertextBytes": 0, + "ciphertextSha256": null, "lastOperationId": null + }) + } + + fn snapshot() -> Value { + let ciphertext = vec![0_u8; 65552]; + json!({ + "protocolVersion": 1, "payloadSchemaVersion": 1, "ownerGithubId": OWNER, + "vaultId": VAULT, "keyId": KEY, "baseRevision": "7", "revision": "8", + "operationId": OLD_OPERATION, "kind": "snapshot", + "cryptoProfile": "argon2id-xchacha20poly1305-v1", + "kdf": { "name": "argon2id", "version": 19, "memoryKiB": 65536, + "iterations": 3, "parallelism": 4, "salt": URL_SAFE_NO_PAD.encode([0_u8; 16]) }, + "aead": "xchacha20poly1305-ietf", "codec": "json-pad64k-v1", + "nonce": URL_SAFE_NO_PAD.encode([0_u8; 24]), + "ciphertextSha256": format!("{:x}", Sha256::digest(&ciphertext)), + "ciphertext": URL_SAFE_NO_PAD.encode(ciphertext) + }) + } + + fn active_metadata() -> Value { + json!({ + "protocolVersion": 1, "state": "active", "ownerGithubId": OWNER, + "revision": "8", "vaultId": VAULT, "keyId": KEY, + "updatedAt": "2026-09-23T12:00:00Z", "payloadSchemaVersion": 1, + "ciphertextBytes": 65552, "ciphertextSha256": snapshot()["ciphertextSha256"], + "lastOperationId": OLD_OPERATION + }) + } + + fn next_snapshot() -> Value { + let mut upload = snapshot(); + upload["baseRevision"] = json!("8"); + upload["revision"] = json!("9"); + upload["operationId"] = json!(NEW_OPERATION); + upload["nonce"] = json!(URL_SAFE_NO_PAD.encode([1_u8; 24])); + upload + } + + fn receipt(kind: &str) -> Value { + json!({ + "operationId": NEW_OPERATION, "status": "committed", "kind": kind, + "committedRevision": "9", "committedAt": "2026-09-23T12:00:00Z", "vaultId": VAULT, + "ciphertextSha256": if kind == "delete" { Value::Null } else { snapshot()["ciphertextSha256"].clone() } + }) + } + + fn error_envelope(code: &str) -> Value { + json!({ "error": { "code": code, "message": "fixture error", "requestId": OLD_OPERATION } }) + } + + fn typed(value: &Value) -> T { + parse_wire(&serde_json::to_vec(value).unwrap(), HTTP_BODY_LIMIT).unwrap() + } + + fn session(transport: &Transport, owner: &str) -> SyncSession { + SyncSession { + origin: transport.origin.clone(), + session: typed(&auth(owner, 'a')), + } + } + + fn metadata(transport: &Transport, value: Value) -> Metadata { + Metadata { + origin: transport.origin.clone(), + value: typed(&value), + etag: StrongEtag::parse("\"opaque-etag-8\"").unwrap(), + } + } + + fn prepared_upload(transport: &Transport, session: &SyncSession) -> PreparedOperation { + transport + .prepare_upload( + session, + &metadata(transport, active_metadata()), + &typed(&next_snapshot()), + Some(&typed(&snapshot())), + ) + .unwrap() + } + + #[tokio::test] + async fn production_constructor_rejects_insecure_or_ambiguous_origins() { + for origin in [ + "http://127.0.0.1:9", + "ftp://sync.example", + "https://user:secret@sync.example", + "https://sync.example/path", + "https://sync.example?token=secret", + "https://sync.example/#secret", + " https://sync.example", + "https://sync.\nexample", + ] { + assert!(matches!( + Transport::new(origin, CLIENT_ID, ProxyPolicy::for_origin(origin, false)).await, + Err(Error::InvalidEndpoint) + )); + } + assert!(parse_origin("https://sync.example:9443", false).is_ok()); + assert!(parse_origin("http://example.com", true).is_err()); + } + + #[test] + fn proxy_policy_records_one_setting_and_the_origin_bypass_decision() { + let remote = "https://sync.example:9443"; + let enabled = ProxyPolicy::for_origin(remote, true); + let disabled = ProxyPolicy::for_origin(remote, false); + assert!(!enabled.bypass_proxy); + assert!(disabled.bypass_proxy); + assert_ne!(enabled, disabled); + for origin in [ + "https://localhost:9443", + "https://127.0.0.1:9443", + "https://[::1]:9443", + ] { + for setting in [false, true] { + let policy = ProxyPolicy::for_origin(origin, setting); + assert_eq!(policy.use_system_proxy, setting); + assert!(policy.bypass_proxy); + } + } + } + + #[tokio::test] + async fn loopback_transport_records_policy_without_allowing_a_proxy() { + for setting in [false, true] { + let server = FakeServer::start(vec![caps_reply()]).await; + // Even a policy derived for a public origin is normalized to the + // actual constructor endpoint before client creation and recording. + let transport = Transport::for_test_with_proxy_policy( + &server.origin, + CLIENT_ID, + ProxyPolicy::for_origin("https://sync.example", setting), + ) + .await + .unwrap(); + assert_eq!( + transport.proxy_policy(), + ProxyPolicy::for_origin(&server.origin, setting) + ); + assert!(transport.proxy_policy().bypass_proxy); + assert_eq!(server.finish().await.len(), 1); + } + } + + #[tokio::test] + async fn direct_policy_clears_explicit_proxy_without_changing_process_environment() { + for setting in [false, true] { + let direct = FakeServer::start(vec![caps_reply()]).await; + let proxy = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let proxy_url = format!("http://{}", proxy.local_addr().unwrap()); + let seeded = client_builder(ProxyPolicy::for_origin("https://sync.example", true)) + .proxy(reqwest::Proxy::all(proxy_url).unwrap()); + let policy = ProxyPolicy::for_origin(&direct.origin, setting); + let response = policy + .apply(seeded) + .build() + .unwrap() + .get(format!("{}/v1/capabilities", direct.origin)) + .send() + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(direct.finish().await.len(), 1); + assert!( + tokio::time::timeout(Duration::from_millis(100), proxy.accept()) + .await + .is_err() + ); + } + } + + #[tokio::test] + async fn system_policy_retains_a_controlled_proxy_without_external_requests() { + let proxy = FakeServer::start(vec![caps_reply()]).await; + let direct = + FakeServer::start(vec![Reply::raw("503 Service Unavailable", Vec::new())]).await; + let address: std::net::SocketAddr = + direct.origin.trim_start_matches("http://").parse().unwrap(); + let origin = format!("http://sync.invalid:{}", address.port()); + let policy = ProxyPolicy::for_origin(&origin, true); + // Apply the production policy after seeding the controlled proxy. The + // DNS override also keeps a faulty direct branch inside this fixture. + let seeded = client_builder(ProxyPolicy::for_origin(&origin, false)) + .proxy(reqwest::Proxy::all(&proxy.origin).unwrap()) + .resolve("sync.invalid", address); + let client = policy.apply(seeded).build().unwrap(); + assert_eq!( + client + .get(format!("{origin}/v1/capabilities")) + .send() + .await + .unwrap() + .status(), + StatusCode::OK + ); + let requests = proxy.finish().await; + assert_eq!(requests[0].path, format!("{origin}/v1/capabilities")); + } + + #[tokio::test] + async fn capabilities_are_checked_before_auth_and_auth_account_is_bound() { + let server = + FakeServer::start(vec![caps_reply(), Reply::json("200 OK", auth(OWNER, 'a'))]).await; + let transport = Transport::for_test(&server.origin, CLIENT_ID) + .await + .unwrap(); + let session = transport + .exchange("github-fixture-token", &GithubId::parse(OWNER).unwrap()) + .await + .unwrap(); + assert_eq!(session.account_id().as_str(), OWNER); + assert_eq!(session.expires_in(), 900); + let requests = server.finish().await; + assert_eq!(requests[0].method, "GET"); + assert_eq!(requests[0].path, "/v1/capabilities"); + assert!(!requests[0].headers.contains_key("authorization")); + assert_eq!(requests[1].method, "POST"); + assert_eq!(requests[1].path, "/v1/auth/github"); + assert_eq!( + requests[1].headers["authorization"], + "Bearer github-fixture-token" + ); + assert!(requests[1].body.is_empty()); + assert_eq!(requests[1].headers["accept-encoding"], "identity"); + } + + #[tokio::test] + async fn wrong_oauth_capability_or_duplicate_wire_key_prevents_authentication() { + let mut wrong = capabilities(); + wrong["githubClientId"] = json!("another-app"); + let duplicate = serde_json::to_string(&capabilities()).unwrap().replacen( + '{', + "{\"protocolVersion\":1,", + 1, + ); + for reply in [ + Reply::json("200 OK", wrong), + Reply::raw("200 OK", duplicate.into_bytes()), + ] { + let server = FakeServer::start(vec![reply]).await; + assert!(Transport::for_test(&server.origin, CLIENT_ID) + .await + .is_err()); + let requests = server.finish().await; + assert_eq!(requests.len(), 1); + assert!(!requests[0].headers.contains_key("authorization")); + } + } + + #[tokio::test] + async fn rejects_auth_response_from_another_account() { + let server = + FakeServer::start(vec![caps_reply(), Reply::json("200 OK", auth("999", 'a'))]).await; + let transport = Transport::for_test(&server.origin, CLIENT_ID) + .await + .unwrap(); + assert!(matches!( + transport + .exchange("github-fixture-token", &GithubId::parse(OWNER).unwrap()) + .await, + Err(Error::AccountMismatch) + )); + server.finish().await; + } + + #[tokio::test] + async fn refuses_redirect_without_following_or_exposing_location() { + let server = FakeServer::start(vec![ + caps_reply(), + Reply::raw("307 Temporary Redirect", b"secret body".to_vec()) + .header("Location", "http://127.0.0.1:9/leaked-token"), + ]) + .await; + let transport = Transport::for_test(&server.origin, CLIENT_ID) + .await + .unwrap(); + let error = transport + .metadata(&session(&transport, OWNER), None) + .await + .unwrap_err(); + assert!(matches!(error, Error::InvalidResponse("redirect refused"))); + assert!(!format!("{error:?}").contains("leaked-token")); + assert_eq!(server.finish().await.len(), 2); + } + + #[tokio::test] + async fn metadata_preserves_etag_and_validates_conditional_304() { + let server = FakeServer::start(vec![ + caps_reply(), + Reply::json("200 OK", empty_metadata()).header("ETag", "\"opaque-zero\""), + Reply::raw("304 Not Modified", Vec::new()) + .without_header("Content-Type") + .header("Content-Length", "240") + .header("ETag", "\"opaque-zero\""), + ]) + .await; + let transport = Transport::for_test(&server.origin, CLIENT_ID) + .await + .unwrap(); + let session = session(&transport, OWNER); + let MetadataResult::Modified(current) = transport.metadata(&session, None).await.unwrap() + else { + panic!("expected metadata") + }; + assert_eq!(current.value().state, VaultState::Empty); + assert_eq!(current.etag().as_str(), "\"opaque-zero\""); + assert!(matches!( + transport + .metadata(&session, Some(current.as_ref())) + .await + .unwrap(), + MetadataResult::NotModified + )); + let requests = server.finish().await; + assert_eq!(requests[2].headers["if-none-match"], "\"opaque-zero\""); + } + + #[tokio::test] + async fn metadata_rejects_unbound_304_and_never_maps_404_to_empty() { + for reply in [ + Reply::raw("304 Not Modified", Vec::new()).header("ETag", "\"opaque-zero\""), + Reply::json("404 Not Found", error_envelope("vault_empty")), + Reply::json("200 OK", empty_metadata()).header("ETag", "W/\"weak\""), + ] { + let server = FakeServer::start(vec![caps_reply(), reply]).await; + let transport = Transport::for_test(&server.origin, CLIENT_ID) + .await + .unwrap(); + assert!(matches!( + transport.metadata(&session(&transport, OWNER), None).await, + Err(Error::InvalidResponse(_)) + )); + server.finish().await; + } + let server = FakeServer::start(vec![ + caps_reply(), + Reply::raw("304 Not Modified", Vec::new()).header("ETag", "\"different\""), + ]) + .await; + let transport = Transport::for_test(&server.origin, CLIENT_ID) + .await + .unwrap(); + assert!(matches!( + transport + .metadata( + &session(&transport, OWNER), + Some(&metadata(&transport, active_metadata())) + ) + .await, + Err(Error::InvalidResponse(_)) + )); + server.finish().await; + } + + #[tokio::test] + async fn rejects_non_json_non_private_encoded_and_duplicate_headers() { + let mut duplicate = Reply::json("200 OK", empty_metadata()).header("ETag", "\"tag\""); + duplicate.headers.push(("ETag".into(), "\"second\"".into())); + let good = || Reply::json("200 OK", empty_metadata()).header("ETag", "\"tag\""); + for reply in [ + good().without_header("Cache-Control"), + good().header("Cache-Control", "max-age=60"), + good().header("Cache-Control", "no-store, public"), + good().header("Content-Type", "text/html"), + good().header("Content-Type", "application/json; charset=utf-16"), + good().header("Content-Encoding", "gzip"), + duplicate, + ] { + let server = FakeServer::start(vec![caps_reply(), reply]).await; + let transport = Transport::for_test(&server.origin, CLIENT_ID) + .await + .unwrap(); + assert!(matches!( + transport.metadata(&session(&transport, OWNER), None).await, + Err(Error::InvalidResponse(_)) + )); + server.finish().await; + } + } + + #[tokio::test] + async fn snapshot_is_bound_to_metadata_account_and_strong_etag() { + let server = FakeServer::start(vec![ + caps_reply(), + Reply::json("200 OK", snapshot()).header("ETag", "\"opaque-etag-8\""), + ]) + .await; + let transport = Transport::for_test(&server.origin, CLIENT_ID) + .await + .unwrap(); + let downloaded = transport + .snapshot( + &session(&transport, OWNER), + &metadata(&transport, active_metadata()), + ) + .await + .unwrap(); + assert_eq!(downloaded.revision.get(), 8); + let requests = server.finish().await; + assert_eq!(requests[1].headers["if-match"], "\"opaque-etag-8\""); + for (field, value) in [ + ("ownerGithubId", json!("999")), + ("vaultId", json!(KEY)), + ("keyId", json!(VAULT)), + ("operationId", json!(NEW_OPERATION)), + ("ciphertextSha256", json!("0".repeat(64))), + ] { + let mut bad = snapshot(); + bad[field] = value; + let server = FakeServer::start(vec![ + caps_reply(), + Reply::json("200 OK", bad).header("ETag", "\"opaque-etag-8\""), + ]) + .await; + let transport = Transport::for_test(&server.origin, CLIENT_ID) + .await + .unwrap(); + assert!(transport + .snapshot( + &session(&transport, OWNER), + &metadata(&transport, active_metadata()) + ) + .await + .is_err()); + server.finish().await; + } + } + + #[tokio::test] + async fn lost_mutation_response_can_be_retried_with_identical_bytes_after_token_renewal() { + let server = FakeServer::start(vec![ + caps_reply(), + Reply::disconnect(), + Reply::json("200 OK", auth(OWNER, 'b')), + Reply::json("200 OK", receipt("snapshot")).header("Idempotency-Replayed", "true"), + ]) + .await; + let transport = Transport::for_test(&server.origin, CLIENT_ID) + .await + .unwrap(); + let first_session = session(&transport, OWNER); + let operation = prepared_upload(&transport, &first_session); + let exact_bytes = operation.body_bytes().to_vec(); + assert!(matches!( + transport.submit(&first_session, &operation).await, + Err(Error::Transport) + )); + let renewed = transport + .exchange("github-fixture-token", &GithubId::parse(OWNER).unwrap()) + .await + .unwrap(); + let result = transport.submit(&renewed, &operation).await.unwrap(); + assert!(result.replayed); + assert_eq!(result.receipt.committed_revision.get(), 9); + let requests = server.finish().await; + for index in [1, 3] { + assert_eq!(requests[index].method, "PUT"); + assert_eq!(requests[index].path, "/v1/me/vault/snapshot"); + assert_eq!(requests[index].body, exact_bytes); + assert_eq!( + requests[index].headers["if-match"], + operation.if_match().as_str() + ); + assert_eq!( + requests[index].headers["idempotency-key"], + operation.operation_id().as_str() + ); + } + assert_ne!( + requests[1].headers["authorization"], + requests[3].headers["authorization"] + ); + } + + #[tokio::test] + async fn prepared_operations_cannot_cross_account_or_service_boundaries() { + let server = FakeServer::start(vec![caps_reply()]).await; + let transport = Transport::for_test(&server.origin, CLIENT_ID) + .await + .unwrap(); + let session_a = session(&transport, OWNER); + let operation = prepared_upload(&transport, &session_a); + let session_b = session(&transport, "999"); + assert!(matches!( + transport.submit(&session_b, &operation).await, + Err(Error::AccountMismatch) + )); + assert!(matches!( + transport + .metadata(&session_b, Some(&metadata(&transport, active_metadata()))) + .await, + Err(Error::AccountMismatch) + )); + let mut another_origin = transport.clone(); + another_origin.origin = "https://other.example/".parse().unwrap(); + assert!(matches!( + another_origin.submit(&session_a, &operation).await, + Err(Error::ContextMismatch) + )); + assert_eq!(server.finish().await.len(), 1); + } + + #[tokio::test] + async fn active_upload_checks_key_epoch_nonce_and_base_revision_before_sending() { + let server = FakeServer::start(vec![caps_reply()]).await; + let transport = Transport::for_test(&server.origin, CLIENT_ID) + .await + .unwrap(); + let session = session(&transport, OWNER); + let current = metadata(&transport, active_metadata()); + let previous = typed(&snapshot()); + assert!(transport + .prepare_upload(&session, ¤t, &typed(&next_snapshot()), None) + .is_err()); + for (field, value) in [ + ("keyId", json!(VAULT)), + ("nonce", snapshot()["nonce"].clone()), + ("vaultId", json!(KEY)), + ] { + let mut upload = next_snapshot(); + upload[field] = value; + assert!(transport + .prepare_upload(&session, ¤t, &typed(&upload), Some(&previous)) + .is_err()); + } + let mut wrong_salt = next_snapshot(); + wrong_salt["kdf"]["salt"] = json!(URL_SAFE_NO_PAD.encode([1_u8; 16])); + assert!(transport + .prepare_upload(&session, ¤t, &typed(&wrong_salt), Some(&previous)) + .is_err()); + let mut changed = next_snapshot(); + changed["kind"] = json!("password_change"); + assert!(transport + .prepare_upload(&session, ¤t, &typed(&changed), Some(&previous)) + .is_err()); + changed["keyId"] = json!(VAULT); + changed["kdf"]["salt"] = json!(URL_SAFE_NO_PAD.encode([1_u8; 16])); + assert!(transport + .prepare_upload(&session, ¤t, &typed(&changed), Some(&previous)) + .is_ok()); + let mut create = snapshot(); + create["kind"] = json!("create"); + create["baseRevision"] = json!("0"); + create["revision"] = json!("1"); + assert!(transport + .prepare_upload( + &session, + &metadata(&transport, empty_metadata()), + &typed(&create), + None + ) + .is_ok()); + server.finish().await; + } + + #[tokio::test] + async fn delete_and_receipt_reconciliation_preserve_the_original_operation() { + let pending = + json!({"operationId":NEW_OPERATION,"status":"pending","retryAfterSeconds":30}); + let server = FakeServer::start(vec![ + caps_reply(), + Reply::json("200 OK", receipt("delete")).header("Idempotency-Replayed", "false"), + Reply::json("202 Accepted", pending), + Reply::json("404 Not Found", error_envelope("operation_not_found")), + Reply::json("200 OK", receipt("delete")), + ]) + .await; + let transport = Transport::for_test(&server.origin, CLIENT_ID) + .await + .unwrap(); + let session = session(&transport, OWNER); + let operation = transport + .prepare_delete( + &session, + &metadata(&transport, active_metadata()), + UuidV4::parse(NEW_OPERATION).unwrap(), + ) + .unwrap(); + let result = transport.submit(&session, &operation).await.unwrap(); + assert!(!result.replayed); + assert_eq!(result.receipt.kind, OperationKind::Delete); + assert!(matches!( + transport.operation(&session, &operation).await.unwrap(), + OperationStatus::Pending(_) + )); + assert!(matches!( + transport.operation(&session, &operation).await.unwrap(), + OperationStatus::NotFound + )); + assert!(matches!( + transport.operation(&session, &operation).await.unwrap(), + OperationStatus::Committed(_) + )); + let requests = server.finish().await; + assert_eq!(requests[1].method, "DELETE"); + assert_eq!(requests[1].headers["idempotency-key"], NEW_OPERATION); + assert_eq!(requests[1].body, operation.body_bytes()); + for request in &requests[2..] { + assert_eq!(request.path, format!("/v1/me/operations/{NEW_OPERATION}")); + } + } + + #[tokio::test] + async fn every_mutation_receipt_field_is_checked_against_the_prepared_request() { + for (field, value) in [ + ("operationId", json!(OLD_OPERATION)), + ("kind", json!("create")), + ("committedRevision", json!("10")), + ("vaultId", json!(KEY)), + ("ciphertextSha256", json!("0".repeat(64))), + ] { + let mut bad = receipt("snapshot"); + bad[field] = value; + let server = FakeServer::start(vec![ + caps_reply(), + Reply::json("200 OK", bad).header("Idempotency-Replayed", "false"), + ]) + .await; + let transport = Transport::for_test(&server.origin, CLIENT_ID) + .await + .unwrap(); + let session = session(&transport, OWNER); + let operation = prepared_upload(&transport, &session); + assert!(matches!( + transport.submit(&session, &operation).await, + Err(Error::InvalidResponse(_)) + )); + server.finish().await; + } + } + + #[tokio::test] + async fn control_and_snapshot_responses_are_limited_while_streaming() { + for limit in [CONTROL_BODY_LIMIT, HTTP_BODY_LIMIT] { + for length in [limit, limit + 1] { + let server = + FakeServer::start(vec![Reply::raw("200 OK", vec![b' '; length]).chunked()]) + .await; + let response = client_builder(ProxyPolicy::for_origin(&server.origin, false)) + .no_proxy() + .build() + .unwrap() + .get(&server.origin) + .send() + .await + .unwrap(); + let result = bounded_body(response, limit).await; + if length == limit { + assert_eq!(result.unwrap().len(), limit); + } else { + assert!(matches!(result, Err(Error::PayloadTooLarge))); + } + server.finish().await; + } + } + let server = FakeServer::start(vec![ + Reply::raw("200 OK", Vec::new()).header("Content-Length", "25165825") + ]) + .await; + let response = client_builder(ProxyPolicy::for_origin(&server.origin, false)) + .no_proxy() + .build() + .unwrap() + .get(&server.origin) + .send() + .await + .unwrap(); + assert!(matches!( + bounded_body(response, HTTP_BODY_LIMIT).await, + Err(Error::PayloadTooLarge) + )); + server.finish().await; + } + + #[tokio::test] + async fn errors_retain_only_allowlisted_code_and_validated_retry_delay() { + let marker = "SENSITIVE_BODY_TOKEN_https://secret.example"; + let mut remote = error_envelope("rate_limited"); + remote["error"]["message"] = json!(marker); + let server = FakeServer::start(vec![ + caps_reply(), + Reply::json("429 Too Many Requests", remote).header("Retry-After", "42"), + ]) + .await; + let transport = Transport::for_test(&server.origin, CLIENT_ID) + .await + .unwrap(); + let error = transport + .metadata(&session(&transport, OWNER), None) + .await + .unwrap_err(); + assert!(matches!( + error, + Error::Api { + status: 429, + code: RemoteErrorCode::RateLimited, + retry_after_seconds: Some(42), + .. + } + )); + assert!(!format!("{error:?} {error}").contains(marker)); + server.finish().await; + for (status, code, delay) in [ + ("429 Too Many Requests", "rate_limited", "0"), + ("429 Too Many Requests", "rate_limited", "86401"), + ("429 Too Many Requests", "rate_limited", "tomorrow"), + ("503 Service Unavailable", "rate_limited", "42"), + ] { + let server = FakeServer::start(vec![ + caps_reply(), + Reply::json(status, error_envelope(code)).header("Retry-After", delay), + ]) + .await; + let transport = Transport::for_test(&server.origin, CLIENT_ID) + .await + .unwrap(); + assert!(matches!( + transport.metadata(&session(&transport, OWNER), None).await, + Err(Error::InvalidResponse(_)) + )); + server.finish().await; + } + } + + #[tokio::test] + async fn logout_accepts_only_no_body_success_or_validated_already_revoked_error() { + for reply in [ + Reply::raw("204 No Content", Vec::new()), + Reply::json("401 Unauthorized", error_envelope("session_expired")), + ] { + let server = FakeServer::start(vec![caps_reply(), reply]).await; + let transport = Transport::for_test(&server.origin, CLIENT_ID) + .await + .unwrap(); + transport + .revoke_session(session(&transport, OWNER)) + .await + .unwrap(); + let requests = server.finish().await; + assert_eq!(requests[1].method, "DELETE"); + assert_eq!(requests[1].path, "/v1/auth/session"); + assert!(requests[1].body.is_empty()); + } + } + + #[tokio::test] + async fn snapshot_rejects_another_revision_etag_or_owner_before_returning_data() { + for (snapshot_value, etag) in [ + (next_snapshot(), "\"opaque-etag-8\""), + (snapshot(), "\"another-etag\""), + ] { + let server = FakeServer::start(vec![ + caps_reply(), + Reply::json("200 OK", snapshot_value).header("ETag", etag), + ]) + .await; + let transport = Transport::for_test(&server.origin, CLIENT_ID) + .await + .unwrap(); + assert!(transport + .snapshot( + &session(&transport, OWNER), + &metadata(&transport, active_metadata()) + ) + .await + .is_err()); + server.finish().await; + } + let mut foreign = empty_metadata(); + foreign["ownerGithubId"] = json!("999"); + let server = FakeServer::start(vec![ + caps_reply(), + Reply::json("200 OK", foreign).header("ETag", "\"foreign\""), + ]) + .await; + let transport = Transport::for_test(&server.origin, CLIENT_ID) + .await + .unwrap(); + assert!(matches!( + transport.metadata(&session(&transport, OWNER), None).await, + Err(Error::AccountMismatch) + )); + server.finish().await; + } + + #[tokio::test] + async fn missing_replay_header_and_mismatched_pending_receipt_stay_unconfirmed() { + let server = FakeServer::start(vec![ + caps_reply(), + Reply::json("200 OK", receipt("snapshot")), + Reply::json( + "202 Accepted", + json!({"operationId":OLD_OPERATION,"status":"pending","retryAfterSeconds":1}), + ), + ]) + .await; + let transport = Transport::for_test(&server.origin, CLIENT_ID) + .await + .unwrap(); + let session = session(&transport, OWNER); + let operation = prepared_upload(&transport, &session); + assert!(matches!( + transport.submit(&session, &operation).await, + Err(Error::InvalidResponse(_)) + )); + assert!(matches!( + transport.operation(&session, &operation).await, + Err(Error::InvalidResponse(_)) + )); + assert_eq!(operation.operation_id().as_str(), NEW_OPERATION); + server.finish().await; + } + + #[tokio::test] + async fn error_response_uses_the_control_limit_even_on_the_snapshot_route() { + let server = FakeServer::start(vec![ + caps_reply(), + Reply::raw( + "503 Service Unavailable", + vec![b' '; CONTROL_BODY_LIMIT + 1], + ) + .chunked(), + ]) + .await; + let transport = Transport::for_test(&server.origin, CLIENT_ID) + .await + .unwrap(); + assert!(matches!( + transport + .snapshot( + &session(&transport, OWNER), + &metadata(&transport, active_metadata()) + ) + .await, + Err(Error::PayloadTooLarge) + )); + server.finish().await; + } + + #[tokio::test] + async fn deleted_vault_recreation_keeps_tombstone_revision_and_requires_a_new_vault() { + let server = FakeServer::start(vec![caps_reply()]).await; + let transport = Transport::for_test(&server.origin, CLIENT_ID) + .await + .unwrap(); + let session = session(&transport, OWNER); + let mut deleted = active_metadata(); + deleted["state"] = json!("deleted"); + for field in ["keyId", "payloadSchemaVersion", "ciphertextSha256"] { + deleted[field] = Value::Null; + } + deleted["ciphertextBytes"] = json!(0); + let tombstone = metadata(&transport, deleted); + let mut create = next_snapshot(); + create["kind"] = json!("create"); + assert!(transport + .prepare_upload(&session, &tombstone, &typed(&create), None) + .is_err()); + create["vaultId"] = json!(KEY); + assert!(transport + .prepare_upload(&session, &tombstone, &typed(&create), None) + .is_ok()); + create["baseRevision"] = json!("0"); + create["revision"] = json!("1"); + assert!(transport + .prepare_upload(&session, &tombstone, &typed(&create), None) + .is_err()); + assert!(transport + .prepare_delete(&session, &tombstone, UuidV4::parse(NEW_OPERATION).unwrap()) + .is_err()); + let mut exhausted = active_metadata(); + exhausted["revision"] = json!(u64::MAX.to_string()); + assert!(matches!( + transport.prepare_delete( + &session, + &metadata(&transport, exhausted), + UuidV4::parse(NEW_OPERATION).unwrap() + ), + Err(Error::RevisionExhausted) + )); + server.finish().await; + } + + #[tokio::test] + async fn debug_output_and_malformed_bearer_values_do_not_expose_credentials() { + let server = FakeServer::start(vec![caps_reply()]).await; + let transport = Transport::for_test(&server.origin, CLIENT_ID) + .await + .unwrap(); + let session = session(&transport, OWNER); + let operation = prepared_upload(&transport, &session); + let text = format!("{transport:?} {session:?} {operation:?}"); + assert!(!text.contains(&"a".repeat(43))); + assert!(!text.contains(&server.origin)); + assert!(!text.contains(snapshot()["ciphertext"].as_str().unwrap())); + for token in ["", "secret\nvalue", "secret value"] { + let error = bearer_header(token).unwrap_err(); + assert!(!format!("{error:?} {error}").contains("secret")); + } + assert!(bearer_header("fixture-token").unwrap().is_sensitive()); + server.finish().await; + } + + #[tokio::test] + async fn pending_records_preserve_original_upload_and_delete_bytes_after_restart() { + for kind in ["snapshot", "delete"] { + let server = FakeServer::start(vec![ + caps_reply(), + Reply::disconnect(), + caps_reply(), + Reply::json("200 OK", auth(OWNER, 'b')), + Reply::json("200 OK", receipt(kind)).header("Idempotency-Replayed", "true"), + ]) + .await; + let transport = Transport::for_test(&server.origin, CLIENT_ID) + .await + .unwrap(); + let first_session = session(&transport, OWNER); + let mut operation = if kind == "snapshot" { + prepared_upload(&transport, &first_session) + } else { + transport + .prepare_delete( + &first_session, + &metadata(&transport, active_metadata()), + UuidV4::parse(NEW_OPERATION).unwrap(), + ) + .unwrap() + }; + // Simulate bytes produced by a previous client serializer. Restoring + // must not reserialize this valid, differently ordered/formatted JSON. + let original_value: Value = serde_json::from_slice(operation.body_bytes()).unwrap(); + operation.body = format!( + "\n\t{}\n", + serde_json::to_string_pretty(&original_value).unwrap() + ) + .into_bytes(); + operation.if_match = StrongEtag::parse("\"opaque-\\tag-8\"").unwrap(); + let original_body = operation.body_bytes().to_vec(); + let original_etag = operation.if_match().as_str().to_owned(); + let record = operation.to_pending_record().unwrap(); + assert!(!std::str::from_utf8(&record) + .unwrap() + .contains(&"a".repeat(43))); + assert!(matches!( + transport.submit(&first_session, &operation).await, + Err(Error::Transport) + )); + drop(operation); + drop(first_session); + drop(transport); + + let restarted = Transport::for_test(&server.origin, CLIENT_ID) + .await + .unwrap(); + let renewed = restarted + .exchange("github-fixture-token", &GithubId::parse(OWNER).unwrap()) + .await + .unwrap(); + let restored = restarted.restore_pending(&renewed, &record).unwrap(); + assert_eq!(restored.body_bytes(), original_body); + assert_eq!(restored.if_match().as_str(), original_etag); + assert_eq!(restored.operation_id().as_str(), NEW_OPERATION); + let accepted = restarted.submit(&renewed, &restored).await.unwrap(); + assert!(accepted.replayed); + let requests = server.finish().await; + for index in [1, 4] { + assert_eq!(requests[index].body, original_body); + assert_eq!(requests[index].headers["if-match"], original_etag); + assert_eq!(requests[index].headers["idempotency-key"], NEW_OPERATION); + assert_eq!( + requests[index].method, + if kind == "snapshot" { "PUT" } else { "DELETE" } + ); + } + assert_ne!( + requests[1].headers["authorization"], + requests[4].headers["authorization"] + ); + assert_eq!(requests[2].path, "/v1/capabilities"); + assert_eq!(requests[3].path, "/v1/auth/github"); + } + } + + #[tokio::test] + async fn pending_record_recovery_rejects_changed_identity_and_untrusted_envelope_fields() { + let server = FakeServer::start(vec![caps_reply()]).await; + let transport = Transport::for_test(&server.origin, CLIENT_ID) + .await + .unwrap(); + let account_session = session(&transport, OWNER); + let operation = prepared_upload(&transport, &account_session); + let bytes = operation.to_pending_record().unwrap(); + let original: Value = serde_json::from_slice(&bytes).unwrap(); + for (field, value) in [ + ("origin", json!("https://another.example/")), + ("origin", json!("http://another.example/")), + ("origin", json!("https://user:password@another.example/")), + ("ownerGithubId", json!("999")), + ("operationId", json!(OLD_OPERATION)), + ("method", json!("post")), + ("method", json!("delete")), + ("recordVersion", json!(2)), + ("ifMatch", json!("W/\"weak\"")), + ("url", json!("https://another.example/arbitrary")), + ("expectedRevision", json!("9")), + ] { + let mut modified = original.clone(); + modified[field] = value; + assert!( + transport + .restore_pending(&account_session, &serde_json::to_vec(&modified).unwrap()) + .is_err(), + "accepted invalid field {field}" + ); + } + let duplicated = + std::str::from_utf8(&bytes) + .unwrap() + .replacen('{', "{\"recordVersion\":1,", 1); + assert!(transport + .restore_pending(&account_session, duplicated.as_bytes()) + .is_err()); + let mut wrong_service = transport.clone(); + wrong_service.origin = "https://another.example/".parse().unwrap(); + let other_session = session(&wrong_service, OWNER); + assert!(matches!( + wrong_service.restore_pending(&other_session, &bytes), + Err(Error::ContextMismatch) + )); + let other_account = session(&transport, "999"); + assert!(matches!( + transport.restore_pending(&other_account, &bytes), + Err(Error::AccountMismatch) + )); + // Restore is synchronous and performs no request at all. + assert_eq!(server.finish().await.len(), 1); + } + + #[tokio::test] + async fn pending_record_recovery_revalidates_original_crypto_header_hash_and_cas_body() { + let server = FakeServer::start(vec![caps_reply()]).await; + let transport = Transport::for_test(&server.origin, CLIENT_ID) + .await + .unwrap(); + let session = session(&transport, OWNER); + let operation = prepared_upload(&transport, &session); + let mut record: Value = + serde_json::from_slice(&operation.to_pending_record().unwrap()).unwrap(); + let body: Value = serde_json::from_str(record["body"].as_str().unwrap()).unwrap(); + for (field, value) in [ + ("ownerGithubId", json!("999")), + ("operationId", json!(OLD_OPERATION)), + ("revision", json!("10")), + ("baseRevision", json!(u64::MAX.to_string())), + ("protocolVersion", json!(2)), + ("cryptoProfile", json!("untrusted-profile")), + ("ciphertextSha256", json!("0".repeat(64))), + ("nonce", json!("invalid")), + ] { + let mut changed = body.clone(); + changed[field] = value; + record["body"] = json!(serde_json::to_string(&changed).unwrap()); + assert!( + transport + .restore_pending(&session, &serde_json::to_vec(&record).unwrap()) + .is_err(), + "accepted invalid body field {field}" + ); + } + let mut zero_base = body.clone(); + zero_base["baseRevision"] = json!("0"); + zero_base["revision"] = json!("1"); + record["body"] = json!(serde_json::to_string(&zero_base).unwrap()); + assert!(transport + .restore_pending(&session, &serde_json::to_vec(&record).unwrap()) + .is_err()); + let mut weak_kdf = body; + weak_kdf["kdf"]["iterations"] = json!(1); + record["body"] = json!(serde_json::to_string(&weak_kdf).unwrap()); + assert!(transport + .restore_pending(&session, &serde_json::to_vec(&record).unwrap()) + .is_err()); + record["body"] = json!(std::str::from_utf8(operation.body_bytes()) + .unwrap() + .replacen('{', "{\"protocolVersion\":1,", 1)); + assert!(transport + .restore_pending(&session, &serde_json::to_vec(&record).unwrap()) + .is_err()); + + let deletion = transport + .prepare_delete( + &session, + &metadata(&transport, active_metadata()), + UuidV4::parse(NEW_OPERATION).unwrap(), + ) + .unwrap(); + let mut record: Value = + serde_json::from_slice(&deletion.to_pending_record().unwrap()).unwrap(); + let mut body: Value = serde_json::from_str(record["body"].as_str().unwrap()).unwrap(); + for value in [json!("0"), json!(u64::MAX.to_string())] { + body["baseRevision"] = value; + record["body"] = json!(serde_json::to_string(&body).unwrap()); + assert!(transport + .restore_pending(&session, &serde_json::to_vec(&record).unwrap()) + .is_err()); + } + assert_eq!(server.finish().await.len(), 1); + } + + #[tokio::test] + async fn pending_record_recovery_enforces_outer_and_per_method_inner_size_limits() { + let server = FakeServer::start(vec![caps_reply()]).await; + let transport = Transport::for_test(&server.origin, CLIENT_ID) + .await + .unwrap(); + let session = session(&transport, OWNER); + assert!(matches!( + transport.restore_pending(&session, &vec![b' '; PENDING_RECORD_LIMIT + 1]), + Err(Error::PayloadTooLarge) + )); + for method in [MutationMethod::Upload, MutationMethod::Delete] { + let operation = match method { + MutationMethod::Upload => prepared_upload(&transport, &session), + MutationMethod::Delete => transport + .prepare_delete( + &session, + &metadata(&transport, active_metadata()), + UuidV4::parse(NEW_OPERATION).unwrap(), + ) + .unwrap(), + }; + let mut record: Value = + serde_json::from_slice(&operation.to_pending_record().unwrap()).unwrap(); + record["body"] = json!(" ".repeat(method.body_limit() + 1)); + assert!(matches!( + transport.restore_pending(&session, &serde_json::to_vec(&record).unwrap()), + Err(Error::PayloadTooLarge) + )); + } + assert_eq!(server.finish().await.len(), 1); + } +} diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_protocol/types.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_protocol/types.rs new file mode 100644 index 00000000..001525ac --- /dev/null +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_protocol/types.rs @@ -0,0 +1,1261 @@ +//! Strict v1 DTOs. Constructors/deserializers preserve canonical wire encodings. + +use super::{Error, Result}; +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; +use serde::{ + de::{self, DeserializeOwned, DeserializeSeed, MapAccess, SeqAccess, Visitor}, + Deserialize, Deserializer, Serialize, Serializer, +}; +use serde_json::{Map, Value}; +use sha2::{Digest, Sha256}; +use std::{collections::HashSet, fmt}; +use zeroize::{Zeroize, Zeroizing}; + +pub(crate) const PROTOCOL_VERSION: u32 = 1; +pub(crate) const CRYPTO_PROFILE: &str = "argon2id-xchacha20poly1305-v1"; +pub(crate) const AEAD_NAME: &str = "xchacha20poly1305-ietf"; +pub(crate) const CODEC_NAME: &str = "json-pad64k-v1"; +pub(crate) const HTTP_BODY_LIMIT: usize = 24 * 1024 * 1024; +pub(crate) const CONTROL_BODY_LIMIT: usize = 8192; +pub(crate) const MAX_PLAINTEXT_JSON_BYTES: usize = 15 * 1024 * 1024; +pub(crate) const MAX_PADDED_BYTES: usize = 16 * 1024 * 1024; +pub(crate) const MAX_CIPHERTEXT_BYTES: usize = MAX_PADDED_BYTES + 16; +pub(crate) const PAD_BLOCK_BYTES: usize = 65_536; +pub(crate) const MAX_DOCUMENTS: usize = 100_000; +pub(crate) const MAX_JSON_DEPTH: usize = 64; + +pub(crate) trait Validate { + fn validate(&self) -> Result<()>; +} + +#[derive(Clone, Copy, Eq, PartialEq, Hash, Ord, PartialOrd)] +pub struct Revision { + value: u64, + wire: [u8; 20], + length: u8, +} +impl Revision { + pub fn new(value: u64) -> Self { + let text = value.to_string(); + let mut wire = [b'0'; 20]; + wire[..text.len()].copy_from_slice(text.as_bytes()); + Self { + value, + wire, + length: text.len() as u8, + } + } + pub(crate) fn parse(wire: &str) -> Result { + if wire.is_empty() + || wire.len() > 20 + || !wire.bytes().all(|c| c.is_ascii_digit()) + || (wire.len() > 1 && wire.starts_with('0')) + { + return Err(Error::InvalidWire("revision")); + } + let value = wire.parse().map_err(|_| Error::InvalidWire("revision"))?; + Ok(Self::new(value)) + } + pub fn get(&self) -> u64 { + self.value + } + pub fn as_str(&self) -> &str { + std::str::from_utf8(&self.wire[..usize::from(self.length)]) + .expect("revision constructor emits ASCII digits") + } + pub(crate) fn checked_next(&self) -> Result { + self.value + .checked_add(1) + .map(Self::new) + .ok_or(Error::RevisionExhausted) + } +} +impl fmt::Debug for Revision { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} +impl fmt::Display for Revision { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} +impl Serialize for Revision { + fn serialize(&self, s: S) -> std::result::Result { + s.serialize_str(self.as_str()) + } +} +impl<'de> Deserialize<'de> for Revision { + fn deserialize>(d: D) -> std::result::Result { + Self::parse(&String::deserialize(d)?).map_err(|_| de::Error::custom("invalid revision")) + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Hash)] +pub(crate) struct GithubId(Revision); +impl GithubId { + pub(crate) fn parse(s: &str) -> Result { + let n = Revision::parse(s)?; + if n.get() == 0 { + return Err(Error::InvalidWire("github id")); + } + Ok(Self(n)) + } + pub(crate) fn as_str(&self) -> &str { + self.0.as_str() + } +} +impl fmt::Display for GithubId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(f) + } +} +impl Serialize for GithubId { + fn serialize(&self, s: S) -> std::result::Result { + s.serialize_str(self.as_str()) + } +} +impl<'de> Deserialize<'de> for GithubId { + fn deserialize>(d: D) -> std::result::Result { + Self::parse(&String::deserialize(d)?).map_err(|_| de::Error::custom("invalid github id")) + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Hash)] +pub(crate) struct UuidV4(String); +impl UuidV4 { + pub(crate) fn parse(s: &str) -> Result { + if s.len() != 36 { + return Err(Error::InvalidWire("uuid v4")); + } + let id = uuid::Uuid::parse_str(s).map_err(|_| Error::InvalidWire("uuid v4"))?; + if id.get_version() != Some(uuid::Version::Random) + || id.get_variant() != uuid::Variant::RFC4122 + || id.to_string() != s + { + return Err(Error::InvalidWire("uuid v4")); + } + Ok(Self(s.to_owned())) + } + pub(crate) fn random() -> Result { + let mut bytes = [0; 16]; + getrandom::fill(&mut bytes).map_err(|_| Error::RandomUnavailable)?; + Ok(Self( + uuid::Builder::from_random_bytes(bytes) + .into_uuid() + .to_string(), + )) + } + pub(crate) fn as_str(&self) -> &str { + &self.0 + } +} +impl fmt::Display for UuidV4 { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} +impl Serialize for UuidV4 { + fn serialize(&self, s: S) -> std::result::Result { + s.serialize_str(&self.0) + } +} +impl<'de> Deserialize<'de> for UuidV4 { + fn deserialize>(d: D) -> std::result::Result { + Self::parse(&String::deserialize(d)?).map_err(|_| de::Error::custom("invalid uuid v4")) + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct StrongEtag(String); +impl StrongEtag { + pub(crate) fn parse(s: &str) -> Result { + let b = s.as_bytes(); + if !(3..=130).contains(&b.len()) + || b[0] != b'"' + || b[b.len() - 1] != b'"' + || !b[1..b.len() - 1] + .iter() + .all(|b| *b == 0x21 || (0x23..=0x7e).contains(b)) + { + return Err(Error::InvalidWire("strong etag")); + } + Ok(Self(s.to_owned())) + } + pub(crate) fn as_str(&self) -> &str { + &self.0 + } +} +impl fmt::Display for StrongEtag { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +#[derive(Clone, Eq, PartialEq)] +pub(crate) struct EncodedBytes([u8; N]); +pub(crate) type Salt = EncodedBytes<16>; +pub(crate) type Nonce = EncodedBytes<24>; +impl EncodedBytes { + #[cfg(test)] + pub(crate) fn new(bytes: [u8; N]) -> Self { + Self(bytes) + } + pub(crate) fn random() -> Result { + let mut b = [0; N]; + getrandom::fill(&mut b).map_err(|_| Error::RandomUnavailable)?; + Ok(Self(b)) + } + pub(crate) fn bytes(&self) -> &[u8; N] { + &self.0 + } + pub(crate) fn encoded(&self) -> String { + URL_SAFE_NO_PAD.encode(self.0) + } + pub(crate) fn parse(s: &str) -> Result { + if s.len() != N.div_ceil(3) * 4 - (3 - N % 3) % 3 { + return Err(Error::InvalidWire("base64 length")); + } + let b = decode_base64(s, N)?; + Ok(Self( + b.try_into() + .map_err(|_| Error::InvalidWire("base64 length"))?, + )) + } +} +impl fmt::Debug for EncodedBytes { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_tuple("PublicEncodedBytes") + .field(&self.encoded()) + .finish() + } +} +impl Serialize for EncodedBytes { + fn serialize(&self, s: S) -> std::result::Result { + s.serialize_str(&self.encoded()) + } +} +impl<'de, const N: usize> Deserialize<'de> for EncodedBytes { + fn deserialize>(d: D) -> std::result::Result { + Self::parse(&String::deserialize(d)?) + .map_err(|_| de::Error::custom("invalid canonical base64")) + } +} + +pub(crate) fn decode_base64(s: &str, max: usize) -> Result> { + if s.is_empty() + || s.len() > max.div_ceil(3) * 4 + || !s + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') + { + return Err(Error::InvalidWire("canonical base64")); + } + let decoded = URL_SAFE_NO_PAD + .decode(s) + .map_err(|_| Error::InvalidWire("canonical base64"))?; + if decoded.len() > max { + return Err(Error::PayloadTooLarge); + } + if URL_SAFE_NO_PAD.encode(&decoded) != s { + return Err(Error::InvalidWire("canonical base64")); + } + Ok(decoded) +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct Sha256Digest([u8; 32]); +impl Sha256Digest { + pub(crate) fn of(bytes: &[u8]) -> Self { + Self(Sha256::digest(bytes).into()) + } + pub(crate) fn as_hex(&self) -> String { + self.0.iter().map(|b| format!("{b:02x}")).collect() + } + pub(crate) fn parse(s: &str) -> Result { + if s.len() != 64 + || !s + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) + { + return Err(Error::InvalidWire("sha256")); + } + let mut out = [0; 32]; + for (n, pair) in s.as_bytes().as_chunks::<2>().0.iter().enumerate() { + out[n] = ((hex(pair[0])) << 4) | hex(pair[1]); + } + Ok(Self(out)) + } +} +fn hex(b: u8) -> u8 { + if b <= b'9' { + b - b'0' + } else { + b - b'a' + 10 + } +} +impl Serialize for Sha256Digest { + fn serialize(&self, s: S) -> std::result::Result { + s.serialize_str(&self.as_hex()) + } +} +impl<'de> Deserialize<'de> for Sha256Digest { + fn deserialize>(d: D) -> std::result::Result { + Self::parse(&String::deserialize(d)?).map_err(|_| de::Error::custom("invalid sha256")) + } +} + +/// Intentionally neither serializable nor cloneable. Moving an IPC String here +/// transfers ownership; all copies created by normalization are also zeroizing. +pub(crate) struct SecretInput(Zeroizing); +impl SecretInput { + pub(crate) fn new(value: String) -> Self { + Self(Zeroizing::new(value)) + } + pub(crate) fn expose(&self) -> &str { + &self.0 + } +} +impl fmt::Debug for SecretInput { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("SecretInput([REDACTED])") + } +} + +pub(crate) struct SecretToken(Zeroizing); +impl SecretToken { + pub(crate) fn new(value: String) -> Result { + let value = Zeroizing::new(value); + if value.is_empty() || value.len() > 2048 || !value.bytes().all(|b| b.is_ascii_graphic()) { + return Err(Error::InvalidWire("bearer token")); + } + Ok(Self(value)) + } + pub(crate) fn expose(&self) -> &str { + &self.0 + } + pub(crate) fn validate_sync(&self) -> Result<()> { + if self.0.len() < 43 { + Err(Error::InvalidWire("sync token")) + } else { + Ok(()) + } + } +} +impl fmt::Debug for SecretToken { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("SecretToken([REDACTED])") + } +} +impl<'de> Deserialize<'de> for SecretToken { + fn deserialize>(d: D) -> std::result::Result { + Self::new(String::deserialize(d)?).map_err(|_| de::Error::custom("invalid token")) + } +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct Kdf { + pub(crate) name: String, + pub(crate) version: u32, + #[serde(rename = "memoryKiB")] + pub(crate) memory_kib: u32, + pub(crate) iterations: u32, + pub(crate) parallelism: u32, + pub(crate) salt: Salt, +} +impl Kdf { + pub(crate) fn fixed(salt: Salt) -> Self { + Self { + name: "argon2id".into(), + version: 19, + memory_kib: 65536, + iterations: 3, + parallelism: 4, + salt, + } + } + pub(crate) fn validate(&self) -> Result<()> { + if self.name != "argon2id" + || self.version != 19 + || self.memory_kib != 65536 + || self.iterations != 3 + || self.parallelism != 4 + { + Err(Error::UnsupportedProtocol) + } else { + Ok(()) + } + } +} + +#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub(crate) enum UploadKind { + Create, + Snapshot, + PasswordChange, +} +impl UploadKind { + pub(crate) fn as_str(&self) -> &'static str { + match self { + Self::Create => "create", + Self::Snapshot => "snapshot", + Self::PasswordChange => "password_change", + } + } +} +#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub(crate) enum OperationKind { + Create, + Snapshot, + PasswordChange, + Delete, +} +impl From for OperationKind { + fn from(v: UploadKind) -> Self { + match v { + UploadKind::Create => Self::Create, + UploadKind::Snapshot => Self::Snapshot, + UploadKind::PasswordChange => Self::PasswordChange, + } + } +} + +#[derive(Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct SnapshotUpload { + pub(crate) protocol_version: u32, + pub(crate) payload_schema_version: u32, + pub(crate) owner_github_id: GithubId, + pub(crate) vault_id: UuidV4, + pub(crate) key_id: UuidV4, + pub(crate) base_revision: Revision, + pub(crate) revision: Revision, + pub(crate) operation_id: UuidV4, + pub(crate) kind: UploadKind, + pub(crate) crypto_profile: String, + pub(crate) kdf: Kdf, + pub(crate) aead: String, + pub(crate) codec: String, + pub(crate) nonce: Nonce, + pub(crate) ciphertext: String, + pub(crate) ciphertext_sha256: Sha256Digest, +} +impl fmt::Debug for SnapshotUpload { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("SnapshotUpload") + .field("revision", &self.revision) + .field("kind", &self.kind) + .field("ciphertext", &"[REDACTED]") + .finish() + } +} +impl SnapshotUpload { + pub(crate) fn validate_header(&self) -> Result<()> { + if self.protocol_version != PROTOCOL_VERSION + || self.payload_schema_version != 1 + || self.crypto_profile != CRYPTO_PROFILE + || self.aead != AEAD_NAME + || self.codec != CODEC_NAME + { + return Err(Error::UnsupportedProtocol); + } + self.kdf.validate()?; + if self.revision != self.base_revision.checked_next()? { + return Err(Error::InvalidWire("revision increment")); + } + if !(87_403..=22_369_643).contains(&self.ciphertext.len()) { + return Err(Error::InvalidWire("ciphertext length")); + } + Ok(()) + } + pub(crate) fn decoded_ciphertext(&self) -> Result> { + self.validate_header()?; + let bytes = decode_base64(&self.ciphertext, MAX_CIPHERTEXT_BYTES)?; + if bytes.len() < PAD_BLOCK_BYTES + 16 || (bytes.len() - 16) % PAD_BLOCK_BYTES != 0 { + return Err(Error::InvalidWire("ciphertext framing")); + } + if Sha256Digest::of(&bytes) != self.ciphertext_sha256 { + return Err(Error::InvalidWire("ciphertext hash")); + } + Ok(bytes) + } + pub(crate) fn validate(&self) -> Result<()> { + self.decoded_ciphertext().map(|_| ()) + } + pub(crate) fn validate_against_metadata( + &self, + meta: &VaultMetadata, + owner: &GithubId, + ) -> Result<()> { + meta.validate()?; + if &meta.owner_github_id != owner || &self.owner_github_id != owner { + return Err(Error::AccountMismatch); + } + if meta.state != VaultState::Active + || self.revision != meta.revision + || Some(&self.vault_id) != meta.vault_id.as_ref() + || Some(&self.key_id) != meta.key_id.as_ref() + || Some(&self.operation_id) != meta.last_operation_id.as_ref() + || Some(self.payload_schema_version) != meta.payload_schema_version + || Some(&self.ciphertext_sha256) != meta.ciphertext_sha256.as_ref() + { + return Err(Error::ContextMismatch); + } + let bytes = self.decoded_ciphertext()?; + if bytes.len() as u64 != meta.ciphertext_bytes { + return Err(Error::ContextMismatch); + } + Ok(()) + } +} +impl Validate for SnapshotUpload { + fn validate(&self) -> Result<()> { + self.validate() + } +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct Capabilities { + pub(crate) protocol_version: u32, + pub(crate) crypto_profile: String, + pub(crate) github_client_id: String, + pub(crate) max_http_body_bytes: u64, + pub(crate) max_ciphertext_bytes: u64, + pub(crate) max_plaintext_json_bytes: u64, + pub(crate) idempotency_retention_seconds: u64, + pub(crate) max_backup_retention_days: u32, +} +impl Validate for Capabilities { + fn validate(&self) -> Result<()> { + if self.protocol_version != PROTOCOL_VERSION + || self.crypto_profile != CRYPTO_PROFILE + || self.max_http_body_bytes != HTTP_BODY_LIMIT as u64 + || self.max_ciphertext_bytes != MAX_CIPHERTEXT_BYTES as u64 + || self.max_plaintext_json_bytes != MAX_PLAINTEXT_JSON_BYTES as u64 + || self.idempotency_retention_seconds < 604800 + || self.max_backup_retention_days > 30 + { + return Err(Error::UnsupportedProtocol); + } + bounded_text(&self.github_client_id, 1, 128) + } +} +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct Account { + pub(crate) github_id: GithubId, + pub(crate) login: String, +} +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct AuthSession { + pub(crate) protocol_version: u32, + pub(crate) access_token: SecretToken, + pub(crate) token_type: String, + pub(crate) expires_in: u32, + pub(crate) account: Account, +} +impl Validate for AuthSession { + fn validate(&self) -> Result<()> { + if self.protocol_version != PROTOCOL_VERSION + || self.token_type != "Bearer" + || !(1..=900).contains(&self.expires_in) + { + return Err(Error::InvalidWire("auth session")); + } + self.access_token.validate_sync()?; + bounded_text(&self.account.login, 1, 100) + } +} + +#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub(crate) enum VaultState { + Empty, + Active, + Deleted, +} +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct VaultMetadata { + pub(crate) protocol_version: u32, + pub(crate) state: VaultState, + pub(crate) owner_github_id: GithubId, + pub(crate) revision: Revision, + #[serde(deserialize_with = "required_nullable")] + pub(crate) vault_id: Option, + #[serde(deserialize_with = "required_nullable")] + pub(crate) key_id: Option, + #[serde(deserialize_with = "required_nullable")] + pub(crate) updated_at: Option, + #[serde(deserialize_with = "required_nullable")] + pub(crate) payload_schema_version: Option, + pub(crate) ciphertext_bytes: u64, + #[serde(deserialize_with = "required_nullable")] + pub(crate) ciphertext_sha256: Option, + #[serde(deserialize_with = "required_nullable")] + pub(crate) last_operation_id: Option, +} +fn required_nullable<'de, D: Deserializer<'de>, T: Deserialize<'de>>( + d: D, +) -> std::result::Result, D::Error> { + Option::::deserialize(d) +} +impl VaultMetadata { + pub(crate) fn validate(&self) -> Result<()> { + if self.protocol_version != PROTOCOL_VERSION { + return Err(Error::UnsupportedProtocol); + } + if let Some(t) = &self.updated_at { + validate_timestamp(t)?; + } + let valid = match self.state { + VaultState::Empty => { + self.revision.get() == 0 + && self.vault_id.is_none() + && self.key_id.is_none() + && self.updated_at.is_none() + && self.payload_schema_version.is_none() + && self.ciphertext_bytes == 0 + && self.ciphertext_sha256.is_none() + && self.last_operation_id.is_none() + } + VaultState::Active => { + self.revision.get() > 0 + && self.vault_id.is_some() + && self.key_id.is_some() + && self.updated_at.is_some() + && self.payload_schema_version == Some(1) + && self.ciphertext_bytes >= 65_552 + && self.ciphertext_bytes <= MAX_CIPHERTEXT_BYTES as u64 + && (self.ciphertext_bytes - 16).is_multiple_of(PAD_BLOCK_BYTES as u64) + && self.ciphertext_sha256.is_some() + && self.last_operation_id.is_some() + } + VaultState::Deleted => { + self.revision.get() > 0 + && self.vault_id.is_some() + && self.key_id.is_none() + && self.updated_at.is_some() + && self.payload_schema_version.is_none() + && self.ciphertext_bytes == 0 + && self.ciphertext_sha256.is_none() + && self.last_operation_id.is_some() + } + }; + if valid { + Ok(()) + } else { + Err(Error::InvalidWire("vault state")) + } + } +} +impl Validate for VaultMetadata { + fn validate(&self) -> Result<()> { + self.validate() + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct DeleteVaultRequest { + pub(crate) protocol_version: u32, + pub(crate) base_revision: Revision, + pub(crate) operation_id: UuidV4, + pub(crate) expected_vault_id: UuidV4, +} +impl Validate for DeleteVaultRequest { + fn validate(&self) -> Result<()> { + if self.protocol_version != PROTOCOL_VERSION { + return Err(Error::UnsupportedProtocol); + } + if self.base_revision.get() == 0 { + return Err(Error::InvalidWire("delete revision")); + } + self.base_revision.checked_next().map(|_| ()) + } +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct OperationReceipt { + pub(crate) operation_id: UuidV4, + pub(crate) status: String, + pub(crate) kind: OperationKind, + pub(crate) committed_revision: Revision, + pub(crate) committed_at: String, + pub(crate) vault_id: UuidV4, + #[serde(deserialize_with = "required_nullable")] + pub(crate) ciphertext_sha256: Option, +} +impl Validate for OperationReceipt { + fn validate(&self) -> Result<()> { + if self.status != "committed" + || self.committed_revision.get() == 0 + || (self.kind == OperationKind::Delete) != self.ciphertext_sha256.is_none() + { + return Err(Error::InvalidWire("receipt")); + } + validate_timestamp(&self.committed_at) + } +} +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct OperationPending { + pub(crate) operation_id: UuidV4, + pub(crate) status: String, + pub(crate) retry_after_seconds: u32, +} +impl Validate for OperationPending { + fn validate(&self) -> Result<()> { + if self.status != "pending" || !(1..=3600).contains(&self.retry_after_seconds) { + Err(Error::InvalidWire("pending receipt")) + } else { + Ok(()) + } + } +} + +#[derive(Clone, Copy, Debug, Serialize, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "snake_case")] +pub(crate) enum RemoteErrorCode { + InvalidRequest, + UnsupportedProtocol, + InvalidCryptoHeader, + Unauthenticated, + SessionExpired, + WrongOauthApp, + OwnerMismatch, + VaultEmpty, + VaultDeleted, + OperationNotFound, + IdempotencyKeyReused, + KeyEpochChanged, + VaultExists, + RevisionExhausted, + RevisionConflict, + PayloadTooLarge, + UnsupportedMediaType, + PreconditionRequired, + RateLimited, + InternalError, + ServiceUnavailable, +} +impl fmt::Display for RemoteErrorCode { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let s = serde_json::to_string(self).map_err(|_| fmt::Error)?; + f.write_str(s.trim_matches('"')) + } +} +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct ErrorEnvelope { + pub(crate) error: ErrorBody, +} +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct ErrorBody { + pub(crate) code: RemoteErrorCode, + pub(crate) message: String, + // Parse the required UUID, but never include a remote body in diagnostics. + #[serde(rename = "requestId")] + _request_id: UuidV4, + #[serde(default, deserialize_with = "optional_present_nonnull")] + pub(crate) current_revision: Option, +} +fn optional_present_nonnull<'de, D: Deserializer<'de>, T: Deserialize<'de>>( + d: D, +) -> std::result::Result, D::Error> { + T::deserialize(d).map(Some) +} +impl Drop for ErrorBody { + fn drop(&mut self) { + self.message.zeroize(); + } +} +impl Validate for ErrorEnvelope { + fn validate(&self) -> Result<()> { + bounded_text(&self.error.message, 1, 200) + } +} + +#[derive(Clone, Copy, Debug, Hash, Serialize, Deserialize, Eq, PartialEq, Ord, PartialOrd)] +#[serde(rename_all = "snake_case")] +pub enum DocumentKind { + Preferences, + UiPreferences, + Channels, + ProviderCredentials, + Dictionary, + VocabularyPresets, + Corrections, + StylePacks, + History, + Activity, + DeviceProfile, +} +#[derive(Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SourceDevice { + pub id: String, + pub os: String, + pub arch: String, + pub app_version: String, +} +impl Drop for SourceDevice { + fn drop(&mut self) { + self.id.zeroize(); + self.os.zeroize(); + self.arch.zeroize(); + self.app_version.zeroize(); + } +} +impl fmt::Debug for SourceDevice { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("SourceDevice([REDACTED])") + } +} +#[derive(Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct LogicalDocument { + pub id: String, + pub kind: DocumentKind, + pub schema_version: u32, + pub value: Value, +} +impl Drop for LogicalDocument { + fn drop(&mut self) { + self.id.zeroize(); + erase_value(&mut self.value); + } +} +impl fmt::Debug for LogicalDocument { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("LogicalDocument([REDACTED])") + } +} +#[derive(Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct Tombstone { + pub id: String, + pub kind: DocumentKind, + pub deleted_at: String, + pub base_revision: Revision, +} +impl Drop for Tombstone { + fn drop(&mut self) { + self.id.zeroize(); + self.deleted_at.zeroize(); + } +} +impl fmt::Debug for Tombstone { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("Tombstone([REDACTED])") + } +} +#[derive(Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct DocumentSet { + pub schema_version: u32, + pub exported_at: String, + pub source_device: SourceDevice, + pub documents: Vec, + pub tombstones: Vec, +} +impl fmt::Debug for DocumentSet { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("DocumentSet([REDACTED])") + } +} +impl Drop for DocumentSet { + fn drop(&mut self) { + self.exported_at.zeroize(); + } +} +impl DocumentSet { + pub(crate) fn validate(&self) -> Result<()> { + if self.schema_version != 1 || self.documents.iter().any(|d| d.schema_version != 1) { + return Err(Error::UnsupportedDocumentVersion); + } + if self.documents.len().saturating_add(self.tombstones.len()) > MAX_DOCUMENTS { + return Err(Error::PayloadTooLarge); + } + validate_timestamp(&self.exported_at)?; + if [ + &self.source_device.id, + &self.source_device.os, + &self.source_device.arch, + &self.source_device.app_version, + ] + .iter() + .any(|v| v.is_empty()) + { + return Err(Error::InvalidWire("source device")); + } + let mut ids = HashSet::new(); + for d in &self.documents { + validate_value_depth(&d.value, 3)?; + if d.id.is_empty() || !ids.insert((d.kind, d.id.as_str())) { + return Err(Error::InvalidWire("duplicate document identity")); + } + } + for d in &self.tombstones { + validate_timestamp(&d.deleted_at)?; + if d.id.is_empty() || !ids.insert((d.kind, d.id.as_str())) { + return Err(Error::InvalidWire("duplicate document identity")); + } + } + Ok(()) + } + pub(crate) fn from_json_bytes(bytes: &[u8]) -> Result { + let mut value = parse_strict_json(bytes, MAX_PLAINTEXT_JSON_BYTES)?; + // Future document kinds/versions require an upgrade; never erase them as + // an empty successful restore. This preflight does not inspect value keys. + if value + .0 + .get("schemaVersion") + .and_then(Value::as_u64) + .is_some_and(|v| v != 1) + { + return Err(Error::UnsupportedDocumentVersion); + } + for collection in ["documents", "tombstones"] { + if let Some(items) = value.0.get(collection).and_then(Value::as_array) { + if items.len() > MAX_DOCUMENTS { + return Err(Error::PayloadTooLarge); + } + for item in items { + if let Some(kind) = item.get("kind").and_then(Value::as_str) { + if serde_json::from_value::(Value::String(kind.into())) + .is_err() + { + return Err(Error::UnsupportedDocumentVersion); + } + } + if item + .get("schemaVersion") + .and_then(Value::as_u64) + .is_some_and(|v| v != 1) + { + return Err(Error::UnsupportedDocumentVersion); + } + } + } + } + let documents: Self = serde_json::from_value(std::mem::take(&mut value.0)) + .map_err(|_| Error::InvalidWire("document schema"))?; + documents.validate()?; + Ok(documents) + } + pub(crate) fn to_secret_json(&self) -> Result>> { + self.validate()?; + bounded_secret_json(self, MAX_PLAINTEXT_JSON_BYTES) + } +} +impl Validate for DocumentSet { + fn validate(&self) -> Result<()> { + self.validate() + } +} +pub(crate) fn validate_timestamp(value: &str) -> Result<()> { + if value.len() > 64 || !(value.ends_with('Z') || value.ends_with("+00:00")) { + return Err(Error::InvalidWire("utc timestamp")); + } + let t = chrono::DateTime::parse_from_rfc3339(value) + .map_err(|_| Error::InvalidWire("utc timestamp"))?; + if t.offset().local_minus_utc() != 0 { + return Err(Error::InvalidWire("utc timestamp")); + } + Ok(()) +} +fn bounded_text(s: &str, min: usize, max: usize) -> Result<()> { + let n = s.chars().count(); + if (min..=max).contains(&n) { + Ok(()) + } else { + Err(Error::InvalidWire("text length")) + } +} + +fn validate_value_depth(value: &Value, depth: usize) -> Result<()> { + match value { + Value::Array(items) => { + if depth >= MAX_JSON_DEPTH { + return Err(Error::InvalidWire("json depth")); + } + for item in items { + validate_value_depth(item, depth + 1)?; + } + } + Value::Object(items) => { + if depth >= MAX_JSON_DEPTH { + return Err(Error::InvalidWire("json depth")); + } + for item in items.values() { + validate_value_depth(item, depth + 1)?; + } + } + _ => {} + } + Ok(()) +} + +fn bounded_secret_json(value: &T, limit: usize) -> Result>> { + // Count with a hard ceiling first, so serialization never grows a secret + // buffer beyond the limit or leaves old plaintext allocations after realloc. + struct Counter { + size: usize, + limit: usize, + } + impl std::io::Write for Counter { + fn write(&mut self, bytes: &[u8]) -> std::io::Result { + self.size = self + .size + .checked_add(bytes.len()) + .filter(|v| *v <= self.limit) + .ok_or_else(|| std::io::Error::other("JSON limit"))?; + Ok(bytes.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + let mut counter = Counter { size: 0, limit }; + serde_json::to_writer(&mut counter, value).map_err(|_| Error::PayloadTooLarge)?; + struct Writer { + bytes: Zeroizing>, + limit: usize, + } + impl std::io::Write for Writer { + fn write(&mut self, bytes: &[u8]) -> std::io::Result { + if self.bytes.len().saturating_add(bytes.len()) > self.limit { + return Err(std::io::Error::other("JSON limit")); + } + self.bytes.extend_from_slice(bytes); + Ok(bytes.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + let mut writer = Writer { + bytes: Zeroizing::new(Vec::with_capacity(counter.size)), + limit: counter.size, + }; + serde_json::to_writer(&mut writer, value) + .map_err(|_| Error::InvalidWire("document serialization"))?; + Ok(writer.bytes) +} + +/// Best-effort cleanup of decrypted strings, including unknown extension keys. +fn erase_value(value: &mut Value) { + match value { + Value::String(s) => s.zeroize(), + Value::Array(a) => { + for v in a { + erase_value(v) + } + } + Value::Object(m) => { + for (mut k, mut v) in std::mem::take(m) { + k.zeroize(); + erase_value(&mut v) + } + } + _ => {} + } + *value = Value::Null; +} + +struct StrictValue(Value); +impl Drop for StrictValue { + fn drop(&mut self) { + erase_value(&mut self.0); + } +} +struct ValueSeed(usize); +impl<'de> DeserializeSeed<'de> for ValueSeed { + type Value = Value; + fn deserialize>(self, d: D) -> std::result::Result { + d.deserialize_any(self) + } +} +impl<'de> Visitor<'de> for ValueSeed { + type Value = Value; + fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("bounded JSON") + } + fn visit_bool(self, v: bool) -> std::result::Result { + Ok(Value::Bool(v)) + } + fn visit_unit(self) -> std::result::Result { + Ok(Value::Null) + } + fn visit_i64(self, v: i64) -> std::result::Result { + Ok(v.into()) + } + fn visit_u64(self, v: u64) -> std::result::Result { + Ok(v.into()) + } + fn visit_f64(self, v: f64) -> std::result::Result { + serde_json::Number::from_f64(v) + .map(Value::Number) + .ok_or_else(|| E::custom("nonfinite JSON")) + } + fn visit_str(self, v: &str) -> std::result::Result { + Ok(Value::String(v.to_owned())) + } + fn visit_string(self, v: String) -> std::result::Result { + Ok(Value::String(v)) + } + fn visit_seq>(self, mut a: A) -> std::result::Result { + if self.0 >= MAX_JSON_DEPTH { + return Err(de::Error::custom("JSON depth")); + } + let mut out = StrictValue(Value::Array(Vec::new())); + while let Some(mut v) = a.next_element_seed(ValueSeed(self.0 + 1))? { + if let Value::Array(items) = &mut out.0 { + if self.0 == 1 && items.len() >= MAX_DOCUMENTS { + erase_value(&mut v); + return Err(de::Error::custom("document count")); + } + items.push(v); + } + } + Ok(std::mem::take(&mut out.0)) + } + fn visit_map>(self, mut a: A) -> std::result::Result { + if self.0 >= MAX_JSON_DEPTH { + return Err(de::Error::custom("JSON depth")); + } + let mut out = StrictValue(Value::Object(Map::new())); + while let Some(mut key) = a.next_key::()? { + if let Value::Object(items) = &mut out.0 { + if items.contains_key(&key) { + key.zeroize(); + return Err(de::Error::custom("duplicate JSON key")); + } + } + let value = match a.next_value_seed(ValueSeed(self.0 + 1)) { + Ok(v) => v, + Err(e) => { + key.zeroize(); + return Err(e); + } + }; + if let Value::Object(items) = &mut out.0 { + items.insert(key, value); + } + } + Ok(std::mem::take(&mut out.0)) + } +} +fn parse_strict_json(bytes: &[u8], limit: usize) -> Result { + if bytes.len() > limit { + return Err(Error::PayloadTooLarge); + } + let mut d = serde_json::Deserializer::from_slice(bytes); + let value = StrictValue( + ValueSeed(0) + .deserialize(&mut d) + .map_err(|_| Error::InvalidWire("json encoding"))?, + ); + d.end() + .map_err(|_| Error::InvalidWire("json trailing data"))?; + Ok(value) +} +pub(crate) fn parse_wire(bytes: &[u8], limit: usize) -> Result { + if bytes.len() > limit { + return Err(Error::PayloadTooLarge); + } + // Validate syntax without constructing an attacker-chosen Value tree (for + // example millions of `null`s where ciphertext must actually be a string). + let mut parser = serde_json::Deserializer::from_slice(bytes); + SyntaxSeed(0) + .deserialize(&mut parser) + .map_err(|_| Error::InvalidWire("json encoding"))?; + parser + .end() + .map_err(|_| Error::InvalidWire("json trailing data"))?; + let value = + serde_json::from_slice::(bytes).map_err(|_| Error::InvalidWire("json schema"))?; + value.validate()?; + Ok(value) +} + +struct SyntaxSeed(usize); +impl<'de> DeserializeSeed<'de> for SyntaxSeed { + type Value = (); + fn deserialize>(self, d: D) -> std::result::Result<(), D::Error> { + d.deserialize_any(self) + } +} +struct SecretKeys(HashSet); +impl Drop for SecretKeys { + fn drop(&mut self) { + for mut key in self.0.drain() { + key.zeroize(); + } + } +} +impl<'de> Visitor<'de> for SyntaxSeed { + type Value = (); + fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("bounded JSON") + } + fn visit_bool(self, _: bool) -> std::result::Result<(), E> { + Ok(()) + } + fn visit_unit(self) -> std::result::Result<(), E> { + Ok(()) + } + fn visit_i64(self, _: i64) -> std::result::Result<(), E> { + Ok(()) + } + fn visit_u64(self, _: u64) -> std::result::Result<(), E> { + Ok(()) + } + fn visit_f64(self, v: f64) -> std::result::Result<(), E> { + if v.is_finite() { + Ok(()) + } else { + Err(E::custom("nonfinite JSON")) + } + } + fn visit_str(self, _: &str) -> std::result::Result<(), E> { + Ok(()) + } + fn visit_string(self, mut v: String) -> std::result::Result<(), E> { + v.zeroize(); + Ok(()) + } + fn visit_seq>(self, mut a: A) -> std::result::Result<(), A::Error> { + if self.0 >= MAX_JSON_DEPTH { + return Err(de::Error::custom("JSON depth")); + } + while a.next_element_seed(SyntaxSeed(self.0 + 1))?.is_some() {} + Ok(()) + } + fn visit_map>(self, mut a: A) -> std::result::Result<(), A::Error> { + if self.0 >= MAX_JSON_DEPTH { + return Err(de::Error::custom("JSON depth")); + } + let mut keys = SecretKeys(HashSet::new()); + while let Some(mut key) = a.next_key::()? { + if keys.0.contains(&key) { + key.zeroize(); + return Err(de::Error::custom("duplicate JSON key")); + } + keys.0.insert(key); + a.next_value_seed(SyntaxSeed(self.0 + 1))?; + } + Ok(()) + } +} + +#[cfg(test)] +mod tests; diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_protocol/types/tests.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_protocol/types/tests.rs new file mode 100644 index 00000000..2e08deb1 --- /dev/null +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_protocol/types/tests.rs @@ -0,0 +1,175 @@ +use super::*; + +fn document_json(value: &str) -> String { + format!( + r#"{{"schemaVersion":1,"exportedAt":"2026-09-23T00:00:00Z","sourceDevice":{{"id":"fixture","os":"test","arch":"test","appVersion":"0.1"}},"documents":[{{"id":"main","kind":"preferences","schemaVersion":1,"value":{value}}}],"tombstones":[]}}"# + ) +} + +#[test] +fn canonical_revision_preserves_uint64_without_float_conversion() { + let v = Revision::parse("9007199254740993").unwrap(); + assert_eq!(v.get(), 9_007_199_254_740_993); + assert_eq!(serde_json::to_string(&v).unwrap(), "\"9007199254740993\""); + for invalid in ["", "01", "00", "+1", "-1", "1.0", "18446744073709551616"] { + assert!(Revision::parse(invalid).is_err()); + } + for invalid in ["1", "1.0", "null", "true"] { + assert!(serde_json::from_str::(invalid).is_err()); + } + assert!(Revision::new(u64::MAX).checked_next().is_err()); + assert!(GithubId::parse("0").is_err()); +} + +#[test] +fn ids_base64_hash_and_etags_are_canonical() { + for invalid in [ + "BE406CA5-37FA-4B26-9A9A-74C1AAD48EAE", + "be406ca5-37fa-1b26-9a9a-74c1aad48eae", + "be406ca537fa4b269a9a74c1aad48eae", + ] { + assert!(UuidV4::parse(invalid).is_err()); + } + let id = UuidV4::random().unwrap(); + assert!(UuidV4::parse(id.as_str()).is_ok()); + assert!(Salt::parse("AAECAwQFBgcICQoLDA0ODw").is_ok()); + for invalid in [ + "AAECAwQFBgcICQoLDA0ODw==", + "AAECAwQFBgcICQoLDA0ODx", + "AAECAwQFBgcICQoLDA0OD+", + ] { + assert!(Salt::parse(invalid).is_err()); + } + assert!(Sha256Digest::parse(&"AB".repeat(32)).is_err()); + for invalid in ["*", "W/\"etag\"", "etag", "\"\"", "\"a\"b\"", "\"a\nb\""] { + assert!(StrongEtag::parse(invalid).is_err()); + } + assert_eq!( + StrongEtag::parse("\"opaque,=!\\\"").unwrap().as_str(), + "\"opaque,=!\\\"" + ); +} + +#[test] +fn private_json_rejects_duplicates_depth_and_bad_unicode() { + assert!(DocumentSet::from_json_bytes(document_json(r#"{"a":1,"a":2}"#).as_bytes()).is_err()); + let root_duplicate = document_json("{}").replacen( + "\"schemaVersion\":1", + "\"schemaVersion\":1,\"schemaVersion\":1", + 1, + ); + assert!(DocumentSet::from_json_bytes(root_duplicate.as_bytes()).is_err()); + let deep = format!("{}0{}", "[".repeat(65), "]".repeat(65)); + assert!(DocumentSet::from_json_bytes(document_json(&deep).as_bytes()).is_err()); + assert!(DocumentSet::from_json_bytes(document_json(r#"{"bad":1e400}"#).as_bytes()).is_err()); + assert!(DocumentSet::from_json_bytes(&[0xff, 0xfe]).is_err()); + assert!( + DocumentSet::from_json_bytes(format!("{} null", document_json("{}")).as_bytes()).is_err() + ); +} + +#[test] +fn document_identity_and_schema_are_validated_without_dropping_extensions() { + let input = document_json(r#"{"futureSetting":{"secret":"fixture"},"value":3}"#); + let mut docs = DocumentSet::from_json_bytes(input.as_bytes()).unwrap(); + let bytes = docs.to_secret_json().unwrap(); + let restored = DocumentSet::from_json_bytes(&bytes).unwrap(); + assert_eq!( + restored.documents[0].value["futureSetting"]["secret"], + "fixture" + ); + assert!(!format!("{docs:?}").contains("fixture")); + docs.documents.push(docs.documents[0].clone()); + assert!(docs.validate().is_err()); + docs.documents.pop(); + docs.tombstones.push(Tombstone { + id: "main".into(), + kind: DocumentKind::Preferences, + deleted_at: "2026-09-23T00:00:00Z".into(), + base_revision: Revision::new(0), + }); + assert!(docs.validate().is_err()); + assert!(matches!( + DocumentSet::from_json_bytes( + input + .replace("\"preferences\"", "\"future_kind\"") + .as_bytes() + ), + Err(Error::UnsupportedDocumentVersion) + )); + docs.tombstones.clear(); + docs.documents[0].schema_version = 2; + assert!(matches!( + docs.validate(), + Err(Error::UnsupportedDocumentVersion) + )); +} + +#[test] +fn metadata_requires_null_fields_and_rejects_inconsistent_states() { + let valid = r#"{"protocolVersion":1,"state":"empty","ownerGithubId":"12345","revision":"0","vaultId":null,"keyId":null,"updatedAt":null,"payloadSchemaVersion":null,"ciphertextBytes":0,"ciphertextSha256":null,"lastOperationId":null}"#; + assert!(parse_wire::(valid.as_bytes(), CONTROL_BODY_LIMIT).is_ok()); + assert!(parse_wire::( + valid.replace("\"keyId\":null,", "").as_bytes(), + CONTROL_BODY_LIMIT + ) + .is_err()); + assert!(parse_wire::( + valid + .replace("\"revision\":\"0\"", "\"revision\":\"1\"") + .as_bytes(), + CONTROL_BODY_LIMIT + ) + .is_err()); + assert!(parse_wire::( + valid.replace("\"empty\"", "\"active\"").as_bytes(), + CONTROL_BODY_LIMIT + ) + .is_err()); +} + +#[test] +fn schema_and_errors_never_echo_attacker_content() { + let marker = "PASSWORD_AND_TOKEN_MUST_NEVER_APPEAR"; + let input = document_json("{}").replace( + "\"schemaVersion\":1", + &format!("\"schemaVersion\":\"{marker}\""), + ); + let err = DocumentSet::from_json_bytes(input.as_bytes()).unwrap_err(); + assert!(!format!("{err:?} {err}").contains(marker)); + assert!(!format!("{:?}", SecretInput::new(marker.into())).contains(marker)); + assert!(!format!("{:?}", SecretToken::new(marker.into()).unwrap()).contains(marker)); +} + +#[test] +fn control_response_limit_is_enforced_before_json_parsing() { + let bytes = vec![b' '; CONTROL_BODY_LIMIT + 1]; + assert!(matches!( + parse_wire::(&bytes, CONTROL_BODY_LIMIT), + Err(Error::PayloadTooLarge) + )); +} + +#[test] +fn export_checks_depth_and_limit_before_allocating_json_output() { + let mut docs = DocumentSet::from_json_bytes(document_json("null").as_bytes()).unwrap(); + docs.documents[0].value = Value::String("x".repeat(MAX_PLAINTEXT_JSON_BYTES)); + assert!(matches!(docs.to_secret_json(), Err(Error::PayloadTooLarge))); + let mut value = Value::Null; + for _ in 0..65 { + value = Value::Array(vec![value]); + } + docs.documents[0].value = value; + assert!(docs.to_secret_json().is_err()); +} + +#[test] +fn optional_current_revision_must_not_be_explicit_null() { + let wire = r#"{"error":{"code":"revision_conflict","message":"fixed","requestId":"e1d8c32e-d209-4e56-8735-bc66b8684c71","currentRevision":null}}"#; + assert!(parse_wire::(wire.as_bytes(), CONTROL_BODY_LIMIT).is_err()); + assert!(parse_wire::( + wire.replace(",\"currentRevision\":null", "").as_bytes(), + CONTROL_BODY_LIMIT + ) + .is_ok()); +} diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/capture.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/capture.rs new file mode 100644 index 00000000..dcc82bf4 --- /dev/null +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/capture.rs @@ -0,0 +1,240 @@ +use std::collections::BTreeSet; + +use crate::cloud_sync_e2ee_documents::*; +use crate::cloud_sync_e2ee_protocol::types::{DocumentSet, Revision, Tombstone}; + +use super::{state::ScopeState, CoreSyncStore, DeviceExtensionKey, ExclusivePermit}; + +impl CoreSyncStore { + pub(super) async fn capture_locked( + &self, + scope: &SyncScope, + permit: &ExclusivePermit, + ) -> DocumentResult { + let state = ScopeState::decode( + scope, + self.inner.extensions.read_scope(scope.clone()).await?, + )?; + let snapshot = self.capture_native(scope, permit, &state).await?; + self.finish_capture(scope, state, snapshot).await + } + + pub(super) async fn capture_readonly( + &self, + scope: &SyncScope, + ) -> DocumentResult { + let token = self.inner.gate.coherent_generation()?; + let state = ScopeState::decode( + scope, + self.inner.extensions.read_scope(scope.clone()).await?, + )?; + let captured = self.capture_native_inner(None, &state, token.0).await; + if self.inner.gate.coherent_generation()? != token { + return Err(DocumentError::SourceChanged); + } + self.finish_capture(scope, state, captured?).await + } + + async fn finish_capture( + &self, + scope: &SyncScope, + mut state: ScopeState, + mut snapshot: ExportSnapshot, + ) -> DocumentResult { + let old_tombstones = std::mem::take(&mut snapshot.tombstones); + let mut exported = export_snapshot(snapshot)?; + let live: BTreeSet<_> = exported + .documents + .documents() + .documents + .iter() + .map(|doc| (doc.kind, doc.id.clone())) + .collect(); + let mut set = exported.documents.documents().clone(); + set.tombstones = old_tombstones + .into_iter() + .filter(|tombstone| !live.contains(&(tombstone.kind, tombstone.id.clone()))) + .collect(); + exported.documents = validate_sync_documents(set, state.observed_revision)?; + if let Some(baseline) = &state.baseline { + let mut current = exported.documents.documents().clone(); + let mut present: BTreeSet<_> = current + .documents + .iter() + .map(|doc| (doc.kind, doc.id.clone())) + .chain( + current + .tombstones + .iter() + .map(|doc| (doc.kind, doc.id.clone())), + ) + .collect(); + for tombstone in &baseline.tombstones { + if present.insert((tombstone.kind, tombstone.id.clone())) { + current.tombstones.push(tombstone.clone()); + } + } + for old in &baseline.documents { + if present.insert((old.kind, old.id.clone())) { + current.tombstones.push(Tombstone { + id: old.id.clone(), + kind: old.kind, + deleted_at: chrono::Utc::now().to_rfc3339(), + base_revision: state.baseline_revision, + }); + } + } + exported.documents = validate_sync_documents( + current, + state.observed_revision.max(state.baseline_revision), + )?; + } + if exported.documents.documents().tombstones != state.tombstones { + state.tombstones = exported.documents.documents().tombstones.clone(); + self.inner + .extensions + .write_scope(scope.clone(), state.secret_json()?) + .await?; + } + Ok(exported) + } + + pub(super) async fn capture_native( + &self, + _scope: &SyncScope, + permit: &ExclusivePermit, + state: &ScopeState, + ) -> DocumentResult { + self.capture_native_inner(Some(permit), state, permit.generation()?) + .await + } + + async fn capture_native_inner( + &self, + permit: Option<&ExclusivePermit>, + state: &ScopeState, + generation: Revision, + ) -> DocumentResult { + let repositories = &self.inner.repositories; + let preferences = SecretJson::new( + match permit { + Some(permit) => repositories.preferences.sync_snapshot(permit), + None => repositories.preferences.sync_snapshot_readonly(), + } + .map_err(|_| DocumentError::CaptureFailed)?, + ); + let dictionary = secret_rows( + &match permit { + Some(permit) => repositories.vocabulary.sync_snapshot(permit), + None => repositories.vocabulary.list(), + } + .map_err(|_| DocumentError::CaptureFailed)?, + )?; + let corrections = secret_rows( + &match permit { + Some(permit) => repositories.correction_rules.sync_snapshot(permit), + None => repositories.correction_rules.list(), + } + .map_err(|_| DocumentError::CaptureFailed)?, + )?; + let history = secret_rows( + &match permit { + Some(permit) => repositories.history.sync_snapshot(permit), + None => repositories.history.list(), + } + .map_err(|_| DocumentError::CaptureFailed)?, + )?; + let style_packs = match permit { + Some(permit) => repositories.style_packs.sync_snapshot(permit), + None => repositories.style_packs.sync_snapshot_readonly(), + } + .map_err(|_| DocumentError::CaptureFailed)?; + let activity = match permit { + Some(permit) => repositories.activity.sync_records(permit), + None => repositories.activity.sync_records_readonly(), + } + .map_err(|_| DocumentError::CaptureFailed)?; + let presets = crate::vocabulary::list_vocab_presets(&self.inner.data_dir) + .map_err(|_| DocumentError::CaptureFailed)?; + let builtin_ids: Vec<_> = crate::vocabulary::builtin_vocab_presets() + .into_iter() + .map(|preset| preset.id) + .collect(); + let vocabulary_presets = vocabulary_records( + &presets.custom, + &presets.overrides, + &presets.disabled_builtin_preset_ids, + &builtin_ids, + )?; + let ui_preferences = self + .inner + .extensions + .read_device(DeviceExtensionKey::UiPreferences) + .await? + .ok_or(DocumentError::Unsupported)?; + let window_positions = match self + .inner + .extensions + .read_device(DeviceExtensionKey::WindowPositions) + .await? + { + Some(value) => serde_json::from_value(value.expose().clone()) + .map_err(|_| DocumentError::InvalidDocument)?, + None => Vec::new(), + }; + let credentials = match permit { + Some(permit) => self.inner.credentials.export_sync_credentials(permit).await, + None => { + self.inner + .credentials + .export_sync_credentials_readonly() + .await + } + } + .map_err(|_| DocumentError::CaptureFailed)?; + validate_credential_set(&credentials.channels, &credentials.credentials)?; + Ok(ExportSnapshot { + source_device: self.inner.device.clone(), + exported_at: chrono::Utc::now().to_rfc3339(), + generation, + base_revision: state.observed_revision, + preferences, + ui_preferences, + channels: credentials.channels, + provider_credentials: credentials.credentials, + dictionary, + vocabulary_presets, + corrections, + style_packs, + history, + activity, + window_positions, + retained_documents: state.retained_documents.clone(), + tombstones: state.tombstones.clone(), + }) + } + + pub(super) async fn baseline_locked( + &self, + scope: &SyncScope, + documents: DocumentSet, + revision: Revision, + ) -> DocumentResult<()> { + let validated = validate_sync_documents(documents, revision)?; + let mut state = ScopeState::decode( + scope, + self.inner.extensions.read_scope(scope.clone()).await?, + )?; + if revision < state.baseline_revision { + return Err(DocumentError::StalePreview); + } + state.baseline_revision = revision; + state.observed_revision = state.observed_revision.max(revision); + state.baseline = Some(validated.documents().clone()); + // Do not replace retained documents or local tombstones: later local changes may exist. + self.inner + .extensions + .write_scope(scope.clone(), state.secret_json()?) + .await + } +} diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/extensions.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/extensions.rs new file mode 100644 index 00000000..5d9cdf1e --- /dev/null +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/extensions.rs @@ -0,0 +1,50 @@ +//! Encrypted extension persistence supplied by the service's OS-key-backed LocalStorage. + +use std::sync::Arc; + +use futures_util::future::BoxFuture; + +use crate::cloud_sync_e2ee_documents::{DocumentResult, JournalProtector, SecretJson, SyncScope}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DeviceExtensionKey { + /// Actual locale/font scale mirrored from the restricted native UI preferences bridge. + UiPreferences, + /// Persisted window positions, if the Host has any; never arbitrary window-state file paths. + WindowPositions, + /// Explicit registered restore scopes. Recovery does not scan arbitrary files. + RecoveryPointers, +} + +impl DeviceExtensionKey { + pub fn storage_name(self) -> &'static str { + match self { + Self::UiPreferences => "sync-ui-preferences", + Self::WindowPositions => "sync-window-positions", + Self::RecoveryPointers => "sync-recovery-pointers", + } + } +} + +pub trait ProtectedExtensionStore: Send + Sync { + /// Scope storage is keyed by origin/owner/vault/device, not a mutable global current account. + /// Cloud key rotation must not strand OS-key-protected local extensions. + fn read_scope(&self, scope: SyncScope) -> BoxFuture<'_, DocumentResult>>; + fn write_scope(&self, scope: SyncScope, value: SecretJson) + -> BoxFuture<'_, DocumentResult<()>>; + fn read_device( + &self, + key: DeviceExtensionKey, + ) -> BoxFuture<'_, DocumentResult>>; + fn write_device( + &self, + key: DeviceExtensionKey, + value: SecretJson, + ) -> BoxFuture<'_, DocumentResult<()>>; + /// A local-only change token, rotated on every restore write even when values repeat. + fn read_ui_revision(&self) -> BoxFuture<'_, DocumentResult>> { + Box::pin(async { Err(crate::cloud_sync_e2ee_documents::DocumentError::Unsupported) }) + } + /// Returns a real local AEAD protector after OS secure storage authorizes its key. + fn journal_protector(&self) -> BoxFuture<'_, DocumentResult>>; +} diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/gate.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/gate.rs new file mode 100644 index 00000000..0ab61270 --- /dev/null +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/gate.rs @@ -0,0 +1,1168 @@ +//! Shared write barrier and durable logical generation. Guards hold no mutex across await. + +use std::cell::RefCell; +use std::collections::{BTreeMap, BTreeSet}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex, MutexGuard, OnceLock, Weak}; + +use serde::{Deserialize, Serialize}; +use tokio::sync::watch; + +use crate::cloud_sync_e2ee_documents::{DocumentError, DocumentResult, SyncScope}; +use crate::cloud_sync_e2ee_protocol::types::Revision; + +static GATES: OnceLock>>> = OnceLock::new(); + +/// Host entry point before constructing any repository. Reuses the registered Arc exactly. +pub fn open_for_data_dir(data_dir: &Path) -> DocumentResult> { + std::fs::create_dir_all(data_dir.join("encrypted-sync")) + .map_err(|_| DocumentError::JournalUnavailable)?; + let root = data_dir + .canonicalize() + .map_err(|_| DocumentError::JournalUnavailable)?; + let mut registry = GATES + .get_or_init(|| Mutex::new(BTreeMap::new())) + .lock() + .map_err(|_| DocumentError::RecoveryRequired)?; + if let Some(gate) = registry.get(&root).and_then(Weak::upgrade) { + return Ok(gate); + } + let gate = SyncWriteGate::open(root.join("encrypted-sync/generation.json"))?; + registry.insert(root, Arc::downgrade(&gate)); + Ok(gate) +} + +fn absolute_normalized(path: &Path) -> DocumentResult { + let absolute = if path.is_absolute() { + path.to_path_buf() + } else { + std::env::current_dir() + .map_err(|_| DocumentError::JournalUnavailable)? + .join(path) + }; + let mut output = PathBuf::new(); + for component in absolute.components() { + match component { + std::path::Component::CurDir => {} + std::path::Component::ParentDir => { + if !output.pop() { + return Err(DocumentError::InvalidDocument); + } + } + value => output.push(value.as_os_str()), + } + } + Ok(output) +} + +/// Resolve the nearest existing ancestor, retaining every not-yet-created descendant. +fn resolved_path(path: &Path) -> DocumentResult { + let mut ancestor = path.to_path_buf(); + let mut suffix = Vec::new(); + loop { + match ancestor.canonicalize() { + Ok(mut resolved) => { + for part in suffix.iter().rev() { + resolved.push(part); + } + return absolute_normalized(&resolved); + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + // A dangling link must not be reinterpreted as an ordinary future directory. + if std::fs::symlink_metadata(&ancestor) + .is_ok_and(|metadata| metadata.file_type().is_symlink()) + { + return Err(DocumentError::InvalidDocument); + } + suffix.push( + ancestor + .file_name() + .ok_or(DocumentError::InvalidDocument)? + .to_os_string(), + ); + if !ancestor.pop() { + return Err(DocumentError::InvalidDocument); + } + } + Err(_) => return Err(DocumentError::JournalUnavailable), + } + } +} + +pub(crate) fn gate_for_path(path: &Path) -> DocumentResult>> { + if path.as_os_str().is_empty() { + return Ok(None); + } + let absolute = if path.is_absolute() { + path.to_path_buf() + } else { + std::env::current_dir() + .map_err(|_| DocumentError::JournalUnavailable)? + .join(path) + }; + let lexical = absolute_normalized(&absolute)?; + let parent = absolute.parent().map(resolved_path).transpose()?; + let final_is_symlink = std::fs::symlink_metadata(&absolute) + .is_ok_and(|metadata| metadata.file_type().is_symlink()); + let resolved = resolved_path(&absolute)?; + // Root aliases must retain ownership even when a later symlink escapes that root. + let ancestors: Vec<_> = absolute + .ancestors() + .filter_map(|ancestor| ancestor.canonicalize().ok()) + .collect(); + let registry = GATES + .get_or_init(|| Mutex::new(BTreeMap::new())) + .lock() + .map_err(|_| DocumentError::RecoveryRequired)?; + // rename replaces the directory entry, not its symlink target. A managed + // final-file link must never borrow a different nested root's permit. + if final_is_symlink + && parent + .as_ref() + .is_some_and(|parent| registry.keys().any(|root| parent.starts_with(root))) + { + return Err(DocumentError::InvalidDocument); + } + let lexical_root = registry + .keys() + .filter(|root| lexical.starts_with(root)) + .max_by_key(|root| root.components().count()); + if lexical_root.is_some_and(|root| !resolved.starts_with(root)) { + return Err(DocumentError::InvalidDocument); + } + for ancestor in &ancestors { + if registry + .keys() + .any(|root| ancestor.starts_with(root) && !resolved.starts_with(root)) + { + return Err(DocumentError::InvalidDocument); + } + } + let found = registry + .iter() + .filter(|(root, _)| resolved.starts_with(root)) + .max_by_key(|(root, _)| root.components().count()); + match found { + Some((_, weak)) => weak + .upgrade() + .map(Some) + .ok_or(DocumentError::RecoveryRequired), + None => Ok(None), + } +} + +/// Constructors may parse in memory but must not migrate, salvage-write, or normalize files. +pub fn recovery_pending_for_path(path: &Path) -> bool { + match gate_for_path(path) { + Ok(Some(gate)) => gate.recovery_required().unwrap_or(true), + Ok(None) => false, + Err(_) => true, + } +} + +struct Observation { + gate: Arc, + wrote: Arc, + dirty: Arc, +} +thread_local! { static OBSERVATIONS: RefCell> = const { RefCell::new(Vec::new()) }; } +struct ObservationGuard; +impl Drop for ObservationGuard { + fn drop(&mut self) { + OBSERVATIONS.with(|stack| { + stack.borrow_mut().pop(); + }); + } +} + +fn backend_error(error: DocumentError) -> crate::BackendError { + crate::BackendError::new( + if error == DocumentError::SourceChanged { + crate::BackendErrorCode::Busy + } else { + crate::BackendErrorCode::InvalidState + }, + error.to_string(), + ) +} + +pub(crate) fn with_registered_mutation( + path: &Path, + origin: ChangeOrigin, + operation: impl FnOnce() -> Result, +) -> Result { + with_registered_mutation_deciding(path, || operation().map(|value| (value, origin))) +} + +pub(crate) fn with_registered_mutation_deciding( + path: &Path, + operation: impl FnOnce() -> Result<(T, ChangeOrigin), crate::BackendError>, +) -> Result { + let Some(gate) = gate_for_path(path).map_err(backend_error)? else { + return operation().map(|(value, _)| value); + }; + let permit = gate.begin_mutation().map_err(backend_error)?; + let wrote = Arc::new(AtomicBool::new(false)); + let dirty = Arc::new(AtomicBool::new(false)); + OBSERVATIONS.with(|stack| { + stack.borrow_mut().push(Observation { + gate, + wrote: Arc::clone(&wrote), + dirty: Arc::clone(&dirty), + }) + }); + let observation = ObservationGuard; + let result = operation(); + drop(observation); + match result { + Ok((value, origin)) => { + if dirty.load(Ordering::Acquire) { + permit.commit(origin).map_err(backend_error)?; + } else { + permit.abort_unmodified().map_err(backend_error)?; + } + Ok(value) + } + Err(error) => { + if !wrote.load(Ordering::Acquire) + && error.code != crate::BackendErrorCode::OutcomeUnknown + { + permit.abort_unmodified().map_err(backend_error)?; + } + // Otherwise Drop retains the durable uncertain-write intent. + Err(error) + } + } +} + +pub(crate) fn with_optional_mutation( + path: Option<&Path>, + origin: ChangeOrigin, + operation: impl FnOnce() -> Result, +) -> Result { + match path { + Some(path) => with_registered_mutation(path, origin, operation), + None => operation(), + } +} + +fn tracked_file(path: &Path) -> bool { + matches!( + path.file_name().and_then(|name| name.to_str()), + Some( + "preferences.json" + | "history.json" + | "activity.json" + | "dictionary.json" + | "correction-rules.json" + | "style-packs.json" + | "vocab-presets.json" + ) + ) +} + +pub(crate) fn begin_unobserved_atomic( + path: &Path, +) -> Result, crate::BackendError> { + // Internal generation/extension files must bypass this lookup to avoid recursion. + if !tracked_file(path) { + return Ok(None); + } + let Some(gate) = gate_for_path(path).map_err(backend_error)? else { + return Ok(None); + }; + let observed = OBSERVATIONS.with(|stack| { + stack + .borrow() + .iter() + .any(|entry| Arc::ptr_eq(&entry.gate, &gate)) + }); + if observed { + return Ok(None); + } + gate.begin_mutation().map(Some).map_err(backend_error) +} + +pub(crate) fn note_successful_write(path: &Path) { + if !tracked_file(path) { + return; + } + if let Ok(Some(gate)) = gate_for_path(path) { + OBSERVATIONS.with(|stack| { + let stack = stack.borrow(); + let matching: Vec<_> = stack + .iter() + .filter(|entry| Arc::ptr_eq(&entry.gate, &gate)) + .collect(); + // All ancestors need a durable recovery intent if a later outer step fails. + for entry in &matching { + entry.wrote.store(true, Ordering::Release); + } + // Only the innermost owner counts this successful persistence. + if let Some(owner) = matching.last() { + owner.dirty.store(true, Ordering::Release); + } + }); + } +} + +pub(crate) fn require_exclusive( + path: &Path, + permit: &ExclusivePermit, +) -> Result<(), crate::BackendError> { + let gate = gate_for_path(path) + .map_err(backend_error)? + .ok_or_else(|| backend_error(DocumentError::Unsupported))?; + if !permit.belongs_to(&gate) { + return Err(backend_error(DocumentError::InvalidDocument)); + } + Ok(()) +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ChangeOrigin { + User, + Restore, + Recovery, + LocalOnly, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct SyncChange { + pub generation: Revision, + pub origin: ChangeOrigin, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct GateRestoreReceipt { + pub operation_id: String, + pub scope_id: String, + pub scope: SyncScope, + pub generation: Revision, + pub committed: bool, +} + +#[derive(Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct PendingRestore { + operation_id: String, + scope_id: String, + scope: SyncScope, + journal_ready: bool, +} + +#[derive(Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct PersistedState { + schema_version: u32, + generation: Revision, + incomplete_mutations: BTreeSet, + pending_restore: Option, + completed_restores: BTreeMap, +} + +struct State { + stored: PersistedState, + active: BTreeSet, + exclusive: bool, + exclusive_lease: Weak, + io_fault: bool, + epoch: u64, + pending_change: Option, +} + +pub struct SyncWriteGate { + path: PathBuf, + state: Mutex, + changes: watch::Sender, + runtime_blocked: AtomicBool, +} + +impl SyncWriteGate { + /// An unreadable/corrupt counter is an error, never a reset to zero. + pub fn open(state_path: PathBuf) -> DocumentResult> { + let initialized_path = state_path.with_extension("initialized"); + let stored = match std::fs::read(&state_path) { + Ok(bytes) => decode(&bytes)?, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + let directory = state_path + .parent() + .ok_or(DocumentError::JournalUnavailable)?; + if initialized_path.exists() + || (directory.file_name().and_then(|name| name.to_str()) + == Some("encrypted-sync") + && std::fs::read_dir(directory) + .map_err(|_| DocumentError::JournalUnavailable)? + .next() + .is_some()) + { + return Err(DocumentError::RecoveryRequired); + } + // Create a durable first-initialization sentinel before the initial counter. + // A crash in this small window fails closed instead of guessing generation zero. + crate::persistence::atomic_write(&initialized_path, b"openless-sync-generation-v1") + .map_err(|_| DocumentError::JournalUnavailable)?; + let state = PersistedState { + schema_version: 1, + generation: Revision::new(0), + incomplete_mutations: BTreeSet::new(), + pending_restore: None, + completed_restores: BTreeMap::new(), + }; + persist(&state_path, &state)?; + state + } + Err(_) => return Err(DocumentError::JournalUnavailable), + }; + if !initialized_path.exists() { + crate::persistence::atomic_write(&initialized_path, b"openless-sync-generation-v1") + .map_err(|_| DocumentError::JournalUnavailable)?; + } + let (changes, _) = watch::channel(SyncChange { + generation: stored.generation, + origin: ChangeOrigin::Recovery, + }); + let runtime_blocked = AtomicBool::new( + !stored.incomplete_mutations.is_empty() || stored.pending_restore.is_some(), + ); + Ok(Arc::new(Self { + runtime_blocked, + path: state_path, + state: Mutex::new(State { + stored, + active: BTreeSet::new(), + exclusive: false, + exclusive_lease: Weak::new(), + io_fault: false, + epoch: 0, + pending_change: None, + }), + changes, + })) + } + + pub fn generation(&self) -> DocumentResult { + let state = self.lock()?; + if state.io_fault || orphaned(&state) { + return Err(DocumentError::RecoveryRequired); + } + Ok(state.stored.generation) + } + + /// Optimistic read token; no writes or restore may be in flight at either observation. + pub fn coherent_generation(&self) -> DocumentResult<(Revision, u64)> { + let state = self.lock()?; + if state.io_fault || orphaned(&state) || state.stored.pending_restore.is_some() { + return Err(DocumentError::RecoveryRequired); + } + if state.exclusive || !state.active.is_empty() { + return Err(DocumentError::SourceChanged); + } + Ok((state.stored.generation, state.epoch)) + } + + pub fn subscribe(&self) -> watch::Receiver { + self.changes.subscribe() + } + + /// Public metadata only; recovery visits these registered scopes, never scans user files. + pub fn registered_recovery_scopes(&self) -> DocumentResult> { + let state = self.lock()?; + let mut scopes = Vec::new(); + if let Some(pending) = &state.stored.pending_restore { + scopes.push(pending.scope.clone()); + } + for receipt in state.stored.completed_restores.values() { + if !scopes.contains(&receipt.scope) { + scopes.push(receipt.scope.clone()); + } + } + Ok(scopes) + } + + /// Runtime hot paths never wait behind journal I/O or the write-state mutex. + pub fn recovery_required(&self) -> DocumentResult { + Ok(self.runtime_blocked.load(Ordering::Acquire)) + } + + fn refresh_runtime_flag(&self, state: &State) { + self.runtime_blocked.store( + state.io_fault || orphaned(state) || state.stored.pending_restore.is_some(), + Ordering::Release, + ); + } + + /// Acquire before reading data for a read-modify-write. Only commit after its save succeeds. + pub fn begin_mutation(self: &Arc) -> DocumentResult { + let mut state = self.lock()?; + if state.io_fault || orphaned(&state) || state.stored.pending_restore.is_some() { + return Err(DocumentError::RecoveryRequired); + } + if state.exclusive { + return Err(DocumentError::SourceChanged); + } + let id = uuid::Uuid::new_v4().to_string(); + let mut next = state.stored.clone(); + next.incomplete_mutations.insert(id.clone()); + self.save(&mut state, next)?; + state.epoch = state + .epoch + .checked_add(1) + .ok_or(DocumentError::RecoveryRequired)?; + state.active.insert(id.clone()); + self.refresh_runtime_flag(&state); + Ok(MutationPermit { + gate: Arc::clone(self), + id, + finished: false, + }) + } + + /// Fail fast while any mutation is active; never block a writer's nested operation. + pub fn try_exclusive(self: &Arc) -> DocumentResult { + let mut state = self.lock()?; + if state.io_fault || orphaned(&state) || state.stored.pending_restore.is_some() { + return Err(DocumentError::RecoveryRequired); + } + if state.exclusive || !state.active.is_empty() { + return Err(DocumentError::SourceChanged); + } + state.epoch = state + .epoch + .checked_add(1) + .ok_or(DocumentError::RecoveryRequired)?; + state.exclusive = true; + let lease = Arc::new(ExclusiveLease { + gate: Arc::clone(self), + }); + state.exclusive_lease = Arc::downgrade(&lease); + Ok(ExclusivePermit { lease }) + } + + /// For startup journal recovery or a strict full-store reconciliation after an uncertain save. + pub fn try_exclusive_recovery(self: &Arc) -> DocumentResult { + let mut state = self.lock()?; + if state.exclusive || !state.active.is_empty() { + return Err(DocumentError::SourceChanged); + } + let stored = + decode(&std::fs::read(&self.path).map_err(|_| DocumentError::JournalUnavailable)?)?; + state.stored = stored; + state.io_fault = false; + self.refresh_runtime_flag(&state); + state.epoch = state + .epoch + .checked_add(1) + .ok_or(DocumentError::RecoveryRequired)?; + state.exclusive = true; + let lease = Arc::new(ExclusiveLease { + gate: Arc::clone(self), + }); + state.exclusive_lease = Arc::downgrade(&lease); + Ok(ExclusivePermit { lease }) + } + + pub(crate) fn clone_exclusive_for_metadata(&self) -> DocumentResult { + self.lock()? + .exclusive_lease + .upgrade() + .map(|lease| ExclusivePermit { lease }) + .ok_or(DocumentError::SourceChanged) + } + + fn lock(&self) -> DocumentResult> { + self.state.lock().map_err(|_| { + self.runtime_blocked.store(true, Ordering::Release); + DocumentError::RecoveryRequired + }) + } + + fn save(&self, state: &mut State, next: PersistedState) -> DocumentResult<()> { + if let Err(error) = persist(&self.path, &next) { + state.io_fault = true; + self.runtime_blocked.store(true, Ordering::Release); + return Err(error); + } + state.stored = next; + Ok(()) + } + + fn publish(&self, generation: Revision, origin: ChangeOrigin) { + if origin != ChangeOrigin::LocalOnly { + // Concurrent commits can reach notification delivery out of order. + self.changes.send_if_modified(|current| { + if generation < current.generation + || *current == (SyncChange { generation, origin }) + { + return false; + } + *current = SyncChange { generation, origin }; + true + }); + } + } +} + +fn orphaned(state: &State) -> bool { + state.stored.incomplete_mutations != state.active +} + +fn decode(bytes: &[u8]) -> DocumentResult { + if bytes.len() > 1024 * 1024 { + return Err(DocumentError::JournalUnavailable); + } + let state: PersistedState = + serde_json::from_slice(bytes).map_err(|_| DocumentError::JournalUnavailable)?; + if state.schema_version != 1 { + return Err(DocumentError::Unsupported); + } + Ok(state) +} + +fn persist(path: &std::path::Path, state: &PersistedState) -> DocumentResult<()> { + let bytes = serde_json::to_vec(state).map_err(|_| DocumentError::JournalUnavailable)?; + if bytes.len() > 1024 * 1024 { + return Err(DocumentError::JournalUnavailable); + } + crate::persistence::atomic_write(path, &bytes).map_err(|_| DocumentError::JournalUnavailable) +} + +/// Owned and Send; keep it inside the actual task, not only its caller's cancellable future. +pub struct MutationPermit { + gate: Arc, + id: String, + finished: bool, +} + +impl MutationPermit { + pub fn commit(mut self, origin: ChangeOrigin) -> DocumentResult { + let (generation, publish) = { + let mut state = self.gate.lock()?; + if !state.active.contains(&self.id) || state.io_fault { + return Err(DocumentError::RecoveryRequired); + } + let mut next = state.stored.clone(); + if origin != ChangeOrigin::LocalOnly { + next.generation = next + .generation + .checked_next() + .map_err(|_| DocumentError::RecoveryRequired)?; + } + next.incomplete_mutations.remove(&self.id); + self.gate.save(&mut state, next)?; + state.active.remove(&self.id); + self.gate.refresh_runtime_flag(&state); + if origin != ChangeOrigin::LocalOnly { + state.pending_change = Some(origin); + } + let publish = if state.active.is_empty() { + state.pending_change.take() + } else { + None + }; + self.finished = true; + (state.stored.generation, publish) + }; + if let Some(origin) = publish { + self.gate.publish(generation, origin); + } + Ok(generation) + } + + /// Only for a validation/no-op/error path known not to have persisted anything. + pub fn abort_unmodified(mut self) -> DocumentResult<()> { + let (generation, publish) = { + let mut state = self.gate.lock()?; + let mut next = state.stored.clone(); + next.incomplete_mutations.remove(&self.id); + self.gate.save(&mut state, next)?; + state.active.remove(&self.id); + self.gate.refresh_runtime_flag(&state); + let publish = if state.active.is_empty() { + state.pending_change.take() + } else { + None + }; + self.finished = true; + (state.stored.generation, publish) + }; + if let Some(origin) = publish { + self.gate.publish(generation, origin); + } + Ok(()) + } +} + +impl Drop for MutationPermit { + fn drop(&mut self) { + if !self.finished { + if let Ok(mut state) = self.gate.state.lock() { + state.active.remove(&self.id); + self.gate.refresh_runtime_flag(&state); + // The durable intent remains until an actual coherent capture reconciles state. + state.io_fault = true; + self.gate.runtime_blocked.store(true, Ordering::Release); + } + } + } +} + +#[derive(Clone)] +pub struct ExclusivePermit { + lease: Arc, +} +struct ExclusiveLease { + gate: Arc, +} + +impl ExclusivePermit { + pub fn belongs_to(&self, gate: &Arc) -> bool { + Arc::ptr_eq(&self.lease.gate, gate) + } + + pub fn generation(&self) -> DocumentResult { + Ok(self.lease.gate.lock()?.stored.generation) + } + + pub fn pending_restore(&self) -> DocumentResult { + Ok(self.lease.gate.lock()?.stored.pending_restore.is_some()) + } + + /// Called only after every store and credential source was successfully read/validated. + /// It records an observed recovery epoch, never declares an interrupted mutation successful. + pub(crate) fn accept_reconciled_capture(&self) -> DocumentResult { + let generation = { + let mut state = self.lease.gate.lock()?; + if state.stored.pending_restore.is_some() { + return Err(DocumentError::RecoveryRequired); + } + if state.stored.incomplete_mutations.is_empty() { + return Ok(state.stored.generation); + } + let mut next = state.stored.clone(); + next.generation = next + .generation + .checked_next() + .map_err(|_| DocumentError::RecoveryRequired)?; + next.incomplete_mutations.clear(); + self.lease.gate.save(&mut state, next)?; + self.lease.gate.refresh_runtime_flag(&state); + state.stored.generation + }; + self.lease.gate.publish(generation, ChangeOrigin::Recovery); + Ok(generation) + } + + pub fn mark_restore_pending( + &self, + operation_id: &str, + scope_id: &str, + scope: &SyncScope, + ) -> DocumentResult<()> { + let mut state = self.lease.gate.lock()?; + if let Some(pending) = &state.stored.pending_restore { + if pending.operation_id != operation_id + || pending.scope_id != scope_id + || &pending.scope != scope + { + return Err(DocumentError::RecoveryRequired); + } + return Ok(()); + } + let mut next = state.stored.clone(); + next.pending_restore = Some(PendingRestore { + operation_id: operation_id.into(), + scope_id: scope_id.into(), + scope: scope.clone(), + journal_ready: false, + }); + self.lease.gate.save(&mut state, next)?; + self.lease.gate.refresh_runtime_flag(&state); + Ok(()) + } + + pub fn mark_journal_ready(&self, operation_id: &str, scope_id: &str) -> DocumentResult<()> { + let mut state = self.lease.gate.lock()?; + let mut next = state.stored.clone(); + let pending = next + .pending_restore + .as_mut() + .ok_or(DocumentError::RecoveryRequired)?; + if pending.operation_id != operation_id || pending.scope_id != scope_id { + return Err(DocumentError::RecoveryRequired); + } + pending.journal_ready = true; + self.lease.gate.save(&mut state, next)?; + self.lease.gate.refresh_runtime_flag(&state); + Ok(()) + } + + pub fn recover_without_journal(&self, scope_id: &str) -> DocumentResult<()> { + let operation = { + let state = self.lease.gate.lock()?; + let Some(pending) = &state.stored.pending_restore else { + return Ok(()); + }; + if pending.scope_id != scope_id || pending.journal_ready { + return Err(DocumentError::RecoveryRequired); + } + pending.operation_id.clone() + }; + self.finish_restore(&operation, scope_id, false).map(|_| ()) + } + + pub fn completed_restore( + &self, + operation_id: &str, + scope_id: &str, + ) -> DocumentResult> { + Ok(self + .lease + .gate + .lock()? + .stored + .completed_restores + .get(scope_id) + .filter(|receipt| receipt.operation_id == operation_id) + .cloned()) + } + + pub(crate) fn forget_completed_scope_without_journal( + &self, + scope_id: &str, + ) -> DocumentResult<()> { + let mut state = self.lease.gate.lock()?; + if state + .stored + .pending_restore + .as_ref() + .is_some_and(|pending| pending.scope_id == scope_id) + { + return Err(DocumentError::RecoveryRequired); + } + if state.stored.completed_restores.contains_key(scope_id) { + let mut next = state.stored.clone(); + next.completed_restores.remove(scope_id); + self.lease.gate.save(&mut state, next)?; + } + Ok(()) + } + + pub(crate) fn forget_completed_restore( + &self, + operation_id: &str, + scope_id: &str, + ) -> DocumentResult<()> { + let mut state = self.lease.gate.lock()?; + if state + .stored + .completed_restores + .get(scope_id) + .is_some_and(|receipt| receipt.operation_id == operation_id) + { + let mut next = state.stored.clone(); + next.completed_restores.remove(scope_id); + self.lease.gate.save(&mut state, next)?; + } + Ok(()) + } + + pub fn finish_restore( + &self, + operation_id: &str, + scope_id: &str, + committed: bool, + ) -> DocumentResult { + if let Some(receipt) = self.completed_restore(operation_id, scope_id)? { + if receipt.committed != committed { + return Err(DocumentError::RecoveryRequired); + } + return Ok(receipt); + } + let receipt = { + let mut state = self.lease.gate.lock()?; + let pending = state + .stored + .pending_restore + .as_ref() + .ok_or(DocumentError::RecoveryRequired)?; + if pending.operation_id != operation_id || pending.scope_id != scope_id { + return Err(DocumentError::RecoveryRequired); + } + let scope = pending.scope.clone(); + let mut next = state.stored.clone(); + if committed { + next.generation = next + .generation + .checked_next() + .map_err(|_| DocumentError::RecoveryRequired)?; + } + next.pending_restore = None; + let receipt = GateRestoreReceipt { + operation_id: operation_id.into(), + scope_id: scope_id.into(), + scope, + generation: next.generation, + committed, + }; + next.completed_restores + .insert(scope_id.to_string(), receipt.clone()); + self.lease.gate.save(&mut state, next)?; + self.lease.gate.refresh_runtime_flag(&state); + receipt + }; + if committed { + self.lease + .gate + .publish(receipt.generation, ChangeOrigin::Restore); + } + Ok(receipt) + } +} + +impl Drop for ExclusiveLease { + fn drop(&mut self) { + if let Ok(mut state) = self.gate.state.lock() { + state.exclusive = false; + match state.epoch.checked_add(1) { + Some(next) => state.epoch = next, + None => { + state.io_fault = true; + self.gate.runtime_blocked.store(true, Ordering::Release); + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + struct Temp(PathBuf); + impl Temp { + fn new() -> Self { + let path = + std::env::temp_dir().join(format!("sync-gate-fixture-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&path).unwrap(); + Self(path) + } + } + impl Drop for Temp { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + #[test] + fn nested_completion_flushes_only_after_last_abort_or_local_only_commit() { + for local_only in [false, true] { + let temp = Temp::new(); + let gate = open_for_data_dir(&temp.0).unwrap(); + let changes = gate.subscribe(); + let outer = gate.begin_mutation().unwrap(); + let inner = gate.begin_mutation().unwrap(); + inner.commit(ChangeOrigin::User).unwrap(); + assert!(!changes.has_changed().unwrap()); + if local_only { + outer.commit(ChangeOrigin::LocalOnly).unwrap(); + } else { + outer.abort_unmodified().unwrap(); + } + assert!(changes.has_changed().unwrap()); + assert_eq!(changes.borrow().generation, gate.generation().unwrap()); + } + } + #[test] + fn exclusive_clones_hold_barrier_and_rollback_changes_capture_epoch() { + let temp = Temp::new(); + let gate = open_for_data_dir(&temp.0).unwrap(); + let before = gate.coherent_generation().unwrap(); + let first = gate.try_exclusive().unwrap(); + let last = first.clone(); + drop(first); + assert!(gate.begin_mutation().is_err()); + drop(last); + let after = gate.coherent_generation().unwrap(); + assert_eq!(before.0, after.0); + assert_ne!(before.1, after.1); + } + #[test] + fn delayed_old_notification_cannot_move_watch_backwards() { + let temp = Temp::new(); + let gate = open_for_data_dir(&temp.0).unwrap(); + gate.publish(Revision::new(2), ChangeOrigin::User); + gate.publish(Revision::new(1), ChangeOrigin::User); + assert_eq!(gate.subscribe().borrow().generation, Revision::new(2)); + } + #[cfg(unix)] + #[test] + fn aliases_missing_descendants_and_escaping_symlinks_cannot_bypass_gate() { + let temp = Temp::new(); + let root = temp.0.join("data"); + std::fs::create_dir(&root).unwrap(); + let gate = open_for_data_dir(&root).unwrap(); + let alias = temp.0.join("alias"); + std::os::unix::fs::symlink(&root, &alias).unwrap(); + let permit = gate.try_exclusive().unwrap(); + for path in [ + alias.join("preferences.json"), + alias.join("missing/nested/preferences.json"), + ] { + assert!(with_registered_mutation(&path, ChangeOrigin::User, || { + crate::persistence::atomic_write(&path, b"{}") + }) + .is_err()); + assert!(!path.exists()); + } + std::os::unix::fs::symlink(temp.0.join("outside"), root.join("escape")).unwrap(); + assert!(gate_for_path(&root.join("escape/preferences.json")).is_err()); + drop(permit); + assert_eq!(gate.generation().unwrap(), Revision::new(0)); + } + #[test] + fn unfinished_mutation_survives_reopen_and_only_reconciliation_clears_it() { + let temp = Temp::new(); + let path = temp.0.join("state.json"); + let gate = SyncWriteGate::open(path.clone()).unwrap(); + drop(gate.begin_mutation().unwrap()); + drop(gate); + let gate = SyncWriteGate::open(path).unwrap(); + assert!(gate.recovery_required().unwrap()); + let permit = gate.try_exclusive_recovery().unwrap(); + permit.accept_reconciled_capture().unwrap(); + drop(permit); + assert_eq!(gate.generation().unwrap(), Revision::new(1)); + assert!(!gate.recovery_required().unwrap()); + } + #[test] + fn persist_rejects_unreadable_size_before_replacing_existing_state() { + let temp = Temp::new(); + let gate = open_for_data_dir(&temp.0).unwrap(); + let path = temp.0.join("encrypted-sync/generation.json"); + let before = std::fs::read(&path).unwrap(); + let mut state = gate.lock().unwrap().stored.clone(); + state.incomplete_mutations.insert("x".repeat(1024 * 1024)); + assert!(persist(&path, &state).is_err()); + assert_eq!(std::fs::read(&path).unwrap(), before); + assert!(decode(&before).is_ok()); + } + #[test] + fn lost_counter_never_resets_an_initialized_gate_or_existing_protected_state() { + let temp = Temp::new(); + let gate = open_for_data_dir(&temp.0).unwrap(); + let path = temp.0.join("encrypted-sync/generation.json"); + gate.begin_mutation() + .unwrap() + .commit(ChangeOrigin::User) + .unwrap(); + drop(gate); + std::fs::remove_file(&path).unwrap(); + assert!(matches!( + SyncWriteGate::open(path), + Err(DocumentError::RecoveryRequired) + )); + let other = Temp::new(); + std::fs::create_dir(other.0.join("encrypted-sync")).unwrap(); + std::fs::write(other.0.join("encrypted-sync/fixture.enc"), b"ciphertext").unwrap(); + assert!(matches!( + open_for_data_dir(&other.0), + Err(DocumentError::RecoveryRequired) + )); + } + + #[test] + fn runtime_recovery_flag_does_not_wait_for_state_lock_or_file_io() { + let temp = Temp::new(); + let gate = open_for_data_dir(&temp.0).unwrap(); + let held = gate.state.lock().unwrap(); + assert!(!gate.recovery_required().unwrap()); + drop(held); + drop(gate.begin_mutation().unwrap()); + let held = gate.state.lock().unwrap(); + assert!(gate.recovery_required().unwrap()); + drop(held); + } + #[test] + fn inner_success_followed_by_outer_failure_keeps_an_uncertain_outer_intent() { + let temp = Temp::new(); + let gate = open_for_data_dir(&temp.0).unwrap(); + let path = temp.0.join("preferences.json"); + let result: Result<(), crate::BackendError> = + with_registered_mutation(&path, ChangeOrigin::User, || { + with_registered_mutation(&path, ChangeOrigin::User, || { + crate::persistence::atomic_write(&path, b"{}") + })?; + Err(crate::BackendError::new( + crate::BackendErrorCode::Persistence, + "later outer step failed", + )) + }); + assert!(result.is_err()); + assert!(gate.recovery_required().unwrap()); + assert_eq!(std::fs::read(path).unwrap(), b"{}"); + let permit = gate.try_exclusive_recovery().unwrap(); + assert_eq!(permit.generation().unwrap(), Revision::new(1)); + assert!(!gate.lock().unwrap().stored.incomplete_mutations.is_empty()); + } + #[cfg(unix)] + #[test] + fn aliased_root_with_final_or_intermediate_escape_cannot_bypass_held_exclusive() { + let temp = Temp::new(); + let root = temp.0.join("data"); + std::fs::create_dir(&root).unwrap(); + let gate = open_for_data_dir(&root).unwrap(); + let alias = temp.0.join("alias"); + std::os::unix::fs::symlink(&root, &alias).unwrap(); + let outside = temp.0.join("outside"); + std::fs::create_dir(&outside).unwrap(); + std::fs::write(outside.join("preferences.json"), b"outside").unwrap(); + std::os::unix::fs::symlink( + outside.join("preferences.json"), + root.join("preferences.json"), + ) + .unwrap(); + std::os::unix::fs::symlink(&outside, root.join("escape")).unwrap(); + let _permit = gate.try_exclusive().unwrap(); + for path in [ + alias.join("preferences.json"), + alias.join("escape/preferences.json"), + ] { + assert!(with_registered_mutation(&path, ChangeOrigin::User, || { + crate::persistence::atomic_write(&path, b"changed") + }) + .is_err()); + } + assert_eq!( + std::fs::read(outside.join("preferences.json")).unwrap(), + b"outside" + ); + assert_eq!(gate.generation().unwrap(), Revision::new(0)); + } + #[cfg(unix)] + #[test] + fn managed_final_link_into_a_nested_registered_root_never_borrows_its_permit() { + let temp = Temp::new(); + let outer = open_for_data_dir(&temp.0).unwrap(); + let nested = temp.0.join("child"); + let inner = open_for_data_dir(&nested).unwrap(); + let target = nested.join("existing.json"); + std::fs::write(&target, b"child").unwrap(); + let link = temp.0.join("preferences.json"); + std::os::unix::fs::symlink(&target, &link).unwrap(); + let _permit = outer.try_exclusive().unwrap(); + assert!(with_registered_mutation(&link, ChangeOrigin::User, || { + crate::persistence::atomic_write(&link, b"outer") + }) + .is_err()); + assert!(std::fs::symlink_metadata(&link) + .unwrap() + .file_type() + .is_symlink()); + assert_eq!(std::fs::read(target).unwrap(), b"child"); + assert_eq!(outer.generation().unwrap(), Revision::new(0)); + assert_eq!(inner.generation().unwrap(), Revision::new(0)); + } +} diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/journal.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/journal.rs new file mode 100644 index 00000000..1cf20dc8 --- /dev/null +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/journal.rs @@ -0,0 +1,136 @@ +use std::io::Read; +use std::path::PathBuf; +use std::sync::Arc; + +use futures_util::future::BoxFuture; + +use crate::cloud_sync_e2ee_documents::{ + DocumentError, DocumentResult, JournalStore, SealedJournal, SyncScope, MAX_JOURNAL_BYTES, +}; + +use super::{state::scope_id, SyncWriteGate}; + +const MAGIC: &[u8; 8] = b"OLSJRNL1"; + +pub(super) struct FileJournalStore { + directory: PathBuf, + gate: Arc, +} +impl FileJournalStore { + pub fn new(directory: PathBuf, gate: Arc) -> Self { + Self { directory, gate } + } + fn path(&self, scope: &SyncScope) -> DocumentResult { + Ok(self + .directory + .join(format!("restore-{}.bin", scope_id(scope)?))) + } +} + +fn sync_directory(path: &std::path::Path) -> DocumentResult<()> { + #[cfg(unix)] + std::fs::File::open(path) + .and_then(|file| file.sync_all()) + .map_err(|_| DocumentError::JournalUnavailable)?; + Ok(()) +} + +impl JournalStore for FileJournalStore { + fn load(&self, scope: SyncScope) -> BoxFuture<'_, DocumentResult>> { + Box::pin(async move { + let path = self.path(&scope)?; + tokio::task::spawn_blocking(move || { + match std::fs::symlink_metadata(&path) { + Ok(metadata) if metadata.file_type().is_symlink() => { + return Err(DocumentError::RecoveryRequired) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(_) => return Err(DocumentError::JournalUnavailable), + _ => {} + } + let mut bytes = Vec::new(); + std::fs::File::open(path) + .map_err(|_| DocumentError::JournalUnavailable)? + .take((MAX_JOURNAL_BYTES + 129 + 44) as u64) + .read_to_end(&mut bytes) + .map_err(|_| DocumentError::JournalUnavailable)?; + if bytes.len() < 44 + || bytes.len() > MAX_JOURNAL_BYTES + 128 + 44 + || !bytes.starts_with(MAGIC) + { + return Err(DocumentError::RecoveryRequired); + } + let operation_id = std::str::from_utf8(&bytes[8..44]) + .map_err(|_| DocumentError::RecoveryRequired)? + .to_string(); + let id = uuid::Uuid::parse_str(&operation_id) + .map_err(|_| DocumentError::RecoveryRequired)?; + if id.get_version() != Some(uuid::Version::Random) || id.to_string() != operation_id + { + return Err(DocumentError::RecoveryRequired); + } + Ok(Some(SealedJournal { + operation_id, + ciphertext: bytes[44..].to_vec(), + })) + }) + .await + .map_err(|_| DocumentError::JournalUnavailable)? + }) + } + + fn save(&self, scope: SyncScope, journal: SealedJournal) -> BoxFuture<'_, DocumentResult<()>> { + Box::pin(async move { + let path = self.path(&scope)?; + let guard = self.gate.clone_exclusive_for_metadata()?; + if journal.operation_id.len() != 36 + || journal.ciphertext.is_empty() + || journal.ciphertext.len() > MAX_JOURNAL_BYTES + 128 + { + return Err(DocumentError::JournalUnavailable); + } + tokio::task::spawn_blocking(move || { + // This clone keeps ordinary writes blocked even if the waiting future is dropped. + let _guard = guard; + let mut bytes = Vec::with_capacity(44 + journal.ciphertext.len()); + bytes.extend_from_slice(MAGIC); + bytes.extend_from_slice(journal.operation_id.as_bytes()); + bytes.extend_from_slice(&journal.ciphertext); + crate::persistence::atomic_write(&path, &bytes) + .map_err(|_| DocumentError::JournalUnavailable) + }) + .await + .map_err(|_| DocumentError::JournalUnavailable)? + }) + } + + fn clear(&self, scope: SyncScope) -> BoxFuture<'_, DocumentResult<()>> { + Box::pin(async move { + let path = self.path(&scope)?; + let scope_id = scope_id(&scope)?; + let guard = self.gate.clone_exclusive_for_metadata()?; + tokio::task::spawn_blocking(move || { + let operation = std::fs::File::open(&path).ok().and_then(|file| { + let mut bytes = Vec::new(); + file.take(44).read_to_end(&mut bytes).ok()?; + bytes + .get(8..44) + .and_then(|bytes| std::str::from_utf8(bytes).ok()) + .map(str::to_owned) + }); + match std::fs::remove_file(&path) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(_) => return Err(DocumentError::JournalUnavailable), + } + sync_directory(path.parent().ok_or(DocumentError::JournalUnavailable)?)?; + if let Some(operation) = operation { + guard.forget_completed_restore(&operation, &scope_id)?; + } + Ok(()) + }) + .await + .map_err(|_| DocumentError::JournalUnavailable)? + }) + } +} diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/mod.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/mod.rs new file mode 100644 index 00000000..97829cd0 --- /dev/null +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/mod.rs @@ -0,0 +1,600 @@ +//! Native repository adapter for encrypted sync. +//! Complete logical restoration is protected by an encrypted crash journal. + +mod capture; +pub mod extensions; +pub mod gate; +mod journal; +mod native; +mod state; + +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; + +use futures_util::future::BoxFuture; + +use crate::cloud_sync_e2ee_documents::{ + DocumentError, DocumentResult, ExportedDocuments, RestoreBackend, RestoreContext, RestoreLease, + SecretJson, SyncScope, ValidatedSyncDocuments, +}; +use crate::cloud_sync_e2ee_protocol::types::{DocumentSet, Revision, SourceDevice}; +use crate::{BackendRepositories, CredentialStore}; + +pub use extensions::{DeviceExtensionKey, ProtectedExtensionStore}; +pub use gate::{ChangeOrigin, ExclusivePermit, MutationPermit, SyncChange, SyncWriteGate}; + +#[derive(Clone)] +pub struct CoreSyncStore { + inner: Arc, +} + +struct Inner { + repositories: BackendRepositories, + credentials: Arc, + data_dir: PathBuf, + device: SourceDevice, + gate: Arc, + extensions: Arc, + restoring: AtomicBool, + metadata: tokio::sync::Mutex<()>, + runtime_idle: std::sync::Mutex bool + Send + Sync>>>, + runtime_effects: std::sync::Mutex>>, +} + +impl CoreSyncStore { + pub fn new( + repositories: BackendRepositories, + credentials: Arc, + data_dir: PathBuf, + device: SourceDevice, + gate: Arc, + extensions: Arc, + ) -> DocumentResult { + let registered = gate::open_for_data_dir(&data_dir)?; + if !Arc::ptr_eq(®istered, &gate) { + return Err(DocumentError::InvalidDocument); + } + credentials + .bind_sync_gate(Arc::clone(&gate)) + .map_err(|_| DocumentError::Locked)?; + repositories + .activity + .bind_sync_device(&device.id) + .map_err(|_| DocumentError::CaptureFailed)?; + Ok(Self { + inner: Arc::new(Inner { + repositories, + credentials, + data_dir, + device, + gate, + extensions, + restoring: AtomicBool::new(false), + metadata: tokio::sync::Mutex::new(()), + runtime_idle: std::sync::Mutex::new(None), + runtime_effects: std::sync::Mutex::new(None), + }), + }) + } + + pub fn generation(&self) -> DocumentResult { + self.inner.gate.generation() + } + pub fn device(&self) -> SourceDevice { + self.inner.device.clone() + } + pub fn changes(&self) -> tokio::sync::watch::Receiver { + self.inner.gate.subscribe() + } + + /// Core binds its shared dictation/insertion/agent session-state probe before startup recovery. + pub fn bind_runtime_idle_probe( + &self, + probe: Arc bool + Send + Sync>, + ) -> DocumentResult<()> { + let mut slot = self + .inner + .runtime_idle + .lock() + .map_err(|_| DocumentError::RecoveryRequired)?; + if slot.is_some() { + return Err(DocumentError::InvalidDocument); + } + *slot = Some(probe); + Ok(()) + } + + /// Install a real, absolute Host effects adapter before allowing restores. + pub fn bind_runtime_effects( + &self, + effects: Arc, + ) -> DocumentResult<()> { + let mut slot = self + .inner + .runtime_effects + .lock() + .map_err(|_| DocumentError::RecoveryRequired)?; + if slot.is_some() { + return Err(DocumentError::InvalidDocument); + } + *slot = Some(effects); + Ok(()) + } + + fn runtime_effects(&self) -> DocumentResult> { + self.inner + .runtime_effects + .lock() + .map_err(|_| DocumentError::RecoveryRequired)? + .clone() + .ok_or(DocumentError::Unsupported) + } + + async fn apply_runtime_effects(&self, permit: &ExclusivePermit) -> DocumentResult<()> { + if !permit.belongs_to(&self.inner.gate) { + return Err(DocumentError::InvalidDocument); + } + let effects = self.runtime_effects()?; + let target = self.inner.repositories.preferences.get(); + effects + .apply_target(target) + .await + .map_err(|_| DocumentError::CaptureFailed) + } + + /// Ordinary runtime starts must check this while holding Core's shared runtime lock. + pub fn is_recovering(&self) -> bool { + self.inner.restoring.load(Ordering::Acquire) + || self.inner.gate.recovery_required().unwrap_or(true) + } + pub fn ensure_runtime_available(&self) -> DocumentResult<()> { + if self.is_recovering() { + Err(DocumentError::RecoveryRequired) + } else { + Ok(()) + } + } + + /// Local-only eligibility sample. Callers recheck this stamp after credential reads. + pub(crate) fn setup_prompt_state( + &self, + ) -> DocumentResult> { + if self.is_recovering() { + return Ok(None); + } + let probe = self + .inner + .runtime_idle + .lock() + .map_err(|_| DocumentError::RecoveryRequired)? + .clone(); + let Some(probe) = probe else { + return Ok(None); + }; + if !probe() { + return Ok(None); + } + let stamp = match self.inner.gate.coherent_generation() { + Ok(stamp) => stamp, + Err(DocumentError::SourceChanged | DocumentError::RecoveryRequired) => return Ok(None), + Err(error) => return Err(error), + }; + let preferences = self.inner.repositories.preferences.get(); + if self.is_recovering() + || !probe() + || self.inner.gate.coherent_generation().ok() != Some(stamp) + { + return Ok(None); + } + Ok(Some((preferences, stamp))) + } + + pub async fn export_scope(&self, scope: SyncScope) -> DocumentResult { + self.validate_local_scope(&scope)?; + let _metadata = self.inner.metadata.lock().await; + self.capture_readonly(&scope).await + } + + pub async fn restore_scope( + &self, + desired: ValidatedSyncDocuments, + context: RestoreContext, + ) -> DocumentResult { + self.validate_local_scope(&context.scope)?; + let scope = context.scope.clone(); + let desired = native::canonicalize_native_documents(desired)?; + let plan = crate::cloud_sync_e2ee_documents::prepare_sync_restore(desired, context)?; + let store = self.clone(); + // Detach only the owned, journalled operation: dropping an IPC waiter cannot cancel it. + tokio::spawn(async move { + let _metadata = store.inner.metadata.lock().await; + let _runtime = store.begin_runtime_restore()?; + store.runtime_effects()?; + let protector = store.inner.extensions.journal_protector().await?; + let journal = store.journal(); + crate::cloud_sync_e2ee_documents::apply_sync_restore( + plan, + &store, + &journal, + protector.as_ref(), + ) + .await?; + let permit = store.inner.gate.try_exclusive()?; + store.capture_locked(&scope, &permit).await + }) + .await + .map_err(|_| DocumentError::RecoveryRequired)? + } + + pub async fn record_baseline( + &self, + scope: SyncScope, + documents: DocumentSet, + revision: Revision, + ) -> DocumentResult<()> { + self.validate_local_scope(&scope)?; + let store = self.clone(); + tokio::spawn(async move { + let _metadata = store.inner.metadata.lock().await; + store.baseline_locked(&scope, documents, revision).await + }) + .await + .map_err(|_| DocumentError::RecoveryRequired)? + } + + pub async fn recover_registered(&self) -> DocumentResult<()> { + let scopes = self.inner.gate.registered_recovery_scopes()?; + if scopes.is_empty() && !self.inner.gate.recovery_required()? { + return Ok(()); + } + let store = self.clone(); + tokio::spawn(async move { + use crate::cloud_sync_e2ee_documents::JournalStore; + let _metadata = store.inner.metadata.lock().await; + let _runtime = store.begin_runtime_restore()?; + // Recovery needs the same real Host adapter before reading protected journals + // or prompting for keys; unsupported recovery must leave the pending bytes intact. + store.runtime_effects()?; + let journal = store.journal(); + for scope in scopes { + if journal.load(scope.clone()).await?.is_none() { + let permit = store.inner.gate.try_exclusive_recovery()?; + let scope_id = state::scope_id(&scope)?; + permit.recover_without_journal(&scope_id)?; + permit.forget_completed_scope_without_journal(&scope_id)?; + } else { + let protector = store.inner.extensions.journal_protector().await?; + crate::cloud_sync_e2ee_documents::recover_sync_restore( + scope, + &store, + &journal, + protector.as_ref(), + ) + .await?; + } + } + if store.inner.gate.recovery_required()? { + let permit = store.inner.gate.try_exclusive_recovery()?; + store.reconcile_native(&permit).await?; + store.apply_runtime_effects(&permit).await?; + permit.accept_reconciled_capture()?; + } + Ok(()) + }) + .await + .map_err(|_| DocumentError::RecoveryRequired)? + } + + fn validate_local_scope(&self, scope: &SyncScope) -> DocumentResult<()> { + crate::cloud_sync_e2ee_documents::validate_scope(scope)?; + if scope.device_id != self.inner.device.id { + return Err(DocumentError::InvalidDocument); + } + Ok(()) + } + fn journal(&self) -> journal::FileJournalStore { + journal::FileJournalStore::new( + self.inner.data_dir.join("encrypted-sync"), + Arc::clone(&self.inner.gate), + ) + } + fn begin_runtime_restore(&self) -> DocumentResult { + self.inner + .restoring + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .map_err(|_| DocumentError::SourceChanged)?; + let guard = RuntimeRestore(self.clone()); + let probe = self + .inner + .runtime_idle + .lock() + .map_err(|_| DocumentError::RecoveryRequired)? + .clone() + .ok_or(DocumentError::Unsupported)?; + if !probe() { + return Err(DocumentError::RuntimeBusy); + } + Ok(guard) + } + + async fn reconcile_native(&self, permit: &ExclusivePermit) -> DocumentResult<()> { + // No UI mirror or sync key is required to reconcile an interrupted ordinary save. + let repo = &self.inner.repositories; + repo.preferences + .sync_snapshot(permit) + .map_err(|_| DocumentError::CaptureFailed)?; + repo.vocabulary + .sync_snapshot(permit) + .map_err(|_| DocumentError::CaptureFailed)?; + repo.correction_rules + .sync_snapshot(permit) + .map_err(|_| DocumentError::CaptureFailed)?; + repo.history + .sync_snapshot(permit) + .map_err(|_| DocumentError::CaptureFailed)?; + let styles = repo + .style_packs + .sync_snapshot(permit) + .map_err(|_| DocumentError::CaptureFailed)?; + for style in styles { + if let Some(path) = style + .pack + .expose() + .get("iconPath") + .and_then(serde_json::Value::as_str) + { + crate::persistence::ensure_durable_file(std::path::Path::new(path)) + .map_err(|_| DocumentError::RecoveryRequired)?; + } + } + for name in [ + "preferences.json", + "history.json", + "activity.json", + "dictionary.json", + "correction-rules.json", + "style-packs.json", + "vocab-presets.json", + ] { + let path = self.inner.data_dir.join(name); + if path.exists() { + crate::persistence::ensure_durable_file(&path) + .map_err(|_| DocumentError::RecoveryRequired)?; + } + } + repo.activity + .sync_records(permit) + .map_err(|_| DocumentError::CaptureFailed)?; + crate::vocabulary::list_vocab_presets(&self.inner.data_dir) + .map_err(|_| DocumentError::CaptureFailed)?; + let credentials = self + .inner + .credentials + .export_sync_credentials(permit) + .await + .map_err(|_| DocumentError::CaptureFailed)?; + crate::cloud_sync_e2ee_documents::validate_credential_set( + &credentials.channels, + &credentials.credentials, + ) + } + + pub async fn set_ui_preferences(&self, value: SecretJson) -> DocumentResult<()> { + self.set_ui_preferences_inner(value, None).await + } + + pub async fn set_ui_preferences_checked( + &self, + value: SecretJson, + expected_revision: Option, + ) -> DocumentResult<()> { + self.set_ui_preferences_inner(value, Some(expected_revision)) + .await + } + + async fn set_ui_preferences_inner( + &self, + value: SecretJson, + expected_revision: Option>, + ) -> DocumentResult<()> { + let map = value + .expose() + .as_object() + .ok_or(DocumentError::InvalidDocument)?; + if map.len() != 2 || !map.contains_key("locale") || !map.contains_key("fontScale") { + return Err(DocumentError::InvalidDocument); + } + let locale = map["locale"] + .as_str() + .ok_or(DocumentError::InvalidDocument)?; + let font = map["fontScale"] + .as_str() + .ok_or(DocumentError::InvalidDocument)?; + if ![ + "system", "zh-CN", "zh-TW", "en", "ja", "ko", "es", "fr", "de", + ] + .contains(&locale) + || !["small", "medium", "large"].contains(&font) + { + return Err(DocumentError::InvalidDocument); + } + let store = self.clone(); + tokio::spawn(async move { + let _metadata = store.inner.metadata.lock().await; + let permit = store.inner.gate.begin_mutation()?; + if let Some(expected) = expected_revision { + let current = match store.inner.extensions.read_ui_revision().await { + Ok(value) => value, + Err(error) => { + permit.abort_unmodified()?; + return Err(error); + } + }; + if current != expected { + permit.abort_unmodified()?; + return Err(DocumentError::StalePreview); + } + } + let previous = match store + .inner + .extensions + .read_device(DeviceExtensionKey::UiPreferences) + .await + { + Ok(value) => value, + Err(error) => { + permit.abort_unmodified()?; + return Err(error); + } + }; + if previous.as_ref() == Some(&value) { + return permit.abort_unmodified(); + } + store + .inner + .extensions + .write_device(DeviceExtensionKey::UiPreferences, value) + .await?; + permit.commit(ChangeOrigin::User).map(|_| ()) + }) + .await + .map_err(|_| DocumentError::RecoveryRequired)? + } +} + +struct RuntimeRestore(CoreSyncStore); +impl Drop for RuntimeRestore { + fn drop(&mut self) { + self.0.inner.restoring.store(false, Ordering::Release); + } +} + +struct NativeLease { + store: CoreSyncStore, + scope: SyncScope, + scope_id: String, + permit: ExclusivePermit, +} +impl RestoreBackend for CoreSyncStore { + fn acquire( + &self, + scope: SyncScope, + expected_generation: Option, + ) -> BoxFuture<'_, DocumentResult>> { + Box::pin(async move { + self.validate_local_scope(&scope)?; + let permit = if expected_generation.is_some() { + self.inner.gate.try_exclusive()? + } else { + self.inner.gate.try_exclusive_recovery()? + }; + if expected_generation + .is_some_and(|expected| permit.generation().ok() != Some(expected)) + { + return Err(DocumentError::StalePreview); + } + let scope_id = state::scope_id(&scope)?; + Ok(Box::new(NativeLease { + store: self.clone(), + scope, + scope_id, + permit, + }) as Box) + }) + } +} + +impl RestoreLease for NativeLease { + fn capture( + &mut self, + ) -> BoxFuture<'_, DocumentResult> { + Box::pin(async move { + let state = state::ScopeState::decode( + &self.scope, + self.store + .inner + .extensions + .read_scope(self.scope.clone()) + .await?, + )?; + self.store + .capture_native(&self.scope, &self.permit, &state) + .await + }) + } + fn capture_rollback_state(&mut self) -> BoxFuture<'_, DocumentResult> { + Box::pin(async move { self.store.capture_local_rollback_state(&self.permit) }) + } + fn restore_rollback_state(&mut self, value: &SecretJson) -> BoxFuture<'_, DocumentResult<()>> { + let value = value.clone(); + Box::pin(async move { + self.store + .restore_local_rollback_state(&value, &self.permit)?; + // Excluded local grants/mirrors are restored after the logical rollback. + // The Host must converge to that final target before the fence is released. + self.store.apply_runtime_effects(&self.permit).await + }) + } + fn preflight( + &mut self, + documents: &ValidatedSyncDocuments, + ) -> BoxFuture<'_, DocumentResult<()>> { + let documents = documents.clone(); + Box::pin(async move { + self.store.runtime_effects()?; + self.store + .validate_native(&self.scope, &documents, &self.permit) + .await + }) + } + fn replace(&mut self, documents: ValidatedSyncDocuments) -> BoxFuture<'_, DocumentResult<()>> { + Box::pin(async move { + self.store.runtime_effects()?; + self.store + .replace_native(&self.scope, &documents, &self.permit) + .await + }) + } + fn reload(&mut self) -> BoxFuture<'_, DocumentResult<()>> { + Box::pin(async move { self.store.apply_runtime_effects(&self.permit).await }) + } + fn mark_recovery_pending(&mut self, operation_id: &str) -> DocumentResult<()> { + self.permit + .mark_restore_pending(operation_id, &self.scope_id, &self.scope) + } + fn mark_journal_ready(&mut self, operation_id: &str) -> DocumentResult<()> { + self.permit.mark_journal_ready(operation_id, &self.scope_id) + } + fn recover_without_journal(&mut self) -> DocumentResult<()> { + self.permit.recover_without_journal(&self.scope_id) + } + fn completed_restore( + &self, + operation_id: &str, + ) -> DocumentResult> { + self.permit + .completed_restore(operation_id, &self.scope_id) + .map(|value| value.map(receipt)) + } + fn finish_restore( + &mut self, + operation_id: &str, + committed: bool, + ) -> DocumentResult { + self.permit + .finish_restore(operation_id, &self.scope_id, committed) + .map(receipt) + } +} +fn receipt(value: gate::GateRestoreReceipt) -> crate::cloud_sync_e2ee_documents::RestoreReceipt { + crate::cloud_sync_e2ee_documents::RestoreReceipt { + operation_id: value.operation_id, + generation: value.generation, + committed: value.committed, + journal_cleanup_pending: false, + } +} + +#[cfg(test)] +mod tests; diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/native.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/native.rs new file mode 100644 index 00000000..4a30c094 --- /dev/null +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/native.rs @@ -0,0 +1,477 @@ +//! Materialization is preflighted completely before the first repository write. +use super::{state::ScopeState, CoreSyncStore, DeviceExtensionKey, ExclusivePermit}; +use crate::cloud_sync_e2ee_documents::registry::{preference_field, PreferenceClass}; +use crate::cloud_sync_e2ee_documents::*; +use crate::cloud_sync_e2ee_protocol::types::{DocumentKind, LogicalDocument}; +use crate::credentials::SyncCredentials; +use crate::types::{ + CorrectionRule, DictationSession, DictionaryEntry, VocabPreset, VocabPresetStore, +}; +use serde::de::DeserializeOwned; +use serde_json::Value; + +struct NativeRestore { + preferences: Value, + ui: SecretJson, + windows: SecretJson, + credentials: SyncCredentials, + dictionary: Vec, + corrections: Vec, + history: Vec, + presets: VocabPresetStore, + styles: Vec, + activity: Vec, + state: ScopeState, +} + +fn typed(value: &Value) -> DocumentResult { + serde_json::from_value(value.clone()).map_err(|_| DocumentError::Unsupported) +} +fn ordered( + documents: &[LogicalDocument], + kind: DocumentKind, +) -> DocumentResult> { + let mut values: Vec<_> = documents.iter().filter(|doc| doc.kind == kind).collect(); + values.sort_by_key(|doc| { + doc.value + .get("sortIndex") + .and_then(Value::as_u64) + .unwrap_or(0) + }); + values.into_iter().map(|doc| typed(&doc.value)).collect() +} + +pub(super) fn canonicalize_native_documents( + desired: ValidatedSyncDocuments, +) -> DocumentResult { + let mut set = desired.documents().clone(); + for doc in &mut set.documents { + let original = doc.value.clone(); + let mut canonical = match doc.kind { + DocumentKind::Dictionary => serde_json::to_value(typed::(&original)?), + DocumentKind::Corrections => serde_json::to_value(typed::(&original)?), + DocumentKind::History => serde_json::to_value(typed::(&original)?), + DocumentKind::StylePacks => { + let mut record: StylePackRecord = typed(&original)?; + let pack: crate::style_packs::StylePack = typed(record.pack.expose())?; + let mut canonical = + serde_json::to_value(pack).map_err(|_| DocumentError::InvalidDocument)?; + crate::persistence::ensure_lossless_value(record.pack.expose(), &canonical, &[]) + .map_err(|_| DocumentError::Unsupported)?; + canonical + .as_object_mut() + .ok_or(DocumentError::InvalidDocument)? + .remove("iconPath"); + canonical + .as_object_mut() + .ok_or(DocumentError::InvalidDocument)? + .remove("active"); + record.pack = SecretJson::new(canonical); + serde_json::to_value(record) + } + _ => continue, + } + .map_err(|_| DocumentError::InvalidDocument)?; + crate::persistence::ensure_lossless_value(&original, &canonical, &["sortIndex"]) + .map_err(|_| DocumentError::Unsupported)?; + if let Some(index) = original.get("sortIndex") { + canonical + .as_object_mut() + .ok_or(DocumentError::InvalidDocument)? + .insert("sortIndex".into(), index.clone()); + } + doc.value = canonical; + } + validate_sync_documents(set, desired.observed_revision()) +} + +#[derive(serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct LocalRollbackState { + schema_version: u32, + coding_agent_enabled: bool, + active_asr_provider: String, + active_llm_provider: String, + active_omni_provider: String, + /// Receiver-only availability; no audio bytes or paths ever enter the journal. + history_audio_markers: std::collections::BTreeMap>, +} + +impl CoreSyncStore { + pub(super) fn capture_local_rollback_state( + &self, + permit: &ExclusivePermit, + ) -> DocumentResult { + let value = self + .inner + .repositories + .preferences + .sync_snapshot(permit) + .map_err(|_| DocumentError::CaptureFailed)?; + let prefs: crate::shared_types::UserPreferences = typed(&value)?; + let history = self + .inner + .repositories + .history + .sync_snapshot(permit) + .map_err(|_| DocumentError::CaptureFailed)?; + let mut history_audio_markers = std::collections::BTreeMap::new(); + for record in history { + if history_audio_markers + .insert(record.id, record.has_audio_recording) + .is_some() + { + return Err(DocumentError::DuplicateId); + } + } + SecretJson::from_serializable(&LocalRollbackState { + schema_version: 1, + coding_agent_enabled: prefs.coding_agent_enabled, + active_asr_provider: prefs.active_asr_provider, + active_llm_provider: prefs.active_llm_provider, + active_omni_provider: prefs.active_omni_provider, + history_audio_markers, + }) + } + pub(super) fn restore_local_rollback_state( + &self, + value: &SecretJson, + permit: &ExclusivePermit, + ) -> DocumentResult<()> { + let local: LocalRollbackState = typed(value.expose())?; + if local.schema_version != 1 { + return Err(DocumentError::Unsupported); + } + // Logical rollback restores the rows first. Require the same complete identity set, + // then restore only this receiver's original metadata, never a cloud media flag. + let mut history = self + .inner + .repositories + .history + .sync_snapshot(permit) + .map_err(|_| DocumentError::CaptureFailed)?; + let ids: std::collections::BTreeSet<_> = + history.iter().map(|record| record.id.as_str()).collect(); + if ids.len() != history.len() + || history.len() != local.history_audio_markers.len() + || !ids + .iter() + .all(|id| local.history_audio_markers.contains_key(*id)) + { + return Err(DocumentError::RecoveryRequired); + } + for record in &mut history { + record.has_audio_recording = local.history_audio_markers[&record.id]; + } + self.inner + .repositories + .history + .sync_replace_all(&history, permit) + .map_err(|_| DocumentError::RecoveryRequired)?; + let mut current = self + .inner + .repositories + .preferences + .sync_snapshot(permit) + .map_err(|_| DocumentError::CaptureFailed)?; + let object = current + .as_object_mut() + .ok_or(DocumentError::InvalidDocument)?; + object.insert( + "codingAgentEnabled".into(), + Value::Bool(local.coding_agent_enabled), + ); + object.insert( + "activeAsrProvider".into(), + Value::String(local.active_asr_provider), + ); + object.insert( + "activeLlmProvider".into(), + Value::String(local.active_llm_provider), + ); + object.insert( + "activeOmniProvider".into(), + Value::String(local.active_omni_provider), + ); + self.inner + .repositories + .preferences + .sync_replace_raw(current, permit) + .map_err(|_| DocumentError::RecoveryRequired) + } + + async fn preflight_native( + &self, + scope: &SyncScope, + desired: &ValidatedSyncDocuments, + permit: &ExclusivePermit, + ) -> DocumentResult { + let mut state = ScopeState::decode( + scope, + self.inner.extensions.read_scope(scope.clone()).await?, + )?; + let current = self + .inner + .repositories + .preferences + .sync_snapshot(permit) + .map_err(|_| DocumentError::CaptureFailed)?; + let mut preferences = current.clone(); + let map = preferences + .as_object_mut() + .ok_or(DocumentError::InvalidDocument)?; + // Unknown preferences have an encrypted home; never spread future keys into native files. + map.retain(|key, _| preference_field(key).is_some()); + let mut retained = Vec::new(); + let mut ui = serde_json::Map::new(); + let mut channels = Vec::new(); + let mut credentials = Vec::new(); + let mut windows = Vec::::new(); + let mut styles = Vec::::new(); + let mut activity = Vec::::new(); + let mut preset_records = Vec::::new(); + let docs = &desired.documents().documents; + for doc in docs { + match doc.kind { + DocumentKind::Preferences => match preference_field(&doc.id) { + Some(field) if field.class == PreferenceClass::Portable => { + map.insert(doc.id.clone(), doc.value.clone()); + } + None => retained.push(doc.clone()), + _ => return Err(DocumentError::ExcludedField), + }, + DocumentKind::UiPreferences + if matches!(doc.id.as_str(), "locale" | "fontScale") => + { + ui.insert(doc.id.clone(), doc.value.clone()); + } + DocumentKind::UiPreferences => retained.push(doc.clone()), + DocumentKind::Channels => channels.push(typed(&doc.value)?), + DocumentKind::ProviderCredentials => credentials.push(typed(&doc.value)?), + DocumentKind::DeviceProfile if doc.id == self.inner.device.id => { + let profile: DeviceProfileRecord = typed(&doc.value)?; + if profile.device.os != self.inner.device.os + || profile.device.arch != self.inner.device.arch + { + return Err(DocumentError::Unsupported); + } + for (key, value) in profile + .preferences + .expose() + .as_object() + .ok_or(DocumentError::InvalidDocument)? + { + if preference_field(key) + .is_none_or(|field| field.class != PreferenceClass::DeviceProfile) + { + return Err(DocumentError::Unsupported); + } + map.insert(key.clone(), value.clone()); + } + channels.extend(profile.channels); + credentials.extend(profile.provider_credentials); + windows = profile.window_positions; + } + DocumentKind::DeviceProfile => retained.push(doc.clone()), + DocumentKind::StylePacks => { + let record: StylePackRecord = typed(&doc.value)?; + let pack: crate::style_packs::StylePack = typed(record.pack.expose())?; + let known_builtin = crate::style_packs::builtin_style_packs() + .iter() + .any(|builtin| builtin.id == pack.id); + if known_builtin != (pack.kind == crate::style_packs::StylePackKind::Builtin) { + return Err(DocumentError::Unsupported); + } + if let Some(icon) = &record.icon { + validate_icon(icon)?; + } + styles.push(record); + } + DocumentKind::Activity => { + let record: ActivityRecord = typed(&doc.value)?; + u32::try_from(record.count).map_err(|_| DocumentError::Unsupported)?; + activity.push(record); + } + DocumentKind::VocabularyPresets => preset_records.push(typed(&doc.value)?), + DocumentKind::Dictionary | DocumentKind::Corrections | DocumentKind::History => {} + } + } + if styles.is_empty() + || !styles.iter().any(|style| { + style.pack.expose().get("enabled").and_then(Value::as_bool) == Some(true) + }) + { + return Err(DocumentError::InvalidReference); + } + // Restored executable/workspace data never silently activates an existing permission. + if [ + "codingAgentProvider", + "codingAgentModel", + "codingAgentPermissionMode", + "codingAgentExe", + "codingAgentWorkdir", + ] + .iter() + .any(|key| current.get(key) != map.get(*key)) + { + map.insert("codingAgentEnabled".into(), Value::Bool(false)); + } + validate_credential_set(&channels, &credentials)?; + for (namespace, key) in [ + (SyncNamespace::Asr, "activeAsrProvider"), + (SyncNamespace::Llm, "activeLlmProvider"), + (SyncNamespace::Omni, "activeOmniProvider"), + ] { + let active = channels + .iter() + .find(|channel| channel.namespace == namespace && channel.active) + .map(|channel| channel.id.clone()) + .unwrap_or_default(); + map.insert(key.into(), Value::String(active)); + } + let _: crate::shared_types::UserPreferences = typed(&preferences)?; + if !ui.contains_key("locale") || !ui.contains_key("fontScale") { + return Err(DocumentError::Unsupported); + } + preset_records.sort_by_key(|record| (record.order, record.id.clone())); + let builtin_ids: Vec<_> = crate::vocabulary::builtin_vocab_presets() + .into_iter() + .map(|item| item.id) + .collect(); + let mut presets = VocabPresetStore::default(); + for record in preset_records { + match record.origin { + PresetOrigin::BuiltinState => { + if !builtin_ids.contains(&record.id) { + return Err(DocumentError::Unsupported); + } + if !record.enabled { + presets.disabled_builtin_preset_ids.push(record.id); + } + } + origin => { + if !record.enabled { + return Err(DocumentError::Unsupported); + } + let preset = VocabPreset { + id: record.id, + name: record.name.ok_or(DocumentError::InvalidDocument)?, + phrases: record.phrases, + }; + if origin == PresetOrigin::Custom { + presets.custom.push(preset); + } else { + presets.overrides.push(preset); + } + } + } + } + // Availability is local metadata, never imported from the cloud. Existing records + // keep the receiver's own marker; newly restored records never claim remote media. + let local_history = self + .inner + .repositories + .history + .sync_snapshot(permit) + .map_err(|_| DocumentError::CaptureFailed)?; + let local_media: std::collections::BTreeMap<_, _> = local_history + .iter() + .map(|record| (record.id.as_str(), record.has_audio_recording)) + .collect(); + let mut history: Vec = ordered(docs, DocumentKind::History)?; + for record in &mut history { + record.has_audio_recording = local_media + .get(record.id.as_str()) + .copied() + .unwrap_or(Some(false)); + } + state.retained_documents = retained; + state.tombstones = desired.documents().tombstones.clone(); + state.observed_revision = desired.observed_revision(); + Ok(NativeRestore { + preferences, + ui: SecretJson::new(Value::Object(ui)), + windows: SecretJson::from_serializable(&windows)?, + credentials: SyncCredentials { + channels, + credentials, + }, + dictionary: ordered(docs, DocumentKind::Dictionary)?, + corrections: ordered(docs, DocumentKind::Corrections)?, + history, + presets, + styles, + activity, + state, + }) + } + + pub(super) async fn validate_native( + &self, + scope: &SyncScope, + desired: &ValidatedSyncDocuments, + permit: &ExclusivePermit, + ) -> DocumentResult<()> { + self.preflight_native(scope, desired, permit) + .await + .map(|_| ()) + } + + pub(super) async fn replace_native( + &self, + scope: &SyncScope, + desired: &ValidatedSyncDocuments, + permit: &ExclusivePermit, + ) -> DocumentResult<()> { + let prepared = self.preflight_native(scope, desired, permit).await?; + let repositories = &self.inner.repositories; + // The durable before/after journal and recovery marker already exist here. + repositories + .vocabulary + .sync_replace_all(&prepared.dictionary, permit) + .map_err(|_| DocumentError::CaptureFailed)?; + repositories + .correction_rules + .sync_replace_all(&prepared.corrections, permit) + .map_err(|_| DocumentError::CaptureFailed)?; + repositories + .history + .sync_replace_all(&prepared.history, permit) + .map_err(|_| DocumentError::CaptureFailed)?; + repositories + .activity + .sync_replace_records(&prepared.activity, permit) + .map_err(|_| DocumentError::CaptureFailed)?; + crate::vocabulary::save_vocab_presets_for_sync( + &self.inner.data_dir, + &prepared.presets, + permit, + ) + .map_err(|_| DocumentError::CaptureFailed)?; + repositories + .style_packs + .sync_replace_all(&prepared.styles, permit) + .map_err(|_| DocumentError::CaptureFailed)?; + repositories + .preferences + .sync_replace_raw(prepared.preferences, permit) + .map_err(|_| DocumentError::CaptureFailed)?; + self.inner + .credentials + .replace_sync_credentials(prepared.credentials, permit) + .await + .map_err(|_| DocumentError::CaptureFailed)?; + self.inner + .extensions + .write_device(DeviceExtensionKey::UiPreferences, prepared.ui) + .await?; + self.inner + .extensions + .write_device(DeviceExtensionKey::WindowPositions, prepared.windows) + .await?; + self.inner + .extensions + .write_scope(scope.clone(), prepared.state.secret_json()?) + .await + } +} diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/state.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/state.rs new file mode 100644 index 00000000..3b743ef0 --- /dev/null +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/state.rs @@ -0,0 +1,58 @@ +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +use crate::cloud_sync_e2ee_documents::{DocumentError, DocumentResult, SecretJson, SyncScope}; +use crate::cloud_sync_e2ee_protocol::types::{DocumentSet, LogicalDocument, Revision, Tombstone}; + +#[derive(Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(super) struct ScopeState { + pub schema_version: u32, + pub scope_id: String, + pub observed_revision: Revision, + pub baseline_revision: Revision, + pub baseline: Option, + pub retained_documents: Vec, + pub tombstones: Vec, +} + +impl ScopeState { + pub fn empty(scope: &SyncScope) -> DocumentResult { + Ok(Self { + schema_version: 1, + scope_id: scope_id(scope)?, + observed_revision: Revision::new(0), + baseline_revision: Revision::new(0), + baseline: None, + retained_documents: Vec::new(), + tombstones: Vec::new(), + }) + } + pub fn decode(scope: &SyncScope, value: Option) -> DocumentResult { + let Some(value) = value else { + return Self::empty(scope); + }; + let state: Self = serde_json::from_value(value.expose().clone()) + .map_err(|_| DocumentError::RecoveryRequired)?; + if state.schema_version != 1 || state.scope_id != scope_id(scope)? { + return Err(DocumentError::RecoveryRequired); + } + Ok(state) + } + pub fn secret_json(&self) -> DocumentResult { + SecretJson::from_serializable(self) + } +} + +/// Cloud key rotation changes encryption, not the account/vault's local logical baseline. +pub fn scope_id(scope: &SyncScope) -> DocumentResult { + let bytes = serde_json::to_vec(&( + "openless-local-documents-v1", + &scope.service_origin, + &scope.owner_github_id, + &scope.vault_id, + &scope.device_id, + )) + .map_err(|_| DocumentError::InvalidDocument)?; + Ok(format!("{:x}", Sha256::digest(bytes))) +} diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/tests.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/tests.rs new file mode 100644 index 00000000..73a2098d --- /dev/null +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/tests.rs @@ -0,0 +1,1659 @@ +use super::*; +use crate::cloud_sync_e2ee_documents::*; +use crate::cloud_sync_e2ee_protocol::{ + crypto::DerivedKey, + types::{DocumentKind, LogicalDocument}, +}; +use crate::credentials::{CredentialKey, SecretValue, SyncCredentials}; +use crate::{BackendError, BackendErrorCode}; +use futures_util::future::BoxFuture; +use serde_json::{json, Value}; +use std::collections::{BTreeMap, VecDeque}; +use std::path::PathBuf; +use std::sync::{ + atomic::{AtomicBool, Ordering}, + Arc, Mutex, +}; +use zeroize::Zeroizing; + +struct Temp(PathBuf); +impl Temp { + fn new() -> Self { + let path = + std::env::temp_dir().join(format!("openless-sync-store-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&path).unwrap(); + Self(path) + } +} +impl Drop for Temp { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +#[derive(Clone)] +struct TestCredentials { + value: Arc>, + failures: Arc>>, + read_barrier: Arc>>>, + bound_gate: Arc>>>, +} +fn unsupported() -> BoxFuture<'static, Result> { + Box::pin(async { + Err(BackendError::new( + BackendErrorCode::Unsupported, + "fixture unused", + )) + }) +} +impl CredentialStore for TestCredentials { + fn bind_sync_gate(&self, gate: Arc) -> Result<(), BackendError> { + let mut bound = self.bound_gate.lock().unwrap(); + if bound + .as_ref() + .is_some_and(|existing| !Arc::ptr_eq(existing, &gate)) + { + return Err(BackendError::new( + BackendErrorCode::InvalidState, + "different sync gate", + )); + } + *bound = Some(gate); + Ok(()) + } + fn status( + &self, + _: crate::shared_types::UserPreferences, + ) -> BoxFuture<'static, Result> { + unsupported() + } + fn read( + &self, + _: CredentialKey, + ) -> BoxFuture<'static, Result, BackendError>> { + unsupported() + } + fn write( + &self, + _: CredentialKey, + _: SecretValue, + ) -> BoxFuture<'static, Result<(), BackendError>> { + unsupported() + } + fn remove(&self, _: CredentialKey) -> BoxFuture<'static, Result<(), BackendError>> { + unsupported() + } + fn export_sync_credentials_readonly( + &self, + ) -> BoxFuture<'static, Result> { + let this = self.clone(); + Box::pin(async move { + if this.bound_gate.lock().unwrap().is_none() { + return Err(BackendError::new( + BackendErrorCode::InvalidState, + "unbound sync gate", + )); + } + let value = this.value.lock().unwrap().clone(); + let barrier = this.read_barrier.lock().unwrap().clone(); + if let Some(barrier) = barrier { + barrier.wait().await; + barrier.wait().await; + } + Ok(value) + }) + } + fn export_sync_credentials( + &self, + permit: &ExclusivePermit, + ) -> BoxFuture<'static, Result> { + let valid = self + .bound_gate + .lock() + .unwrap() + .as_ref() + .is_some_and(|gate| permit.belongs_to(gate)); + let value = self.value.lock().unwrap().clone(); + Box::pin(async move { + if !valid { + return Err(BackendError::new( + BackendErrorCode::InvalidState, + "unbound sync gate", + )); + } + Ok(value) + }) + } + fn replace_sync_credentials( + &self, + value: SyncCredentials, + permit: &ExclusivePermit, + ) -> BoxFuture<'static, Result<(), BackendError>> { + let valid = self + .bound_gate + .lock() + .unwrap() + .as_ref() + .is_some_and(|gate| permit.belongs_to(gate)); + let this = self.clone(); + Box::pin(async move { + if !valid { + return Err(BackendError::new( + BackendErrorCode::InvalidState, + "unbound sync gate", + )); + } + if this.failures.lock().unwrap().pop_front().unwrap_or(false) { + return Err(BackendError::new( + BackendErrorCode::Persistence, + "fixture credential failure", + )); + } + *this.value.lock().unwrap() = value; + Ok(()) + }) + } +} +struct Extensions { + values: Mutex>, + protector: Arc, + key_requested: AtomicBool, + ui_revision: Mutex>, +} +impl ProtectedExtensionStore for Extensions { + fn read_scope(&self, scope: SyncScope) -> BoxFuture<'_, DocumentResult>> { + Box::pin(async move { + Ok(self + .values + .lock() + .unwrap() + .get(&state::scope_id(&scope)?) + .cloned()) + }) + } + fn write_scope( + &self, + scope: SyncScope, + value: SecretJson, + ) -> BoxFuture<'_, DocumentResult<()>> { + Box::pin(async move { + self.values + .lock() + .unwrap() + .insert(state::scope_id(&scope)?, value); + Ok(()) + }) + } + fn read_device( + &self, + key: DeviceExtensionKey, + ) -> BoxFuture<'_, DocumentResult>> { + Box::pin(async move { Ok(self.values.lock().unwrap().get(key.storage_name()).cloned()) }) + } + fn write_device( + &self, + key: DeviceExtensionKey, + value: SecretJson, + ) -> BoxFuture<'_, DocumentResult<()>> { + Box::pin(async move { + self.values + .lock() + .unwrap() + .insert(key.storage_name().into(), value); + if key == DeviceExtensionKey::UiPreferences { + *self.ui_revision.lock().unwrap() = Some(uuid::Uuid::new_v4().to_string()); + } + Ok(()) + }) + } + fn read_ui_revision(&self) -> BoxFuture<'_, DocumentResult>> { + Box::pin(async move { Ok(self.ui_revision.lock().unwrap().clone()) }) + } + fn journal_protector(&self) -> BoxFuture<'_, DocumentResult>> { + Box::pin(async move { + self.key_requested.store(true, Ordering::Release); + Ok(Arc::clone(&self.protector)) + }) + } +} +#[derive(Clone, Default)] +struct TestEffects { + targets: Arc>>, + failures: Arc>>, + wait: Arc>>>, + gate: Arc>>>, +} +impl crate::config::RestoreRuntimeEffects for TestEffects { + fn apply_target( + &self, + target: crate::shared_types::UserPreferences, + ) -> BoxFuture<'static, Result<(), BackendError>> { + let this = self.clone(); + Box::pin(async move { + let gate = this + .gate + .lock() + .unwrap() + .as_ref() + .and_then(std::sync::Weak::upgrade) + .unwrap(); + assert!( + gate.try_exclusive().is_err(), + "effects must remain inside the exclusive restore fence" + ); + assert!( + gate.begin_mutation().is_err(), + "ordinary writes must remain blocked during effects" + ); + this.targets.lock().unwrap().push(target); + let barrier = this.wait.lock().unwrap().clone(); + if let Some(barrier) = barrier { + barrier.wait().await; + barrier.wait().await; + } + if this.failures.lock().unwrap().pop_front().unwrap_or(false) { + Err(BackendError::new( + BackendErrorCode::Platform, + "fixture effects failure", + )) + } else { + Ok(()) + } + }) + } +} + +struct Fixture { + _temp: Temp, + store: CoreSyncStore, + repo: BackendRepositories, + credentials: TestCredentials, + extensions: Arc, + scope: SyncScope, + effects: TestEffects, +} +impl Fixture { + fn new() -> Self { + let temp = Temp::new(); + let gate = gate::open_for_data_dir(&temp.0).unwrap(); + let repo = BackendRepositories::open(&temp.0).unwrap(); + let credentials = TestCredentials { + value: Arc::new(Mutex::new(SyncCredentials { + channels: vec![], + credentials: vec![], + })), + failures: Arc::new(Mutex::new(VecDeque::new())), + read_barrier: Arc::new(Mutex::new(None)), + bound_gate: Arc::new(Mutex::new(None)), + }; + let extensions = Arc::new(Extensions { + values: Mutex::new(BTreeMap::from([( + "sync-ui-preferences".into(), + ui_preferences("en", "medium"), + )])), + protector: Arc::new(CryptoJournalProtector::new(Arc::new( + DerivedKey::from_secret_bytes(Zeroizing::new([37; 32])), + ))), + key_requested: AtomicBool::new(false), + ui_revision: Mutex::new(Some(uuid::Uuid::new_v4().to_string())), + }); + let device = SourceDevice { + id: "fixture-device".into(), + os: "macos".into(), + arch: "aarch64".into(), + app_version: "2.0.0-Beta.3".into(), + }; + let scope = SyncScope { + service_origin: "https://sync.example.test".into(), + owner_github_id: "42".into(), + vault_id: uuid::Uuid::new_v4().to_string(), + key_id: uuid::Uuid::new_v4().to_string(), + device_id: device.id.clone(), + }; + let store = CoreSyncStore::new( + repo.clone(), + Arc::new(credentials.clone()), + temp.0.clone(), + device, + gate, + extensions.clone(), + ) + .unwrap(); + store.bind_runtime_idle_probe(Arc::new(|| true)).unwrap(); + let effects = TestEffects::default(); + *effects.gate.lock().unwrap() = Some(Arc::downgrade(&store.inner.gate)); + store + .bind_runtime_effects(Arc::new(effects.clone())) + .unwrap(); + Self { + _temp: temp, + store, + repo, + credentials, + extensions, + scope, + effects, + } + } + fn reopened(self) -> Self { + let Self { + _temp, + store, + repo, + credentials, + extensions, + scope, + effects, + } = self; + let device = store.device(); + drop(store); + drop(repo); + // Stand-in for reopening the persistent OS vault, not retaining the old process gate. + credentials.bound_gate.lock().unwrap().take(); + let gate = gate::open_for_data_dir(&_temp.0).unwrap(); + let repo = BackendRepositories::open(&_temp.0).unwrap(); + let store = CoreSyncStore::new( + repo.clone(), + Arc::new(credentials.clone()), + _temp.0.clone(), + device, + gate, + extensions.clone(), + ) + .unwrap(); + store.bind_runtime_idle_probe(Arc::new(|| true)).unwrap(); + *effects.gate.lock().unwrap() = Some(Arc::downgrade(&store.inner.gate)); + store + .bind_runtime_effects(Arc::new(effects.clone())) + .unwrap(); + Self { + _temp, + store, + repo, + credentials, + extensions, + scope, + effects, + } + } + + fn context(&self, export: &ExportedDocuments) -> RestoreContext { + RestoreContext { + scope: self.scope.clone(), + operation_id: uuid::Uuid::new_v4().to_string(), + observed_revision: Revision::new(3), + local_generation: export.generation, + target_device: self.store.device(), + } + } +} +fn set_doc(set: &mut DocumentSet, kind: DocumentKind, id: &str, value: Value) { + if let Some(old) = set + .documents + .iter_mut() + .find(|doc| doc.kind == kind && doc.id == id) + { + old.value = value; + } else { + set.documents.push(LogicalDocument { + id: id.into(), + kind, + schema_version: 1, + value, + }); + } +} +fn validate(set: DocumentSet) -> ValidatedSyncDocuments { + validate_sync_documents(set, Revision::new(3)).unwrap() +} + +#[tokio::test] +async fn disabled_fresh_start_never_requests_local_key() { + let fixture = Fixture::new(); + fixture.store.recover_registered().await.unwrap(); + assert!(!fixture.extensions.key_requested.load(Ordering::Acquire)); +} + +#[tokio::test] +async fn complete_native_restore_keeps_all_history_order_unknown_preferences_and_activity_provenance( +) { + let fixture = Fixture::new(); + let before = fixture + .store + .export_scope(fixture.scope.clone()) + .await + .unwrap(); + let mut desired = before.documents.documents().clone(); + set_doc( + &mut desired, + DocumentKind::Preferences, + "themeMode", + json!("dark"), + ); + set_doc( + &mut desired, + DocumentKind::Preferences, + "futureSetting", + json!({"nested":[1,2,3]}), + ); + set_doc( + &mut desired, + DocumentKind::UiPreferences, + "locale", + json!("zh-CN"), + ); + for index in 0..205 { + let id = format!("history-{index:03}"); + set_doc( + &mut desired, + DocumentKind::History, + &id, + json!({"id":id,"createdAt":"2026-09-26T00:00:00Z","source":"quick_note","rawTranscript":format!("fixture {index}"),"finalText":format!("fixture {index}"),"mode":"raw","insertStatus":"notRequested","hasAudioRecording":false,"sortIndex":204-index}), + ); + } + for (source, count) in [("fixture-device", 4), ("other-device", 7)] { + set_doc( + &mut desired, + DocumentKind::Activity, + &format!("{source}:2026-09-26"), + json!({"sourceDeviceId":source,"date":"2026-09-26","count":count,"chars":10,"durationMs":20}), + ); + } + let expected = native::canonicalize_native_documents(validate(desired)).unwrap(); + let result = fixture + .store + .restore_scope(expected.clone(), fixture.context(&before)) + .await + .unwrap(); + assert_eq!( + result.documents.documents().documents, + expected.documents().documents + ); + assert_eq!(result.generation.get(), before.generation.get() + 1); + let history = fixture.repo.history.list().unwrap(); + assert_eq!(history.len(), 205); + assert_eq!(history[0].id, "history-204"); + assert_eq!(fixture.repo.activity.snapshot().unwrap()[0].count, 11); + let raw: Value = + serde_json::from_slice(&std::fs::read(fixture._temp.0.join("preferences.json")).unwrap()) + .unwrap(); + assert!( + raw.get("futureSetting").is_none(), + "unknown remote preference stays in encrypted extension" + ); + let again = fixture + .store + .export_scope(fixture.scope.clone()) + .await + .unwrap(); + assert!(again + .documents + .documents() + .documents + .iter() + .any(|doc| doc.id == "futureSetting")); + assert!(fixture + .store + .inner + .gate + .registered_recovery_scopes() + .unwrap() + .is_empty()); +} + +#[tokio::test] +async fn real_files_roll_back_after_credential_failure_and_recover_if_rollback_also_fails() { + for failures in [vec![true, false], vec![true, true, false]] { + let fixture = Fixture::new(); + let before = fixture + .store + .export_scope(fixture.scope.clone()) + .await + .unwrap(); + let mut desired = before.documents.documents().clone(); + set_doc( + &mut desired, + DocumentKind::Preferences, + "themeMode", + json!("dark"), + ); + fixture + .credentials + .failures + .lock() + .unwrap() + .extend(failures.clone()); + let result = fixture + .store + .restore_scope(validate(desired), fixture.context(&before)) + .await; + assert_eq!( + result.unwrap_err(), + if failures.len() == 2 { + DocumentError::RestoreRolledBack + } else { + DocumentError::RecoveryRequired + } + ); + if failures.len() == 3 { + assert!(fixture.store.is_recovering()); + let journal = std::fs::read_dir(fixture._temp.0.join("encrypted-sync")) + .unwrap() + .filter_map(Result::ok) + .find(|entry| entry.file_name().to_string_lossy().starts_with("restore-")) + .unwrap(); + let bytes = std::fs::read(journal.path()).unwrap(); + assert!(!String::from_utf8_lossy(&bytes).contains("themeMode")); + fixture.store.recover_registered().await.unwrap(); + } + let after = fixture + .store + .export_scope(fixture.scope.clone()) + .await + .unwrap(); + assert_eq!( + after.documents.documents().documents, + before.documents.documents().documents + ); + assert_eq!(after.generation, before.generation); + assert!(!fixture.store.is_recovering()); + } +} + +#[tokio::test] +async fn optimistic_capture_does_not_block_writes_and_discards_a_mixed_snapshot() { + let fixture = Fixture::new(); + let barrier = Arc::new(tokio::sync::Barrier::new(2)); + *fixture.credentials.read_barrier.lock().unwrap() = Some(barrier.clone()); + let store = fixture.store.clone(); + let scope = fixture.scope.clone(); + let capture = tokio::spawn(async move { store.export_scope(scope).await }); + barrier.wait().await; + fixture + .repo + .preferences + .update(|prefs| prefs.show_capsule = !prefs.show_capsule) + .unwrap(); + barrier.wait().await; + assert_eq!( + capture.await.unwrap().unwrap_err(), + DocumentError::SourceChanged + ); +} + +#[test] +fn every_preference_save_preserves_top_level_and_nested_unknown_values() { + let fixture = Fixture::new(); + let path = fixture._temp.0.join("preferences.json"); + let mut raw: Value = serde_json::to_value(fixture.repo.preferences.get()).unwrap(); + raw["futureSetting"] = json!({"x":9}); + raw["customStylePrompts"]["futureMode"] = json!("keep"); + std::fs::write(&path, serde_json::to_vec(&raw).unwrap()).unwrap(); + fixture + .repo + .preferences + .update(|prefs| prefs.show_capsule = !prefs.show_capsule) + .unwrap(); + fixture + .repo + .preferences + .set_preserving_current_style_preferences(fixture.repo.preferences.get()) + .unwrap(); + let saved: Value = serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap(); + assert_eq!(saved["futureSetting"], raw["futureSetting"]); + assert_eq!(saved["customStylePrompts"]["futureMode"], "keep"); +} + +#[test] +fn unknown_nested_rows_abort_ordinary_mutation_without_writing_or_dirty_generation() { + let fixture = Fixture::new(); + let generation = fixture.store.generation().unwrap(); + let path = fixture._temp.0.join("style-packs.json"); + let mut styles: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); + styles[0]["examples"] = json!([{"input":"a","output":"b","future":{"x":1}}]); + let bytes = serde_json::to_vec(&styles).unwrap(); + std::fs::write(&path, &bytes).unwrap(); + let id = styles[0]["id"].as_str().unwrap(); + assert_eq!( + fixture + .repo + .style_packs + .set_enabled(id, false) + .unwrap_err() + .code, + BackendErrorCode::Unsupported + ); + assert_eq!(std::fs::read(path).unwrap(), bytes); + assert_eq!(fixture.store.generation().unwrap(), generation); + let activity = fixture._temp.0.join("activity.json"); + let bytes = + br#"{"2026-09-26":{"count":1,"sourceContributions":{"device":{"count":1,"future":2}}}}"#; + std::fs::write(&activity, bytes).unwrap(); + assert_eq!( + fixture + .repo + .activity + .bump("2026-09-26", 1, 2) + .unwrap_err() + .code, + BackendErrorCode::Unsupported + ); + assert_eq!(std::fs::read(activity).unwrap(), bytes); +} + +#[tokio::test] +async fn baseline_updates_do_not_erase_newer_local_edits_or_tombstones() { + let fixture = Fixture::new(); + let before = fixture + .store + .export_scope(fixture.scope.clone()) + .await + .unwrap(); + fixture + .repo + .preferences + .update(|prefs| prefs.show_capsule = !prefs.show_capsule) + .unwrap(); + let current = fixture.repo.preferences.get().show_capsule; + fixture + .store + .record_baseline( + fixture.scope.clone(), + before.documents.documents().clone(), + Revision::new(3), + ) + .await + .unwrap(); + assert_eq!(fixture.repo.preferences.get().show_capsule, current); + let after = fixture + .store + .export_scope(fixture.scope.clone()) + .await + .unwrap(); + assert!(after.generation > before.generation); +} + +fn png() -> Vec { + use base64::Engine; + base64::engine::general_purpose::STANDARD.decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO3c3b0AAAAASUVORK5CYII=").unwrap() +} + +#[test] +fn failed_style_save_never_changes_live_cache() { + let fixture = Fixture::new(); + let before = fixture.repo.style_packs.get("builtin.light").unwrap(); + let mut next = before.clone(); + next.name = "new title".into(); + let generation = fixture.store.generation().unwrap(); + crate::persistence::fail_next_atomic_write(&fixture._temp.0.join("style-packs.json"), false); + assert!(fixture.repo.style_packs.update(next).is_err()); + assert_eq!( + fixture.repo.style_packs.get("builtin.light").unwrap(), + before + ); + assert_eq!(fixture.store.generation().unwrap(), generation); + assert!(!fixture.store.is_recovering()); +} + +#[tokio::test] +async fn uncertain_style_icon_and_import_commits_keep_their_referenced_assets() { + for import in [false, true] { + let fixture = Fixture::new(); + let pack = fixture + .repo + .style_packs + .update_icon("builtin.light", Some(&png())) + .unwrap(); + let original = pack.icon_path.unwrap(); + let archive = fixture + .repo + .style_packs + .export_zip_bytes("builtin.light") + .unwrap(); + crate::persistence::fail_next_atomic_write(&fixture._temp.0.join("style-packs.json"), true); + let error = if import { + fixture + .repo + .style_packs + .import_from_zip_bytes(&archive) + .unwrap_err() + } else { + fixture + .repo + .style_packs + .update_icon("builtin.light", Some(&png())) + .unwrap_err() + }; + assert_eq!(error.code, BackendErrorCode::OutcomeUnknown); + assert!(fixture.store.is_recovering()); + let persisted: Vec = serde_json::from_slice( + &std::fs::read(fixture._temp.0.join("style-packs.json")).unwrap(), + ) + .unwrap(); + for pack in &persisted { + if let Some(path) = &pack.icon_path { + assert!(std::path::Path::new(path).is_file()); + } + } + assert!(std::path::Path::new(&original).is_file()); + fixture.store.recover_registered().await.unwrap(); + assert!(!fixture.store.is_recovering()); + assert_eq!(fixture.repo.style_packs.list().unwrap(), persisted); + } +} + +#[tokio::test] +async fn runtime_probe_rejects_restore_before_any_file_changes() { + let fixture = Fixture::new(); + *fixture.store.inner.runtime_idle.lock().unwrap() = Some(Arc::new(|| false)); + let before = fixture + .store + .export_scope(fixture.scope.clone()) + .await + .unwrap(); + let mut desired = before.documents.documents().clone(); + set_doc( + &mut desired, + DocumentKind::Preferences, + "themeMode", + json!("dark"), + ); + let result = fixture + .store + .restore_scope(validate(desired), fixture.context(&before)) + .await; + assert_eq!(result.unwrap_err(), DocumentError::RuntimeBusy); + assert!(!fixture.store.is_recovering()); + assert_eq!(fixture.store.generation().unwrap(), before.generation); + assert!(!fixture.extensions.key_requested.load(Ordering::Acquire)); +} + +#[tokio::test] +async fn activity_restore_beyond_rolling_retention_keeps_every_source_record() { + let fixture = Fixture::new(); + let before = fixture + .store + .export_scope(fixture.scope.clone()) + .await + .unwrap(); + let mut desired = before.documents.documents().clone(); + let first = chrono::NaiveDate::from_ymd_opt(2020, 1, 1).unwrap(); + for index in 0..733 { + let date = (first + chrono::Days::new(index)).to_string(); + set_doc( + &mut desired, + DocumentKind::Activity, + &format!("fixture-device:{date}"), + json!({"sourceDeviceId":"fixture-device","date":date,"count":1,"chars":2,"durationMs":3}), + ); + } + fixture + .store + .restore_scope(validate(desired), fixture.context(&before)) + .await + .unwrap(); + assert_eq!(fixture.repo.activity.snapshot().unwrap().len(), 733); +} + +#[tokio::test] +async fn all_eleven_kinds_restore_stable_ids_credentials_icons_presets_and_foreign_profiles() { + use base64::Engine; + let fixture = Fixture::new(); + let before = fixture + .store + .export_scope(fixture.scope.clone()) + .await + .unwrap(); + let mut desired = before.documents.documents().clone(); + set_doc( + &mut desired, + DocumentKind::Preferences, + "themeMode", + json!("dark"), + ); + set_doc( + &mut desired, + DocumentKind::UiPreferences, + "locale", + json!("ja"), + ); + set_doc( + &mut desired, + DocumentKind::Channels, + "llm:stable-llm", + json!({"id":"stable-llm","namespace":"llm","providerType":"requesty","name":"Fixture API","enabled":true,"order":0,"active":true}), + ); + set_doc( + &mut desired, + DocumentKind::ProviderCredentials, + "llm:stable-llm", + json!({"channelId":"stable-llm","namespace":"llm","accounts":{"ark.api_key":"fixture-private-key","ark.endpoint":"https://router.requesty.ai/v1","ark.model_id":"fixture-model"}}), + ); + set_doc( + &mut desired, + DocumentKind::Dictionary, + "stable-word", + json!({"id":"stable-word","phrase":"OpenLess fixture","note":"fixture note","enabled":true,"hits":2,"createdAt":"2026-09-26T00:00:00Z","sortIndex":0}), + ); + set_doc( + &mut desired, + DocumentKind::Corrections, + "stable-rule", + json!({"id":"stable-rule","pattern":"opnless","replacement":"OpenLess","enabled":true,"createdAt":"2026-09-26T00:00:00Z","source":"learned","sortIndex":0}), + ); + set_doc( + &mut desired, + DocumentKind::VocabularyPresets, + "custom:stable-preset", + json!({"id":"stable-preset","origin":"custom","name":"Private words","phrases":["one","two"],"enabled":true,"order":0}), + ); + let builtin = crate::vocabulary::builtin_vocab_presets() + .into_iter() + .next() + .unwrap() + .id; + set_doc( + &mut desired, + DocumentKind::VocabularyPresets, + &format!("override:{builtin}"), + json!({"id":builtin,"origin":"override","name":"Override words","phrases":["three"],"enabled":true,"order":0}), + ); + let icon = base64::engine::general_purpose::STANDARD.encode(png()); + let style = desired + .documents + .iter_mut() + .find(|doc| doc.kind == DocumentKind::StylePacks && doc.id == "builtin.light") + .unwrap(); + style.value["icon"] = json!({"mime":"image/png","base64":icon}); + set_doc( + &mut desired, + DocumentKind::History, + "stable-history", + json!({"id":"stable-history","createdAt":"2026-09-26T00:00:00Z","source":"quick_note","rawTranscript":"Fixture note","finalText":"Fixture note","mode":"raw","insertStatus":"notRequested","hasAudioRecording":false,"sortIndex":0}), + ); + set_doc( + &mut desired, + DocumentKind::Activity, + "foreign-device:2026-09-26", + json!({"sourceDeviceId":"foreign-device","date":"2026-09-26","count":2,"chars":4,"durationMs":8}), + ); + set_doc( + &mut desired, + DocumentKind::DeviceProfile, + "foreign-device", + json!({"device":{"id":"foreign-device","os":"windows","arch":"x86_64","appVersion":"2.0.0-Beta.3"},"preferences":{"microphoneDeviceName":"foreign microphone","codingAgentExe":"C:\\fixture\\do-not-run.exe"},"channels":[{"id":"foreign-local","namespace":"asr","providerType":"foundry-local-whisper","name":"Foreign local model","enabled":true,"order":0,"active":true}],"providerCredentials":[{"channelId":"foreign-local","namespace":"asr","accounts":{"asr.model":"fixture-local-model"}}],"windowPositions":[]}), + ); + let desired = native::canonicalize_native_documents(validate(desired)).unwrap(); + let result = fixture + .store + .restore_scope(desired.clone(), fixture.context(&before)) + .await + .unwrap(); + assert_eq!( + result.documents.documents().documents, + desired.documents().documents + ); + let kinds = result + .documents + .documents() + .documents + .iter() + .map(|doc| doc.kind) + .collect::>(); + assert_eq!(kinds.len(), 11); + let credentials = fixture.credentials.value.lock().unwrap(); + assert_eq!(credentials.channels.len(), 1); + assert_eq!(credentials.channels[0].id, "stable-llm"); + assert_eq!( + credentials.credentials[0].accounts["ark.api_key"], + "fixture-private-key" + ); + drop(credentials); + assert_ne!( + fixture.repo.preferences.get().microphone_device_name, + "foreign microphone" + ); + assert_eq!(fixture.repo.vocabulary.list().unwrap()[0].id, "stable-word"); + assert_eq!( + fixture.repo.correction_rules.list().unwrap()[0].id, + "stable-rule" + ); + let presets = crate::vocabulary::list_vocab_presets(&fixture._temp.0).unwrap(); + assert_eq!(presets.custom[0].id, "stable-preset"); + assert_eq!(presets.overrides[0].phrases, vec!["three"]); + assert!(fixture + .repo + .style_packs + .icon_data_url("builtin.light") + .unwrap() + .is_some()); +} + +#[tokio::test] +async fn deletion_ledger_and_upload_baseline_are_isolated_by_account_vault_scope() { + let fixture = Fixture::new(); + let before = fixture + .store + .export_scope(fixture.scope.clone()) + .await + .unwrap(); + let mut desired = before.documents.documents().clone(); + set_doc( + &mut desired, + DocumentKind::Dictionary, + "deleted-word", + json!({"id":"deleted-word","phrase":"remove me","note":null,"enabled":true,"hits":0,"createdAt":"2026-09-26T00:00:00Z","sortIndex":0}), + ); + let seeded = fixture + .store + .restore_scope(validate(desired), fixture.context(&before)) + .await + .unwrap(); + fixture + .store + .record_baseline( + fixture.scope.clone(), + seeded.documents.documents().clone(), + Revision::new(3), + ) + .await + .unwrap(); + fixture.repo.vocabulary.remove("deleted-word").unwrap(); + let deleted = fixture + .store + .export_scope(fixture.scope.clone()) + .await + .unwrap(); + assert!(deleted + .documents + .documents() + .tombstones + .iter() + .any(|mark| mark.id == "deleted-word")); + let mut other = fixture.scope.clone(); + other.owner_github_id = "43".into(); + other.vault_id = uuid::Uuid::new_v4().to_string(); + let fresh = fixture.store.export_scope(other).await.unwrap(); + assert!(fresh.documents.documents().tombstones.is_empty()); + fixture + .repo + .preferences + .update(|prefs| prefs.show_capsule = !prefs.show_capsule) + .unwrap(); + fixture + .store + .record_baseline( + fixture.scope.clone(), + deleted.documents.documents().clone(), + Revision::new(4), + ) + .await + .unwrap(); + let later = fixture + .store + .export_scope(fixture.scope.clone()) + .await + .unwrap(); + assert!(later + .documents + .documents() + .tombstones + .iter() + .any(|mark| mark.id == "deleted-word")); + assert!(later.generation > deleted.generation); +} + +#[tokio::test] +async fn ui_compare_and_swap_rejects_an_old_mirror_after_restore_and_preserves_generation() { + let fixture = Fixture::new(); + let old = fixture.extensions.read_ui_revision().await.unwrap(); + let before = fixture + .store + .export_scope(fixture.scope.clone()) + .await + .unwrap(); + let mut desired = before.documents.documents().clone(); + set_doc( + &mut desired, + DocumentKind::UiPreferences, + "locale", + json!("ja"), + ); + let restored = fixture + .store + .restore_scope(validate(desired), fixture.context(&before)) + .await + .unwrap(); + let result = fixture + .store + .set_ui_preferences_checked(ui_preferences("en", "large"), old) + .await; + assert_eq!(result.unwrap_err(), DocumentError::StalePreview); + assert_eq!(fixture.store.generation().unwrap(), restored.generation); + assert_eq!( + fixture + .extensions + .read_device(DeviceExtensionKey::UiPreferences) + .await + .unwrap() + .unwrap() + .expose()["locale"], + "ja" + ); + assert_eq!( + fixture + .store + .set_ui_preferences_checked(ui_preferences("de", "small"), None) + .await + .unwrap_err(), + DocumentError::StalePreview + ); + fixture + .store + .set_ui_preferences_checked( + ui_preferences("ja", "large"), + fixture.extensions.read_ui_revision().await.unwrap(), + ) + .await + .unwrap(); + assert_eq!( + fixture.store.generation().unwrap().get(), + restored.generation.get() + 1 + ); +} + +#[test] +fn preference_array_extensions_follow_stable_identity_across_ordinary_updates() { + let fixture = Fixture::new(); + let path = fixture._temp.0.join("preferences.json"); + let mut raw = serde_json::to_value(fixture.repo.preferences.get()).unwrap(); + raw["stylePackHotkeys"] = json!([{"packId":"builtin.light","binding":{"primary":"L","modifiers":["ctrl"],"futureBinding":true},"futureHotkey":{"x":1}}]); + std::fs::write(&path, serde_json::to_vec(&raw).unwrap()).unwrap(); + fixture + .repo + .preferences + .update(|prefs| prefs.show_capsule = !prefs.show_capsule) + .unwrap(); + let saved: Value = serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap(); + assert_eq!(saved["stylePackHotkeys"][0]["futureHotkey"], json!({"x":1})); + assert_eq!( + saved["stylePackHotkeys"][0]["binding"]["futureBinding"], + true + ); +} + +#[test] +fn pending_restore_constructors_never_salvage_or_normalize_original_files() { + let temp = Temp::new(); + let gate = gate::open_for_data_dir(&temp.0).unwrap(); + let permit = gate.try_exclusive().unwrap(); + let scope = SyncScope { + service_origin: "https://sync.example.test".into(), + owner_github_id: "42".into(), + vault_id: uuid::Uuid::new_v4().to_string(), + key_id: uuid::Uuid::new_v4().to_string(), + device_id: "fixture-device".into(), + }; + permit + .mark_restore_pending( + &uuid::Uuid::new_v4().to_string(), + &state::scope_id(&scope).unwrap(), + &scope, + ) + .unwrap(); + let preferences = temp.0.join("preferences.json"); + let styles = temp.0.join("style-packs.json"); + std::fs::write( + &preferences, + br#"{"defaultMode":"unknown-future-mode","streamingInsert":false}"#, + ) + .unwrap(); + std::fs::write(&styles, b"[]").unwrap(); + let before = std::fs::read(&preferences).unwrap(); + let _prefs = crate::preferences::PreferencesStore::open(&preferences).unwrap(); + let style_store = crate::style_pack_store::StylePackStore::at_data_dir(&temp.0).unwrap(); + assert!(style_store.list().unwrap().is_empty()); + assert_eq!(std::fs::read(preferences).unwrap(), before); + assert_eq!(std::fs::read(styles).unwrap(), b"[]"); + assert_eq!(std::fs::read_dir(&temp.0).unwrap().count(), 3); +} + +#[tokio::test] +async fn ui_revision_cas_rejects_a_pre_restore_writer_even_when_values_are_identical() { + let fixture = Fixture::new(); + let old = fixture.extensions.read_ui_revision().await.unwrap(); + let before = fixture + .store + .export_scope(fixture.scope.clone()) + .await + .unwrap(); + let desired = + validate_sync_documents(before.documents.documents().clone(), Revision::new(3)).unwrap(); + fixture + .store + .restore_scope(desired, fixture.context(&before)) + .await + .unwrap(); + assert_eq!( + fixture + .extensions + .read_device(DeviceExtensionKey::UiPreferences) + .await + .unwrap() + .unwrap(), + ui_preferences("en", "medium") + ); + assert_ne!(fixture.extensions.read_ui_revision().await.unwrap(), old); + assert_eq!( + fixture + .store + .set_ui_preferences_checked(ui_preferences("en", "large"), old) + .await + .unwrap_err(), + DocumentError::StalePreview + ); +} + +#[tokio::test] +async fn restoring_history_never_replaces_existing_local_media_availability_with_remote_flags() { + let fixture = Fixture::new(); + let record:crate::types::DictationSession=serde_json::from_value(json!({"id":"local-recording","createdAt":"2026-09-26T00:00:00Z","source":"quick_note","rawTranscript":"fixture","finalText":"fixture","mode":"raw","insertStatus":"notRequested","hasAudioRecording":true})).unwrap(); + fixture + .repo + .history + .append_with_retention(record, 0, None) + .unwrap(); + let before = fixture + .store + .export_scope(fixture.scope.clone()) + .await + .unwrap(); + assert_eq!( + before + .documents + .documents() + .documents + .iter() + .find(|doc| doc.kind == DocumentKind::History) + .unwrap() + .value["hasAudioRecording"], + false + ); + let desired = + validate_sync_documents(before.documents.documents().clone(), Revision::new(3)).unwrap(); + fixture + .store + .restore_scope(desired, fixture.context(&before)) + .await + .unwrap(); + assert_eq!( + fixture.repo.history.list().unwrap()[0].has_audio_recording, + Some(true) + ); +} + +#[tokio::test] +async fn constructing_store_binds_the_exact_gate_required_by_real_credential_operations() { + let fixture = Fixture::new(); + let bound = fixture + .credentials + .bound_gate + .lock() + .unwrap() + .clone() + .unwrap(); + assert!(Arc::ptr_eq(&bound, &fixture.store.inner.gate)); + *fixture.credentials.bound_gate.lock().unwrap() = None; + assert!(fixture + .credentials + .export_sync_credentials_readonly() + .await + .is_err()); + fixture.credentials.bind_sync_gate(bound).unwrap(); + fixture + .store + .export_scope(fixture.scope.clone()) + .await + .unwrap(); +} + +#[tokio::test] +async fn failed_device_path_restore_restores_original_local_grant_without_exporting_it() { + for fail_rollback in [false, true] { + let fixture = Fixture::new(); + fixture + .repo + .preferences + .update(|prefs| { + prefs.coding_agent_enabled = true; + prefs.coding_agent_exe = Some("/fixture/original-agent".into()); + }) + .unwrap(); + let before = fixture + .store + .export_scope(fixture.scope.clone()) + .await + .unwrap(); + assert!(!serde_json::to_string(before.documents.documents()) + .unwrap() + .contains("codingAgentEnabled")); + let mut desired = before.documents.documents().clone(); + desired + .documents + .iter_mut() + .find(|doc| doc.kind == DocumentKind::DeviceProfile && doc.id == "fixture-device") + .unwrap() + .value["preferences"]["codingAgentExe"] = json!("/fixture/never-run-imported-agent"); + fixture + .credentials + .failures + .lock() + .unwrap() + .extend(if fail_rollback { + vec![true, true, false] + } else { + vec![true, false] + }); + assert!(fixture + .store + .restore_scope(validate(desired), fixture.context(&before)) + .await + .is_err()); + if fail_rollback { + fixture.store.recover_registered().await.unwrap(); + } + let prefs = fixture.repo.preferences.get(); + assert!(prefs.coding_agent_enabled); + assert_eq!( + prefs.coding_agent_exe.as_deref(), + Some("/fixture/original-agent") + ); + } +} + +#[tokio::test] +async fn deleted_history_rollback_restores_all_local_audio_markers_and_keeps_wav_files() { + for restart_recovery in [false, true] { + let mut fixture = Fixture::new(); + let recording_dir = fixture._temp.0.join("recordings"); + std::fs::create_dir_all(&recording_dir).unwrap(); + let mut expected = std::collections::BTreeMap::new(); + let mut recording = None; + for marker in [Some(true), Some(false), None] { + let id = uuid::Uuid::new_v4().to_string(); + if marker == Some(true) { + let path = recording_dir.join(format!("{id}.wav")); + std::fs::write(&path, b"fixture local recording remains untouched").unwrap(); + recording = Some(path); + } + let row:crate::types::DictationSession=serde_json::from_value(json!({"id":id,"createdAt":"2026-09-26T00:00:00Z","rawTranscript":"fixture","finalText":"fixture","mode":"raw","insertStatus":"inserted","hasAudioRecording":marker})).unwrap(); + fixture + .repo + .history + .append_with_retention(row, 0, None) + .unwrap(); + expected.insert(id, marker); + } + let before = fixture + .store + .export_scope(fixture.scope.clone()) + .await + .unwrap(); + assert!(before + .documents + .documents() + .documents + .iter() + .filter(|doc| doc.kind == DocumentKind::History) + .all(|doc| doc.value["hasAudioRecording"] == false)); + let mut desired = before.documents.documents().clone(); + desired + .documents + .retain(|doc| doc.kind != DocumentKind::History); + for id in expected.keys() { + desired + .tombstones + .push(crate::cloud_sync_e2ee_protocol::types::Tombstone { + id: id.clone(), + kind: DocumentKind::History, + deleted_at: "2026-09-26T01:00:00Z".into(), + base_revision: Revision::new(0), + }); + } + fixture + .credentials + .failures + .lock() + .unwrap() + .extend(if restart_recovery { + vec![true, true, false] + } else { + vec![true, false] + }); + let result = fixture + .store + .restore_scope(validate(desired), fixture.context(&before)) + .await; + assert_eq!( + result.unwrap_err(), + if restart_recovery { + DocumentError::RecoveryRequired + } else { + DocumentError::RestoreRolledBack + } + ); + if restart_recovery { + assert!(fixture.store.is_recovering()); + fixture = fixture.reopened(); + fixture.store.recover_registered().await.unwrap(); + } + let actual: std::collections::BTreeMap<_, _> = fixture + .repo + .history + .list() + .unwrap() + .into_iter() + .map(|record| (record.id, record.has_audio_recording)) + .collect(); + assert_eq!(actual,expected,"rollback must preserve true, false and unknown receiver-only markers after rows were removed"); + assert_eq!( + std::fs::read(recording.unwrap()).unwrap(), + b"fixture local recording remains untouched" + ); + let exported = fixture + .store + .export_scope(fixture.scope.clone()) + .await + .unwrap(); + assert!(exported + .documents + .documents() + .documents + .iter() + .filter(|doc| doc.kind == DocumentKind::History) + .all(|doc| doc.value["hasAudioRecording"] == false)); + assert_eq!(fixture.store.generation().unwrap(), before.generation); + assert!(!fixture.store.is_recovering()); + } +} + +#[tokio::test] +async fn remote_history_audio_true_cannot_create_local_media_availability() { + let fixture = Fixture::new(); + let before = fixture + .store + .export_scope(fixture.scope.clone()) + .await + .unwrap(); + let mut desired = before.documents.documents().clone(); + set_doc( + &mut desired, + DocumentKind::History, + "foreign-recording", + json!({"id":"foreign-recording","createdAt":"2026-09-26T00:00:00Z","rawTranscript":"fixture","finalText":"fixture","mode":"raw","insertStatus":"inserted","hasAudioRecording":true,"sortIndex":0}), + ); + assert_eq!( + validate_sync_documents(desired, Revision::new(3)).unwrap_err(), + DocumentError::ExcludedField + ); + assert!(fixture + .repo + .history + .read_entry("foreign-recording") + .unwrap() + .is_none()); + assert!(!fixture.store.is_recovering()); +} + +#[tokio::test] +async fn restore_requires_bound_runtime_effects_before_local_writes() { + let fixture = Fixture::new(); + *fixture.store.inner.runtime_effects.lock().unwrap() = None; + let before = fixture + .store + .export_scope(fixture.scope.clone()) + .await + .unwrap(); + let mut desired = before.documents.documents().clone(); + set_doc( + &mut desired, + DocumentKind::Preferences, + "themeMode", + json!("dark"), + ); + assert_eq!( + fixture + .store + .restore_scope(validate(desired), fixture.context(&before)) + .await + .unwrap_err(), + DocumentError::Unsupported + ); + assert_eq!(fixture.store.generation().unwrap(), before.generation); + assert!(!fixture.store.is_recovering()); + assert!(!fixture.extensions.key_requested.load(Ordering::Acquire)); + assert!(fixture.effects.targets.lock().unwrap().is_empty()); +} + +#[tokio::test] +async fn runtime_effects_are_awaited_under_the_exclusive_and_runtime_fences() { + let fixture = Fixture::new(); + let before = fixture + .store + .export_scope(fixture.scope.clone()) + .await + .unwrap(); + let mut desired = before.documents.documents().clone(); + set_doc( + &mut desired, + DocumentKind::Preferences, + "themeMode", + json!("dark"), + ); + let barrier = Arc::new(tokio::sync::Barrier::new(2)); + *fixture.effects.wait.lock().unwrap() = Some(barrier.clone()); + let store = fixture.store.clone(); + let context = fixture.context(&before); + let restore = + tokio::spawn(async move { store.restore_scope(validate(desired), context).await }); + barrier.wait().await; + assert!(!restore.is_finished()); + assert!(fixture.store.is_recovering()); + assert!(fixture.store.ensure_runtime_available().is_err()); + assert!(fixture + .repo + .preferences + .update(|prefs| prefs.show_capsule = false) + .is_err()); + barrier.wait().await; + let result = restore.await.unwrap().unwrap(); + assert_eq!(result.generation.get(), before.generation.get() + 1); + assert!(!fixture.store.is_recovering()); + assert_eq!(fixture.effects.targets.lock().unwrap().len(), 1); +} + +#[tokio::test] +async fn effects_failure_rolls_back_and_final_local_grants_are_applied_again() { + for startup_recovery in [false, true] { + let mut fixture = Fixture::new(); + fixture + .repo + .preferences + .update(|prefs| { + prefs.coding_agent_enabled = true; + prefs.coding_agent_exe = Some("/fixture/original".into()); + }) + .unwrap(); + let before = fixture + .store + .export_scope(fixture.scope.clone()) + .await + .unwrap(); + let mut desired = before.documents.documents().clone(); + desired + .documents + .iter_mut() + .find(|doc| doc.kind == DocumentKind::DeviceProfile && doc.id == "fixture-device") + .unwrap() + .value["preferences"]["codingAgentExe"] = json!("/fixture/new-program"); + fixture + .effects + .failures + .lock() + .unwrap() + .extend(if startup_recovery { + vec![true, true, false, false] + } else { + vec![true, false, false] + }); + assert_eq!( + fixture + .store + .restore_scope(validate(desired), fixture.context(&before)) + .await + .unwrap_err(), + if startup_recovery { + DocumentError::RecoveryRequired + } else { + DocumentError::RestoreRolledBack + } + ); + if startup_recovery { + assert!(fixture.store.is_recovering()); + fixture = fixture.reopened(); + fixture.store.recover_registered().await.unwrap(); + } + let applied = fixture.effects.targets.lock().unwrap(); + assert!(!applied[0].coding_agent_enabled); + assert_eq!( + applied[0].coding_agent_exe.as_deref(), + Some("/fixture/new-program") + ); + let final_target = applied.last().unwrap(); + assert!(final_target.coding_agent_enabled); + assert_eq!( + final_target.coding_agent_exe.as_deref(), + Some("/fixture/original") + ); + assert_eq!(fixture.store.generation().unwrap(), before.generation); + assert!(!fixture.store.is_recovering()); + } +} + +#[tokio::test] +async fn launch_at_login_is_excluded_and_preserves_the_receivers_local_setting() { + let fixture = Fixture::new(); + fixture + .repo + .preferences + .update(|prefs| prefs.launch_at_login = true) + .unwrap(); + let before = fixture + .store + .export_scope(fixture.scope.clone()) + .await + .unwrap(); + let encoded = serde_json::to_string(before.documents.documents()).unwrap(); + assert!(!encoded.contains("launchAtLogin")); + let mut desired = before.documents.documents().clone(); + set_doc( + &mut desired, + DocumentKind::Preferences, + "themeMode", + json!("dark"), + ); + fixture + .store + .restore_scope(validate(desired), fixture.context(&before)) + .await + .unwrap(); + assert!(fixture.repo.preferences.get().launch_at_login); + assert!( + fixture + .effects + .targets + .lock() + .unwrap() + .last() + .unwrap() + .launch_at_login + ); +} + +#[tokio::test] +async fn pending_recovery_without_effects_never_reads_key_or_changes_its_durable_markers() { + let fixture = Fixture::new(); + let before = fixture + .store + .export_scope(fixture.scope.clone()) + .await + .unwrap(); + let mut desired = before.documents.documents().clone(); + set_doc( + &mut desired, + DocumentKind::Preferences, + "themeMode", + json!("dark"), + ); + fixture + .credentials + .failures + .lock() + .unwrap() + .extend([true, true]); + assert_eq!( + fixture + .store + .restore_scope(validate(desired), fixture.context(&before)) + .await + .unwrap_err(), + DocumentError::RecoveryRequired + ); + let directory = fixture._temp.0.join("encrypted-sync"); + let journal = std::fs::read_dir(&directory) + .unwrap() + .filter_map(Result::ok) + .find(|entry| entry.file_name().to_string_lossy().starts_with("restore-")) + .unwrap() + .path(); + let generation = std::fs::read(directory.join("generation.json")).unwrap(); + let sealed = std::fs::read(&journal).unwrap(); + let effects_calls = fixture.effects.targets.lock().unwrap().len(); + *fixture.store.inner.runtime_effects.lock().unwrap() = None; + fixture + .extensions + .key_requested + .store(false, Ordering::Release); + assert_eq!( + fixture.store.recover_registered().await.unwrap_err(), + DocumentError::Unsupported + ); + assert!(!fixture.extensions.key_requested.load(Ordering::Acquire)); + assert_eq!( + std::fs::read(directory.join("generation.json")).unwrap(), + generation + ); + assert_eq!(std::fs::read(journal).unwrap(), sealed); + assert_eq!(fixture.effects.targets.lock().unwrap().len(), effects_calls); + assert!(fixture.store.is_recovering()); +} + +#[test] +fn setup_prompt_store_probe_requires_idle_runtime_and_no_active_writer() { + let fixture = Fixture::new(); + assert!(fixture.store.setup_prompt_state().unwrap().is_some()); + let writing = fixture.store.inner.gate.begin_mutation().unwrap(); + assert!(fixture.store.setup_prompt_state().unwrap().is_none()); + writing.abort_unmodified().unwrap(); + *fixture.store.inner.runtime_idle.lock().unwrap() = Some(Arc::new(|| false)); + assert!(fixture.store.setup_prompt_state().unwrap().is_none()); + *fixture.store.inner.runtime_idle.lock().unwrap() = Some(Arc::new(|| true)); + fixture.store.inner.restoring.store(true, Ordering::Release); + assert!(fixture.store.setup_prompt_state().unwrap().is_none()); + fixture + .store + .inner + .restoring + .store(false, Ordering::Release); + assert!(fixture.store.setup_prompt_state().unwrap().is_some()); +} diff --git a/openless-all/app/crates/openless-core/src/config.rs b/openless-all/app/crates/openless-core/src/config.rs index adf083ea..096dc9fd 100644 --- a/openless-all/app/crates/openless-core/src/config.rs +++ b/openless-all/app/crates/openless-core/src/config.rs @@ -51,6 +51,17 @@ impl Default for BackendConfig { } } +/// Absolute, idempotent Host convergence during a journalled sync restore. +/// Called only after the complete repository target is installed and while +/// the restore fence still blocks runtime admission. A failure must propagate +/// so the journal can reinstall and reconcile its previous target. +pub trait RestoreRuntimeEffects: Send + Sync { + fn apply_target( + &self, + target: crate::shared_types::UserPreferences, + ) -> BoxFuture<'static, Result<(), BackendError>>; +} + pub trait TaskSpawner: Send + Sync { fn spawn(&self, task: BoxFuture<'static, ()>); } diff --git a/openless-all/app/crates/openless-core/src/correction.rs b/openless-all/app/crates/openless-core/src/correction.rs index 910528aa..1ad5cd14 100644 --- a/openless-all/app/crates/openless-core/src/correction.rs +++ b/openless-all/app/crates/openless-core/src/correction.rs @@ -10,7 +10,7 @@ use std::sync::Mutex; use chrono::Utc; use crate::errors::{BackendError, BackendErrorCode}; -use crate::persistence::{atomic_write, persistence_error, read_or_default}; +use crate::persistence::{atomic_write, persistence_error}; use crate::types::{CorrectionRule, RuleSource}; const NUM_TOKEN: &str = "{num}"; @@ -38,6 +38,27 @@ impl CorrectionRuleStore { self.read_locked() } + pub(crate) fn sync_snapshot( + &self, + permit: &crate::cloud_sync_e2ee_store::gate::ExclusivePermit, + ) -> Result, BackendError> { + crate::cloud_sync_e2ee_store::gate::require_exclusive(&self.path, permit)?; + let _guard = self.lock_store()?; + crate::persistence::read_lossless_rows(&self.path, &[]) + } + + pub(crate) fn sync_replace_all( + &self, + records: &[CorrectionRule], + permit: &crate::cloud_sync_e2ee_store::gate::ExclusivePermit, + ) -> Result<(), BackendError> { + crate::cloud_sync_e2ee_store::gate::require_exclusive(&self.path, permit)?; + let _guard = self.lock_store()?; + let bytes = serde_json::to_vec_pretty(records) + .map_err(|_| persistence_error("encode restored corrections"))?; + crate::persistence::atomic_write_for_sync(&self.path, &bytes, permit) + } + pub(crate) fn cloud_sync_access( &self, ) -> Result<(std::sync::MutexGuard<'_, ()>, &Path), BackendError> { @@ -49,7 +70,11 @@ impl CorrectionRuleStore { pattern: String, replacement: String, ) -> Result { - self.add_with_source(pattern, replacement, RuleSource::Manual) + crate::cloud_sync_e2ee_store::gate::with_registered_mutation( + &self.path, + crate::cloud_sync_e2ee_store::gate::ChangeOrigin::User, + || self.add_with_source(pattern, replacement, RuleSource::Manual), + ) } pub fn add_with_source( @@ -58,50 +83,68 @@ impl CorrectionRuleStore { replacement: String, source: RuleSource, ) -> Result { - let pattern = pattern.trim().to_string(); - let replacement = replacement.trim().to_string(); - validate_correction_rule_syntax(&pattern, &replacement)?; - let _guard = self.lock_store()?; - let mut rules = self.read_locked()?; - let rule = CorrectionRule { - id: uuid::Uuid::new_v4().to_string(), - pattern, - replacement, - enabled: true, - created_at: Utc::now().to_rfc3339(), - source, - }; - rules.insert(0, rule.clone()); - self.write_locked(&rules)?; - Ok(rule) + crate::cloud_sync_e2ee_store::gate::with_registered_mutation( + &self.path, + crate::cloud_sync_e2ee_store::gate::ChangeOrigin::User, + || { + let pattern = pattern.trim().to_string(); + let replacement = replacement.trim().to_string(); + validate_correction_rule_syntax(&pattern, &replacement)?; + let _guard = self.lock_store()?; + let mut rules = self.read_locked()?; + let rule = CorrectionRule { + id: uuid::Uuid::new_v4().to_string(), + pattern, + replacement, + enabled: true, + created_at: Utc::now().to_rfc3339(), + source, + }; + rules.insert(0, rule.clone()); + self.write_locked(&rules)?; + Ok(rule) + }, + ) } /// Removing an unknown id is deliberately idempotent. pub fn remove(&self, id: &str) -> Result<(), BackendError> { - let _guard = self.lock_store()?; - let mut rules = self.read_locked()?; - let before = rules.len(); - rules.retain(|rule| rule.id != id); - if rules.len() != before { - self.write_locked(&rules)?; - } - Ok(()) + crate::cloud_sync_e2ee_store::gate::with_registered_mutation( + &self.path, + crate::cloud_sync_e2ee_store::gate::ChangeOrigin::User, + || { + let _guard = self.lock_store()?; + let mut rules = self.read_locked()?; + let before = rules.len(); + rules.retain(|rule| rule.id != id); + if rules.len() != before { + self.write_locked(&rules)?; + } + Ok(()) + }, + ) } pub fn set_enabled(&self, id: &str, enabled: bool) -> Result<(), BackendError> { - let _guard = self.lock_store()?; - let mut rules = self.read_locked()?; - let rule = rules.iter_mut().find(|rule| rule.id == id).ok_or_else(|| { - BackendError::new( - BackendErrorCode::InvalidArgument, - "correction rule not found", - ) - })?; - if rule.enabled != enabled { - rule.enabled = enabled; - self.write_locked(&rules)?; - } - Ok(()) + crate::cloud_sync_e2ee_store::gate::with_registered_mutation( + &self.path, + crate::cloud_sync_e2ee_store::gate::ChangeOrigin::User, + || { + let _guard = self.lock_store()?; + let mut rules = self.read_locked()?; + let rule = rules.iter_mut().find(|rule| rule.id == id).ok_or_else(|| { + BackendError::new( + BackendErrorCode::InvalidArgument, + "correction rule not found", + ) + })?; + if rule.enabled != enabled { + rule.enabled = enabled; + self.write_locked(&rules)?; + } + Ok(()) + }, + ) } fn lock_store(&self) -> Result, BackendError> { @@ -114,7 +157,7 @@ impl CorrectionRuleStore { } fn read_locked(&self) -> Result, BackendError> { - read_or_default(&self.path) + crate::persistence::read_lossless_rows(&self.path, &[]) } fn write_locked(&self, rules: &[CorrectionRule]) -> Result<(), BackendError> { diff --git a/openless-all/app/crates/openless-core/src/credentials.rs b/openless-all/app/crates/openless-core/src/credentials.rs index ba1cab46..b74d8c65 100644 --- a/openless-all/app/crates/openless-core/src/credentials.rs +++ b/openless-all/app/crates/openless-core/src/credentials.rs @@ -3,10 +3,81 @@ use std::fmt; use std::sync::RwLock; use futures_util::future::BoxFuture; +use zeroize::Zeroize; use crate::errors::{BackendError, BackendErrorCode}; use crate::shared_types::{CredentialsStatus, UserPreferences}; +pub use crate::cloud_sync_e2ee_documents::{ + ChannelRecord as SyncChannel, ProviderCredentialRecord as SyncCredentialRecord, SyncNamespace, +}; +pub use crate::cloud_sync_e2ee_store::gate::{ + ChangeOrigin, ExclusivePermit, MutationPermit, SyncWriteGate, +}; + +/// A single coherent native vault capture. It deliberately has no serde support: +/// only the explicit encrypted document exporter may serialize provider secrets. +#[derive(Clone, PartialEq, Eq)] +pub struct SyncCredentials { + pub channels: Vec, + pub credentials: Vec, +} + +impl SyncCredentials { + pub fn validate(&self) -> Result<(), BackendError> { + crate::cloud_sync_e2ee_documents::validate_credential_set(&self.channels, &self.credentials) + .map_err(|_| { + BackendError::new( + BackendErrorCode::InvalidArgument, + "invalid encrypted sync credential set", + ) + }) + } +} + +impl fmt::Debug for SyncCredentials { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("SyncCredentials([REDACTED])") + } +} + +/// Only two app-owned E2EE key namespaces may reach the system-secret adapter. +/// The digest binds native service/account/vault/key/device context; callers can +/// never use this interface to enumerate or read arbitrary Keychain accounts. +#[derive(Clone, PartialEq, Eq, Hash)] +pub struct SyncSecretAccount(String); + +impl SyncSecretAccount { + pub fn new(value: impl Into) -> Result { + let value = value.into(); + let suffix = value + .strip_prefix("cloud-sync.e2ee.local.") + .or_else(|| value.strip_prefix("cloud-sync.e2ee.key.")); + if !suffix.is_some_and(|digest| { + digest.len() == 64 + && digest + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + }) { + return Err(BackendError::new( + BackendErrorCode::InvalidArgument, + "invalid local encrypted sync key account", + )); + } + Ok(Self(value)) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Debug for SyncSecretAccount { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("SyncSecretAccount([REDACTED])") + } +} + macro_rules! provider_identifier { ($name:ident, $label:literal) => { #[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, serde::Serialize)] @@ -255,8 +326,14 @@ impl SecretValue { &self.0 } - pub fn into_exposed(self) -> String { - self.0 + pub fn into_exposed(mut self) -> String { + std::mem::take(&mut self.0) + } +} + +impl Drop for SecretValue { + fn drop(&mut self) { + self.0.zeroize(); } } @@ -267,6 +344,56 @@ impl fmt::Debug for SecretValue { } pub trait CredentialStore: Send + Sync { + fn bind_sync_gate(&self, _gate: std::sync::Arc) -> Result<(), BackendError> { + Ok(()) + } + + /// Capture one coherent vault image without holding the global restore gate. + /// The caller must verify the shared capture epoch before and after reading + /// all repositories; this method only guarantees consistency within the vault. + fn export_sync_credentials_readonly( + &self, + ) -> BoxFuture<'static, Result> { + unsupported_credentials() + } + + fn export_sync_credentials( + &self, + _permit: &ExclusivePermit, + ) -> BoxFuture<'static, Result> { + unsupported_credentials() + } + + fn replace_sync_credentials( + &self, + _snapshot: SyncCredentials, + _permit: &ExclusivePermit, + ) -> BoxFuture<'static, Result<(), BackendError>> { + unsupported_credentials() + } + + fn read_sync_secret( + &self, + _account: SyncSecretAccount, + ) -> BoxFuture<'static, Result, BackendError>> { + unsupported_credentials() + } + + fn write_sync_secret( + &self, + _account: SyncSecretAccount, + _value: SecretValue, + ) -> BoxFuture<'static, Result<(), BackendError>> { + unsupported_credentials() + } + + fn remove_sync_secret( + &self, + _account: SyncSecretAccount, + ) -> BoxFuture<'static, Result<(), BackendError>> { + unsupported_credentials() + } + fn status( &self, preferences: UserPreferences, @@ -1024,6 +1151,66 @@ fn unsupported_credentials() -> BoxFuture<'static, Result> { mod tests { use super::*; + #[test] + fn sync_secret_accounts_cannot_reach_provider_or_other_app_credentials() { + for prefix in ["cloud-sync.e2ee.local.", "cloud-sync.e2ee.key."] { + let accepted = format!("{prefix}{}", "0123456789abcdef".repeat(4)); + let account = SyncSecretAccount::new(&accepted).unwrap(); + assert_eq!(account.as_str(), accepted); + assert!(!format!("{account:?}").contains(&accepted)); + } + for value in [ + "credentials.v2".into(), + "cloud-sync.e2ee.local.".into(), + format!("cloud-sync.e2ee.local.{}", "a".repeat(63)), + format!("cloud-sync.e2ee.local.{}", "a".repeat(65)), + format!("cloud-sync.e2ee.local.{}", "A".repeat(64)), + format!("cloud-sync.e2ee.local.{}", "g".repeat(64)), + format!("cloud-sync.e2ee.local.{}\n", "a".repeat(64)), + format!("cloud-sync.e2ee.local.{}", "../".repeat(22)), + ] { + assert!(SyncSecretAccount::new(value).is_err()); + } + let secret = SecretValue::new("private-canary-secret"); + assert_eq!(format!("{secret:?}"), "SecretValue([REDACTED])"); + assert_eq!(secret.into_exposed(), "private-canary-secret"); + } + + #[tokio::test] + async fn sync_secret_defaults_fail_closed_on_hosts_without_an_adapter() { + let store = UnsupportedCredentialStore; + assert_eq!( + store + .export_sync_credentials_readonly() + .await + .unwrap_err() + .code, + BackendErrorCode::Unsupported + ); + let account = + SyncSecretAccount::new(format!("cloud-sync.e2ee.local.{}", "a".repeat(64))).unwrap(); + assert_eq!( + store + .read_sync_secret(account.clone()) + .await + .unwrap_err() + .code, + BackendErrorCode::Unsupported + ); + assert_eq!( + store + .write_sync_secret(account.clone(), SecretValue::new("private-canary-secret")) + .await + .unwrap_err() + .code, + BackendErrorCode::Unsupported + ); + assert_eq!( + store.remove_sync_secret(account).await.unwrap_err().code, + BackendErrorCode::Unsupported + ); + } + fn summary(id: &str, order: u32, enabled: bool) -> ChannelSummary { ChannelSummary { id: id.to_string(), diff --git a/openless-all/app/crates/openless-core/src/domains.rs b/openless-all/app/crates/openless-core/src/domains.rs index b22c506e..6fd1a397 100644 --- a/openless-all/app/crates/openless-core/src/domains.rs +++ b/openless-all/app/crates/openless-core/src/domains.rs @@ -23,6 +23,10 @@ use crate::local_asr_catalog::{ use crate::style_packs::StylePack; use crate::types::{PolishMode, SessionId}; +/// Fast, synchronous restore admission check. Implementations must not do I/O +/// or acquire repository locks; Core calls it while holding a runtime state lock. +pub type RuntimeRestoreGuard = Arc Result<(), BackendError> + Send + Sync>; + fn unsupported(domain: &'static str) -> BoxFuture<'static, Result> { Box::pin(async move { Err(BackendError::new( @@ -398,6 +402,21 @@ pub struct SelectionPolishRequest { } pub trait SelectionApi: Send + Sync { + /// Bind restore admission before the service is exposed to runtime callers. + /// Legacy custom services remain source compatible, but conservatively do + /// not report restore-safe idleness until they implement both methods. + #[doc(hidden)] + fn bind_runtime_restore_guard( + &self, + _guard: RuntimeRestoreGuard, + _spawner: Arc, + ) -> Result<(), BackendError> { + Ok(()) + } + #[doc(hidden)] + fn runtime_restore_idle(&self) -> bool { + false + } fn snapshot(&self) -> BoxFuture<'static, Result>; fn begin_polish( &self, @@ -648,6 +667,21 @@ impl SelectionVoiceApplyOutcome { } pub trait SelectionVoiceApi: Send + Sync { + /// Bind restore admission before the service is exposed to runtime callers. + /// Legacy custom services remain source compatible, but conservatively do + /// not report restore-safe idleness until they implement both methods. + #[doc(hidden)] + fn bind_runtime_restore_guard( + &self, + _guard: RuntimeRestoreGuard, + _spawner: Arc, + ) -> Result<(), BackendError> { + Ok(()) + } + #[doc(hidden)] + fn runtime_restore_idle(&self) -> bool { + false + } #[doc(hidden)] fn bind_qa(&self, _qa: std::sync::Weak) {} /// Register the Host's capture/target cleanup before asynchronous startup. @@ -944,6 +978,21 @@ pub trait QaRuntimeAdapter: Send + Sync { } pub trait QaApi: Send + Sync { + /// Bind restore admission before the service is exposed to runtime callers. + /// Legacy custom services remain source compatible, but conservatively do + /// not report restore-safe idleness until they implement both methods. + #[doc(hidden)] + fn bind_runtime_restore_guard( + &self, + _guard: RuntimeRestoreGuard, + _spawner: Arc, + ) -> Result<(), BackendError> { + Ok(()) + } + #[doc(hidden)] + fn runtime_restore_idle(&self) -> bool { + false + } #[doc(hidden)] fn bind_event_publisher(&self, _publisher: crate::events::BackendEventPublisher) {} /// Show the QA surface without implicitly starting a recording or creating @@ -967,6 +1016,10 @@ pub trait QaApi: Send + Sync { unsupported("QA") } fn submit_text(&self, text: String) -> BoxFuture<'static, Result<(), BackendError>>; + /// Check the displayed conversation and claim the new turn atomically. + fn submit_text_in_context(&self, _text: String, _expected_session: Option) -> BoxFuture<'static, Result<(), BackendError>> { + Box::pin(async { Err(BackendError::new(BackendErrorCode::Unsupported, "scoped QA submission is unavailable")) }) + } fn submit_captured_text( &self, _input: QaInput, @@ -1641,6 +1694,9 @@ impl LocalAsrApi for UnsupportedDomainServices { } impl SelectionApi for UnsupportedDomainServices { + fn runtime_restore_idle(&self) -> bool { + true + } fn snapshot(&self) -> BoxFuture<'static, Result> { unsupported("selection") } @@ -1666,6 +1722,9 @@ impl SelectionApi for UnsupportedDomainServices { } impl SelectionVoiceApi for UnsupportedDomainServices { + fn runtime_restore_idle(&self) -> bool { + true + } fn snapshot(&self) -> BoxFuture<'static, Result> { unsupported("selection voice") } @@ -1760,6 +1819,9 @@ impl SelectionVoiceApi for UnsupportedDomainServices { } impl QaApi for UnsupportedDomainServices { + fn runtime_restore_idle(&self) -> bool { + true + } fn show(&self) -> BoxFuture<'static, Result<(), BackendError>> { unsupported("QA") } diff --git a/openless-all/app/crates/openless-core/src/events.rs b/openless-all/app/crates/openless-core/src/events.rs index 2d2e27c8..789edfe4 100644 --- a/openless-all/app/crates/openless-core/src/events.rs +++ b/openless-all/app/crates/openless-core/src/events.rs @@ -377,6 +377,9 @@ pub enum BackendEventKind { HistoryChanged(HistoryChange), VocabularyChanged(VocabularyChange), StylePacksChanged(StylePackChange), + CloudSyncStateChanged(crate::cloud_sync_e2ee::EncryptedSyncEvent), + CloudSyncConflictDetected(crate::cloud_sync_e2ee::EncryptedSyncConflictEvent), + CloudSyncRestoreCompleted(crate::cloud_sync_e2ee::EncryptedSyncRestoreEvent), DownloadProgress(DownloadProgress), PermissionChanged(PermissionSnapshot), HotkeyStatusChanged(HotkeyStatus), diff --git a/openless-all/app/crates/openless-core/src/history.rs b/openless-all/app/crates/openless-core/src/history.rs index 37da6ddd..31f8efa7 100644 --- a/openless-all/app/crates/openless-core/src/history.rs +++ b/openless-all/app/crates/openless-core/src/history.rs @@ -4,7 +4,7 @@ use std::path::{Path, PathBuf}; use std::sync::Mutex; use crate::errors::{BackendError, BackendErrorCode}; -use crate::persistence::{atomic_write, persistence_error, read_or_default}; +use crate::persistence::{atomic_write, persistence_error}; use crate::types::{DictationSession, HistorySource}; pub const HISTORY_CAP: usize = 200; @@ -31,17 +31,45 @@ impl HistoryStore { self.read_locked() } + pub(crate) fn sync_snapshot( + &self, + permit: &crate::cloud_sync_e2ee_store::gate::ExclusivePermit, + ) -> Result, BackendError> { + crate::cloud_sync_e2ee_store::gate::require_exclusive(&self.path, permit)?; + let _guard = self.lock_store()?; + crate::persistence::read_lossless_rows(&self.path, &[]) + } + + /// Whole logical replacement deliberately bypasses both age and entry-count retention. + pub(crate) fn sync_replace_all( + &self, + records: &[DictationSession], + permit: &crate::cloud_sync_e2ee_store::gate::ExclusivePermit, + ) -> Result<(), BackendError> { + crate::cloud_sync_e2ee_store::gate::require_exclusive(&self.path, permit)?; + let _guard = self.lock_store()?; + let bytes = serde_json::to_vec_pretty(records) + .map_err(|_| persistence_error("encode restored history"))?; + crate::persistence::atomic_write_for_sync(&self.path, &bytes, permit) + } + pub fn append_with_retention( &self, session: DictationSession, retention_days: u32, max_entries: Option, ) -> Result<(), BackendError> { - let _guard = self.lock_store()?; - let mut sessions = self.read_locked()?; - sessions.insert(0, session); - retain_with_policy(&mut sessions, retention_days, max_entries); - self.write_locked(&sessions) + crate::cloud_sync_e2ee_store::gate::with_registered_mutation( + &self.path, + crate::cloud_sync_e2ee_store::gate::ChangeOrigin::User, + || { + let _guard = self.lock_store()?; + let mut sessions = self.read_locked()?; + sessions.insert(0, session); + retain_with_policy(&mut sessions, retention_days, max_entries); + self.write_locked(&sessions) + }, + ) } /// Replace an in-progress record when its terminal result arrives, or @@ -52,19 +80,25 @@ impl HistoryStore { retention_days: u32, max_entries: Option, ) -> Result<(), BackendError> { - let _guard = self.lock_store()?; - let mut sessions = self.read_locked()?; - if let Some(existing) = sessions.iter_mut().find(|item| item.id == session.id) { - let mut replacement = session; - if replacement.has_audio_recording.is_none() { - replacement.has_audio_recording = existing.has_audio_recording; - } - *existing = replacement; - } else { - sessions.insert(0, session); - } - retain_with_policy(&mut sessions, retention_days, max_entries); - self.write_locked(&sessions) + crate::cloud_sync_e2ee_store::gate::with_registered_mutation( + &self.path, + crate::cloud_sync_e2ee_store::gate::ChangeOrigin::User, + || { + let _guard = self.lock_store()?; + let mut sessions = self.read_locked()?; + if let Some(existing) = sessions.iter_mut().find(|item| item.id == session.id) { + let mut replacement = session; + if replacement.has_audio_recording.is_none() { + replacement.has_audio_recording = existing.has_audio_recording; + } + *existing = replacement; + } else { + sessions.insert(0, session); + } + retain_with_policy(&mut sessions, retention_days, max_entries); + self.write_locked(&sessions) + }, + ) } pub fn contains(&self, id: &str) -> Result { @@ -101,35 +135,54 @@ impl HistoryStore { } pub fn delete(&self, id: &str) -> Result<(), BackendError> { - let _guard = self.lock_store()?; - let mut sessions = self.read_locked()?; - let before = sessions.len(); - sessions.retain(|session| session.id != id); - if sessions.len() != before { - self.write_locked(&sessions)?; - } - Ok(()) + crate::cloud_sync_e2ee_store::gate::with_registered_mutation( + &self.path, + crate::cloud_sync_e2ee_store::gate::ChangeOrigin::User, + || { + let _guard = self.lock_store()?; + let mut sessions = self.read_locked()?; + let before = sessions.len(); + sessions.retain(|session| session.id != id); + if sessions.len() != before { + self.write_locked(&sessions)?; + } + Ok(()) + }, + ) } pub fn update_entry(&self, updated: DictationSession) -> Result { - let _guard = self.lock_store()?; - let mut sessions = self.read_locked()?; - let Some(slot) = sessions.iter_mut().find(|session| session.id == updated.id) else { - return Ok(false); - }; - *slot = updated; - self.write_locked(&sessions)?; - Ok(true) + crate::cloud_sync_e2ee_store::gate::with_registered_mutation( + &self.path, + crate::cloud_sync_e2ee_store::gate::ChangeOrigin::User, + || { + let _guard = self.lock_store()?; + let mut sessions = self.read_locked()?; + let Some(slot) = sessions.iter_mut().find(|session| session.id == updated.id) + else { + return Ok(false); + }; + *slot = updated; + self.write_locked(&sessions)?; + Ok(true) + }, + ) } pub fn clear(&self) -> Result<(), BackendError> { - let _guard = self.lock_store()?; - let quick_notes = self - .read_locked()? - .into_iter() - .filter(|session| session.source == HistorySource::QuickNote) - .collect::>(); - self.write_locked(&quick_notes) + crate::cloud_sync_e2ee_store::gate::with_registered_mutation( + &self.path, + crate::cloud_sync_e2ee_store::gate::ChangeOrigin::User, + || { + let _guard = self.lock_store()?; + let quick_notes = self + .read_locked()? + .into_iter() + .filter(|session| session.source == HistorySource::QuickNote) + .collect::>(); + self.write_locked(&quick_notes) + }, + ) } fn lock_store(&self) -> Result, BackendError> { @@ -139,7 +192,7 @@ impl HistoryStore { } fn read_locked(&self) -> Result, BackendError> { - read_or_default(&self.path) + crate::persistence::read_lossless_rows(&self.path, &[]) } fn write_locked(&self, sessions: &[DictationSession]) -> Result<(), BackendError> { diff --git a/openless-all/app/crates/openless-core/src/less_computer.rs b/openless-all/app/crates/openless-core/src/less_computer.rs index e524aad4..ffc27dab 100644 --- a/openless-all/app/crates/openless-core/src/less_computer.rs +++ b/openless-all/app/crates/openless-core/src/less_computer.rs @@ -581,10 +581,12 @@ impl LessComputerApi for LessComputerService { .lock() .expect("Less Computer approval lock poisoned") .remove(&token); - if let Some(sender) = sender { - let _ = sender.send(approved); - } - Ok(()) + let sender = sender.ok_or_else(|| BackendError::new( + BackendErrorCode::InvalidState, "approval request is no longer pending", + ))?; + sender.send(approved).map_err(|_| BackendError::new( + BackendErrorCode::InvalidState, "approval receiver is no longer waiting", + )) }) } @@ -1167,7 +1169,7 @@ mod tests { } #[tokio::test] - async fn approval_tokens_are_instance_local_idempotent_and_event_driven() { + async fn approval_tokens_are_instance_local_confirmed_once_and_event_driven() { let (owner, mut events) = service_with_events(); let (other, _) = service_with_events(); let owner_for_request = owner.clone(); @@ -1193,12 +1195,12 @@ mod tests { assert_eq!(command, "rm file"); assert_eq!(reason, "destructive"); - other.approve(token.clone(), true).await.unwrap(); + assert!(other.approve(token.clone(), true).await.is_err()); tokio::task::yield_now().await; assert!(!waiting.is_finished()); owner.approve(token.clone(), true).await.unwrap(); - owner.approve(token, false).await.unwrap(); + assert!(owner.approve(token, false).await.is_err()); assert!(waiting.await.unwrap().unwrap()); assert_eq!(owner.pending_approval_count(), 0); } diff --git a/openless-all/app/crates/openless-core/src/lib.rs b/openless-all/app/crates/openless-core/src/lib.rs index cacf8e67..db82bf29 100644 --- a/openless-all/app/crates/openless-core/src/lib.rs +++ b/openless-all/app/crates/openless-core/src/lib.rs @@ -13,6 +13,10 @@ pub mod auxiliary; pub mod cli; mod cloud_providers; pub mod cloud_sync; +pub mod cloud_sync_e2ee; +pub mod cloud_sync_e2ee_documents; +pub mod cloud_sync_e2ee_store; +mod cloud_sync_e2ee_protocol; mod cloud_sync_transaction; mod cloud_sync_types; mod cloud_sync_validation; diff --git a/openless-all/app/crates/openless-core/src/llm_protocol.rs b/openless-all/app/crates/openless-core/src/llm_protocol.rs index 3ba50952..be525f13 100644 --- a/openless-all/app/crates/openless-core/src/llm_protocol.rs +++ b/openless-all/app/crates/openless-core/src/llm_protocol.rs @@ -385,12 +385,7 @@ impl TextEventStream { } pub fn push(&mut self, chunk: &[u8]) -> Result<(), LLMError> { - crate::polish::append_utf8_sse_chunk(&mut self.buffer, &mut self.pending, chunk)?; - // 在完整字符串上替换,兼容 CR 与 LF 分属不同网络块。 - if self.buffer.contains("\r\n") { - self.buffer = self.buffer.replace("\r\n", "\n"); - } - Ok(()) + crate::polish::append_utf8_sse_chunk(&mut self.buffer, &mut self.pending, chunk) } pub fn next(&mut self) -> Result, LLMError> { @@ -769,6 +764,39 @@ mod tests { .is_err()); } + #[test] + fn chat_sse_preserves_utf8_and_done_with_lf_crlf_or_mixed_frames() { + for (text_eol, done_eol) in [ + ("\n", "\n"), + ("\r\n", "\r\n"), + ("\n", "\r\n"), + ("\r\n", "\n"), + ] { + let mut stream = TextEventStream::new(LlmRequestFormat::ChatCompletions); + let text_event = format!("data: {{\"choices\":[{{\"delta\":{{\"content\":\"你\\r\\n🙂好\"}}}}]}}{text_eol}{text_eol}"); + let mut text = String::new(); + for byte in text_event.as_bytes() { + stream.push(&[*byte]).unwrap(); + while let Some(event) = stream.next().unwrap() { + if let StreamEvent::Text(delta) = event { + text.push_str(&delta); + } + } + } + assert_eq!(text, "你\r\n🙂好", "JSON escapes must remain user text"); + assert!(!stream.done); + let done_event = format!("data: [DONE]{done_eol}{done_eol}"); + for byte in done_event.as_bytes() { + stream.push(&[*byte]).unwrap(); + while let Some(event) = stream.next().unwrap() { + assert!(matches!(event, StreamEvent::Done)); + } + } + assert!(stream.done, "recognize the terminal marker before EOF"); + stream.finish().unwrap(); + } + } + #[test] fn sse_handles_every_byte_boundary_and_requires_successful_termination() { for (format, fixture) in [ diff --git a/openless-all/app/crates/openless-core/src/marketplace.rs b/openless-all/app/crates/openless-core/src/marketplace.rs index 0b25e9f0..8a99c710 100644 --- a/openless-all/app/crates/openless-core/src/marketplace.rs +++ b/openless-all/app/crates/openless-core/src/marketplace.rs @@ -26,6 +26,7 @@ pub const CLOUD_SYNC_BASE_URL: &str = "https://apic.openless.top:9443"; #[derive(Debug, Clone)] pub struct MarketplaceConfig { + pub(crate) encrypted_sync_config: Option, pub base_url: reqwest::Url, pub cloud_sync_base_url: reqwest::Url, pub github_client_id: String, @@ -59,6 +60,7 @@ impl MarketplaceConfig { .filter(|value| !value.trim().is_empty()) .unwrap_or_else(|| "Ov23liyv3nEucG7oMHNE".into()); Ok(Self { + encrypted_sync_config: None, base_url: parse(base_url.as_ref(), "marketplace")?, cloud_sync_base_url: match cloud_sync_base_url { Some(value) => parse(value, "cloud sync")?, @@ -81,6 +83,12 @@ impl MarketplaceConfig { Self::with_cloud_sync_base_url(MARKETPLACE_BASE_URL, Some(CLOUD_SYNC_BASE_URL)) .expect("built-in Marketplace URLs are valid") } + + /// Opt in after the host provides the controlled credential and UI bridges. + pub fn with_encrypted_sync(mut self, config: crate::cloud_sync_e2ee::EncryptedSyncConfig) -> Self { + self.encrypted_sync_config = Some(config); + self + } } #[derive(Clone)] @@ -449,6 +457,43 @@ impl MarketplaceService { .ok_or_else(Self::authentication_required) } + /// Establish the numeric identity used to bind encrypted snapshots. The + /// stored display login is never an authorization or account-isolation key. + pub(crate) async fn sync_identity( + &self, + ) -> Result<(SecretValue, crate::cloud_sync_e2ee_protocol::types::Account), BackendError> { + use futures_util::StreamExt; + use crate::cloud_sync_e2ee_protocol::types::{Account, GithubId}; + let token = self.read_access_token().await?; + let response = crate::net::credential_http_for_url(self.config.github_user_url.as_str()) + .get(self.config.github_user_url.clone()) + .header("Accept", "application/vnd.github+json") + .header("User-Agent", "OpenLess-encrypted-sync") + .timeout(Duration::from_secs(15)) + .bearer_auth(token.expose_secret()) + .send().await.map_err(|_| Self::authentication_required())?; + if !response.status().is_success() { + return Err(Self::authentication_required()); + } + let mut bytes = Vec::new(); + let mut stream = response.bytes_stream(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|_| Self::authentication_required())?; + if bytes.len().saturating_add(chunk.len()) > 16 * 1024 { + return Err(Self::authentication_required()); + } + bytes.extend_from_slice(&chunk); + } + #[derive(serde::Deserialize)] + struct User { id: u64, login: String } + let user: User = serde_json::from_slice(&bytes).map_err(|_| Self::authentication_required())?; + if user.login.is_empty() || user.login.len() > 128 || self.read_access_token().await? != token { + return Err(Self::authentication_required()); + } + let github_id = GithubId::parse(&user.id.to_string()).map_err(|_| Self::authentication_required())?; + Ok((token, Account { github_id, login: user.login })) + } + async fn clear_authentication(&self) -> Result<(), BackendError> { self.auth_tombstoned.store(true, Ordering::Release); let remove_result = self.credential_store.remove(Self::token_key()?).await; @@ -458,6 +503,10 @@ impl MarketplaceService { remove_result.and(preferences_result) } + pub(crate) fn invalidate_authentication(&self) { + self.auth_tombstoned.store(true, Ordering::Release); + } + async fn authenticated_response( &self, method: reqwest::Method, diff --git a/openless-all/app/crates/openless-core/src/omni.rs b/openless-all/app/crates/openless-core/src/omni.rs index 3f08aa46..171daff8 100644 --- a/openless-all/app/crates/openless-core/src/omni.rs +++ b/openless-all/app/crates/openless-core/src/omni.rs @@ -207,7 +207,7 @@ impl OpenAICompatibleOmni { }); } - // SSE 流解析与 polish 路径同款:一帧 = 若干行,`\n\n` 分隔, + // 共用 UTF-8 解码会把 CRLF 归一为 LF;一帧 = 若干行,`\n\n` 分隔, // 每行 `data: {...}` / `data: [DONE]`。 let mut response = response; let mut buffer = String::new(); diff --git a/openless-all/app/crates/openless-core/src/persistence.rs b/openless-all/app/crates/openless-core/src/persistence.rs index f0f67482..b86d8aba 100644 --- a/openless-all/app/crates/openless-core/src/persistence.rs +++ b/openless-all/app/crates/openless-core/src/persistence.rs @@ -1,6 +1,7 @@ //! Small framework-independent JSON persistence primitives. use std::fs; +use std::io::{Read, Write}; use std::path::Path; use serde::de::DeserializeOwned; @@ -21,9 +22,55 @@ pub(crate) fn read_or_default( } pub(crate) fn atomic_write(path: &Path, contents: &[u8]) -> Result<(), BackendError> { + use crate::cloud_sync_e2ee_store::gate::{begin_unobserved_atomic, ChangeOrigin}; + let permit = begin_unobserved_atomic(path)?; + let outcome = atomic_replace(path, contents); + match outcome { + Ok(changed) => { + if changed { + crate::cloud_sync_e2ee_store::gate::note_successful_write(path); + } + if let Some(permit) = permit { + let result = if changed { + permit.commit(ChangeOrigin::User).map(|_| ()) + } else { + permit.abort_unmodified() + }; + result.map_err(|_| persistence_error("record saved data generation"))?; + } + Ok(()) + } + Err(error) => { + if error.code != BackendErrorCode::OutcomeUnknown { + if let Some(permit) = permit { + permit + .abort_unmodified() + .map_err(|_| persistence_error("clear unused mutation intent"))?; + } + } + Err(error) + } + } +} + +/// Restore hooks require proof of the same exclusive gate; they emit one final batch change. +pub(crate) fn atomic_write_for_sync( + path: &Path, + contents: &[u8], + permit: &crate::cloud_sync_e2ee_store::gate::ExclusivePermit, +) -> Result<(), BackendError> { + crate::cloud_sync_e2ee_store::gate::require_exclusive(path, permit)?; + atomic_replace(path, contents).map(|_| ()) +} + +fn atomic_replace(path: &Path, contents: &[u8]) -> Result { if path.as_os_str().is_empty() { return Err(persistence_error("empty JSON store path")); } + if fs::read(path).is_ok_and(|existing| existing == contents) { + ensure_durable_file(path)?; + return Ok(false); + } if let Some(parent) = path.parent() { fs::create_dir_all(parent).map_err(|_| persistence_error("create JSON store directory"))?; } @@ -33,15 +80,202 @@ pub(crate) fn atomic_write(path: &Path, contents: &[u8]) -> Result<(), BackendEr .unwrap_or_default(); let temporary = path.with_file_name(format!("{file_name}.tmp-{}", uuid::Uuid::new_v4().simple())); - fs::write(&temporary, contents) - .map_err(|_| persistence_error("write JSON store temporary file"))?; + let write = (|| { + let mut options = fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let mut file = options + .open(&temporary) + .map_err(|_| persistence_error("create JSON store temporary file"))?; + file.write_all(contents) + .map_err(|_| persistence_error("write JSON store temporary file"))?; + file.sync_all() + .map_err(|_| persistence_error("flush JSON store temporary file")) + })(); + if let Err(error) = write { + let _ = fs::remove_file(&temporary); + return Err(error); + } + #[cfg(test)] + if take_test_fault(path, false) { + let _ = fs::remove_file(&temporary); + return Err(persistence_error("fixture pre-rename failure")); + } if fs::rename(&temporary, path).is_err() { let _ = fs::remove_file(&temporary); return Err(persistence_error("replace JSON store file")); } + #[cfg(test)] + if take_test_fault(path, true) { + return Err(BackendError::new( + BackendErrorCode::OutcomeUnknown, + "fixture directory flush failure", + )); + } + #[cfg(unix)] + if let Some(parent) = path.parent() { + fs::File::open(parent) + .and_then(|file| file.sync_all()) + .map_err(|_| { + BackendError::new( + BackendErrorCode::OutcomeUnknown, + "JSON replacement outcome needs verification", + ) + })?; + } + Ok(true) +} + +/// A read-back of an uncertain rename is complete only after its data and directory flush. +pub(crate) fn ensure_durable_file(path: &Path) -> Result<(), BackendError> { + let mut options = fs::OpenOptions::new(); + options.read(true); + #[cfg(windows)] + options.write(true); + options + .open(path) + .and_then(|file| file.sync_all()) + .map_err(|_| { + BackendError::new( + BackendErrorCode::OutcomeUnknown, + "stored data durability needs verification", + ) + })?; + #[cfg(unix)] + if let Some(parent) = path.parent() { + fs::File::open(parent) + .and_then(|file| file.sync_all()) + .map_err(|_| { + BackendError::new( + BackendErrorCode::OutcomeUnknown, + "stored directory durability needs verification", + ) + })?; + } Ok(()) } pub(crate) fn persistence_error(operation: &'static str) -> BackendError { BackendError::new(BackendErrorCode::Persistence, operation) } + +/// Sync must either preserve every stored field or report an unsupported schema. +pub(crate) fn read_lossless_rows( + path: &Path, + aliases: &[&str], +) -> Result, BackendError> { + let file = match fs::File::open(path) { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(_) => return Err(persistence_error("read sync rows")), + }; + let limit = crate::cloud_sync_e2ee_documents::MAX_JSON_BYTES; + let mut bytes = Vec::new(); + file.take((limit + 1) as u64) + .read_to_end(&mut bytes) + .map_err(|_| persistence_error("read sync rows"))?; + if bytes.len() > limit { + return Err(BackendError::new( + BackendErrorCode::InvalidArgument, + "payload_too_large", + )); + } + if bytes.is_empty() { + return Ok(Vec::new()); + } + let raw: Vec = + serde_json::from_slice(&bytes).map_err(|_| persistence_error("decode sync rows"))?; + raw.into_iter() + .map(|value| { + let row: T = serde_json::from_value(value.clone()) + .map_err(|_| persistence_error("decode sync row"))?; + let canonical = + serde_json::to_value(&row).map_err(|_| persistence_error("encode sync row"))?; + ensure_lossless_value(&value, &canonical, aliases)?; + Ok(row) + }) + .collect() +} + +/// Every explicit source value must remain representable. Canonical defaults may be added; +/// aliases are allowed only at the root and must be explicitly registered by the caller. +pub(crate) fn ensure_lossless_value( + raw: &serde_json::Value, + canonical: &serde_json::Value, + aliases: &[&str], +) -> Result<(), BackendError> { + use serde_json::Value; + match (raw, canonical) { + (Value::Object(raw), Value::Object(canonical)) => { + for (key, value) in raw { + if aliases.contains(&key.as_str()) { + continue; + } + let next = canonical.get(key).ok_or_else(|| { + BackendError::new(BackendErrorCode::Unsupported, "unsupported stored fields") + })?; + ensure_lossless_value(value, next, &[])?; + } + } + (Value::Array(raw), Value::Array(canonical)) if raw.len() == canonical.len() => { + for (raw, canonical) in raw.iter().zip(canonical) { + ensure_lossless_value(raw, canonical, &[])?; + } + } + _ if raw == canonical => {} + _ => { + return Err(BackendError::new( + BackendErrorCode::Unsupported, + "stored value cannot be preserved", + )) + } + } + Ok(()) +} + +pub(crate) fn read_lossless_object( + path: &Path, +) -> Result { + let bytes = match fs::read(path) { + Ok(bytes) => bytes, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(T::default()), + Err(_) => return Err(persistence_error("read JSON store")), + }; + if bytes.is_empty() { + return Ok(T::default()); + } + let raw: serde_json::Value = + serde_json::from_slice(&bytes).map_err(|_| persistence_error("decode JSON store"))?; + let value: T = + serde_json::from_value(raw.clone()).map_err(|_| persistence_error("decode JSON store"))?; + let canonical = + serde_json::to_value(&value).map_err(|_| persistence_error("encode JSON store"))?; + ensure_lossless_value(&raw, &canonical, &[])?; + Ok(value) +} + +#[cfg(test)] +static TEST_FAULTS: std::sync::LazyLock< + std::sync::Mutex>, +> = std::sync::LazyLock::new(|| std::sync::Mutex::new(std::collections::BTreeMap::new())); +#[cfg(test)] +pub(crate) fn fail_next_atomic_write(path: &Path, after_rename: bool) { + TEST_FAULTS + .lock() + .unwrap() + .insert(path.to_path_buf(), after_rename); +} +#[cfg(test)] +fn take_test_fault(path: &Path, after_rename: bool) -> bool { + let mut faults = TEST_FAULTS.lock().unwrap(); + if faults.get(path) == Some(&after_rename) { + faults.remove(path); + true + } else { + false + } +} diff --git a/openless-all/app/crates/openless-core/src/polish.rs b/openless-all/app/crates/openless-core/src/polish.rs index 0d2e089e..7b8e3719 100644 --- a/openless-all/app/crates/openless-core/src/polish.rs +++ b/openless-all/app/crates/openless-core/src/polish.rs @@ -1451,7 +1451,13 @@ pub(crate) fn append_utf8_sse_chunk( chunk: &[u8], ) -> Result<(), LLMError> { pending.extend_from_slice(chunk); - drain_complete_utf8(buffer, pending) + drain_complete_utf8(buffer, pending)?; + // Normalize only after reassembling UTF-8, including CR/LF split across chunks. + // Omni, Codex and TextEventStream share this framing boundary. + if buffer.contains("\r\n") { + *buffer = buffer.replace("\r\n", "\n"); + } + Ok(()) } pub(crate) fn finish_utf8_sse_chunks( @@ -2170,6 +2176,96 @@ mod tests { format!("{}.{}.sig", header, payload) } + #[test] + fn utf8_sse_decoder_emits_each_crlf_or_lf_frame_before_the_next_one() { + let frames = [ + "data: {\"delta\":\"你好🙂\"}\r\n\r\n", + "data: {\"delta\":\"second\"}\n\n", + "data: [DONE]\r\n\r\n", + ]; + let mut buffer = String::new(); + let mut pending = Vec::new(); + let mut emitted = Vec::new(); + for (index, frame) in frames.iter().enumerate() { + // One byte per HTTP chunk splits both CRLF pairs and UTF-8 codepoints. + for byte in frame.as_bytes() { + append_utf8_sse_chunk(&mut buffer, &mut pending, &[*byte]).unwrap(); + while let Some(end) = buffer.find("\n\n") { + emitted.push(buffer[..end].to_string()); + buffer.drain(..end + 2); + } + } + assert_eq!( + emitted.len(), + index + 1, + "must emit before another frame or EOF" + ); + } + finish_utf8_sse_chunks(&mut buffer, &mut pending).unwrap(); + assert_eq!( + emitted, + [ + "data: {\"delta\":\"你好🙂\"}", + "data: {\"delta\":\"second\"}", + "data: [DONE]" + ] + ); + assert!(buffer.is_empty()); + assert!(pending.is_empty()); + } + + #[test] + fn utf8_sse_decoder_normalizes_crlf_at_every_network_split() { + let frame = "data: {\"delta\":\"你好🙂\"}\r\n\r\n"; + for split in 0..=frame.len() { + let mut buffer = String::new(); + let mut pending = Vec::new(); + append_utf8_sse_chunk(&mut buffer, &mut pending, &frame.as_bytes()[..split]).unwrap(); + append_utf8_sse_chunk(&mut buffer, &mut pending, &frame.as_bytes()[split..]).unwrap(); + assert_eq!(buffer, "data: {\"delta\":\"你好🙂\"}\n\n", "split={split}"); + assert!(pending.is_empty()); + } + } + + #[test] + fn codex_crlf_deltas_and_completion_do_not_wait_for_eof() { + for terminal in ["response.done", "response.completed"] { + let frames = [ + "data: {\"type\":\"response.output_text.delta\",\"delta\":\"你🙂\"}\r\n\r\n".to_string(), + "data: {\"type\":\"response.text.delta\",\"text\":\"好\"}\n\n".to_string(), + format!("data: {{\"type\":\"{terminal}\",\"response\":{{\"output_text\":\"最终文本\"}}}}\r\n\r\n"), + "data: [DONE]\r\n\r\n".to_string(), + ]; + let mut buffer = String::new(); + let mut pending = Vec::new(); + let mut full_text = String::new(); + let mut final_text = String::new(); + let callbacks = StdMutex::new(Vec::new()); + for (index, frame) in frames.iter().enumerate() { + for byte in frame.as_bytes() { + append_utf8_sse_chunk(&mut buffer, &mut pending, &[*byte]).unwrap(); + while let Some(end) = buffer.find("\n\n") { + let event = buffer[..end].to_string(); + buffer.drain(..end + 2); + handle_codex_sse_event(&event, &mut full_text, &mut final_text, &|text| { + callbacks.lock().unwrap().push(text.to_string()); + }); + } + } + assert_eq!(full_text, if index == 0 { "你🙂" } else { "你🙂好" }); + assert_eq!( + callbacks.lock().unwrap().len(), + if index == 0 { 1 } else { 2 } + ); + if index >= 2 { + assert_eq!(final_text, "最终文本", "retain Codex completion fallback"); + } + } + finish_utf8_sse_chunks(&mut buffer, &mut pending).unwrap(); + assert!(buffer.is_empty()); + } + } + #[test] fn utf8_sse_decoder_preserves_multibyte_split_across_chunks() { let mut buffer = String::new(); diff --git a/openless-all/app/crates/openless-core/src/preferences.rs b/openless-all/app/crates/openless-core/src/preferences.rs index 35a4b815..113cea66 100644 --- a/openless-all/app/crates/openless-core/src/preferences.rs +++ b/openless-all/app/crates/openless-core/src/preferences.rs @@ -14,6 +14,7 @@ fn persistence_error(operation: impl Into) -> BackendError { } fn read_preferences(path: &Path) -> Result { + let read_only = crate::cloud_sync_e2ee_store::gate::recovery_pending_for_path(path); if !path.exists() { return Ok(UserPreferences::default()); } @@ -24,9 +25,14 @@ fn read_preferences(path: &Path) -> Result { let preferences = match serde_json::from_slice::(&bytes) { Ok(preferences) => preferences, - Err(error) => { + Err(_error) => { + if read_only { + // Recovery owns the authoritative before/after images. Do not back up, + // migrate, or overwrite a partially restored file during construction. + return Ok(UserPreferences::salvage_from_json_bytes(&bytes)); + } log::error!( - "[prefs] strict decode of {} failed: {error}; backing up and salvaging", + "[prefs] strict decode of {} failed; backing up and salvaging", path.display() ); let backup = backup_unparseable_preferences(path, &bytes)?; @@ -35,8 +41,11 @@ fn read_preferences(path: &Path) -> Result { backup.display() ); let salvaged = UserPreferences::salvage_from_json_bytes(&bytes); - match serde_json::to_vec_pretty(&salvaged) - .map_err(|_| persistence_error("encode salvaged preferences")) + match preferences_json_preserving_unknown(path, &salvaged) + .and_then(|value| { + serde_json::to_vec_pretty(&value) + .map_err(|_| persistence_error("encode salvaged preferences")) + }) .and_then(|json| atomic_write(path, &json)) { Ok(()) => log::info!( @@ -60,9 +69,12 @@ fn read_preferences(path: &Path) -> Result { .and_then(|flag| flag.as_bool()) }) .unwrap_or(false); - if !streaming_default_migrated { - match serde_json::to_vec_pretty(&preferences) - .map_err(|_| persistence_error("encode migrated preferences")) + if !streaming_default_migrated && !read_only { + match preferences_json_preserving_unknown(path, &preferences) + .and_then(|value| { + serde_json::to_vec_pretty(&value) + .map_err(|_| persistence_error("encode migrated preferences")) + }) .and_then(|json| atomic_write(path, &json)) { Ok(()) => log::info!("[prefs] migrated streamingInsert default marker"), @@ -76,6 +88,87 @@ fn read_preferences(path: &Path) -> Result { Ok(preferences) } +fn preferences_json_preserving_unknown( + path: &Path, + preferences: &UserPreferences, +) -> Result { + let mut value = + serde_json::to_value(preferences).map_err(|_| persistence_error("encode preferences"))?; + let existing = match fs::read(path) { + Ok(bytes) => Some(bytes), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, + Err(_) => return Err(persistence_error("read unknown preference fields")), + }; + if let Some(bytes) = existing { + if let Ok(serde_json::Value::Object(old)) = serde_json::from_slice(&bytes) { + let object = value + .as_object_mut() + .ok_or_else(|| persistence_error("preferences object"))?; + let mut old = serde_json::Value::Object(old); + if let Some(map) = old.as_object_mut() { + map.remove("windowsSendinputInsertionOnly"); + map.remove("windowsSendinputNewlineMode"); + } + let mut canonical = serde_json::to_value(preferences) + .map_err(|_| persistence_error("encode preferences"))?; + preserve_unknown_fields(&mut canonical, &old)?; + *object = canonical + .as_object_mut() + .ok_or_else(|| persistence_error("preferences object"))? + .clone(); + } + } + Ok(value) +} + +fn preserve_unknown_fields( + canonical: &mut serde_json::Value, + old: &serde_json::Value, +) -> Result<(), BackendError> { + use serde_json::Value; + match (canonical, old) { + (Value::Object(current), Value::Object(old)) => { + for (key, value) in old { + if let Some(known) = current.get_mut(key) { + preserve_unknown_fields(known, value)?; + } else { + current.insert(key.clone(), value.clone()); + } + } + } + (Value::Array(current), Value::Array(old)) => { + // Existing object arrays have stable identities. Preserve extensions across + // reordering/editing, and let explicit entity deletion remove its extensions. + for item in current.iter_mut().filter(|item| item.is_object()) { + let key = ["packId", "code", "id"] + .into_iter() + .find(|key| item.get(*key).is_some()); + let Some(key) = key else { + return Err(BackendError::new( + BackendErrorCode::Unsupported, + "unsupported preference array extension", + )); + }; + let matches: Vec<_> = old + .iter() + .filter(|candidate| candidate.get(key) == item.get(key)) + .collect(); + if matches.len() > 1 { + return Err(BackendError::new( + BackendErrorCode::Unsupported, + "ambiguous preference array identity", + )); + } + if let Some(previous) = matches.first() { + preserve_unknown_fields(item, previous)?; + } + } + } + _ => {} + } + Ok(()) +} + fn backup_unparseable_preferences(path: &Path, bytes: &[u8]) -> Result { let timestamp = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -109,7 +202,15 @@ impl PreferencesStore { if path.as_os_str().is_empty() { return Err(persistence_error("preferences path is empty")); } - let preferences = read_preferences(&path)?; + let preferences = if crate::cloud_sync_e2ee_store::gate::recovery_pending_for_path(&path) { + read_preferences(&path)? + } else { + crate::cloud_sync_e2ee_store::gate::with_registered_mutation( + &path, + crate::cloud_sync_e2ee_store::gate::ChangeOrigin::User, + || read_preferences(&path), + )? + }; Ok(Self { path, state: Mutex::new(preferences), @@ -141,6 +242,11 @@ impl PreferencesStore { .clone() } + /// Immutable Host-selected path, used to check a write barrier before taking store locks. + pub(crate) fn persistence_path(&self) -> &Path { + &self.path + } + /// Keep the live value locked until a coordinated restore has committed all of its files. pub(crate) fn cloud_sync_access(&self) -> (std::sync::MutexGuard<'_, UserPreferences>, &Path) { ( @@ -152,14 +258,47 @@ impl PreferencesStore { } pub fn set(&self, preferences: UserPreferences) -> Result<(), BackendError> { - let json = serde_json::to_vec_pretty(&preferences) - .map_err(|_| persistence_error("encode preferences"))?; + self.update(|current| *current = preferences) + } + + pub(crate) fn sync_snapshot( + &self, + permit: &crate::cloud_sync_e2ee_store::gate::ExclusivePermit, + ) -> Result { + crate::cloud_sync_e2ee_store::gate::require_exclusive(&self.path, permit)?; + self.sync_snapshot_readonly() + } + + pub(crate) fn sync_snapshot_readonly(&self) -> Result { let mut state = self .state .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); - atomic_write(&self.path, &json)?; - *state = preferences; + if self.path.exists() { + let bytes = + fs::read(&self.path).map_err(|_| persistence_error("read sync preferences"))?; + *state = serde_json::from_slice(&bytes) + .map_err(|_| persistence_error("decode sync preferences"))?; + } + preferences_json_preserving_unknown(&self.path, &state) + } + + pub(crate) fn sync_replace_raw( + &self, + value: serde_json::Value, + permit: &crate::cloud_sync_e2ee_store::gate::ExclusivePermit, + ) -> Result<(), BackendError> { + crate::cloud_sync_e2ee_store::gate::require_exclusive(&self.path, permit)?; + let parsed: UserPreferences = serde_json::from_value(value.clone()) + .map_err(|_| persistence_error("validate restored preferences"))?; + let bytes = serde_json::to_vec_pretty(&value) + .map_err(|_| persistence_error("encode restored preferences"))?; + let mut state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + crate::persistence::atomic_write_for_sync(&self.path, &bytes, permit)?; + *state = parsed; Ok(()) } @@ -171,33 +310,50 @@ impl PreferencesStore { &self, update: impl FnOnce(&mut UserPreferences) -> R, ) -> Result { - let mut state = self - .state - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let mut next = state.clone(); - let result = update(&mut next); - let json = serde_json::to_vec_pretty(&next) - .map_err(|_| persistence_error("encode preferences"))?; - atomic_write(&self.path, &json)?; - *state = next; - Ok(result) + crate::cloud_sync_e2ee_store::gate::with_registered_mutation_deciding(&self.path, || { + let mut state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let mut next = match fs::read(&self.path) { + Ok(bytes) if !bytes.is_empty() => serde_json::from_slice::(&bytes) + .map_err(|_| persistence_error("decode preferences before save"))?, + Ok(_) => state.clone(), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => state.clone(), + Err(_) => return Err(persistence_error("read preferences before save")), + }; + let previous = + serde_json::to_value(&next).map_err(|_| persistence_error("encode preferences"))?; + let result = update(&mut next); + let value = preferences_json_preserving_unknown(&self.path, &next)?; + let changed = crate::cloud_sync_e2ee_documents::registry::PREFERENCE_FIELDS + .iter() + .any(|field| { + field.class + != crate::cloud_sync_e2ee_documents::registry::PreferenceClass::Excluded + && previous.get(field.key) != value.get(field.key) + }); + let origin = if changed { + crate::cloud_sync_e2ee_store::gate::ChangeOrigin::User + } else { + crate::cloud_sync_e2ee_store::gate::ChangeOrigin::LocalOnly + }; + let json = serde_json::to_vec_pretty(&value) + .map_err(|_| persistence_error("encode preferences"))?; + atomic_write(&self.path, &json)?; + *state = next; + Ok((result, origin)) + }) } pub fn set_preserving_current_style_preferences( &self, mut preferences: UserPreferences, ) -> Result<(), BackendError> { - let mut state = self - .state - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - preferences.preserve_style_preferences_from(&state); - let json = serde_json::to_vec_pretty(&preferences) - .map_err(|_| persistence_error("encode preferences"))?; - atomic_write(&self.path, &json)?; - *state = preferences; - Ok(()) + self.update(|current| { + preferences.preserve_style_preferences_from(current); + *current = preferences; + }) } } diff --git a/openless-all/app/crates/openless-core/src/qa_service.rs b/openless-all/app/crates/openless-core/src/qa_service.rs index ab230df6..c7f5c733 100644 --- a/openless-all/app/crates/openless-core/src/qa_service.rs +++ b/openless-all/app/crates/openless-core/src/qa_service.rs @@ -24,6 +24,7 @@ struct QaState { enum QaSubmission { Text(String), + ScopedText { text: String, expected_session: Option }, Captured(QaInput), SelectionEdit { selection_voice_session_id: SessionId, @@ -45,6 +46,7 @@ pub struct QaService { persistence: Option>, selection_voice: Option>, voice_sessions: Arc, + runtime_work: Arc, } pub(crate) struct QaPersistence { @@ -77,6 +79,7 @@ impl QaService { host_actions, events: Arc::new(Mutex::new(None)), state: Arc::new(Mutex::new(QaState::default())), + runtime_work: Arc::new(crate::voice_session::RuntimeActivityGate::default()), presentation: Arc::new(Mutex::new(())), persistence: None, selection_voice: None, @@ -96,6 +99,7 @@ impl QaService { host_actions, events: Arc::new(Mutex::new(None)), state: Arc::new(Mutex::new(QaState::default())), + runtime_work: Arc::new(crate::voice_session::RuntimeActivityGate::default()), presentation: Arc::new(Mutex::new(())), persistence: Some(Arc::new(persistence)), selection_voice: Some(selection_voice), @@ -103,6 +107,20 @@ impl QaService { } } + fn begin_runtime_work( + &self, + ) -> Result { + let state = self.state.lock().expect("QA state lock poisoned"); + if matches!( + state.snapshot.phase, + QaPhase::Recording | QaPhase::Thinking | QaPhase::AwaitingApproval + ) { + Ok(self.runtime_work.existing_work()) + } else { + self.runtime_work.acquire() + } + } + fn progress_sink(&self) -> Arc { Arc::new(QaServiceProgress { state: Arc::clone(&self.state), @@ -166,6 +184,7 @@ impl QaService { } async fn begin_recording(&self) -> Result<(), BackendError> { + let _runtime = self.begin_runtime_work()?; let session_id = SessionId::new(); { let _presentation = self @@ -218,14 +237,15 @@ impl QaService { } async fn finish_recording(&self, session_id: SessionId) -> Result<(), BackendError> { - { + let _runtime = { let mut state = self.state.lock().expect("QA state lock poisoned"); // Validate the callback's generation and claim the finish under // one lock. A delayed silence event cannot toggle a completed turn // back on, stop its successor, or compete with a manual stop. ensure_current_phase(&state.snapshot, session_id, QaPhase::Recording)?; state.snapshot.phase = QaPhase::Thinking; - } + self.runtime_work.existing_work() + }; self.publish_snapshot(QaStateKind::Loading, Some((session_id, QaPhase::Thinking))); let input = match self.runtime.finish_recording(session_id).await { @@ -261,8 +281,14 @@ impl QaService { } async fn submit_inner(&self, submission: QaSubmission) -> Result<(), BackendError> { + let _runtime = self.begin_runtime_work()?; + let expected_session = match &submission { + QaSubmission::ScopedText { expected_session, .. } => Some(*expected_session), + _ => None, + }; let text = match &submission { QaSubmission::Text(text) => text, + QaSubmission::ScopedText { text, .. } => text, QaSubmission::Captured(input) => &input.text, QaSubmission::SelectionEdit { instruction, .. } => instruction, } @@ -280,6 +306,9 @@ impl QaService { let previous = { let mut state = self.state.lock().expect("QA state lock poisoned"); ensure_qa_idle(&state.snapshot)?; + if expected_session.is_some_and(|expected| expected != state.snapshot.session_id) { + return Err(BackendError::new(BackendErrorCode::InvalidState, "qa_context_changed")); + } let previous = state.snapshot.clone(); let conversation_id = state.snapshot.conversation_id.unwrap_or(session_id); state.snapshot.phase = QaPhase::Thinking; @@ -305,7 +334,7 @@ impl QaService { } let prepared = match submission { - QaSubmission::Text(_) => self.runtime.prepare_text(session_id, text).await, + QaSubmission::Text(_) | QaSubmission::ScopedText { .. } => self.runtime.prepare_text(session_id, text).await, QaSubmission::Captured(mut input) => { input.text = text; self.runtime.prepare_captured_text(session_id, input).await @@ -556,6 +585,21 @@ impl QaService { &self, requested_session_id: Option, clear: bool, + ) -> Result<(), BackendError> { + let service = self.clone(); + self.runtime_work + .cleanup(Box::pin(async move { + service + .cancel_inner_owned(requested_session_id, clear) + .await + })) + .await + } + + async fn cancel_inner_owned( + &self, + requested_session_id: Option, + clear: bool, ) -> Result<(), BackendError> { let (runtime_session_id, conversation_id, host_result) = { let _presentation = self @@ -631,7 +675,11 @@ impl QaService { } async fn cancel_runtime_best_effort(&self, session_id: SessionId) { - if let Err(error) = self.runtime.cancel(session_id).await { + if let Err(error) = self + .runtime_work + .cleanup(self.runtime.cancel(session_id)) + .await + { log::warn!("failed to release QA runtime session after an error: {error}"); } } @@ -659,6 +707,23 @@ impl QaService { } impl QaApi for QaService { + fn bind_runtime_restore_guard( + &self, + guard: crate::domains::RuntimeRestoreGuard, + spawner: Arc, + ) -> Result<(), BackendError> { + self.runtime_work.bind(guard, spawner) + } + + fn runtime_restore_idle(&self) -> bool { + self.state.lock().is_ok_and(|state| { + matches!( + state.snapshot.phase, + QaPhase::Idle | QaPhase::Completed | QaPhase::Cancelled | QaPhase::Failed + ) && self.runtime_work.runtime_restore_idle() + }) + } + fn bind_event_publisher(&self, publisher: BackendEventPublisher) { *self .events @@ -720,7 +785,7 @@ impl QaApi for QaService { error: BackendError, ) -> BoxFuture<'static, Result<(), BackendError>> { let service = self.clone(); - Box::pin(async move { + self.runtime_work.cleanup(Box::pin(async move { { let state = service.state.lock().expect("QA state lock poisoned"); ensure_current_phase(&state.snapshot, session_id, QaPhase::Recording)?; @@ -728,7 +793,7 @@ impl QaApi for QaService { service.fail_if_current(session_id, &error); service.voice_sessions.release(session_id); service.runtime.cancel(session_id).await - }) + })) } fn stop_recording( @@ -744,6 +809,11 @@ impl QaApi for QaService { Box::pin(async move { service.submit_text_inner(text).await }) } + fn submit_text_in_context(&self, text: String, expected_session: Option) -> BoxFuture<'static, Result<(), BackendError>> { + let service = self.clone(); + Box::pin(async move { service.submit_inner(QaSubmission::ScopedText { text, expected_session }).await }) + } + fn submit_captured_text(&self, input: QaInput) -> BoxFuture<'static, Result<(), BackendError>> { let service = self.clone(); Box::pin(async move { service.submit_inner(QaSubmission::Captured(input)).await }) @@ -1067,3 +1137,281 @@ fn publish_qa_snapshot_impl( )), ); } + +#[cfg(test)] +mod runtime_restore_tests { + use super::*; + use crate::domains::QaTurnResult; + use std::sync::atomic::{AtomicBool, AtomicUsize}; + + #[derive(Clone, Default)] + struct Runtime { + calls: Arc, + block_prepare: Arc, + fail_prepare: Arc, + prepare_entered: Arc, + prepare_release: Arc, + block_cancel: Arc, + cancel_entered: Arc, + cancel_release: Arc, + cancel_done: Arc, + } + impl QaRuntimeAdapter for Runtime { + fn prepare_text( + &self, + _: SessionId, + text: String, + ) -> BoxFuture<'static, Result> { + let this = self.clone(); + Box::pin(async move { + this.calls.fetch_add(1, Ordering::SeqCst); + if this.fail_prepare.load(Ordering::SeqCst) { + return Err(BackendError::new( + BackendErrorCode::Provider, + "fixture prepare failure", + )); + } + if this.block_prepare.load(Ordering::SeqCst) { + this.prepare_entered.notify_one(); + this.prepare_release.notified().await; + } + Ok(QaInput { + text, + selection_text: None, + selection_source_app: None, + }) + }) + } + fn start_recording( + &self, + _: SessionId, + _: Arc, + ) -> BoxFuture<'static, Result<(), BackendError>> { + self.calls.fetch_add(1, Ordering::SeqCst); + Box::pin(async { Ok(()) }) + } + fn finish_recording( + &self, + _: SessionId, + ) -> BoxFuture<'static, Result> { + Box::pin(async { + Ok(QaInput { + text: "question".into(), + selection_text: None, + selection_source_app: None, + }) + }) + } + fn answer( + &self, + _: QaTurnRequest, + _: Arc, + ) -> BoxFuture<'static, Result> { + Box::pin(async { + Ok(QaTurnResult { + answer: "answer".into(), + }) + }) + } + fn cancel(&self, _: SessionId) -> BoxFuture<'static, Result<(), BackendError>> { + let this = self.clone(); + Box::pin(async move { + if this.block_cancel.load(Ordering::SeqCst) { + this.cancel_entered.notify_one(); + this.cancel_release.notified().await; + } + this.cancel_done.notify_one(); + Ok(()) + }) + } + } + fn service(runtime: Runtime, restoring: Arc) -> QaService { + let service = QaService::new(Arc::new(runtime), Arc::new(crate::ports::NoopHostActions)); + service.bind_event_publisher(BackendEventPublisher::new(Arc::new( + crate::events::EventBus::new(32), + ))); + service + .bind_runtime_restore_guard( + Arc::new(move || { + if restoring.load(Ordering::Acquire) { + Err(BackendError::new(BackendErrorCode::Busy, "restoring")) + } else { + Ok(()) + } + }), + Arc::new(crate::config::TokioTaskSpawner), + ) + .unwrap(); + service + } + + #[tokio::test] + async fn runtime_restore_rejects_qa_text_and_voice_before_host_work_but_keeps_snapshot_readable( + ) { + let runtime = Runtime::default(); + let flag = Arc::new(AtomicBool::new(true)); + let service = service(runtime.clone(), flag.clone()); + assert_eq!( + service + .submit_text("question".into()) + .await + .unwrap_err() + .code, + BackendErrorCode::Busy + ); + assert_eq!( + service.toggle_recording().await.unwrap_err().code, + BackendErrorCode::Busy + ); + assert_eq!(runtime.calls.load(Ordering::SeqCst), 0); + assert_eq!(service.snapshot().await.unwrap().phase, QaPhase::Idle); + assert!(service.runtime_restore_idle()); + flag.store(false, Ordering::Release); + service.submit_text("question".into()).await.unwrap(); + assert_eq!(service.snapshot().await.unwrap().phase, QaPhase::Completed); + assert!(service.runtime_restore_idle()); + } + + #[tokio::test] + async fn runtime_restore_qa_text_lease_does_not_take_the_exclusive_voice_slot() { + let runtime = Runtime::default(); + runtime.block_prepare.store(true, Ordering::SeqCst); + let flag = Arc::new(AtomicBool::new(false)); + let service = service(runtime.clone(), flag.clone()); + let text = tokio::spawn(service.submit_text("question".into())); + runtime.prepare_entered.notified().await; + assert!(!service.runtime_restore_idle()); + let voice = SessionId::new(); + service + .voice_sessions + .acquire(voice, crate::voice_session::VoiceSessionKind::Dictation) + .unwrap(); + service.voice_sessions.release(voice); + flag.store(true, Ordering::Release); + assert_eq!( + service + .submit_text("another".into()) + .await + .unwrap_err() + .code, + BackendErrorCode::Busy + ); + runtime.prepare_release.notify_one(); + text.await.unwrap().unwrap(); + assert!( + service.runtime_restore_idle(), + "an attempted restore must not break the accepted turn" + ); + } + + #[tokio::test] + async fn runtime_restore_qa_cancel_waiter_drop_keeps_cleanup_busy_until_drained() { + let runtime = Runtime::default(); + runtime.block_cancel.store(true, Ordering::SeqCst); + let service = service(runtime.clone(), Arc::new(AtomicBool::new(false))); + service.toggle_recording().await.unwrap(); + let cancel = tokio::spawn(service.cancel(None)); + runtime.cancel_entered.notified().await; + assert_eq!(service.snapshot().await.unwrap().phase, QaPhase::Cancelled); + assert!(!service.runtime_restore_idle()); + cancel.abort(); + assert!(cancel.await.unwrap_err().is_cancelled()); + assert!(!service.runtime_restore_idle()); + runtime.cancel_release.notify_one(); + runtime.cancel_done.notified().await; + for _ in 0..4 { + tokio::task::yield_now().await; + } + assert!(service.runtime_restore_idle()); + } + + #[test] + fn runtime_restore_qa_probe_cannot_pass_between_check_and_claim() { + let flag = Arc::new(AtomicBool::new(false)); + let service = QaService::new( + Arc::new(Runtime::default()), + Arc::new(crate::ports::NoopHostActions), + ); + let (checked, receive_checked) = std::sync::mpsc::channel(); + let (resume, receive_resume) = std::sync::mpsc::channel(); + let receive_resume = Mutex::new(receive_resume); + let check_flag = flag.clone(); + service.bind_event_publisher(BackendEventPublisher::new(Arc::new( + crate::events::EventBus::new(32), + ))); + service + .bind_runtime_restore_guard( + Arc::new(move || { + assert!(!check_flag.load(Ordering::Acquire)); + checked.send(()).unwrap(); + receive_resume.lock().unwrap().recv().unwrap(); + Ok(()) + }), + Arc::new(crate::config::TokioTaskSpawner), + ) + .unwrap(); + let start = std::thread::spawn({ + let service = service.clone(); + move || { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() + .block_on(service.toggle_recording()) + } + }); + receive_checked + .recv_timeout(std::time::Duration::from_secs(2)) + .unwrap(); + flag.store(true, Ordering::Release); + let probe = std::thread::spawn({ + let service = service.clone(); + move || service.runtime_restore_idle() + }); + resume.send(()).unwrap(); + start.join().unwrap().unwrap(); + assert!(!probe.join().unwrap()); + } + #[tokio::test] + async fn runtime_restore_error_cleanup_survives_dropping_the_failed_submit_waiter() { + let runtime = Runtime::default(); + runtime.fail_prepare.store(true, Ordering::SeqCst); + runtime.block_cancel.store(true, Ordering::SeqCst); + let service = service(runtime.clone(), Arc::new(AtomicBool::new(false))); + let submitting = tokio::spawn(service.submit_text("fixture".into())); + runtime.cancel_entered.notified().await; + assert_eq!(service.snapshot().await.unwrap().phase, QaPhase::Failed); + submitting.abort(); + assert!(submitting.await.unwrap_err().is_cancelled()); + assert!( + !service.runtime_restore_idle(), + "error cleanup still owns native runtime work" + ); + runtime.cancel_release.notify_one(); + runtime.cancel_done.notified().await; + for _ in 0..4 { + tokio::task::yield_now().await; + } + assert!(service.runtime_restore_idle()); + } + #[tokio::test] + async fn runtime_restore_cancel_return_does_not_release_an_inflight_text_reader() { + let runtime = Runtime::default(); + runtime.block_prepare.store(true, Ordering::SeqCst); + let service = service(runtime.clone(), Arc::new(AtomicBool::new(false))); + let question = tokio::spawn(service.submit_text("fixture".into())); + runtime.prepare_entered.notified().await; + service.cancel(None).await.unwrap(); + assert_eq!(service.snapshot().await.unwrap().phase, QaPhase::Cancelled); + assert!( + !service.runtime_restore_idle(), + "old context preparation has not drained" + ); + runtime.prepare_release.notify_one(); + assert_eq!( + question.await.unwrap().unwrap_err().code, + BackendErrorCode::Cancelled + ); + assert!(service.runtime_restore_idle()); + } +} diff --git a/openless-all/app/crates/openless-core/src/selection_service.rs b/openless-all/app/crates/openless-core/src/selection_service.rs index 9bee9224..e205d037 100644 --- a/openless-all/app/crates/openless-core/src/selection_service.rs +++ b/openless-all/app/crates/openless-core/src/selection_service.rs @@ -55,6 +55,7 @@ struct SelectionServiceInner { activity: Arc, credential_store: Arc, state: RwLock, + runtime_work: Arc, } pub(crate) struct SelectionService { @@ -97,12 +98,41 @@ impl SelectionService { activity: dependencies.activity, credential_store: dependencies.credential_store, state: RwLock::new(SelectionState::default()), + runtime_work: Arc::new(crate::voice_session::RuntimeActivityGate::default()), }), } } } impl SelectionServiceInner { + fn begin_runtime_work( + &self, + ) -> Result { + let state = self.state.write().expect("selection state lock poisoned"); + if matches!( + state.snapshot.phase, + SelectionPhase::Capturing | SelectionPhase::Preview | SelectionPhase::Applying + ) || state.reverting + { + Ok(self.runtime_work.existing_work()) + } else { + self.runtime_work.acquire() + } + } + + async fn cancel_runtime_best_effort(&self, session_id: SessionId) { + let polisher = Arc::clone(&self.polisher); + let runtime = Arc::clone(&self.runtime); + let _ = self + .runtime_work + .cleanup(Box::pin(async move { + let _ = polisher.cancel(session_id).await; + let _ = runtime.cancel(session_id).await; + Ok(()) + })) + .await; + } + fn hide_preview(&self) { if let Err(error) = self.host_actions.request(HostAction::HideSelectionPreview) { log::warn!("failed to hide selection preview: {error}"); @@ -581,6 +611,27 @@ impl SelectionServiceInner { } impl SelectionApi for SelectionService { + fn bind_runtime_restore_guard( + &self, + guard: crate::domains::RuntimeRestoreGuard, + spawner: Arc, + ) -> Result<(), BackendError> { + self.inner.runtime_work.bind(guard, spawner) + } + + fn runtime_restore_idle(&self) -> bool { + self.inner.state.read().is_ok_and(|state| { + matches!( + state.snapshot.phase, + SelectionPhase::Idle + | SelectionPhase::Completed + | SelectionPhase::Cancelled + | SelectionPhase::Failed + ) && !state.reverting + && self.inner.runtime_work.runtime_restore_idle() + }) + } + fn snapshot(&self) -> BoxFuture<'static, Result> { let inner = Arc::clone(&self.inner); Box::pin(async move { @@ -599,6 +650,7 @@ impl SelectionApi for SelectionService { ) -> BoxFuture<'static, Result> { let inner = Arc::clone(&self.inner); Box::pin(async move { + let _runtime = inner.begin_runtime_work()?; let session_id = inner.begin(&request)?; let result = async { let capture = inner @@ -695,8 +747,7 @@ impl SelectionApi for SelectionService { } .await; if result.is_err() && inner.fail_if_active(session_id) { - let _ = inner.polisher.cancel(session_id).await; - let _ = inner.runtime.cancel(session_id).await; + inner.cancel_runtime_best_effort(session_id).await; inner.hide_preview(); } result @@ -710,6 +761,7 @@ impl SelectionApi for SelectionService { ) -> BoxFuture<'static, Result<(), BackendError>> { let inner = Arc::clone(&self.inner); Box::pin(async move { + let _runtime = inner.begin_runtime_work()?; let (source_text, replacement_text) = inner.applying_text(session_id, text)?; let replacement_text = inner.corrected_replacement(session_id, replacement_text)?; let result = inner @@ -727,8 +779,7 @@ impl SelectionApi for SelectionService { // 平台错误(焦点恢复 / 目标复核抖动)保持 preview 可重试—— // 否则窗口被隐藏、busy 卡死,表现为「点确认没反应」。 if inner.settle_confirm_failure(session_id, &error) { - let _ = inner.polisher.cancel(session_id).await; - let _ = inner.runtime.cancel(session_id).await; + inner.cancel_runtime_best_effort(session_id).await; inner.hide_preview(); } Err(error) @@ -742,7 +793,7 @@ impl SelectionApi for SelectionService { session_id: Option, ) -> BoxFuture<'static, Result<(), BackendError>> { let inner = Arc::clone(&self.inner); - Box::pin(async move { + self.inner.runtime_work.cleanup(Box::pin(async move { let active_session = { let mut state = inner.state.write().expect("selection state lock poisoned"); let Some(active_session) = state.snapshot.session_id else { @@ -771,12 +822,13 @@ impl SelectionApi for SelectionService { let runtime_result = inner.runtime.cancel(active_session).await; polish_result?; runtime_result - }) + })) } fn revert(&self, session_id: SessionId) -> BoxFuture<'static, Result<(), BackendError>> { let inner = Arc::clone(&self.inner); Box::pin(async move { + let _runtime = inner.begin_runtime_work()?; inner.begin_revert(session_id)?; match inner.runtime.revert(session_id).await { Ok(outcome) => inner.finish_revert(session_id, outcome), diff --git a/openless-all/app/crates/openless-core/src/selection_voice_service.rs b/openless-all/app/crates/openless-core/src/selection_voice_service.rs index ebe2d427..c7faea4f 100644 --- a/openless-all/app/crates/openless-core/src/selection_voice_service.rs +++ b/openless-all/app/crates/openless-core/src/selection_voice_service.rs @@ -1,5 +1,6 @@ +use std::collections::HashMap; use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::{Arc, RwLock, Weak}; +use std::sync::{Arc, Mutex, RwLock, Weak}; use futures_util::future::BoxFuture; @@ -134,6 +135,8 @@ pub(crate) struct SelectionVoiceService { voice_sessions: Arc, qa: Arc>>>, auto_press_at: Arc>>, + runtime_work: Arc, + applying_work: Arc>>, } struct SelectionVoiceWorkflow { @@ -197,6 +200,29 @@ impl SelectionVoiceService { voice_sessions, qa: Arc::new(RwLock::new(None)), auto_press_at: Arc::new(RwLock::new(None)), + runtime_work: Arc::new(crate::voice_session::RuntimeActivityGate::default()), + applying_work: Arc::new(Mutex::new(HashMap::new())), + } + } + + fn begin_runtime_work( + &self, + ) -> Result { + let state = self + .state + .write() + .expect("selection voice state lock poisoned"); + if matches!( + state.phase, + SelectionVoicePhase::Recording + | SelectionVoicePhase::Processing + | SelectionVoicePhase::AwaitingIntent + | SelectionVoicePhase::Preview + | SelectionVoicePhase::Applying + ) { + Ok(self.runtime_work.existing_work()) + } else { + self.runtime_work.acquire() } } @@ -219,6 +245,7 @@ impl SelectionVoiceService { capture: SelectionCapture, phase: SelectionVoicePhase, ) -> Result { + let _runtime = self.begin_runtime_work()?; if capture.text.trim().is_empty() { return Err(BackendError::new( BackendErrorCode::InvalidArgument, @@ -618,6 +645,26 @@ impl SelectionVoicePersistence { } impl SelectionVoiceApi for SelectionVoiceService { + fn bind_runtime_restore_guard( + &self, + guard: crate::domains::RuntimeRestoreGuard, + spawner: Arc, + ) -> Result<(), BackendError> { + self.runtime_work.bind(guard, spawner) + } + + fn runtime_restore_idle(&self) -> bool { + self.state.read().is_ok_and(|state| { + matches!( + state.phase, + SelectionVoicePhase::Idle + | SelectionVoicePhase::Completed + | SelectionVoicePhase::Cancelled + | SelectionVoicePhase::Failed + ) && self.runtime_work.runtime_restore_idle() + }) + } + fn bind_qa(&self, qa: Weak) { *self .qa @@ -803,6 +850,7 @@ impl SelectionVoiceApi for SelectionVoiceService { ) -> BoxFuture<'static, Result> { let service = self.clone(); Box::pin(async move { + let _runtime = service.begin_runtime_work()?; { let state = service .state @@ -948,6 +996,7 @@ impl SelectionVoiceApi for SelectionVoiceService { ) -> BoxFuture<'static, Result> { let service = self.clone(); Box::pin(async move { + let _runtime = service.begin_runtime_work()?; let session_id = match &disposition { SelectionVoiceDisposition::AwaitingIntent { prompt } => prompt.session_id, SelectionVoiceDisposition::Question { session_id, .. } @@ -1006,6 +1055,7 @@ impl SelectionVoiceApi for SelectionVoiceService { ) -> BoxFuture<'static, Result> { let service = self.clone(); Box::pin(async move { + let _runtime = service.begin_runtime_work()?; let (selection, instruction) = { let state = service .state @@ -1067,6 +1117,7 @@ impl SelectionVoiceApi for SelectionVoiceService { ) -> BoxFuture<'static, Result> { let service = self.clone(); Box::pin(async move { + let _runtime = service.begin_runtime_work()?; let instruction = request.instruction.trim().to_string(); if instruction.is_empty() { return Err(BackendError::new( @@ -1279,6 +1330,7 @@ impl SelectionVoiceApi for SelectionVoiceService { owner_session_id: Option, text: String, ) -> Result { + let _runtime = self.begin_runtime_work()?; let replacement_text = self.persistence.corrected_text(text.trim().to_string()); if replacement_text.is_empty() { return Err(BackendError::new( @@ -1314,6 +1366,13 @@ impl SelectionVoiceApi for SelectionVoiceService { summary, source_app: selection.source_app.clone(), }; + // The native apply can outlive logical cancellation. Its ticket owns + // this lease until the Host reports completion, even if cancel clears + // the visible preview and the old ticket becomes stale. + self.applying_work + .lock() + .expect("selection voice apply work lock poisoned") + .insert(ticket.ticket_id, _runtime); state.applying_ticket = Some(ticket.clone()); state.apply_outcome = None; state.phase = SelectionVoicePhase::Applying; @@ -1335,8 +1394,15 @@ impl SelectionVoiceApi for SelectionVoiceService { let events = self.events.clone(); let persistence = Arc::clone(&self.persistence); let voice_sessions = Arc::clone(&self.voice_sessions); + let runtime_work = Arc::clone(&self.runtime_work); + let applying_work = Arc::clone(&self.applying_work); Box::pin(async move { + let _apply = applying_work + .lock() + .expect("selection voice apply work lock poisoned") + .remove(&ticket_id); let mut state = state.write().expect("selection voice state lock poisoned"); + let _runtime = runtime_work.existing_work(); let ticket = state .applying_ticket .as_ref() @@ -1414,7 +1480,7 @@ impl SelectionVoiceApi for SelectionVoiceService { let events = self.events.clone(); let polisher = self.workflow.polisher.clone(); let voice_sessions = Arc::clone(&self.voice_sessions); - Box::pin(async move { + self.runtime_work.cleanup(Box::pin(async move { let (active_session, snapshot, control) = { let mut state = state.write().expect("selection voice state lock poisoned"); let Some(active_session) = state.session_id else { @@ -1451,7 +1517,7 @@ impl SelectionVoiceApi for SelectionVoiceService { } } host_result - }) + })) } } diff --git a/openless-all/app/crates/openless-core/src/style_pack_store.rs b/openless-all/app/crates/openless-core/src/style_pack_store.rs index 767cfdb1..c75f7b87 100644 --- a/openless-all/app/crates/openless-core/src/style_pack_store.rs +++ b/openless-all/app/crates/openless-core/src/style_pack_store.rs @@ -7,7 +7,7 @@ use std::path::{Path, PathBuf}; use std::sync::Mutex; use crate::errors::{BackendError, BackendErrorCode}; -use crate::persistence::{atomic_write, persistence_error, read_or_default}; +use crate::persistence::{atomic_write, persistence_error}; use crate::shared_types::UserPreferences; use crate::style_pack_archive::{ cleanup_style_pack_asset_dir, persist_style_pack_icon, read_style_pack_archive, @@ -26,6 +26,18 @@ pub struct StylePackStore { state: Mutex>, } +/// New asset paths have no live references until the index commits. Keep them only +/// on a confirmed or uncertain index commit; all earlier errors clean them up. +#[derive(Default)] +struct UncommittedSyncIcons(Vec); +impl Drop for UncommittedSyncIcons { + fn drop(&mut self) { + for path in &self.0 { + let _ = fs::remove_file(path); + } + } +} + impl StylePackStore { pub fn at_data_dir(data_dir: impl AsRef) -> Result { let data_dir = data_dir.as_ref(); @@ -65,20 +77,45 @@ impl StylePackStore { asset_root: PathBuf, preferences: Option<&UserPreferences>, ) -> Result { - let mut packs: Vec = read_or_default(&path)?; - let mut changed = preferences - .map(|preferences| migrate_style_packs_from_preferences(&mut packs, preferences)) - .unwrap_or(false); - changed |= reconcile_builtin_packs(&mut packs) | ensure_at_least_one_enabled(&mut packs); - sort_packs(&mut packs); - if changed { - write_packs(&path, &packs)?; + let operation_path = path.clone(); + if crate::cloud_sync_e2ee_store::gate::recovery_pending_for_path(&path) { + let packs = crate::persistence::read_lossless_rows(&path, &[])?; + return Ok(Self { + path: Some(path), + asset_root: Some(asset_root), + state: Mutex::new(packs), + }); } - Ok(Self { - path: Some(path), - asset_root: Some(asset_root), - state: Mutex::new(packs), - }) + crate::cloud_sync_e2ee_store::gate::with_registered_mutation( + &operation_path, + crate::cloud_sync_e2ee_store::gate::ChangeOrigin::User, + || { + let mut packs: Vec = crate::persistence::read_lossless_rows(&path, &[])?; + if crate::cloud_sync_e2ee_store::gate::recovery_pending_for_path(&path) { + return Ok(Self { + path: Some(path), + asset_root: Some(asset_root), + state: Mutex::new(packs), + }); + } + let mut changed = preferences + .map(|preferences| { + migrate_style_packs_from_preferences(&mut packs, preferences) + }) + .unwrap_or(false); + changed |= + reconcile_builtin_packs(&mut packs) | ensure_at_least_one_enabled(&mut packs); + sort_packs(&mut packs); + if changed { + write_packs(&path, &packs)?; + } + Ok(Self { + path: Some(path), + asset_root: Some(asset_root), + state: Mutex::new(packs), + }) + }, + ) } pub fn in_memory() -> Self { @@ -95,6 +132,136 @@ impl StylePackStore { Ok(self.lock()?.clone()) } + pub(crate) fn sync_snapshot( + &self, + permit: &crate::cloud_sync_e2ee_store::gate::ExclusivePermit, + ) -> Result, BackendError> { + let path = self + .path + .as_deref() + .ok_or_else(|| persistence_error("style pack persistence unavailable"))?; + crate::cloud_sync_e2ee_store::gate::require_exclusive(path, permit)?; + self.sync_snapshot_readonly() + } + + pub(crate) fn sync_snapshot_readonly( + &self, + ) -> Result, BackendError> { + let path = self + .path + .as_deref() + .ok_or_else(|| persistence_error("style pack persistence unavailable"))?; + let mut state = self + .state + .lock() + .map_err(|_| persistence_error("style pack state poisoned"))?; + let packs: Vec = crate::persistence::read_lossless_rows(path, &[])?; + *state = packs.clone(); + packs + .iter() + .map(|pack| { + let icon = match self.icon_data_url_for_pack(pack)? { + Some(data) => { + let (mime, base64) = data + .strip_prefix("data:") + .and_then(|value| value.split_once(";base64,")) + .ok_or_else(|| persistence_error("invalid controlled icon response"))?; + Some(crate::cloud_sync_e2ee_documents::IconAsset { + mime: mime.into(), + base64: base64.into(), + }) + } + None if pack.icon_path.is_some() => { + return Err(persistence_error("registered style icon is missing")) + } + None => None, + }; + Ok(crate::cloud_sync_e2ee_documents::StylePackRecord { + pack: crate::cloud_sync_e2ee_documents::SecretJson::from_serializable(pack) + .map_err(|_| persistence_error("encode style pack snapshot"))?, + icon, + }) + }) + .collect() + } + + pub(crate) fn sync_replace_all( + &self, + records: &[crate::cloud_sync_e2ee_documents::StylePackRecord], + permit: &crate::cloud_sync_e2ee_store::gate::ExclusivePermit, + ) -> Result<(), BackendError> { + let path = self + .path + .as_deref() + .ok_or_else(|| persistence_error("style pack persistence unavailable"))?; + let asset_root = self + .asset_root + .as_deref() + .ok_or_else(|| persistence_error("style pack assets unavailable"))?; + crate::cloud_sync_e2ee_store::gate::require_exclusive(path, permit)?; + let mut state = self + .state + .lock() + .map_err(|_| persistence_error("style pack state poisoned"))?; + let mut prepared = Vec::with_capacity(records.len()); + // Validate all resource bytes before allocating paths or mutating the index. + for record in records { + let mut pack: StylePack = serde_json::from_value(record.pack.expose().clone()) + .map_err(|_| persistence_error("decode restored style pack"))?; + pack.icon_path = None; + pack.active = false; + if let Some(icon) = &record.icon { + crate::cloud_sync_e2ee_documents::validate_icon(icon) + .map_err(|_| persistence_error("validate restored icon"))?; + } + prepared.push((pack, record.icon.as_ref())); + } + let mut restored = Vec::with_capacity(prepared.len()); + let mut created = UncommittedSyncIcons::default(); + for (mut pack, icon) in prepared { + if let Some(icon) = icon { + let expected = format!("data:{};base64,{}", icon.mime, icon.base64); + if let Some(existing) = state.iter().find(|old| old.id == pack.id) { + if self.icon_data_url_for_pack(existing)?.as_deref() == Some(expected.as_str()) + { + pack.icon_path = existing.icon_path.clone(); + } + } + if pack.icon_path.is_none() { + let extension = match icon.mime.as_str() { + "image/png" => "png", + "image/jpeg" => "jpg", + "image/webp" => "webp", + _ => return Err(persistence_error("unsupported restored icon")), + }; + let destination = asset_root.join(format!( + "sync-{}.{}", + uuid::Uuid::new_v4().simple(), + extension + )); + let bytes = base64::engine::general_purpose::STANDARD + .decode(&icon.base64) + .map_err(|_| persistence_error("decode restored icon"))?; + created.0.push(destination.clone()); + crate::persistence::atomic_write_for_sync(&destination, &bytes, permit)?; + pack.icon_path = Some(destination.to_string_lossy().into_owned()); + } + } + restored.push(pack); + } + let bytes = serde_json::to_vec_pretty(&restored) + .map_err(|_| persistence_error("encode restored styles"))?; + if let Err(error) = crate::persistence::atomic_write_for_sync(path, &bytes, permit) { + if error.code == BackendErrorCode::OutcomeUnknown { + created.0.clear(); + } + return Err(error); + } + created.0.clear(); + *state = restored; + Ok(()) + } + pub(crate) fn cloud_sync_access( &self, ) -> Result<(std::sync::MutexGuard<'_, Vec>, &Path, &Path), BackendError> { @@ -143,49 +310,65 @@ impl StylePackStore { } pub fn create(&self, mut pack: StylePack) -> Result { - let mut packs = self.lock()?; - let requested = if pack.id.trim().is_empty() { - format!("imported-{}", uuid::Uuid::new_v4().simple()) - } else { - pack.id.clone() - }; - pack.id = unique_imported_id(&packs, &requested); - pack.name = required_text(&pack.name, "style pack name")?; - pack.kind = StylePackKind::Imported; - pack.active = false; - pack.enabled = true; - let now = chrono::Utc::now().to_rfc3339(); - pack.created_at = Some(now.clone()); - pack.updated_at = Some(now); - pack.version = normalized_version(&pack.version); - pack.examples = normalized_examples(pack.examples); - pack.tags = normalized_tags(&pack.tags); - packs.push(pack.clone()); - self.persist_locked(&packs)?; - Ok(pack) + crate::cloud_sync_e2ee_store::gate::with_optional_mutation( + self.path.as_deref(), + crate::cloud_sync_e2ee_store::gate::ChangeOrigin::User, + || { + let mut live = self.lock_for_mutation()?; + let mut packs = live.clone(); + let requested = if pack.id.trim().is_empty() { + format!("imported-{}", uuid::Uuid::new_v4().simple()) + } else { + pack.id.clone() + }; + pack.id = unique_imported_id(&packs, &requested); + pack.name = required_text(&pack.name, "style pack name")?; + pack.kind = StylePackKind::Imported; + pack.active = false; + pack.enabled = true; + let now = chrono::Utc::now().to_rfc3339(); + pack.created_at = Some(now.clone()); + pack.updated_at = Some(now); + pack.version = normalized_version(&pack.version); + pack.examples = normalized_examples(pack.examples); + pack.tags = normalized_tags(&pack.tags); + packs.push(pack.clone()); + self.persist_locked(&packs)?; + *live = packs; + Ok(pack) + }, + ) } pub fn update(&self, incoming: StylePack) -> Result { - let mut packs = self.lock()?; - let slot = packs - .iter_mut() - .find(|pack| pack.id == incoming.id) - .ok_or_else(|| not_found(&incoming.id))?; - slot.name = required_text(&incoming.name, "style pack name")?; - slot.description = incoming.description.trim().to_string(); - slot.author = normalized_optional(incoming.author); - slot.version = normalized_version(&incoming.version); - slot.selection_prompt = incoming.selection_prompt; - slot.voice_edit_prompt = incoming.voice_edit_prompt; - slot.prompt = incoming.prompt; - slot.examples = normalized_examples(incoming.examples); - slot.tags = normalized_tags(&incoming.tags); - slot.recommended_model = normalized_optional(incoming.recommended_model); - slot.compatible_app_version = normalized_optional(incoming.compatible_app_version); - slot.updated_at = Some(chrono::Utc::now().to_rfc3339()); - let updated = slot.clone(); - self.persist_locked(&packs)?; - Ok(updated) + crate::cloud_sync_e2ee_store::gate::with_optional_mutation( + self.path.as_deref(), + crate::cloud_sync_e2ee_store::gate::ChangeOrigin::User, + || { + let mut live = self.lock_for_mutation()?; + let mut packs = live.clone(); + let slot = packs + .iter_mut() + .find(|pack| pack.id == incoming.id) + .ok_or_else(|| not_found(&incoming.id))?; + slot.name = required_text(&incoming.name, "style pack name")?; + slot.description = incoming.description.trim().to_string(); + slot.author = normalized_optional(incoming.author); + slot.version = normalized_version(&incoming.version); + slot.selection_prompt = incoming.selection_prompt; + slot.voice_edit_prompt = incoming.voice_edit_prompt; + slot.prompt = incoming.prompt; + slot.examples = normalized_examples(incoming.examples); + slot.tags = normalized_tags(&incoming.tags); + slot.recommended_model = normalized_optional(incoming.recommended_model); + slot.compatible_app_version = normalized_optional(incoming.compatible_app_version); + slot.updated_at = Some(chrono::Utc::now().to_rfc3339()); + let updated = slot.clone(); + self.persist_locked(&packs)?; + *live = packs; + Ok(updated) + }, + ) } /// Save a PNG icon in this pack's owned asset directory, or clear it with `None`. @@ -197,76 +380,86 @@ impl StylePackStore { /// Returns an error for an unknown/invalid pack ID, an invalid PNG, an image /// over 64 KiB, an unavailable asset directory, or a failed filesystem write. pub fn update_icon(&self, id: &str, png: Option<&[u8]>) -> Result { - if id.is_empty() - || id == "." - || id == ".." - || !id - .bytes() - .all(|c| c.is_ascii_alphanumeric() || b"._-".contains(&c)) - { - return Err(invalid_icon("invalid style pack id")); - } - let mut packs = self.lock()?; - let index = packs - .iter() - .position(|pack| pack.id == id) - .ok_or_else(|| not_found(id))?; - let old_path = packs[index].icon_path.clone(); - let new_path = if let Some(bytes) = png { - if bytes.len() > MAX_ICON_BYTES { - return Err(invalid_icon("style pack icon exceeds 64 KiB")); - } - validate_icon_content("png", bytes).map_err(archive_error)?; - let root = self - .asset_root - .as_ref() - .filter(|root| !root.as_os_str().is_empty()) - .ok_or_else(|| invalid_icon("style pack asset root unavailable"))?; - fs::create_dir_all(root) - .map_err(|_| persistence_error("create style pack asset root"))?; - let root = root - .canonicalize() - .map_err(|_| persistence_error("resolve style pack asset root"))?; - let directory = root.join(id); - fs::create_dir_all(&directory) - .map_err(|_| persistence_error("create style pack icon directory"))?; - let directory = directory - .canonicalize() - .map_err(|_| persistence_error("resolve style pack icon directory"))?; - if !directory.starts_with(&root) { - return Err(invalid_icon( - "style pack icon directory is outside its asset root", - )); - } - // A new filename keeps the previous image valid until metadata commits. - let target = directory.join(format!("icon-{}.png", uuid::Uuid::new_v4().simple())); - atomic_write(&target, bytes)?; - Some(target) - } else { - None - }; - let mut next = packs.clone(); - next[index].icon_path = new_path - .as_ref() - .map(|path| path.to_string_lossy().into_owned()); - next[index].updated_at = Some(chrono::Utc::now().to_rfc3339()); - if let Err(error) = self.persist_locked(&next) { - if let Some(path) = new_path { - let _ = fs::remove_file(path); - } - return Err(error); - } - let saved = next[index].clone(); - *packs = next; - if let (Some(root), Some(old)) = (&self.asset_root, old_path) { - let old = Path::new(&old); - if let (Ok(owned), Some(parent)) = (root.join(id).canonicalize(), old.parent()) { - if parent.canonicalize().ok().as_ref() == Some(&owned) { - let _ = fs::remove_file(old); + crate::cloud_sync_e2ee_store::gate::with_optional_mutation( + self.path.as_deref(), + crate::cloud_sync_e2ee_store::gate::ChangeOrigin::User, + || { + if id.is_empty() + || id == "." + || id == ".." + || !id + .bytes() + .all(|c| c.is_ascii_alphanumeric() || b"._-".contains(&c)) + { + return Err(invalid_icon("invalid style pack id")); } - } - } - Ok(saved) + let mut packs = self.lock_for_mutation()?; + let index = packs + .iter() + .position(|pack| pack.id == id) + .ok_or_else(|| not_found(id))?; + let old_path = packs[index].icon_path.clone(); + let new_path = if let Some(bytes) = png { + if bytes.len() > MAX_ICON_BYTES { + return Err(invalid_icon("style pack icon exceeds 64 KiB")); + } + validate_icon_content("png", bytes).map_err(archive_error)?; + let root = self + .asset_root + .as_ref() + .filter(|root| !root.as_os_str().is_empty()) + .ok_or_else(|| invalid_icon("style pack asset root unavailable"))?; + fs::create_dir_all(root) + .map_err(|_| persistence_error("create style pack asset root"))?; + let root = root + .canonicalize() + .map_err(|_| persistence_error("resolve style pack asset root"))?; + let directory = root.join(id); + fs::create_dir_all(&directory) + .map_err(|_| persistence_error("create style pack icon directory"))?; + let directory = directory + .canonicalize() + .map_err(|_| persistence_error("resolve style pack icon directory"))?; + if !directory.starts_with(&root) { + return Err(invalid_icon( + "style pack icon directory is outside its asset root", + )); + } + // A new filename keeps the previous image valid until metadata commits. + let target = + directory.join(format!("icon-{}.png", uuid::Uuid::new_v4().simple())); + atomic_write(&target, bytes)?; + Some(target) + } else { + None + }; + let mut next = packs.clone(); + next[index].icon_path = new_path + .as_ref() + .map(|path| path.to_string_lossy().into_owned()); + next[index].updated_at = Some(chrono::Utc::now().to_rfc3339()); + if let Err(error) = self.persist_locked(&next) { + if error.code != BackendErrorCode::OutcomeUnknown { + if let Some(path) = new_path { + let _ = fs::remove_file(path); + } + } + return Err(error); + } + let saved = next[index].clone(); + *packs = next; + if let (Some(root), Some(old)) = (&self.asset_root, old_path) { + let old = Path::new(&old); + if let (Ok(owned), Some(parent)) = (root.join(id).canonicalize(), old.parent()) + { + if parent.canonicalize().ok().as_ref() == Some(&owned) { + let _ = fs::remove_file(old); + } + } + } + Ok(saved) + }, + ) } /// Read a pack icon as a PNG/JPEG/WebP data URL without exposing filesystem access. @@ -336,84 +529,128 @@ impl StylePackStore { origin_pack_id: Option, origin_author_login: Option, ) -> Result { - let mut packs = self.lock()?; - let slot = packs - .iter_mut() - .find(|pack| pack.id == id) - .ok_or_else(|| not_found(id))?; - slot.origin_pack_id = normalized_optional(origin_pack_id); - slot.origin_author_login = normalized_optional(origin_author_login); - slot.updated_at = Some(chrono::Utc::now().to_rfc3339()); - let updated = slot.clone(); - self.persist_locked(&packs)?; - Ok(updated) + crate::cloud_sync_e2ee_store::gate::with_optional_mutation( + self.path.as_deref(), + crate::cloud_sync_e2ee_store::gate::ChangeOrigin::User, + || { + let mut live = self.lock_for_mutation()?; + let mut packs = live.clone(); + let slot = packs + .iter_mut() + .find(|pack| pack.id == id) + .ok_or_else(|| not_found(id))?; + slot.origin_pack_id = normalized_optional(origin_pack_id); + slot.origin_author_login = normalized_optional(origin_author_login); + slot.updated_at = Some(chrono::Utc::now().to_rfc3339()); + let updated = slot.clone(); + self.persist_locked(&packs)?; + *live = packs; + Ok(updated) + }, + ) } pub fn set_enabled(&self, id: &str, enabled: bool) -> Result { - let mut packs = self.lock()?; - let index = packs - .iter() - .position(|pack| pack.id == id) - .ok_or_else(|| not_found(id))?; - packs[index].enabled = enabled; - packs[index].updated_at = Some(chrono::Utc::now().to_rfc3339()); - ensure_at_least_one_enabled(&mut packs); - let updated = packs[index].clone(); - self.persist_locked(&packs)?; - Ok(updated) + crate::cloud_sync_e2ee_store::gate::with_optional_mutation( + self.path.as_deref(), + crate::cloud_sync_e2ee_store::gate::ChangeOrigin::User, + || { + let mut live = self.lock_for_mutation()?; + let mut packs = live.clone(); + let index = packs + .iter() + .position(|pack| pack.id == id) + .ok_or_else(|| not_found(id))?; + packs[index].enabled = enabled; + packs[index].updated_at = Some(chrono::Utc::now().to_rfc3339()); + ensure_at_least_one_enabled(&mut packs); + let updated = packs[index].clone(); + self.persist_locked(&packs)?; + *live = packs; + Ok(updated) + }, + ) } pub fn reset_builtin(&self, id: &str) -> Result { - let mode = builtin_mode(id).ok_or_else(|| { - BackendError::new( - BackendErrorCode::InvalidArgument, - "style pack is not builtin", - ) - })?; - let mut packs = self.lock()?; - let index = packs - .iter() - .position(|pack| pack.id == id) - .ok_or_else(|| not_found(id))?; - let existing = &packs[index]; - let mut reset = builtin_style_pack_for_mode(mode); - reset.enabled = existing.enabled; - reset.created_at = existing.created_at.clone(); - reset.updated_at = Some(chrono::Utc::now().to_rfc3339()); - packs[index] = reset.clone(); - self.persist_locked(&packs)?; - Ok(reset) + crate::cloud_sync_e2ee_store::gate::with_optional_mutation( + self.path.as_deref(), + crate::cloud_sync_e2ee_store::gate::ChangeOrigin::User, + || { + let mode = builtin_mode(id).ok_or_else(|| { + BackendError::new( + BackendErrorCode::InvalidArgument, + "style pack is not builtin", + ) + })?; + let mut live = self.lock_for_mutation()?; + let mut packs = live.clone(); + let index = packs + .iter() + .position(|pack| pack.id == id) + .ok_or_else(|| not_found(id))?; + let existing = &packs[index]; + let mut reset = builtin_style_pack_for_mode(mode); + reset.enabled = existing.enabled; + reset.created_at = existing.created_at.clone(); + reset.updated_at = Some(chrono::Utc::now().to_rfc3339()); + packs[index] = reset.clone(); + self.persist_locked(&packs)?; + *live = packs; + Ok(reset) + }, + ) } pub fn remove_imported(&self, id: &str) -> Result<(), BackendError> { - let mut packs = self.lock()?; - let index = packs - .iter() - .position(|pack| pack.id == id) - .ok_or_else(|| not_found(id))?; - if packs[index].kind == StylePackKind::Builtin { - return Err(BackendError::new( - BackendErrorCode::InvalidArgument, - "builtin style pack cannot be deleted", - )); - } - let removed = packs.remove(index); - ensure_at_least_one_enabled(&mut packs); - self.persist_locked(&packs)?; - if let Some(asset_root) = &self.asset_root { - cleanup_style_pack_asset_dir(asset_root, &removed.id); - } - Ok(()) + crate::cloud_sync_e2ee_store::gate::with_optional_mutation( + self.path.as_deref(), + crate::cloud_sync_e2ee_store::gate::ChangeOrigin::User, + || { + let mut live = self.lock_for_mutation()?; + let mut packs = live.clone(); + let index = packs + .iter() + .position(|pack| pack.id == id) + .ok_or_else(|| not_found(id))?; + if packs[index].kind == StylePackKind::Builtin { + return Err(BackendError::new( + BackendErrorCode::InvalidArgument, + "builtin style pack cannot be deleted", + )); + } + let removed = packs.remove(index); + ensure_at_least_one_enabled(&mut packs); + self.persist_locked(&packs)?; + *live = packs; + if let Some(asset_root) = &self.asset_root { + cleanup_style_pack_asset_dir(asset_root, &removed.id); + } + Ok(()) + }, + ) } pub fn import_from_zip(&self, path: &Path) -> Result { - let parsed = read_style_pack_archive(path).map_err(archive_error)?; - self.import_parsed_archive(parsed) + crate::cloud_sync_e2ee_store::gate::with_optional_mutation( + self.path.as_deref(), + crate::cloud_sync_e2ee_store::gate::ChangeOrigin::User, + || { + let parsed = read_style_pack_archive(path).map_err(archive_error)?; + self.import_parsed_archive(parsed) + }, + ) } pub fn import_from_zip_bytes(&self, bytes: &[u8]) -> Result { - let parsed = read_style_pack_archive_bytes(bytes).map_err(archive_error)?; - self.import_parsed_archive(parsed) + crate::cloud_sync_e2ee_store::gate::with_optional_mutation( + self.path.as_deref(), + crate::cloud_sync_e2ee_store::gate::ChangeOrigin::User, + || { + let parsed = read_style_pack_archive_bytes(bytes).map_err(archive_error)?; + self.import_parsed_archive(parsed) + }, + ) } /// Imports a validated Marketplace archive while committing its remote @@ -426,10 +663,16 @@ impl StylePackStore { origin_pack_id: String, origin_author_login: Option, ) -> Result { - let mut parsed = read_style_pack_archive_bytes(bytes).map_err(archive_error)?; - parsed.manifest.origin_pack_id = Some(origin_pack_id); - parsed.manifest.origin_author_login = origin_author_login; - self.import_parsed_archive(parsed) + crate::cloud_sync_e2ee_store::gate::with_optional_mutation( + self.path.as_deref(), + crate::cloud_sync_e2ee_store::gate::ChangeOrigin::User, + || { + let mut parsed = read_style_pack_archive_bytes(bytes).map_err(archive_error)?; + parsed.manifest.origin_pack_id = Some(origin_pack_id); + parsed.manifest.origin_author_login = origin_author_login; + self.import_parsed_archive(parsed) + }, + ) } fn import_parsed_archive( @@ -437,7 +680,7 @@ impl StylePackStore { parsed: ParsedStylePackArchive, ) -> Result { let manifest = parsed.manifest; - let mut packs = self.lock()?; + let mut packs = self.lock_for_mutation()?; let pack_id = unique_imported_id(&packs, &manifest.id); let icon_path = match (parsed.icon, self.asset_root.as_deref()) { (Some(icon), Some(asset_root)) => { @@ -478,7 +721,7 @@ impl StylePackStore { let mut next = packs.clone(); next.insert(0, pack.clone()); if let Err(error) = self.persist_locked(&next) { - if pack.icon_path.is_some() { + if error.code != BackendErrorCode::OutcomeUnknown && pack.icon_path.is_some() { if let Some(asset_root) = &self.asset_root { cleanup_style_pack_asset_dir(asset_root, &pack.id); } @@ -568,6 +811,16 @@ impl StylePackStore { }) } + fn lock_for_mutation(&self) -> Result>, BackendError> { + let mut state = self.lock()?; + if let Some(path) = &self.path { + if path.exists() { + *state = crate::persistence::read_lossless_rows(path, &[])?; + } + } + Ok(state) + } + fn persist_locked(&self, packs: &[StylePack]) -> Result<(), BackendError> { match &self.path { Some(path) => write_packs(path, packs), diff --git a/openless-all/app/crates/openless-core/src/vocabulary.rs b/openless-all/app/crates/openless-core/src/vocabulary.rs index 6d822983..09a03016 100644 --- a/openless-all/app/crates/openless-core/src/vocabulary.rs +++ b/openless-all/app/crates/openless-core/src/vocabulary.rs @@ -6,7 +6,7 @@ use std::sync::Mutex; use chrono::Utc; use crate::errors::{BackendError, BackendErrorCode}; -use crate::persistence::{atomic_write, persistence_error, read_or_default}; +use crate::persistence::{atomic_write, persistence_error}; use crate::shared_types::LEARNED_VOCAB_NOTE; use crate::types::{DictionaryEntry, VocabPreset, VocabPresetStore}; @@ -81,7 +81,28 @@ impl DictionaryStore { pub fn list(&self) -> Result, BackendError> { let _guard = self.lock_store()?; - read_or_default(&self.path) + crate::persistence::read_lossless_rows(&self.path, &["notes", "hitCount"]) + } + + pub(crate) fn sync_snapshot( + &self, + permit: &crate::cloud_sync_e2ee_store::gate::ExclusivePermit, + ) -> Result, BackendError> { + crate::cloud_sync_e2ee_store::gate::require_exclusive(&self.path, permit)?; + let _guard = self.lock_store()?; + crate::persistence::read_lossless_rows(&self.path, &["notes", "hitCount"]) + } + + pub(crate) fn sync_replace_all( + &self, + records: &[DictionaryEntry], + permit: &crate::cloud_sync_e2ee_store::gate::ExclusivePermit, + ) -> Result<(), BackendError> { + crate::cloud_sync_e2ee_store::gate::require_exclusive(&self.path, permit)?; + let _guard = self.lock_store()?; + let bytes = serde_json::to_vec_pretty(records) + .map_err(|_| persistence_error("encode restored dictionary"))?; + crate::persistence::atomic_write_for_sync(&self.path, &bytes, permit) } /// Cloud restore locks every participating store before preparing or replacing any file. @@ -97,12 +118,18 @@ impl DictionaryStore { phrase: String, note: Option, ) -> Result { - let _guard = self.lock_store()?; - let mut entries = self.read_locked()?; - let entry = new_entry(phrase, note); - entries.insert(0, entry.clone()); - self.write_locked(&entries)?; - Ok(entry) + crate::cloud_sync_e2ee_store::gate::with_registered_mutation( + &self.path, + crate::cloud_sync_e2ee_store::gate::ChangeOrigin::User, + || { + let _guard = self.lock_store()?; + let mut entries = self.read_locked()?; + let entry = new_entry(phrase, note); + entries.insert(0, entry.clone()); + self.write_locked(&entries)?; + Ok(entry) + }, + ) } /// Learned entries are deduplicated and appended behind manual entries. @@ -111,111 +138,141 @@ impl DictionaryStore { phrase: String, note: Option, ) -> Result, BackendError> { - let phrase = phrase.trim().to_string(); - if phrase.is_empty() { - return Ok(None); - } - let _guard = self.lock_store()?; - let mut entries = self.read_locked()?; - if entries.iter().any(|entry| entry.phrase == phrase) { - return Ok(None); - } - let entry = new_entry(phrase, note); - entries.push(entry.clone()); - self.write_locked(&entries)?; - Ok(Some(entry)) + crate::cloud_sync_e2ee_store::gate::with_registered_mutation( + &self.path, + crate::cloud_sync_e2ee_store::gate::ChangeOrigin::User, + || { + let phrase = phrase.trim().to_string(); + if phrase.is_empty() { + return Ok(None); + } + let _guard = self.lock_store()?; + let mut entries = self.read_locked()?; + if entries.iter().any(|entry| entry.phrase == phrase) { + return Ok(None); + } + let entry = new_entry(phrase, note); + entries.push(entry.clone()); + self.write_locked(&entries)?; + Ok(Some(entry)) + }, + ) } pub fn remove(&self, id: &str) -> Result<(), BackendError> { - let _guard = self.lock_store()?; - let mut entries = self.read_locked()?; - let before = entries.len(); - entries.retain(|entry| entry.id != id); - if entries.len() != before { - self.write_locked(&entries)?; - } - Ok(()) + crate::cloud_sync_e2ee_store::gate::with_registered_mutation( + &self.path, + crate::cloud_sync_e2ee_store::gate::ChangeOrigin::User, + || { + let _guard = self.lock_store()?; + let mut entries = self.read_locked()?; + let before = entries.len(); + entries.retain(|entry| entry.id != id); + if entries.len() != before { + self.write_locked(&entries)?; + } + Ok(()) + }, + ) } pub fn set_enabled(&self, id: &str, enabled: bool) -> Result<(), BackendError> { - let _guard = self.lock_store()?; - let mut entries = self.read_locked()?; - let entry = entries - .iter_mut() - .find(|entry| entry.id == id) - .ok_or_else(|| { - BackendError::new( - BackendErrorCode::InvalidArgument, - "dictionary entry not found", - ) - })?; - if entry.enabled != enabled { - entry.enabled = enabled; - self.write_locked(&entries)?; - } - Ok(()) + crate::cloud_sync_e2ee_store::gate::with_registered_mutation( + &self.path, + crate::cloud_sync_e2ee_store::gate::ChangeOrigin::User, + || { + let _guard = self.lock_store()?; + let mut entries = self.read_locked()?; + let entry = entries + .iter_mut() + .find(|entry| entry.id == id) + .ok_or_else(|| { + BackendError::new( + BackendErrorCode::InvalidArgument, + "dictionary entry not found", + ) + })?; + if entry.enabled != enabled { + entry.enabled = enabled; + self.write_locked(&entries)?; + } + Ok(()) + }, + ) } /// Rename an entry in place so its id / hits / enabled state survive the edit. /// Empty phrases and phrases colliding with another entry are rejected. pub fn update_phrase(&self, id: &str, phrase: String) -> Result<(), BackendError> { - let phrase = phrase.trim().to_string(); - if phrase.is_empty() { - return Err(BackendError::new( - BackendErrorCode::InvalidArgument, - "dictionary phrase is empty", - )); - } - let _guard = self.lock_store()?; - let mut entries = self.read_locked()?; - if entries - .iter() - .any(|entry| entry.id != id && entry.phrase == phrase) - { - return Err(BackendError::new( - BackendErrorCode::InvalidArgument, - "dictionary phrase already exists", - )); - } - let entry = entries - .iter_mut() - .find(|entry| entry.id == id) - .ok_or_else(|| { - BackendError::new( - BackendErrorCode::InvalidArgument, - "dictionary entry not found", - ) - })?; - if entry.phrase != phrase { - entry.phrase = phrase; - self.write_locked(&entries)?; - } - Ok(()) + crate::cloud_sync_e2ee_store::gate::with_registered_mutation( + &self.path, + crate::cloud_sync_e2ee_store::gate::ChangeOrigin::User, + || { + let phrase = phrase.trim().to_string(); + if phrase.is_empty() { + return Err(BackendError::new( + BackendErrorCode::InvalidArgument, + "dictionary phrase is empty", + )); + } + let _guard = self.lock_store()?; + let mut entries = self.read_locked()?; + if entries + .iter() + .any(|entry| entry.id != id && entry.phrase == phrase) + { + return Err(BackendError::new( + BackendErrorCode::InvalidArgument, + "dictionary phrase already exists", + )); + } + let entry = entries + .iter_mut() + .find(|entry| entry.id == id) + .ok_or_else(|| { + BackendError::new( + BackendErrorCode::InvalidArgument, + "dictionary entry not found", + ) + })?; + if entry.phrase != phrase { + entry.phrase = phrase; + self.write_locked(&entries)?; + } + Ok(()) + }, + ) } /// Count case-insensitive, non-overlapping occurrences in final output. pub fn record_hits(&self, text: &str) -> Result { - if text.is_empty() { - return Ok(0); - } - let _guard = self.lock_store()?; - let mut entries = self.read_locked()?; - let haystack = text.to_lowercase(); - let mut total = 0_u64; - let mut changed = false; - for entry in entries.iter_mut().filter(|entry| entry.enabled) { - let needle = entry.phrase.trim().to_lowercase(); - let count = count_occurrences(&haystack, &needle); - if count > 0 { - entry.hits = entry.hits.saturating_add(count); - total = total.saturating_add(count); - changed = true; - } - } - if changed { - self.write_locked(&entries)?; - } - Ok(total) + crate::cloud_sync_e2ee_store::gate::with_registered_mutation( + &self.path, + crate::cloud_sync_e2ee_store::gate::ChangeOrigin::User, + || { + if text.is_empty() { + return Ok(0); + } + let _guard = self.lock_store()?; + let mut entries = self.read_locked()?; + let haystack = text.to_lowercase(); + let mut total = 0_u64; + let mut changed = false; + for entry in entries.iter_mut().filter(|entry| entry.enabled) { + let needle = entry.phrase.trim().to_lowercase(); + let count = count_occurrences(&haystack, &needle); + if count > 0 { + entry.hits = entry.hits.saturating_add(count); + total = total.saturating_add(count); + changed = true; + } + } + if changed { + self.write_locked(&entries)?; + } + Ok(total) + }, + ) } fn lock_store(&self) -> Result, BackendError> { @@ -225,7 +282,7 @@ impl DictionaryStore { } fn read_locked(&self) -> Result, BackendError> { - read_or_default(&self.path) + crate::persistence::read_lossless_rows(&self.path, &["notes", "hitCount"]) } fn write_locked(&self, entries: &[DictionaryEntry]) -> Result<(), BackendError> { @@ -263,7 +320,7 @@ fn count_occurrences(haystack: &str, needle: &str) -> u64 { } pub fn list_vocab_presets(data_dir: &Path) -> Result { - read_or_default(&data_dir.join("vocab-presets.json")) + crate::persistence::read_lossless_object(&data_dir.join("vocab-presets.json")) } pub fn builtin_vocab_presets() -> Vec { @@ -295,9 +352,29 @@ pub fn resolve_vocab_presets(store: &VocabPresetStore) -> Vec { } pub fn save_vocab_presets(data_dir: &Path, store: &VocabPresetStore) -> Result<(), BackendError> { - let json = serde_json::to_vec_pretty(store) - .map_err(|_| persistence_error("encode vocabulary presets"))?; - atomic_write(&data_dir.join("vocab-presets.json"), &json) + crate::cloud_sync_e2ee_store::gate::with_registered_mutation( + &data_dir.join("vocab-presets.json"), + crate::cloud_sync_e2ee_store::gate::ChangeOrigin::User, + || { + // A typed replacement may not erase fields introduced by a newer app. + let _: VocabPresetStore = + crate::persistence::read_lossless_object(&data_dir.join("vocab-presets.json"))?; + let json = serde_json::to_vec_pretty(store) + .map_err(|_| persistence_error("encode vocabulary presets"))?; + atomic_write(&data_dir.join("vocab-presets.json"), &json) + }, + ) +} + +pub(crate) fn save_vocab_presets_for_sync( + data_dir: &Path, + store: &VocabPresetStore, + permit: &crate::cloud_sync_e2ee_store::gate::ExclusivePermit, +) -> Result<(), BackendError> { + let path = data_dir.join("vocab-presets.json"); + let bytes = serde_json::to_vec_pretty(store) + .map_err(|_| persistence_error("encode restored vocabulary presets"))?; + crate::persistence::atomic_write_for_sync(&path, &bytes, permit) } #[cfg(test)] diff --git a/openless-all/app/crates/openless-core/src/voice_session.rs b/openless-all/app/crates/openless-core/src/voice_session.rs index 83a7f434..1a223d19 100644 --- a/openless-all/app/crates/openless-core/src/voice_session.rs +++ b/openless-all/app/crates/openless-core/src/voice_session.rs @@ -1,4 +1,4 @@ -use std::sync::{Arc, Mutex}; +use std::sync::{Arc, Mutex, OnceLock}; use crate::{BackendError, BackendErrorCode, SessionId}; @@ -19,12 +19,30 @@ struct ActiveVoiceSession { cancel: crate::CancellationToken, } -#[derive(Debug, Default)] +#[derive(Default)] pub(crate) struct VoiceSessionGate { active: Mutex>, + restore_guard: OnceLock, } impl VoiceSessionGate { + pub(crate) fn bind_restore_guard( + &self, + guard: crate::domains::RuntimeRestoreGuard, + ) -> Result<(), BackendError> { + self.restore_guard.set(guard).map_err(|_| { + BackendError::new( + BackendErrorCode::InvalidState, + "voice restore guard is already bound", + ) + }) + } + + pub(crate) fn runtime_restore_idle(&self) -> bool { + // A released owner with native cleanup holds is deliberately not idle. + self.active.lock().is_ok_and(|active| active.is_none()) + } + pub(crate) fn acquire( &self, session_id: SessionId, @@ -44,6 +62,11 @@ impl VoiceSessionGate { format!("another voice session is active: {:?}", current.kind), )), None => { + // Same mutex as the restore idle probe: the restoring flag is + // checked before claiming the owner, without a check/claim gap. + if let Some(guard) = self.restore_guard.get() { + guard()?; + } *active = Some(ActiveVoiceSession { session_id, kind, @@ -120,6 +143,153 @@ impl Drop for VoiceResourceHold { } } +/// Counts non-audio runtime work without taking the exclusive voice slot. +/// Domain callers acquire a work lease while holding their own state lock; +/// probes read that same state lock before this short counter mutex. +#[derive(Default)] +pub(crate) struct RuntimeActivityGate { + state: Mutex, + binding: OnceLock, +} + +#[derive(Default)] +struct RuntimeActivityState { + active: usize, + abandoned_cleanup: bool, +} +struct RuntimeActivityBinding { + guard: crate::domains::RuntimeRestoreGuard, + spawner: Arc, +} + +impl RuntimeActivityGate { + pub(crate) fn bind( + &self, + guard: crate::domains::RuntimeRestoreGuard, + spawner: Arc, + ) -> Result<(), BackendError> { + self.binding + .set(RuntimeActivityBinding { guard, spawner }) + .map_err(|_| { + BackendError::new( + BackendErrorCode::InvalidState, + "runtime restore guard is already bound", + ) + }) + } + + pub(crate) fn acquire(self: &Arc) -> Result { + let mut state = self.state.lock().expect("runtime activity lock poisoned"); + if let Some(binding) = self.binding.get() { + (binding.guard)()?; + } + state.active = state + .active + .checked_add(1) + .expect("runtime activity count overflow"); + Ok(RuntimeActivityHold { + gate: Arc::clone(self), + clean: true, + }) + } + + /// Continue an owner already validated under the domain state lock. + /// Its active phase makes the restore probe reject admission, even if a + /// restore attempt has raised its flag immediately before that probe. + pub(crate) fn existing_work(self: &Arc) -> RuntimeActivityHold { + let mut state = self.state.lock().expect("runtime activity lock poisoned"); + state.active = state + .active + .checked_add(1) + .expect("runtime activity count overflow"); + RuntimeActivityHold { + gate: Arc::clone(self), + clean: true, + } + } + + fn cleanup_hold(self: &Arc) -> RuntimeActivityHold { + let mut state = self.state.lock().expect("runtime activity lock poisoned"); + state.active = state + .active + .checked_add(1) + .expect("runtime activity count overflow"); + RuntimeActivityHold { + gate: Arc::clone(self), + clean: false, + } + } + + pub(crate) fn runtime_restore_idle(&self) -> bool { + self.state + .lock() + .is_ok_and(|state| state.active == 0 && !state.abandoned_cleanup) + } + + /// A restore-enabled Host owns cleanup through its existing TaskSpawner. + /// Dropping the IPC waiter does not release the lease or cancel cleanup. + /// Hosts without restore binding retain their original executor behavior. + pub(crate) fn cleanup( + self: &Arc, + work: futures_util::future::BoxFuture<'static, Result>, + ) -> futures_util::future::BoxFuture<'static, Result> { + let gate = Arc::clone(self); + let spawner = self + .binding + .get() + .map(|binding| Arc::clone(&binding.spawner)); + Box::pin(async move { + // Claim before handing the task to the scheduler: the caller may + // drop its main lease as soon as this future first yields. + let hold = gate.cleanup_hold(); + let task = async move { + let result = work.await; + hold.complete(); + result + }; + if let Some(spawner) = spawner { + let (send, receive) = tokio::sync::oneshot::channel(); + spawner.spawn(Box::pin(async move { + let _ = send.send(task.await); + })); + receive.await.map_err(|_| { + BackendError::new( + BackendErrorCode::Cancelled, + "runtime cleanup task was interrupted", + ) + })? + } else { + task.await + } + }) + } +} + +pub(crate) struct RuntimeActivityHold { + gate: Arc, + clean: bool, +} +impl RuntimeActivityHold { + fn complete(mut self) { + // Consume the whole guard so async field capture cannot leave its + // Drop on the caller while moving only the Copy completion flag. + self.clean = true; + } +} +impl Drop for RuntimeActivityHold { + fn drop(&mut self) { + let mut state = self + .gate + .state + .lock() + .expect("runtime activity lock poisoned"); + state.active -= 1; + if !self.clean { + state.abandoned_cleanup = true; + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -149,4 +319,179 @@ mod tests { gate.acquire(SessionId::new(), VoiceSessionKind::Qa) .unwrap(); } + fn restore_guard( + flag: Arc, + ) -> crate::domains::RuntimeRestoreGuard { + Arc::new(move || { + if flag.load(std::sync::atomic::Ordering::Acquire) { + Err(BackendError::new( + BackendErrorCode::Busy, + "restore in progress", + )) + } else { + Ok(()) + } + }) + } + + #[test] + fn runtime_restore_blocks_new_voice_but_preserves_an_existing_owner() { + let flag = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let gate = Arc::new(VoiceSessionGate::default()); + gate.bind_restore_guard(restore_guard(flag.clone())) + .unwrap(); + let owner = SessionId::new(); + gate.acquire(owner, VoiceSessionKind::Dictation).unwrap(); + let resources = gate.hold_resources(owner).unwrap(); + flag.store(true, std::sync::atomic::Ordering::Release); + gate.acquire(owner, VoiceSessionKind::Dictation).unwrap(); + gate.release(owner); + assert!( + !gate.runtime_restore_idle(), + "native cleanup still owns resources" + ); + drop(resources); + assert!(gate.runtime_restore_idle()); + for kind in [ + VoiceSessionKind::Dictation, + VoiceSessionKind::LessComputer, + VoiceSessionKind::Qa, + VoiceSessionKind::SelectionVoice, + ] { + assert_eq!( + gate.acquire(SessionId::new(), kind).unwrap_err().code, + BackendErrorCode::Busy + ); + } + flag.store(false, std::sync::atomic::Ordering::Release); + gate.acquire(SessionId::new(), VoiceSessionKind::Qa) + .unwrap(); + } + + #[test] + fn runtime_restore_probe_cannot_cross_a_voice_check_claim_race() { + let flag = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let gate = Arc::new(VoiceSessionGate::default()); + let (checked, checked_rx) = std::sync::mpsc::channel(); + let (resume, resume_rx) = std::sync::mpsc::channel(); + let resume_rx = Mutex::new(resume_rx); + let check_flag = flag.clone(); + gate.bind_restore_guard(Arc::new(move || { + assert!(!check_flag.load(std::sync::atomic::Ordering::Acquire)); + checked.send(()).unwrap(); + resume_rx.lock().unwrap().recv().unwrap(); + Ok(()) + })) + .unwrap(); + let owner = SessionId::new(); + let starting = std::thread::spawn({ + let gate = gate.clone(); + move || gate.acquire(owner, VoiceSessionKind::Dictation) + }); + checked_rx + .recv_timeout(std::time::Duration::from_secs(2)) + .unwrap(); + flag.store(true, std::sync::atomic::Ordering::Release); + let probing = std::thread::spawn({ + let gate = gate.clone(); + move || gate.runtime_restore_idle() + }); + resume.send(()).unwrap(); + starting.join().unwrap().unwrap(); + assert!( + !probing.join().unwrap(), + "restore must reject the newly admitted voice owner" + ); + gate.release(owner); + } + + #[derive(Default)] + struct CapturingSpawner { + tasks: Mutex>>, + } + impl crate::config::TaskSpawner for CapturingSpawner { + fn spawn(&self, task: futures_util::future::BoxFuture<'static, ()>) { + self.tasks.lock().unwrap().push(tokio::spawn(task)); + } + } + + #[tokio::test] + async fn runtime_restore_waits_for_owned_cleanup_after_waiter_is_dropped() { + let gate = Arc::new(RuntimeActivityGate::default()); + let spawner = Arc::new(CapturingSpawner::default()); + gate.bind(Arc::new(|| Ok(())), spawner.clone()).unwrap(); + let entered = Arc::new(tokio::sync::Notify::new()); + let release = Arc::new(tokio::sync::Notify::new()); + let waiter = tokio::spawn(gate.cleanup(Box::pin({ + let entered = entered.clone(); + let release = release.clone(); + async move { + entered.notify_one(); + release.notified().await; + Ok(()) + } + }))); + entered.notified().await; + waiter.abort(); + assert!(waiter.await.unwrap_err().is_cancelled()); + assert!(!gate.runtime_restore_idle()); + let owned = spawner.tasks.lock().unwrap().pop().unwrap(); + release.notify_one(); + owned.await.unwrap(); + assert!(gate.runtime_restore_idle()); + } + + #[tokio::test] + async fn runtime_restore_fails_closed_if_the_cleanup_owner_itself_is_aborted() { + let gate = Arc::new(RuntimeActivityGate::default()); + let spawner = Arc::new(CapturingSpawner::default()); + gate.bind(Arc::new(|| Ok(())), spawner.clone()).unwrap(); + let entered = Arc::new(tokio::sync::Notify::new()); + let waiter = tokio::spawn(gate.cleanup(Box::pin({ + let entered = entered.clone(); + async move { + entered.notify_one(); + std::future::pending::<()>().await; + Ok(()) + } + }))); + entered.notified().await; + let owned = spawner.tasks.lock().unwrap().pop().unwrap(); + owned.abort(); + assert!(owned.await.unwrap_err().is_cancelled()); + assert_eq!( + waiter.await.unwrap().unwrap_err().code, + BackendErrorCode::Cancelled + ); + assert!( + !gate.runtime_restore_idle(), + "uncertain cleanup cannot make restore safe" + ); + } + #[derive(Default)] + struct QueuedSpawner { + tasks: Mutex>>, + } + impl crate::config::TaskSpawner for QueuedSpawner { + fn spawn(&self, task: futures_util::future::BoxFuture<'static, ()>) { + self.tasks.lock().unwrap().push(task); + } + } + + #[tokio::test] + async fn runtime_restore_error_cleanup_claims_before_the_owned_task_is_scheduled() { + let gate = Arc::new(RuntimeActivityGate::default()); + let spawner = Arc::new(QueuedSpawner::default()); + gate.bind(Arc::new(|| Ok(())), spawner.clone()).unwrap(); + let mut cleanup = gate.cleanup(Box::pin(async { Ok(()) })); + assert!(futures_util::poll!(cleanup.as_mut()).is_pending()); + assert!( + !gate.runtime_restore_idle(), + "queued cleanup must already own its restore lease" + ); + let task = spawner.tasks.lock().unwrap().pop().unwrap(); + task.await; + cleanup.await.unwrap(); + assert!(gate.runtime_restore_idle()); + } } diff --git a/openless-all/app/scripts/build-mac.sh b/openless-all/app/scripts/build-mac.sh index 05306dcc..69b4de82 100755 --- a/openless-all/app/scripts/build-mac.sh +++ b/openless-all/app/scripts/build-mac.sh @@ -74,7 +74,30 @@ DMG_PATH="$DMG_DIR/OpenLess_${APP_VERSION}_${MAC_BUNDLE_ARCH}.dmg" rm -rf "$APP" rm -f "$DMG_PATH" "${APP}.tar.gz" "${APP}.tar.gz.sig" TAURI_BUILD_ARGS+=(-- --locked --timings) -npm run tauri -- "${TAURI_BUILD_ARGS[@]}" +if [ "$MAC_BUNDLE_ARCH" = "aarch64" ]; then + # Tauri skips Finder AppleScript in CI. Write deterministic Finder metadata + # into its temporary image before Tauri compresses and signs the final DMG. + DMG_LAYOUT_ENV_DIR="$(mktemp -d "${TMPDIR:-/tmp}/openless-dmg-python.XXXXXX")" + cleanup_dmg_environment() { rm -rf "$DMG_LAYOUT_ENV_DIR"; } + trap cleanup_dmg_environment EXIT + python3 -m venv "$DMG_LAYOUT_ENV_DIR" + DMG_LAYOUT_PYTHON="$DMG_LAYOUT_ENV_DIR/bin/python3" + "$DMG_LAYOUT_PYTHON" -m pip install --quiet --disable-pip-version-check \ + --only-binary=:all: --no-deps --require-hashes -r scripts/macos-dmg-requirements.txt + CI=true TAURI_BUNDLER_DMG_IGNORE_CI=false \ + OPENLESS_DMG_LAYOUT_ROOT="$PWD" OPENLESS_DMG_LAYOUT_PYTHON="$DMG_LAYOUT_PYTHON" \ + OPENLESS_DMG_LAYOUT_STAMP="$DMG_LAYOUT_ENV_DIR/layout-applied" \ + PATH="$PWD/scripts/macos-dmg-bin:$PATH" npm run tauri -- "${TAURI_BUILD_ARGS[@]}" + if [ ! -s "$DMG_LAYOUT_ENV_DIR/layout-applied" ]; then + echo "✗ Tauri 未调用 DMG 布局步骤,中止交付" + exit 1 + fi + "$DMG_LAYOUT_PYTHON" scripts/macos-dmg-layout.py verify "$DMG_PATH" + cleanup_dmg_environment + trap - EXIT +else + npm run tauri -- "${TAURI_BUILD_ARGS[@]}" +fi if [ ! -f "$APP/Contents/MacOS/openless" ]; then echo "✗ $APP 缺失或不是本次构建的产物(打包未完成),中止" diff --git a/openless-all/app/scripts/encrypted-sync-setup-prompt.test.mjs b/openless-all/app/scripts/encrypted-sync-setup-prompt.test.mjs new file mode 100644 index 00000000..3a77fb32 --- /dev/null +++ b/openless-all/app/scripts/encrypted-sync-setup-prompt.test.mjs @@ -0,0 +1,242 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +const require = createRequire(import.meta.url); +const ts = require('typescript'); +const source = readFileSync( + new URL('../src/components/CloudSyncSetupPrompt.tsx', import.meta.url), + 'utf8', +); +const code = ts.transpileModule(source, { + compilerOptions: { + target: ts.ScriptTarget.ES2022, + module: ts.ModuleKind.CommonJS, + jsx: ts.JsxEmit.ReactJSX, + }, +}).outputText; +const tick = () => new Promise((resolve) => setImmediate(resolve)); +const deferred = () => { + let resolve, reject; + const promise = new Promise((a, b) => { + resolve = a; + reject = b; + }); + return { promise, resolve, reject }; +}; +const jsx = (type, props) => ({ type, props: props ?? {} }); +const all = (tree) => + Array.isArray(tree) ? tree.flatMap(all) : tree?.props ? [tree, ...all(tree.props.children)] : []; +function context({ native = true, blocked = false } = {}) { + const slots = [], + effects = [], + listeners = new Map(), + timers = new Map(); + let cursor = 0, + id = 0, + calls = 0, + response = false, + props = { blocked, onSetup: () => {} }, + last; + const document = Object.assign(new EventTarget(), { + visibilityState: 'visible', + hasFocus: () => true, + activeElement: null, + }); + const window = new EventTarget(); + const react = { + useState(initial) { + const i = cursor++; + slots[i] ??= { value: initial }; + return [ + slots[i].value, + (v) => { + slots[i].value = typeof v === 'function' ? v(slots[i].value) : v; + }, + ]; + }, + useRef(initial) { + return this.useState({ current: initial })[0]; + }, + useEffect(fn, deps) { + const i = cursor++, + previous = slots[i]; + if (previous && deps.every((v, j) => Object.is(v, previous.deps[j]))) return; + slots[i] = { deps }; + effects.push(() => { + previous?.cleanup?.(); + slots[i].cleanup = fn(); + }); + }, + }; + react.useRef = (initial) => react.useState({ current: initial })[0]; + const imports = { + react, + 'react-i18next': { useTranslation: () => ({ t: (k) => k }) }, + 'react/jsx-runtime': { jsx, jsxs: jsx, Fragment: 'fragment' }, + 'lucide-react': { CloudIcon: 'cloud', XIcon: 'x' }, + '../lib/ipc/shared': { isTauri: native }, + '../lib/ipc/cloud-sync-e2ee': { + cloudSyncE2eeClaimSetupPrompt: async () => { + calls++; + return typeof response === 'function' ? response() : response; + }, + }, + '../pages/settings/CloudSyncSection': { CloudSyncSection: 'sync-section' }, + './ui/Modal': { Modal: 'modal' }, + '@tauri-apps/api/event': { + listen: async (name, fn) => { + listeners.set(name, fn); + return () => listeners.delete(name); + }, + }, + }; + const exports = {}; + new Function( + 'require', + 'exports', + 'window', + 'document', + 'HTMLElement', + 'setTimeout', + 'clearTimeout', + code, + )( + (name) => imports[name], + exports, + window, + document, + class {}, + (fn) => { + timers.set(++id, fn); + return id; + }, + (i) => timers.delete(i), + ); + const render = () => { + cursor = 0; + last = exports.CloudSyncSetupPrompt(props); + effects.splice(0).forEach((fn) => fn()); + return last; + }; + return { + render, + document, + window, + setResponse: (v) => { + response = v; + }, + setBlocked: (v) => { + props = { ...props, blocked: v }; + }, + calls: () => calls, + listeners, + timers, + event: (type) => listeners.get('backend:event')?.({ payload: { kind: { type } } }), + async flush() { + await tick(); + const queued = [...timers.values()]; + timers.clear(); + queued.forEach((fn) => fn()); + await tick(); + return render(); + }, + unmount() { + slots.forEach((s) => s?.cleanup?.()); + }, + welcome: exports.CloudSyncWelcome, + }; +} +for (const options of [{ native: false }, { blocked: true }]) { + const c = context(options); + c.setResponse(true); + assert.equal(c.render(), null); + await c.flush(); + assert.equal(c.calls(), 0); + assert.equal(c.listeners.size, 0); + c.unmount(); +} +{ + const c = context(); + c.setResponse(true); + c.render(); + const tree = await c.flush(); + assert.equal(tree.type, 'modal'); + assert.equal(c.calls(), 1); + const later = all(tree).find( + (n) => n.type === 'button' && n.props.children === 'cloudSyncE2ee.setupPromptLater', + ); + assert(later); + later.props.onClick(); + assert.equal(c.render(), null); + c.unmount(); + assert.equal(c.listeners.size, 0); +} +{ + const c = context(); + c.render(); + await c.flush(); + assert.equal(c.calls(), 1); + assert.equal(c.render(), null); + c.event('qa_level'); + assert.equal(c.timers.size, 0); + c.setResponse(true); + c.event('credentials_changed'); + assert.equal((await c.flush()).type, 'modal'); + c.unmount(); +} +{ + const c = context(); + c.setResponse(() => Promise.reject(new Error('vault denied'))); + c.render(); + await c.flush(); + assert.equal(c.render(), null); + assert.equal(c.listeners.size, 1); + c.setResponse(true); + c.window.dispatchEvent(new Event('focus')); + assert.equal((await c.flush()).type, 'modal'); + c.unmount(); +} +{ + const c = context(); + const pending = deferred(); + c.setResponse(() => pending.promise); + c.render(); + await c.flush(); + c.setBlocked(true); + c.render(); + pending.resolve(true); + await tick(); + assert.equal(c.render(), null); + assert.equal(c.listeners.size, 0); + c.unmount(); +} +{ + const c = context(); + c.document.visibilityState = 'hidden'; + c.setResponse(true); + c.render(); + await c.flush(); + assert.equal(c.calls(), 0); + c.document.visibilityState = 'visible'; + c.document.dispatchEvent(new Event('visibilitychange')); + assert.equal((await c.flush()).type, 'modal'); + c.unmount(); +} +for (const change of ['hidden', 'focus', 'recording']) { + const c = context(); + const pending = deferred(); + c.setResponse(() => pending.promise); + c.render(); + await c.flush(); + if (change === 'hidden') { + c.document.visibilityState = 'hidden'; + c.document.dispatchEvent(new Event('visibilitychange')); + } + if (change === 'focus') c.document.hasFocus = () => false; + if (change === 'recording') c.event('dictation_state_changed'); + pending.resolve(true); + await tick(); + assert.equal(c.render(), null, `late claim after ${change} must not open`); + c.unmount(); +} +console.log('encrypted sync setup prompt: 10 lifecycle cases passed'); diff --git a/openless-all/app/scripts/encrypted-sync-ui-bridge.test.mjs b/openless-all/app/scripts/encrypted-sync-ui-bridge.test.mjs new file mode 100644 index 00000000..b1761ae7 --- /dev/null +++ b/openless-all/app/scripts/encrypted-sync-ui-bridge.test.mjs @@ -0,0 +1,312 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { fileURLToPath } from 'node:url'; +const app = fileURLToPath(new URL('../', import.meta.url)); +const require = createRequire(import.meta.url); +const ts = require('typescript'); +const source = readFileSync(app + '/src/lib/encryptedSyncUiBridge.ts', 'utf8'); +const code = ts.transpileModule(source, { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }, +}).outputText; +const tick = () => new Promise((r) => setImmediate(r)); +const drain = async () => { + for (let i = 0; i < 8; i++) await tick(); +}; +const defer = () => { + let resolve, reject; + const promise = new Promise((r, e) => { + resolve = r; + reject = e; + }); + return { promise, resolve, reject }; +}; +function context({ fresh = false } = {}) { + const window = new EventTarget(), + handlers = new Map(), + statusQueue = [], + attempts = []; + let errors = 0; + window.addEventListener('openless:sync-ui-persistence-failed', () => errors++); + let locale = 'en', + fontScale = 'medium', + rev = fresh ? 0 : 1; + let preferences = fresh ? null : { locale, fontScale }; + let status = { + sequence: '0', + account: fresh ? null : { githubId: '1' }, + vaultId: fresh ? null : 'v1', + consentVersion: fresh ? null : 'yes', + taskId: null, + }; + const revision = () => (rev ? `00000000-0000-4000-8000-${String(rev).padStart(12, '0')}` : null); + const event = (key, source) => + window.dispatchEvent( + new CustomEvent('openless:ui-preferences-changed', { detail: { key, source } }), + ); + const i18n = { + getLocalePreference: () => locale, + setLocalePreference: async (value, source = 'user') => { + locale = value; + event('locale', source); + }, + SUPPORTED_LOCALES: ['en', 'fr', 'de'], + }; + const fonts = { + readFontScale: () => fontScale, + setFontScale: (value, source = 'user') => { + fontScale = value; + event('fontScale', source); + }, + }; + const invoke = async (cmd, args) => { + if (cmd === 'cloud_sync_e2ee_status') + return statusQueue.length ? statusQueue.shift().promise : { ...status }; + if (cmd === 'cloud_sync_e2ee_get_ui_preferences_snapshot') + return { preferences: preferences && { ...preferences }, revision: revision() }; + if ( + cmd === 'cloud_sync_e2ee_set_ui_preferences_checked' || + cmd === 'cloud_sync_e2ee_set_ui_preferences' + ) { + attempts.push({ cmd, ...args }); + if (cmd.endsWith('_checked') && args.expectedRevision !== revision()) + throw { details: { reason: 'stale_preview' } }; + const next = { locale: args.locale, fontScale: args.fontScale }; + if (JSON.stringify(next) !== JSON.stringify(preferences)) { + preferences = next; + rev++; + } + return; + } + throw new Error(cmd); + }; + const exports = {}, + shared = { isTauri: true, invokeOrMock: invoke }; + const localRequire = (name) => + name === './ipc/shared' + ? shared + : name === '../i18n' + ? i18n + : name === './fontScale' + ? fonts + : name === '@tauri-apps/api/event' + ? { + listen: async (name, fn) => { + handlers.set(name, fn); + return () => handlers.delete(name); + }, + } + : null; + new Function('require', 'exports', 'window', 'location', 'Event', 'CustomEvent', code)( + localRequire, + exports, + window, + { search: '' }, + Event, + CustomEvent, + ); + return { + install: exports.installEncryptedSyncUiBridge, + flush: exports.flushEncryptedSyncUiPreferences, + userLocale: i18n.setLocalePreference, + userFont: fonts.setFontScale, + statusQueue, + attempts, + setStatus: (value) => { + status = { ...status, ...value }; + }, + snapshot: () => ({ locale, fontScale, preferences, revision: revision(), errors }), + externalWrite: (value = {}) => { + preferences = { ...preferences, ...value }; + rev++; + }, + unchecked: () => invoke('cloud_sync_e2ee_set_ui_preferences', { locale, fontScale }), + restore: (sequence = '1', scope = {}) => + handlers.get('cloud-sync-e2ee:restored')({ + payload: { + sequence, + accountId: status.account.githubId, + vaultId: status.vaultId, + taskId: 'restore', + ...scope, + }, + }), + }; +} +let failed = 0; +async function test(name, fn) { + try { + await fn(); + console.log('PASS ' + name); + } catch (e) { + failed++; + console.log('FAIL ' + name + '\n' + e.message); + } +} +await test('old mirror queued before restore cannot overwrite its native value', async () => { + const c = context(); + await c.install(); + const wait = defer(); + c.statusQueue.push(wait); + await c.userLocale('de'); + await tick(); + c.externalWrite({ locale: 'fr' }); + c.restore(); + await tick(); + wait.resolve({ sequence: '2', account: { githubId: '1' }, vaultId: 'v1', consentVersion: 'yes' }); + await drain(); + assert.equal(c.snapshot().preferences.locale, 'fr'); + assert.equal(c.snapshot().locale, 'fr'); +}); +await test('new choice after restore notification wins only its chosen field', async () => { + const c = context(); + await c.install(); + const wait = defer(); + c.statusQueue.push(wait); + c.externalWrite({ locale: 'fr', fontScale: 'large' }); + c.restore(); + await tick(); + await c.userLocale('de'); + wait.resolve({ sequence: '2', account: { githubId: '1' }, vaultId: 'v1', consentVersion: 'yes' }); + await drain(); + assert.deepEqual(c.snapshot().preferences, { locale: 'de', fontScale: 'large' }); + assert.equal(c.snapshot().locale, 'de'); + assert.equal(c.snapshot().fontScale, 'large'); +}); +await test('prepare mirror must not permanently stale the live bridge revision', async () => { + const c = context({ fresh: true }); + await c.install(); + await c.flush(); + c.setStatus({ account: { githubId: '1' }, vaultId: 'v1', consentVersion: 'yes' }); + await c.userLocale('de'); + await drain(); + c.userFont('large'); + await drain(); + assert.deepEqual( + c.snapshot().preferences, + { locale: 'de', fontScale: 'large' }, + JSON.stringify(c.snapshot()), + ); +}); +await test('legitimate account switch must rebase the cached mirror scope', async () => { + const c = context(); + await c.install(); + c.setStatus({ sequence: '3', account: { githubId: '2' }, vaultId: 'v2' }); + await c.userLocale('de'); + await drain(); + c.userFont('large'); + await drain(); + assert.deepEqual( + c.snapshot().preferences, + { locale: 'de', fontScale: 'large' }, + JSON.stringify(c.snapshot()), + ); +}); +await test('rollback-only UUID change must not permanently poison later user saves', async () => { + const c = context(); + await c.install(); + c.externalWrite({ locale: 'en' }); + await c.userLocale('de'); + await drain(); + await c.userLocale('fr'); + await drain(); + assert.equal(c.snapshot().preferences.locale, 'fr', JSON.stringify(c.snapshot())); +}); + +await test('a new vault restore is hydrated after an account switch', async () => { + const c = context(); + await c.install(); + c.setStatus({ sequence: '3', account: { githubId: '2' }, vaultId: 'v2' }); + c.externalWrite({ locale: 'fr', fontScale: 'large' }); + c.restore('4'); + await drain(); + assert.equal(c.snapshot().locale, 'fr'); + assert.equal(c.snapshot().fontScale, 'large'); + await c.userLocale('de'); + await drain(); + assert.equal(c.snapshot().preferences.locale, 'de'); +}); +await test('an equal-value restore invalidates an older unsent user choice', async () => { + const c = context(); + await c.install(); + const wait = defer(); + c.statusQueue.push(wait); + await c.userLocale('de'); + await tick(); + c.externalWrite({ locale: 'en' }); + c.restore(); + wait.resolve({ sequence: '2', account: { githubId: '1' }, vaultId: 'v1', consentVersion: 'yes' }); + await drain(); + assert.equal(c.snapshot().preferences.locale, 'en'); + await c.userLocale('fr'); + await drain(); + assert.equal(c.snapshot().preferences.locale, 'fr'); +}); +await test('prepare and user edits use only the checked writer', async () => { + const c = context({ fresh: true }); + await c.install(); + await c.flush(); + c.setStatus({ consentVersion: 'yes' }); + await c.userLocale('de'); + c.userFont('large'); + await drain(); + assert.deepEqual(c.snapshot().preferences, { locale: 'de', fontScale: 'large' }); + assert(c.attempts.every((a) => a.cmd === 'cloud_sync_e2ee_set_ui_preferences_checked')); +}); +await test('a late old-vault event cannot discard a new-account user choice', async () => { + const c = context(); + await c.install(); + c.setStatus({ account: { githubId: '2' }, vaultId: 'v2' }); + const wait = defer(); + c.statusQueue.push(wait); + await c.userLocale('de'); + await tick(); + c.restore('99', { accountId: '1', vaultId: 'v1' }); + wait.resolve({ account: { githubId: '2' }, vaultId: 'v2', consentVersion: 'yes' }); + await drain(); + assert.equal(c.snapshot().preferences.locale, 'de'); + c.externalWrite({ locale: 'fr' }); + c.restore('5'); + await drain(); + assert.equal(c.snapshot().locale, 'fr'); +}); +await test('a failed old-scope notification read retains the newer local choice', async () => { + const c = context(); + await c.install(); + c.setStatus({ account: { githubId: '2' }, vaultId: 'v2' }); + const userStatus = defer(), + restoreStatus = defer(); + c.statusQueue.push(userStatus, restoreStatus); + await c.userLocale('de'); + await tick(); + c.restore('99', { accountId: '1', vaultId: 'v1' }); + userStatus.resolve({ account: { githubId: '2' }, vaultId: 'v2', consentVersion: 'yes' }); + await tick(); + restoreStatus.reject(new Error('transient native status failure')); + await drain(); + assert.equal(c.snapshot().preferences.locale, 'de', JSON.stringify(c.snapshot())); + assert.equal(c.snapshot().locale, 'de'); +}); +await test('overlapping valid and old-scope notifications preserve a later field choice', async () => { + const c = context(); + await c.install(); + c.setStatus({ account: { githubId: '2' }, vaultId: 'v2' }); + const wait = defer(); + c.statusQueue.push(wait); + c.externalWrite({ locale: 'fr', fontScale: 'large' }); + c.restore('4'); + await tick(); + await c.userLocale('de'); + c.restore('99', { accountId: '1', vaultId: 'v1' }); + wait.resolve({ account: { githubId: '2' }, vaultId: 'v2', consentVersion: 'yes' }); + await drain(); + assert.deepEqual( + c.snapshot().preferences, + { locale: 'de', fontScale: 'large' }, + JSON.stringify(c.snapshot()), + ); + assert.equal(c.snapshot().locale, 'de'); + assert.equal(c.snapshot().fontScale, 'large'); +}); +console.log(`failures=${failed}`); +process.exitCode = failed ? 1 : 0; diff --git a/openless-all/app/scripts/less-computer-panel-runtime.test.mjs b/openless-all/app/scripts/less-computer-panel-runtime.test.mjs new file mode 100644 index 00000000..bd324db7 --- /dev/null +++ b/openless-all/app/scripts/less-computer-panel-runtime.test.mjs @@ -0,0 +1,514 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { resolve } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +const app = fileURLToPath(new URL('../', import.meta.url)); +const require = createRequire(resolve(app, 'package.json')); +const ts = require('typescript'); +const replayModule = await import(pathToFileURL(resolve(app, 'src/lib/lessComputerReplay.ts'))); +const source = readFileSync(resolve(app, 'src/pages/LessComputerPanel.tsx'), 'utf8'); +const parsed = ts.createSourceFile( + 'LessComputerPanel.tsx', + source, + ts.ScriptTarget.ES2020, + true, + ts.ScriptKind.TSX, +); +const names = parsed.statements + .filter(ts.isImportDeclaration) + .flatMap((node) => + node.importClause?.namedBindings && ts.isNamedImports(node.importClause.namedBindings) + ? node.importClause.namedBindings.elements.map((item) => item.name.text) + : [], + ); +const body = parsed.statements + .filter((node) => !ts.isImportDeclaration(node)) + .map((node) => node.getFullText(parsed)) + .join('\n') + .replaceAll("import('@tauri-apps/api/event')", 'Promise.resolve({listen: __listen})') + .replaceAll( + "import('@tauri-apps/api/window')", + 'Promise.resolve({getCurrentWindow: __getWindow})', + ); +const compiled = ts.transpileModule(body, { + compilerOptions: { + target: ts.ScriptTarget.ES2020, + module: ts.ModuleKind.CommonJS, + jsx: ts.JsxEmit.ReactJSX, + }, +}).outputText; +const factory = new Function( + ...names, + '__listen', + '__getWindow', + 'exports', + 'require', + `${compiled};return {LessComputerPanel,Composer,ApprovalCard,TurnView,ActivityPanel,voiceTime};`, +); +const tick = () => new Promise((resolve) => setImmediate(resolve)); +const deferred = () => { + let resolve, reject; + const promise = new Promise((a, b) => { + resolve = a; + reject = b; + }); + return { promise, resolve, reject }; +}; +const jsx = (type, props) => ({ type, props: props ?? {} }); +function nodes(value) { + if (Array.isArray(value)) return value.flatMap(nodes); + if (!value || typeof value !== 'object' || !value.props) return []; + return [value, ...nodes(value.props.children)]; +} +function find(tree, predicate) { + const result = nodes(tree).find(predicate); + assert(result, 'control exists'); + return result; +} +let active; +class Hooks { + slots = []; + cursor = 0; + pending = []; + state(initial) { + const index = this.cursor++; + this.slots[index] ??= { value: typeof initial === 'function' ? initial() : initial }; + return [ + this.slots[index].value, + (value) => { + this.slots[index].value = + typeof value === 'function' ? value(this.slots[index].value) : value; + }, + ]; + } + ref(initial) { + return this.state({ current: initial })[0]; + } + effect(fn, deps) { + const index = this.cursor++; + const prev = this.slots[index]; + if ( + prev && + deps && + prev.deps?.length === deps.length && + deps.every((v, i) => Object.is(v, prev.deps[i])) + ) + return; + const slot = { deps }; + this.slots[index] = slot; + this.pending.push(() => { + prev?.cleanup?.(); + slot.cleanup = fn(); + }); + } + render(fn) { + this.cursor = 0; + active = this; + const tree = fn(); + this.pending.splice(0).forEach((fn) => fn()); + return tree; + } + unmount() { + this.slots.forEach((slot) => slot.cleanup?.()); + } +} +function context(native, replay) { + const events = new Map(); + const keys = new Set(); + const calls = { approve: [], submit: [], windows: [] }; + let approvalResult, submitResult; + globalThis.window = { + location: { search: '?window=less-computer&demo=1' }, + addEventListener: (name, fn) => { + if (name === 'keydown') keys.add(fn); + }, + removeEventListener: (name, fn) => { + if (name === 'keydown') keys.delete(fn); + }, + }; + const imports = Object.fromEntries(names.map((name) => [name, name])); + Object.assign(imports, { + useState: (initial) => active.state(initial), + useRef: (initial) => active.ref(initial), + useEffect: (fn, deps) => active.effect(fn, deps), + useTranslation: () => ({ t: (key) => key }), + useChatPanelLifecycle: () => ({ enterEpoch: 0, closing: false }), + isTauri: native, + getSettings: async () => ({ codingAgentProvider: 'codex-cli' }), + marketplaceAuthStatus: async () => ({ signedIn: false }), + lessComputerSync: () => + replay?.promise ?? Promise.resolve({ events: [], latestSequence: 0, truncated: false }), + lessComputerApprove: async (...args) => { + calls.approve.push(args); + return approvalResult?.promise; + }, + lessComputerSubmitText: async (text) => { + calls.submit.push(text); + return submitResult?.promise; + }, + lessComputerWindowDismiss: async () => { + calls.windows.push('hide'); + }, + chatPanelFocusKeyboard: async () => {}, + ...replayModule, + }); + const api = factory( + ...names.map((n) => imports[n]), + async (name, fn) => { + const set = events.get(name) ?? new Set(); + set.add(fn); + events.set(name, set); + return () => set.delete(fn); + }, + () => ({ + minimize: async () => calls.windows.push('minimize'), + toggleMaximize: async () => calls.windows.push('maximize'), + }), + {}, + () => ({ jsx, jsxs: jsx, Fragment: 'fragment' }), + ); + const hooks = new Hooks(); + const render = () => hooks.render(api.LessComputerPanel); + const emit = (ev) => { + for (const fn of events.get('less-computer:event') ?? []) fn({ payload: ev }); + }; + return { + api, + hooks, + render, + emit, + calls, + events, + keys, + imports, + approval: (d) => { + approvalResult = d; + }, + submission: (d) => { + submitResult = d; + }, + turns: (tree) => + nodes(tree) + .filter((node) => node.type === api.TurnView) + .map((node) => node.props), + voice: (tree) => find(tree, (node) => node.type === api.Composer).props.voice, + }; +} +let passed = 0; +async function test(name, fn) { + await fn(); + console.log(`PASS ${name}`); + passed++; +} +await test('browser has no demo state and cannot submit, approve, open OAuth or control native windows', async () => { + const c = context(false); + let tree = c.render(); + assert.equal(c.turns(tree).length, 0); + const windowButtons = nodes(tree).filter( + (n) => n.type === 'button' && n.props.className?.startsWith('lc-window-'), + ); + for (const button of windowButtons) { + assert.equal(button.props.disabled, true); + await button.props.onClick(); + } + assert.deepEqual(c.calls.windows, []); + const hooks = new Hooks(); + let composer = hooks.render(() => c.api.Composer({ working: false, voice: null, t: (k) => k })); + find(composer, (n) => n.type === 'textarea').props.onChange({ + currentTarget: { value: 'never execute' }, + }); + composer = hooks.render(() => c.api.Composer({ working: false, voice: null, t: (k) => k })); + find(composer, (n) => n.type === 'form').props.onSubmit({ preventDefault() {} }); + await tick(); + assert.deepEqual(c.calls.submit, []); + find(tree, (n) => n.type === 'button' && n.props.className === 'lc-github').props.onClick(); + tree = c.render(); + assert(!nodes(tree).some((n) => n.type === 'GithubLoginModal')); + c.hooks.unmount(); + assert.equal(c.keys.size, 0); +}); +await test('replay/live dedup, ordered deltas and fresh session reset', async () => { + const replay = deferred(), + c = context(true, replay); + c.render(); + await tick(); + c.emit({ kind: 'tool', name: 'Read', seq: 2 }); + c.emit({ kind: 'delta', text: 'A', seq: 3 }); + replay.resolve({ + events: [ + { kind: 'user', text: 'task', fresh: true, seq: 1 }, + { kind: 'tool', name: 'Read', seq: 2 }, + ], + latestSequence: 2, + truncated: false, + }); + await tick(); + let turns = c.turns(c.render()); + assert.equal(turns.length, 1); + assert.equal(turns[0].turn.user, 'task'); + assert.deepEqual(turns[0].turn.segments, [ + { kind: 'tool', name: 'Read', running: false }, + { kind: 'text', content: 'A' }, + ]); + c.emit({ kind: 'delta', text: 'duplicate', seq: 3 }); + c.emit({ kind: 'delta', text: 'B', seq: 4 }); + turns = c.turns(c.render()); + assert.equal(turns[0].turn.segments[1].content, 'AB'); + c.emit({ kind: 'user', text: 'new', fresh: true, seq: 5 }); + turns = c.turns(c.render()); + assert.equal(turns.length, 1); + assert.equal(turns[0].turn.user, 'new'); + c.hooks.unmount(); + assert.equal( + [...c.events.values()].reduce((a, s) => a + s.size, 0), + 0, + ); +}); +await test('cold missing-user stream self-heals; completed text fallback preserves tools and real cost', async () => { + const c = context(true); + c.render(); + await tick(); + c.emit({ kind: 'tool', name: 'Read', seq: 1 }); + c.emit({ kind: 'completed', text: 'result', costUsd: 0.005, seq: 2 }); + const turn = c.turns(c.render())[0].turn; + assert.equal(turn.user, ''); + assert.deepEqual(turn.segments, [ + { kind: 'tool', name: 'Read', running: false }, + { kind: 'text', content: 'result' }, + ]); + assert.equal(turn.costUsd, 0.005); + assert.equal(turn.status, 'done'); + c.hooks.unmount(); +}); +await test('approval waits for IPC, rejects duplicate clicks, retains retry on failure, confirms only success', async () => { + const c = context(true); + c.render(); + await tick(); + c.emit({ kind: 'user', text: 'task', fresh: true, seq: 1 }); + c.emit({ kind: 'approval', token: 'one', command: 'fixture', reason: 'fixture', seq: 2 }); + let turn = c.turns(c.render())[0]; + let d = deferred(); + c.approval(d); + const first = turn.onApproval('one', true); + void turn.onApproval('one', false); + turn = c.turns(c.render())[0]; + assert.equal(c.calls.approve.length, 1); + assert.equal(turn.turn.segments[0].pending, true); + assert.equal(turn.turn.segments[0].decision, undefined); + d.reject(new Error('fixture rejection')); + await first; + turn = c.turns(c.render())[0]; + assert.equal(turn.turn.segments[0].pending, false); + assert.equal(turn.turn.segments[0].failed, true); + assert.equal(turn.turn.segments[0].decision, undefined); + d = deferred(); + c.approval(d); + const second = turn.onApproval('one', true); + d.resolve(); + await second; + turn = c.turns(c.render())[0]; + assert.equal(turn.turn.segments[0].decision, 'approved'); + assert.equal(turn.turn.segments[0].failed, false); + c.hooks.unmount(); +}); +await test('late approval from old turn cannot mutate a fresh session or remove its pending request', async () => { + const c = context(true); + c.render(); + await tick(); + c.emit({ kind: 'user', text: 'old', fresh: true, seq: 1 }); + c.emit({ kind: 'approval', token: 'same', command: 'old', reason: 'x', seq: 2 }); + let d = deferred(); + c.approval(d); + const old = c.turns(c.render())[0].onApproval('same', true); + c.emit({ kind: 'user', text: 'new', fresh: true, seq: 3 }); + c.emit({ kind: 'approval', token: 'same', command: 'new', reason: 'x', seq: 4 }); + const newer = deferred(); + c.approval(newer); + let turn = c.turns(c.render())[0]; + const current = turn.onApproval('same', false); + d.resolve(); + await old; + turn = c.turns(c.render())[0]; + assert.equal(turn.turn.segments[0].decision, undefined); + assert.equal(turn.turn.segments[0].pending, true); + void turn.onApproval('same', true); + assert.equal(c.calls.approve.length, 2); + newer.resolve(); + await current; + turn = c.turns(c.render())[0]; + assert.equal(turn.turn.segments[0].decision, 'denied'); + c.hooks.unmount(); +}); +await test('terminal cancellation blocks approvals and removes tool running indicator', async () => { + const c = context(true); + c.render(); + await tick(); + c.emit({ kind: 'user', text: 'task', fresh: true, seq: 1 }); + c.emit({ kind: 'tool', name: 'Read', seq: 2 }); + c.emit({ kind: 'approval', token: 'old', command: 'fixture', reason: 'x', seq: 3 }); + c.emit({ kind: 'cancelled', seq: 4 }); + let turn = c.turns(c.render())[0]; + await turn.onApproval('old', true); + assert.equal(c.calls.approve.length, 0); + assert.equal(turn.turn.segments[0].running, false); + assert.equal(turn.turn.status, 'cancelled'); + c.hooks.unmount(); +}); +await test('voice projection preserves session ownership and exposes only actual level/phase/elapsed', async () => { + const c = context(true); + c.render(); + await tick(); + c.emit({ + kind: 'voice_state', + sessionId: 'a', + phase: 'recording', + level: 0.5, + elapsedMs: 1000, + seq: 1, + }); + c.emit({ + kind: 'voice_state', + sessionId: 'b', + phase: 'starting', + level: 0, + elapsedMs: 0, + seq: 2, + }); + c.emit({ kind: 'voice_state', sessionId: 'a', phase: 'idle', level: 0, elapsedMs: 5000, seq: 3 }); + assert.equal(c.voice(c.render()).sessionId, 'b'); + c.emit({ + kind: 'voice_state', + sessionId: 'b', + phase: 'recording', + level: 0.7, + elapsedMs: 65000, + seq: 4, + }); + const props = find(c.render(), (n) => n.type === c.api.Composer).props; + const hooks = new Hooks(); + const tree = hooks.render(() => c.api.Composer(props)); + const waveform = find(tree, (n) => n.type === 'VoiceWaveform'); + assert.equal(waveform.props.level, 0.7); + assert.equal(waveform.props.processing, false); + assert.equal(find(tree, (n) => n.type === 'time').props.children, '1:05'); + assert(find(tree, (n) => n.type === 'textarea').props.disabled); + assert.equal(c.api.voiceTime(NaN), '0:00'); + assert.equal(c.api.voiceTime(-8), '0:00'); + c.hooks.unmount(); +}); +await test('composer IME/229/Shift+Enter guards; one pending send; RPC failure retains draft', async () => { + const c = context(true); + const h = new Hooks(); + const render = () => h.render(() => c.api.Composer({ working: false, voice: null, t: (k) => k })); + let tree = render(); + find(tree, (n) => n.type === 'textarea').props.onChange({ currentTarget: { value: '你好' } }); + tree = render(); + let input = find(tree, (n) => n.type === 'textarea'); + const key = (overrides = {}) => ({ + key: 'Enter', + shiftKey: false, + keyCode: 13, + nativeEvent: { isComposing: false }, + preventDefault() { + this.prevented = true; + }, + ...overrides, + }); + input.props.onCompositionStart(); + input.props.onKeyDown(key()); + find(tree, (n) => n.type === 'form').props.onSubmit({ preventDefault() {} }); + input.props.onCompositionEnd(); + input.props.onKeyDown(key({ nativeEvent: { isComposing: true } })); + input.props.onKeyDown(key({ keyCode: 229 })); + let shift = key({ shiftKey: true }); + input.props.onKeyDown(shift); + assert.equal(shift.prevented, undefined); + assert.equal(c.calls.submit.length, 0); + let d = deferred(); + c.submission(d); + input.props.onKeyDown(key()); + input.props.onKeyDown(key()); + assert.equal(c.calls.submit.length, 1); + tree = render(); + assert(find(tree, (n) => n.type === 'button' && n.props.type === 'submit').props.disabled); + d.reject(new Error('fixture')); + await tick(); + tree = render(); + assert.equal(find(tree, (n) => n.type === 'textarea').props.value, '你好'); + assert(nodes(tree).some((n) => n.props.role === 'alert')); + d = deferred(); + c.submission(d); + find(tree, (n) => n.type === 'textarea').props.onKeyDown(key()); + tree = render(); + find(tree, (n) => n.type === 'textarea').props.onChange({ + currentTarget: { value: 'next draft' }, + }); + d.resolve(); + await tick(); + tree = render(); + assert.equal(find(tree, (n) => n.type === 'textarea').props.value, 'next draft'); +}); +await test('native window actions use hide/minimize/toggleMaximize; Escape closes OAuth without hiding panel', async () => { + const c = context(true); + let tree = c.render(); + await tick(); + for (const button of nodes(tree).filter( + (n) => n.type === 'button' && n.props.className?.startsWith('lc-window-'), + )) + await button.props.onClick(); + await tick(); + assert.deepEqual(c.calls.windows, ['hide', 'minimize', 'maximize']); + find(tree, (n) => n.type === 'button' && n.props.className === 'lc-github').props.onClick(); + tree = c.render(); + assert(nodes(tree).some((n) => n.type === 'GithubLoginModal')); + let prevented = false, + stopped = false; + for (const key of c.keys) + key({ + key: 'Escape', + isComposing: false, + keyCode: 27, + preventDefault() { + prevented = true; + }, + stopPropagation() { + stopped = true; + }, + }); + tree = c.render(); + assert.equal(prevented, true); + assert.equal(stopped, true); + assert(!nodes(tree).some((n) => n.type === 'GithubLoginModal')); + assert.equal(c.calls.windows.length, 3); + for (const key of c.keys) + key({ + key: 'Escape', + isComposing: true, + keyCode: 229, + preventDefault() { + throw new Error('IME Escape must be left alone'); + }, + }); + assert.equal(c.calls.windows.length, 3); + c.hooks.unmount(); +}); +await test('activity derives tool report/approval/cost only; unknown API values and text deltas create no invented events', async () => { + const c = context(false); + const tree = c.api.ActivityPanel({ + turn: { + user: 'task', + segments: [ + { kind: 'tool', name: 'Read', running: false }, + { kind: 'text', content: 'answer' }, + ], + status: 'done', + costUsd: null, + }, + t: (k) => k, + }); + assert.equal(nodes(tree).filter((n) => n.type === 'li').length, 1); + assert(nodes(tree).some((n) => n.props.children === 'lessComputer.desktop.toolReported')); + assert(!nodes(tree).some((n) => n.props.className === 'lc-api-cost')); +}); +console.log(`${passed} actual-component behavior tests passed`); diff --git a/openless-all/app/scripts/macos-dmg-bin/hdiutil b/openless-all/app/scripts/macos-dmg-bin/hdiutil new file mode 100755 index 00000000..190c0992 --- /dev/null +++ b/openless-all/app/scripts/macos-dmg-bin/hdiutil @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +set -euo pipefail +exec "${OPENLESS_DMG_LAYOUT_PYTHON:?}" "${OPENLESS_DMG_LAYOUT_ROOT:?}/scripts/macos-dmg-layout.py" wrap "$@" diff --git a/openless-all/app/scripts/macos-dmg-layout.py b/openless-all/app/scripts/macos-dmg-layout.py new file mode 100644 index 00000000..fc290a42 --- /dev/null +++ b/openless-all/app/scripts/macos-dmg-layout.py @@ -0,0 +1,184 @@ +#!/usr/bin/env python3 +"""Add Finder metadata to Tauri's temporary DMG before its final conversion/signing.""" + +import contextlib +import json +import os +from pathlib import Path +import plistlib +import re +import shutil +import subprocess +import sys +import tempfile + +from ds_store import DSStore +from mac_alias import Alias + +HDIUTIL = "/usr/bin/hdiutil" +APP_ROOT = Path(__file__).resolve().parent.parent + + +def settings(): + tauri = APP_ROOT / "src-tauri" + app = json.loads((tauri / "tauri.conf.json").read_text()) + dmg = json.loads((tauri / "tauri.macos-mlx.conf.json").read_text())["bundle"]["macOS"]["dmg"] + style = json.loads((tauri / "dmg/layout.json").read_text()) + return app, dmg, style + + +def conversion_source(args): + """Only the expected native ARM64 bundle can be altered by this scoped wrapper.""" + app, _, _ = settings() + folder = (APP_ROOT / "src-tauri/target/release/bundle/macos").resolve() + expected_name = f'{app["productName"]}_{app["version"]}_aarch64.dmg' + if len(args) < 2 or args[0] != "convert": + raise ValueError("Expected a Tauri DMG conversion") + try: + output = Path(args[args.index("-o") + 1]).resolve() + image_format = args[args.index("-format") + 1] + except (ValueError, IndexError) as error: + raise ValueError("Unrecognized Tauri DMG conversion arguments") from error + source = Path(args[1]).resolve() + if ( + image_format != "UDZO" + or output != folder / expected_name + or source.parent != folder + or not re.fullmatch(r"rw\.\d+\." + re.escape(expected_name), source.name) + or not source.is_file() + ): + raise ValueError("Refusing DMG layout changes outside the current Tauri bundle") + return source + + +@contextlib.contextmanager +def mounted(image, writable=False): + # Do not recursively delete this directory: it may still contain a mounted volume. + mountpoint = Path(tempfile.mkdtemp(prefix="openless-dmg-layout-", dir="/tmp")).resolve() + device = None + try: + result = subprocess.run( + [HDIUTIL, "attach", str(image), "-readwrite" if writable else "-readonly", + "-noautoopen", "-nobrowse", "-mountpoint", str(mountpoint), "-plist"], + check=True, stdout=subprocess.PIPE, + ) + entities = plistlib.loads(result.stdout)["system-entities"] + device = next( + item["dev-entry"] for item in entities + if item.get("mount-point") and Path(item["mount-point"]).resolve() == mountpoint + ) + yield mountpoint + finally: + if device or os.path.ismount(mountpoint): + subprocess.run([HDIUTIL, "detach", device or str(mountpoint)], check=True, stdout=subprocess.PIPE) + mountpoint.rmdir() + + +def ensure_targets(volume, app_name): + if not (volume / app_name).is_dir(): + raise ValueError("Tauri app is missing from the DMG") + applications = volume / "Applications" + if not applications.is_symlink() or os.readlink(applications) != "/Applications": + raise ValueError("Applications must be the real /Applications drag target") + + +def write_layout(volume): + app, dmg, style = settings() + app_name = app["productName"] + ".app" + ensure_targets(volume, app_name) + background_source = APP_ROOT / "src-tauri" / style["retinaBackground"] + if not background_source.is_file(): + raise ValueError("Retina DMG background is missing") + background = volume / ".background/installer-background.tiff" + background.parent.mkdir(exist_ok=True) + shutil.copyfile(background_source, background) + # mac_alias requires a canonical path to avoid /var vs /private/var alias drift. + alias = Alias.for_file(str(background.resolve())).to_bytes() + position, size = dmg["windowPosition"], dmg["windowSize"] + bounds = "{{%d, %d}, {%d, %d}}" % (position["x"], position["y"], size["width"], size["height"]) + with DSStore.open(str(volume / ".DS_Store"), "w+") as store: + store["."]["vSrn"] = ("long", 1) + store["."]["icvl"] = ("type", b"icnv") + store["."]["bwsp"] = { + "WindowBounds": bounds, "ShowToolbar": False, "ShowStatusBar": False, + "ShowPathbar": False, "ShowSidebar": False, "ShowTabView": False, + "ContainerShowSidebar": False, "SidebarWidth": 0, + } + store["."]["icvp"] = { + "viewOptionsVersion": 1, "iconSize": float(style["iconSize"]), + "textSize": float(style["textSize"]), "arrangeBy": "none", + "labelOnBottom": True, "showItemInfo": False, "showIconPreview": False, + "gridOffsetX": 0.0, "gridOffsetY": 0.0, "gridSpacing": 64.0, + "scrollPositionX": 0.0, "scrollPositionY": 0.0, + "backgroundType": 2, "backgroundImageAlias": alias, + } + for name, key in [(app_name, "appPosition"), ("Applications", "applicationFolderPosition")]: + store[name]["Iloc"] = (dmg[key]["x"], dmg[key]["y"]) + + +def verify_retina_background(background, size): + result = subprocess.run( + ["/usr/bin/tiffutil", "-info", str(background)], + check=True, capture_output=True, text=True, + ) + metadata = result.stdout + result.stderr + dimensions = [(int(w), int(h)) for w, h in re.findall(r"Image Width: (\d+) Image Length: (\d+)", metadata)] + resolutions = [(float(x), float(y)) for x, y in re.findall(r"Resolution: ([\d.]+), ([\d.]+)", metadata)] + expected = [(size["width"], size["height"]), (size["width"] * 2, size["height"] * 2)] + if dimensions != expected or resolutions != [(72.0, 72.0), (144.0, 144.0)]: + raise ValueError("DMG background must contain matching 1x and 2x Retina representations") + + +def verify_layout(image): + app, dmg, style = settings() + with mounted(image) as volume: + app_name = app["productName"] + ".app" + ensure_targets(volume, app_name) + with DSStore.open(str(volume / ".DS_Store"), "r") as store: + icon = store["."]["icvp"] + view = store["."]["bwsp"] + position, size = dmg["windowPosition"], dmg["windowSize"] + expected_bounds = "{{%d, %d}, {%d, %d}}" % (position["x"], position["y"], size["width"], size["height"]) + if view["WindowBounds"] != expected_bounds or any(view[key] for key in ["ShowToolbar", "ShowStatusBar", "ShowPathbar", "ShowSidebar", "ShowTabView"]): + raise ValueError("DMG window geometry does not match its design") + if (icon["iconSize"] != style["iconSize"] or icon["textSize"] != style["textSize"] + or icon["backgroundType"] != 2 or icon["arrangeBy"] != "none" + or not icon["labelOnBottom"] or store["."]["icvl"] != (b"type", b"icnv")): + raise ValueError("DMG icon size, label size or background mode is incorrect") + for name, key in [(app_name, "appPosition"), ("Applications", "applicationFolderPosition")]: + if tuple(store[name]["Iloc"]) != (dmg[key]["x"], dmg[key]["y"]): + raise ValueError("DMG drag target positions are incorrect") + alias = Alias.from_bytes(icon["backgroundImageAlias"]) + if alias.target.posix_path != "/.background/installer-background.tiff": + raise ValueError("DMG background alias escapes the installer volume") + background = volume / ".background/installer-background.tiff" + if background.read_bytes() != (APP_ROOT / "src-tauri" / style["retinaBackground"]).read_bytes(): + raise ValueError("DMG background does not match its Retina source") + verify_retina_background(background, dmg["windowSize"]) + bundled_info = plistlib.loads((volume / app_name / "Contents/Info.plist").read_bytes()) + if bundled_info["CFBundleShortVersionString"] != app["version"]: + raise ValueError("DMG contains a different app version") + print("✓ DMG: Retina background, large icons, window layout and Applications target verified") + + +def main(): + if len(sys.argv) < 2: + raise ValueError("Expected wrap or verify") + if sys.argv[1] == "verify": + verify_layout(Path(sys.argv[2]).resolve()) + elif sys.argv[1] == "wrap": + args = sys.argv[2:] + if args and args[0] == "convert": + image = conversion_source(args) + with mounted(image, writable=True) as volume: + write_layout(volume) + with Path(os.environ["OPENLESS_DMG_LAYOUT_STAMP"]).open("x") as stamp: + stamp.write(str(image)) + print("✓ Prepared Finder layout in Tauri's temporary DMG", file=sys.stderr) + os.execv(HDIUTIL, [HDIUTIL, *args]) + else: + raise ValueError("Unknown DMG layout command") + + +if __name__ == "__main__": + main() diff --git a/openless-all/app/scripts/macos-dmg-requirements.txt b/openless-all/app/scripts/macos-dmg-requirements.txt new file mode 100644 index 00000000..e6f74b5e --- /dev/null +++ b/openless-all/app/scripts/macos-dmg-requirements.txt @@ -0,0 +1,3 @@ +# Last versions compatible with the Python 3.9 shipped in Xcode command-line tools. +ds_store==1.3.1 --hash=sha256:fbacbb0bd5193ab3e66e5a47fff63619f15e374ffbec8ae29744251a6c8f05b5 +mac_alias==2.2.2 --hash=sha256:504ab8ac546f35bbd75ad014d6ad977c426660aa721f2cd3acf3dc2f664141bd diff --git a/openless-all/app/scripts/qa-panel-runtime.test.mjs b/openless-all/app/scripts/qa-panel-runtime.test.mjs new file mode 100644 index 00000000..b38591ca --- /dev/null +++ b/openless-all/app/scripts/qa-panel-runtime.test.mjs @@ -0,0 +1,673 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { resolve } from 'node:path'; +import { pathToFileURL, fileURLToPath } from 'node:url'; +const app = fileURLToPath(new URL('../', import.meta.url)), + require = createRequire(resolve(app, 'package.json')), + ts = require('typescript'); +const helpers = await import(pathToFileURL(resolve(app, 'src/lib/qaMessage.ts'))); +const source = readFileSync(resolve(app, 'src/pages/QaPanel.tsx'), 'utf8'); +const parsed = ts.createSourceFile( + 'QaPanel.tsx', + source, + ts.ScriptTarget.ES2020, + true, + ts.ScriptKind.TSX, +); +const names = parsed.statements + .filter(ts.isImportDeclaration) + .flatMap((n) => + n.importClause?.namedBindings && ts.isNamedImports(n.importClause.namedBindings) + ? n.importClause.namedBindings.elements.map((n) => n.name.text) + : [], + ); +const body = parsed.statements + .filter((n) => !ts.isImportDeclaration(n)) + .map((n) => n.getFullText(parsed)) + .join('\n') + .replaceAll("import('@tauri-apps/api/event')", 'Promise.resolve({listen: __listen})'); +const compiled = ts.transpileModule(body, { + compilerOptions: { + target: ts.ScriptTarget.ES2020, + module: ts.ModuleKind.CommonJS, + jsx: ts.JsxEmit.ReactJSX, + }, +}).outputText; +const factory = new Function( + ...names, + '__listen', + 'exports', + 'require', + `${compiled};return {QaPanel,Composer,MessageRow,ErrorContent};`, +); +const tick = () => new Promise((resolve) => setImmediate(resolve)); +const deferred = () => { + let resolve, reject; + const promise = new Promise((a, b) => { + resolve = a; + reject = b; + }); + return { promise, resolve, reject }; +}; +const jsx = (type, props) => ({ type, props: props ?? {} }); +function nodes(tree) { + if (Array.isArray(tree)) return tree.flatMap(nodes); + if (!tree || typeof tree !== 'object' || !tree.props) return []; + return [tree, ...nodes(tree.props.children)]; +} +function find(tree, predicate) { + const node = nodes(tree).find(predicate); + assert(node, 'expected control'); + return node; +} +let active; +class Hooks { + slots = []; + cursor = 0; + pending = []; + state(initial) { + const i = this.cursor++; + this.slots[i] ??= { value: typeof initial === 'function' ? initial() : initial }; + return [ + this.slots[i].value, + (value) => { + this.slots[i].value = typeof value === 'function' ? value(this.slots[i].value) : value; + }, + ]; + } + ref(initial) { + return this.state({ current: initial })[0]; + } + effect(fn, deps) { + const i = this.cursor++, + prev = this.slots[i]; + if ( + prev && + deps && + prev.deps?.length === deps.length && + deps.every((v, i) => Object.is(v, prev.deps[i])) + ) + return; + const slot = { deps }; + this.slots[i] = slot; + this.pending.push(() => { + prev?.cleanup?.(); + slot.cleanup = fn(); + }); + } + render(fn) { + this.cursor = 0; + active = this; + const tree = fn(); + this.pending.splice(0).forEach((fn) => fn()); + return tree; + } + unmount() { + this.slots.forEach((s) => s.cleanup?.()); + } +} +function context(native = true, embedded = false) { + const events = new Map(), + keys = new Set(), + calls = { + resize: [], + submit: [], + submitArgs: [], + snapshot: 0, + mic: 0, + mode: [], + preview: [], + confirm: [], + revert: [], + dismiss: 0, + close: 0, + }; + const pending = {}; + const lifecycle = { enterEpoch: 0, closing: false }; + globalThis.window = { + location: { search: '?window=qa&demo=1' }, + addEventListener: (name, fn) => { + if (name === 'keydown') keys.add(fn); + }, + removeEventListener: (name, fn) => { + if (name === 'keydown') keys.delete(fn); + }, + }; + const imports = Object.fromEntries(names.map((n) => [n, n])); + Object.assign(imports, { + useState: (i) => active.state(i), + useRef: (i) => active.ref(i), + useEffect: (f, d) => active.effect(f, d), + useTranslation: () => ({ t: (k) => k }), + useGithubLogin: () => '', + useChatPanelLifecycle: () => lifecycle, + isTauri: native, + ...helpers, + qaWindowSetExpanded: async (next) => { + calls.resize.push(next); + return pending.resize?.(next); + }, + qaSubmitText: async (text, session) => { + calls.submit.push(text); + calls.submitArgs.push([text, session]); + return pending.submit?.promise; + }, + qaGetSnapshot: async () => { + calls.snapshot++; + return pending.snapshot?.() ?? { kind: 'idle', messages: [] }; + }, + qaToggleRecording: async () => { + calls.mic++; + return pending.mic?.promise; + }, + qaSetEditInstructionMode: async (mode) => { + calls.mode.push(mode); + return pending.mode?.promise; + }, + qaWindowDismiss: async () => { + calls.dismiss++; + return pending.dismiss?.promise; + }, + getSelectionVoicePreview: async (session) => { + calls.preview.push(session); + return pending.preview?.promise ?? { text: ' fixture edit ' }; + }, + confirmSelectionVoicePreview: async (...args) => { + calls.confirm.push(args); + return pending.confirm?.promise; + }, + revertSelectionVoicePreview: async (session) => { + calls.revert.push(session); + return pending.revert?.promise; + }, + chatPanelFocusKeyboard: async () => {}, + }); + const api = factory( + ...names.map((n) => imports[n]), + async (name, fn) => { + const listeners = events.get(name) ?? new Set(); + listeners.add(fn); + events.set(name, listeners); + return () => listeners.delete(fn); + }, + {}, + () => ({ jsx, jsxs: jsx, Fragment: 'fragment' }), + ); + const hooks = new Hooks(), + render = () => + hooks.render(() => api.QaPanel({ embedded, onRequestClose: () => calls.close++ })); + const emit = (name, payload) => { + for (const fn of events.get(name) ?? []) fn({ payload }); + }; + const composer = (tree) => find(tree, (n) => n.type === api.Composer).props; + return { api, hooks, render, emit, calls, pending, lifecycle, events, keys, composer }; +} +let passed = 0, + failed = 0; +async function test(name, fn) { + try { + await fn(); + console.log('PASS ' + name); + passed++; + } catch (error) { + console.log('FAIL ' + name + '\n' + error.stack); + failed++; + } +} +await test('browser compact has no demo conversations and cannot execute native actions', async () => { + const c = context(false); + let tree = c.render(); + assert(!tree.props.className.includes('is-expanded')); + assert(!nodes(tree).some((n) => n.type === c.api.MessageRow)); + let p = c.composer(tree); + p.onChange('fixture'); + p = c.composer(c.render()); + await p.onSubmit(); + await p.onToggleRecording(); + await p.onEditInstructionModeChange(true); + await tick(); + assert.deepEqual(c.calls.resize, []); + assert.deepEqual(c.calls.submit, []); + assert.equal(c.calls.mic, 0); + assert.deepEqual(c.calls.mode, []); + const h = new Hooks(), + ui = h.render(() => c.api.Composer(p)); + for (const button of nodes(ui).filter((n) => n.type === 'button')) + assert.equal(button.props.disabled, true); + c.hooks.unmount(); +}); +await test('native text waits for expansion and excludes repeated clicks; keeps a newer draft', async () => { + const c = context(); + c.render(); + await tick(); + assert.deepEqual(c.calls.resize, [false]); + c.composer(c.render()).onChange('question'); + let p = c.composer(c.render()); + const resize = deferred(), + send = deferred(); + c.pending.resize = (next) => (next ? resize.promise : undefined); + c.pending.submit = send; + const first = p.onSubmit(); + void p.onSubmit(); + c.render(); + await tick(); + assert.deepEqual(c.calls.resize, [false, true]); + assert.deepEqual(c.calls.submit, []); + resize.resolve(); + await tick(); + assert.deepEqual(c.calls.submit, ['question']); + c.composer(c.render()).onChange('next draft'); + send.resolve(); + await first; + assert.equal(c.composer(c.render()).value, 'next draft'); + c.hooks.unmount(); +}); +await test('send failure preserves draft and exposes error without a fake user message', async () => { + const c = context(); + c.render(); + await tick(); + c.composer(c.render()).onChange('keep this'); + const d = deferred(); + c.pending.submit = d; + const result = c.composer(c.render()).onSubmit(); + await tick(); + d.reject(new Error('fixture')); + await result; + const tree = c.render(); + assert.equal(c.composer(tree).value, 'keep this'); + assert.equal(c.composer(tree).status, 'error'); + assert(!nodes(tree).some((n) => n.type === c.api.MessageRow)); + c.hooks.unmount(); +}); +await test('cancel during resize prevents queued question and late layout failures do not overwrite reopened compact state', async () => { + const c = context(); + c.render(); + await tick(); + c.composer(c.render()).onChange('do not send'); + const d = deferred(); + c.pending.resize = (next) => (next ? d.promise : undefined); + const submission = c.composer(c.render()).onSubmit(); + c.render(); + await tick(); + await c.composer(c.render()).onClose(); + c.lifecycle.closing = true; + c.render(); + c.lifecycle.closing = false; + c.lifecycle.enterEpoch++; + c.render(); + assert.deepEqual(c.calls.resize, [false, true]); + d.reject(new Error('old geometry')); + await submission; + await tick(); + c.render(); + await tick(); + assert.deepEqual(c.calls.submit, []); + assert.deepEqual(c.calls.resize, [false, true, false]); + assert.equal(c.composer(c.render()).status, 'idle'); + assert(!nodes(c.render()).some((n) => n.props.className === 'qa-layout-error')); + c.hooks.unmount(); +}); +await test('recording level requires active session; no fake recording before backend phase', async () => { + const c = context(); + c.render(); + await tick(); + const d = deferred(); + c.pending.mic = d; + let p = c.composer(c.render()); + const first = p.onToggleRecording(); + void p.onToggleRecording(); + assert.equal(c.calls.mic, 1); + p = c.composer(c.render()); + assert.equal(p.status, 'idle'); + assert.equal(p.micBusy, true); + assert.equal(p.level, 0); + c.emit('qa:state', { + kind: 'recording', + sessionId: 'a', + selectionPreview: 'selected', + messages: [], + }); + c.emit('qa:level', { sessionId: 'other', level: 0.9 }); + assert.equal(c.composer(c.render()).level, 0); + c.emit('qa:level', { sessionId: 'a', level: 0.6 }); + p = c.composer(c.render()); + assert.equal(p.level, 0.6); + assert.equal(p.status, 'recording'); + assert.equal(p.voiceActive, true); + assert.equal(p.selectionPreview, 'selected'); + d.resolve(); + await first; + c.emit('qa:state', { + kind: 'thinking', + sessionId: 'a', + messages: [{ role: 'user', content: 'actual question' }], + }); + c.emit('qa:level', { sessionId: 'a', level: 0.9 }); + p = c.composer(c.render()); + assert.equal(p.level, 0); + assert.equal(p.voiceActive, false); + c.hooks.unmount(); +}); +await test('stale answer is ignored and final answer replaces streaming buffer exactly once', async () => { + const c = context(); + c.render(); + await tick(); + c.emit('qa:state', { + kind: 'thinking', + sessionId: 'new', + messages: [{ role: 'user', content: 'question' }], + }); + c.emit('qa:state', { kind: 'answer_delta', sessionId: 'old', chunk: 'stale' }); + c.emit('qa:state', { kind: 'answer_delta', sessionId: 'new', chunk: 'real ' }); + c.emit('qa:state', { kind: 'answer_delta', sessionId: 'new', chunk: 'answer' }); + let tree = c.render(); + assert.equal(find(tree, (n) => n.type === 'AssistantMarkdown').props.markdown, 'real answer'); + c.emit('qa:state', { + kind: 'answer', + sessionId: 'new', + messages: [ + { role: 'user', content: 'question' }, + { role: 'assistant', content: 'real answer' }, + ], + }); + tree = c.render(); + assert(!nodes(tree).some((n) => n.type === 'AssistantMarkdown')); + assert.equal(nodes(tree).filter((n) => n.type === c.api.MessageRow).length, 2); + c.hooks.unmount(); +}); +await test('edit preview captured in old session cannot be applied after a new recording starts', async () => { + const c = context(); + c.render(); + await tick(); + c.emit('qa:state', { + kind: 'answer', + sessionId: 'a', + messages: [{ role: 'assistant', content: 'preview' }], + editApplyAvailable: true, + editRevertAvailable: true, + }); + let tree = c.render(); + const d = deferred(); + c.pending.preview = d; + find(tree, (n) => n.type === 'button' && n.props.className === 'qa-apply').props.onClick(); + assert.deepEqual(c.calls.preview, ['a']); + c.emit('qa:state', { kind: 'recording', sessionId: 'b', messages: [] }); + d.resolve({ text: 'must not apply' }); + await tick(); + assert.deepEqual(c.calls.confirm, []); + assert.equal(c.composer(c.render()).status, 'recording'); + c.hooks.unmount(); +}); +await test('an old rendered edit button cannot approve a newer answer before React presents it', async () => { + const c = context(); + c.render(); + await tick(); + c.emit('qa:state', { + kind: 'answer', + sessionId: 'a', + messages: [{ role: 'assistant', content: 'old preview' }], + editApplyAvailable: true, + }); + const oldTree = c.render(); + c.emit('qa:state', { kind: 'thinking', sessionId: 'b', messages: [] }); + c.emit('qa:state', { + kind: 'answer', + sessionId: 'b', + messages: [{ role: 'assistant', content: 'new unreviewed preview' }], + editApplyAvailable: true, + }); + find(oldTree, (n) => n.type === 'button' && n.props.className === 'qa-apply').props.onClick(); + await tick(); + assert.deepEqual(c.calls.preview, []); + assert.deepEqual(c.calls.confirm, []); + c.hooks.unmount(); +}); +await test('edit apply/revert are single-flight and send exact text plus captured session', async () => { + const c = context(); + c.render(); + await tick(); + c.emit('qa:state', { + kind: 'answer', + sessionId: 'a', + messages: [{ role: 'assistant', content: 'preview' }], + editApplyAvailable: true, + editRevertAvailable: true, + }); + let tree = c.render(); + const d = deferred(); + c.pending.confirm = d; + const button = find(tree, (n) => n.type === 'button' && n.props.className === 'qa-apply'); + button.props.onClick(); + button.props.onClick(); + await tick(); + assert.deepEqual(c.calls.confirm, [['fixture edit', 'a']]); + d.resolve(); + await tick(); + assert(!nodes(c.render()).some((n) => n.props.className === 'qa-edit-actions')); + c.emit('qa:state', { + kind: 'answer', + sessionId: 'a', + messages: [{ role: 'assistant', content: 'preview' }], + editApplyAvailable: true, + editRevertAvailable: true, + }); + tree = c.render(); + const r = deferred(); + c.pending.revert = r; + const back = find( + tree, + (n) => n.type === 'button' && n.props.children === 'qa.editRevertPrevious', + ); + back.props.onClick(); + back.props.onClick(); + assert.deepEqual(c.calls.revert, ['a']); + r.resolve(); + await tick(); + tree = c.render(); + assert(nodes(tree).some((n) => n.props.className === 'qa-apply')); + assert(!nodes(tree).some((n) => n.props.children === 'qa.editRevertPrevious')); + c.hooks.unmount(); +}); +await test('mode update waits for backend and repeated click cannot flip twice', async () => { + const c = context(); + c.render(); + await tick(); + const d = deferred(); + c.pending.mode = d; + let p = c.composer(c.render()); + const first = p.onEditInstructionModeChange(true); + void p.onEditInstructionModeChange(false); + p = c.composer(c.render()); + assert.equal(p.editInstructionMode, false); + assert.deepEqual(c.calls.mode, [true]); + d.reject(new Error('fixture')); + await first; + p = c.composer(c.render()); + assert.equal(p.editInstructionMode, false); + assert.equal(p.status, 'error'); + c.hooks.unmount(); +}); +await test('embedded skip native resize, preserve existing parent close path and event cleanup', async () => { + const c = context(true, true); + c.render(); + await tick(); + c.composer(c.render()).onChange('embedded'); + await c.composer(c.render()).onSubmit(); + c.render(); + await tick(); + assert.deepEqual(c.calls.resize, []); + c.emit('qa:dismiss', {}); + assert.equal(c.calls.close, 1); + c.hooks.unmount(); + assert.equal( + [...c.events.values()].reduce((n, set) => n + set.size, 0), + 0, + ); + assert.equal(c.keys.size, 0); +}); +await test('composer IME enter, WebKit 229 and form submission guards; genuine enter sends', async () => { + const c = context(); + const h = new Hooks(); + let sent = 0; + const p = { + value: '中文', + status: 'idle', + level: 0, + voiceActive: false, + selectionPreview: '', + busy: false, + micBusy: false, + embedded: false, + editInstructionMode: false, + onEditInstructionModeChange() {}, + onChange() {}, + onSubmit() { + sent++; + }, + onToggleRecording() {}, + onClose() {}, + t: (k) => k, + }; + const tree = h.render(() => c.api.Composer(p)); + const input = find(tree, (n) => n.type === 'input'); + const key = (extra = {}) => ({ + key: 'Enter', + keyCode: 13, + nativeEvent: { isComposing: false }, + preventDefault() {}, + ...extra, + }); + input.props.onCompositionStart(); + input.props.onKeyDown(key()); + find(tree, (n) => n.type === 'form').props.onSubmit({ preventDefault() {} }); + input.props.onCompositionEnd(); + input.props.onKeyDown(key({ nativeEvent: { isComposing: true } })); + input.props.onKeyDown(key({ keyCode: 229 })); + assert.equal(sent, 0); + input.props.onKeyDown(key()); + assert.equal(sent, 1); +}); + +await test('session drift during delayed native resize cancels old draft', async () => { + const c = context(); + c.render(); + await tick(); + c.emit('qa:state', { kind: 'idle', sessionId: 'a', messages: [] }); + c.composer(c.render()).onChange('belongs to a'); + const d = deferred(); + c.pending.resize = (next) => (next ? d.promise : undefined); + const result = c.composer(c.render()).onSubmit(); + await tick(); + c.emit('qa:state', { kind: 'idle', sessionId: 'b', messages: [] }); + d.resolve(); + await result; + await tick(); + assert.deepEqual(c.calls.submit, []); + c.hooks.unmount(); +}); +await test('text always carries the exact session captured before resize', async () => { + const c = context(); + c.render(); + await tick(); + c.emit('qa:state', { kind: 'idle', sessionId: 'a', messages: [] }); + c.composer(c.render()).onChange('question a'); + await c.composer(c.render()).onSubmit(); + assert.deepEqual(c.calls.submitArgs, [['question a', 'a']]); + c.hooks.unmount(); +}); +for (const embedded of [false, true]) + await test(`cold ${embedded ? 'embedded' : 'desktop'} snapshot recovers initial recording and accepts its levels`, async () => { + const c = context(true, embedded); + c.pending.snapshot = () => ({ + kind: 'recording', + sessionId: 'cold', + messages: [], + selectionPreview: 'captured text', + }); + c.render(); + await tick(); + c.emit('qa:level', { sessionId: 'cold', level: 0.4 }); + const p = c.composer(c.render()); + assert.equal(p.status, 'recording'); + assert.equal(p.level, 0.4); + assert.equal(p.selectionPreview, 'captured text'); + assert.equal(c.calls.snapshot, 1); + c.hooks.unmount(); + }); +await test('snapshot reply overtaken by a live session event is discarded and reread', async () => { + const c = context(); + const first = deferred(); + c.pending.snapshot = () => + c.calls.snapshot === 1 + ? first.promise + : { kind: 'recording', sessionId: 'new', messages: [], selectionPreview: 'new' }; + c.render(); + await tick(); + c.emit('qa:state', { + kind: 'recording', + sessionId: 'new', + messages: [], + selectionPreview: 'new', + }); + first.resolve({ kind: 'recording', sessionId: 'old', messages: [], selectionPreview: 'old' }); + await tick(); + c.emit('qa:level', { sessionId: 'new', level: 0.7 }); + const p = c.composer(c.render()); + assert.equal(p.selectionPreview, 'new'); + assert.equal(p.level, 0.7); + assert.equal(c.calls.snapshot, 2); + c.hooks.unmount(); +}); +await test('snapshot completion after dismiss cannot reopen old recording state', async () => { + const c = context(); + const first = deferred(); + c.pending.snapshot = () => first.promise; + c.render(); + await tick(); + c.emit('qa:dismiss', {}); + c.lifecycle.closing = true; + c.render(); + first.resolve({ kind: 'recording', sessionId: 'old', messages: [], selectionPreview: 'old' }); + await tick(); + assert.equal(c.composer(c.render()).status, 'idle'); + c.hooks.unmount(); +}); +await test('a snapshot read failure does not silently remove all established live subscriptions', async () => { + const c = context(); + c.pending.snapshot = () => Promise.reject(new Error('transient snapshot unavailable')); + c.render(); + await tick(); + c.emit('qa:state', { kind: 'recording', sessionId: 'live', messages: [] }); + c.emit('qa:level', { sessionId: 'live', level: 0.4 }); + const p = c.composer(c.render()); + assert.equal(p.status, 'recording'); + assert.equal(p.level, 0.4); + c.hooks.unmount(); +}); +await test('old microphone RPC failure cannot overwrite a newer native recording session', async () => { + const c = context(); + c.render(); + await tick(); + c.emit('qa:state', { kind: 'idle', sessionId: 'before', messages: [] }); + const old = deferred(); + c.pending.mic = old; + const request = c.composer(c.render()).onToggleRecording(); + c.emit('qa:state', { kind: 'recording', sessionId: 'a', messages: [] }); + c.emit('qa:state', { + kind: 'error', + sessionId: 'a', + messages: [], + error: 'old recording failed', + }); + c.emit('qa:state', { kind: 'recording', sessionId: 'b', messages: [] }); + old.reject(new Error('old RPC completed late')); + await request; + const p = c.composer(c.render()); + assert.equal(p.status, 'recording'); + c.emit('qa:level', { sessionId: 'b', level: 0.8 }); + assert.equal(c.composer(c.render()).level, 0.8); + c.hooks.unmount(); +}); +console.log(JSON.stringify({ passed, failed })); +process.exitCode = failed ? 1 : 0; diff --git a/openless-all/app/scripts/selection-intent-reopen.test.mjs b/openless-all/app/scripts/selection-intent-reopen.test.mjs new file mode 100644 index 00000000..6112470f --- /dev/null +++ b/openless-all/app/scripts/selection-intent-reopen.test.mjs @@ -0,0 +1,76 @@ +// Exercise a reused native intent picker; only React/IPC/event boundaries are replaced. +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +const app = fileURLToPath(new URL('../', import.meta.url)); +const require = createRequire(import.meta.url); +const ts = require('typescript'); +const source = readFileSync(app + '/src/pages/SelectionVoiceIntentPicker.tsx', 'utf8'); +const start = source.indexOf('export function SelectionVoiceIntentPicker()'); +const end = source.indexOf('\n return (', start); +assert(start >= 0 && end >= 0); +const body = + source + .slice(start, end) + .replace('export function', 'function') + .replace("import('@tauri-apps/api/event')", 'Promise.resolve({listen})') + + 'return {choose,cancel};\n}'; +const compiled = ts.transpileModule(body, { + compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.None }, +}).outputText; +const factory = new Function( + 'useState', + 'useEffect', + 'useTranslation', + 'getSelectionVoiceIntentPrompt', + 'confirmSelectionVoiceIntentPrompt', + 'cancelSelectionVoiceIntentPrompt', + 'listen', + 'isTauri', + compiled + ';return SelectionVoiceIntentPicker();', +); +const settle = () => new Promise((resolve) => setImmediate(resolve)); +let failures = 0; +for (const action of ['choose', 'cancel']) { + const state = []; + const callbacks = new Map(); + const useState = (initial) => { + const index = state.length; + state.push(initial); + return [ + initial, + (value) => { + state[index] = value; + }, + ]; + }; + const component = factory( + useState, + (effect) => effect(), + () => ({ t: (key) => key }), + async () => ({ instruction: 'Question', sourceText: 'Selected text' }), + async () => {}, + async () => {}, + async (name, callback) => { + callbacks.set(name, callback); + return () => {}; + }, + true, + ); + await settle(); + if (action === 'choose') await component.choose('question'); + else await component.cancel(); + assert.equal(state[2], true); + callbacks.get('selection-voice-intent:shown')(); + await settle(); + try { + assert.equal(state[2], false, action + ' then hide/show must restore button usability'); + console.log(action + ': PASS'); + } catch (error) { + console.log(action + ': FAIL - ' + error.message); + failures++; + } +} +console.log(`${failures} lifecycle regression(s) reproduced`); +process.exitCode = failures ? 1 : 0; diff --git a/openless-all/app/scripts/shared-backend-wire-contract.test.mjs b/openless-all/app/scripts/shared-backend-wire-contract.test.mjs index 0cab6c6e..741c01ec 100644 --- a/openless-all/app/scripts/shared-backend-wire-contract.test.mjs +++ b/openless-all/app/scripts/shared-backend-wire-contract.test.mjs @@ -178,7 +178,7 @@ assert.match( ); assert.match( lessComputerPanel, - /reconciled\.reset\) \{\s*setTurns\(\[\]\);\s*setVoice\(null\)/, + /reconciled\.reset\) \{[^}]*?setTurns\(\[\]\);\s*setVoice\(null\)/, 'a truncated replay must reset both the conversation and voice presentation', ); diff --git a/openless-all/app/src-tauri/Cargo.lock b/openless-all/app/src-tauri/Cargo.lock index af8588ee..89f711f6 100644 --- a/openless-all/app/src-tauri/Cargo.lock +++ b/openless-all/app/src-tauri/Cargo.lock @@ -8,6 +8,16 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + [[package]] name = "aes" version = "0.8.4" @@ -174,6 +184,19 @@ dependencies = [ "x11rb", ] +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures 0.2.17", + "password-hash", + "zeroize", +] + [[package]] name = "arrayvec" version = "0.7.8" @@ -486,6 +509,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bindgen" version = "0.71.1" @@ -554,6 +583,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + [[package]] name = "block" version = "0.1.6" @@ -869,6 +907,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + [[package]] name = "chacha20" version = "0.10.1" @@ -880,6 +929,19 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20 0.9.1", + "cipher", + "poly1305", + "zeroize", +] + [[package]] name = "chrono" version = "0.4.45" @@ -902,6 +964,7 @@ checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ "crypto-common", "inout", + "zeroize", ] [[package]] @@ -1266,6 +1329,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", + "rand_core 0.6.4", "typenum", ] @@ -4243,6 +4307,12 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "open" version = "5.3.5" @@ -4337,6 +4407,7 @@ dependencies = [ "windows 0.58.0", "winreg 0.52.0", "x509-parser", + "zeroize", "zip 2.4.2", ] @@ -4345,11 +4416,14 @@ name = "openless-core" version = "0.1.0" dependencies = [ "anyhow", + "argon2", "base64 0.22.1", "bzip2 0.4.4", + "chacha20poly1305", "chrono", "ferrous-opencc", "futures-util", + "getrandom 0.3.4", "hmac", "log", "md-5", @@ -4365,8 +4439,10 @@ dependencies = [ "thiserror 1.0.69", "tokio", "tokio-tungstenite", + "unicode-normalization", "url", "uuid", + "zeroize", "zip 2.4.2", ] @@ -4507,6 +4583,17 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "paste" version = "1.0.15" @@ -4691,6 +4778,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + [[package]] name = "portable-atomic" version = "1.13.1" @@ -4994,7 +5092,7 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ - "chacha20", + "chacha20 0.10.1", "getrandom 0.4.2", "rand_core 0.10.1", ] @@ -7502,6 +7600,15 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + [[package]] name = "unicode-normalization-alignments" version = "0.1.12" @@ -7535,6 +7642,16 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + [[package]] name = "untrusted" version = "0.9.0" diff --git a/openless-all/app/src-tauri/Cargo.toml b/openless-all/app/src-tauri/Cargo.toml index 8e20bc80..fd27caab 100644 --- a/openless-all/app/src-tauri/Cargo.toml +++ b/openless-all/app/src-tauri/Cargo.toml @@ -17,6 +17,7 @@ tauri-build = { version = "2", features = [] } cc = "1.1" [dependencies] +zeroize = "1" # Framework-independent business core. Tauri remains an adapter and owns the # window/plugin/IPC concerns; Linux must depend on the same crate directly. openless-core = { path = "../crates/openless-core" } diff --git a/openless-all/app/src-tauri/dmg/README.md b/openless-all/app/src-tauri/dmg/README.md new file mode 100644 index 00000000..77719933 --- /dev/null +++ b/openless-all/app/src-tauri/dmg/README.md @@ -0,0 +1,11 @@ +# Apple Silicon 安装界面 + +背景为本次 OpenLess 安装界面生成的摄影风格图像,不包含模拟的应用或文件夹图标。构图是浅色海岸、清晨日光及中央留白,标题、双语安装说明和箭头位于背景中;真正的 `OpenLess.app` 和 `Applications` 别名由 Finder 展示,支持原生拖动安装。 + +- `installer-background@2x.png`:1536 × 1024,生成图像的交付资源。 +- `installer-background.tiff`:包含 768 × 512(72 dpi)和 1536 × 1024(144 dpi)两种表示,供 Finder Retina 背景使用。 +- `layout.json`:真实图标 128 pt、文字 14 pt;窗口及图标位置以 `../tauri.macos-mlx.conf.json` 为准。 + +通过 `INSTALL=0 ./scripts/build-mac.sh` 打包。ARM 构建使用 `scripts/macos-dmg-layout.py` 在 Tauri 压缩和签名之前写入 Finder 布局,不依赖 CI 上的 Finder 或 AppleScript。最终镜像必须通过 helper 的 `verify` 检查;图像只用于安装盘,不进入应用运行时。 + +设计来源:2026-09-26 用户给出的双图标安装示例及新照片背景要求,由 OpenAI 图像生成工具制作。后续生成可沿用“浅色石灰岩海岸、清晨柔光、中央为真实图标保留空间”的方向;这段说明是设计摘要,不是原始逐字提示。 diff --git a/openless-all/app/src-tauri/dmg/installer-background.tiff b/openless-all/app/src-tauri/dmg/installer-background.tiff new file mode 100644 index 00000000..e8494e20 Binary files /dev/null and b/openless-all/app/src-tauri/dmg/installer-background.tiff differ diff --git a/openless-all/app/src-tauri/dmg/installer-background@2x.png b/openless-all/app/src-tauri/dmg/installer-background@2x.png new file mode 100644 index 00000000..60fe5c3b Binary files /dev/null and b/openless-all/app/src-tauri/dmg/installer-background@2x.png differ diff --git a/openless-all/app/src-tauri/dmg/layout.json b/openless-all/app/src-tauri/dmg/layout.json new file mode 100644 index 00000000..29111e9e --- /dev/null +++ b/openless-all/app/src-tauri/dmg/layout.json @@ -0,0 +1,5 @@ +{ + "iconSize": 128, + "textSize": 14, + "retinaBackground": "dmg/installer-background.tiff" +} diff --git a/openless-all/app/src-tauri/src/commands/cloud_sync_e2ee.rs b/openless-all/app/src-tauri/src/commands/cloud_sync_e2ee.rs new file mode 100644 index 00000000..075acfd2 --- /dev/null +++ b/openless-all/app/src-tauri/src/commands/cloud_sync_e2ee.rs @@ -0,0 +1,246 @@ +//! Main-window-only encrypted sync IPC. Secrets never leave native Core. +use super::CoreState; +use openless_core::cloud_sync_e2ee::*; +use openless_core::{BackendError, BackendErrorCode}; + +fn require_settings_window(window: &tauri::WebviewWindow) -> Result<(), BackendError> { + let allowed = window.label() == "main" + && window.url().is_ok_and(|url| { + let bundled = (url.scheme() == "tauri" && url.host_str() == Some("localhost")) + || (matches!(url.scheme(), "https" | "http") + && url.host_str() == Some("tauri.localhost") + && url.port().is_none()); + let development = cfg!(debug_assertions) + && url.scheme() == "http" + && matches!(url.host_str(), Some("localhost" | "127.0.0.1")); + bundled || development + }); + if allowed { + Ok(()) + } else { + Err(BackendError::new( + BackendErrorCode::PermissionDenied, + "encrypted_sync_settings_window_required", + )) + } +} + +#[tauri::command] +pub async fn cloud_sync_e2ee_claim_setup_prompt( + window: tauri::WebviewWindow, + core: CoreState<'_>, +) -> Result { + require_settings_window(&window)?; + core.cloud_sync_e2ee_claim_setup_prompt().await +} + +#[tauri::command] +pub async fn cloud_sync_e2ee_status( + window: tauri::WebviewWindow, + core: CoreState<'_>, +) -> Result { + require_settings_window(&window)?; + core.cloud_sync_e2ee_status() +} + +#[tauri::command] +pub async fn cloud_sync_e2ee_prepare_enable( + window: tauri::WebviewWindow, + core: CoreState<'_>, + consent_version: String, +) -> Result { + require_settings_window(&window)?; + core.cloud_sync_e2ee_prepare_enable(consent_version).await +} + +#[tauri::command] +pub async fn cloud_sync_e2ee_create( + window: tauri::WebviewWindow, + core: CoreState<'_>, + password: String, + password_confirmation: String, + remember_key: bool, + consent_version: String, + observed_revision: String, +) -> Result { + require_settings_window(&window)?; + core.cloud_sync_e2ee_create( + password, + password_confirmation, + remember_key, + consent_version, + observed_revision, + ) + .await +} + +#[tauri::command] +pub async fn cloud_sync_e2ee_unlock( + window: tauri::WebviewWindow, + core: CoreState<'_>, + password: String, + remember_key: bool, +) -> Result { + require_settings_window(&window)?; + core.cloud_sync_e2ee_unlock(password, remember_key).await +} + +#[tauri::command] +pub async fn cloud_sync_e2ee_lock( + window: tauri::WebviewWindow, + core: CoreState<'_>, +) -> Result { + require_settings_window(&window)?; + core.cloud_sync_e2ee_lock().await +} + +#[tauri::command] +pub async fn cloud_sync_e2ee_set_enabled( + window: tauri::WebviewWindow, + core: CoreState<'_>, + enabled: bool, +) -> Result { + require_settings_window(&window)?; + core.cloud_sync_e2ee_set_enabled(enabled).await +} + +#[tauri::command] +pub async fn cloud_sync_e2ee_sync_now( + window: tauri::WebviewWindow, + core: CoreState<'_>, +) -> Result { + require_settings_window(&window)?; + core.cloud_sync_e2ee_sync_now().await +} + +#[tauri::command] +pub async fn cloud_sync_e2ee_cancel( + window: tauri::WebviewWindow, + core: CoreState<'_>, + task_id: String, +) -> Result { + require_settings_window(&window)?; + core.cloud_sync_e2ee_cancel(task_id) +} + +#[tauri::command] +pub async fn cloud_sync_e2ee_preview_restore( + window: tauri::WebviewWindow, + core: CoreState<'_>, + observed_revision: String, +) -> Result { + require_settings_window(&window)?; + core.cloud_sync_e2ee_preview_restore(observed_revision) + .await +} + +#[tauri::command] +pub async fn cloud_sync_e2ee_apply_restore( + window: tauri::WebviewWindow, + core: CoreState<'_>, + preview_id: String, + mode: RestoreMode, + conflict_choices: Vec, +) -> Result { + require_settings_window(&window)?; + core.cloud_sync_e2ee_apply_restore(preview_id, mode, conflict_choices) + .await +} + +#[tauri::command] +pub async fn cloud_sync_e2ee_change_password( + window: tauri::WebviewWindow, + core: CoreState<'_>, + current_password: String, + new_password: String, + confirmation: String, + remember_key: bool, +) -> Result { + require_settings_window(&window)?; + core.cloud_sync_e2ee_change_password(current_password, new_password, confirmation, remember_key) + .await +} + +#[tauri::command] +pub async fn cloud_sync_e2ee_delete_remote( + window: tauri::WebviewWindow, + core: CoreState<'_>, + expected_vault_id: String, + observed_revision: String, + confirmed: bool, +) -> Result { + require_settings_window(&window)?; + core.cloud_sync_e2ee_delete_remote(expected_vault_id, observed_revision, confirmed) + .await +} + +#[tauri::command] +pub async fn cloud_sync_e2ee_sign_out( + window: tauri::WebviewWindow, + core: CoreState<'_>, +) -> Result { + require_settings_window(&window)?; + core.cloud_sync_e2ee_sign_out().await +} + +#[tauri::command] +pub async fn cloud_sync_e2ee_begin_sign_in( + window: tauri::WebviewWindow, + core: CoreState<'_>, +) -> Result { + require_settings_window(&window)?; + core.cloud_sync_e2ee_begin_sign_in().await +} + +#[tauri::command] +pub async fn cloud_sync_e2ee_poll_sign_in( + window: tauri::WebviewWindow, + core: CoreState<'_>, + authorization_session_id: String, +) -> Result { + require_settings_window(&window)?; + core.cloud_sync_e2ee_poll_sign_in(authorization_session_id) + .await +} + +#[tauri::command] +pub async fn cloud_sync_e2ee_cancel_sign_in( + window: tauri::WebviewWindow, + core: CoreState<'_>, + authorization_session_id: String, +) -> Result<(), BackendError> { + require_settings_window(&window)?; + core.cloud_sync_e2ee_cancel_sign_in(authorization_session_id) + .await +} + +#[tauri::command] +pub async fn cloud_sync_e2ee_get_ui_preferences( + window: tauri::WebviewWindow, + core: CoreState<'_>, +) -> Result, BackendError> { + require_settings_window(&window)?; + core.cloud_sync_e2ee_get_ui_preferences().await +} + +#[tauri::command] +pub async fn cloud_sync_e2ee_get_ui_preferences_snapshot( + window: tauri::WebviewWindow, + core: CoreState<'_>, +) -> Result { + require_settings_window(&window)?; + core.cloud_sync_e2ee_get_ui_preferences_snapshot().await +} + +#[tauri::command] +pub async fn cloud_sync_e2ee_set_ui_preferences_checked( + window: tauri::WebviewWindow, + core: CoreState<'_>, + locale: String, + font_scale: String, + expected_revision: Option, +) -> Result<(), BackendError> { + require_settings_window(&window)?; + core.cloud_sync_e2ee_set_ui_preferences_checked(locale, font_scale, expected_revision) + .await +} diff --git a/openless-all/app/src-tauri/src/commands/credentials.rs b/openless-all/app/src-tauri/src/commands/credentials.rs index 497f672b..31cf8870 100644 --- a/openless-all/app/src-tauri/src/commands/credentials.rs +++ b/openless-all/app/src-tauri/src/commands/credentials.rs @@ -104,6 +104,82 @@ pub(crate) fn active_sherpa_asr_is_supported(provider: &str) -> bool { } impl openless_core::CredentialStore for SystemCredentialStore { + fn bind_sync_gate( + &self, + gate: std::sync::Arc, + ) -> Result<(), openless_core::BackendError> { + CredentialsVault::bind_sync_gate(gate).map_err(credential_persistence_error) + } + + fn export_sync_credentials_readonly( + &self, + ) -> futures_util::future::BoxFuture< + 'static, + Result, + > { + run_credential_task(|| { + CredentialsVault::export_sync_credentials_readonly() + .map_err(credential_persistence_error) + }) + } + + fn export_sync_credentials( + &self, + permit: &openless_core::credentials::ExclusivePermit, + ) -> futures_util::future::BoxFuture< + 'static, + Result, + > { + let permit = permit.clone(); + run_credential_task(move || { + CredentialsVault::export_sync_credentials(&permit).map_err(credential_persistence_error) + }) + } + + fn replace_sync_credentials( + &self, + snapshot: openless_core::credentials::SyncCredentials, + permit: &openless_core::credentials::ExclusivePermit, + ) -> futures_util::future::BoxFuture<'static, Result<(), openless_core::BackendError>> { + let permit = permit.clone(); + run_credential_task(move || { + CredentialsVault::replace_sync_credentials(&snapshot, &permit) + .map_err(credential_persistence_error) + }) + } + + fn read_sync_secret( + &self, + account: openless_core::credentials::SyncSecretAccount, + ) -> futures_util::future::BoxFuture< + 'static, + Result, openless_core::BackendError>, + > { + run_credential_task(move || { + CredentialsVault::read_sync_secret(&account).map_err(credential_persistence_error) + }) + } + + fn write_sync_secret( + &self, + account: openless_core::credentials::SyncSecretAccount, + value: openless_core::SecretValue, + ) -> futures_util::future::BoxFuture<'static, Result<(), openless_core::BackendError>> { + run_credential_task(move || { + CredentialsVault::write_sync_secret(&account, &value) + .map_err(credential_persistence_error) + }) + } + + fn remove_sync_secret( + &self, + account: openless_core::credentials::SyncSecretAccount, + ) -> futures_util::future::BoxFuture<'static, Result<(), openless_core::BackendError>> { + run_credential_task(move || { + CredentialsVault::remove_sync_secret(&account).map_err(credential_persistence_error) + }) + } + fn status( &self, preferences: UserPreferences, @@ -192,10 +268,10 @@ fn run_credential_task( Box::pin(async move { tauri::async_runtime::spawn_blocking(task) .await - .map_err(|error| { + .map_err(|_| { openless_core::BackendError::new( openless_core::BackendErrorCode::Internal, - format!("credential worker failed: {error}"), + "credential worker failed", ) })? }) @@ -420,10 +496,18 @@ fn invalid_credential_key(key: &openless_core::CredentialKey) -> openless_core:: } fn credential_persistence_error(error: anyhow::Error) -> openless_core::BackendError { - openless_core::BackendError::new( - openless_core::BackendErrorCode::Persistence, - format!("credential vault operation failed: {error:#}"), - ) + if let Some(error) = error.downcast_ref::() { + return error.clone(); + } + let code = if matches!( + error.downcast_ref::(), + Some(crate::persistence::VaultCommitFailure::Unknown) + ) { + openless_core::BackendErrorCode::OutcomeUnknown + } else { + openless_core::BackendErrorCode::Persistence + }; + openless_core::BackendError::new(code, "credential vault operation failed") } fn require_readable_vault() -> Result<(), openless_core::BackendError> { @@ -889,6 +973,43 @@ fn parse_account(s: &str) -> Result { mod tests { use super::*; + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn cancelled_credential_worker_holds_restore_lease_until_it_finishes() { + let dir = std::env::temp_dir().join(format!( + "openless-credential-worker-{}", + uuid::Uuid::new_v4() + )); + let gate = + openless_core::credentials::SyncWriteGate::open(dir.join("generation.json")).unwrap(); + let original = gate.try_exclusive().unwrap(); + let worker_lease = original.clone(); + let (started_tx, started_rx) = tokio::sync::oneshot::channel(); + let (finish_tx, finish_rx) = std::sync::mpsc::channel(); + let (done_tx, done_rx) = tokio::sync::oneshot::channel(); + let task = tokio::spawn(run_credential_task(move || { + let _lease = worker_lease; + let _ = started_tx.send(()); + finish_rx + .recv_timeout(std::time::Duration::from_secs(5)) + .unwrap(); + drop(_lease); + let _ = done_tx.send(()); + Ok(()) + })); + started_rx.await.unwrap(); + drop(original); + task.abort(); + let _ = task.await; + assert!(gate.begin_mutation().is_err()); + assert!(gate.try_exclusive().is_err()); + finish_tx.send(()).unwrap(); + done_rx.await.unwrap(); + let permit = gate.begin_mutation().unwrap(); + permit.abort_unmodified().unwrap(); + drop(gate); + std::fs::remove_dir_all(dir).unwrap(); + } + #[test] fn omni_credential_keys_preserve_the_explicit_provider_scope() { for account in [ diff --git a/openless-all/app/src-tauri/src/commands/github_oauth.rs b/openless-all/app/src-tauri/src/commands/github_oauth.rs index 406514b6..0f1f20ee 100644 --- a/openless-all/app/src-tauri/src/commands/github_oauth.rs +++ b/openless-all/app/src-tauri/src/commands/github_oauth.rs @@ -75,11 +75,7 @@ pub async fn marketplace_auth_status( #[tauri::command] pub async fn marketplace_logout(core: CoreState<'_>) -> Result<(), String> { - core.services() - .marketplace - .logout() - .await - .map_err(command_error) + core.sign_out_account().await.map_err(command_error) } #[cfg(test)] diff --git a/openless-all/app/src-tauri/src/commands/mod.rs b/openless-all/app/src-tauri/src/commands/mod.rs index fb485e92..c6ed9b43 100644 --- a/openless-all/app/src-tauri/src/commands/mod.rs +++ b/openless-all/app/src-tauri/src/commands/mod.rs @@ -49,6 +49,7 @@ pub(crate) use crate::types::{ mod channels; mod cloud_sync; +mod cloud_sync_e2ee; mod credentials; mod dictation; mod dictionary; @@ -79,6 +80,7 @@ mod style_packs; pub use channels::*; pub use cloud_sync::*; +pub use cloud_sync_e2ee::*; pub use credentials::*; pub use dictation::*; pub use dictionary::*; diff --git a/openless-all/app/src-tauri/src/commands/qa.rs b/openless-all/app/src-tauri/src/commands/qa.rs index ab24c5a5..64e836cb 100644 --- a/openless-all/app/src-tauri/src/commands/qa.rs +++ b/openless-all/app/src-tauri/src/commands/qa.rs @@ -50,12 +50,34 @@ pub async fn qa_toggle_recording(core: CoreState<'_>) -> Result<(), String> { /// QA 面板键盘输入:复用语音 QA 的 LLM 管线,只替换问题来源。 #[tauri::command] -pub async fn qa_submit_text(core: CoreState<'_>, text: String) -> Result<(), String> { - core.services() - .qa - .submit_text(text) - .await - .map_err(|error| error.message) +pub async fn qa_submit_text(core: CoreState<'_>, text: String, expected_session_id: Option, enforce_context: Option) -> Result<(), String> { + if enforce_context.unwrap_or(false) { + core.services().qa.submit_text_in_context(text, expected_session_id).await.map_err(|error| error.message) + } else { + core.services().qa.submit_text(text).await.map_err(|error| error.message) + } +} + +#[tauri::command] +pub async fn qa_get_snapshot(window: Window, core: CoreState<'_>) -> Result { + if !matches!(window.label(), "qa" | "main") { return Err("qa_window_required".into()); } + let snapshot = core.services().qa.snapshot().await.map_err(|_| "qa_snapshot_unavailable")?; + let mut event = openless_core::events::QaStateEvent::from_snapshot(&snapshot); + event.edit_instruction_mode = Some(snapshot.edit_instruction_mode); + event.edit_apply_available = Some(snapshot.edit_apply_available); + event.edit_revert_available = Some(snapshot.edit_revert_available); + Ok(event) +} + +#[tauri::command] +pub async fn qa_window_set_expanded(window: Window, expanded: bool) -> Result<(), String> { + if window.label() != "qa" { return Err("qa_window_required".into()); } + let app = window.app_handle().clone(); + let (sender, receiver) = tokio::sync::oneshot::channel(); + window.app_handle().run_on_main_thread(move || { + let _ = sender.send(crate::set_qa_window_expanded(&app, expanded)); + }).map_err(|_| "qa_main_thread_unavailable".to_string())?; + receiver.await.map_err(|_| "qa_resize_cancelled".to_string())? } /// 划词提问面板「编辑指令」复选框。 diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 42246a5a..e791656b 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -37,6 +37,7 @@ mod hotkey_loops; #[cfg(target_os = "macos")] mod native_dictation_key; mod qa; +mod restore_runtime; #[cfg(all(not(mobile), target_os = "windows"))] pub(crate) mod selection_voice_session; use capsule_focus::*; @@ -355,6 +356,39 @@ pub struct Coordinator { inner: Arc, } +fn startup_storage_error() -> openless_core::BackendError { + openless_core::BackendError::new( + openless_core::BackendErrorCode::Persistence, + "local recovery or secure storage is unavailable; restore access and restart OpenLess", + ) + .retryable(true) +} + +fn startup_sync_gate( +) -> Result, openless_core::BackendError> { + let directory = crate::persistence::data_dir().map_err(|_| startup_storage_error())?; + openless_core::cloud_sync_e2ee_store::gate::open_for_data_dir(&directory) + .map_err(|_| startup_storage_error()) +} + +fn startup_store( + startup_error: &mut Option, + open: impl FnOnce() -> Result, + fallback: impl FnOnce() -> T, +) -> T { + if startup_error.is_some() { + return fallback(); + } + match open() { + Ok(store) => store, + Err(_) => { + log::error!("[core] local store initialization failed; startup is blocked"); + *startup_error = Some(startup_storage_error()); + fallback() + } + } +} + fn shared_backend_from_stores( history: &HistoryStore, activity: &ActivityStore, @@ -366,6 +400,7 @@ fn shared_backend_from_stores( native_asr: crate::core_adapters::TauriNativeAsrDependencies, hotkey_status: Arc>, qa_context: Arc, + startup_error: Option, ) -> Arc { let data_dir = crate::persistence::data_dir().unwrap_or_else(|error| { log::warn!("[core] data directory unavailable, using fallback config path: {error}"); @@ -375,6 +410,21 @@ fn shared_backend_from_stores( .ok() .filter(|value| !value.trim().is_empty()) .unwrap_or_else(|| "en-US".to_string()); + let config = openless_core::BackendConfig { + cache_dir: data_dir.join("cache"), + data_dir, + home_dir: std::env::var_os("HOME") + .or_else(|| std::env::var_os("USERPROFILE")) + .map(std::path::PathBuf::from), + resource_dir: None, + platform: crate::types::PlatformCapabilities::current(), + locale, + }; + if let Some(error) = startup_error { + return Arc::new(openless_core::OpenLessBackend::blocked_startup( + config, error, + )); + } let repositories = openless_core::BackendRepositories { preferences: prefs.core(), history: history.core(), @@ -392,23 +442,28 @@ fn shared_backend_from_stores( hotkey_status, qa_context, ); - dependencies.marketplace_config = Some(openless_core::MarketplaceConfig::production()); - let backend = Arc::new( - openless_core::OpenLessBackend::new_with_repositories( - openless_core::BackendConfig { - cache_dir: data_dir.join("cache"), - data_dir, - home_dir: std::env::var_os("HOME") - .or_else(|| std::env::var_os("USERPROFILE")) - .map(std::path::PathBuf::from), - resource_dir: None, - platform: crate::types::PlatformCapabilities::current(), - locale, + dependencies.marketplace_config = Some( + openless_core::MarketplaceConfig::production().with_encrypted_sync( + openless_core::cloud_sync_e2ee::EncryptedSyncConfig { + service_origin: openless_core::cloud_sync_e2ee::DEFAULT_SYNC_SERVICE_ORIGIN.into(), + app_version: env!("CARGO_PKG_VERSION").into(), }, + ), + ); + let backend = Arc::new( + match openless_core::OpenLessBackend::new_with_repositories( + config.clone(), dependencies, repositories, - ) - .expect("shared backend config always has a non-empty data directory"), + ) { + Ok(backend) => backend, + Err(_) => { + log::error!( + "[core] shared backend initialization failed; exposing blocked startup" + ); + openless_core::OpenLessBackend::blocked_startup(config, startup_storage_error()) + } + }, ); *backend_slot.lock() = Some(Arc::downgrade(&backend)); backend @@ -448,6 +503,7 @@ struct Inner { /// 串行化 Tauri 侧“Core 设置事务 + 宿主 effect”以及风格包删除 effect, /// 防止两个命令把显式 runtime target 乱序安装。 settings_host_gate: Mutex<()>, + hotkey_resume_started: AtomicBool, overlay_qa_handoff: tokio::sync::Mutex<()>, inserter: TextInserter, /// 建议卡片是不是正占着胶囊窗口。 @@ -543,48 +599,48 @@ impl Coordinator { #[cfg(not(target_os = "windows"))] { - #[cfg(target_os = "android")] - const PERSIST_DEGRADE_SUFFIX: &str = " (Android 禁止 /data/local/tmp)"; - #[cfg(not(target_os = "android"))] - const PERSIST_DEGRADE_SUFFIX: &str = ""; - - let history = HistoryStore::new().unwrap_or_else(|e| { - log::error!( - "[coord] HistoryStore init failed: {e}; 降级为空历史记录{PERSIST_DEGRADE_SUFFIX}" - ); - HistoryStore::new_fallback() - }); - let prefs = PreferencesStore::new().unwrap_or_else(|e| { - log::error!( - "[coord] PreferencesStore init failed: {e}; 降级为默认偏好设置{PERSIST_DEGRADE_SUFFIX}" - ); - PreferencesStore::new_fallback() - }); - // 启动即同步系统代理开关(issue #869),让首个请求就按用户设置建客户端。 - crate::net::set_use_system_proxy(prefs.get().use_system_proxy); - let style_packs = StylePackStore::new(&prefs).unwrap_or_else(|e| { - log::error!( - "[coord] StylePackStore init failed: {e}; 降级为空样式包列表{PERSIST_DEGRADE_SUFFIX}" - ); - StylePackStore::new_fallback() - }); - let vocab = DictionaryStore::new().unwrap_or_else(|e| { - log::error!( - "[coord] DictionaryStore init failed: {e}; 降级为空词库{PERSIST_DEGRADE_SUFFIX}" - ); - DictionaryStore::new_fallback() - }); - let correction_rules = CorrectionRuleStore::new().unwrap_or_else(|e| { - log::error!( - "[coord] CorrectionRuleStore init failed: {e}; 降级为空纠错规则{PERSIST_DEGRADE_SUFFIX}" - ); - CorrectionRuleStore::new_fallback() - }); - - let activity = ActivityStore::load().unwrap_or_else(|e| { - log::error!("[coord] ActivityStore init failed: {e}; 活动计数降级为内存态"); - ActivityStore::new_fallback() - }); + let gate = startup_sync_gate(); + let mut startup_error = gate.as_ref().err().cloned(); + // Keep the registered barrier alive until Core adopts it. Once any store + // fails, later constructors use fallbacks and no real repository is touched. + let _sync_gate = gate.ok(); + let history = startup_store( + &mut startup_error, + HistoryStore::new, + HistoryStore::new_fallback, + ); + let prefs = startup_store( + &mut startup_error, + PreferencesStore::new, + PreferencesStore::new_fallback, + ); + if startup_error.is_none() + && _sync_gate + .as_ref() + .is_some_and(|gate| !gate.recovery_required().unwrap_or(true)) + { + crate::net::set_use_system_proxy(prefs.get().use_system_proxy); + } + let style_packs = startup_store( + &mut startup_error, + || StylePackStore::new(&prefs), + StylePackStore::new_fallback, + ); + let vocab = startup_store( + &mut startup_error, + DictionaryStore::new, + DictionaryStore::new_fallback, + ); + let correction_rules = startup_store( + &mut startup_error, + CorrectionRuleStore::new, + CorrectionRuleStore::new_fallback, + ); + let activity = startup_store( + &mut startup_error, + ActivityStore::load, + ActivityStore::new_fallback, + ); let app = crate::core_adapters::app_handle_slot(); let native_asr = crate::core_adapters::TauriNativeAsrDependencies::new(); @@ -601,6 +657,7 @@ impl Coordinator { native_asr.clone(), Arc::clone(&hotkey_status), Arc::clone(&qa_context), + startup_error, ); let host = crate::tauri_coordinator_host::TauriCoordinatorHost::new(Arc::clone(&app)); @@ -611,6 +668,7 @@ impl Coordinator { less_computer_voice: Mutex::new(None), hotkey_runtime_target: Mutex::new(hotkey_runtime_target), settings_host_gate: Mutex::new(()), + hotkey_resume_started: AtomicBool::new(false), overlay_qa_handoff: tokio::sync::Mutex::new(()), inserter: TextInserter::new(), vocab_card_visible: AtomicBool::new(false), @@ -664,33 +722,48 @@ impl Coordinator { foundry_local_runtime: Arc, sherpa_onnx_runtime: Arc, ) -> Self { - let history = HistoryStore::new().unwrap_or_else(|e| { - log::error!("[coord] HistoryStore init failed: {e}; 降级为空历史记录"); - HistoryStore::new_fallback() - }); - let prefs = PreferencesStore::new().unwrap_or_else(|e| { - log::error!("[coord] PreferencesStore init failed: {e}; 降级为默认偏好设置"); - PreferencesStore::new_fallback() - }); - // 启动即同步系统代理开关(issue #869),让首个请求就按用户设置建客户端。 - crate::net::set_use_system_proxy(prefs.get().use_system_proxy); - let style_packs = StylePackStore::new(&prefs).unwrap_or_else(|e| { - log::error!("[coord] StylePackStore init failed: {e}; 降级为空样式包列表"); - StylePackStore::new_fallback() - }); - let vocab = DictionaryStore::new().unwrap_or_else(|e| { - log::error!("[coord] DictionaryStore init failed: {e}; 降级为空词库"); - DictionaryStore::new_fallback() - }); - let correction_rules = CorrectionRuleStore::new().unwrap_or_else(|e| { - log::error!("[coord] CorrectionRuleStore init failed: {e}; 降级为空纠错规则"); - CorrectionRuleStore::new_fallback() - }); - - let activity = ActivityStore::load().unwrap_or_else(|e| { - log::error!("[coord] ActivityStore init failed: {e}; 活动计数降级为内存态"); - ActivityStore::new_fallback() - }); + let gate = startup_sync_gate(); + let mut startup_error = gate.as_ref().err().cloned(); + // Keep the registered barrier alive until Core adopts it. Once any store + // fails, later constructors use fallbacks and no real repository is touched. + let _sync_gate = gate.ok(); + let history = startup_store( + &mut startup_error, + HistoryStore::new, + HistoryStore::new_fallback, + ); + let prefs = startup_store( + &mut startup_error, + PreferencesStore::new, + PreferencesStore::new_fallback, + ); + if startup_error.is_none() + && _sync_gate + .as_ref() + .is_some_and(|gate| !gate.recovery_required().unwrap_or(true)) + { + crate::net::set_use_system_proxy(prefs.get().use_system_proxy); + } + let style_packs = startup_store( + &mut startup_error, + || StylePackStore::new(&prefs), + StylePackStore::new_fallback, + ); + let vocab = startup_store( + &mut startup_error, + DictionaryStore::new, + DictionaryStore::new_fallback, + ); + let correction_rules = startup_store( + &mut startup_error, + CorrectionRuleStore::new, + CorrectionRuleStore::new_fallback, + ); + let activity = startup_store( + &mut startup_error, + ActivityStore::load, + ActivityStore::new_fallback, + ); let app = crate::core_adapters::app_handle_slot(); let hotkey_status = Arc::new(Mutex::new(HotkeyStatus::default())); @@ -712,6 +785,7 @@ impl Coordinator { ), Arc::clone(&hotkey_status), Arc::clone(&qa_context), + startup_error, ); let host = crate::tauri_coordinator_host::TauriCoordinatorHost::new(Arc::clone(&app)); @@ -722,6 +796,7 @@ impl Coordinator { less_computer_voice: Mutex::new(None), hotkey_runtime_target: Mutex::new(hotkey_runtime_target), settings_host_gate: Mutex::new(()), + hotkey_resume_started: AtomicBool::new(false), overlay_qa_handoff: tokio::sync::Mutex::new(()), inserter: TextInserter::new(), vocab_card_visible: AtomicBool::new(false), @@ -758,6 +833,10 @@ impl Coordinator { Self { inner } } + pub fn startup_error(&self) -> Option { + self.inner.backend.startup_error() + } + pub fn backend(&self) -> Arc { Arc::clone(&self.inner.backend) } @@ -906,7 +985,60 @@ impl Coordinator { self.inner.shutdown.store(true, Ordering::SeqCst); } + /// Call once from RunEvent::Ready, even when recovery is still pending. + /// Installation waits for the fence; sleeping never owns the settings gate. + pub fn start_hotkey_supervisors_when_ready(&self) { + if self + .inner + .hotkey_resume_started + .swap(true, Ordering::AcqRel) + { + return; + } + let weak = Arc::downgrade(&self.inner); + let fallback = weak.clone(); + if std::thread::Builder::new() + .name("openless-hotkey-resume".into()) + .spawn(move || loop { + let Some(inner) = weak.upgrade() else { + return; + }; + if inner.shutdown.load(Ordering::SeqCst) || inner.backend.startup_error().is_some() + { + return; + } + if inner.backend.ensure_runtime_ready().is_ok() { + let coord = Coordinator { inner }; + coord.start_hotkey_listener(); + coord.start_qa_hotkey_listener(); + #[cfg(not(mobile))] + coord.start_selection_polish_hotkey_listener(); + coord.start_coding_agent_hotkey_listener(); + coord.start_combo_hotkey_listener(); + coord.start_translation_hotkey_listener(); + coord.start_switch_style_hotkey_listener(); + coord.start_open_app_hotkey_listener(); + coord.start_quick_note_hotkey_listener(); + coord.start_style_pack_hotkey_listeners(); + return; + } + drop(inner); + std::thread::sleep(std::time::Duration::from_secs(1)); + }) + .is_err() + { + if let Some(inner) = fallback.upgrade() { + inner.hotkey_resume_started.store(false, Ordering::Release); + } + log::error!("[coord] hotkey resume supervisor could not start"); + } + } + pub fn start_hotkey_listener(&self) { + if self.inner.backend.ensure_runtime_ready().is_err() { + log::info!("[coord] hotkey startup waits for local recovery"); + return; + } // 起一个守护线程,反复尝试安装 hotkey hook。Accessibility 一被授予就立即生效, // 用户不需要手动重启 OpenLess。 let inner = Arc::clone(&self.inner); @@ -1384,6 +1516,15 @@ impl Coordinator { } fn ensure_modifier_hotkey_monitor(&self, binding: crate::types::HotkeyBinding) { + if let Err(error) = self.try_ensure_modifier_hotkey_monitor(binding) { + log::warn!("[coord] modifier hotkey update failed: {error}"); + } + } + + fn try_ensure_modifier_hotkey_monitor( + &self, + binding: crate::types::HotkeyBinding, + ) -> Result<(), String> { if let Some(monitor) = self.inner.hotkey.lock().as_ref() { #[cfg(target_os = "linux")] let plugin_binding = binding.clone(); @@ -1394,7 +1535,7 @@ impl Coordinator { } else { crate::linux_fcitx::sync_binding_to_plugin(&plugin_binding); } - return; + return Ok(()); } let (tx, rx) = mpsc::channel::(); #[cfg(target_os = "linux")] @@ -1406,6 +1547,13 @@ impl Coordinator { match HotkeyMonitor::start(binding, tx, cancel_tx, combo_tx) { Ok(monitor) => { let adapter = monitor.kind(); + let inner_clone = Arc::clone(&self.inner); + std::thread::Builder::new() + .name("openless-hotkey-bridge".into()) + .spawn(move || hotkey_bridge_loop(inner_clone, rx)) + .map_err(|error| error.to_string())?; + // Publish only after the bridge exists. A failed thread spawn + // must drop this monitor, not leave a registered dead sender. *self.inner.hotkey.lock() = Some(monitor); *self.inner.hotkey_status.lock() = HotkeyStatus { adapter, @@ -1413,11 +1561,6 @@ impl Coordinator { message: Some(format!("{} 已安装", adapter.display_name())), last_error: None, }; - let inner_clone = Arc::clone(&self.inner); - std::thread::Builder::new() - .name("openless-hotkey-bridge".into()) - .spawn(move || hotkey_bridge_loop(inner_clone, rx)) - .ok(); // Linux: 启动 fcitx5 插件信号监听作为热键源。 #[cfg(target_os = "linux")] { @@ -1445,10 +1588,12 @@ impl Coordinator { adapter: HotkeyMonitor::capability().adapter, state: HotkeyStatusState::Failed, message: Some(e.message.clone()), - last_error: Some(e), + last_error: Some(e.clone()), }; + return Err(e.message); } } + Ok(()) } pub fn update_modifier_shortcut_bindings(&self) { @@ -1771,3 +1916,43 @@ fn schedule_selection_polish_capsule_idle(inner: &Arc, epoch: u64, delay_ hide_selection_polish_capsule_if_current(&inner, epoch); }); } + +#[cfg(test)] +mod startup_restore_tests { + use super::*; + + #[test] + fn restore_host_denied_store_blocks_later_real_constructors() { + let mut failure = None; + let first = startup_store(&mut failure, || Err::("denied"), || 7); + assert_eq!(first, 7); + assert!(failure.is_some()); + let second = startup_store( + &mut failure, + || -> Result { panic!("must not open another real store after failure") }, + || 9, + ); + assert_eq!(second, 9); + let backend = openless_core::OpenLessBackend::blocked_startup( + openless_core::BackendConfig::default(), + failure.unwrap(), + ); + assert!(backend.startup_error().is_some()); + assert!(!backend.snapshot().running); + assert!(backend.ensure_runtime_ready().is_err()); + } + + #[test] + fn restore_host_healthy_store_does_not_use_fallback() { + let mut failure = None; + assert_eq!( + startup_store( + &mut failure, + || Ok::<_, ()>(11), + || panic!("unexpected fallback") + ), + 11 + ); + assert!(failure.is_none()); + } +} diff --git a/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs b/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs index d2d0b39f..f5cc414d 100644 --- a/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs +++ b/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs @@ -86,6 +86,13 @@ pub(super) fn hotkey_supervisor_loop(inner: Arc) { if inner.shutdown.load(Ordering::SeqCst) { return; } + let registration = inner.settings_host_gate.lock(); + if inner.backend.ensure_runtime_ready().is_err() { + drop(registration); + std::thread::sleep(std::time::Duration::from_secs(1)); + continue; + } + let target = hotkey_runtime_target(&inner); if inner.hotkey.lock().is_some() { @@ -105,6 +112,7 @@ pub(super) fn hotkey_supervisor_loop(inner: Arc) { }; log::warn!("[hotkey-supervisor] fcitx5 plugin unavailable, retrying..."); attempts += 1; + drop(registration); std::thread::sleep(std::time::Duration::from_secs(3)); continue; } @@ -194,6 +202,7 @@ pub(super) fn hotkey_supervisor_loop(inner: Arc) { error_message ); } + drop(registration); std::thread::sleep(std::time::Duration::from_secs(3)); } } @@ -202,23 +211,40 @@ pub(super) fn hotkey_supervisor_loop(inner: Arc) { // ─────────────────────────── QA hotkey supervisor ─────────────────────────── +fn take_qa_hotkey_on_main_thread(inner: &Arc) { + let main = Arc::clone(inner); + if let Err(error) = inner.host.run_on_main_thread(move || { + main.qa_hotkey.lock().take(); + }) { + log::warn!("[qa] cannot dispatch hotkey removal: {error}"); + } +} + pub(super) fn qa_hotkey_supervisor_loop(inner: Arc) { let mut attempts: u32 = 0; loop { if inner.shutdown.load(Ordering::SeqCst) { return; } + let registration = inner.settings_host_gate.lock(); + if inner.backend.ensure_runtime_ready().is_err() { + drop(registration); + std::thread::sleep(std::time::Duration::from_secs(1)); + continue; + } + // 用户已经把 QA 关掉就睡着等 runtime target 改动;改动通过显式 settings effect 唤醒。 let binding = match hotkey_runtime_target(&inner).qa { Some(b) => b, None => { - inner.qa_hotkey.lock().take(); + take_qa_hotkey_on_main_thread(&inner); + drop(registration); std::thread::sleep(std::time::Duration::from_secs(5)); continue; } }; if crate::shortcut_binding::legacy_modifier_trigger(&binding).is_some() { - inner.qa_hotkey.lock().take(); + take_qa_hotkey_on_main_thread(&inner); if let Some(monitor) = inner.hotkey.lock().as_ref() { let (qa_trigger, selection_polish_trigger, translation_trigger) = modifier_shortcut_triggers(&inner); @@ -228,12 +254,14 @@ pub(super) fn qa_hotkey_supervisor_loop(inner: Arc) { translation_trigger, ); } + drop(registration); std::thread::sleep(std::time::Duration::from_secs(5)); continue; } if inner.qa_hotkey.lock().is_some() { // 已注册成功 → 不重复装;睡 5s 复查( binding 变化由 update 路径手动触发 )。 + drop(registration); std::thread::sleep(std::time::Duration::from_secs(5)); continue; } @@ -243,7 +271,7 @@ pub(super) fn qa_hotkey_supervisor_loop(inner: Arc) { // PR #119 第一版漏掉的关键步骤,导致用户按了 hotkey 完全无反应。这里通过 // run_on_main_thread 把 QaHotkeyMonitor::start 跳到主线程跑,结果再回 channel。 let (tx, rx) = mpsc::channel::(); - let (init_tx, init_rx) = mpsc::sync_channel::>(1); + let (init_tx, init_rx) = mpsc::sync_channel::>(0); let binding_for_main = binding.clone(); if inner .host @@ -253,6 +281,7 @@ pub(super) fn qa_hotkey_supervisor_loop(inner: Arc) { }) .is_err() { + drop(registration); std::thread::sleep(std::time::Duration::from_secs(1)); continue; } @@ -262,12 +291,14 @@ pub(super) fn qa_hotkey_supervisor_loop(inner: Arc) { let init_result = match init_rx.recv_timeout(std::time::Duration::from_secs(5)) { Ok(r) => r, Err(_) => { + drop(init_rx); attempts += 1; if attempts <= 3 || attempts % 10 == 0 { log::warn!( "[coord] QA hotkey 第 {attempts} 次注册超时(主线程未回执);3s 后重试" ); } + drop(registration); std::thread::sleep(std::time::Duration::from_secs(3)); continue; } @@ -292,6 +323,7 @@ pub(super) fn qa_hotkey_supervisor_loop(inner: Arc) { if attempts <= 3 || attempts % 10 == 0 { log::warn!("[coord] QA hotkey 第 {attempts} 次注册失败: {e}; 3s 后重试"); } + drop(registration); std::thread::sleep(std::time::Duration::from_secs(3)); } } @@ -324,6 +356,13 @@ pub(super) fn selection_polish_hotkey_supervisor_loop(inner: Arc) { if inner.shutdown.load(Ordering::SeqCst) { return; } + let registration = inner.settings_host_gate.lock(); + if inner.backend.ensure_runtime_ready().is_err() { + drop(registration); + std::thread::sleep(std::time::Duration::from_secs(1)); + continue; + } + match try_update_selection_polish_hotkey_binding(&inner) { Ok(()) => return, Err(error) => { @@ -333,6 +372,7 @@ pub(super) fn selection_polish_hotkey_supervisor_loop(inner: Arc) { "[selection-polish] hotkey registration attempt #{attempts} failed: {error}; retrying in 3s" ); } + drop(registration); std::thread::sleep(std::time::Duration::from_secs(3)); } } @@ -613,9 +653,17 @@ pub(super) fn coding_agent_hotkey_supervisor_loop(inner: Arc) { if inner.shutdown.load(Ordering::SeqCst) { return; } + let registration = inner.settings_host_gate.lock(); + if inner.backend.ensure_runtime_ready().is_err() { + drop(registration); + std::thread::sleep(std::time::Duration::from_secs(1)); + continue; + } + if let Err(error) = update_coding_agent_hotkey_binding_now(&inner) { log::warn!("[less-computer] hotkey registration failed: {error}"); } + drop(registration); std::thread::sleep(std::time::Duration::from_secs(5)); } } @@ -1273,6 +1321,13 @@ pub(super) fn combo_hotkey_supervisor_loop(inner: Arc) { if inner.shutdown.load(Ordering::SeqCst) { return; } + let registration = inner.settings_host_gate.lock(); + if inner.backend.ensure_runtime_ready().is_err() { + drop(registration); + std::thread::sleep(std::time::Duration::from_secs(1)); + continue; + } + let target = hotkey_runtime_target(&inner); if crate::shortcut_binding::legacy_modifier_trigger(&target.dictation).is_some() { take_combo_hotkey_on_main_thread(&inner); @@ -1311,6 +1366,7 @@ pub(super) fn combo_hotkey_supervisor_loop(inner: Arc) { "[coord] side-aware combo 第 {attempts} 次注册失败: {e}; 3s 后重试" ); } + drop(registration); std::thread::sleep(std::time::Duration::from_secs(3)); continue; } @@ -1325,7 +1381,7 @@ pub(super) fn combo_hotkey_supervisor_loop(inner: Arc) { let (tx, rx) = mpsc::channel::(); let (init_tx, init_rx) = - mpsc::sync_channel::>(1); + mpsc::sync_channel::>(0); let binding_for_main = binding.clone(); if inner .host @@ -1335,6 +1391,7 @@ pub(super) fn combo_hotkey_supervisor_loop(inner: Arc) { }) .is_err() { + drop(registration); std::thread::sleep(std::time::Duration::from_secs(1)); continue; } @@ -1342,12 +1399,14 @@ pub(super) fn combo_hotkey_supervisor_loop(inner: Arc) { let init_result = match init_rx.recv_timeout(std::time::Duration::from_secs(5)) { Ok(r) => r, Err(_) => { + drop(init_rx); attempts += 1; if attempts <= 3 || attempts % 10 == 0 { log::warn!( "[coord] combo hotkey 第 {attempts} 次注册超时(主线程未回执);3s 后重试" ); } + drop(registration); std::thread::sleep(std::time::Duration::from_secs(3)); continue; } @@ -1374,6 +1433,7 @@ pub(super) fn combo_hotkey_supervisor_loop(inner: Arc) { if attempts <= 3 || attempts % 10 == 0 { log::warn!("[coord] combo hotkey 第 {attempts} 次注册失败: {e}; 3s 后重试"); } + drop(registration); std::thread::sleep(std::time::Duration::from_secs(3)); } } @@ -1412,6 +1472,13 @@ pub(super) fn translation_hotkey_supervisor_loop(inner: Arc) { if inner.shutdown.load(Ordering::SeqCst) { return; } + let registration = inner.settings_host_gate.lock(); + if inner.backend.ensure_runtime_ready().is_err() { + drop(registration); + std::thread::sleep(std::time::Duration::from_secs(1)); + continue; + } + let binding = hotkey_runtime_target(&inner).translation; if is_builtin_translation_shift(&binding) || crate::shortcut_binding::legacy_modifier_trigger(&binding).is_some() @@ -1437,7 +1504,7 @@ pub(super) fn translation_hotkey_supervisor_loop(inner: Arc) { let (tx, rx) = mpsc::channel::(); let (init_tx, init_rx) = - mpsc::sync_channel::>(1); + mpsc::sync_channel::>(0); let binding_for_main = binding.clone(); if inner .host @@ -1447,6 +1514,7 @@ pub(super) fn translation_hotkey_supervisor_loop(inner: Arc) { }) .is_err() { + drop(registration); std::thread::sleep(std::time::Duration::from_secs(1)); continue; } @@ -1454,7 +1522,9 @@ pub(super) fn translation_hotkey_supervisor_loop(inner: Arc) { let init_result = match init_rx.recv_timeout(std::time::Duration::from_secs(5)) { Ok(r) => r, Err(_) => { + drop(init_rx); attempts += 1; + drop(registration); std::thread::sleep(std::time::Duration::from_secs(3)); continue; } @@ -1477,6 +1547,7 @@ pub(super) fn translation_hotkey_supervisor_loop(inner: Arc) { "[coord] translation hotkey 第 {attempts} 次注册失败: {e}; 3s 后重试" ); } + drop(registration); std::thread::sleep(std::time::Duration::from_secs(3)); } } @@ -1523,6 +1594,13 @@ pub(super) fn action_hotkey_supervisor_loop(inner: Arc, kind: ActionHotke if inner.shutdown.load(Ordering::SeqCst) { return; } + let registration = inner.settings_host_gate.lock(); + if inner.backend.ensure_runtime_ready().is_err() { + drop(registration); + std::thread::sleep(std::time::Duration::from_secs(1)); + continue; + } + // None = 用户主动停用:反注册后退出守护(由 update_action_hotkey_binding 主动路径重装)。 let Some(binding) = action_hotkey_binding(&inner, kind) else { take_action_hotkey_on_main_thread(&inner, kind); @@ -1542,7 +1620,7 @@ pub(super) fn action_hotkey_supervisor_loop(inner: Arc, kind: ActionHotke let (tx, rx) = mpsc::channel::(); let (init_tx, init_rx) = - mpsc::sync_channel::>(1); + mpsc::sync_channel::>(0); let binding_for_main = binding.clone(); if inner .host @@ -1552,6 +1630,7 @@ pub(super) fn action_hotkey_supervisor_loop(inner: Arc, kind: ActionHotke }) .is_err() { + drop(registration); std::thread::sleep(std::time::Duration::from_secs(1)); continue; } @@ -1559,12 +1638,14 @@ pub(super) fn action_hotkey_supervisor_loop(inner: Arc, kind: ActionHotke let init_result = match init_rx.recv_timeout(std::time::Duration::from_secs(5)) { Ok(r) => r, Err(_) => { + drop(init_rx); attempts += 1; if attempts <= 3 || attempts % 10 == 0 { log::warn!( "[coord] action hotkey {kind:?} 第 {attempts} 次注册超时;3s 后重试" ); } + drop(registration); std::thread::sleep(std::time::Duration::from_secs(3)); continue; } @@ -1591,6 +1672,7 @@ pub(super) fn action_hotkey_supervisor_loop(inner: Arc, kind: ActionHotke "[coord] action hotkey {kind:?} 第 {attempts} 次注册失败: {e}; 3s 后重试" ); } + drop(registration); std::thread::sleep(std::time::Duration::from_secs(3)); } } @@ -1640,12 +1722,10 @@ pub(super) fn handle_action_hotkey_pressed(inner: &Arc, kind: ActionHotke ) => { backend - .stop_dictation_with_options( - openless_core::DictationStopOptions { - quick_note: Some(true), - ..openless_core::DictationStopOptions::default() - }, - ) + .stop_dictation_with_options(openless_core::DictationStopOptions { + quick_note: Some(true), + ..openless_core::DictationStopOptions::default() + }) .await .map(|_| ()) } @@ -1828,6 +1908,12 @@ pub(super) fn style_pack_hotkey_supervisor_loop(inner: Arc) { if inner.shutdown.load(Ordering::SeqCst) { return; } + let registration = inner.settings_host_gate.lock(); + if inner.backend.ensure_runtime_ready().is_err() { + drop(registration); + std::thread::sleep(std::time::Duration::from_secs(1)); + continue; + } let desired = configured_style_pack_hotkeys(&inner); let registrations_match = { @@ -1838,6 +1924,7 @@ pub(super) fn style_pack_hotkey_supervisor_loop(inner: Arc) { }; if registrations_match { attempts = 0; + drop(registration); std::thread::sleep(std::time::Duration::from_secs(5)); continue; } @@ -1849,6 +1936,7 @@ pub(super) fn style_pack_hotkey_supervisor_loop(inner: Arc) { attempts + 1 ); attempts = 0; + drop(registration); std::thread::sleep(std::time::Duration::from_secs(5)); } Err(error) => { @@ -1858,6 +1946,7 @@ pub(super) fn style_pack_hotkey_supervisor_loop(inner: Arc) { "[coord] style pack hotkeys 第 {attempts} 次同步失败: {error}; 3s 后重试" ); } + drop(registration); std::thread::sleep(std::time::Duration::from_secs(3)); } } diff --git a/openless-all/app/src-tauri/src/coordinator/restore_runtime.rs b/openless-all/app/src-tauri/src/coordinator/restore_runtime.rs new file mode 100644 index 00000000..a5961b90 --- /dev/null +++ b/openless-all/app/src-tauri/src/coordinator/restore_runtime.rs @@ -0,0 +1,350 @@ +//! Journal-fenced reconciliation of actual Host state. This is deliberately +//! separate from ordinary settings persistence: credentials and preferences +//! have already been restored under the exclusive source permit. +use super::*; +use futures_util::future::BoxFuture; +use openless_core::{BackendError, BackendErrorCode}; +use std::sync::Weak; + +struct RestoreHost { + coordinator: Weak, +} + +fn failure(reason: &'static str) -> BackendError { + BackendError::new(BackendErrorCode::Platform, reason) +} + +impl openless_core::config::RestoreRuntimeEffects for RestoreHost { + fn apply_target( + &self, + target: crate::types::UserPreferences, + ) -> BoxFuture<'static, Result<(), BackendError>> { + let weak = self.coordinator.clone(); + Box::pin(async move { + let inner = weak + .upgrade() + .ok_or_else(|| failure("restore_host_unavailable"))?; + openless_core::reject_hotkey_collisions(&target) + .map_err(|_| failure("restore_hotkey_conflict"))?; + let coord = Coordinator { + inner: Arc::clone(&inner), + }; + let work_target = target.clone(); + let work_inner = Arc::clone(&inner); + // Ordinary settings transactions take this same Host lock and are + // refused by Core's restoring guard before preparing new effects. + inner + .host + .spawn_blocking(move || { + let coord = Coordinator { inner: work_inner }; + let _host_guard = coord.lock_settings_host(); + #[cfg(target_os = "windows")] + crate::windows_ime_profile::apply_windows_openless_keyboard_list( + openless_core::WindowsKeyboardRuntimeTarget::from(&work_target) + .openless_language_profile_enabled, + ) + .map_err(|_| failure("restore_windows_keyboard_failed"))?; + #[cfg(any(target_os = "macos", target_os = "windows"))] + reconcile_hotkeys(&coord, (&work_target).into())?; + #[cfg(not(any( + target_os = "macos", + target_os = "windows", + target_os = "android", + target_os = "ios" + )))] + return Err(failure("restore_host_platform_unsupported")); + crate::net::set_use_system_proxy(work_target.use_system_proxy); + coord + .inner + .host + .cache_capsule_style(work_target.capsule_style); + Ok::<_, BackendError>(()) + }) + .await + .map_err(|_| failure("restore_host_task_interrupted"))??; + + #[cfg(target_os = "android")] + match target.android_overlay_trigger.normalized() { + crate::types::AndroidOverlayTrigger::Always => { + crate::android::replace_android_overlay() + } + crate::types::AndroidOverlayTrigger::Background + | crate::types::AndroidOverlayTrigger::Keyboard => { + crate::android::hide_android_overlay() + } + } + .map_err(|_| failure("restore_android_overlay_failed"))?; + + // Enabled remains receiver-local consent. Its restored port still + // has to reach the actual listener; configure owns stop/start and + // exposes bind failures to the journal's rollback path. + reconcile_remote_input( + inner.backend.services().remote_input.as_ref(), + target.remote_input_enabled, + target.remote_input_port, + ) + .await?; + // The presentation cache enqueues a native refresh. A final main + // thread acknowledgement keeps that refresh inside the fence. + let (send, receive) = tokio::sync::oneshot::channel(); + inner + .host + .run_on_main_thread(move || { + let _ = send.send(()); + }) + .map_err(|_| failure("restore_main_thread_unavailable"))?; + receive + .await + .map_err(|_| failure("restore_main_thread_interrupted"))?; + coord.backend().reset_restored_runtime_preferences(); + Ok(()) + }) + } +} + +async fn reconcile_remote_input( + remote: &dyn openless_core::domains::RemoteInputApi, + enabled: bool, + port: u16, +) -> Result<(), BackendError> { + match remote.status() { + Ok(status) => { + if status.enabled != enabled || status.port != port || (enabled && !status.running) { + remote + .configure(openless_core::RemoteInputConfig { enabled, port }) + .await + .map_err(|_| failure("restore_remote_input_failed"))?; + } + } + Err(error) if error.code == BackendErrorCode::Unsupported && !enabled => {} + Err(_) => return Err(failure("restore_remote_input_unavailable")), + } + Ok(()) +} + +impl Coordinator { + pub fn bind_restore_runtime_effects(&self) -> Result<(), BackendError> { + if self.startup_error().is_some() { + return Ok(()); + } + if !self.inner.host.is_bound() { + return Err(failure("restore_host_not_bound")); + } + self.inner + .backend + .bind_restore_runtime_effects(Arc::new(RestoreHost { + coordinator: Arc::downgrade(&self.inner), + })) + } +} + +#[cfg(any(target_os = "macos", target_os = "windows"))] +fn reconcile_hotkeys( + coord: &Coordinator, + target: openless_core::HotkeyRuntimeTarget, +) -> Result<(), BackendError> { + *coord.inner.hotkey_runtime_target.lock() = target.clone(); + let trigger = crate::shortcut_binding::legacy_modifier_trigger(&target.dictation); + let binding = crate::types::HotkeyBinding { + trigger: trigger.unwrap_or(crate::types::HotkeyTrigger::Custom), + mode: target.dictation_mode, + keys: None, + }; + coord + .try_ensure_modifier_hotkey_monitor(binding) + .map_err(|_| failure("restore_dictation_monitor_failed"))?; + let (send, receive) = mpsc::sync_channel(1); + let inner = Arc::clone(&coord.inner); + // No timeout success and no abandoned queued registration: keep waiting + // for this exact callback before the journal can roll back another target. + coord + .inner + .host + .run_on_main_thread(move || { + let result = reconcile_hotkeys_on_main(&inner, &target); + let _ = send.send(result); + }) + .map_err(|_| failure("restore_main_thread_unavailable"))?; + receive + .recv() + .map_err(|_| failure("restore_main_thread_interrupted"))? + .map_err(|_| failure("restore_hotkeys_failed"))?; + coord + .try_update_selection_polish_hotkey_binding() + .map_err(|_| failure("restore_selection_hotkey_failed"))?; + coord + .update_coding_agent_hotkey_binding() + .map_err(|_| failure("restore_agent_hotkey_failed"))?; + Ok(()) +} + +#[cfg(any(target_os = "macos", target_os = "windows"))] +fn reconcile_hotkeys_on_main( + inner: &Arc, + target: &openless_core::HotkeyRuntimeTarget, +) -> Result<(), String> { + // Release old combinations together before applying an absolute target; + // this also permits swapping two bindings during a restore. + inner.combo_hotkey.lock().take(); + inner.qa_hotkey.lock().take(); + inner.translation_hotkey.lock().take(); + inner.switch_style_hotkey.lock().take(); + inner.open_app_hotkey.lock().take(); + inner.quick_note_hotkey.lock().take(); + inner.style_pack_hotkeys.lock().clear(); + inner.selection_polish_hotkey.lock().take(); + inner.coding_agent_combo_hotkey.lock().take(); + let trigger = crate::shortcut_binding::legacy_modifier_trigger(&target.dictation); + if trigger.is_some() || is_unconfigured_shortcut(&target.dictation) { + inner.side_aware_combo.lock().take(); + } else if crate::shortcut_binding::binding_requires_side_aware_hook(&target.dictation) { + let mut side_slot = inner.side_aware_combo.lock(); + if let Some(monitor) = side_slot.as_ref() { + monitor + .update_binding(target.dictation.clone()) + .map_err(|error| error.to_string())?; + } else { + let (send, receive) = mpsc::channel(); + let combined = spawn_combo_abort_bridge(inner, handle_trigger_combined); + let monitor = crate::side_aware_combo::SideAwareComboMonitor::start( + target.dictation.clone(), + send, + combined, + ) + .map_err(|error| error.to_string())?; + let owned = Arc::clone(inner); + std::thread::Builder::new() + .name("openless-side-combo-bridge".into()) + .spawn(move || hotkey_bridge_loop(owned, receive)) + .map_err(|error| error.to_string())?; + *side_slot = Some(monitor); + } + } else { + inner.side_aware_combo.lock().take(); + let (send, receive) = mpsc::channel(); + let monitor = ComboHotkeyMonitor::start(target.dictation.clone(), send) + .map_err(|error| error.to_string())?; + let owned = Arc::clone(inner); + std::thread::Builder::new() + .name("openless-combo-hotkey-bridge".into()) + .spawn(move || combo_hotkey_bridge_loop(owned, receive)) + .map_err(|error| error.to_string())?; + *inner.combo_hotkey.lock() = Some(monitor); + } + if let Some(binding) = target.qa.as_ref().filter(|binding| { + crate::shortcut_binding::legacy_modifier_trigger(binding).is_none() + && !is_unconfigured_shortcut(binding) + }) { + let (send, receive) = mpsc::channel(); + let monitor = + QaHotkeyMonitor::start(binding.clone(), send).map_err(|error| error.to_string())?; + let owned = Arc::clone(inner); + std::thread::Builder::new() + .name("openless-qa-hotkey-bridge".into()) + .spawn(move || qa_hotkey_bridge_loop(owned, receive)) + .map_err(|error| error.to_string())?; + *inner.qa_hotkey.lock() = Some(monitor); + } + if !is_builtin_translation_shift(&target.translation) + && crate::shortcut_binding::legacy_modifier_trigger(&target.translation).is_none() + && !is_unconfigured_shortcut(&target.translation) + { + update_translation_hotkey_on_main_thread(Arc::clone(inner), target.translation.clone()) + .map_err(|error| error.to_string())?; + } + for kind in [ + ActionHotkeyKind::SwitchStyle, + ActionHotkeyKind::OpenApp, + ActionHotkeyKind::QuickNote, + ] { + let Some(binding) = action_hotkey_binding(inner, kind) else { + continue; + }; + if is_unconfigured_shortcut(&binding) { + continue; + } + if is_modifier_only_shortcut(&binding) { + return Err("unsupported action hotkey".into()); + } + let (send, receive) = mpsc::channel(); + let monitor = + ComboHotkeyMonitor::start(binding, send).map_err(|error| error.to_string())?; + let owned = Arc::clone(inner); + std::thread::Builder::new() + .name(action_hotkey_bridge_thread_name(kind).into()) + .spawn(move || action_hotkey_bridge_loop(owned, receive, kind)) + .map_err(|error| error.to_string())?; + *action_hotkey_slot(inner, kind).lock() = Some(monitor); + } + sync_style_pack_hotkeys(inner)?; + if let Some(monitor) = inner.hotkey.lock().as_ref() { + let (qa, selection, translation) = modifier_shortcut_triggers(inner); + monitor.update_modifier_shortcuts(qa, selection, translation); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use openless_core::domains::RemoteInputApi; + + fn remote() -> ( + openless_core::RemoteInputService, + Arc, + ) { + let runtime = Arc::new(openless_core::testing::RecordingRemoteInputRuntime::default()); + let service = openless_core::RemoteInputService::new(runtime.clone(), 8443, "en").unwrap(); + let publisher_owner = openless_core::OpenLessBackend::blocked_startup( + openless_core::BackendConfig::default(), + failure("fixture has no external adapters"), + ); + service.bind_event_publisher(publisher_owner.event_publisher()); + (service, runtime) + } + + #[tokio::test] + async fn restore_host_running_remote_port_and_rollback_reach_the_listener() { + let (remote, runtime) = remote(); + remote + .configure(openless_core::RemoteInputConfig { + enabled: true, + port: 8443, + }) + .await + .unwrap(); + reconcile_remote_input(&remote, true, 9443).await.unwrap(); + assert!(remote.status().unwrap().running); + assert_eq!(remote.status().unwrap().port, 9443); + assert_eq!(runtime.server_start_count(), 2); + assert_eq!(runtime.server_stop_count(), 1); + // The journal invokes the same absolute callback with its old target. + reconcile_remote_input(&remote, true, 8443).await.unwrap(); + assert!(remote.status().unwrap().running); + assert_eq!(remote.status().unwrap().port, 8443); + assert_eq!(runtime.server_start_count(), 3); + assert_eq!(runtime.server_stop_count(), 2); + } + + #[tokio::test] + async fn restore_host_invalid_remote_target_is_an_error_and_disabled_capability_is_explicit() { + let (remote, runtime) = remote(); + remote + .configure(openless_core::RemoteInputConfig { + enabled: true, + port: 8443, + }) + .await + .unwrap(); + assert!(reconcile_remote_input(&remote, true, 0).await.is_err()); + assert_eq!(remote.status().unwrap().port, 8443); + assert_eq!(runtime.server_stop_count(), 0); + let unsupported = openless_core::domains::UnsupportedDomainServices; + reconcile_remote_input(&unsupported, false, 8443) + .await + .unwrap(); + assert!(reconcile_remote_input(&unsupported, true, 8443) + .await + .is_err()); + } +} diff --git a/openless-all/app/src-tauri/src/core_adapters.rs b/openless-all/app/src-tauri/src/core_adapters.rs index 4b0a3add..ecedfc21 100644 --- a/openless-all/app/src-tauri/src/core_adapters.rs +++ b/openless-all/app/src-tauri/src/core_adapters.rs @@ -2826,6 +2826,130 @@ where } } +/// Worker creation follows an already completed TIS switch. Roll back that +/// switch on startup failure, retaining the original error if rollback fails. +#[cfg(target_os = "macos")] +async fn start_worker_restoring_on_error( + previous: P, + start: impl FnOnce() -> Result, + restore: impl FnOnce(P) -> F, +) -> Result<(P, W), BackendError> +where + F: std::future::Future>, +{ + match start() { + Ok(worker) => Ok((previous, worker)), + Err(error) => { + if let Err(restore_error) = restore(previous).await { + log::warn!("[core-adapter] restore input source after worker startup failed: {restore_error}"); + } + Err(error) + } + } +} + +#[cfg(target_os = "macos")] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum MacInsertionCompletion { + Finished(InsertOutcome), + Cancelled, +} + +/// TIS restoration belongs to the terminal effect, never to an individual +/// caller's future. Repeated finish/cancel callers join the same result. +#[cfg(target_os = "macos")] +#[derive(Default)] +struct MacInsertionTerminal { + started: AtomicBool, + result: std::sync::OnceLock>, + ready: tokio::sync::Notify, +} + +#[cfg(target_os = "macos")] +impl MacInsertionTerminal { + fn settle(&self, result: Result) { + if self.result.set(result).is_ok() { + self.ready.notify_waiters(); + } + } + + async fn join_or_spawn( + self: &Arc, + effect: impl FnOnce() -> F + Send + 'static, + spawn: impl FnOnce(BoxFuture<'static, ()>), + ) -> Result + where + F: std::future::Future> + + Send + + 'static, + { + if self + .started + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_ok() + { + let guard = MacInsertionTerminalGuard(Arc::clone(self)); + spawn(Box::pin(async move { + guard.settle(effect().await); + })); + } + loop { + let notified = self.ready.notified(); + if let Some(result) = self.result.get() { + return result.clone(); + } + notified.await; + } + } +} + +#[cfg(target_os = "macos")] +struct MacInsertionTerminalGuard(Arc); + +#[cfg(target_os = "macos")] +impl MacInsertionTerminalGuard { + fn settle(&self, result: Result) { + self.0.settle(result); + } +} + +#[cfg(target_os = "macos")] +impl Drop for MacInsertionTerminalGuard { + fn drop(&mut self) { + self.0.settle(Err(BackendError::new( + BackendErrorCode::Internal, + "macOS insertion finalization did not complete", + ))); + } +} + +/// Preserve the existing written-text outcome on TIS restoration failure, but +/// always restore after a failed delivery barrier as well. A barrier error must +/// not cause a second insertion of text that may already have been posted. +#[cfg(target_os = "macos")] +async fn finish_mac_insertion_after_barrier( + barrier: B, + effect: impl FnOnce() -> E, + restore: impl FnOnce() -> R, +) -> Result +where + B: std::future::Future>, + E: std::future::Future>, + R: std::future::Future>, +{ + let result = match barrier.await { + Ok(()) => effect().await, + Err(error) => Err(error), + }; + if let Err(error) = restore().await { + log::warn!("[core-adapter] restore input state after insertion failed: {error}"); + if matches!(result, Ok(MacInsertionCompletion::Cancelled)) { + return Err(error); + } + } + result +} + impl CoreTextInserter for TauriTextInserter { fn capture_target(&self) -> Option> { Some(Arc::new(Self { @@ -2869,7 +2993,7 @@ impl CoreTextInserter for TauriTextInserter { None }; #[cfg(target_os = "macos")] - let (app_handle, previous_input_source, streaming_ready) = { + let (app_handle, mut previous_input_source, streaming_ready) = { let app_handle = app.lock().clone().ok_or_else(|| { BackendError::new( BackendErrorCode::InvalidState, @@ -2890,6 +3014,36 @@ impl CoreTextInserter for TauriTextInserter { .await; (app_handle, previous, streaming_ready) }; + #[cfg(target_os = "macos")] + let cancel_requested = Arc::new(AtomicBool::new(false)); + #[cfg(target_os = "macos")] + let streaming_worker = if streaming_ready { + let (previous, worker) = start_worker_restoring_on_error( + previous_input_source, + || { + crate::macos_streaming_input::MacStreamingInput::spawn( + insertion_target.clone(), + context.insertion.macos_newline_mode, + Arc::clone(&cancel_requested), + ) + }, + |previous| { + let app_handle = &app_handle; + async move { + crate::unicode_keystroke::restore_input_source(app_handle, previous) + .await + .map_err(|error| { + BackendError::new(BackendErrorCode::Platform, error.to_string()) + }) + } + }, + ) + .await?; + previous_input_source = previous; + Some(worker) + } else { + None + }; #[cfg(not(target_os = "macos"))] let _ = app; Ok(Arc::new(TauriTextInsertionSession { @@ -2908,7 +3062,11 @@ impl CoreTextInserter for TauriTextInserter { #[cfg(target_os = "macos")] streaming_ready, #[cfg(target_os = "macos")] - confirm_keyboard_delivery: Arc::new(AtomicBool::new(true)), + streaming_worker, + #[cfg(target_os = "macos")] + cancel_requested, + #[cfg(target_os = "macos")] + terminal: Arc::new(MacInsertionTerminal::default()), }) as Arc) }) } @@ -2931,7 +3089,11 @@ struct TauriTextInsertionSession { #[cfg(target_os = "macos")] streaming_ready: bool, #[cfg(target_os = "macos")] - confirm_keyboard_delivery: Arc, + streaming_worker: Option, + #[cfg(target_os = "macos")] + cancel_requested: Arc, + #[cfg(target_os = "macos")] + terminal: Arc, } impl TauriTextInsertionSession { @@ -2947,66 +3109,47 @@ impl TauriTextInsertionSession { } async fn write_chunk(&self, text: String) -> Result { + #[cfg(target_os = "macos")] + { + // A write future may have been created before a terminal caller + // sealed the session but only polled afterwards. + if self.finished.load(Ordering::Acquire) { + return Err(BackendError::new( + BackendErrorCode::Cancelled, + "text insertion session is closed", + )); + } + let worker = self.streaming_worker.as_ref().ok_or_else(|| { + BackendError::new( + BackendErrorCode::Unsupported, + "macOS streaming insertion is unavailable", + ) + })?; + worker.write(text).await + } + #[cfg(not(target_os = "macos"))] self.restore_insertion_target()?; - #[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))] + #[cfg(any(target_os = "windows", target_os = "linux"))] { let chunk = text.clone(); #[cfg(target_os = "windows")] let newline_mode = self.context.insertion.windows_sendinput_newline_mode; - #[cfg(target_os = "macos")] - let newline_mode = self.context.insertion.macos_newline_mode; - #[cfg(target_os = "macos")] - let confirm_delivery = Arc::clone(&self.confirm_keyboard_delivery); let finished = Arc::clone(&self.finished); let written = tauri::async_runtime::spawn_blocking(move || { if finished.load(Ordering::Acquire) { return 0; } - // CGEventPost returns before the target has consumed its input. - // Retain the original control before posting; inspect only its - // caret, on this blocking thread, before completing the write. - #[cfg(target_os = "macos")] - let delivery = if confirm_delivery.load(Ordering::Acquire) - && !(newline_mode == crate::types::MacosNewlineMode::Return - && chunk.contains('\n')) - { - crate::host_document::KeyboardDelivery::capture() - } else { - None - }; - #[cfg(target_os = "macos")] - if delivery - .as_ref() - .is_some_and(|delivery| !delivery.is_focused()) - { - return 0; - } #[cfg(target_os = "windows")] let result = crate::unicode_keystroke::type_unicode_chunk_with_options( &chunk, crate::unicode_keystroke::WindowsSendInputOptions { newline_mode }, ); - #[cfg(target_os = "macos")] - let result = - crate::unicode_keystroke::type_unicode_chunk_with_options(&chunk, newline_mode); #[cfg(target_os = "linux")] let result = crate::unicode_keystroke::type_unicode_chunk(&chunk); let written = match result { Ok(written) => written, Err(error) => error.typed_chars(), }; - #[cfg(target_os = "macos")] - { - let delivered = delivery.is_some_and(|delivery| { - let posted: String = chunk.chars().take(written).collect(); - delivery.wait(&posted) - }); - // Unsupported/stalled controls are tried once per session, - // so a missing AX caret cannot add a delay to every delta. - if !delivered { - confirm_delivery.store(false, Ordering::Release); - } - } written }) .await @@ -3148,6 +3291,56 @@ impl TauriTextInsertionSession { } Ok(()) } + + #[cfg(target_os = "macos")] + async fn finalize_mac( + self, + final_text: Option, + ) -> Result { + self.finished.store(true, Ordering::Release); + if final_text.is_none() { + // Separate from the normal finished latch: normal finish must not + // cancel Write commands already accepted by the worker's queue. + self.cancel_requested.store(true, Ordering::Release); + } + let terminal = Arc::clone(&self.terminal); + terminal + .join_or_spawn( + move || async move { + let worker = self.streaming_worker.clone(); + let session = &self; + finish_mac_insertion_after_barrier( + async move { + match worker { + Some(worker) => worker.finish().await, + None => Ok(()), + } + }, + move || async move { + if session.cancel_requested.load(Ordering::Acquire) { + return Ok(MacInsertionCompletion::Cancelled); + } + match final_text { + Some(text) if !text.is_empty() => session + .insert_final(text) + .await + .map(MacInsertionCompletion::Finished), + Some(_) => { + Ok(MacInsertionCompletion::Finished(InsertOutcome::Inserted)) + } + None => Ok(MacInsertionCompletion::Cancelled), + } + }, + || session.restore_platform_state(), + ) + .await + }, + |task| { + tauri::async_runtime::spawn(task); + }, + ) + .await + } } impl TextInsertionSession for TauriTextInsertionSession { @@ -3186,37 +3379,254 @@ impl TextInsertionSession for TauriTextInsertionSession { ) -> BoxFuture<'static, Result> { let session = self.clone(); Box::pin(async move { - if session.finished.swap(true, Ordering::AcqRel) { - return Err(BackendError::new( - BackendErrorCode::InvalidState, - "text insertion session is already closed", - )); + #[cfg(target_os = "macos")] + { + match session.finalize_mac(Some(final_text)).await? { + MacInsertionCompletion::Finished(outcome) => Ok(outcome), + MacInsertionCompletion::Cancelled => Err(BackendError::new( + BackendErrorCode::Cancelled, + "text insertion session was cancelled before completion", + )), + } } - let result = if final_text.is_empty() { - Ok(InsertOutcome::Inserted) - } else { - session.insert_final(final_text).await - }; - if let Err(error) = session.restore_platform_state().await { - // 恢复输入源失败并不能撤销已经落下的文字。保留真实交付结果, - // 避免历史误报失败后诱导用户重试造成重复;无论插入成败都记录恢复错误。 - log::warn!("[core-adapter] restore input state after insertion failed: {error}"); + #[cfg(not(target_os = "macos"))] + { + if session.finished.swap(true, Ordering::AcqRel) { + return Err(BackendError::new( + BackendErrorCode::InvalidState, + "text insertion session is already closed", + )); + } + let result = if final_text.is_empty() { + Ok(InsertOutcome::Inserted) + } else { + session.insert_final(final_text).await + }; + if let Err(error) = session.restore_platform_state().await { + // 恢复输入源失败并不能撤销已经落下的文字。保留真实交付结果, + // 避免历史误报失败后诱导用户重试造成重复;无论插入成败都记录恢复错误。 + log::warn!( + "[core-adapter] restore input state after insertion failed: {error}" + ); + } + result } - result }) } fn cancel(&self) -> BoxFuture<'static, Result<(), BackendError>> { let session = self.clone(); Box::pin(async move { - if session.finished.swap(true, Ordering::AcqRel) { - return Ok(()); + #[cfg(target_os = "macos")] + { + session.finalize_mac(None).await.map(|_| ()) + } + #[cfg(not(target_os = "macos"))] + { + if session.finished.swap(true, Ordering::AcqRel) { + return Ok(()); + } + session.restore_platform_state().await } - session.restore_platform_state().await }) } } +#[cfg(all(test, target_os = "macos"))] +mod mac_insertion_lifecycle_tests { + use super::*; + + fn spawn(task: BoxFuture<'static, ()>) { + tokio::spawn(task); + } + + #[tokio::test] + async fn worker_start_failure_restores_the_previous_source_once() { + let restored = Arc::new(Mutex::new(Vec::new())); + let error = BackendError::new(BackendErrorCode::Platform, "worker unavailable"); + let result = start_worker_restoring_on_error( + 7, + || Err::<(), _>(error.clone()), + |token| { + let restored = &restored; + async move { + restored.lock().push(token); + Ok(()) + } + }, + ) + .await; + assert_eq!(result.unwrap_err(), error); + assert_eq!(*restored.lock(), [7]); + assert_eq!( + start_worker_restoring_on_error( + 8, + || Ok(9), + |_| async { + panic!("successful startup must retain the source for terminal cleanup") + } + ) + .await + .unwrap(), + (8, 9) + ); + } + + #[tokio::test] + async fn failed_barrier_skips_insertion_but_still_restores() { + let actions = Mutex::new(Vec::new()); + let error = BackendError::new(BackendErrorCode::Internal, "worker stopped"); + let result = finish_mac_insertion_after_barrier( + async { + actions.lock().push("barrier"); + Err(error.clone()) + }, + || async { panic!("uncertain posted input must not be inserted again") }, + || async { + actions.lock().push("restore"); + Ok(()) + }, + ) + .await; + assert_eq!(result.unwrap_err(), error); + assert_eq!(*actions.lock(), ["barrier", "restore"]); + } + + #[tokio::test] + async fn failed_final_insertion_still_restores_and_preserves_its_error() { + let actions = Mutex::new(Vec::new()); + let error = BackendError::new(BackendErrorCode::Platform, "target unavailable"); + let result = finish_mac_insertion_after_barrier( + async { + actions.lock().push("barrier"); + Ok(()) + }, + || async { + actions.lock().push("insert"); + Err(error.clone()) + }, + || async { + actions.lock().push("restore"); + Err(BackendError::new( + BackendErrorCode::Platform, + "TIS unavailable", + )) + }, + ) + .await; + assert_eq!(result.unwrap_err(), error); + assert_eq!(*actions.lock(), ["barrier", "insert", "restore"]); + } + + #[tokio::test] + async fn restoration_error_keeps_written_outcome_but_is_reported_on_cancel() { + let error = BackendError::new(BackendErrorCode::Platform, "TIS unavailable"); + let completed = MacInsertionCompletion::Finished(InsertOutcome::Inserted); + assert_eq!( + finish_mac_insertion_after_barrier( + async { Ok(()) }, + || async { Ok(completed) }, + || async { Err(error.clone()) }, + ) + .await + .unwrap(), + completed + ); + assert_eq!( + finish_mac_insertion_after_barrier( + async { Ok(()) }, + || async { Ok(MacInsertionCompletion::Cancelled) }, + || async { Err(error.clone()) }, + ) + .await + .unwrap_err(), + error + ); + } + + async fn assert_terminal_join(completion: MacInsertionCompletion, drop_first: bool) { + let terminal = Arc::new(MacInsertionTerminal::default()); + let actions = Arc::new(Mutex::new(Vec::new())); + let entered = Arc::new(tokio::sync::Semaphore::new(0)); + let release = Arc::new(tokio::sync::Semaphore::new(0)); + let observed = Arc::clone(&actions); + let start = Arc::clone(&entered); + let gate = Arc::clone(&release); + let mut first = Box::pin(terminal.join_or_spawn( + move || async move { + let observed = &observed; + finish_mac_insertion_after_barrier( + async { + observed.lock().push("barrier started"); + start.add_permits(1); + gate.acquire().await.unwrap().forget(); + observed.lock().push("barrier drained"); + Ok(()) + }, + || async { + observed.lock().push("effect"); + Ok(completion) + }, + || async { + observed.lock().push("source restored"); + Ok(()) + }, + ) + .await + }, + spawn, + )); + assert!(futures_util::poll!(first.as_mut()).is_pending()); + entered.acquire().await.unwrap().forget(); + assert_eq!(*actions.lock(), ["barrier started"]); + let mut second = std::pin::pin!(terminal.join_or_spawn( + || async { panic!("duplicate terminal call must not repeat effects") }, + spawn, + )); + assert!(futures_util::poll!(second.as_mut()).is_pending()); + let first = if drop_first { + drop(first); + None + } else { + Some(first) + }; + release.add_permits(1); + assert_eq!(second.await.unwrap(), completion); + if let Some(first) = first { + assert_eq!(first.await.unwrap(), completion); + } + assert_eq!( + *actions.lock(), + [ + "barrier started", + "barrier drained", + "effect", + "source restored" + ] + ); + } + + #[tokio::test] + async fn repeated_finish_and_cancel_wait_for_the_same_terminal_barrier() { + for completion in [ + MacInsertionCompletion::Finished(InsertOutcome::Inserted), + MacInsertionCompletion::Cancelled, + ] { + assert_terminal_join(completion, false).await; + } + } + + #[tokio::test] + async fn dropping_the_initial_terminal_future_does_not_skip_restore() { + for completion in [ + MacInsertionCompletion::Finished(InsertOutcome::Inserted), + MacInsertionCompletion::Cancelled, + ] { + assert_terminal_join(completion, true).await; + } + } +} + #[cfg(target_os = "windows")] fn windows_unicode_fallback(context: &DictationContext, text: &str) -> crate::types::InsertStatus { let inserter = crate::insertion::TextInserter::new(); diff --git a/openless-all/app/src-tauri/src/host_document/macos.rs b/openless-all/app/src-tauri/src/host_document/macos.rs index e1b282e8..acaa7539 100644 --- a/openless-all/app/src-tauri/src/host_document/macos.rs +++ b/openless-all/app/src-tauri/src/host_document/macos.rs @@ -381,12 +381,12 @@ unsafe fn copy_selected_range(focused: AxUiElementRef) -> Option { (ok != 0).then_some(range) } -/// Confirms a posted keyboard chunk against the original text control's caret. +/// Confirms all posted keyboard input against the original text control's caret. /// Only metadata is read; the target's document text is never fetched. /// Create, wait and drop on the same blocking insertion thread. pub(crate) struct KeyboardDelivery { element: AxUiElementRef, - start: usize, + progress: KeyboardDeliveryProgress, } impl KeyboardDelivery { @@ -402,9 +402,10 @@ impl KeyboardDelivery { let GatedElement::Ready(element) = focused_element_passing_the_gate(gate) else { return None; }; - let mut delivery = Self { element, start: 0 }; - delivery.start = copy_caret_offset(element)?; - Some(delivery) + Some(Self { + element, + progress: KeyboardDeliveryProgress::new(copy_caret_offset(element)), + }) } } @@ -426,38 +427,48 @@ impl KeyboardDelivery { } } - /// False means this target cannot be confirmed; stop probing it for this session. - pub(crate) fn wait(self, posted_text: &str) -> bool { + /// Account only for the prefix actually posted by the native typer. This + /// never reads AX or waits, so another streamed chunk can follow immediately. + pub(crate) fn record_posted( + &mut self, + posted_text: &str, + newline_mode: crate::types::MacosNewlineMode, + ) { + self.progress.record_posted(posted_text, newline_mode); + } + + /// One terminal delivery barrier, on the same thread that captured the + /// element. Unreadable/stale controls keep the existing posted-input fallback. + pub(crate) fn finish(self) -> KeyboardDeliveryOutcome { let started = Instant::now(); - let outcome = wait_for_caret_delivery( - self.start, - posted_text, - || { - if crate::unicode_keystroke::is_secure_input_enabled() { - return None; - } - // SAFETY: self retains the same control throughout this wait. - unsafe { copy_selected_range(self.element) }.and_then(|range| { - if range.length < 0 { + let outcome = match self.progress.expected() { + DeliveryExpectation::NothingPosted => KeyboardDeliveryOutcome::Delivered, + DeliveryExpectation::Unavailable => KeyboardDeliveryOutcome::Unavailable, + DeliveryExpectation::Caret { start, expected } => wait_for_caret_delivery( + start, + expected, + || { + if crate::unicode_keystroke::is_secure_input_enabled() { return None; } - caret_offset_from_location(range.location).map(|offset| (offset, range.length)) - }) - }, - || { - if started.elapsed() >= Duration::from_secs(10) { - return false; - } - std::thread::sleep(Duration::from_millis(10)); - true - }, + // SAFETY: self retains the original control on this worker. + unsafe { copy_selected_range(self.element) }.and_then(|range| { + if range.length < 0 { + return None; + } + caret_offset_from_location(range.location) + .map(|offset| (offset, range.length)) + }) + }, + || started.elapsed(), + || std::thread::sleep(Duration::from_millis(10)), + ), + }; + log::info!( + "[insertion] final keyboard delivery outcome={outcome:?} elapsed_ms={}", + started.elapsed().as_millis() ); - if outcome == KeyboardDeliveryOutcome::TimedOut { - log::warn!( - "[insertion] target caret did not acknowledge posted keyboard input within 10s" - ); - } - outcome == KeyboardDeliveryOutcome::Delivered + outcome } } @@ -468,118 +479,250 @@ impl Drop for KeyboardDelivery { } } -#[derive(Debug, PartialEq, Eq)] -enum KeyboardDeliveryOutcome { +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum KeyboardDeliveryOutcome { Delivered, Unavailable, + NoProgress, TimedOut, } +const DELIVERY_NO_PROGRESS_BUDGET: Duration = Duration::from_millis(250); +const DELIVERY_TOTAL_BUDGET: Duration = Duration::from_secs(10); + +#[derive(Debug, PartialEq, Eq)] +enum DeliveryExpectation { + NothingPosted, + Unavailable, + Caret { start: usize, expected: usize }, +} + +/// Pure accounting, independent of AX ownership. A terminal capture cannot +/// substitute for this cumulative offset: previously posted keys may still queue. +#[derive(Debug)] +struct KeyboardDeliveryProgress { + start: Option, + expected: Option, + posted: bool, +} + +impl KeyboardDeliveryProgress { + fn new(start: Option) -> Self { + Self { + start, + expected: start, + posted: false, + } + } + + fn record_posted(&mut self, text: &str, newline_mode: crate::types::MacosNewlineMode) { + let mut units = 0usize; + for ch in text.chars().filter(|ch| *ch != '\r') { + self.posted = true; + units = units.saturating_add(ch.len_utf16()); + if ch == '\n' && newline_mode == crate::types::MacosNewlineMode::Return { + // A submitting Return can clear the field or move focus. Keep + // its keys, but never wait for a monotonically increasing caret. + self.expected = None; + } + } + self.expected = self.expected.and_then(|offset| offset.checked_add(units)); + } + + fn expected(&self) -> DeliveryExpectation { + if !self.posted { + return DeliveryExpectation::NothingPosted; + } + match (self.start, self.expected) { + (Some(start), Some(expected)) => DeliveryExpectation::Caret { start, expected }, + _ => DeliveryExpectation::Unavailable, + } + } +} + fn wait_for_caret_delivery( start: usize, - posted_text: &str, + expected: usize, mut read: impl FnMut() -> Option<(usize, isize)>, - mut wait: impl FnMut() -> bool, + mut elapsed: impl FnMut() -> Duration, + mut pause: impl FnMut(), ) -> KeyboardDeliveryOutcome { - // AX offsets count UTF-16 units. CR is consumed without posting a key. - let units = posted_text - .chars() - .filter(|ch| *ch != '\r') - .map(char::len_utf16) - .sum(); - if units == 0 { - return KeyboardDeliveryOutcome::Delivered; - } - let Some(expected) = start.checked_add(units) else { - return KeyboardDeliveryOutcome::Unavailable; - }; + let mut high_water = start; + let mut last_progress = Duration::ZERO; loop { + let now = elapsed(); + if now >= DELIVERY_TOTAL_BUDGET { + return KeyboardDeliveryOutcome::TimedOut; + } + if now.saturating_sub(last_progress) >= DELIVERY_NO_PROGRESS_BUDGET { + return KeyboardDeliveryOutcome::NoProgress; + } let Some((offset, selected_length)) = read() else { return KeyboardDeliveryOutcome::Unavailable; }; + let now = elapsed(); + if now >= DELIVERY_TOTAL_BUDGET { + return KeyboardDeliveryOutcome::TimedOut; + } if selected_length == 0 && offset >= expected { return KeyboardDeliveryOutcome::Delivered; } - if !wait() { - return KeyboardDeliveryOutcome::TimedOut; + if offset > high_water { + high_water = offset; + last_progress = now; } + if now.saturating_sub(last_progress) >= DELIVERY_NO_PROGRESS_BUDGET { + return KeyboardDeliveryOutcome::NoProgress; + } + pause(); } } #[cfg(test)] mod keyboard_delivery_tests { use super::*; + use crate::types::MacosNewlineMode; + use std::cell::Cell; #[test] - fn posted_input_waits_until_target_consumes_the_last_character() { - // A selected range / intermediate caret is not an insertion receipt. + fn cumulative_receipt_counts_only_posted_unicode_and_ignores_swallowed_cr() { + let mut progress = KeyboardDeliveryProgress::new(Some(7)); + assert_eq!(progress.expected(), DeliveryExpectation::NothingPosted); + progress.record_posted("A🙂\r", MacosNewlineMode::ShiftReturn); + progress.record_posted("\n界", MacosNewlineMode::ShiftReturn); + assert_eq!( + progress.expected(), + DeliveryExpectation::Caret { + start: 7, + expected: 12 + } + ); + } + + #[test] + fn submitting_return_and_unknown_offsets_keep_posted_input_fallback() { + let mut progress = KeyboardDeliveryProgress::new(Some(5)); + progress.record_posted("first", MacosNewlineMode::Return); + progress.record_posted("\nsecond", MacosNewlineMode::Return); + progress.record_posted("more", MacosNewlineMode::Return); + assert_eq!(progress.expected(), DeliveryExpectation::Unavailable); + let mut unknown = KeyboardDeliveryProgress::new(None); + unknown.record_posted("text", MacosNewlineMode::ShiftReturn); + assert_eq!(unknown.expected(), DeliveryExpectation::Unavailable); + let mut cr = KeyboardDeliveryProgress::new(None); + cr.record_posted("\r", MacosNewlineMode::Return); + assert_eq!(cr.expected(), DeliveryExpectation::NothingPosted); + } + + #[test] + fn delivery_offset_overflow_does_not_wrap_into_a_false_receipt() { + let mut progress = KeyboardDeliveryProgress::new(Some(usize::MAX)); + progress.record_posted("x", MacosNewlineMode::ShiftReturn); + assert_eq!(progress.expected(), DeliveryExpectation::Unavailable); + } + + #[test] + fn final_delivery_waits_for_the_cumulative_end_not_a_selected_range() { let mut samples = [(120, 20), (101, 0), (119, 0), (120, 0)].into_iter(); - let mut waits = 0; + let clock = Cell::new(Duration::ZERO); assert_eq!( wait_for_caret_delivery( 100, - &"字".repeat(20), + 120, || samples.next(), - || { - waits += 1; - true - } + || clock.get(), + || clock.set(clock.get() + Duration::from_millis(10)) ), KeyboardDeliveryOutcome::Delivered ); - assert_eq!(waits, 3); + assert_eq!(clock.get(), Duration::from_millis(30)); } #[test] - fn unavailable_or_stalled_targets_do_not_wait_forever() { + fn stale_readable_caret_stops_after_short_no_progress_budget() { + let clock = Cell::new(Duration::ZERO); assert_eq!( wait_for_caret_delivery( - 0, - "input", - || None, - || panic!("unavailable target must stop") + 100, + 120, + || Some((100, 0)), + || clock.get(), + || clock.set(clock.get() + Duration::from_millis(10)) ), - KeyboardDeliveryOutcome::Unavailable + KeyboardDeliveryOutcome::NoProgress ); - let mut waits = 0; + assert_eq!(clock.get(), DELIVERY_NO_PROGRESS_BUDGET); + } + + #[test] + fn progressing_target_can_take_longer_than_one_no_progress_budget() { + let clock = Cell::new(Duration::ZERO); + let offset = Cell::new(0); assert_eq!( wait_for_caret_delivery( 0, - "input", - || Some((3, 0)), + 5, || { - waits += 1; - waits < 3 - } + offset.set(offset.get() + 1); + Some((offset.get(), 0)) + }, + || clock.get(), + || clock.set(clock.get() + Duration::from_millis(200)) + ), + KeyboardDeliveryOutcome::Delivered + ); + assert_eq!(clock.get(), Duration::from_millis(800)); + } + + #[test] + fn even_progressing_targets_have_a_hard_deadline() { + let clock = Cell::new(Duration::ZERO); + let offset = Cell::new(0); + assert_eq!( + wait_for_caret_delivery( + 0, + usize::MAX, + || { + offset.set(offset.get() + 1); + Some((offset.get(), 0)) + }, + || clock.get(), + || clock.set(clock.get() + Duration::from_millis(100)) ), KeyboardDeliveryOutcome::TimedOut ); - assert_eq!(waits, 3); + assert_eq!(clock.get(), DELIVERY_TOTAL_BUDGET); } #[test] - fn unicode_and_crlf_wait_for_the_actual_utf16_end() { - let mut samples = [(11, 0), (12, 0)].into_iter(); - let mut waits = 0; + fn unavailable_range_never_waits() { assert_eq!( wait_for_caret_delivery( - 7, - "A🙂\r\n界", - || samples.next(), - || { - waits += 1; - true - } + 0, + 5, + || None, + || Duration::ZERO, + || panic!("unavailable target must not wait") ), - KeyboardDeliveryOutcome::Delivered + KeyboardDeliveryOutcome::Unavailable ); + } + + #[test] + fn a_slow_ax_read_cannot_extend_the_total_deadline() { + let clock = Cell::new(Duration::ZERO); assert_eq!( - waits, 1, - "must neither stop at a scalar offset nor wait for swallowed CR" - ); - assert_eq!( - wait_for_caret_delivery(7, "\r", || panic!("no keys were posted"), || false), - KeyboardDeliveryOutcome::Delivered + wait_for_caret_delivery( + 0, + 5, + || { + clock.set(DELIVERY_TOTAL_BUDGET); + Some((5, 0)) + }, + || clock.get(), + || panic!("deadline already elapsed"), + ), + KeyboardDeliveryOutcome::TimedOut ); } } diff --git a/openless-all/app/src-tauri/src/host_document/mod.rs b/openless-all/app/src-tauri/src/host_document/mod.rs index ab011509..d6670a2f 100644 --- a/openless-all/app/src-tauri/src/host_document/mod.rs +++ b/openless-all/app/src-tauri/src/host_document/mod.rs @@ -29,7 +29,7 @@ mod macos; #[cfg(target_os = "macos")] -pub(crate) use macos::KeyboardDelivery; +pub(crate) use macos::{KeyboardDelivery, KeyboardDeliveryOutcome}; // `minimal_edit` 目前只有 macOS 的观察回调在用,非 macOS 构建下没有消费方。 #[allow(unused_imports)] diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index 534fcf7d..48b09e5b 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -33,6 +33,8 @@ mod core_adapters; mod correction; #[cfg(target_os = "macos")] mod macos_dictation_key; +#[cfg(target_os = "macos")] +mod macos_streaming_input; mod qa_adapter; mod tauri_coordinator_host; // 托盘麦克风设备变更监听:macOS CoreAudio / Windows MMDevice 原生通知(空闲零唤醒), @@ -121,6 +123,8 @@ const OPENLESS_BUNDLE_ID: &str = "com.openless.app"; /// 第一次 show 时把 QA 浮窗摆到屏幕底部居中;之后的 show 不再 reposition, /// 让用户拖动后的位置在 hide → show 之间得以保持。详见 issue #118 v2。 static QA_WINDOW_POSITIONED: AtomicBool = AtomicBool::new(false); +#[cfg(target_os = "macos")] +static LESS_COMPUTER_WINDOW_POSITIONED: AtomicBool = AtomicBool::new(false); /// 聊天面板退场动画的世代计数:hide 先发 `chat-panel:closing` 让前端播 220ms /// 退场动画、240ms 后才真正 hide;期间再次 show 会推进世代,作废挂起的 hide。 static QA_PANEL_EPOCH: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); @@ -238,6 +242,26 @@ macro_rules! app_invoke_handler_desktop { commands::cloud_sync_upload, commands::cloud_sync_restore, commands::cloud_sync_delete, + commands::cloud_sync_e2ee_status, + commands::cloud_sync_e2ee_claim_setup_prompt, + commands::cloud_sync_e2ee_prepare_enable, + commands::cloud_sync_e2ee_create, + commands::cloud_sync_e2ee_unlock, + commands::cloud_sync_e2ee_lock, + commands::cloud_sync_e2ee_set_enabled, + commands::cloud_sync_e2ee_sync_now, + commands::cloud_sync_e2ee_cancel, + commands::cloud_sync_e2ee_preview_restore, + commands::cloud_sync_e2ee_apply_restore, + commands::cloud_sync_e2ee_change_password, + commands::cloud_sync_e2ee_delete_remote, + commands::cloud_sync_e2ee_sign_out, + commands::cloud_sync_e2ee_begin_sign_in, + commands::cloud_sync_e2ee_poll_sign_in, + commands::cloud_sync_e2ee_cancel_sign_in, + commands::cloud_sync_e2ee_get_ui_preferences, + commands::cloud_sync_e2ee_get_ui_preferences_snapshot, + commands::cloud_sync_e2ee_set_ui_preferences_checked, commands::marketplace_logout, commands::list_vocab, commands::add_vocab, @@ -329,6 +353,8 @@ macro_rules! app_invoke_handler_desktop { commands::set_quick_note_hotkey, commands::set_style_pack_hotkeys, commands::qa_window_dismiss, + commands::qa_window_set_expanded, + commands::qa_get_snapshot, commands::qa_toggle_recording, commands::qa_submit_text, commands::qa_set_edit_instruction_mode, @@ -488,6 +514,26 @@ macro_rules! app_invoke_handler_mobile { $crate::commands::cloud_sync_upload, $crate::commands::cloud_sync_restore, $crate::commands::cloud_sync_delete, + $crate::commands::cloud_sync_e2ee_status, + $crate::commands::cloud_sync_e2ee_claim_setup_prompt, + $crate::commands::cloud_sync_e2ee_prepare_enable, + $crate::commands::cloud_sync_e2ee_create, + $crate::commands::cloud_sync_e2ee_unlock, + $crate::commands::cloud_sync_e2ee_lock, + $crate::commands::cloud_sync_e2ee_set_enabled, + $crate::commands::cloud_sync_e2ee_sync_now, + $crate::commands::cloud_sync_e2ee_cancel, + $crate::commands::cloud_sync_e2ee_preview_restore, + $crate::commands::cloud_sync_e2ee_apply_restore, + $crate::commands::cloud_sync_e2ee_change_password, + $crate::commands::cloud_sync_e2ee_delete_remote, + $crate::commands::cloud_sync_e2ee_sign_out, + $crate::commands::cloud_sync_e2ee_begin_sign_in, + $crate::commands::cloud_sync_e2ee_poll_sign_in, + $crate::commands::cloud_sync_e2ee_cancel_sign_in, + $crate::commands::cloud_sync_e2ee_get_ui_preferences, + $crate::commands::cloud_sync_e2ee_get_ui_preferences_snapshot, + $crate::commands::cloud_sync_e2ee_set_ui_preferences_checked, $crate::commands::marketplace_logout, $crate::commands::list_vocab, $crate::commands::add_vocab, @@ -504,6 +550,8 @@ macro_rules! app_invoke_handler_mobile { $crate::commands::stop_dictation, $crate::commands::cancel_dictation, $crate::commands::qa_window_dismiss, + $crate::commands::qa_window_set_expanded, + $crate::commands::qa_get_snapshot, $crate::commands::qa_toggle_recording, $crate::commands::qa_submit_text, $crate::commands::qa_set_edit_instruction_mode, @@ -553,13 +601,8 @@ fn run_desktop() { #[cfg(not(target_os = "windows"))] let coordinator = Arc::new(coordinator::Coordinator::new()); let core_backend = coordinator.backend(); - // 启动时把偏好里的 active ASR 同步进凭据库;get_credentials 按凭据库的 active 渠道取密钥。 - let startup_active_asr = core_backend.get_preferences().active_asr_provider; - if !startup_active_asr.is_empty() { - if let Err(error) = commands::sync_active_asr_provider_to_vault(&startup_active_asr) { - log::warn!("[startup] sync active ASR provider from preferences failed: {error}"); - } - } + // Runtime effects and active-provider mirroring follow Core startup/recovery + // in tauri_events::start; pending restore must not mutate the old vault here. let builder = tauri::Builder::default(); // macOS:胶囊要叠到别的 app 的全屏 Space 之上,必须是「非激活 NSPanel」(普通 // NSWindow 即便设 collectionBehavior 也做不到 —— tauri#9556 / #11488)。下面 setup 里 @@ -589,7 +632,8 @@ fn run_desktop() { .try_state::>() .map(|s| Arc::clone(&*s)) { - if coordinator.backend().get_preferences().start_minimized { + if coordinator.startup_error().is_none() + && coordinator.backend().get_preferences().start_minimized { log::info!( "[single-instance] start_minimized=true → skipping show on relaunch" ); @@ -622,20 +666,6 @@ fn run_desktop() { init_file_logger(); log::info!("=== OpenLess 启动 ==="); - #[cfg(target_os = "windows")] - { - let target = openless_core::WindowsKeyboardRuntimeTarget::from( - &coordinator.backend().get_preferences(), - ); - if let Err(err) = crate::windows_ime_profile::apply_windows_openless_keyboard_list( - target.openless_language_profile_enabled, - ) { - log::warn!( - "[windows-ime] apply keyboard list visibility pref on startup failed: {err}" - ); - } - } - // Capsule 启动时定位到屏幕底部居中并隐藏;coordinator 按需显示。 // 与 Swift `CapsuleWindowController.repositionToBottomCenter` 同语义。 if let Some(capsule) = app.get_webview_window("capsule") { @@ -691,9 +721,9 @@ fn run_desktop() { let _ = capsule.hide(); } - // QA / Less Computer / glow 浮窗改为懒创建(不再在 tauri.conf.json eager 声明): + // QA / Less Computer 浮窗懒创建(不在 tauri.conf.json eager 声明): // 用到时才 build(ensure_qa_window / ensure_less_computer_window / - // ensure_less_computer_glow_window),idle 时根本没有它们的 WebKit 进程 —— + // ensure_less_computer_window),idle 时没有额外的 WebKit 进程 —— // 省 3 个常驻 webview。定位 + QA 拖拽修复在创建/show 路径里补。 // 主窗口磨砂:macOS 用 NSVisualEffectView,Windows 用 Mica。 @@ -739,8 +769,8 @@ fn run_desktop() { // 于 prefs。 let force_show = std::env::var("OPENLESS_SHOW_MAIN_ON_START").ok().as_deref() == Some("1"); - let suppress_show = - !force_show && coordinator.backend().get_preferences().start_minimized; + let suppress_show = !force_show && coordinator.startup_error().is_none() + && coordinator.backend().get_preferences().start_minimized; if suppress_show { log::info!("[main] start_minimized=true → 跳过初始 show,等用户点托盘"); } else { @@ -849,12 +879,15 @@ fn run_desktop() { log::warn!("[startup] default window icon missing; tray icon disabled"); } - // Spin up hotkey listener; coordinator owns the lifecycle. + // Bind recovery effects before Core startup; the Ready handler + // starts hotkey supervisors once the recovery fence is clear. let app_handle = app.handle().clone(); coordinator.tauri_host().bind(app_handle); - coordinator.sync_capsule_style_from_preferences(); + coordinator.bind_restore_runtime_effects()?; + if core_backend.ensure_runtime_ready().is_ok() { + coordinator.sync_capsule_style_from_preferences(); + } crate::tauri_events::start(app.handle().clone(), Arc::clone(&core_backend)); - coordinator.start_hotkey_listener(); // QA / custom combo hotkeys use `global-hotkey` (Carbon on macOS). // Start those after RunEvent::Ready, when the AppKit event loop is live. if std::env::var("OPENLESS_SHOW_MAIN_ON_START").ok().as_deref() == Some("1") { @@ -877,19 +910,7 @@ fn run_desktop() { .run(|app, event| match event { RunEvent::Ready => { let coordinator = app.state::>(); - // 同步启动 QA hotkey listener。和 dictation hotkey 平行,互不抢状态。 - coordinator.start_qa_hotkey_listener(); - coordinator.start_selection_polish_hotkey_listener(); - // 选区语音复用选区润色热键,不再单独注册 voice hotkey。 - // 启动「快速 Agent」双热键监听(功能默认关闭,启用后才注册)。 - coordinator.start_coding_agent_hotkey_listener(); - // 启动自定义组合键监听器。当 trigger == Custom 时替代 modifier-only 监听器。 - coordinator.start_combo_hotkey_listener(); - coordinator.start_translation_hotkey_listener(); - coordinator.start_switch_style_hotkey_listener(); - coordinator.start_open_app_hotkey_listener(); - coordinator.start_quick_note_hotkey_listener(); - coordinator.start_style_pack_hotkey_listeners(); + coordinator.start_hotkey_supervisors_when_ready(); } #[cfg(target_os = "macos")] RunEvent::Reopen { .. } => show_main_window(app), @@ -1945,14 +1966,13 @@ fn wait_for_app_activation(app: &AppHandle) { #[cfg(not(target_os = "macos"))] fn wait_for_app_activation(_app: &AppHandle) {} -/// QA 浮窗的目标尺寸(issue #118;统一聊天面板后与 Less Computer 同尺寸)。 -/// 窗口固定大小,内容在面板内部的 MessageScroller 里滚动。 -const QA_WINDOW_WIDTH: f64 = 420.0; -const QA_WINDOW_HEIGHT: f64 = 540.0; +/// QA starts as a small composer and grows downwards after a question. +const QA_WINDOW_WIDTH: f64 = 480.0; +const QA_WINDOW_HEIGHT: f64 = 80.0; +const QA_WINDOW_EXPANDED_HEIGHT: f64 = 560.0; /// 胶囊与 QA 窗口的间距,与设计稿一致。 const QA_WINDOW_GAP_TO_CAPSULE: f64 = 8.0; /// 给 macOS Dock 留的下边距(与 capsule 同源)。 -const DOCK_BOTTOM_PADDING_FOR_QA: f64 = 80.0; #[derive(Clone, Copy, Debug, PartialEq)] struct LogicalMonitorFrame { @@ -2149,6 +2169,23 @@ fn floating_window_monitor_frame( ))) } +/// First presentation may follow the pointer; resizing an existing chat stays +/// on its own monitor. Work areas exclude the Dock/menu bar/taskbar. +fn chat_window_work_area(window: &tauri::WebviewWindow, initial: bool) -> tauri::Result> { + #[cfg(target_os = "macos")] + if initial { + if let Some(target) = capsule_target_monitor(window) { + return Ok(Some((target.logical_work_area(), target.scale.max(0.1)))); + } + } + #[cfg(not(target_os = "macos"))] + let _ = initial; + let Some(monitor) = window.current_monitor()? else { return Ok(None); }; + let area = monitor.work_area(); + let scale = monitor.scale_factor().max(0.1); + Ok(Some((logical_monitor_frame(area.position.x, area.position.y, area.size.width, area.size.height, scale), scale))) +} + #[cfg(target_os = "macos")] fn macos_mouse_cursor_point() -> Option<(f64, f64)> { macos_capsule_ax::mouse_cursor_point() @@ -2429,18 +2466,39 @@ fn clamp_to_monitor( /// 把 QA 浮窗放到屏幕底部居中、紧贴胶囊上方。tauri 启动期 + show 之前都会调一次, /// 防止用户切换显示器后位置错乱。 fn position_qa_window(window: &tauri::WebviewWindow) -> tauri::Result<()> { - let Some(frame) = floating_window_monitor_frame(window)? else { + let Some((frame, scale)) = chat_window_work_area(window, true)? else { return Ok(()); }; let capsule_height = capsule_height_for_qa(); let (x, y) = bottom_center_position( frame, QA_WINDOW_WIDTH, - QA_WINDOW_HEIGHT, - DOCK_BOTTOM_PADDING_FOR_QA + capsule_height + QA_WINDOW_GAP_TO_CAPSULE, + QA_WINDOW_EXPANDED_HEIGHT, + capsule_height + QA_WINDOW_GAP_TO_CAPSULE, ); - window.set_size(tauri::LogicalSize::new(QA_WINDOW_WIDTH, QA_WINDOW_HEIGHT))?; - window.set_position(LogicalPosition::new(x, y))?; + window.set_position(tauri::PhysicalPosition::new((x * scale).round() as i32, (y * scale).round() as i32))?; + window.set_size(tauri::PhysicalSize::new((QA_WINDOW_WIDTH * scale).round() as u32, (QA_WINDOW_HEIGHT * scale).round() as u32))?; + Ok(()) +} + +/// Called on the native main thread by the restricted QA command. The compact +/// state has a genuinely small native frame, so hidden content cannot eat clicks. +pub(crate) fn set_qa_window_expanded(app: &AppHandle, expanded: bool) -> Result<(), String> { + let window = app.get_webview_window("qa").ok_or_else(|| "qa_window_unavailable".to_string())?; + let area = chat_window_work_area(&window, false).map_err(|_| "qa_geometry_unavailable")?; + let scale = area.map(|(_, scale)| scale).unwrap_or(window.scale_factor().map_err(|_| "qa_geometry_unavailable")?); + let position = window.inner_position().map_err(|_| "qa_geometry_unavailable")?.to_logical::(scale); + let mut width = QA_WINDOW_WIDTH; + let mut height = if expanded { QA_WINDOW_EXPANDED_HEIGHT } else { QA_WINDOW_HEIGHT }; + let (mut x, mut y) = (position.x, position.y); + if let Some((frame, _)) = area { + width = width.min((frame.width - 32.0).max(240.0)); + height = height.min((frame.height - 64.0).max(QA_WINDOW_HEIGHT)); + x = x.clamp(frame.x + 16.0, (frame.x + frame.width - width - 16.0).max(frame.x + 16.0)); + y = y.clamp(frame.y + 32.0, (frame.y + frame.height - height - 16.0).max(frame.y + 32.0)); + } + window.set_position(tauri::PhysicalPosition::new((x * scale).round() as i32, (y * scale).round() as i32)).map_err(|_| "qa_position_failed")?; + window.set_size(tauri::PhysicalSize::new((width * scale).round() as u32, (height * scale).round() as u32)).map_err(|_| "qa_resize_failed")?; Ok(()) } @@ -2559,8 +2617,15 @@ fn make_chat_window_panel_macos(window: &tauri::WebviewWindow use tauri_nspanel::WebviewWindowExt; match window.to_panel() { Ok(panel) => { - const NS_NONACTIVATING_PANEL_MASK: i32 = 1 << 7; - panel.set_style_mask(NS_NONACTIVATING_PANEL_MASK); + use objc2::msg_send; + use objc2::runtime::AnyObject; + let raw = &*panel as *const _ as *mut AnyObject; + if !raw.is_null() { + unsafe { + let current: usize = msg_send![raw, styleMask]; + let _: () = msg_send![raw, setStyleMask: current | (1usize << 7)]; + } + } // 浮层级别(NSFloatingWindowLevel):盖普通窗口,不盖菜单栏/胶囊(25)。 panel.set_level(3); panel.set_collection_behaviour( @@ -2690,7 +2755,8 @@ fn ensure_less_computer_window( .shadow(true) .always_on_top(true) .skip_taskbar(true) - .resizable(false) + .resizable(true) + .min_inner_size(760.0, 520.0) .focused(false) .visible(false) .accept_first_mouse(true) @@ -2703,6 +2769,7 @@ fn ensure_less_computer_window( let _ = app.run_on_main_thread(move || { make_chat_window_panel_macos(&w_clone, "less-computer"); make_chat_window_draggable_macos(&w_clone, "less-computer"); + LESS_COMPUTER_WINDOW_POSITIONED.store(false, Ordering::Relaxed); }); Some(w) } @@ -2732,7 +2799,8 @@ fn ensure_less_computer_window( .shadow(true) .always_on_top(true) .skip_taskbar(true) - .resizable(false) + .resizable(true) + .min_inner_size(760.0, 520.0) .focused(false) .visible(false) .build() @@ -2743,41 +2811,6 @@ fn ensure_less_computer_window( }) } -/// 懒创建 Less Computer glow 描边窗(macOS only)。shadow:false、无 acceptFirstMouse。 -/// 它的 level/collectionBehavior/ignore-mouse 在每次 show_less_computer_glow 里幂等设置, -/// 所以创建时不需要额外原生配置。 -#[cfg(target_os = "macos")] -fn ensure_less_computer_glow_window( - app: &AppHandle, -) -> Option> { - if let Some(w) = app.get_webview_window("less-computer-glow") { - return Some(w); - } - match WebviewWindowBuilder::new( - app, - "less-computer-glow", - WebviewUrl::App("index.html?window=less-computer-glow".into()), - ) - .title("OpenLess Less Computer Glow") - .inner_size(800.0, 600.0) - .decorations(false) - .transparent(true) - .shadow(false) - .always_on_top(true) - .skip_taskbar(true) - .resizable(false) - .focused(false) - .visible(false) - .build() - { - Ok(w) => Some(w), - Err(e) => { - log::warn!("[less-computer-glow] lazy window create failed: {e}"); - None - } - } -} - /// 带退场动画地隐藏聊天面板:先发 `chat-panel:closing` 让前端播退场动画, /// 240ms 后真正 hide。期间再次 show(epoch 前进)则作废本次挂起的 hide —— /// 避免「关的动画还没放完用户又唤起 → 窗口被旧定时器藏掉」。 @@ -3291,33 +3324,26 @@ pub(crate) fn hide_selection_voice_intent_prompt(_app: &AppHa // 操作仍按平台分支,macOS 的 NSWindow/AppKit 调整不能流入 Windows 构建。 // Linux 的产品窗口由 egui Host 接入,不在 Tauri 创建;不支持的平台保留 no-op。 -/// Less Computer 浮窗尺寸:与 QA 同款「统一聊天面板」固定大小 —— 窗口出现即 -/// 定死,内容只在面板内部的 MessageScroller 里滚动,不再按内容自适应缩放窗口。 +/// Less Computer defaults to a desktop workspace; users can resize it afterwards. #[cfg(any(target_os = "macos", target_os = "windows"))] -const LESS_COMPUTER_WINDOW_WIDTH: f64 = 420.0; +const LESS_COMPUTER_WINDOW_WIDTH: f64 = 990.0; #[cfg(any(target_os = "macos", target_os = "windows"))] -const LESS_COMPUTER_WINDOW_HEIGHT: f64 = 540.0; +const LESS_COMPUTER_WINDOW_HEIGHT: f64 = 680.0; -/// 把 Less Computer 浮窗(固定尺寸)摆到屏幕底部居中、紧贴胶囊上方。 +/// Position the initial workspace within the monitor; subsequent shows preserve user geometry. #[cfg(target_os = "macos")] fn position_less_computer_window( window: &tauri::WebviewWindow, ) -> tauri::Result<()> { - let Some(frame) = floating_window_monitor_frame(window)? else { + let Some((frame, scale)) = chat_window_work_area(window, true)? else { return Ok(()); }; - let capsule_height = capsule_height_for_qa(); - let (x, y) = bottom_center_position( - frame, - LESS_COMPUTER_WINDOW_WIDTH, - LESS_COMPUTER_WINDOW_HEIGHT, - DOCK_BOTTOM_PADDING_FOR_QA + capsule_height + QA_WINDOW_GAP_TO_CAPSULE, - ); - window.set_size(tauri::LogicalSize::new( - LESS_COMPUTER_WINDOW_WIDTH, - LESS_COMPUTER_WINDOW_HEIGHT, - ))?; - window.set_position(LogicalPosition::new(x, y))?; + let width = LESS_COMPUTER_WINDOW_WIDTH.min((frame.width - 32.0).max(760.0)); + let height = LESS_COMPUTER_WINDOW_HEIGHT.min((frame.height - 32.0).max(520.0)); + let x = frame.x + (frame.width - width).max(0.0) / 2.0; + let y = frame.y + (frame.height - height).max(0.0) / 2.0; + window.set_position(tauri::PhysicalPosition::new((x * scale).round() as i32, (y * scale).round() as i32))?; + window.set_size(tauri::PhysicalSize::new((width * scale).round() as u32, (height * scale).round() as u32))?; Ok(()) } @@ -3336,8 +3362,11 @@ pub(crate) fn show_less_computer_window(app: &AppHandle) { // voice Agent turn. Keep every AppKit-backed window mutation on the main // thread; macOS aborts the process if a converted NSPanel is resized or moved // from that worker while WebKit is servicing its custom URL scheme. - if let Err(e) = position_less_computer_window(&window_clone) { - log::warn!("[less-computer] position before show failed: {e}"); + if !LESS_COMPUTER_WINDOW_POSITIONED.load(Ordering::Relaxed) { + match position_less_computer_window(&window_clone) { + Ok(()) => LESS_COMPUTER_WINDOW_POSITIONED.store(true, Ordering::Relaxed), + Err(e) => log::warn!("[less-computer] position before show failed: {e}"), + } } // A lazily-created window starts with Tauri's visible=false state. Cocoa's // orderFrontRegardless alone does not always clear that state, leaving the first @@ -3393,88 +3422,8 @@ pub(crate) fn hide_less_computer_window(app: &AppHandle) { #[cfg(not(any(target_os = "macos", target_os = "windows")))] pub(crate) fn hide_less_computer_window(_app: &AppHandle) {} -/// 显示全屏彩虹描边浮层:盖满当前显示器、点击穿透、置顶。Agent 工作时点亮整屏边缘。 -#[cfg(target_os = "macos")] -pub(crate) fn show_less_computer_glow(app: &AppHandle) { - let Some(window) = ensure_less_computer_glow_window(app) else { - return; - }; - // 盖满胶囊所在的那块显示器(跟随鼠标光标,见 floating_window_monitor_frame), - // 含菜单栏/Dock 区域。frame 已是「逻辑坐标」—— Retina 上 monitor.size() 是物理像素(2x), - // 直接拿去 set_size 会把窗口铺成两倍、错位、不贴边。 - let frame = floating_window_monitor_frame(&window) - .ok() - .flatten() - .or_else(|| { - let monitor = app.primary_monitor().ok().flatten()?; - let size = monitor.size(); - let pos = monitor.position(); - Some(logical_monitor_frame( - pos.x, - pos.y, - size.width, - size.height, - monitor.scale_factor(), - )) - }); - if let Some(frame) = frame { - let _ = window.set_position(tauri::LogicalPosition::new(frame.x, frame.y)); - let _ = window.set_size(tauri::LogicalSize::new(frame.width, frame.height)); - } - // 点击穿透:纯视觉浮层,绝不拦截鼠标。 - let _ = window.set_ignore_cursor_events(true); - // issue #470:通知 glow 前端「可见」,恢复发光动画(隐藏时会 emit(false) 卸载发光层以释放 GPU)。 - let _ = window.emit("less-computer-glow:active", true); - let window_clone = window.clone(); - let app_for_reassert = app.clone(); - let _ = app.run_on_main_thread(move || { - use objc2::msg_send; - use objc2::runtime::AnyObject; - match window_clone.ns_window() { - Ok(handle) => { - let ns = handle as *mut AnyObject; - if ns.is_null() { - let _ = window_clone.show(); - } else { - unsafe { - // 抬到菜单栏(24)/Dock 之上,让描边能真正贴到屏幕最外缘(含顶部菜单栏区域)。 - let _: () = msg_send![ns, setLevel: 25i64]; - // 所有 Space 都显示、不参与窗口循环、全屏 app 上也叠加(273 = - // CanJoinAllSpaces|Stationary|FullScreenAuxiliary)。macOS 26 会在 - // 运行中把窗口从「全 Space 贴附」剥离且同值写入救不回(详见 - // show_capsule_window_no_activate 的重注册注释);glow show 频率低, - // 每次都走重注册序列:先以去掉 CanJoinAllSpaces 位的 272 上屏, - // 下一个 tick 再写 273 —— 可见状态下的位翻转才触发重新注册。 - let _: () = msg_send![ns, setCollectionBehavior: 272u64]; - let _: () = msg_send![ns, setIgnoresMouseEvents: true]; - let _: () = msg_send![ns, orderFrontRegardless]; - } - let window_for_reassert = window_clone.clone(); - std::thread::spawn(move || { - std::thread::sleep(std::time::Duration::from_millis(30)); - let _ = app_for_reassert.run_on_main_thread(move || { - let Ok(handle) = window_for_reassert.ns_window() else { - return; - }; - let ns = handle as *mut AnyObject; - if ns.is_null() { - return; - } - unsafe { - let _: () = msg_send![ns, setCollectionBehavior: 273u64]; - } - }); - }); - } - } - Err(_) => { - let _ = window_clone.show(); - } - } - }); -} - -#[cfg(not(target_os = "macos"))] +/// Less Computer presents work and recording feedback inside its panel. +/// Keep the host port callable without creating a full-screen glow WebView. pub(crate) fn show_less_computer_glow(_app: &AppHandle) {} /// 隐藏全屏彩虹描边浮层。 diff --git a/openless-all/app/src-tauri/src/macos_streaming_input.rs b/openless-all/app/src-tauri/src/macos_streaming_input.rs new file mode 100644 index 00000000..90c79696 --- /dev/null +++ b/openless-all/app/src-tauri/src/macos_streaming_input.rs @@ -0,0 +1,626 @@ +//! Serial macOS keyboard posting with one terminal AX delivery barrier. +//! +//! AX references are created, used and released on the worker thread. The +//! cloneable handle contains only channels and completion state, never raw AX +//! pointers. Once a write is queued, dropping its future does not revoke it; +//! callers must await `finish` before restoring TIS, including on cancellation. + +#![cfg(target_os = "macos")] + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{mpsc, Arc, OnceLock}; + +use openless_core::ports::InsertWriteResult; +use openless_core::{BackendError, BackendErrorCode}; +use parking_lot::Mutex; +use tokio::sync::{oneshot, Notify}; + +use crate::host_document::{KeyboardDelivery, KeyboardDeliveryOutcome}; +use crate::selection::SelectionInsertionTarget; +use crate::types::MacosNewlineMode; + +#[derive(Clone)] +pub(crate) struct MacStreamingInput { + inner: Arc, +} + +struct WorkerHandle { + // Taking the sender seals the queue atomically with respect to new writes. + commands: Mutex>>, + completion: Arc, + closed: Arc, +} + +enum Command { + Write { + text: String, + reply: oneshot::Sender>, + }, + Finish, +} + +#[derive(Default)] +struct Completion { + result: OnceLock>, + ready: Notify, +} + +impl Completion { + fn settle(&self, result: Result<(), BackendError>) { + if self.result.set(result).is_ok() { + self.ready.notify_waiters(); + } + } + + async fn wait(&self) -> Result<(), BackendError> { + loop { + let notified = self.ready.notified(); + if let Some(result) = self.result.get() { + return result.clone(); + } + notified.await; + } + } +} + +// A panic or an unexpectedly abandoned channel must wake all terminal waiters. +struct WorkerExit(Arc); + +impl Drop for WorkerExit { + fn drop(&mut self) { + self.0.settle(Err(worker_stopped())); + } +} + +impl Drop for WorkerHandle { + fn drop(&mut self) { + // Best effort cleanup when the last handle goes away. This cannot + // replace awaiting finish before restoring the caller's input source. + if let Some(sender) = self.commands.get_mut().take() { + if sender.send(Command::Finish).is_err() { + self.completion.settle(Err(worker_stopped())); + } + } + } +} + +impl MacStreamingInput { + pub(crate) fn spawn( + target: SelectionInsertionTarget, + newline_mode: MacosNewlineMode, + closed: Arc, + ) -> Result { + Self::spawn_with_backend( + move || NativeInput { + target, + newline_mode, + submitted_return: false, + }, + closed, + ) + } + + // B need not be Send: it is constructed and destroyed inside this thread. + fn spawn_with_backend( + create: impl FnOnce() -> B + Send + 'static, + closed: Arc, + ) -> Result { + let (sender, receiver) = mpsc::channel(); + let completion = Arc::new(Completion::default()); + let worker_completion = Arc::clone(&completion); + let worker_closed = Arc::clone(&closed); + std::thread::Builder::new() + .name("openless-macos-stream-input".into()) + .spawn(move || { + let _exit = WorkerExit(Arc::clone(&worker_completion)); + let mut input = InputState::new(create()); + let mut failed: Option = None; + while let Ok(command) = receiver.recv() { + match command { + Command::Write { text, reply } => { + let result = if let Some(error) = &failed { + Err(error.clone()) + } else { + input.write(&text, &worker_closed) + }; + match &result { + Err(error) => failed = Some(error.clone()), + Ok(result) if result.written_chars < text.chars().count() => { + failed = Some(BackendError::new( + BackendErrorCode::Platform, + "native keyboard insertion stopped after a partial write", + )); + } + _ => {} + } + // A dropped receiver does not abandon a committed + // native write or the terminal delivery barrier. + let _ = reply.send(result); + } + Command::Finish => break, + } + } + // Previous write failures were reported to Core already. Its + // clipboard reconciliation must still be able to finish cleanup. + let result = input.finish(); + worker_completion.settle(result); + }) + .map_err(|error| { + BackendError::new( + BackendErrorCode::Platform, + format!("start macOS streaming input worker: {error}"), + ) + })?; + Ok(Self { + inner: Arc::new(WorkerHandle { + commands: Mutex::new(Some(sender)), + completion, + closed, + }), + }) + } + + pub(crate) async fn write(&self, text: String) -> Result { + let (reply, receiver) = oneshot::channel(); + { + let commands = self.inner.commands.lock(); + if self.inner.closed.load(Ordering::Acquire) { + return Err(input_closed()); + } + let sender = commands.as_ref().ok_or_else(input_closed)?; + sender + .send(Command::Write { text, reply }) + .map_err(|_| worker_stopped())?; + } + receiver.await.map_err(|_| worker_stopped())? + } + + /// Seal writes, drain everything already posted, then confirm the cumulative + /// caret. Concurrent or cancelled finish callers share one terminal result. + /// The caller may set `closed` to cancel not-yet-started writes, but finish + /// itself does not cancel writes that were accepted before this barrier. + pub(crate) async fn finish(&self) -> Result<(), BackendError> { + { + if let Some(sender) = self.inner.commands.lock().take() { + if sender.send(Command::Finish).is_err() { + self.inner.completion.settle(Err(worker_stopped())); + } + } + } + self.inner.completion.wait().await + } +} + +fn input_closed() -> BackendError { + BackendError::new( + BackendErrorCode::Cancelled, + "macOS streaming insertion is closed", + ) +} + +fn worker_stopped() -> BackendError { + BackendError::new( + BackendErrorCode::Internal, + "macOS streaming input worker stopped", + ) +} + +/// Only native side effects are replaceable in tests; queueing, target guards, +/// prefix accounting and terminal ordering exercise the production code. +trait InputBackend { + type Delivery; + fn restore_target(&mut self) -> bool; + fn capture_delivery(&mut self) -> Option; + fn is_focused(&mut self, delivery: &Self::Delivery) -> bool; + fn post(&mut self, text: &str) -> usize; + fn record_posted(&mut self, delivery: &mut Self::Delivery, posted: &str); + fn finish_delivery(&mut self, delivery: Self::Delivery) -> Result<(), BackendError>; +} + +struct InputState { + backend: B, + captured: bool, + delivery: Option, +} + +impl InputState { + fn new(backend: B) -> Self { + Self { + backend, + captured: false, + delivery: None, + } + } + + fn write( + &mut self, + text: &str, + closed: &AtomicBool, + ) -> Result { + if closed.load(Ordering::Acquire) { + return Err(input_closed()); + } + if text.is_empty() { + return Ok(InsertWriteResult { written_chars: 0 }); + } + if !self.backend.restore_target() { + return Err(BackendError::new( + BackendErrorCode::Platform, + "original text insertion target is unavailable", + )); + } + if !self.captured { + self.delivery = self.backend.capture_delivery(); + self.captured = true; + } + if self + .delivery + .as_ref() + .is_some_and(|delivery| !self.backend.is_focused(delivery)) + { + return Err(BackendError::new( + BackendErrorCode::Platform, + "original text insertion control lost focus", + )); + } + // Restoring focus / AX capture can take time. A cancellation that won + // during those operations must not start posting a fresh batch. + if closed.load(Ordering::Acquire) { + return Err(input_closed()); + } + let written_chars = self.backend.post(text); + if let Some(delivery) = self.delivery.as_mut() { + let posted: String = text.chars().take(written_chars).collect(); + self.backend.record_posted(delivery, &posted); + } + Ok(InsertWriteResult { written_chars }) + } + + fn finish(mut self) -> Result<(), BackendError> { + match self.delivery.take() { + Some(delivery) => self.backend.finish_delivery(delivery), + None => Ok(()), + } + } +} + +struct NativeInput { + target: SelectionInsertionTarget, + newline_mode: MacosNewlineMode, + submitted_return: bool, +} + +impl InputBackend for NativeInput { + type Delivery = KeyboardDelivery; + + fn restore_target(&mut self) -> bool { + crate::selection::reactivate_selection_insertion_target(&self.target) + } + + fn capture_delivery(&mut self) -> Option { + KeyboardDelivery::capture() + } + + fn is_focused(&mut self, delivery: &Self::Delivery) -> bool { + // Explicit Return mode can submit/replace the focused control. Preserve + // its existing posted-input fallback after an actual submitting key; + // the original application check still runs before every later batch. + self.submitted_return || delivery.is_focused() + } + + fn post(&mut self, text: &str) -> usize { + match crate::unicode_keystroke::type_unicode_chunk_with_options(text, self.newline_mode) { + Ok(written) => written, + Err(error) => { + let written = error.typed_chars(); + log::warn!("[insertion] keyboard posting failed after {written} chars: {error}"); + written + } + } + } + + fn record_posted(&mut self, delivery: &mut Self::Delivery, posted: &str) { + delivery.record_posted(posted, self.newline_mode); + if self.newline_mode == MacosNewlineMode::Return && posted.contains('\n') { + self.submitted_return = true; + } + } + + fn finish_delivery(&mut self, delivery: Self::Delivery) -> Result<(), BackendError> { + let outcome = delivery.finish(); + if outcome != KeyboardDeliveryOutcome::Delivered { + log::warn!("[insertion] using posted-input completion; caret outcome={outcome:?}"); + } + // Unsupported/stalled AX targets already use posting completion. Never + // turn an unobservable caret into a retry/paste of text already sent. + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::rc::Rc; + use std::time::Duration; + + struct FakeInput { + events: mpsc::Sender, + restore: bool, + focused: bool, + observable: bool, + limit: usize, + post_gate: Option>, + finish_gate: Option>, + cancel_during_restore: Option>, + } + + impl FakeInput { + fn new(events: mpsc::Sender) -> Self { + Self { + events, + restore: true, + focused: true, + observable: true, + limit: usize::MAX, + post_gate: None, + finish_gate: None, + cancel_during_restore: None, + } + } + } + + // Rc deliberately makes the receipt !Send, like the real AX reference. + impl InputBackend for FakeInput { + type Delivery = (Rc<()>, String); + fn restore_target(&mut self) -> bool { + if let Some(closed) = &self.cancel_during_restore { + closed.store(true, Ordering::Release); + } + self.restore + } + fn capture_delivery(&mut self) -> Option { + self.events.send("capture".into()).unwrap(); + self.observable.then(|| (Rc::new(()), String::new())) + } + fn is_focused(&mut self, _: &Self::Delivery) -> bool { + self.focused + } + fn post(&mut self, text: &str) -> usize { + self.events.send(format!("post:{text}")).unwrap(); + if let Some(gate) = self.post_gate.take() { + gate.recv_timeout(Duration::from_secs(2)).unwrap(); + } + text.chars().count().min(self.limit) + } + fn record_posted(&mut self, delivery: &mut Self::Delivery, text: &str) { + delivery.1.push_str(text); + } + fn finish_delivery(&mut self, delivery: Self::Delivery) -> Result<(), BackendError> { + self.events.send(format!("ack:{}", delivery.1)).unwrap(); + if let Some(gate) = self.finish_gate.take() { + gate.recv_timeout(Duration::from_secs(2)).unwrap(); + } + self.events.send("finished".into()).unwrap(); + Ok(()) + } + } + + fn event(events: &mpsc::Receiver) -> String { + events.recv_timeout(Duration::from_secs(2)).unwrap() + } + + #[tokio::test] + async fn writes_do_not_wait_for_ack_and_finish_waits_for_the_combined_receipt() { + let (tx, events) = mpsc::channel(); + let (release, gate) = mpsc::channel(); + let input = MacStreamingInput::spawn_with_backend( + move || { + let mut backend = FakeInput::new(tx); + backend.finish_gate = Some(gate); + backend + }, + Arc::new(AtomicBool::new(false)), + ) + .unwrap(); + assert_eq!(input.write("A🙂".into()).await.unwrap().written_chars, 2); + assert_eq!(input.write("B".into()).await.unwrap().written_chars, 1); + assert_eq!(event(&events), "capture"); + assert_eq!(event(&events), "post:A🙂"); + assert_eq!(event(&events), "post:B"); + assert!(events.try_recv().is_err()); + let mut finish = std::pin::pin!(input.finish()); + assert!(futures_util::poll!(finish.as_mut()).is_pending()); + assert_eq!(event(&events), "ack:A🙂B"); + assert!(futures_util::poll!(finish.as_mut()).is_pending()); + release.send(()).unwrap(); + finish.await.unwrap(); + input.finish().await.unwrap(); + assert_eq!(event(&events), "finished"); + assert_eq!( + input.write("late".into()).await.unwrap_err().code, + BackendErrorCode::Cancelled + ); + } + + #[tokio::test] + async fn a_dropped_write_and_finish_future_do_not_abandon_queued_input() { + let (tx, events) = mpsc::channel(); + let (release, gate) = mpsc::channel(); + let input = MacStreamingInput::spawn_with_backend( + move || { + let mut backend = FakeInput::new(tx); + backend.post_gate = Some(gate); + backend + }, + Arc::new(AtomicBool::new(false)), + ) + .unwrap(); + { + let mut write = std::pin::pin!(input.write("committed".into())); + assert!(futures_util::poll!(write.as_mut()).is_pending()); + assert_eq!(event(&events), "capture"); + assert_eq!(event(&events), "post:committed"); + } + { + let mut finish = std::pin::pin!(input.finish()); + assert!(futures_util::poll!(finish.as_mut()).is_pending()); + } + release.send(()).unwrap(); + input.finish().await.unwrap(); + assert_eq!(event(&events), "ack:committed"); + assert_eq!(event(&events), "finished"); + } + + #[tokio::test] + async fn cancellation_rejects_queued_writes_but_drains_the_started_batch() { + let (tx, events) = mpsc::channel(); + let (release, gate) = mpsc::channel(); + let closed = Arc::new(AtomicBool::new(false)); + let input = MacStreamingInput::spawn_with_backend( + move || { + let mut backend = FakeInput::new(tx); + backend.post_gate = Some(gate); + backend + }, + Arc::clone(&closed), + ) + .unwrap(); + let mut first = std::pin::pin!(input.write("first".into())); + assert!(futures_util::poll!(first.as_mut()).is_pending()); + assert_eq!(event(&events), "capture"); + assert_eq!(event(&events), "post:first"); + let mut second = std::pin::pin!(input.write("cancelled".into())); + assert!(futures_util::poll!(second.as_mut()).is_pending()); + closed.store(true, Ordering::Release); + let mut finish = std::pin::pin!(input.finish()); + assert!(futures_util::poll!(finish.as_mut()).is_pending()); + release.send(()).unwrap(); + assert_eq!(first.await.unwrap().written_chars, 5); + assert_eq!(second.await.unwrap_err().code, BackendErrorCode::Cancelled); + finish.await.unwrap(); + assert_eq!(event(&events), "ack:first"); + assert_eq!(event(&events), "finished"); + } + + #[tokio::test] + async fn target_failure_prevents_posting_and_does_not_block_cleanup() { + for restore_failure in [true, false] { + let (tx, events) = mpsc::channel(); + let input = MacStreamingInput::spawn_with_backend( + move || { + let mut backend = FakeInput::new(tx); + backend.restore = !restore_failure; + backend.focused = false; + backend + }, + Arc::new(AtomicBool::new(false)), + ) + .unwrap(); + assert_eq!( + input.write("must not post".into()).await.unwrap_err().code, + BackendErrorCode::Platform + ); + input.finish().await.unwrap(); + assert!(events.try_iter().all(|event| !event.starts_with("post:"))); + } + } + + #[tokio::test] + async fn cancellation_during_target_restore_is_checked_before_posting() { + let (tx, events) = mpsc::channel(); + let closed = Arc::new(AtomicBool::new(false)); + let cancel = Arc::clone(&closed); + let input = MacStreamingInput::spawn_with_backend( + move || { + let mut backend = FakeInput::new(tx); + backend.cancel_during_restore = Some(cancel); + backend + }, + closed, + ) + .unwrap(); + assert_eq!( + input.write("must not post".into()).await.unwrap_err().code, + BackendErrorCode::Cancelled + ); + input.finish().await.unwrap(); + assert!(events.try_iter().all(|event| !event.starts_with("post:"))); + } + + #[tokio::test] + async fn unobservable_targets_keep_posting_fallback_without_recapturing() { + let (tx, events) = mpsc::channel(); + let input = MacStreamingInput::spawn_with_backend( + move || { + let mut backend = FakeInput::new(tx); + backend.observable = false; + backend + }, + Arc::new(AtomicBool::new(false)), + ) + .unwrap(); + input.write("one".into()).await.unwrap(); + input.write("two".into()).await.unwrap(); + input.finish().await.unwrap(); + assert_eq!( + events.try_iter().collect::>(), + ["capture", "post:one", "post:two"] + ); + } + + #[tokio::test] + async fn partial_writes_account_only_for_the_posted_prefix_and_stop_later_input() { + let (tx, events) = mpsc::channel(); + let input = MacStreamingInput::spawn_with_backend( + move || { + let mut backend = FakeInput::new(tx); + backend.limit = 2; + backend + }, + Arc::new(AtomicBool::new(false)), + ) + .unwrap(); + assert_eq!(input.write("A🙂B".into()).await.unwrap().written_chars, 2); + assert!(input.write("duplicate".into()).await.is_err()); + input.finish().await.unwrap(); + assert_eq!( + events.try_iter().collect::>(), + ["capture", "post:A🙂B", "ack:A🙂", "finished"] + ); + } + + #[tokio::test] + async fn last_handle_drop_closes_the_queue_and_drains_its_receipt() { + let (tx, events) = mpsc::channel(); + let input = MacStreamingInput::spawn_with_backend( + move || FakeInput::new(tx), + Arc::new(AtomicBool::new(false)), + ) + .unwrap(); + input.write("done".into()).await.unwrap(); + let completion = Arc::clone(&input.inner.completion); + drop(input); + completion.wait().await.unwrap(); + assert_eq!( + events.try_iter().collect::>(), + ["capture", "post:done", "ack:done", "finished"] + ); + } + + #[tokio::test] + async fn worker_start_panic_wakes_write_and_finish_waiters() { + let input = MacStreamingInput::spawn_with_backend( + || -> FakeInput { panic!("test worker initialization failure") }, + Arc::new(AtomicBool::new(false)), + ) + .unwrap(); + assert_eq!( + input.write("not posted".into()).await.unwrap_err().code, + BackendErrorCode::Internal + ); + assert_eq!( + input.finish().await.unwrap_err().code, + BackendErrorCode::Internal + ); + } +} diff --git a/openless-all/app/src-tauri/src/persistence/android_credentials.rs b/openless-all/app/src-tauri/src/persistence/android_credentials.rs index 83530f77..c6d17e1f 100644 --- a/openless-all/app/src-tauri/src/persistence/android_credentials.rs +++ b/openless-all/app/src-tauri/src/persistence/android_credentials.rs @@ -124,6 +124,58 @@ fn open_envelope( .map_err(StoreError::Crypto) } +/// Inspect the last durable candidate without completing migrations or recovery. +/// Startup with an E2EE recovery marker must not rename, chmod, reset a key, +/// mark migration complete, or delete any envelope before its restore lease. +pub(super) fn read_only( + path: &Path, + crypto: &mut impl AndroidCredentialsCrypto, +) -> Result { + match fs::read(verified_v2_temporary_path(path)) { + Ok(bytes) => return open_envelope(&bytes, crypto).map(ReadOutcome::Plaintext), + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => return Err(io_error("read verified recovery candidate", error)), + } + let bytes = match fs::read(path) { + Ok(bytes) if !bytes.is_empty() => Some(bytes), + Ok(_) => Some(Vec::new()), + Err(error) if error.kind() == io::ErrorKind::NotFound => None, + Err(error) => return Err(io_error("read without migration", error)), + }; + let bytes = if bytes.as_ref().is_none_or(Vec::is_empty) { + match fs::read(path.with_extension("legacy.tmp")) { + Ok(candidate) => candidate, + Err(error) if error.kind() == io::ErrorKind::NotFound => match bytes { + Some(bytes) => bytes, + None => return Ok(ReadOutcome::Missing), + }, + Err(error) => return Err(io_error("read legacy recovery candidate", error)), + } + } else { + bytes.ok_or(StoreError::InvalidEnvelope)? + }; + match bytes + .iter() + .copied() + .find(|byte| !byte.is_ascii_whitespace()) + { + Some(b'{') => open_envelope(&bytes, crypto).map(ReadOutcome::Plaintext), + Some(_) => { + if crypto.migration_complete().map_err(StoreError::Crypto)? { + return Err(StoreError::InvalidEnvelope); + } + let plaintext = base64::engine::general_purpose::STANDARD + .decode(&bytes) + .map_err(|_| StoreError::InvalidEnvelope)?; + if plaintext.is_empty() { + return Err(StoreError::InvalidEnvelope); + } + Ok(ReadOutcome::Legacy(plaintext)) + } + None => Err(StoreError::InvalidEnvelope), + } +} + pub(super) fn read( path: &Path, crypto: &mut impl AndroidCredentialsCrypto, @@ -746,6 +798,73 @@ mod tests { } } + #[test] + fn read_only_preserves_verified_candidates_and_invalidated_key_envelopes() { + let path = test_path("android-read-only-pending"); + let pending = verified_v2_temporary_path(&path); + let mut crypto = TestCrypto::default(); + write_verified(&path, b"old committed root", &mut crypto).unwrap(); + let old = fs::read(&path).unwrap(); + let sealed = crypto.seal(b"new verified root", ENVELOPE_AAD).unwrap(); + let candidate = envelope_for(&sealed).unwrap(); + fs::write(&pending, &candidate).unwrap(); + #[cfg(unix)] + { + fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap(); + } + crypto.fail_next_mark_migration = Some(CryptoErrorKind::TemporarilyUnavailable); + assert_eq!( + read_only(&path, &mut crypto).unwrap(), + ReadOutcome::Plaintext(b"new verified root".to_vec()) + ); + assert!(crypto.fail_next_mark_migration.is_some()); + assert_eq!(fs::read(&path).unwrap(), old); + assert_eq!(fs::read(&pending).unwrap(), candidate); + #[cfg(unix)] + { + assert_eq!( + fs::metadata(&path).unwrap().permissions().mode() & 0o777, + 0o644 + ); + } + crypto.fail_next_open = Some(CryptoErrorKind::KeyMissingOrInvalidated); + assert!(matches!( + read_only(&path, &mut crypto), + Err(StoreError::Crypto(CryptoErrorKind::KeyMissingOrInvalidated)) + )); + assert_eq!(crypto.delete_key_calls, 0); + assert_eq!(fs::read(&path).unwrap(), old); + assert_eq!(fs::read(&pending).unwrap(), candidate); + fs::write(&pending, b"invalid candidate").unwrap(); + assert!(read_only(&path, &mut crypto).is_err()); + assert_eq!(fs::read(&path).unwrap(), old); + remove_test_parent(&path); + } + + #[test] + fn read_only_legacy_recovery_never_promotes_or_marks_a_source() { + let path = test_path("android-read-only-legacy"); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + let candidate = path.with_extension("legacy.tmp"); + let bytes = base64::engine::general_purpose::STANDARD.encode(b"legacy root"); + fs::write(&candidate, &bytes).unwrap(); + let mut crypto = TestCrypto::default(); + assert_eq!( + read_only(&path, &mut crypto).unwrap(), + ReadOutcome::Legacy(b"legacy root".to_vec()) + ); + assert!(!path.exists()); + assert_eq!(fs::read(&candidate).unwrap(), bytes.as_bytes()); + assert!(!crypto.migration_complete().unwrap()); + crypto.mark_migration_complete().unwrap(); + assert!(matches!( + read_only(&path, &mut crypto), + Err(StoreError::InvalidEnvelope) + )); + assert!(candidate.exists()); + remove_test_parent(&path); + } + #[test] fn v2_round_trip_hides_plaintext() { let path = test_path("android-v2-round-trip"); diff --git a/openless-all/app/src-tauri/src/persistence/credentials.rs b/openless-all/app/src-tauri/src/persistence/credentials.rs index 645b61f0..2821bcf9 100644 --- a/openless-all/app/src-tauri/src/persistence/credentials.rs +++ b/openless-all/app/src-tauri/src/persistence/credentials.rs @@ -60,6 +60,9 @@ const RESERVED_EXTRA_HEADER_NAMES: &[&str] = &[ const KEYRING_CHUNK_MAX_UTF16_UNITS: usize = 1000; static CREDENTIALS_LOCK: OnceLock> = OnceLock::new(); +static SYNC_WRITE_GATE: OnceLock< + Mutex>>, +> = OnceLock::new(); // Keychain 访问节流:每进程只补写/扫描一次,避免重复授权弹窗。 #[cfg(not(target_os = "android"))] @@ -85,6 +88,367 @@ fn credentials_lock() -> &'static Mutex<()> { CREDENTIALS_LOCK.get_or_init(|| Mutex::new(())) } +fn sync_write_gate() -> Option> { + SYNC_WRITE_GATE + .get_or_init(|| Mutex::new(None)) + .lock() + .clone() +} + +fn install_sync_write_gate( + installed: &mut Option>, + gate: std::sync::Arc, +) -> Result<()> { + if let Some(current) = installed.as_ref() { + anyhow::ensure!( + std::sync::Arc::ptr_eq(current, &gate), + "credential vault already has a different encrypted sync gate" + ); + } else { + *installed = Some(gate); + } + Ok(()) +} + +fn sync_access_error( + error: openless_core::cloud_sync_e2ee_documents::DocumentError, +) -> anyhow::Error { + use openless_core::cloud_sync_e2ee_documents::DocumentError; + use openless_core::{BackendError, BackendErrorCode}; + let code = if error == DocumentError::SourceChanged { + BackendErrorCode::Busy + } else { + BackendErrorCode::OutcomeUnknown + }; + BackendError::new( + code, + if code == BackendErrorCode::Busy { + "credential store is busy with encrypted sync" + } else { + "credential store requires encrypted sync recovery" + }, + ) + .into() +} + +/// The lease precedes both the vault lock and the initial read. Cancellation of +/// the async caller cannot release it: this entire function runs in its worker. +fn mutate_credentials( + origin: openless_core::credentials::ChangeOrigin, + update: impl FnOnce(&mut CredsRoot) -> Result, +) -> Result<()> { + mutate_credentials_with( + sync_write_gate(), + origin, + load_credentials_for_update, + update, + save_credentials, + ) +} + +fn mutate_credentials_with( + gate: Option>, + origin: openless_core::credentials::ChangeOrigin, + load: impl FnOnce() -> Result, + update: impl FnOnce(&mut CredsRoot) -> Result, + persist: impl FnOnce(&CredsRoot) -> Result<()>, +) -> Result<()> { + let was_unbound = gate.is_none(); + let permit = gate + .map(|gate| gate.begin_mutation()) + .transpose() + .map_err(sync_access_error)?; + let _guard = credentials_lock().lock(); + // First binding may win the lock after this worker observed no gate. + // Such a worker must retry; it cannot write through a newly installed barrier. + if was_unbound && sync_write_gate().is_some() { + return Err(sync_access_error( + openless_core::cloud_sync_e2ee_documents::DocumentError::SourceChanged, + )); + } + let prepared = (|| -> Result<(CredsRoot, bool)> { + let mut root = load()?; + let changed = update(&mut root)?; + Ok((root, changed)) + })(); + let (root, changed) = match prepared { + Ok(result) => result, + Err(error) => { + if let Some(permit) = permit { + permit.abort_unmodified().map_err(sync_access_error)?; + } + return Err(error); + } + }; + if !changed { + if let Some(permit) = permit { + permit.abort_unmodified().map_err(sync_access_error)?; + } + return Ok(()); + } + if let Err(error) = persist(&root) { + // The chunked writer can prove that the old manifest never changed. + // Other native failures remain uncertain and intentionally retain intent. + if matches!( + error.downcast_ref::(), + Some(VaultCommitFailure::Unchanged) + ) { + if let Some(permit) = permit { + permit.abort_unmodified().map_err(sync_access_error)?; + } + } + return Err(error); + } + if let Some(permit) = permit { + permit.commit(origin).map_err(sync_access_error)?; + } + Ok(()) +} + +fn require_sync_exclusive(permit: &openless_core::credentials::ExclusivePermit) -> Result<()> { + let gate = sync_write_gate() + .ok_or_else(|| anyhow::anyhow!("encrypted sync credential gate is not bound"))?; + if !permit.belongs_to(&gate) { + anyhow::bail!("encrypted sync credential lease belongs to another gate"); + } + Ok(()) +} + +fn validate_sync_key(value: &openless_core::SecretValue) -> Result<()> { + use base64::Engine; + let encoded = value.expose_secret(); + anyhow::ensure!(encoded.len() == 43, "invalid encrypted sync key encoding"); + let decoded = zeroize::Zeroizing::new( + base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(encoded) + .map_err(|_| anyhow::anyhow!("invalid encrypted sync key encoding"))?, + ); + anyhow::ensure!( + decoded.len() == 32 + && base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&*decoded) == encoded, + "invalid encrypted sync key encoding" + ); + Ok(()) +} + +#[cfg(not(target_os = "android"))] +fn read_sync_secret_raw( + account: &openless_core::credentials::SyncSecretAccount, +) -> Result> { + get_keyring_password(account.as_str())? + .map(|raw| { + let value = openless_core::SecretValue::new(raw); + validate_sync_key(&value)?; + Ok(value) + }) + .transpose() +} + +#[cfg(not(target_os = "android"))] +fn write_sync_secret_raw( + account: &openless_core::credentials::SyncSecretAccount, + value: &openless_core::SecretValue, +) -> Result<()> { + set_keyring_password(account.as_str(), value.expose_secret()) +} + +#[cfg(not(target_os = "android"))] +fn remove_sync_secret_raw(account: &openless_core::credentials::SyncSecretAccount) -> Result<()> { + match keyring_entry_for(account.as_str())?.delete_credential() { + Ok(()) | Err(keyring::Error::NoEntry) => Ok(()), + Err(_) => anyhow::bail!("could not remove encrypted sync key from system credential store"), + } +} + +#[cfg(any(target_os = "android", test))] +struct SyncSecretCrypto { + inner: C, + account: String, +} + +#[cfg(any(target_os = "android", test))] +impl SyncSecretCrypto { + fn aad(&self, original: &[u8]) -> Vec { + let mut aad = b"openless-e2ee-local-keystore\0v1\0".to_vec(); + aad.extend_from_slice(self.account.as_bytes()); + aad.push(0); + aad.extend_from_slice(original); + aad + } +} + +#[cfg(any(target_os = "android", test))] +impl + super::android_credentials::AndroidCredentialsCrypto for SyncSecretCrypto +{ + fn seal( + &mut self, + plaintext: &[u8], + aad: &[u8], + ) -> std::result::Result< + super::android_credentials::SealedPayload, + super::android_credentials::CryptoErrorKind, + > { + self.inner.seal(plaintext, &self.aad(aad)) + } + fn open( + &mut self, + sealed: &super::android_credentials::SealedPayload, + aad: &[u8], + ) -> std::result::Result, super::android_credentials::CryptoErrorKind> { + self.inner.open(sealed, &self.aad(aad)) + } + fn delete_key( + &mut self, + ) -> std::result::Result<(), super::android_credentials::CryptoErrorKind> { + // This namespace never owns the provider vault's shared master key. + Ok(()) + } + fn migration_complete( + &mut self, + ) -> std::result::Result { + // Sync keys have never had a plaintext format. Reject downgrade without + // reading or modifying the provider vault's independent migration marker. + Ok(true) + } + fn mark_migration_complete( + &mut self, + ) -> std::result::Result<(), super::android_credentials::CryptoErrorKind> { + Ok(()) + } +} + +#[cfg(any(target_os = "android", test))] +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct AndroidSyncKeyRecord { + version: u32, + account: String, + value: String, +} + +#[cfg(any(target_os = "android", test))] +impl Drop for AndroidSyncKeyRecord { + fn drop(&mut self) { + use zeroize::Zeroize; + self.value.zeroize(); + } +} + +#[cfg(any(target_os = "android", test))] +fn read_android_sync_key_with_crypto( + path: &Path, + account: &openless_core::credentials::SyncSecretAccount, + crypto: &mut impl super::android_credentials::AndroidCredentialsCrypto, +) -> Result> { + use super::android_credentials::ReadOutcome; + match std::fs::metadata(path) { + Ok(meta) => anyhow::ensure!( + meta.len() <= 16_384, + "encrypted sync key envelope exceeds limit" + ), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(_) => anyhow::bail!("could not inspect encrypted sync key envelope"), + } + let bytes = match super::android_credentials::read(path, crypto) + .map_err(|_| anyhow::anyhow!("could not open Android Keystore sync key"))? + { + ReadOutcome::Missing => return Ok(None), + ReadOutcome::Legacy(mut bytes) => { + use zeroize::Zeroize; + bytes.zeroize(); + anyhow::bail!("legacy plaintext sync keys are not supported"); + } + ReadOutcome::Plaintext(bytes) => zeroize::Zeroizing::new(bytes), + }; + let mut record: AndroidSyncKeyRecord = serde_json::from_slice(&bytes) + .map_err(|_| anyhow::anyhow!("invalid encrypted sync key record"))?; + anyhow::ensure!( + record.version == 1 && record.account == account.as_str(), + "encrypted sync key binding mismatch" + ); + let value = openless_core::SecretValue::new(std::mem::take(&mut record.value)); + validate_sync_key(&value)?; + Ok(Some(value)) +} + +#[cfg(any(target_os = "android", test))] +fn write_android_sync_key_with_crypto( + path: &Path, + account: &openless_core::credentials::SyncSecretAccount, + value: &openless_core::SecretValue, + crypto: &mut impl super::android_credentials::AndroidCredentialsCrypto, +) -> Result<()> { + validate_sync_key(value)?; + let record = AndroidSyncKeyRecord { + version: 1, + account: account.as_str().into(), + value: value.expose_secret().into(), + }; + let bytes = zeroize::Zeroizing::new( + serde_json::to_vec(&record) + .map_err(|_| anyhow::anyhow!("cannot encode encrypted sync key record"))?, + ); + super::android_credentials::write_verified(path, &bytes, crypto) + .map_err(|_| anyhow::anyhow!("could not save Android Keystore sync key")) +} + +#[cfg(target_os = "android")] +fn android_sync_key_path( + account: &openless_core::credentials::SyncSecretAccount, +) -> Result { + let credential_path = android_credentials_path()?; + let parent = credential_path + .parent() + .context("Android credential directory is unavailable")?; + Ok(parent + .join("sync-secrets") + .join(format!("{}.json", account.as_str()))) +} + +#[cfg(target_os = "android")] +fn read_sync_secret_raw( + account: &openless_core::credentials::SyncSecretAccount, +) -> Result> { + let mut crypto = SyncSecretCrypto { + inner: super::android_credentials::AndroidKeystoreCrypto, + account: account.as_str().into(), + }; + read_android_sync_key_with_crypto(&android_sync_key_path(account)?, account, &mut crypto) +} + +#[cfg(target_os = "android")] +fn write_sync_secret_raw( + account: &openless_core::credentials::SyncSecretAccount, + value: &openless_core::SecretValue, +) -> Result<()> { + let mut crypto = SyncSecretCrypto { + inner: super::android_credentials::AndroidKeystoreCrypto, + account: account.as_str().into(), + }; + write_android_sync_key_with_crypto( + &android_sync_key_path(account)?, + account, + value, + &mut crypto, + ) +} + +#[cfg(target_os = "android")] +fn remove_sync_secret_raw(account: &openless_core::credentials::SyncSecretAccount) -> Result<()> { + let path = android_sync_key_path(account)?; + // Remove only this binding, never the shared non-exportable Keystore master key. + for item in [ + path.clone(), + path.with_extension("json.tmp"), + path.with_extension("json.pending"), + ] { + super::android_credentials::secure_remove(&item) + .map_err(|_| anyhow::anyhow!("could not remove Android sync key envelope"))?; + } + Ok(()) +} + #[cfg(target_os = "android")] fn android_marketplace_token() -> &'static Mutex> { ANDROID_MARKETPLACE_TOKEN.get_or_init(|| Mutex::new(None)) @@ -120,7 +484,13 @@ fn store_credentials_cache(root: &CredsRoot) { } fn record_vault_read_failure(error: &anyhow::Error) { - let chain = format!("{error:#}"); + // Serde errors may echo a malformed field's raw string (including a secret). + // Preserve native Keystore error categories while keeping payload diagnostics local. + let chain = if error.downcast_ref::().is_some() { + "credential payload could not be decoded".to_string() + } else { + format!("{error:#}") + }; *last_vault_read_error_slot().lock() = Some(chain.clone()); let mut logged = last_vault_read_error_logged_slot().lock(); if logged.as_deref() != Some(chain.as_str()) { @@ -240,6 +610,10 @@ fn creds_default_omni() -> String { #[derive(Debug, Serialize, Deserialize, Default, Clone)] #[allow(non_snake_case)] struct CredsOmniEntry { + #[serde(flatten)] + channel: ChannelMeta, + #[serde(skip_serializing_if = "Option::is_none")] + displayName: Option, #[serde(skip_serializing_if = "Option::is_none")] apiKey: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -254,6 +628,9 @@ struct CredsOmniEntry { impl CredsOmniEntry { fn is_empty(&self) -> bool { + if self.channel.providerType.is_some() { + return false; + } self.apiKey.as_deref().unwrap_or("").is_empty() && self.baseURL.as_deref().unwrap_or("").is_empty() && self.model.as_deref().unwrap_or("").is_empty() @@ -1180,6 +1557,63 @@ fn mark_marketplace_token_verified() { MARKETPLACE_TOKEN_REJECTED.store(false, Ordering::SeqCst); } +#[cfg(any(not(target_os = "android"), test))] +fn set_marketplace_token_with( + gate: Option>, + value: &str, + load: impl FnOnce() -> Result, + persist: impl FnOnce(&CredsRoot) -> Result<()>, +) -> Result<()> { + mutate_credentials_with( + gate, + openless_core::credentials::ChangeOrigin::LocalOnly, + load, + |root| { + write_marketplace_github_token(root, Some(value.to_string())); + Ok(true) + }, + |root| { + persist(root)?; + // Keep verification in the same vault critical section as publication. + if value.trim().is_empty() { + invalidate_marketplace_token_process_local(); + } else { + mark_marketplace_token_verified(); + } + Ok(()) + }, + ) +} + +#[cfg(any(not(target_os = "android"), test))] +fn remove_marketplace_token_with( + gate: Option>, + load: impl FnOnce() -> Result, + persist: impl FnOnce(&CredsRoot) -> Result<()>, +) -> Result<()> { + // Block new reads immediately, including when the gate is busy with restore. + invalidate_marketplace_token_process_local(); + let result = mutate_credentials_with( + gate, + openless_core::credentials::ChangeOrigin::LocalOnly, + load, + |root| { + // A concurrent login could have finished while this worker waited for the lock. + invalidate_marketplace_token_process_local(); + write_marketplace_github_token(root, None); + Ok(true) + }, + persist, + ); + if result.is_err() { + // Read/gate failures happen before the update closure. Reassert the local + // revocation after any earlier in-flight login has left its vault section. + let _guard = credentials_lock().lock(); + invalidate_marketplace_token_process_local(); + } + result +} + fn read_legacy_credentials_file(path: &Path) -> Option { if !path.exists() { return None; @@ -1221,13 +1655,103 @@ fn remove_legacy_credentials_file_best_effort() { struct CredsChunkManifest { openless_credentials_storage: String, version: u32, - /// Earlier vaults used UUID-prefixed chunks. Keep reading them during migration; - /// current chunked writes use stable names so item authorizations can persist. + /// Every new Windows write uses a private generation; old stable chunks remain + /// readable until one atomic manifest update commits the new complete payload. #[serde(default, skip_serializing_if = "Option::is_none")] generation: Option, chunks: usize, } +#[derive(Debug, thiserror::Error)] +pub(crate) enum VaultCommitFailure { + #[error("credential vault write failed before commit")] + Unchanged, + #[error("credential vault commit result requires reconciliation")] + Unknown, +} + +/// Each candidate lives under a new UUID namespace. The single manifest is the +/// only publication point: readers never guess a generation from orphan chunks. +#[cfg(any(not(any(target_os = "macos", target_os = "android")), test))] +fn save_chunked_credentials_with( + json: &str, + mut read: impl FnMut(&str) -> Result>, + mut write: impl FnMut(&str, &str) -> Result<()>, + mut delete: impl FnMut(&str) -> Result<()>, +) -> Result<()> { + let previous_head = + read(KEYRING_CREDENTIALS_ACCOUNT).map_err(|_| VaultCommitFailure::Unchanged)?; + let previous = match previous_head.as_deref() { + Some(head) => match read_chunk_manifest(head) { + Some(manifest) if manifest.chunks > 0 => Some(manifest), + Some(_) => return Err(VaultCommitFailure::Unchanged.into()), + None => { + decode_single_credentials(head).map_err(|_| VaultCommitFailure::Unchanged)?; + None + } + }, + None => None, + }; + decode_single_credentials(json).map_err(|_| VaultCommitFailure::Unchanged)?; + let generation = uuid::Uuid::new_v4().to_string(); + let chunks = chunk_json_payload(json); + let accounts = (0..chunks.len()) + .map(|index| chunk_account(Some(&generation), index)) + .collect::>(); + let candidate = CredsChunkManifest { + openless_credentials_storage: "chunked".into(), + version: 1, + generation: Some(generation), + chunks: chunks.len(), + }; + let candidate_head = + serde_json::to_string(&candidate).map_err(|_| VaultCommitFailure::Unchanged)?; + let prepared = (|| -> Result<()> { + for (account, chunk) in accounts.iter().zip(&chunks) { + write(account, chunk)?; + } + let mut recovered = zeroize::Zeroizing::new(String::with_capacity(json.len())); + for account in &accounts { + let chunk = + zeroize::Zeroizing::new(read(account)?.ok_or(VaultCommitFailure::Unchanged)?); + if recovered.len().saturating_add(chunk.len()) > json.len() { + return Err(VaultCommitFailure::Unchanged.into()); + } + recovered.push_str(&chunk); + } + if recovered.as_str() != json { + return Err(VaultCommitFailure::Unchanged.into()); + } + Ok(()) + })(); + if prepared.is_err() { + for account in &accounts { + let _ = delete(account); + } + return Err(VaultCommitFailure::Unchanged.into()); + } + + // Even a failed native call may have committed. Confirm the exact head before + // deciding whether a candidate may be discarded, or old chunks may be pruned. + let _publication = write(KEYRING_CREDENTIALS_ACCOUNT, &candidate_head); + match read(KEYRING_CREDENTIALS_ACCOUNT) { + Ok(Some(head)) if head == candidate_head => {} + Ok(head) if head == previous_head => { + for account in &accounts { + let _ = delete(account); + } + return Err(VaultCommitFailure::Unchanged.into()); + } + _ => return Err(VaultCommitFailure::Unknown.into()), + } + if let Some(previous) = previous { + for index in 0..previous.chunks { + let _ = delete(&chunk_account(previous.generation.as_deref(), index)); + } + } + Ok(()) +} + /// Legacy UUID-prefixed and current stable chunk names share one reader. fn chunk_account(generation: Option<&str>, index: usize) -> String { match generation { @@ -1413,7 +1937,6 @@ fn load_keyring_credentials_with( Ok(Some(root)) } -#[cfg(any(not(target_os = "android"), test))] fn decode_single_credentials(json: &str) -> Result { // Serde defaults alone would accept unrelated JSON as an empty configuration. let payload: serde_json::Value = @@ -1543,11 +2066,143 @@ fn load_credentials_for_update() -> Result { Ok(root) } +/// Pending restore startup may inspect existing sources, but the restore lease +/// exclusively owns their eventual migration/replacement. Never synthesize an +/// empty vault from a failed native read or a corrupt legacy source. +fn load_credentials_for_recovery_readonly() -> Result { + #[cfg(not(target_os = "android"))] + let mut root = load_desktop_credentials_readonly_with( + get_keyring_password, + || { + let path = credentials_path()?; + match std::fs::read(path) { + Ok(bytes) => { + let bytes = zeroize::Zeroizing::new(bytes); + let json = std::str::from_utf8(&bytes) + .context("invalid legacy credential encoding")?; + decode_single_credentials(json).map(Some) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(_) => anyhow::bail!("could not read legacy credential source"), + } + }, + cfg!(target_os = "macos"), + )?; + #[cfg(target_os = "android")] + let mut root = { + let path = android_credentials_path()?; + let mut crypto = super::android_credentials::AndroidKeystoreCrypto; + let mut loaded = load_android_credentials_readonly_at(&path, &mut crypto)?; + if loaded.is_none() { + for source in android_legacy_credentials_paths(&path) { + loaded = load_android_credentials_readonly_at(&source, &mut crypto)?; + if loaded.is_some() { + break; + } + } + } + loaded.unwrap_or_default() + }; + migrate_channels(&mut root); + Ok(root) +} + +#[cfg(any(not(target_os = "android"), test))] +fn load_desktop_credentials_readonly_with( + mut read: impl FnMut(&str) -> Result>, + legacy_file: impl FnOnce() -> Result>, + prefer_single: bool, +) -> Result { + if prefer_single { + if let Some(json) = read(KEYRING_SINGLE_CREDENTIALS_ACCOUNT)? { + let json = zeroize::Zeroizing::new(json); + return decode_single_credentials(&json); + } + } + if let Some(root) = load_keyring_credentials_with( + &mut read, + |_, _| anyhow::bail!("read-only credential loading cannot consolidate"), + false, + )? { + return Ok(root); + } + if let Some(root) = legacy_file()? { + return Ok(root); + } + let mut root = CredsRoot::default(); + for account in CredentialAccount::all() { + if let Some(value) = read(account.keyring_account())? { + write_account(&mut root, *account, Some(value)); + } + } + Ok(clean_credentials(&root)) +} + +#[cfg(any(target_os = "android", test))] +fn load_android_credentials_readonly_at( + path: &Path, + crypto: &mut impl super::android_credentials::AndroidCredentialsCrypto, +) -> Result> { + use super::android_credentials::ReadOutcome; + let bytes = + match super::android_credentials::read_only(path, crypto).map_err(anyhow::Error::new)? { + ReadOutcome::Missing => return Ok(None), + ReadOutcome::Plaintext(bytes) | ReadOutcome::Legacy(bytes) => { + zeroize::Zeroizing::new(bytes) + } + }; + let json = + std::str::from_utf8(&bytes).context("invalid read-only Android credential encoding")?; + let root = decode_single_credentials(json)?; + // Legacy OAuth remains excluded even while the encrypted restore barrier + // temporarily prevents rewriting its old source envelope. + Ok(Some(android_persistable_credentials(&root))) +} + +fn load_credentials_for_sync_binding_with( + readonly_required: bool, + readonly: impl FnOnce() -> Result, + ordinary: impl FnOnce() -> Result, +) -> Result { + if readonly_required { + readonly() + } else { + ordinary() + } +} + +/// Bound readers may run before recovery or while an exclusive restore is +/// preparing. Only inspect complete sources; the next gated save can migrate +/// legacy storage. A failed read leaves no default cache and remains retryable. +fn load_credentials_readonly_into_cache_with( + loader: impl FnOnce() -> Result, +) -> Result { + if let Some(cached) = credentials_cache().lock().as_ref().cloned() { + return Ok(cached); + } + match loader() { + Ok(root) => { + clear_vault_read_error(); + store_credentials_cache(&root); + Ok(root) + } + Err(error) => { + record_vault_read_failure(&error); + Err(error) + } + } +} + fn load_credentials_raw() -> CredsRoot { if let Some(cached) = credentials_cache().lock().as_ref().cloned() { return cached; } + if sync_write_gate().is_some() { + return load_credentials_readonly_into_cache_with(load_credentials_for_recovery_readonly) + .unwrap_or_default(); + } + #[cfg(target_os = "android")] { return load_credentials_into_cache_with(load_android_credentials); @@ -1572,6 +2227,14 @@ fn load_credentials_for_update_raw() -> Result { return Ok(cached); } + load_credentials_for_sync_binding_with( + sync_write_gate().is_some(), + || load_credentials_readonly_into_cache_with(load_credentials_for_recovery_readonly), + load_credentials_for_update_unbound, + ) +} + +fn load_credentials_for_update_unbound() -> Result { #[cfg(target_os = "android")] { return android_credentials_root_for_update(load_android_credentials); @@ -1604,6 +2267,26 @@ fn load_credentials_for_update_raw() -> Result { } } +fn finish_credential_write(root: &CredsRoot, outcome: Result<()>) -> Result<()> { + match outcome { + Ok(()) => { + store_credentials_cache(root); + Ok(()) + } + Err(error) => { + // A native write may have committed even though confirmation failed. + // Reconciliation must read the OS store rather than a stale process cache. + if !matches!( + error.downcast_ref::(), + Some(VaultCommitFailure::Unchanged) + ) { + *credentials_cache().lock() = None; + } + Err(error) + } + } +} + fn save_credentials(root: &CredsRoot) -> Result<()> { let mut cleaned = clean_credentials(root); let current_revision = credentials_cache() @@ -1618,8 +2301,7 @@ fn save_credentials(root: &CredsRoot) -> Result<()> { #[cfg(target_os = "android")] { - save_android_credentials(&cleaned)?; - store_credentials_cache(&cleaned); + finish_credential_write(&cleaned, save_android_credentials(&cleaned))?; return Ok(()); } @@ -1628,8 +2310,10 @@ fn save_credentials(root: &CredsRoot) -> Result<()> { let json = serde_json::to_string(&cleaned).context("encode credentials failed")?; // Updating this stable item keeps the user's authorization attached to it. // Do not recreate entries or rewrite/delete legacy chunks on each save. - set_keyring_password(KEYRING_SINGLE_CREDENTIALS_ACCOUNT, &json)?; - store_credentials_cache(&cleaned); + finish_credential_write( + &cleaned, + set_keyring_password(KEYRING_SINGLE_CREDENTIALS_ACCOUNT, &json), + )?; remove_legacy_credentials_file_best_effort(); return Ok(()); } @@ -1637,55 +2321,17 @@ fn save_credentials(root: &CredsRoot) -> Result<()> { #[cfg(not(any(target_os = "android", target_os = "macos")))] { let json = serde_json::to_string(&cleaned).context("encode credentials failed")?; - let previous_manifest = get_keyring_password(KEYRING_CREDENTIALS_ACCOUNT) - .ok() - .flatten() - .and_then(|value| read_chunk_manifest(&value)); - let chunks = chunk_json_payload(&json); - - // Publish the chunk count only after all writes succeed. Existing chunk - // names remain stable; this format is retained for bounded platform stores. - for (index, chunk) in chunks.iter().enumerate() { - let account = chunk_account(None, index); - keyring_entry_for(&account)? - .set_password(chunk) - .with_context(|| format!("write system credential vault chunk {index}"))?; - } - - let manifest = CredsChunkManifest { - openless_credentials_storage: "chunked".to_string(), - version: 1, - generation: None, - chunks: chunks.len(), - }; - let manifest_json = - serde_json::to_string(&manifest).context("encode credential manifest failed")?; - keyring_entry()? - .set_password(&manifest_json) - .context("write system credential vault manifest")?; - - // 清理旧 chunks: - // 1) 旧 manifest 用 UUID generation → 那一代 chunks 全删(迁移到 stable name) - // 2) 旧 manifest 也是 stable name,但 chunks 数量比这次多 → 删多余的 idx - if let Some(previous) = previous_manifest { - match previous.generation.as_deref() { - Some(prev_gen) => { - for index in 0..previous.chunks { - delete_keyring_password(&chunk_account(Some(prev_gen), index)); - } - } - None => { - for index in chunks.len()..previous.chunks { - delete_keyring_password(&chunk_account(None, index)); - } - } - } - } - + let outcome = save_chunked_credentials_with( + &json, + get_keyring_password, + set_keyring_password, + |account| { + delete_keyring_password(account); + Ok(()) + }, + ); + finish_credential_write(&cleaned, outcome)?; remove_legacy_credentials_file_best_effort(); - // 写完成功后立刻刷新 process cache —— 同进程后续读不再回 Keychain。 - // 见 CREDENTIALS_CACHE 的 doc。 - store_credentials_cache(&cleaned); Ok(()) } } @@ -2175,6 +2821,296 @@ fn channel_has_secrets(root: &CredsRoot, kind: ChannelKind, id: &str) -> bool { } } +fn canonical_headers(headers: &Option>) -> Result> { + headers + .as_ref() + .map(|values| { + let ordered = values.iter().collect::>(); + serde_json::to_string(&ordered).context("encode sync provider headers") + }) + .transpose() +} + +fn sync_channel( + id: &str, + namespace: openless_core::credentials::SyncNamespace, + meta: &ChannelMeta, + name: &Option, + active: &str, +) -> openless_core::credentials::SyncChannel { + openless_core::credentials::SyncChannel { + id: id.into(), + namespace, + provider_type: meta.providerType.clone().unwrap_or_else(|| id.into()), + name: name.clone().unwrap_or_default(), + enabled: meta.enabled, + order: meta.order.unwrap_or(u32::MAX), + active: id == active, + } +} + +/// Project actual storage fields, not lookup_account's compatibility fallbacks. +/// In particular appKey=None must not become a duplicated apiKey after a restore. +fn export_sync_credentials_root( + root: &CredsRoot, +) -> Result { + use openless_core::credentials::{SyncCredentialRecord, SyncCredentials, SyncNamespace}; + let mut snapshot = SyncCredentials { + channels: Vec::new(), + credentials: Vec::new(), + }; + macro_rules! accounts { + ($entry:expr, $( $name:literal => $field:ident ),+ $(,)?) => {{ + let mut values = BTreeMap::new(); + $(if let Some(value) = &$entry.$field { values.insert($name.into(), value.clone()); })+ + values + }}; + } + for (id, entry) in &root.providers.asr { + snapshot.channels.push(sync_channel( + id, + SyncNamespace::Asr, + &entry.channel, + &entry.displayName, + &root.active.asr, + )); + snapshot.credentials.push(SyncCredentialRecord { channel_id: id.clone(), namespace: SyncNamespace::Asr, + accounts: accounts!(entry, + "asr.api_key"=>apiKey, "asr.endpoint"=>baseURL, "asr.model"=>model, + "asr.vocabulary_id"=>vocabularyId, "asr.advanced_config"=>advancedConfig, + "volcengine.app_key"=>appKey, "volcengine.access_key"=>accessKey, + "volcengine.resource_id"=>resourceId, "volcengine.service"=>volcengineService, + "volcengine.auth_mode"=>authMode, "volcengine.api_key"=>volcengineApiKey, + "xfyun.app_id"=>xfyunAppId, "xfyun.api_key"=>xfyunApiKey, + "tencent_cloud.app_id"=>tencentCloudAppId, "tencent_cloud.secret_id"=>tencentCloudSecretId, + "tencent_cloud.secret_key"=>tencentCloudSecretKey), + }); + } + for (id, entry) in &root.providers.llm { + snapshot.channels.push(sync_channel( + id, + SyncNamespace::Llm, + &entry.channel, + &entry.displayName, + &root.active.llm, + )); + let mut values = accounts!(entry, "ark.api_key"=>apiKey, "ark.endpoint"=>baseURL, "ark.model_id"=>model, + "ark.request_format"=>requestFormat, "ark.messages_thinking"=>messagesThinking, + "ark.max_tokens"=>maxTokens, "ark.thinking_budget"=>thinkingBudget); + if let Some(value) = entry.temperature { + values.insert("ark.temperature".into(), value.to_string()); + } + if let Some(value) = canonical_headers(&entry.extraHeaders)? { + values.insert("ark.extra_headers".into(), value); + } + snapshot.credentials.push(SyncCredentialRecord { + channel_id: id.clone(), + namespace: SyncNamespace::Llm, + accounts: values, + }); + } + let mut omni_ids = root.omni.providers.keys().cloned().collect::>(); + // Omni has a fixed-provider selector, which may be persisted before any key. + if !root.omni.active.is_empty() && !omni_ids.contains(&root.omni.active) { + omni_ids.push(root.omni.active.clone()); + } + omni_ids.sort(); + for (index, id) in omni_ids.iter().enumerate() { + let empty = CredsOmniEntry::default(); + let entry = root.omni.providers.get(id).unwrap_or(&empty); + let mut channel = sync_channel( + id, + SyncNamespace::Omni, + &entry.channel, + &entry.displayName, + &root.omni.active, + ); + if entry.channel.order.is_none() { + channel.order = u32::try_from(index).context("too many Omni providers")?; + } + snapshot.channels.push(channel); + let mut values = + accounts!(entry, "omni.api_key"=>apiKey, "omni.endpoint"=>baseURL, "omni.model"=>model); + if let Some(value) = entry.temperature { + values.insert("omni.temperature".into(), value.to_string()); + } + if let Some(value) = canonical_headers(&entry.extraHeaders)? { + values.insert("omni.extra_headers".into(), value); + } + snapshot.credentials.push(SyncCredentialRecord { + channel_id: id.clone(), + namespace: SyncNamespace::Omni, + accounts: values, + }); + } + snapshot + .channels + .sort_by(|a, b| (a.namespace, a.order, &a.id).cmp(&(b.namespace, b.order, &b.id))); + snapshot + .credentials + .sort_by(|a, b| (a.namespace, &a.channel_id).cmp(&(b.namespace, &b.channel_id))); + snapshot.validate().map_err(anyhow::Error::new)?; + Ok(snapshot) +} + +fn capture_sync_credentials_with( + load: impl FnOnce() -> Result, +) -> Result { + export_sync_credentials_root(&load()?) +} + +fn restored_channel_meta(channel: &openless_core::credentials::SyncChannel) -> ChannelMeta { + ChannelMeta { + providerType: Some(channel.provider_type.clone()), + order: Some(channel.order), + enabled: channel.enabled, + lastTest: None, + } +} + +fn apply_sync_credentials_root( + root: &CredsRoot, + snapshot: &openless_core::credentials::SyncCredentials, +) -> Result { + use openless_core::credentials::SyncNamespace; + snapshot.validate().map_err(anyhow::Error::new)?; + let accounts = snapshot + .credentials + .iter() + .map(|record| { + ( + (record.namespace, record.channel_id.as_str()), + &record.accounts, + ) + }) + .collect::>(); + let mut next = root.clone(); // Preserve Marketplace OAuth and all unrelated local root state. + next.version = CHANNELS_SCHEMA_VERSION; + next.providers = CredsProviders::default(); + next.omni.providers.clear(); + next.active.asr.clear(); + next.active.llm.clear(); + next.omni.active.clear(); + for channel in &snapshot.channels { + let values = accounts + .get(&(channel.namespace, channel.id.as_str())) + .context("missing sync credential channel")?; + let value = |name: &str| values.get(name).cloned(); + let name = (!channel.name.is_empty()).then(|| channel.name.clone()); + match channel.namespace { + SyncNamespace::Asr => { + next.providers.asr.insert( + channel.id.clone(), + CredsAsrEntry { + channel: restored_channel_meta(channel), + displayName: name, + apiKey: value("asr.api_key"), + baseURL: value("asr.endpoint"), + model: value("asr.model"), + vocabularyId: value("asr.vocabulary_id"), + advancedConfig: value("asr.advanced_config"), + appKey: value("volcengine.app_key"), + accessKey: value("volcengine.access_key"), + resourceId: value("volcengine.resource_id"), + volcengineService: value("volcengine.service"), + authMode: value("volcengine.auth_mode"), + volcengineApiKey: value("volcengine.api_key"), + xfyunAppId: value("xfyun.app_id"), + xfyunApiKey: value("xfyun.api_key"), + tencentCloudAppId: value("tencent_cloud.app_id"), + tencentCloudSecretId: value("tencent_cloud.secret_id"), + tencentCloudSecretKey: value("tencent_cloud.secret_key"), + }, + ); + if channel.active { + next.active.asr = channel.id.clone(); + } + } + SyncNamespace::Llm => { + let mut protocol = openless_core::llm_protocol::LlmProtocolConfig { + format: openless_core::llm_protocol::LlmRequestFormat::default_for( + &channel.provider_type, + ), + ..Default::default() + }; + for key in [ + "ark.request_format", + "ark.messages_thinking", + "ark.max_tokens", + "ark.thinking_budget", + ] { + if let Some(value) = values.get(key) { + protocol.apply(key, value)?; + } + } + let temperature = values + .get("ark.temperature") + .map(|value| parse_llm_temperature(value)) + .transpose()? + .flatten(); + let headers = values + .get("ark.extra_headers") + .map(|value| parse_extra_headers_json(value)) + .transpose()?; + protocol.validate()?; + if let Some(headers) = &headers { + protocol.validate_headers(headers)?; + } + next.providers.llm.insert( + channel.id.clone(), + CredsLlmEntry { + channel: restored_channel_meta(channel), + displayName: name, + apiKey: value("ark.api_key"), + baseURL: value("ark.endpoint"), + model: value("ark.model_id"), + temperature, + extraHeaders: headers, + requestFormat: value("ark.request_format"), + messagesThinking: value("ark.messages_thinking"), + maxTokens: value("ark.max_tokens"), + thinkingBudget: value("ark.thinking_budget"), + }, + ); + if channel.active { + next.active.llm = channel.id.clone(); + } + } + SyncNamespace::Omni => { + anyhow::ensure!( + channel.id == channel.provider_type, + "Omni provider identity cannot be remapped" + ); + let temperature = values + .get("omni.temperature") + .map(|value| parse_llm_temperature(value)) + .transpose()? + .flatten(); + let headers = values + .get("omni.extra_headers") + .map(|value| parse_extra_headers_json(value)) + .transpose()?; + next.omni.providers.insert( + channel.id.clone(), + CredsOmniEntry { + channel: restored_channel_meta(channel), + displayName: name, + apiKey: value("omni.api_key"), + baseURL: value("omni.endpoint"), + model: value("omni.model"), + temperature, + extraHeaders: headers, + }, + ); + if channel.active { + next.omni.active = channel.id.clone(); + } + } + } + } + Ok(next) +} + /// 凭据存储——系统凭据库;旧 JSON 文件只作为迁移来源。 pub struct CredentialsVault; @@ -2182,6 +3118,91 @@ impl CredentialsVault { /// 系统凭据库 service name;macOS 下对应 Keychain service。 pub const SERVICE_NAME: &'static str = "com.openless.app"; + pub(crate) fn bind_sync_gate( + gate: std::sync::Arc, + ) -> Result<()> { + let _guard = credentials_lock().lock(); + let mut installed = SYNC_WRITE_GATE.get_or_init(|| Mutex::new(None)).lock(); + // Binding is deliberately independent of OS authorization and migration. + // Status/read/export lazily inspect the native store under this barrier; + // denial must remain retryable rather than disabling sync construction. + install_sync_write_gate(&mut installed, gate) + } + + pub(crate) fn export_sync_credentials_readonly( + ) -> Result { + let _guard = credentials_lock().lock(); + // Cold capture uses the same strict read-only loader as bound getters. + // Legacy sources remain readable without migration, even before startup + // recovery. Failed OS access leaves the next capture free to retry. + anyhow::ensure!( + sync_write_gate().is_some(), + "encrypted sync credential gate is not bound" + ); + capture_sync_credentials_with(|| { + let mut root = + load_credentials_readonly_into_cache_with(load_credentials_for_recovery_readonly)?; + migrate_channels(&mut root); + Ok(root) + }) + } + + pub(crate) fn export_sync_credentials( + permit: &openless_core::credentials::ExclusivePermit, + ) -> Result { + require_sync_exclusive(permit)?; + let _guard = credentials_lock().lock(); + capture_sync_credentials_with(load_credentials_for_update) + } + + pub(crate) fn replace_sync_credentials( + snapshot: &openless_core::credentials::SyncCredentials, + permit: &openless_core::credentials::ExclusivePermit, + ) -> Result<()> { + require_sync_exclusive(permit)?; + let _guard = credentials_lock().lock(); + let current = load_credentials_for_update()?; + let replacement = apply_sync_credentials_root(¤t, snapshot)?; + // The outer restore transaction owns the exclusive generation/receipt. + save_credentials(&replacement) + } + + pub(crate) fn read_sync_secret( + account: &openless_core::credentials::SyncSecretAccount, + ) -> Result> { + let _guard = credentials_lock().lock(); + read_sync_secret_raw(account) + } + + pub(crate) fn write_sync_secret( + account: &openless_core::credentials::SyncSecretAccount, + value: &openless_core::SecretValue, + ) -> Result<()> { + validate_sync_key(value)?; + let _guard = credentials_lock().lock(); + if let Some(existing) = read_sync_secret_raw(account)? { + if existing == *value { + return Ok(()); + } + anyhow::bail!("refusing to replace an existing encrypted sync key binding"); + } + write_sync_secret_raw(account, value)?; + let stored = read_sync_secret_raw(account)? + .context("encrypted sync key write could not be verified")?; + anyhow::ensure!( + stored == *value, + "encrypted sync key write verification failed" + ); + Ok(()) + } + + pub(crate) fn remove_sync_secret( + account: &openless_core::credentials::SyncSecretAccount, + ) -> Result<()> { + let _guard = credentials_lock().lock(); + remove_sync_secret_raw(account) + } + /// Last envelope/keyring read failure, if this process has not successfully /// loaded credentials since. Distinguishes "vault unreadable" from /// "user has not configured a provider" (empty default is volcengine). @@ -2195,10 +3216,13 @@ impl CredentialsVault { } pub fn save_metadata(metadata: openless_core::CredentialMetadata) -> Result<()> { - let _guard = credentials_lock().lock(); - let mut root = load_credentials_for_update()?; - apply_credential_metadata(&mut root, metadata)?; - save_credentials(&root) + mutate_credentials( + openless_core::credentials::ChangeOrigin::User, + move |root| { + apply_credential_metadata(root, metadata)?; + Ok(true) + }, + ) } pub fn channel_has_secrets(kind: ChannelKind, id: &str) -> Result { @@ -2216,15 +3240,14 @@ impl CredentialsVault { } pub fn set(account: CredentialAccount, value: &str) -> Result<()> { - let _guard = credentials_lock().lock(); - let mut root = load_credentials_for_update()?; - let v = if value.is_empty() { - None - } else { - Some(value.to_string()) - }; - write_account(&mut root, account, v); - save_credentials(&root) + mutate_credentials(openless_core::credentials::ChangeOrigin::User, |root| { + write_account( + root, + account, + (!value.is_empty()).then(|| value.to_string()), + ); + Ok(true) + }) } pub fn get_for_asr_provider(id: &str, account: CredentialAccount) -> Result> { @@ -2235,21 +3258,24 @@ impl CredentialsVault { } pub fn set_for_asr_provider(id: &str, account: CredentialAccount, value: &str) -> Result<()> { - let _guard = credentials_lock().lock(); - let mut root = load_credentials_for_update()?; - let active = root.active.asr.clone(); - root.active.asr = id.to_string(); - let value = (!value.is_empty()).then(|| value.to_string()); - write_account(&mut root, account, value); - root.active.asr = active; - save_credentials(&root) + mutate_credentials(openless_core::credentials::ChangeOrigin::User, |root| { + let active = root.active.asr.clone(); + root.active.asr = id.to_string(); + write_account( + root, + account, + (!value.is_empty()).then(|| value.to_string()), + ); + root.active.asr = active; + Ok(true) + }) } pub fn remove(account: CredentialAccount) -> Result<()> { - let _guard = credentials_lock().lock(); - let mut root = load_credentials_for_update()?; - write_account(&mut root, account, None); - save_credentials(&root) + mutate_credentials(openless_core::credentials::ChangeOrigin::User, |root| { + write_account(root, account, None); + Ok(true) + }) } /// GitHub OAuth token for authenticated marketplace operations. @@ -2277,9 +3303,9 @@ impl CredentialsVault { } pub fn set_marketplace_github_token(value: &str) -> Result<()> { - let _guard = credentials_lock().lock(); #[cfg(target_os = "android")] { + let _guard = credentials_lock().lock(); ensure_android_marketplace_legacy_scrubbed()?; *android_marketplace_token().lock() = (!value.trim().is_empty()).then(|| MarketplaceGithubToken(value.to_string())); @@ -2288,34 +3314,30 @@ impl CredentialsVault { } else { mark_marketplace_token_verified(); } - return Ok(()); - } - #[cfg(not(target_os = "android"))] - { - let mut root = load_credentials_for_update()?; - write_marketplace_github_token(&mut root, Some(value.to_string())); - save_credentials(&root)?; - mark_marketplace_token_verified(); Ok(()) } + #[cfg(not(target_os = "android"))] + set_marketplace_token_with( + sync_write_gate(), + value, + load_credentials_for_update, + save_credentials, + ) } pub fn remove_marketplace_github_token() -> Result<()> { - let _guard = credentials_lock().lock(); - invalidate_marketplace_token_with(|| { - #[cfg(target_os = "android")] - { - // Retry the durable legacy scrub on every logout until it has - // actually completed. Process memory is already invalidated. - return ensure_android_marketplace_legacy_scrubbed(); - } - #[cfg(not(target_os = "android"))] - { - let mut root = load_credentials_for_update()?; - write_marketplace_github_token(&mut root, None); - save_credentials(&root) - } - }) + #[cfg(target_os = "android")] + { + invalidate_marketplace_token_process_local(); + let _guard = credentials_lock().lock(); + invalidate_marketplace_token_with(ensure_android_marketplace_legacy_scrubbed) + } + #[cfg(not(target_os = "android"))] + remove_marketplace_token_with( + sync_write_gate(), + load_credentials_for_update, + save_credentials, + ) } #[cfg(test)] @@ -2408,14 +3430,17 @@ impl CredentialsVault { } pub fn set_for_llm_provider(id: &str, account: CredentialAccount, value: &str) -> Result<()> { - let _guard = credentials_lock().lock(); - let mut root = load_credentials_for_update()?; - let active = root.active.llm.clone(); - root.active.llm = id.to_string(); - let value = (!value.is_empty()).then(|| value.to_string()); - write_account(&mut root, account, value); - root.active.llm = active; - save_credentials(&root) + mutate_credentials(openless_core::credentials::ChangeOrigin::User, |root| { + let active = root.active.llm.clone(); + root.active.llm = id.to_string(); + write_account( + root, + account, + (!value.is_empty()).then(|| value.to_string()), + ); + root.active.llm = active; + Ok(true) + }) } pub fn get_active_omni() -> String { @@ -2428,18 +3453,18 @@ impl CredentialsVault { } fn select_active_provider(slot: openless_core::ProviderSlot, id: &str) -> Result<()> { - let _guard = credentials_lock().lock(); - let mut root = load_credentials_for_update()?; - let mut metadata = credential_metadata(&root); - let revision = metadata.revision(); - metadata - .select_active_provider(slot, id.to_string()) - .map_err(|error| anyhow::anyhow!(error.message))?; - if metadata.revision() == revision { - return Ok(()); - } - apply_credential_metadata(&mut root, metadata)?; - save_credentials(&root) + mutate_credentials(openless_core::credentials::ChangeOrigin::User, |root| { + let mut metadata = credential_metadata(root); + let revision = metadata.revision(); + metadata + .select_active_provider(slot, id.to_string()) + .map_err(anyhow::Error::new)?; + if metadata.revision() == revision { + return Ok(false); + } + apply_credential_metadata(root, metadata)?; + Ok(true) + }) } pub fn get_for_omni_provider(id: &str, account: CredentialAccount) -> Result> { @@ -2448,15 +3473,15 @@ impl CredentialsVault { } pub fn set_for_omni_provider(id: &str, account: CredentialAccount, value: &str) -> Result<()> { - let _guard = credentials_lock().lock(); - let mut root = load_credentials_for_update()?; - write_omni_account( - &mut root, - id, - account, - (!value.is_empty()).then(|| value.to_string()), - )?; - save_credentials(&root) + mutate_credentials(openless_core::credentials::ChangeOrigin::User, |root| { + write_omni_account( + root, + id, + account, + (!value.is_empty()).then(|| value.to_string()), + )?; + Ok(true) + }) } pub fn get_active_omni_extra_headers() -> HashMap { @@ -2490,57 +3515,51 @@ impl CredentialsVault { } pub fn set_active_omni_temperature(value: &str) -> Result<()> { - let _guard = credentials_lock().lock(); let temperature = parse_llm_temperature(value)?; - let mut root = load_credentials_for_update()?; - let entry = root - .omni - .providers - .entry(root.omni.active.clone()) - .or_default(); - entry.temperature = temperature; - save_credentials(&root) + mutate_credentials(openless_core::credentials::ChangeOrigin::User, |root| { + root.omni + .providers + .entry(root.omni.active.clone()) + .or_default() + .temperature = temperature; + Ok(true) + }) } pub fn set_omni_temperature_for_provider(id: &str, value: &str) -> Result<()> { - let _guard = credentials_lock().lock(); let temperature = parse_llm_temperature(value)?; - let mut root = load_credentials_for_update()?; - root.omni - .providers - .entry(id.to_string()) - .or_default() - .temperature = temperature; - save_credentials(&root) + mutate_credentials(openless_core::credentials::ChangeOrigin::User, |root| { + root.omni + .providers + .entry(id.to_string()) + .or_default() + .temperature = temperature; + Ok(true) + }) } pub fn set_active_omni_extra_headers_json(value: &str) -> Result<()> { - let _guard = credentials_lock().lock(); let headers = parse_extra_headers_json(value)?; - let mut root = load_credentials_for_update()?; - let entry = root - .omni - .providers - .entry(root.omni.active.clone()) - .or_default(); - entry.extraHeaders = if headers.is_empty() { - None - } else { - Some(headers) - }; - save_credentials(&root) + mutate_credentials(openless_core::credentials::ChangeOrigin::User, |root| { + root.omni + .providers + .entry(root.omni.active.clone()) + .or_default() + .extraHeaders = (!headers.is_empty()).then_some(headers); + Ok(true) + }) } pub fn set_omni_extra_headers_json_for_provider(id: &str, value: &str) -> Result<()> { - let _guard = credentials_lock().lock(); let headers = parse_extra_headers_json(value)?; - let mut root = load_credentials_for_update()?; - root.omni - .providers - .entry(id.to_string()) - .or_default() - .extraHeaders = (!headers.is_empty()).then_some(headers); - save_credentials(&root) + mutate_credentials(openless_core::credentials::ChangeOrigin::User, |root| { + root.omni + .providers + .entry(id.to_string()) + .or_default() + .extraHeaders = (!headers.is_empty()).then_some(headers); + Ok(true) + }) } pub fn get_active_llm_extra_headers() -> HashMap { @@ -2564,16 +3583,15 @@ impl CredentialsVault { } pub fn set_active_llm_temperature(value: &str) -> Result<()> { - let _guard = credentials_lock().lock(); let temperature = parse_llm_temperature(value)?; - let mut root = load_credentials_for_update()?; - let entry = root - .providers - .llm - .entry(root.active.llm.clone()) - .or_default(); - entry.temperature = temperature; - save_credentials(&root) + mutate_credentials(openless_core::credentials::ChangeOrigin::User, |root| { + root.providers + .llm + .entry(root.active.llm.clone()) + .or_default() + .temperature = temperature; + Ok(true) + }) } pub fn get_llm_protocol_option(id: Option<&str>, account: &str) -> Result> { @@ -2587,51 +3605,46 @@ impl CredentialsVault { } pub fn set_llm_protocol_option(id: Option<&str>, account: &str, value: &str) -> Result<()> { - let _guard = credentials_lock().lock(); let mut config = openless_core::llm_protocol::LlmProtocolConfig::default(); config.apply(account, value)?; - let mut root = load_credentials_for_update()?; - let id = id.unwrap_or(&root.active.llm).to_string(); - let entry = root.providers.llm.entry(id).or_default(); - *entry.protocol_option(account)? = - (!value.trim().is_empty()).then(|| value.trim().to_string()); - entry.channel.lastTest = None; - save_credentials(&root) + mutate_credentials(openless_core::credentials::ChangeOrigin::User, |root| { + let id = id.unwrap_or(&root.active.llm).to_string(); + let entry = root.providers.llm.entry(id).or_default(); + *entry.protocol_option(account)? = + (!value.trim().is_empty()).then(|| value.trim().to_string()); + entry.channel.lastTest = None; + Ok(true) + }) } /// 写入指定 LLM 渠道的采样温度,不改变 active 渠道。 pub fn set_llm_temperature_for_provider(id: &str, value: &str) -> Result<()> { - let _guard = credentials_lock().lock(); let temperature = parse_llm_temperature(value)?; - let mut root = load_credentials_for_update()?; - set_llm_temperature_for_provider_in_root(&mut root, id, temperature); - save_credentials(&root) + mutate_credentials(openless_core::credentials::ChangeOrigin::User, |root| { + set_llm_temperature_for_provider_in_root(root, id, temperature); + Ok(true) + }) } pub fn set_active_llm_extra_headers_json(value: &str) -> Result<()> { - let _guard = credentials_lock().lock(); let headers = parse_extra_headers_json(value)?; - let mut root = load_credentials_for_update()?; - let entry = root - .providers - .llm - .entry(root.active.llm.clone()) - .or_default(); - entry.extraHeaders = if headers.is_empty() { - None - } else { - Some(headers) - }; - save_credentials(&root) + mutate_credentials(openless_core::credentials::ChangeOrigin::User, |root| { + root.providers + .llm + .entry(root.active.llm.clone()) + .or_default() + .extraHeaders = (!headers.is_empty()).then_some(headers); + Ok(true) + }) } /// 写入指定 LLM 渠道的额外请求头,不改变 active 渠道。 pub fn set_llm_extra_headers_json_for_provider(id: &str, value: &str) -> Result<()> { - let _guard = credentials_lock().lock(); let headers = parse_extra_headers_json(value)?; - let mut root = load_credentials_for_update()?; - set_llm_extra_headers_for_provider_in_root(&mut root, id, headers); - save_credentials(&root) + mutate_credentials(openless_core::credentials::ChangeOrigin::User, |root| { + set_llm_extra_headers_for_provider_in_root(root, id, headers); + Ok(true) + }) } pub fn snapshot() -> CredentialsSnapshot { @@ -3368,7 +4381,6 @@ mod tests { #[test] fn android_startup_failure_does_not_cache_default_or_suppress_retry() { - use anyhow::Context; reset_credentials_cache_for_tests(); let first = load_credentials_into_cache_with(|| { Err(anyhow!("injected startup scrub failure") @@ -3404,9 +4416,20 @@ mod tests { let _ = std::fs::remove_dir_all(dir); } + #[test] + fn malformed_payload_errors_never_echo_private_field_values() { + reset_credentials_cache_for_tests(); + let error = serde_json::from_str::(r#"{"version":"private-canary-secret"}"#) + .unwrap_err(); + super::record_vault_read_failure(&anyhow::Error::new(error)); + let recorded = CredentialsVault::last_read_error().unwrap(); + assert_eq!(recorded, "credential payload could not be decoded"); + assert!(!recorded.contains("private-canary-secret")); + super::clear_vault_read_error(); + } + #[test] fn android_for_update_path_retries_and_does_not_cache_on_envelope_error() { - use anyhow::Context; reset_credentials_cache_for_tests(); let err = android_credentials_root_for_update(|| { Err(anyhow!("temporarily unavailable") @@ -3815,3 +4838,766 @@ mod tests { ); } } + +#[cfg(test)] +mod encrypted_sync_tests { + use super::*; + use openless_core::credentials::{ + ChangeOrigin, SyncNamespace, SyncSecretAccount, SyncWriteGate, + }; + use std::cell::{Cell, RefCell}; + + struct Temporary(PathBuf); + impl Temporary { + fn new() -> Self { + let path = + std::env::temp_dir().join(format!("openless-vault-sync-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&path).unwrap(); + Self(path) + } + } + impl Drop for Temporary { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + fn root_with_secret(value: &str) -> CredsRoot { + let mut root = CredsRoot::default(); + root.version = 2; + root.active.asr = "stable-channel".into(); + root.providers.asr.insert( + "stable-channel".into(), + CredsAsrEntry { + channel: ChannelMeta { + providerType: Some("openai-compatible".into()), + order: Some(0), + enabled: true, + lastTest: None, + }, + apiKey: Some(value.into()), + ..Default::default() + }, + ); + root + } + fn installed_chunks(json: &str, generation: Option<&str>) -> HashMap { + let chunks = chunk_json_payload(json); + let manifest = CredsChunkManifest { + openless_credentials_storage: "chunked".into(), + version: 1, + generation: generation.map(str::to_string), + chunks: chunks.len(), + }; + let mut values = HashMap::from([( + KEYRING_CREDENTIALS_ACCOUNT.into(), + serde_json::to_string(&manifest).unwrap(), + )]); + for (i, part) in chunks.into_iter().enumerate() { + values.insert(chunk_account(generation, i), part); + } + values + } + fn read_root(values: &RefCell>) -> CredsRoot { + load_keyring_credentials_with( + |key| Ok(values.borrow().get(key).cloned()), + |_, _| panic!("reader must not write"), + false, + ) + .unwrap() + .unwrap() + } + + #[test] + fn sync_gate_binding_is_lazy_and_preserves_pending_recovery() { + let temp = Temporary::new(); + let path = temp.0.join("generation.json"); + let gate = SyncWriteGate::open(path.clone()).unwrap(); + drop(gate.begin_mutation().unwrap()); + let before = std::fs::read(&path).unwrap(); + let mut installed = None; + install_sync_write_gate(&mut installed, gate.clone()).unwrap(); + install_sync_write_gate(&mut installed, gate.clone()).unwrap(); + assert!(std::sync::Arc::ptr_eq(installed.as_ref().unwrap(), &gate)); + assert!(gate.recovery_required().unwrap()); + assert_eq!(std::fs::read(&path).unwrap(), before); + let other = SyncWriteGate::open(temp.0.join("other.json")).unwrap(); + assert!(install_sync_write_gate(&mut installed, other).is_err()); + assert!(std::sync::Arc::ptr_eq(installed.as_ref().unwrap(), &gate)); + } + + #[test] + fn readonly_warm_cache_retries_denial_and_only_real_read_clears_failure() { + reset_credentials_cache_for_tests(); + let failure = load_credentials_readonly_into_cache_with(|| { + Err(anyhow::anyhow!("fixture native access denied")) + }) + .unwrap_err(); + assert!(failure.to_string().contains("fixture native access denied")); + assert!(credentials_cache().lock().is_none()); + let recorded = CredentialsVault::last_read_error().unwrap(); + + // A cache hit itself is not a successful OS read. Seed only the cache + // slot, without the successful-load/save helper's error-latch update. + *credentials_cache().lock() = Some(root_with_secret("fixture-cached")); + let cached = + load_credentials_readonly_into_cache_with(|| panic!("must use cache")).unwrap(); + assert_eq!( + cached.providers.asr["stable-channel"].apiKey.as_deref(), + Some("fixture-cached") + ); + assert_eq!( + CredentialsVault::last_read_error().as_deref(), + Some(recorded.as_str()) + ); + + *credentials_cache().lock() = None; + let loaded = load_credentials_readonly_into_cache_with(|| { + Ok(root_with_secret("fixture-retry-succeeded")) + }) + .unwrap(); + assert_eq!( + loaded.providers.asr["stable-channel"].apiKey.as_deref(), + Some("fixture-retry-succeeded") + ); + assert!(credentials_cache().lock().is_some()); + assert!(CredentialsVault::last_read_error().is_none()); + reset_credentials_cache_for_tests(); + } + + #[test] + fn bound_cold_read_preserves_legacy_file_until_a_real_save() { + let temp = Temporary::new(); + let path = temp.0.join("legacy.json"); + let mut legacy = root_with_secret("fixture-legacy-provider"); + legacy.version = 1; + legacy.marketplace.githubAccessToken = + Some(MarketplaceGithubToken("fixture-local-oauth".into())); + let original = serde_json::to_vec(&legacy).unwrap(); + std::fs::write(&path, &original).unwrap(); + let loaded = load_credentials_for_sync_binding_with( + true, + || { + load_desktop_credentials_readonly_with( + |_| Ok(None), + || decode_single_credentials(&std::fs::read_to_string(&path)?).map(Some), + true, + ) + }, + || panic!("bound read must not call the migration loader"), + ) + .unwrap(); + assert_eq!( + loaded.providers.asr["stable-channel"].apiKey.as_deref(), + Some("fixture-legacy-provider") + ); + assert_eq!( + lookup_marketplace_github_token(&loaded).as_deref(), + Some("fixture-local-oauth") + ); + assert_eq!(std::fs::read(&path).unwrap(), original); + } + + #[test] + fn recovery_binding_uses_only_readers_and_preserves_the_pending_gate() { + let temp = Temporary::new(); + let path = temp.0.join("generation.json"); + let gate = SyncWriteGate::open(path.clone()).unwrap(); + drop(gate.begin_mutation().unwrap()); + let before = std::fs::read(&path).unwrap(); + let json = serde_json::to_string(&root_with_secret("fixture-source")).unwrap(); + let chunks = installed_chunks(&json, Some("prior-generation")); + let root = load_credentials_for_sync_binding_with( + gate.recovery_required().unwrap(), + || { + load_desktop_credentials_readonly_with( + |account| Ok(chunks.get(account).cloned()), + || panic!("complete chunks must not probe a legacy file"), + true, + ) + }, + || panic!("pending startup must not call the migration loader"), + ) + .unwrap(); + assert_eq!( + root.providers.asr["stable-channel"].apiKey.as_deref(), + Some("fixture-source") + ); + assert_eq!(std::fs::read(&path).unwrap(), before); + let error = load_credentials_for_sync_binding_with( + gate.recovery_required().unwrap(), + || { + load_desktop_credentials_readonly_with( + |_| Err(anyhow::anyhow!("fixture native access denied")), + || panic!("native errors must not fall back"), + true, + ) + }, + || panic!("must not migrate after denial"), + ); + assert!(error.is_err()); + assert!(gate.recovery_required().unwrap()); + assert_eq!(std::fs::read(&path).unwrap(), before); + let single = load_desktop_credentials_readonly_with( + |account| { + assert_eq!(account, KEYRING_SINGLE_CREDENTIALS_ACCOUNT); + Ok(Some(json.clone())) + }, + || panic!("v2 has priority"), + true, + ) + .unwrap(); + assert_eq!( + single.providers.asr["stable-channel"].apiKey.as_deref(), + Some("fixture-source") + ); + assert!(load_desktop_credentials_readonly_with( + |_| Ok(Some("{}".into())), + || panic!("corrupt source must not fall back"), + true + ) + .is_err()); + assert!(load_desktop_credentials_readonly_with( + |_| Ok(None), + || Err(anyhow::anyhow!("unreadable legacy source")), + false + ) + .is_err()); + let legacy = load_desktop_credentials_readonly_with( + |account| Ok((account == "asr.api_key").then(|| "legacy-source".into())), + || Ok(None), + false, + ) + .unwrap(); + assert_eq!( + lookup_account(&legacy, CredentialAccount::AsrApiKey).as_deref(), + Some("legacy-source") + ); + let empty = + load_desktop_credentials_readonly_with(|_| Ok(None), || Ok(None), false).unwrap(); + assert!(empty.providers.asr.is_empty()); + } + + #[cfg(not(windows))] + #[test] + fn recovery_android_vault_read_is_strict_and_does_not_scrub_or_migrate() { + use base64::Engine; + let temp = Temporary::new(); + let path = temp.0.join("credentials.enc.json"); + let mut root = root_with_secret("private-provider"); + write_marketplace_github_token(&mut root, Some("legacy-oauth".into())); + let bytes = + base64::engine::general_purpose::STANDARD.encode(serde_json::to_vec(&root).unwrap()); + std::fs::write(&path, &bytes).unwrap(); + let mut crypto = super::super::android_credentials::TestCrypto::default(); + let loaded = load_android_credentials_readonly_at(&path, &mut crypto) + .unwrap() + .unwrap(); + assert_eq!( + loaded.providers.asr["stable-channel"].apiKey.as_deref(), + Some("private-provider") + ); + assert!(lookup_marketplace_github_token(&loaded).is_none()); + assert_eq!(std::fs::read(&path).unwrap(), bytes.as_bytes()); + assert_eq!(crypto.delete_key_calls, 0); + std::fs::write( + &path, + base64::engine::general_purpose::STANDARD.encode(b"{}"), + ) + .unwrap(); + assert!(load_android_credentials_readonly_at(&path, &mut crypto).is_err()); + assert!(path.exists()); + } + + #[test] + fn generation_writer_preserves_old_data_at_every_precommit_failure() { + let old = serde_json::to_string(&root_with_secret(&"old".repeat(1500))).unwrap(); + let new = serde_json::to_string(&root_with_secret(&"new".repeat(1500))).unwrap(); + let initial = installed_chunks(&old, None); + for failed_piece in 0..chunk_json_payload(&new).len() { + let values = RefCell::new(initial.clone()); + let writes = Cell::new(0); + let result = save_chunked_credentials_with( + &new, + |key| Ok(values.borrow().get(key).cloned()), + |key, value| { + if key != KEYRING_CREDENTIALS_ACCOUNT { + let n = writes.get(); + writes.set(n + 1); + if n == failed_piece { + anyhow::bail!("fixture failure"); + } + } + values.borrow_mut().insert(key.into(), value.into()); + Ok(()) + }, + |key| { + values.borrow_mut().remove(key); + Ok(()) + }, + ); + assert!(result.is_err()); + assert_eq!( + read_root(&values).providers.asr["stable-channel"].apiKey, + root_with_secret(&"old".repeat(1500)).providers.asr["stable-channel"].apiKey + ); + assert_eq!( + values.borrow().get(KEYRING_CREDENTIALS_ACCOUNT), + initial.get(KEYRING_CREDENTIALS_ACCOUNT) + ); + } + } + + #[test] + fn generation_writer_verifies_chunks_and_head_without_claiming_fake_success() { + let old = serde_json::to_string(&root_with_secret("old")).unwrap(); + let new = serde_json::to_string(&root_with_secret(&"new".repeat(1000))).unwrap(); + for mode in [ + "head_read", + "chunk_read", + "corrupt_chunk", + "head_noop", + "head_error", + "head_confirmation", + ] { + let values = RefCell::new(installed_chunks(&old, Some("old-generation"))); + let switched = Cell::new(false); + let result = save_chunked_credentials_with( + &new, + |key| { + if mode == "head_read" && key == KEYRING_CREDENTIALS_ACCOUNT { + anyhow::bail!("fixture"); + } + if mode == "head_confirmation" + && key == KEYRING_CREDENTIALS_ACCOUNT + && switched.get() + { + anyhow::bail!("fixture"); + } + if key.starts_with(KEYRING_CREDENTIALS_CHUNK_PREFIX) + && !key.contains("old-generation") + { + if mode == "chunk_read" { + anyhow::bail!("fixture"); + } + if mode == "corrupt_chunk" { + return Ok(Some("corrupt".into())); + } + } + Ok(values.borrow().get(key).cloned()) + }, + |key, value| { + if key == KEYRING_CREDENTIALS_ACCOUNT { + if mode == "head_error" { + anyhow::bail!("fixture"); + } + if mode == "head_noop" { + return Ok(()); + } + switched.set(true); + } + values.borrow_mut().insert(key.into(), value.into()); + Ok(()) + }, + |key| { + values.borrow_mut().remove(key); + Ok(()) + }, + ); + assert!(result.is_err(), "{mode}"); + let expected = if mode == "head_confirmation" { + "new".repeat(1000) + } else { + "old".into() + }; + assert_eq!( + read_root(&values).providers.asr["stable-channel"] + .apiKey + .as_deref(), + Some(expected.as_str()) + ); + } + } + + #[test] + fn generation_commit_survives_lost_ack_and_cleanup_failure_and_migrates_legacy() { + let old = serde_json::to_string(&root_with_secret("old")).unwrap(); + let new = serde_json::to_string(&root_with_secret("new")).unwrap(); + for initial in [ + installed_chunks(&old, None), + installed_chunks(&old, Some("previous-generation")), + HashMap::from([(KEYRING_CREDENTIALS_ACCOUNT.into(), old.clone())]), + ] { + let values = RefCell::new(initial); + save_chunked_credentials_with( + &new, + |key| Ok(values.borrow().get(key).cloned()), + |key, value| { + values.borrow_mut().insert(key.into(), value.into()); + if key == KEYRING_CREDENTIALS_ACCOUNT { + anyhow::bail!("lost native ack"); + } + Ok(()) + }, + |_| anyhow::bail!("cleanup denied"), + ) + .unwrap(); + assert_eq!( + read_root(&values).providers.asr["stable-channel"] + .apiKey + .as_deref(), + Some("new") + ); + let head = values.borrow()[KEYRING_CREDENTIALS_ACCOUNT].clone(); + assert!(read_chunk_manifest(&head).unwrap().generation.is_some()); + } + } + + #[test] + fn full_provider_capture_roundtrips_fixed_ids_and_keeps_local_oauth() { + let mut root = root_with_secret("asr-primary"); + let asr = root.providers.asr.get_mut("stable-channel").unwrap(); + asr.appKey = None; + asr.accessKey = Some("legacy-access".into()); + asr.resourceId = Some("resource".into()); + asr.volcengineApiKey = Some("new-service-key".into()); + asr.vocabularyId = Some("vocab".into()); + asr.advancedConfig = Some("{\"verboseJson\":true}".into()); + asr.xfyunAppId = Some("xfyun-id".into()); + asr.xfyunApiKey = Some("xfyun-secret".into()); + asr.tencentCloudAppId = Some("tencent-id".into()); + asr.tencentCloudSecretId = Some("tencent-secret-id".into()); + asr.tencentCloudSecretKey = Some("tencent-secret".into()); + asr.channel.lastTest = Some(ChannelTest { + ok: true, + latencyMs: Some(1), + at: 5, + error: None, + }); + root.active.llm = "stable-llm".into(); + root.providers.llm.insert( + "stable-llm".into(), + CredsLlmEntry { + channel: ChannelMeta { + providerType: Some("custom".into()), + order: Some(5), + enabled: true, + lastTest: asr.channel.lastTest.clone(), + }, + displayName: Some("Named provider".into()), + apiKey: Some("llm-secret".into()), + baseURL: Some("https://api.example/v1".into()), + model: Some("model".into()), + temperature: Some(0.7), + extraHeaders: Some(HashMap::from([( + "x-provider-key".into(), + "header-secret".into(), + )])), + requestFormat: Some("chat_completions".into()), + messagesThinking: Some("adaptive".into()), + maxTokens: Some("4096".into()), + thinkingBudget: None, + }, + ); + root.omni.active = "custom".into(); + for id in ["custom", "qwen3-omni", "volcengine-omni"] { + root.omni.providers.insert( + id.into(), + CredsOmniEntry { + apiKey: Some(format!("{id}-secret")), + baseURL: Some("https://omni.example".into()), + model: Some("omni-model".into()), + temperature: Some(0.8), + extraHeaders: Some(HashMap::from([( + "x-extra".into(), + "omni-extra-secret".into(), + )])), + ..Default::default() + }, + ); + } + write_marketplace_github_token(&mut root, Some("local-only-oauth".into())); + let count = Cell::new(0); + let capture = capture_sync_credentials_with(|| { + count.set(count.get() + 1); + Ok(root.clone()) + }) + .unwrap(); + assert_eq!(count.get(), 1); + assert!(!format!("{capture:?}").contains("secret")); + assert!(!capture.credentials.iter().any(|record| record + .accounts + .values() + .any(|value| value == "local-only-oauth"))); + assert!(!capture + .credentials + .iter() + .find(|record| record.namespace == SyncNamespace::Asr) + .unwrap() + .accounts + .contains_key("volcengine.app_key")); + assert_eq!( + capture + .channels + .iter() + .filter(|c| c.namespace == SyncNamespace::Omni) + .count(), + 3 + ); + let restored = apply_sync_credentials_root(&root, &capture).unwrap(); + assert_eq!(export_sync_credentials_root(&restored).unwrap(), capture); + assert_eq!( + lookup_marketplace_github_token(&restored).as_deref(), + Some("local-only-oauth") + ); + assert!(restored + .providers + .asr + .values() + .all(|entry| entry.channel.lastTest.is_none())); + assert!(restored + .providers + .llm + .values() + .all(|entry| entry.channel.lastTest.is_none())); + let mut excluded = capture.clone(); + excluded.credentials[0] + .accounts + .insert("github.oauth_token".into(), "not-allowed".into()); + assert!(apply_sync_credentials_root(&root, &excluded).is_err()); + let mut invalid_protocol = capture.clone(); + let accounts = &mut invalid_protocol + .credentials + .iter_mut() + .find(|record| record.namespace == SyncNamespace::Llm) + .unwrap() + .accounts; + accounts.insert("ark.request_format".into(), "messages".into()); + accounts.insert("ark.messages_thinking".into(), "budget".into()); + accounts.insert("ark.thinking_budget".into(), "4096".into()); + assert!(apply_sync_credentials_root(&root, &invalid_protocol).is_err()); + assert!( + capture_sync_credentials_with(|| Err(anyhow::anyhow!("unreadable fixture vault"))) + .is_err() + ); + } + + #[test] + fn mutation_lease_precedes_read_and_lives_until_actual_save_finishes() { + let temp = Temporary::new(); + let gate = SyncWriteGate::open(temp.0.join("generation.json")).unwrap(); + mutate_credentials_with( + Some(gate.clone()), + ChangeOrigin::User, + || { + assert!(gate.try_exclusive().is_err()); + Ok(root_with_secret("old")) + }, + |root| { + root.active.asr = "changed".into(); + Ok(true) + }, + |_| { + assert!(gate.try_exclusive().is_err()); + Ok(()) + }, + ) + .unwrap(); + assert_eq!(gate.generation().unwrap().get(), 1); + let exclusive = gate.try_exclusive().unwrap(); + assert!(mutate_credentials_with( + Some(gate.clone()), + ChangeOrigin::User, + || panic!("must reject before read"), + |_| Ok(true), + |_| Ok(()) + ) + .is_err()); + drop(exclusive); + assert!(mutate_credentials_with( + Some(gate.clone()), + ChangeOrigin::User, + || Err(anyhow::anyhow!("read failed")), + |_| Ok(true), + |_| Ok(()) + ) + .is_err()); + assert!(!gate.recovery_required().unwrap()); + mutate_credentials_with( + Some(gate.clone()), + ChangeOrigin::LocalOnly, + || Ok(root_with_secret("old")), + |_| Ok(true), + |_| Ok(()), + ) + .unwrap(); + assert_eq!(gate.generation().unwrap().get(), 1); + assert!(mutate_credentials_with( + Some(gate.clone()), + ChangeOrigin::User, + || Ok(root_with_secret("old")), + |_| Ok(true), + |_| Err(VaultCommitFailure::Unknown.into()) + ) + .is_err()); + assert!(gate.recovery_required().unwrap()); + } + + #[test] + fn unknown_publication_drops_cache_before_reconciliation() { + let old = root_with_secret("old"); + let new = root_with_secret("new"); + store_credentials_cache(&old); + assert!(finish_credential_write(&new, Err(VaultCommitFailure::Unchanged.into())).is_err()); + assert_eq!( + credentials_cache().lock().as_ref().unwrap().providers.asr["stable-channel"] + .apiKey + .as_deref(), + Some("old") + ); + assert!(finish_credential_write(&new, Err(VaultCommitFailure::Unknown.into())).is_err()); + assert!(credentials_cache().lock().is_none()); + let count = Cell::new(0); + let loaded = load_credentials_into_cache_with(|| { + count.set(count.get() + 1); + Ok(Some(new)) + }); + assert_eq!(count.get(), 1); + assert_eq!( + loaded.providers.asr["stable-channel"].apiKey.as_deref(), + Some("new") + ); + reset_credentials_cache_for_tests(); + } + + #[test] + fn failed_revocation_after_concurrent_login_keeps_oauth_unusable() { + use std::sync::{mpsc, Arc}; + use std::time::{Duration, Instant}; + let root = Arc::new(Mutex::new(root_with_secret("provider-only"))); + store_credentials_cache(&root.lock()); + mark_marketplace_token_verified(); + let (started_tx, started_rx) = mpsc::channel(); + let (release_tx, release_rx) = mpsc::channel(); + let login_root = root.clone(); + let login = std::thread::spawn(move || { + set_marketplace_token_with( + None, + "new-oauth", + || Ok(login_root.lock().clone()), + |next| { + started_tx.send(()).unwrap(); + release_rx.recv_timeout(Duration::from_secs(5)).unwrap(); + *login_root.lock() = next.clone(); + store_credentials_cache(next); + Ok(()) + }, + ) + }); + started_rx.recv_timeout(Duration::from_secs(5)).unwrap(); + let revoke = std::thread::spawn(move || { + remove_marketplace_token_with( + None, + || Ok(root.lock().clone()), + |_| Err(VaultCommitFailure::Unchanged.into()), + ) + }); + let start = Instant::now(); + while !marketplace_token_is_rejected() && start.elapsed() < Duration::from_secs(5) { + std::thread::yield_now(); + } + let saw_revocation = marketplace_token_is_rejected(); + release_tx.send(()).unwrap(); + login.join().unwrap().unwrap(); + assert!(revoke.join().unwrap().is_err()); + assert!(saw_revocation); + assert!(marketplace_token_is_rejected()); + assert!(credentials_cache() + .lock() + .as_ref() + .and_then(lookup_marketplace_github_token) + .is_none()); + reset_credentials_cache_for_tests(); + mark_marketplace_token_verified(); + } + + #[cfg(not(windows))] + #[test] + fn sync_key_write_does_not_complete_or_delete_main_vault_migration() { + use super::super::android_credentials::{AndroidCredentialsCrypto, ReadOutcome}; + use base64::Engine; + let temp = Temporary::new(); + let main_path = temp.0.join("credentials.enc.json"); + let legacy = serde_json::to_vec(&root_with_secret("legacy-provider-secret")).unwrap(); + std::fs::write( + &main_path, + base64::engine::general_purpose::STANDARD.encode(&legacy), + ) + .unwrap(); + let account = + SyncSecretAccount::new(format!("cloud-sync.e2ee.key.{}", "c".repeat(64))).unwrap(); + let key = openless_core::SecretValue::new( + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([8; 32]), + ); + let mut wrapper = SyncSecretCrypto { + inner: super::super::android_credentials::TestCrypto::default(), + account: account.as_str().into(), + }; + write_android_sync_key_with_crypto( + &temp.0.join("sync-key.json"), + &account, + &key, + &mut wrapper, + ) + .unwrap(); + assert!(!wrapper.inner.migration_complete().unwrap()); + assert!(wrapper.migration_complete().unwrap()); + wrapper.delete_key().unwrap(); + assert_eq!(wrapper.inner.delete_key_calls, 0); + assert!(matches!( + super::super::android_credentials::read(&main_path, &mut wrapper.inner).unwrap(), + ReadOutcome::Legacy(_) + )); + let migrated = + load_android_credentials_from_path_with_crypto(&main_path, &mut wrapper.inner) + .unwrap() + .unwrap(); + assert_eq!( + migrated.providers.asr["stable-channel"].apiKey.as_deref(), + Some("legacy-provider-secret") + ); + assert!(wrapper.inner.migration_complete().unwrap()); + } + + #[cfg(not(windows))] + #[test] + fn android_sync_key_envelope_is_account_bound_and_never_plaintext() { + use base64::Engine; + let temp = Temporary::new(); + let path = temp.0.join("sync-key.json"); + let account = + SyncSecretAccount::new(format!("cloud-sync.e2ee.local.{}", "a".repeat(64))).unwrap(); + let other = + SyncSecretAccount::new(format!("cloud-sync.e2ee.local.{}", "b".repeat(64))).unwrap(); + let key = openless_core::SecretValue::new( + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([7; 32]), + ); + let mut crypto = SyncSecretCrypto { + inner: super::super::android_credentials::TestCrypto::default(), + account: account.as_str().into(), + }; + write_android_sync_key_with_crypto(&path, &account, &key, &mut crypto).unwrap(); + let bytes = std::fs::read(&path).unwrap(); + assert!(!String::from_utf8_lossy(&bytes).contains(key.expose_secret())); + assert_eq!( + read_android_sync_key_with_crypto(&path, &account, &mut crypto).unwrap(), + Some(key.clone()) + ); + crypto.account = other.as_str().into(); + assert!(read_android_sync_key_with_crypto(&path, &other, &mut crypto).is_err()); + assert!(path.exists()); + assert!(super::super::android_credentials::read(&path, &mut crypto.inner).is_err()); + assert!(path.exists()); + assert!(validate_sync_key(&openless_core::SecretValue::new("not-a-key")).is_err()); + } +} diff --git a/openless-all/app/src-tauri/src/selection.rs b/openless-all/app/src-tauri/src/selection.rs index d8106db7..a41503e2 100644 --- a/openless-all/app/src-tauri/src/selection.rs +++ b/openless-all/app/src-tauri/src/selection.rs @@ -506,6 +506,12 @@ pub(crate) fn reactivate_selection_insertion_target(target: &SelectionInsertionT let Some(pid) = captured.front_app_pid else { return false; }; + // Streaming writes usually already own the foreground application. + // Do not reactivate it and sleep for 80ms on every delta; the caller + // still validates the captured text control before posting keys. + if current_front_app_pid() == Some(pid) { + return true; + } // 预览窗是 OpenLess 自己的窗口,确认后需要把焦点交还原应用再粘贴。 // NSRunningApplication activate 是 best-effort,且部分 app(Electron、 // 自绘窗口)恢复 key window 需要 >120ms——固定 sleep 一次就核 pid 会 diff --git a/openless-all/app/src-tauri/src/tauri_events.rs b/openless-all/app/src-tauri/src/tauri_events.rs index 6bb39ff8..ad87e1cb 100644 --- a/openless-all/app/src-tauri/src/tauri_events.rs +++ b/openless-all/app/src-tauri/src/tauri_events.rs @@ -60,7 +60,26 @@ pub fn start(app: AppHandle, backend: Arc) { log::error!("[core-events] backend start failed: {error}"); return; } + if backend.ensure_runtime_ready().is_err() { + return; + } let preferences = backend.get_preferences(); + if !preferences.active_asr_provider.is_empty() { + if let Err(error) = crate::commands::sync_active_asr_provider_to_vault( + &preferences.active_asr_provider, + ) { + log::warn!("[startup] active ASR provider mirror failed: {error}"); + } + } + #[cfg(target_os = "windows")] + { + let target = openless_core::WindowsKeyboardRuntimeTarget::from(&preferences); + if let Err(error) = crate::windows_ime_profile::apply_windows_openless_keyboard_list( + target.openless_language_profile_enabled, + ) { + log::warn!("[windows-ime] startup keyboard visibility failed: {error}"); + } + } if let Err(error) = backend .services() .remote_input @@ -136,6 +155,16 @@ async fn forward_legacy_event( } match kind { BackendEventKind::PreferencesChanged(_) => emit_preferences(app, backend), + BackendEventKind::CloudSyncStateChanged(event) => { + let _ = app.emit_to("main", "cloud-sync-e2ee:state", event); + } + BackendEventKind::CloudSyncConflictDetected(event) => { + let _ = app.emit_to("main", "cloud-sync-e2ee:conflict", event); + } + BackendEventKind::CloudSyncRestoreCompleted(event) => { + emit_preferences(app, backend); + let _ = app.emit_to("main", "cloud-sync-e2ee:restored", event); + } BackendEventKind::CredentialsChanged(status) => { let _ = app.emit("credentials:changed", status); } @@ -304,7 +333,7 @@ async fn forward_legacy_event( let _ = app.emit_to( crate::coordinator::qa_event_target(), "qa:level", - serde_json::json!({ "level": level.level }), + serde_json::json!({ "sessionId": level.session_id, "level": level.level }), ); } BackendEventKind::QaState(state) => { diff --git a/openless-all/app/src-tauri/tauri.macos-mlx.conf.json b/openless-all/app/src-tauri/tauri.macos-mlx.conf.json index 4dcbd2b5..d1fc4359 100644 --- a/openless-all/app/src-tauri/tauri.macos-mlx.conf.json +++ b/openless-all/app/src-tauri/tauri.macos-mlx.conf.json @@ -5,6 +5,13 @@ }, "bundle": { "macOS": { + "dmg": { + "background": "dmg/installer-background@2x.png", + "windowPosition": { "x": 120, "y": 120 }, + "windowSize": { "width": 768, "height": 512 }, + "appPosition": { "x": 216, "y": 253 }, + "applicationFolderPosition": { "x": 552, "y": 253 } + }, "files": { "Resources/mlx.metallib": "target/release/openless-mlx/mlx.metallib" } diff --git a/openless-all/app/src/components/AutoUpdate.tsx b/openless-all/app/src/components/AutoUpdate.tsx index 907d8e96..674a012e 100644 --- a/openless-all/app/src/components/AutoUpdate.tsx +++ b/openless-all/app/src/components/AutoUpdate.tsx @@ -340,7 +340,7 @@ export function UpdateDialog({ style={{ position: 'fixed', inset: 0, - background: 'rgba(0,0,0,0.22)', + background: 'var(--ol-dialog-backdrop)', display: 'grid', placeItems: 'center', zIndex: 40, @@ -350,10 +350,10 @@ export function UpdateDialog({
diff --git a/openless-all/app/src/components/CloudSyncSetupPrompt.tsx b/openless-all/app/src/components/CloudSyncSetupPrompt.tsx new file mode 100644 index 00000000..c634280d --- /dev/null +++ b/openless-all/app/src/components/CloudSyncSetupPrompt.tsx @@ -0,0 +1,214 @@ +import { useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { CloudIcon, XIcon } from 'lucide-react'; +import { isTauri } from '../lib/ipc/shared'; +import { cloudSyncE2eeClaimSetupPrompt } from '../lib/ipc/cloud-sync-e2ee'; +import { CloudSyncSection } from '../pages/settings/CloudSyncSection'; +import { Modal } from './ui/Modal'; + +const PROMPT_EVENTS = new Set([ + 'backend_started', + 'preferences_changed', + 'credentials_changed', + 'dictation_completed', + 'dictation_state_changed', + 'qa_state', + 'selection_state_changed', + 'selection_voice_state_changed', + 'less_computer_event', + 'local_asr_engine_changed', + 'permission_changed', +]); + +/** Core owns eligibility and the durable once-per-installation claim. */ +export function CloudSyncSetupPrompt({ + blocked, + onSetup, +}: { + blocked: boolean; + onSetup: () => void; +}) { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + const checkRef = useRef(0); + const focusRef = useRef(null); + + useEffect(() => { + if (!isTauri || blocked) return; + let cancelled = false; + let inFlight = false; + let timer: ReturnType | undefined; + let unlisten: (() => void) | undefined; + const check = async () => { + if (cancelled || inFlight || document.visibilityState !== 'visible' || !document.hasFocus()) + return; + inFlight = true; + const request = ++checkRef.current; + try { + const claimed = await cloudSyncE2eeClaimSetupPrompt(); + if ( + !cancelled && + checkRef.current === request && + claimed && + document.visibilityState === 'visible' && + document.hasFocus() + ) + setOpen(true); + } catch { + // Denied credential reads and in-progress saves never consume consent + // or display a misleading configuration-complete message. + } finally { + inFlight = false; + } + }; + const schedule = () => { + if (timer) clearTimeout(timer); + timer = setTimeout(() => { + void check(); + }, 300); + }; + const invalidate = () => { + checkRef.current += 1; + setOpen(false); + }; + const eligibilityChanged = () => { + invalidate(); + schedule(); + }; + void import('@tauri-apps/api/event') + .then(async ({ listen }) => { + const handle = await listen<{ kind: { type: string } }>('backend:event', ({ payload }) => { + if (PROMPT_EVENTS.has(payload.kind.type)) eligibilityChanged(); + }); + if (cancelled) handle(); + else { + unlisten = handle; + schedule(); + } + }) + .catch(() => {}); + window.addEventListener('focus', schedule); + window.addEventListener('blur', invalidate); + document.addEventListener('visibilitychange', eligibilityChanged); + return () => { + cancelled = true; + checkRef.current += 1; + if (timer) clearTimeout(timer); + unlisten?.(); + window.removeEventListener('focus', schedule); + window.removeEventListener('blur', invalidate); + document.removeEventListener('visibilitychange', eligibilityChanged); + }; + }, [blocked]); + + useEffect(() => { + if (!open || blocked) return; + const previous = document.activeElement; + focusRef.current?.focus(); + return () => { + if (previous instanceof HTMLElement) previous.focus(); + }; + }, [open, blocked]); + + if (!open || blocked) return null; + return ( + setOpen(false)} width="min(480px, 100%)" zIndex={75}> +
{ + if (event.key === 'Escape') { + event.preventDefault(); + event.stopPropagation(); + setOpen(false); + } + if (event.key === 'Tab') { + const buttons = [...event.currentTarget.querySelectorAll('button')]; + const last = buttons[buttons.length - 1]; + const next = event.shiftKey ? last : buttons[0]; + if (document.activeElement === (event.shiftKey ? buttons[0] : last)) { + event.preventDefault(); + next?.focus(); + } + } + }} + > +
+
+ ); +} + +/** Available before API-key setup. Opening this never enables sync or uploads. */ +export function CloudSyncWelcome() { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + return ( + <> + + {open && ( + setOpen(false)} width="min(680px, 100%)" zIndex={50}> +
+ +
+ +
+ )} + + ); +} diff --git a/openless-all/app/src/components/FloatingShell.tsx b/openless-all/app/src/components/FloatingShell.tsx index 11446241..b6c2bbe2 100644 --- a/openless-all/app/src/components/FloatingShell.tsx +++ b/openless-all/app/src/components/FloatingShell.tsx @@ -8,6 +8,7 @@ import { Tooltip } from './Tooltip'; import { WindowChrome, detectOS, type OS } from './WindowChrome'; import { AudioCueListener } from './AudioCue'; import { SettingsModal } from './SettingsModal'; +import { CloudSyncSetupPrompt } from './CloudSyncSetupPrompt'; import { Overview } from '../pages/Overview'; import { History } from '../pages/History'; import { Vocab } from '../pages/Vocab'; @@ -635,6 +636,10 @@ function FloatingShellBody({ closing={providerPromptMount.closing} onLater={rememberProviderPrompt} onOpenSettings={openProviderSettings} + onRestore={() => { + rememberProviderPrompt(); + openSettings('privacy'); + }} /> ) : hotkeyPromptMount.mounted ? ( ) : null} + openSettings('privacy')} + /> {/* tab 切换 + provider prompt + footer popover 公用的入场关键帧 */} @@ -899,10 +908,12 @@ function ProviderSetupPrompt({ closing = false, onLater, onOpenSettings, + onRestore, }: { closing?: boolean; onLater: () => void; onOpenSettings: () => void; + onRestore: () => void; }) { const { t } = useTranslation(); return ( @@ -915,7 +926,7 @@ function ProviderSetupPrompt({ alignItems: 'center', justifyContent: 'center', padding: 28, - background: 'rgba(15,17,22,0.28)', + background: 'var(--ol-dialog-backdrop)', backdropFilter: 'blur(6px) saturate(140%)', WebkitBackdropFilter: 'blur(6px) saturate(140%)', animation: closing @@ -926,10 +937,10 @@ function ProviderSetupPrompt({
{t('shell.providerPrompt.body')}
+
+ + ); +} diff --git a/openless-all/app/src/i18n/de.ts b/openless-all/app/src/i18n/de.ts index f095de62..711afbd0 100644 --- a/openless-all/app/src/i18n/de.ts +++ b/openless-all/app/src/i18n/de.ts @@ -1,6 +1,163 @@ import type { zhCN } from './zh-CN'; export const de: typeof zhCN = { + cloudSyncE2ee: { + setupPromptTitle: 'Einrichtung verschlüsselt sichern?', + setupPromptBody: + 'Die Dienste sind eingerichtet. Einstellungen, API-Schlüssel und Textverlauf werden auf diesem Gerät verschlüsselt. Erst nach Ihrer Zustimmung und Aktivierung werden Daten hochgeladen.', + setupPromptLater: 'Jetzt nicht', + setupPromptOpen: 'Verschlüsselte Synchronisierung', + title: 'Verschlüsselte Cloud-Synchronisierung', + description: 'Vor der Synchronisierung mit deinem GitHub-Konto auf diesem Gerät verschlüsseln.', + enable: 'Verschlüsselte Synchronisierung aktivieren', + refresh: 'Status aktualisieren', + loading: 'Synchronisierungsstatus wird geprüft…', + signIn: 'Mit GitHub anmelden', + signOut: 'Abmelden', + account: 'Synchronisierungskonto', + keyLocked: 'Auf diesem Gerät gesperrt', + keyUnlocked: 'Auf diesem Gerät entsperrt', + hasSnapshot: 'Eine verschlüsselte Cloud-Sicherung ist verfügbar.', + noSnapshot: 'Noch keine verschlüsselte Cloud-Sicherung.', + snapshotUnknown: 'Cloud-Sicherung noch nicht geprüft.', + lastSync: 'Zuletzt synchronisiert: {{time}}', + syncNow: 'Jetzt synchronisieren', + checkPending: 'Ausstehendes Ergebnis prüfen', + unlock: 'Entsperren', + lock: 'Dieses Gerät sperren', + changePassword: 'Sync-Passwort ändern', + restore: 'Cloud-Wiederherstellung prüfen', + delete: 'Cloud-Sicherung löschen', + working: 'Wird verarbeitet…', + cancelTask: 'Aktuellen Vorgang abbrechen', + cancelRequested: 'Abbruch angefordert. Der Vorgang wird beendet.', + done: 'Synchronisierungseinstellungen aktualisiert.', + restored: 'Cloud-Daten wiederhergestellt. Gerätespezifische Einstellungen prüfen.', + retryAfter: 'In {{seconds}} Sekunden erneut versuchen.', + passwordWarning: + 'Bewahre dein Sync-Passwort sicher auf. Es ist nicht wiederherstellbar; bei Verlust können Cloud-Daten unlesbar werden.', + consentTitle: 'Ende-zu-Ende-verschlüsselte Synchronisierung aktivieren', + consentDescription: + 'Die Synchronisierung umfasst Dienstzugangsdaten und private Textverläufe. Alles wird vor dem Upload lokal verschlüsselt. Prüfe zuerst den vollständigen Umfang.', + scopeSummary: 'Vollständigen Umfang anzeigen', + consentCheck: + 'Ich verstehe den Umfang und stimme der verschlüsselten Synchronisierung dieser Daten zu.', + continue: 'Weiter', + createTitle: 'Sync-Passwort erstellen', + unlockTitle: 'Cloud-Sicherung entsperren', + passwordTitle: 'Sync-Passwort ändern', + create: 'Verschlüsselte Sicherung erstellen', + password: 'Sync-Passwort', + newPassword: 'Neues Sync-Passwort', + currentPassword: 'Aktuelles Sync-Passwort', + confirmPassword: 'Neues Passwort bestätigen', + passwordPolicy: + '12–128 Zeichen mit Groß- und Kleinbuchstaben sowie einer Zahl. Keine üblichen Passwörter verwenden.', + rememberKey: 'Entsperrschlüssel im sicheren Speicher dieses Geräts merken', + disableTitle: 'Verschlüsselte Synchronisierung ausschalten?', + disableDescription: + 'Weitere Synchronisierung wird gestoppt. Cloud-Sicherung und lokale Daten bleiben erhalten. Löschen ist eine separate Aktion.', + confirmDisable: 'Synchronisierung ausschalten', + deleteTitle: 'Verschlüsselte Cloud-Sicherung löschen?', + deleteDescription: + 'Nur die Cloud-Sicherung dieses Kontos wird gelöscht. Lokale Daten bleiben. Für ein anderes Gerät wird danach eine neue Sicherung benötigt.', + backupRetention: + 'Nach dem Löschen können Sicherungskopien des Dienstes bis zu {{days}} Tage aufbewahrt werden.', + deleteCheck: 'Ich bestätige die Löschung dieser Cloud-Sicherung.', + confirmDelete: 'Cloud-Sicherung löschen', + restoreTitle: 'Wiederherstellung prüfen', + restoreDescription: + 'Prüfe die Datenkategorien. Vertrauliche Werte bleiben verborgen. Ohne Bestätigung wird nichts wiederhergestellt.', + deviceReview: + 'Nach der Wiederherstellung {{count}} Geräteeinstellungen prüfen, etwa Tastenkürzel, Modellpfade, Mikrofone und Systemrechte.', + restoreMode: 'Wiederherstellungsart', + merge: 'Zusammenführen und Konflikte lösen', + replace: 'Synchronisierte lokale Daten durch Cloud-Daten ersetzen', + replaceWarning: + 'Lokale Daten im Sync-Umfang werden ersetzt. Zum Behalten lokaler Änderungen Zusammenführen wählen.', + restoreCheck: 'Ich habe Wiederherstellungsart und Konfliktentscheidungen geprüft.', + applyRestore: 'Wiederherstellung bestätigen', + conflictProgress: '{{selected}} von {{total}} Konflikten gelöst', + conflictItem: 'Konflikt {{index}} · {{category}}', + conflictLocal: 'Lokal behalten', + conflictCloud: 'Cloud verwenden', + previous: 'Zurück', + next: 'Weiter', + scope: { + preferences: 'App-Einstellungen und Oberfläche.', + credentials: + 'ASR-, LLM- und Omni-Kanäle samt API-Schlüsseln, IDs, Access Keys und Secret Keys.', + personal: 'Wörterbuch, eigene Wortlisten, Korrekturen, Stilpakete und Symbole.', + history: 'Alle Diktat- und Kurznotiz-Texte sowie Aktivitätsstatistiken.', + device: 'Geräteprofile zur Prüfung auf dem Zielgerät.', + excluded: + 'Nicht enthalten: OAuth-Anmeldesitzungen, Sync-Passwörter oder abgeleitete Schlüssel, OS-Zugangsdaten, PINs, Geräteberechtigungen, Originalaufnahmen oder Modellgewichte.', + }, + states: { + disabled: 'Synchronisierung aus', + sign_in_required: 'GitHub-Anmeldung erforderlich', + unlock_required: 'Zum Entsperren Passwort eingeben', + ready: 'Aktuell', + pending: 'Lokale Änderungen warten auf Synchronisierung', + syncing: 'Synchronisierung läuft', + conflict: 'Konflikte prüfen', + failed: 'Synchronisierung benötigt Aufmerksamkeit', + outcome_unknown: 'Cloud-Ergebnis noch unbestätigt', + recovery_required: 'Lokale Wiederherstellung erforderlich', + }, + categories: { + preferences: 'App-Einstellungen', + ui_preferences: 'Oberfläche', + channels: 'Dienstkanäle und Zugangsdaten', + provider_credentials: 'Dienstzugangsdaten', + dictionary: 'Wörterbuch', + vocabulary_presets: 'Eigene Wortlisten', + corrections: 'Korrekturen', + style_packs: 'Stilpakete und Symbole', + history: 'Textverlauf', + activity: 'Aktivitätsstatistiken', + device_profile: 'Geräteprofile', + other: 'Weitere Sync-Daten', + }, + conflictReasons: { + both_modified: 'Lokale und Cloud-Version wurden geändert.', + delete_modify: 'Eine Version wurde gelöscht, die andere geändert.', + no_common_baseline: 'Keine gemeinsame Ausgangsversion. Wähle die zu behaltende Version.', + other: 'Lokale oder Cloud-Version wählen. Werte werden nicht angezeigt.', + }, + errors: { + unknown: 'Vorgang fehlgeschlagen. Status aktualisieren und erneut versuchen.', + signIn: 'Erneut bei GitHub anmelden.', + unlock: 'Dieses Gerät zuerst mit dem Sync-Passwort entsperren.', + unavailable: + 'Verschlüsselte Synchronisierung nicht verfügbar. Native App verwenden und Dienst später prüfen.', + weakPassword: 'Ein stärkeres Passwort mit passender Länge und Zeichenwahl verwenden.', + passwordMismatch: 'Die Passwörter stimmen nicht überein.', + invalidPassword: + 'Passwort entsperrt die Sicherung nicht oder verschlüsselte Daten konnten nicht geprüft werden.', + secureStorage: + 'Zugriff auf sicheren Speicher verweigert. Berechtigungen prüfen oder ohne gespeicherten Schlüssel entsperren.', + changed: 'Daten wurden geändert. Dialog schließen, aktualisieren und neue Vorschau prüfen.', + accountChanged: 'GitHub-Konto geändert. Vor dem Fortfahren aktualisieren.', + busy: 'Ein Sync-Vorgang läuft. Warten oder zuerst abbrechen.', + cancelled: 'Vorgang abgebrochen.', + network: 'Sync-Dienst nicht erreichbar. Verbindung prüfen und erneut versuchen.', + rateLimited: 'Zu viele Anfragen. Vor erneutem Versuch warten.', + outcomeUnknown: + 'Cloud-Ergebnis unbestätigt. Ausstehenden Vorgang prüfen, bevor eine neue Sicherung erstellt wird.', + recovery: + 'Lokale Wiederherstellung nötig. Daten dieses Geräts behalten und Cloud-Sicherung nicht überschreiben.', + rolledBack: + 'Wiederherstellung fehlgeschlagen; lokale Änderungen zurückgesetzt. Vor erneutem Versuch aktualisieren.', + tooLarge: 'Verschlüsselte Sicherung überschreitet das Größenlimit.', + reviewRequired: 'Zuerst Sync-Umfang und Wiederherstellungsvorschau prüfen.', + choicesRequired: 'Für jeden Konflikt lokal oder Cloud wählen.', + invalidData: + 'Verschlüsselte Sicherung nicht verifizierbar. Lokale Daten wurden nicht ersetzt.', + localData: + 'Lokale Daten konnten nicht sicher vorbereitet werden. Aktualisieren und erneut versuchen.', + }, + }, cloudSync: { title: 'Cloud-Synchronisierung', description: @@ -110,6 +267,18 @@ export const de: typeof zhCN = { cancel: 'Abbrechen', }, qa: { + compact: { + closeError: 'Schließen fehlgeschlagen. Erneut versuchen.', + sendError: 'Senden fehlgeschlagen. Deine Eingabe bleibt erhalten. Erneut versuchen.', + microphoneError: 'Die Mikrofonaktion ist fehlgeschlagen. Erneut versuchen.', + modeError: 'Der Bearbeitungsmodus wurde nicht geändert. Erneut versuchen.', + conversation: 'Aktuelles Gespräch', + sending: 'Deine Frage wird gesendet…', + addUnavailable: 'Inhalt hinzufügen · noch nicht verfügbar', + browserUnavailable: 'Vorschau führt keine Befehle aus', + answer: 'OpenLess-Antwort', + layoutError: 'Das Fenster konnte nicht vergrößert werden. Schließe und öffne es erneut.', + }, title: 'Nachfragen', headerHint: 'Jederzeit fragen', thinking: 'Denkt nach…', @@ -142,6 +311,48 @@ export const de: typeof zhCN = { editInstructionMode: 'Bearbeitungsanweisung', }, lessComputer: { + desktop: { + approvedSubmitted: 'Freigabe gesendet', + deniedSubmitted: 'Ablehnung gesendet', + agents: 'Deine Agenten', + configured: 'Aktuell konfiguriert', + agentSettings: 'In Einstellungen wählen', + historyUnavailable: 'Verlauf · nicht verfügbar', + sessionUnavailable: 'Neue Sitzung · nicht verfügbar', + agentHint: 'Wähle den Agenten in den Einstellungen.', + workspace: 'Arbeitsbereich', + multitask: 'Mehrere Aufgaben', + unavailable: 'Noch nicht verfügbar', + historyHint: + 'Verlauf und mehrere Sitzungen sind für diese vier Agenten noch nicht verfügbar.', + signedIn: 'Angemeldet', + signIn: 'Anmelden', + currentSession: 'Aktuelles Gespräch', + minimize: 'Minimieren', + maximize: 'Maximieren / Wiederherstellen', + windowError: 'Die Fensteraktion ist fehlgeschlagen. Erneut versuchen.', + emptyHint: 'Beschreibe eine Aufgabe oder nutze dein eingerichtetes Sprachkürzel.', + voiceHint: 'Sprachkürzel gedrückt halten', + idle: 'Wartet auf eine Aufgabe', + waitingApproval: 'Wartet auf Freigabe', + submittingApproval: 'Wird gesendet…', + approvalError: 'Die Freigabe wurde nicht gesendet. Erneut versuchen.', + approvalExpired: 'Dieser Durchlauf ist beendet. Eine Freigabe ist nicht mehr möglich.', + sendError: 'Senden fehlgeschlagen. Deine Eingabe bleibt erhalten. Erneut versuchen.', + browserUnavailable: 'Die Browser-Vorschau führt keine Befehle aus. Nutze die Desktop-App.', + cancelHint: 'Eine laufende Aufgabe kannst du über die Kapsel abbrechen.', + inputHint: 'Enter zum Senden · Umschalt + Enter für eine neue Zeile', + activity: 'Aufgabenaktivität', + noActivity: 'Noch keine Aktivität', + activityHint: 'Werkzeugaufrufe und Freigaben erscheinen hier, sobald eine Aufgabe startet.', + currentTask: 'Aktuelle Aufgabe', + toolActive: 'Wird aufgerufen', + toolReported: 'Aufruf gemeldet', + approval: 'Aktionsfreigabe', + apiCost: 'API-Kosten dieses Durchlaufs', + activitySource: + 'Folgt der aktuellen Aufgabe. Kosten erscheinen nur, wenn der Dienst sie meldet.', + }, title: 'Less Computer', subtitle: 'Was soll dein Computer tun?', you: 'Du', @@ -1492,15 +1703,16 @@ export const de: typeof zhCN = { bailianVocabularyIdLabel: 'Wörterbuch-ID für Begriffe (optional)', bailianVocabularyIdNote: 'Wenn du bei DashScope ein Begriffswörterbuch erstellt hast, gib seine vocab-... ID ein. Leer lassen, um keine Begriffe zu übergeben.', - bailianProtocolLabel: "API-Typ", - bailianProtocolNote: "Die manuelle Auswahl ersetzt die Erkennung anhand des Modellnamens und gilt pro Kanal für Prüfung und Aufnahme. Beachten Sie die Modelldokumentation.", + bailianProtocolLabel: 'API-Typ', + bailianProtocolNote: + 'Die manuelle Auswahl ersetzt die Erkennung anhand des Modellnamens und gilt pro Kanal für Prüfung und Aufnahme. Beachten Sie die Modelldokumentation.', bailianProtocolOptions: { - "auto": "Automatisch", - "dashscope-realtime": "Echtzeit (DashScope)", - "qwen-realtime": "Echtzeit (Qwen Realtime)", - "multimodal": "Synchron, nicht in Echtzeit (Fun-ASR / Qwen-Audio)", - "qwen-multimodal": "Synchron, nicht in Echtzeit (Qwen3-ASR)", - "async-transcription": "Asynchron (Dateitranskription)", + auto: 'Automatisch', + 'dashscope-realtime': 'Echtzeit (DashScope)', + 'qwen-realtime': 'Echtzeit (Qwen Realtime)', + multimodal: 'Synchron, nicht in Echtzeit (Fun-ASR / Qwen-Audio)', + 'qwen-multimodal': 'Synchron, nicht in Echtzeit (Qwen3-ASR)', + 'async-transcription': 'Asynchron (Dateitranskription)', }, bailianModelRealtimeHint: 'Echtzeitmodell · transkribiert während des Sprechens.', bailianModelSyncFileHint: diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index 2a9919e8..5914b80f 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -4,6 +4,159 @@ import type { zhCN } from './zh-CN'; // Type-level guarantee that en mirrors the zh-CN shape. export const en: typeof zhCN = { + cloudSyncE2ee: { + setupPromptTitle: 'Back up your setup securely?', + setupPromptBody: + 'Your service setup is complete. Settings, service API keys and text history are encrypted on this device. Uploads start only after you review the scope and enable sync.', + setupPromptLater: 'Not now', + setupPromptOpen: 'Explore encrypted sync', + title: 'Encrypted cloud sync', + description: 'Encrypt on this device before syncing with your GitHub account.', + enable: 'Enable encrypted sync', + refresh: 'Refresh status', + loading: 'Checking sync status…', + signIn: 'Sign in with GitHub', + signOut: 'Sign out', + account: 'Sync account', + keyLocked: 'Locked on this device', + keyUnlocked: 'Unlocked on this device', + hasSnapshot: 'An encrypted cloud backup is available.', + noSnapshot: 'No encrypted cloud backup yet.', + snapshotUnknown: 'Cloud backup has not been checked yet.', + lastSync: 'Last synced {{time}}', + syncNow: 'Sync now', + checkPending: 'Check pending result', + unlock: 'Unlock', + lock: 'Lock this device', + changePassword: 'Change sync password', + restore: 'Review cloud restore', + delete: 'Delete cloud backup', + working: 'Working securely…', + cancelTask: 'Cancel current task', + cancelRequested: 'Cancellation requested. Waiting for the task to settle.', + done: 'Sync settings updated.', + restored: 'Cloud data restored. Review settings specific to this device.', + retryAfter: 'Try again in {{seconds}} seconds.', + passwordWarning: + 'Keep your sync password safe. It cannot be recovered; losing it can make cloud data unreadable.', + consentTitle: 'Enable end-to-end encrypted sync', + consentDescription: + 'This sync includes service credentials and private text history. Data is encrypted on your device before upload. Review the full scope first.', + scopeSummary: 'View everything included', + consentCheck: 'I understand the full scope and agree to sync this data in encrypted form.', + continue: 'Continue', + createTitle: 'Create a sync password', + unlockTitle: 'Unlock your cloud backup', + passwordTitle: 'Change your sync password', + create: 'Create encrypted backup', + password: 'Sync password', + newPassword: 'New sync password', + currentPassword: 'Current sync password', + confirmPassword: 'Confirm new password', + passwordPolicy: + 'Use 12–128 characters with uppercase and lowercase letters and a number. Avoid common passwords.', + rememberKey: 'Remember the unlock key in this device’s secure storage', + disableTitle: 'Turn off encrypted sync?', + disableDescription: + 'This stops further sync. Your cloud backup and local data are kept. Deleting the cloud backup is a separate action.', + confirmDisable: 'Turn off sync', + deleteTitle: 'Delete the encrypted cloud backup?', + deleteDescription: + 'Only this account’s cloud backup is deleted. Local data is kept. You will need a new backup to restore on another device.', + backupRetention: + 'After deletion, service backup copies may be retained for up to {{days}} days.', + deleteCheck: 'I confirm deletion of this cloud backup.', + confirmDelete: 'Delete cloud backup', + restoreTitle: 'Review before restoring', + restoreDescription: + 'Review the categories below. Sensitive values stay hidden. Nothing is restored until you confirm.', + deviceReview: + 'After restoring, review {{count}} device settings, including shortcuts, model paths, microphones and system permissions.', + restoreMode: 'How to restore', + merge: 'Merge and resolve conflicts', + replace: 'Replace synced local data with cloud data', + replaceWarning: + 'This replaces local data within the sync scope. Choose merge if you need to keep local changes.', + restoreCheck: 'I have reviewed the restore mode and conflict choices.', + applyRestore: 'Confirm restore', + conflictProgress: '{{selected}} of {{total}} conflicts resolved', + conflictItem: 'Conflict {{index}} · {{category}}', + conflictLocal: 'Keep local', + conflictCloud: 'Use cloud', + previous: 'Previous', + next: 'Next', + scope: { + preferences: 'App preferences and interface settings.', + credentials: + 'ASR, LLM and Omni channels, including service API keys, IDs, access keys and secret keys.', + personal: 'Dictionary, custom vocabulary presets, corrections, style packs and icons.', + history: 'All dictation and quick-note text history, plus activity statistics.', + device: 'Device configuration profiles, to review on the receiving device.', + excluded: + 'Not included: OAuth login sessions, sync passwords or derived keys, OS credential files, PINs, device permissions, original audio/video recordings, or model weights.', + }, + states: { + disabled: 'Sync is off', + sign_in_required: 'GitHub sign-in required', + unlock_required: 'Enter your password to unlock', + ready: 'Up to date', + pending: 'Local changes are waiting to sync', + syncing: 'Sync in progress', + conflict: 'Review conflicting changes', + failed: 'Sync needs attention', + outcome_unknown: 'Waiting to confirm the cloud result', + recovery_required: 'Local recovery is required', + }, + categories: { + preferences: 'App preferences', + ui_preferences: 'Interface settings', + channels: 'Service channels and credentials', + provider_credentials: 'Service credentials', + dictionary: 'Dictionary', + vocabulary_presets: 'Custom vocabulary presets', + corrections: 'Corrections', + style_packs: 'Style packs and icons', + history: 'Text history', + activity: 'Activity statistics', + device_profile: 'Device profiles', + other: 'Other synced data', + }, + conflictReasons: { + both_modified: 'Both local and cloud versions changed.', + delete_modify: 'One version was deleted while the other changed.', + no_common_baseline: 'No shared baseline is available. Choose which version to keep.', + other: 'Choose the local or cloud version. Values are not displayed.', + }, + errors: { + unknown: 'The operation could not be completed. Refresh the status and try again.', + signIn: 'Sign in to GitHub again.', + unlock: 'Unlock this device with your sync password first.', + unavailable: + 'Encrypted sync is unavailable here. Use the native app and check the service later.', + weakPassword: 'Choose a stronger password meeting the length and character requirements.', + passwordMismatch: 'The two passwords do not match.', + invalidPassword: + 'The password could not unlock this backup, or the encrypted data could not be verified.', + secureStorage: + 'Secure storage denied access. Check device permissions, or unlock without remembering the key.', + changed: 'Data changed during review. Close this dialog, refresh, and review a new preview.', + accountChanged: 'The GitHub account changed. Refresh before continuing.', + busy: 'Another sync task is running. Wait or cancel it first.', + cancelled: 'The task was cancelled.', + network: 'Could not reach the sync service. Check your connection and try again.', + rateLimited: 'Too many requests. Wait before retrying.', + outcomeUnknown: + 'The cloud result is not confirmed. Check the pending result before creating another backup.', + recovery: + 'Local recovery is needed. Keep this device’s data and do not overwrite the cloud backup.', + rolledBack: 'Restore failed and local changes were rolled back. Refresh before trying again.', + tooLarge: 'The encrypted backup exceeds the service’s size limit.', + reviewRequired: 'Review the full sync scope and the cloud restore preview before continuing.', + choicesRequired: 'Choose local or cloud for every conflict.', + invalidData: 'The encrypted backup could not be verified. Local data was not replaced.', + localData: 'Could not prepare local data securely. Refresh and try again.', + }, + }, cloudSync: { title: 'Cloud sync', description: @@ -110,6 +263,18 @@ export const en: typeof zhCN = { cancel: 'Cancel', }, qa: { + compact: { + closeError: 'Could not close. Try again.', + sendError: 'Could not send. Your draft is kept here; try again.', + microphoneError: 'The microphone action failed. Try again.', + modeError: 'Edit mode was not changed. Try again.', + conversation: 'Current conversation', + sending: 'Sending your question…', + addUnavailable: 'Add content · not available yet', + browserUnavailable: 'Preview cannot run commands', + answer: 'OpenLess answer', + layoutError: 'Could not expand the window. Close and reopen it.', + }, title: 'Ask', headerHint: 'Ask anytime', thinking: 'Thinking…', @@ -142,6 +307,46 @@ export const en: typeof zhCN = { editInstructionMode: 'Edit instruction', }, lessComputer: { + desktop: { + approvedSubmitted: 'Approval sent', + deniedSubmitted: 'Denial sent', + agents: 'Your agents', + configured: 'Configured agent', + agentSettings: 'Choose in Settings', + historyUnavailable: 'History · unavailable', + sessionUnavailable: 'New session · unavailable', + agentHint: 'Choose your agent in Settings.', + workspace: 'Workspace', + multitask: 'Multiple tasks', + unavailable: 'Not available yet', + historyHint: 'History and multiple sessions are not available for these four agents yet.', + signedIn: 'Signed in', + signIn: 'Sign in', + currentSession: 'Current conversation', + minimize: 'Minimize', + maximize: 'Maximize / restore', + windowError: 'The window action failed. Try again.', + emptyHint: 'Describe a task, or use your configured voice shortcut to get started.', + voiceHint: 'Hold your voice shortcut', + idle: 'Waiting for a task', + waitingApproval: 'Waiting for approval', + submittingApproval: 'Submitting…', + approvalError: 'Approval was not submitted. Try again.', + approvalExpired: 'This turn has ended. Approval is no longer available.', + sendError: 'Could not send. Your draft is saved here; try again.', + browserUnavailable: 'This browser preview cannot run commands. Use the desktop app.', + cancelHint: 'Cancel a running task from the capsule.', + inputHint: 'Enter to send · Shift + Enter for a new line', + activity: 'Task activity', + noActivity: 'No activity yet', + activityHint: 'Tool calls and approvals will appear here when a task starts.', + currentTask: 'Current task', + toolActive: 'Calling tool', + toolReported: 'Call reported', + approval: 'Action approval', + apiCost: 'API cost this turn', + activitySource: 'Updates with this task. Cost appears only when reported by the service.', + }, title: 'Less Computer', subtitle: 'What should your computer do?', you: 'You', @@ -1462,15 +1667,16 @@ export const en: typeof zhCN = { bailianVocabularyIdLabel: 'Hotword Vocabulary ID (optional)', bailianVocabularyIdNote: 'If you have created a DashScope hotword vocabulary, enter its vocab-... ID. Leave blank to skip hotwords.', - bailianProtocolLabel: "API type", - bailianProtocolNote: "Manual selection overrides model-name detection and is saved per channel for both validation and recording. Choose the API specified by the model documentation.", + bailianProtocolLabel: 'API type', + bailianProtocolNote: + 'Manual selection overrides model-name detection and is saved per channel for both validation and recording. Choose the API specified by the model documentation.', bailianProtocolOptions: { - "auto": "Automatic", - "dashscope-realtime": "Realtime (DashScope)", - "qwen-realtime": "Realtime (Qwen Realtime)", - "multimodal": "Non-realtime synchronous (Fun-ASR / Qwen-Audio)", - "qwen-multimodal": "Non-realtime synchronous (Qwen3-ASR)", - "async-transcription": "Non-realtime asynchronous (file transcription)", + auto: 'Automatic', + 'dashscope-realtime': 'Realtime (DashScope)', + 'qwen-realtime': 'Realtime (Qwen Realtime)', + multimodal: 'Non-realtime synchronous (Fun-ASR / Qwen-Audio)', + 'qwen-multimodal': 'Non-realtime synchronous (Qwen3-ASR)', + 'async-transcription': 'Non-realtime asynchronous (file transcription)', }, bailianModelRealtimeHint: 'Realtime model · transcribes as you speak.', bailianModelSyncFileHint: diff --git a/openless-all/app/src/i18n/es.ts b/openless-all/app/src/i18n/es.ts index eea8bd8b..da7eb938 100644 --- a/openless-all/app/src/i18n/es.ts +++ b/openless-all/app/src/i18n/es.ts @@ -1,6 +1,161 @@ import type { zhCN } from './zh-CN'; export const es: typeof zhCN = { + cloudSyncE2ee: { + setupPromptTitle: '¿Guardar una copia cifrada?', + setupPromptBody: + 'Los servicios están configurados. Los ajustes, claves de API e historial de texto se cifran en este dispositivo. Solo se suben tras revisar el alcance y activar la sincronización.', + setupPromptLater: 'Ahora no', + setupPromptOpen: 'Ver sincronización cifrada', + title: 'Sincronización cifrada en la nube', + description: 'Cifra en este dispositivo antes de sincronizar con tu cuenta de GitHub.', + enable: 'Activar sincronización cifrada', + refresh: 'Actualizar estado', + loading: 'Comprobando la sincronización…', + signIn: 'Iniciar sesión con GitHub', + signOut: 'Cerrar sesión', + account: 'Cuenta de sincronización', + keyLocked: 'Bloqueado en este dispositivo', + keyUnlocked: 'Desbloqueado en este dispositivo', + hasSnapshot: 'Hay una copia cifrada en la nube.', + noSnapshot: 'Todavía no hay una copia cifrada en la nube.', + snapshotUnknown: 'La copia en la nube aún no se ha comprobado.', + lastSync: 'Última sincronización: {{time}}', + syncNow: 'Sincronizar ahora', + checkPending: 'Comprobar resultado pendiente', + unlock: 'Desbloquear', + lock: 'Bloquear este dispositivo', + changePassword: 'Cambiar contraseña de sincronización', + restore: 'Revisar restauración desde la nube', + delete: 'Eliminar copia en la nube', + working: 'Procesando…', + cancelTask: 'Cancelar tarea actual', + cancelRequested: 'Cancelación solicitada. Esperando a que termine la tarea.', + done: 'Configuración de sincronización actualizada.', + restored: 'Datos restaurados. Revisa los ajustes específicos de este dispositivo.', + retryAfter: 'Inténtalo de nuevo en {{seconds}} segundos.', + passwordWarning: + 'Guarda tu contraseña. No se puede recuperar; perderla puede impedir leer los datos de la nube.', + consentTitle: 'Activar sincronización cifrada de extremo a extremo', + consentDescription: + 'Incluye credenciales de servicios e historial de textos privados. Los datos se cifran en el dispositivo antes de subirlos. Revisa todo el alcance.', + scopeSummary: 'Ver el alcance completo', + consentCheck: 'Entiendo el alcance y acepto sincronizar estos datos cifrados.', + continue: 'Continuar', + createTitle: 'Crear contraseña de sincronización', + unlockTitle: 'Desbloquear copia en la nube', + passwordTitle: 'Cambiar contraseña de sincronización', + create: 'Crear copia cifrada', + password: 'Contraseña de sincronización', + newPassword: 'Nueva contraseña de sincronización', + currentPassword: 'Contraseña actual', + confirmPassword: 'Confirmar nueva contraseña', + passwordPolicy: + 'Usa 12–128 caracteres con mayúsculas, minúsculas y un número. Evita contraseñas comunes.', + rememberKey: 'Recordar la clave en el almacenamiento seguro del dispositivo', + disableTitle: '¿Desactivar la sincronización cifrada?', + disableDescription: + 'Se detiene la sincronización. Se conservan la copia en la nube y los datos locales. Eliminar la copia es una acción separada.', + confirmDisable: 'Desactivar sincronización', + deleteTitle: '¿Eliminar la copia cifrada en la nube?', + deleteDescription: + 'Solo se elimina la copia en la nube de esta cuenta. Los datos locales permanecen. Necesitarás una nueva copia para restaurar en otro dispositivo.', + backupRetention: + 'Tras la eliminación, el servicio puede conservar copias de seguridad durante un máximo de {{days}} días.', + deleteCheck: 'Confirmo que quiero eliminar esta copia en la nube.', + confirmDelete: 'Eliminar copia en la nube', + restoreTitle: 'Revisar antes de restaurar', + restoreDescription: + 'Revisa las categorías. Los valores sensibles permanecen ocultos. No se restaura nada hasta que confirmes.', + deviceReview: + 'Después de restaurar, revisa {{count}} ajustes del dispositivo: atajos, rutas de modelos, micrófonos y permisos del sistema.', + restoreMode: 'Modo de restauración', + merge: 'Combinar y resolver conflictos', + replace: 'Reemplazar datos locales sincronizados por los de la nube', + replaceWarning: + 'Se reemplazarán los datos locales incluidos. Elige combinar para conservar cambios locales.', + restoreCheck: 'He revisado el modo y las decisiones sobre conflictos.', + applyRestore: 'Confirmar restauración', + conflictProgress: '{{selected}} de {{total}} conflictos resueltos', + conflictItem: 'Conflicto {{index}} · {{category}}', + conflictLocal: 'Conservar local', + conflictCloud: 'Usar nube', + previous: 'Anterior', + next: 'Siguiente', + scope: { + preferences: 'Preferencias de la aplicación y de la interfaz.', + credentials: + 'Canales ASR, LLM y Omni con claves API, IDs, claves de acceso y claves secretas.', + personal: 'Diccionario, vocabularios personalizados, correcciones, estilos e iconos.', + history: 'Todo el historial de textos dictados y notas rápidas, y estadísticas de actividad.', + device: 'Perfiles de configuración para revisar en el dispositivo de destino.', + excluded: + 'No incluye sesiones OAuth, contraseñas de sincronización ni claves derivadas, archivos de credenciales del sistema, PIN, permisos, grabaciones originales de audio/vídeo ni pesos de modelos.', + }, + states: { + disabled: 'Sincronización desactivada', + sign_in_required: 'Se requiere iniciar sesión con GitHub', + unlock_required: 'Introduce tu contraseña para desbloquear', + ready: 'Actualizado', + pending: 'Cambios locales pendientes de sincronizar', + syncing: 'Sincronizando', + conflict: 'Revisar cambios en conflicto', + failed: 'La sincronización requiere atención', + outcome_unknown: 'Resultado en la nube sin confirmar', + recovery_required: 'Se requiere recuperación local', + }, + categories: { + preferences: 'Preferencias de la aplicación', + ui_preferences: 'Interfaz', + channels: 'Canales y credenciales de servicios', + provider_credentials: 'Credenciales de servicios', + dictionary: 'Diccionario', + vocabulary_presets: 'Vocabularios personalizados', + corrections: 'Correcciones', + style_packs: 'Estilos e iconos', + history: 'Historial de textos', + activity: 'Estadísticas de actividad', + device_profile: 'Perfiles de dispositivos', + other: 'Otros datos sincronizados', + }, + conflictReasons: { + both_modified: 'Se modificaron las versiones local y de la nube.', + delete_modify: 'Se eliminó una versión y se modificó la otra.', + no_common_baseline: 'No hay una base común. Elige la versión que quieres conservar.', + other: 'Elige la versión local o de la nube. No se muestran los valores.', + }, + errors: { + unknown: 'No se pudo completar la operación. Actualiza el estado y vuelve a intentarlo.', + signIn: 'Vuelve a iniciar sesión con GitHub.', + unlock: 'Primero desbloquea el dispositivo con la contraseña de sincronización.', + unavailable: + 'La sincronización cifrada no está disponible. Usa la aplicación nativa y comprueba el servicio más tarde.', + weakPassword: 'Elige una contraseña más fuerte que cumpla los requisitos.', + passwordMismatch: 'Las contraseñas no coinciden.', + invalidPassword: + 'La contraseña no desbloqueó la copia o no se pudieron verificar los datos cifrados.', + secureStorage: + 'Acceso al almacenamiento seguro denegado. Revisa permisos o desbloquea sin recordar la clave.', + changed: 'Los datos cambiaron. Cierra el diálogo, actualiza y revisa una nueva vista previa.', + accountChanged: 'La cuenta GitHub cambió. Actualiza antes de continuar.', + busy: 'Hay otra tarea de sincronización en curso. Espera o cancélala.', + cancelled: 'Tarea cancelada.', + network: 'No se pudo conectar con el servicio. Comprueba la conexión y vuelve a intentarlo.', + rateLimited: 'Demasiadas solicitudes. Espera antes de reintentar.', + outcomeUnknown: + 'Resultado sin confirmar. Comprueba la operación pendiente antes de crear otra copia.', + recovery: + 'Se necesita recuperación local. Conserva los datos de este dispositivo y no sobrescribas la copia en la nube.', + rolledBack: + 'La restauración falló y se revirtieron los cambios locales. Actualiza antes de reintentar.', + tooLarge: 'La copia cifrada supera el límite de tamaño del servicio.', + reviewRequired: 'Revisa el alcance y la vista previa de restauración antes de continuar.', + choicesRequired: 'Elige local o nube para cada conflicto.', + invalidData: 'No se pudo verificar la copia cifrada. No se reemplazaron datos locales.', + localData: + 'No se pudieron preparar los datos locales de forma segura. Actualiza y vuelve a intentarlo.', + }, + }, cloudSync: { title: 'Sincronización en la nube', description: @@ -110,6 +265,18 @@ export const es: typeof zhCN = { cancel: 'Cancelar', }, qa: { + compact: { + closeError: 'No se pudo cerrar. Inténtalo de nuevo.', + sendError: 'No se pudo enviar. Tu texto se conserva. Inténtalo de nuevo.', + microphoneError: 'La acción del micrófono ha fallado. Inténtalo de nuevo.', + modeError: 'No se cambió el modo de edición. Inténtalo de nuevo.', + conversation: 'Conversación actual', + sending: 'Enviando tu pregunta…', + addUnavailable: 'Añadir contenido · aún no disponible', + browserUnavailable: 'La vista previa no ejecuta comandos', + answer: 'Respuesta de OpenLess', + layoutError: 'No se pudo ampliar la ventana. Ciérrala y vuelve a abrirla.', + }, title: 'Preguntar', headerHint: 'Pregunta cuando quieras', thinking: 'Pensando…', @@ -142,6 +309,48 @@ export const es: typeof zhCN = { editInstructionMode: 'Instrucción de edición', }, lessComputer: { + desktop: { + approvedSubmitted: 'Aprobación enviada', + deniedSubmitted: 'Rechazo enviado', + agents: 'Tus agentes', + configured: 'Agente configurado', + agentSettings: 'Elegir en Ajustes', + historyUnavailable: 'Historial · no disponible', + sessionUnavailable: 'Nueva sesión · no disponible', + agentHint: 'Elige tu agente en Ajustes.', + workspace: 'Espacio de trabajo', + multitask: 'Varias tareas', + unavailable: 'Aún no disponible', + historyHint: + 'El historial y las sesiones múltiples aún no están disponibles para estos cuatro agentes.', + signedIn: 'Sesión iniciada', + signIn: 'Iniciar sesión', + currentSession: 'Conversación actual', + minimize: 'Minimizar', + maximize: 'Maximizar / restaurar', + windowError: 'La acción de la ventana ha fallado. Inténtalo de nuevo.', + emptyHint: 'Describe una tarea o usa tu atajo de voz configurado para empezar.', + voiceHint: 'Mantén pulsado tu atajo de voz', + idle: 'Esperando una tarea', + waitingApproval: 'Esperando aprobación', + submittingApproval: 'Enviando…', + approvalError: 'No se ha enviado la aprobación. Inténtalo de nuevo.', + approvalExpired: 'Este turno ha terminado. Ya no se puede aprobar.', + sendError: 'No se pudo enviar. Tu texto sigue aquí. Inténtalo de nuevo.', + browserUnavailable: 'Esta vista previa no ejecuta comandos. Usa la aplicación de escritorio.', + cancelHint: 'Cancela una tarea en curso desde la cápsula.', + inputHint: 'Intro para enviar · Mayús + Intro para una nueva línea', + activity: 'Actividad de la tarea', + noActivity: 'Aún no hay actividad', + activityHint: + 'Las llamadas a herramientas y las aprobaciones aparecerán al iniciar una tarea.', + currentTask: 'Tarea actual', + toolActive: 'Llamada en curso', + toolReported: 'Llamada recibida', + approval: 'Aprobación de acción', + apiCost: 'Coste de API de este turno', + activitySource: 'Sigue la tarea actual. El coste solo aparece si el servicio lo comunica.', + }, title: 'Less Computer', subtitle: '¿Qué quieres que haga tu ordenador?', you: 'Tú', @@ -1483,15 +1692,16 @@ export const es: typeof zhCN = { bailianVocabularyIdLabel: 'ID del vocabulario de palabras clave (opcional)', bailianVocabularyIdNote: 'Si creaste un vocabulario en DashScope, introduce su ID vocab-... Déjalo vacío para no usar palabras clave.', - bailianProtocolLabel: "Tipo de API", - bailianProtocolNote: "La selección manual prevalece sobre el nombre del modelo y se guarda por canal para validar y grabar. Consulte la documentación del modelo.", + bailianProtocolLabel: 'Tipo de API', + bailianProtocolNote: + 'La selección manual prevalece sobre el nombre del modelo y se guarda por canal para validar y grabar. Consulte la documentación del modelo.', bailianProtocolOptions: { - "auto": "Automático", - "dashscope-realtime": "Tiempo real (DashScope)", - "qwen-realtime": "Tiempo real (Qwen Realtime)", - "multimodal": "Síncrono no en tiempo real (Fun-ASR / Qwen-Audio)", - "qwen-multimodal": "Síncrono no en tiempo real (Qwen3-ASR)", - "async-transcription": "Asíncrono (transcripción de archivos)", + auto: 'Automático', + 'dashscope-realtime': 'Tiempo real (DashScope)', + 'qwen-realtime': 'Tiempo real (Qwen Realtime)', + multimodal: 'Síncrono no en tiempo real (Fun-ASR / Qwen-Audio)', + 'qwen-multimodal': 'Síncrono no en tiempo real (Qwen3-ASR)', + 'async-transcription': 'Asíncrono (transcripción de archivos)', }, bailianModelRealtimeHint: 'Modelo en tiempo real: transcribe mientras hablas.', bailianModelSyncFileHint: diff --git a/openless-all/app/src/i18n/fr.ts b/openless-all/app/src/i18n/fr.ts index 0030543a..0539bb07 100644 --- a/openless-all/app/src/i18n/fr.ts +++ b/openless-all/app/src/i18n/fr.ts @@ -1,6 +1,164 @@ import type { zhCN } from './zh-CN'; export const fr: typeof zhCN = { + cloudSyncE2ee: { + setupPromptTitle: 'Sauvegarder votre configuration ?', + setupPromptBody: + 'Les services sont configurés. Réglages, clés API et historique texte sont chiffrés sur cet appareil. L’envoi commence uniquement après examen du périmètre et activation.', + setupPromptLater: 'Pas maintenant', + setupPromptOpen: 'Découvrir la synchronisation', + title: 'Synchronisation cloud chiffrée', + description: 'Chiffrez sur cet appareil avant de synchroniser avec votre compte GitHub.', + enable: 'Activer la synchronisation chiffrée', + refresh: 'Actualiser l’état', + loading: 'Vérification de la synchronisation…', + signIn: 'Se connecter avec GitHub', + signOut: 'Se déconnecter', + account: 'Compte de synchronisation', + keyLocked: 'Verrouillé sur cet appareil', + keyUnlocked: 'Déverrouillé sur cet appareil', + hasSnapshot: 'Une sauvegarde cloud chiffrée est disponible.', + noSnapshot: 'Aucune sauvegarde cloud chiffrée pour le moment.', + snapshotUnknown: 'La sauvegarde cloud n’a pas encore été vérifiée.', + lastSync: 'Dernière synchronisation : {{time}}', + syncNow: 'Synchroniser maintenant', + checkPending: 'Vérifier le résultat en attente', + unlock: 'Déverrouiller', + lock: 'Verrouiller cet appareil', + changePassword: 'Modifier le mot de passe', + restore: 'Examiner la restauration cloud', + delete: 'Supprimer la sauvegarde cloud', + working: 'Traitement en cours…', + cancelTask: 'Annuler la tâche en cours', + cancelRequested: 'Annulation demandée. Fin de la tâche en cours.', + done: 'Paramètres de synchronisation mis à jour.', + restored: 'Données cloud restaurées. Vérifiez les réglages propres à cet appareil.', + retryAfter: 'Réessayez dans {{seconds}} secondes.', + passwordWarning: + 'Conservez votre mot de passe. Il est irrécupérable ; sa perte peut rendre les données cloud illisibles.', + consentTitle: 'Activer la synchronisation chiffrée de bout en bout', + consentDescription: + 'La synchronisation inclut les identifiants de services et l’historique de textes privés. Les données sont chiffrées sur l’appareil avant envoi. Consultez le périmètre complet.', + scopeSummary: 'Voir le périmètre complet', + consentCheck: + 'Je comprends le périmètre et j’accepte la synchronisation chiffrée de ces données.', + continue: 'Continuer', + createTitle: 'Créer un mot de passe de synchronisation', + unlockTitle: 'Déverrouiller la sauvegarde cloud', + passwordTitle: 'Modifier le mot de passe de synchronisation', + create: 'Créer une sauvegarde chiffrée', + password: 'Mot de passe de synchronisation', + newPassword: 'Nouveau mot de passe', + currentPassword: 'Mot de passe actuel', + confirmPassword: 'Confirmer le nouveau mot de passe', + passwordPolicy: + '12 à 128 caractères avec majuscule, minuscule et chiffre. Évitez les mots de passe courants.', + rememberKey: 'Mémoriser la clé dans le stockage sécurisé de cet appareil', + disableTitle: 'Désactiver la synchronisation chiffrée ?', + disableDescription: + 'Les synchronisations cessent. La sauvegarde cloud et les données locales sont conservées. La suppression est une action distincte.', + confirmDisable: 'Désactiver la synchronisation', + deleteTitle: 'Supprimer la sauvegarde cloud chiffrée ?', + deleteDescription: + 'Seule la sauvegarde cloud de ce compte sera supprimée. Les données locales restent. Une nouvelle sauvegarde sera nécessaire pour un autre appareil.', + backupRetention: + 'Après suppression, les copies de sauvegarde du service peuvent être conservées jusqu’à {{days}} jours.', + deleteCheck: 'Je confirme la suppression de cette sauvegarde cloud.', + confirmDelete: 'Supprimer la sauvegarde cloud', + restoreTitle: 'Examiner avant de restaurer', + restoreDescription: + 'Vérifiez les catégories. Les valeurs sensibles restent masquées. Rien n’est restauré avant confirmation.', + deviceReview: + 'Après restauration, vérifiez {{count}} réglages de l’appareil : raccourcis, chemins des modèles, microphones et autorisations système.', + restoreMode: 'Mode de restauration', + merge: 'Fusionner et résoudre les conflits', + replace: 'Remplacer les données locales synchronisées par le cloud', + replaceWarning: + 'Les données locales du périmètre seront remplacées. Choisissez la fusion pour conserver vos modifications locales.', + restoreCheck: 'J’ai vérifié le mode et mes choix pour les conflits.', + applyRestore: 'Confirmer la restauration', + conflictProgress: '{{selected}} conflits résolus sur {{total}}', + conflictItem: 'Conflit {{index}} · {{category}}', + conflictLocal: 'Garder la version locale', + conflictCloud: 'Utiliser le cloud', + previous: 'Précédent', + next: 'Suivant', + scope: { + preferences: 'Préférences de l’application et de l’interface.', + credentials: + 'Canaux ASR, LLM et Omni, avec clés API, identifiants, clés d’accès et clés secrètes.', + personal: + 'Dictionnaire, listes de vocabulaire personnalisées, corrections, styles et icônes.', + history: + 'Tout l’historique textuel de dictée et de notes rapides, ainsi que les statistiques d’activité.', + device: 'Profils de configuration à vérifier sur l’appareil de destination.', + excluded: + 'Exclus : sessions OAuth, mots de passe de synchronisation ou clés dérivées, fichiers d’identifiants système, PIN, autorisations, enregistrements audio/vidéo originaux et poids des modèles.', + }, + states: { + disabled: 'Synchronisation désactivée', + sign_in_required: 'Connexion GitHub requise', + unlock_required: 'Saisissez le mot de passe pour déverrouiller', + ready: 'À jour', + pending: 'Modifications locales en attente', + syncing: 'Synchronisation en cours', + conflict: 'Examiner les conflits', + failed: 'La synchronisation nécessite votre attention', + outcome_unknown: 'Résultat cloud à confirmer', + recovery_required: 'Récupération locale requise', + }, + categories: { + preferences: 'Préférences de l’application', + ui_preferences: 'Interface', + channels: 'Canaux et identifiants de services', + provider_credentials: 'Identifiants de services', + dictionary: 'Dictionnaire', + vocabulary_presets: 'Vocabulaire personnalisé', + corrections: 'Corrections', + style_packs: 'Styles et icônes', + history: 'Historique textuel', + activity: 'Statistiques d’activité', + device_profile: 'Profils des appareils', + other: 'Autres données synchronisées', + }, + conflictReasons: { + both_modified: 'Les versions locale et cloud ont été modifiées.', + delete_modify: 'Une version a été supprimée, l’autre modifiée.', + no_common_baseline: 'Aucune base commune. Choisissez la version à conserver.', + other: 'Choisissez la version locale ou cloud. Les valeurs restent masquées.', + }, + errors: { + unknown: 'L’opération n’a pas abouti. Actualisez l’état et réessayez.', + signIn: 'Reconnectez-vous à GitHub.', + unlock: 'Déverrouillez d’abord cet appareil avec votre mot de passe.', + unavailable: + 'Synchronisation chiffrée indisponible. Utilisez l’application native et vérifiez le service plus tard.', + weakPassword: 'Choisissez un mot de passe plus fort respectant les critères.', + passwordMismatch: 'Les mots de passe ne correspondent pas.', + invalidPassword: + 'Le mot de passe n’a pas déverrouillé la sauvegarde, ou les données chiffrées sont invérifiables.', + secureStorage: + 'Accès au stockage sécurisé refusé. Vérifiez les autorisations ou déverrouillez sans mémoriser la clé.', + changed: 'Les données ont changé. Fermez, actualisez et examinez un nouvel aperçu.', + accountChanged: 'Le compte GitHub a changé. Actualisez avant de continuer.', + busy: 'Une tâche de synchronisation est en cours. Attendez ou annulez-la.', + cancelled: 'Tâche annulée.', + network: 'Service de synchronisation inaccessible. Vérifiez votre connexion et réessayez.', + rateLimited: 'Trop de demandes. Attendez avant de réessayer.', + outcomeUnknown: + 'Résultat cloud non confirmé. Vérifiez l’opération en attente avant une nouvelle sauvegarde.', + recovery: + 'Une récupération locale est nécessaire. Conservez les données de cet appareil sans écraser la sauvegarde cloud.', + rolledBack: + 'La restauration a échoué ; les modifications locales ont été annulées. Actualisez avant de réessayer.', + tooLarge: 'La sauvegarde chiffrée dépasse la limite de taille du service.', + reviewRequired: 'Examinez le périmètre et l’aperçu de restauration avant de continuer.', + choicesRequired: 'Choisissez local ou cloud pour chaque conflit.', + invalidData: + 'Sauvegarde chiffrée invérifiable. Les données locales n’ont pas été remplacées.', + localData: 'Impossible de préparer les données locales en sécurité. Actualisez et réessayez.', + }, + }, cloudSync: { title: 'Synchronisation cloud', description: @@ -110,6 +268,18 @@ export const fr: typeof zhCN = { cancel: 'Annuler', }, qa: { + compact: { + closeError: 'Impossible de fermer. Réessayez.', + sendError: 'Échec de l’envoi. Votre saisie est conservée. Réessayez.', + microphoneError: 'L’action du microphone a échoué. Réessayez.', + modeError: 'Le mode d’édition n’a pas été modifié. Réessayez.', + conversation: 'Conversation actuelle', + sending: 'Envoi de votre question…', + addUnavailable: 'Ajouter du contenu · pas encore disponible', + browserUnavailable: 'L’aperçu n’exécute aucune commande', + answer: 'Réponse d’OpenLess', + layoutError: 'Impossible d’agrandir la fenêtre. Fermez-la, puis rouvrez-la.', + }, title: 'Questions', headerHint: 'Posez une question à tout moment', thinking: 'Réflexion…', @@ -142,6 +312,50 @@ export const fr: typeof zhCN = { editInstructionMode: 'Instruction de modification', }, lessComputer: { + desktop: { + approvedSubmitted: 'Approbation envoyée', + deniedSubmitted: 'Refus envoyé', + agents: 'Vos agents', + configured: 'Agent configuré', + agentSettings: 'Choisir dans Réglages', + historyUnavailable: 'Historique · indisponible', + sessionUnavailable: 'Nouvelle session · indisponible', + agentHint: 'Choisissez votre agent dans les réglages.', + workspace: 'Espace de travail', + multitask: 'Plusieurs tâches', + unavailable: 'Pas encore disponible', + historyHint: + 'L’historique et les sessions multiples ne sont pas encore disponibles pour ces quatre agents.', + signedIn: 'Connecté', + signIn: 'Se connecter', + currentSession: 'Conversation actuelle', + minimize: 'Réduire', + maximize: 'Agrandir / restaurer', + windowError: 'L’action sur la fenêtre a échoué. Réessayez.', + emptyHint: 'Décrivez une tâche ou utilisez votre raccourci vocal configuré pour commencer.', + voiceHint: 'Maintenez votre raccourci vocal', + idle: 'En attente d’une tâche', + waitingApproval: 'En attente d’approbation', + submittingApproval: 'Envoi en cours…', + approvalError: 'L’approbation n’a pas été envoyée. Réessayez.', + approvalExpired: 'Ce tour est terminé. L’approbation n’est plus disponible.', + sendError: 'Échec de l’envoi. Votre saisie est conservée. Réessayez.', + browserUnavailable: + 'Cet aperçu dans le navigateur n’exécute aucune commande. Utilisez l’application de bureau.', + cancelHint: 'Annulez une tâche en cours depuis la capsule.', + inputHint: 'Entrée pour envoyer · Maj + Entrée pour une nouvelle ligne', + activity: 'Activité de la tâche', + noActivity: 'Aucune activité pour l’instant', + activityHint: + 'Les appels d’outils et les approbations apparaîtront ici au début d’une tâche.', + currentTask: 'Tâche actuelle', + toolActive: 'Appel en cours', + toolReported: 'Appel reçu', + approval: 'Approbation d’action', + apiCost: 'Coût API de ce tour', + activitySource: + 'Suit la tâche actuelle. Le coût s’affiche uniquement lorsque le service le communique.', + }, title: 'Less Computer', subtitle: 'Que doit faire votre ordinateur ?', you: 'Vous', @@ -1503,15 +1717,16 @@ export const fr: typeof zhCN = { bailianVocabularyIdLabel: 'ID du vocabulaire de mots-clés (facultatif)', bailianVocabularyIdNote: 'Si vous avez créé un vocabulaire dans DashScope, saisissez son ID vocab-... Laissez vide pour ne pas utiliser de mots-clés.', - bailianProtocolLabel: "Type d’API", - bailianProtocolNote: "Le choix manuel remplace la détection par nom et est enregistré par canal pour la validation et l’enregistrement. Consultez la documentation du modèle.", + bailianProtocolLabel: 'Type d’API', + bailianProtocolNote: + 'Le choix manuel remplace la détection par nom et est enregistré par canal pour la validation et l’enregistrement. Consultez la documentation du modèle.', bailianProtocolOptions: { - "auto": "Automatique", - "dashscope-realtime": "Temps réel (DashScope)", - "qwen-realtime": "Temps réel (Qwen Realtime)", - "multimodal": "Synchrone hors temps réel (Fun-ASR / Qwen-Audio)", - "qwen-multimodal": "Synchrone hors temps réel (Qwen3-ASR)", - "async-transcription": "Asynchrone (transcription de fichier)", + auto: 'Automatique', + 'dashscope-realtime': 'Temps réel (DashScope)', + 'qwen-realtime': 'Temps réel (Qwen Realtime)', + multimodal: 'Synchrone hors temps réel (Fun-ASR / Qwen-Audio)', + 'qwen-multimodal': 'Synchrone hors temps réel (Qwen3-ASR)', + 'async-transcription': 'Asynchrone (transcription de fichier)', }, bailianModelRealtimeHint: 'Modèle en temps réel : transcrit pendant que vous parlez.', bailianModelSyncFileHint: diff --git a/openless-all/app/src/i18n/index.ts b/openless-all/app/src/i18n/index.ts index f9f72963..078247f4 100644 --- a/openless-all/app/src/i18n/index.ts +++ b/openless-all/app/src/i18n/index.ts @@ -117,6 +117,7 @@ export function getLocalePreference(): SupportedLocale | typeof FOLLOW_SYSTEM_VA */ export async function setLocalePreference( pref: SupportedLocale | typeof FOLLOW_SYSTEM_VALUE, + source: 'user' | 'sync-restore' = 'user', ): Promise { const resolved = resolveLocalePreference(pref); if (pref === FOLLOW_SYSTEM_VALUE) { @@ -124,6 +125,7 @@ export async function setLocalePreference( } else { window.localStorage.setItem(LOCALE_STORAGE_KEY, pref); } + window.dispatchEvent(new CustomEvent('openless:ui-preferences-changed', { detail: { source, key: 'locale' } })); await applyLocale(resolved); return resolved; } diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index 7dece6e8..0248baa2 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -6,6 +6,159 @@ import { en } from './en'; // 更新してください(更新されない key は ...en で英語にフォールバックします)。 export const ja: typeof zhCN = { ...en, + cloudSyncE2ee: { + setupPromptTitle: '設定を暗号化してバックアップしますか?', + setupPromptBody: + 'サービスの設定が完了しました。設定、API キー、テキスト履歴はこの端末で暗号化されます。対象を確認して同期を有効にした後にのみアップロードされます。', + setupPromptLater: '今はしない', + setupPromptOpen: '暗号化同期について', + title: '暗号化クラウド同期', + description: 'この端末で暗号化してから、GitHub アカウントで端末間を同期します。', + enable: '暗号化同期を有効にする', + refresh: '状態を更新', + loading: '同期状態を確認中…', + signIn: 'GitHub でログイン', + signOut: 'ログアウト', + account: '同期アカウント', + keyLocked: 'この端末ではロック中', + keyUnlocked: 'この端末でロック解除済み', + hasSnapshot: '暗号化されたクラウドバックアップがあります。', + noSnapshot: '暗号化クラウドバックアップはまだありません。', + snapshotUnknown: 'クラウドバックアップは未確認です。', + lastSync: '最終同期:{{time}}', + syncNow: '今すぐ同期', + checkPending: '未確定の結果を確認', + unlock: 'ロック解除', + lock: 'この端末をロック', + changePassword: '同期パスワードを変更', + restore: 'クラウドからの復元を確認', + delete: 'クラウドバックアップを削除', + working: '処理中…', + cancelTask: '現在の処理をキャンセル', + cancelRequested: 'キャンセルを要求しました。処理の終了を待っています。', + done: '同期設定を更新しました。', + restored: 'クラウドのデータを復元しました。この端末固有の設定を確認してください。', + retryAfter: '{{seconds}} 秒後に再試行してください。', + passwordWarning: + '同期パスワードは安全に保管してください。再発行できず、紛失するとクラウドのデータを読めなくなる場合があります。', + consentTitle: 'エンドツーエンド暗号化同期を有効にする', + consentDescription: + 'サービスの認証情報と非公開のテキスト履歴も同期します。アップロード前に端末で暗号化します。対象範囲を確認してください。', + scopeSummary: '同期する全データを確認', + consentCheck: '対象範囲を理解し、これらのデータの暗号化同期に同意します。', + continue: '続ける', + createTitle: '同期パスワードを設定', + unlockTitle: 'クラウドバックアップを解除', + passwordTitle: '同期パスワードを変更', + create: '暗号化バックアップを作成', + password: '同期パスワード', + newPassword: '新しい同期パスワード', + currentPassword: '現在の同期パスワード', + confirmPassword: '新しいパスワードを再入力', + passwordPolicy: + '12~128 文字で英大文字・英小文字・数字を含めてください。よくあるパスワードは避けてください。', + rememberKey: 'この端末の安全なストレージに解除キーを記憶する', + disableTitle: '暗号化同期をオフにしますか?', + disableDescription: + '以後の同期を停止します。クラウドのバックアップとローカルのデータは残ります。削除は別の操作です。', + confirmDisable: '同期をオフにする', + deleteTitle: '暗号化クラウドバックアップを削除しますか?', + deleteDescription: + 'このアカウントのクラウドバックアップのみ削除します。ローカルデータは残ります。別の端末で復元するには新しいバックアップが必要です。', + backupRetention: + '削除後も、サービスのバックアップコピーは最長 {{days}} 日間保持される場合があります。', + deleteCheck: 'このクラウドバックアップの削除を確認します。', + confirmDelete: 'クラウドバックアップを削除', + restoreTitle: '復元前に確認', + restoreDescription: + 'データの種類を確認してください。機密の値は表示しません。確認するまで復元しません。', + deviceReview: + '復元後は、ショートカット、モデルの場所、マイク、システム権限など {{count}} 件の端末設定を確認してください。', + restoreMode: '復元方法', + merge: 'マージして競合を解決', + replace: '同期対象のローカルデータをクラウドの内容で置換', + replaceWarning: + '同期範囲のローカルデータを置き換えます。ローカルの変更を残すにはマージを選んでください。', + restoreCheck: '復元方法と競合の選択を確認しました。', + applyRestore: '復元を確認', + conflictProgress: '{{total}} 件中 {{selected}} 件を選択済み', + conflictItem: '競合 {{index}} · {{category}}', + conflictLocal: 'ローカルを使用', + conflictCloud: 'クラウドを使用', + previous: '前へ', + next: '次へ', + scope: { + preferences: 'アプリの環境設定と表示設定。', + credentials: + 'ASR・LLM・Omni の接続設定と、API キー、ID、アクセスキー、シークレットキーなどの認証情報。', + personal: '辞書、カスタム語彙プリセット、修正ルール、スタイルパックとアイコン。', + history: 'すべての音声入力・クイックメモのテキスト履歴と利用統計。', + device: '復元先の端末で確認するデバイス設定プロファイル。', + excluded: + 'OAuth ログイン状態、同期パスワード・派生キー、OS の認証情報ファイル、PIN、端末の権限、元の録音・動画、モデルの重みは含みません。', + }, + states: { + disabled: '同期はオフです', + sign_in_required: 'GitHub ログインが必要です', + unlock_required: 'パスワードを入力して解除してください', + ready: '同期済み', + pending: 'ローカルの変更は同期待ちです', + syncing: '同期中', + conflict: '競合する変更を確認してください', + failed: '同期の確認が必要です', + outcome_unknown: 'クラウドの結果は未確定です', + recovery_required: 'ローカル状態の復旧が必要です', + }, + categories: { + preferences: 'アプリ設定', + ui_preferences: '表示設定', + channels: 'サービス接続と認証情報', + provider_credentials: 'サービス認証情報', + dictionary: '辞書', + vocabulary_presets: 'カスタム語彙プリセット', + corrections: '修正ルール', + style_packs: 'スタイルとアイコン', + history: 'テキスト履歴', + activity: '利用統計', + device_profile: '端末設定プロファイル', + other: 'その他の同期データ', + }, + conflictReasons: { + both_modified: 'ローカルとクラウドの両方で変更されています。', + delete_modify: '一方で削除され、もう一方で変更されています。', + no_common_baseline: '共通の同期履歴がありません。残す側を選んでください。', + other: 'ローカルかクラウドを選択してください。値は表示しません。', + }, + errors: { + unknown: '操作を完了できませんでした。状態を更新して再試行してください。', + signIn: 'GitHub に再ログインしてください。', + unlock: '同期パスワードでこの端末を解除してください。', + unavailable: + '暗号化同期は利用できません。ネイティブアプリで後ほどサービスを確認してください。', + weakPassword: '長さと文字種の条件を満たす、推測されにくいパスワードにしてください。', + passwordMismatch: 'パスワードが一致しません。', + invalidPassword: 'このパスワードで解除できないか、暗号化データを検証できませんでした。', + secureStorage: + '安全なストレージへのアクセスが拒否されました。権限を確認するか、キーを記憶せずに解除してください。', + changed: + '確認中にデータが変わりました。閉じて状態を更新し、再度プレビューを取得してください。', + accountChanged: 'GitHub アカウントが変わりました。更新してから続けてください。', + busy: '別の同期処理が実行中です。待つか先にキャンセルしてください。', + cancelled: '処理をキャンセルしました。', + network: '同期サービスに接続できません。ネットワークを確認して再試行してください。', + rateLimited: 'リクエストが多すぎます。しばらく待ってから再試行してください。', + outcomeUnknown: + 'クラウドの結果が未確定です。新しいバックアップを作る前に未確定の結果を確認してください。', + recovery: + 'ローカル状態の復旧が必要です。この端末のデータを保持し、クラウドを上書きしないでください。', + rolledBack: '復元に失敗し、ローカルの変更を元に戻しました。更新してから再試行してください。', + tooLarge: '暗号化バックアップがサービスのサイズ上限を超えています。', + reviewRequired: '続ける前に同期対象と復元プレビューを確認してください。', + choicesRequired: 'すべての競合でローカルかクラウドを選択してください。', + invalidData: '暗号化バックアップを検証できませんでした。ローカルデータは置き換えていません。', + localData: 'ローカルデータを安全に準備できませんでした。更新して再試行してください。', + }, + }, cloudSync: { title: 'クラウド同期', description: 'GitHub アカウントで辞書、スタイル、個人設定をデバイス間で同期します。', @@ -101,6 +254,18 @@ export const ja: typeof zhCN = { cancel: 'キャンセル', }, qa: { + compact: { + closeError: '閉じられませんでした。再試行してください。', + sendError: '送信できませんでした。入力は保持されています。再試行してください。', + microphoneError: 'マイク操作に失敗しました。再試行してください。', + modeError: '編集モードを変更できませんでした。再試行してください。', + conversation: '現在の会話', + sending: '質問を送信中…', + addUnavailable: 'コンテンツを追加 · 未対応', + browserUnavailable: 'プレビューでは実行できません', + answer: 'OpenLess の回答', + layoutError: 'ウィンドウを展開できませんでした。閉じてから開き直してください。', + }, title: '質問', headerHint: 'いつでも質問', thinking: '思考中…', @@ -133,6 +298,48 @@ export const ja: typeof zhCN = { editInstructionMode: '編集指示', }, lessComputer: { + desktop: { + approvedSubmitted: '許可を送信しました', + deniedSubmitted: '拒否を送信しました', + agents: 'エージェント', + configured: '現在の設定', + agentSettings: '設定で選択', + historyUnavailable: '履歴 · 未対応', + sessionUnavailable: '新しい会話 · 未対応', + agentHint: '使用するエージェントは設定で選択します。', + workspace: 'ワークスペース', + multitask: '複数のタスク', + unavailable: 'まだ利用できません', + historyHint: '4つのエージェントの履歴と複数会話には、まだ対応していません。', + signedIn: 'ログイン済み', + signIn: 'ログイン', + currentSession: '現在の会話', + minimize: '最小化', + maximize: '最大化 / 元に戻す', + windowError: 'ウィンドウ操作に失敗しました。再試行してください。', + emptyHint: '具体的なタスクを入力するか、設定した音声ショートカットで始めましょう。', + voiceHint: '音声ショートカットを長押し', + idle: '指示を待っています', + waitingApproval: '承認待ち', + submittingApproval: '送信中…', + approvalError: '承認を送信できませんでした。再試行してください。', + approvalExpired: 'このターンは終了しました。承認はできません。', + sendError: '送信できませんでした。入力は保持されています。再試行してください。', + browserUnavailable: + 'ブラウザのプレビューでは実行できません。デスクトップアプリをご利用ください。', + cancelHint: '実行中のタスクはカプセルからキャンセルできます。', + inputHint: 'Enter で送信 · Shift + Enter で改行', + activity: 'タスクの動き', + noActivity: 'まだ動きはありません', + activityHint: 'タスクが始まると、ツール呼び出しと承認がここに表示されます。', + currentTask: '現在のタスク', + toolActive: '呼び出し中', + toolReported: '呼び出しを受信', + approval: '操作の承認', + apiCost: '今回の API 料金', + activitySource: + '現在のタスクに合わせて更新。料金はサービスから報告された場合のみ表示します。', + }, title: 'Less Computer', subtitle: 'コンピュータに何をさせますか?', you: 'あなた', @@ -1448,15 +1655,16 @@ export const ja: typeof zhCN = { bailianVocabularyIdLabel: 'ホットワード Vocabulary ID(任意)', bailianVocabularyIdNote: 'DashScope でホットワード辞書を作成済みの場合は vocab-... ID を入力します。空欄なら送信しません。', - bailianProtocolLabel: "API の種類", - bailianProtocolNote: "手動選択はモデル名による判定より優先され、検証と録音用にチャネルごとに保存されます。モデルのドキュメントに従って選択してください。", + bailianProtocolLabel: 'API の種類', + bailianProtocolNote: + '手動選択はモデル名による判定より優先され、検証と録音用にチャネルごとに保存されます。モデルのドキュメントに従って選択してください。', bailianProtocolOptions: { - "auto": "自動判定", - "dashscope-realtime": "リアルタイム(DashScope)", - "qwen-realtime": "リアルタイム(Qwen Realtime)", - "multimodal": "非リアルタイム同期(Fun-ASR / Qwen-Audio)", - "qwen-multimodal": "非リアルタイム同期(Qwen3-ASR)", - "async-transcription": "非リアルタイム非同期(ファイル文字起こし)", + auto: '自動判定', + 'dashscope-realtime': 'リアルタイム(DashScope)', + 'qwen-realtime': 'リアルタイム(Qwen Realtime)', + multimodal: '非リアルタイム同期(Fun-ASR / Qwen-Audio)', + 'qwen-multimodal': '非リアルタイム同期(Qwen3-ASR)', + 'async-transcription': '非リアルタイム非同期(ファイル文字起こし)', }, bailianModelRealtimeHint: 'リアルタイムモデル · 話しながら文字起こし。', bailianModelSyncFileHint: '同期録音モデル · 話し終えてから一括で文字起こし(1 本 ≤ 5 分)。', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index f91eab8b..fc5b4eb3 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -6,6 +6,157 @@ import { en } from './en'; // 갱신해 주세요(갱신되지 않은 key 는 ...en 으로 영어로 fallback 됩니다). export const ko: typeof zhCN = { ...en, + cloudSyncE2ee: { + setupPromptTitle: '설정을 암호화해 백업할까요?', + setupPromptBody: + '서비스 설정이 완료되었습니다. 설정, API 키, 텍스트 기록은 이 기기에서 암호화됩니다. 범위를 확인하고 동기화를 켠 후에만 업로드됩니다.', + setupPromptLater: '나중에', + setupPromptOpen: '암호화 동기화 알아보기', + title: '암호화 클라우드 동기화', + description: '이 기기에서 암호화한 후 GitHub 계정으로 기기 간에 동기화합니다.', + enable: '암호화 동기화 사용', + refresh: '상태 새로고침', + loading: '동기화 상태 확인 중…', + signIn: 'GitHub로 로그인', + signOut: '로그아웃', + account: '동기화 계정', + keyLocked: '이 기기에서 잠김', + keyUnlocked: '이 기기에서 잠금 해제됨', + hasSnapshot: '암호화된 클라우드 백업이 있습니다.', + noSnapshot: '아직 암호화된 클라우드 백업이 없습니다.', + snapshotUnknown: '클라우드 백업을 아직 확인하지 않았습니다.', + lastSync: '마지막 동기화: {{time}}', + syncNow: '지금 동기화', + checkPending: '확인 대기 결과 조회', + unlock: '잠금 해제', + lock: '이 기기 잠그기', + changePassword: '동기화 비밀번호 변경', + restore: '클라우드 복원 검토', + delete: '클라우드 백업 삭제', + working: '처리 중…', + cancelTask: '현재 작업 취소', + cancelRequested: '취소를 요청했습니다. 작업이 종료되기를 기다립니다.', + done: '동기화 설정을 업데이트했습니다.', + restored: '클라우드 데이터를 복원했습니다. 이 기기의 전용 설정을 확인하세요.', + retryAfter: '{{seconds}}초 후 다시 시도하세요.', + passwordWarning: + '동기화 비밀번호를 안전하게 보관하세요. 복구할 수 없으며, 분실하면 클라우드 데이터를 읽지 못할 수 있습니다.', + consentTitle: '종단 간 암호화 동기화 사용', + consentDescription: + '서비스 자격 증명과 비공개 텍스트 기록도 동기화됩니다. 업로드 전에 기기에서 암호화합니다. 전체 범위를 확인하세요.', + scopeSummary: '전체 동기화 범위 보기', + consentCheck: '전체 범위를 이해했으며 이 데이터를 암호화해 동기화하는 데 동의합니다.', + continue: '계속', + createTitle: '동기화 비밀번호 설정', + unlockTitle: '클라우드 백업 잠금 해제', + passwordTitle: '동기화 비밀번호 변경', + create: '암호화 백업 만들기', + password: '동기화 비밀번호', + newPassword: '새 동기화 비밀번호', + currentPassword: '현재 동기화 비밀번호', + confirmPassword: '새 비밀번호 확인', + passwordPolicy: '12~128자로 영문 대문자, 소문자, 숫자를 포함하세요. 흔한 비밀번호는 피하세요.', + rememberKey: '이 기기의 보안 저장소에 잠금 해제 키 기억', + disableTitle: '암호화 동기화를 끌까요?', + disableDescription: + '이후 동기화를 중지합니다. 클라우드 백업과 로컬 데이터는 유지됩니다. 백업 삭제는 별도 작업입니다.', + confirmDisable: '동기화 끄기', + deleteTitle: '암호화된 클라우드 백업을 삭제할까요?', + deleteDescription: + '이 계정의 클라우드 백업만 삭제합니다. 로컬 데이터는 유지됩니다. 다른 기기에서 복원하려면 새 백업이 필요합니다.', + backupRetention: '삭제 후에도 서비스의 백업 사본이 최대 {{days}}일 동안 보관될 수 있습니다.', + deleteCheck: '이 클라우드 백업 삭제에 동의합니다.', + confirmDelete: '클라우드 백업 삭제', + restoreTitle: '복원 전 검토', + restoreDescription: + '데이터 종류를 확인하세요. 민감한 값은 표시하지 않습니다. 확인 전에는 복원하지 않습니다.', + deviceReview: + '복원 후 단축키, 모델 경로, 마이크, 시스템 권한 등 기기 설정 {{count}}개를 확인하세요.', + restoreMode: '복원 방식', + merge: '병합하고 충돌 해결', + replace: '동기화 대상 로컬 데이터를 클라우드 데이터로 교체', + replaceWarning: + '동기화 범위의 로컬 데이터를 교체합니다. 로컬 변경을 유지하려면 병합을 선택하세요.', + restoreCheck: '복원 방식과 충돌 선택을 확인했습니다.', + applyRestore: '복원 확인', + conflictProgress: '충돌 {{total}}개 중 {{selected}}개 해결', + conflictItem: '충돌 {{index}} · {{category}}', + conflictLocal: '로컬 유지', + conflictCloud: '클라우드 사용', + previous: '이전', + next: '다음', + scope: { + preferences: '앱 환경설정과 인터페이스 설정.', + credentials: 'ASR·LLM·Omni 채널과 API 키, ID, 액세스 키, 시크릿 키 등의 자격 증명.', + personal: '사전, 사용자 어휘 프리셋, 교정, 스타일 팩과 아이콘.', + history: '전체 받아쓰기·빠른 메모 텍스트 기록과 활동 통계.', + device: '복원 대상 기기에서 확인할 기기 설정 프로필.', + excluded: + 'OAuth 로그인 상태, 동기화 비밀번호·파생 키, OS 자격 증명 파일, PIN, 기기 권한, 원본 녹음·동영상, 모델 가중치는 포함하지 않습니다.', + }, + states: { + disabled: '동기화 꺼짐', + sign_in_required: 'GitHub 로그인 필요', + unlock_required: '비밀번호를 입력해 잠금을 해제하세요', + ready: '최신 상태', + pending: '로컬 변경 동기화 대기 중', + syncing: '동기화 중', + conflict: '충돌하는 변경 검토 필요', + failed: '동기화 확인 필요', + outcome_unknown: '클라우드 결과 확인 대기 중', + recovery_required: '로컬 상태 복구 필요', + }, + categories: { + preferences: '앱 환경설정', + ui_preferences: '인터페이스 설정', + channels: '서비스 채널 및 자격 증명', + provider_credentials: '서비스 자격 증명', + dictionary: '사전', + vocabulary_presets: '사용자 어휘 프리셋', + corrections: '교정 규칙', + style_packs: '스타일 팩과 아이콘', + history: '텍스트 기록', + activity: '활동 통계', + device_profile: '기기 설정 프로필', + other: '기타 동기화 데이터', + }, + conflictReasons: { + both_modified: '로컬과 클라우드 양쪽에서 변경되었습니다.', + delete_modify: '한쪽에서 삭제하고 다른 쪽에서 변경했습니다.', + no_common_baseline: '공통 동기화 기준이 없습니다. 유지할 버전을 선택하세요.', + other: '로컬 또는 클라우드를 선택하세요. 실제 값은 표시하지 않습니다.', + }, + errors: { + unknown: '작업을 완료하지 못했습니다. 상태를 새로고침한 후 다시 시도하세요.', + signIn: 'GitHub에 다시 로그인하세요.', + unlock: '먼저 동기화 비밀번호로 이 기기를 잠금 해제하세요.', + unavailable: + '암호화 동기화를 사용할 수 없습니다. 네이티브 앱에서 나중에 서비스를 확인하세요.', + weakPassword: '길이와 문자 조건을 충족하는 더 강력한 비밀번호를 사용하세요.', + passwordMismatch: '두 비밀번호가 일치하지 않습니다.', + invalidPassword: + '비밀번호로 백업을 잠금 해제할 수 없거나 암호화 데이터를 검증하지 못했습니다.', + secureStorage: + '보안 저장소 접근이 거부되었습니다. 권한을 확인하거나 키를 기억하지 않고 잠금 해제하세요.', + changed: + '검토 중 데이터가 변경되었습니다. 창을 닫고 새로고침한 후 미리보기를 다시 확인하세요.', + accountChanged: 'GitHub 계정이 변경되었습니다. 새로고침한 후 계속하세요.', + busy: '다른 동기화 작업이 실행 중입니다. 기다리거나 먼저 취소하세요.', + cancelled: '작업이 취소되었습니다.', + network: '동기화 서비스에 연결할 수 없습니다. 네트워크를 확인한 후 다시 시도하세요.', + rateLimited: '요청이 너무 많습니다. 잠시 후 다시 시도하세요.', + outcomeUnknown: + '클라우드 결과가 확인되지 않았습니다. 새 백업을 만들기 전에 대기 중인 결과를 확인하세요.', + recovery: + '로컬 상태 복구가 필요합니다. 이 기기의 데이터를 보관하고 클라우드 백업을 덮어쓰지 마세요.', + rolledBack: '복원에 실패해 로컬 변경을 되돌렸습니다. 새로고침한 후 다시 시도하세요.', + tooLarge: '암호화 백업이 서비스 크기 제한을 초과합니다.', + reviewRequired: '계속하기 전에 동기화 범위와 복원 미리보기를 확인하세요.', + choicesRequired: '각 충돌에 대해 로컬 또는 클라우드를 선택하세요.', + invalidData: '암호화 백업을 검증하지 못했습니다. 로컬 데이터는 교체되지 않았습니다.', + localData: '로컬 데이터를 안전하게 준비하지 못했습니다. 새로고침한 후 다시 시도하세요.', + }, + }, cloudSync: { title: '클라우드 동기화', description: 'GitHub 계정으로 사전, 스타일, 개인 설정을 기기 간에 동기화합니다.', @@ -99,6 +250,18 @@ export const ko: typeof zhCN = { cancel: '취소', }, qa: { + compact: { + closeError: '닫지 못했습니다. 다시 시도하세요.', + sendError: '전송하지 못했습니다. 입력 내용은 유지됩니다. 다시 시도하세요.', + microphoneError: '마이크 작업에 실패했습니다. 다시 시도하세요.', + modeError: '편집 모드를 변경하지 못했습니다. 다시 시도하세요.', + conversation: '현재 대화', + sending: '질문 전송 중…', + addUnavailable: '콘텐츠 추가 · 아직 지원 안 함', + browserUnavailable: '미리보기에서는 실행할 수 없음', + answer: 'OpenLess 답변', + layoutError: '창을 확장하지 못했습니다. 닫고 다시 열어 주세요.', + }, title: '질문', headerHint: '언제든 질문하세요', thinking: '생각 중…', @@ -131,6 +294,47 @@ export const ko: typeof zhCN = { editInstructionMode: '편집 지시', }, lessComputer: { + desktop: { + approvedSubmitted: '허용을 제출했습니다', + deniedSubmitted: '거부를 제출했습니다', + agents: '내 에이전트', + configured: '현재 설정', + agentSettings: '설정에서 선택', + historyUnavailable: '기록 · 아직 지원 안 함', + sessionUnavailable: '새 대화 · 아직 지원 안 함', + agentHint: '사용할 에이전트는 설정에서 선택하세요.', + workspace: '작업 공간', + multitask: '여러 작업', + unavailable: '아직 사용할 수 없음', + historyHint: '네 에이전트의 기록과 여러 대화 기능은 아직 지원하지 않습니다.', + signedIn: '로그인됨', + signIn: '로그인', + currentSession: '현재 대화', + minimize: '최소화', + maximize: '최대화 / 복원', + windowError: '창 작업에 실패했습니다. 다시 시도하세요.', + emptyHint: '구체적인 작업을 입력하거나 설정한 음성 단축키로 시작하세요.', + voiceHint: '음성 단축키를 길게 누르세요', + idle: '지시 대기 중', + waitingApproval: '승인 대기 중', + submittingApproval: '제출 중…', + approvalError: '승인이 제출되지 않았습니다. 다시 시도하세요.', + approvalExpired: '이 차례는 종료되어 더 이상 승인할 수 없습니다.', + sendError: '전송하지 못했습니다. 입력 내용은 유지됩니다. 다시 시도하세요.', + browserUnavailable: + '브라우저 미리보기에서는 명령을 실행할 수 없습니다. 데스크톱 앱을 사용하세요.', + cancelHint: '실행 중인 작업은 캡슐에서 취소할 수 있습니다.', + inputHint: 'Enter로 전송 · Shift + Enter로 줄 바꿈', + activity: '작업 활동', + noActivity: '아직 활동이 없습니다', + activityHint: '작업이 시작되면 도구 호출과 승인이 여기에 표시됩니다.', + currentTask: '현재 작업', + toolActive: '호출 중', + toolReported: '호출 기록 수신', + approval: '작업 승인', + apiCost: '이번 API 비용', + activitySource: '현재 작업과 함께 업데이트됩니다. 비용은 서비스가 보고할 때만 표시됩니다.', + }, title: 'Less Computer', subtitle: '컴퓨터로 무엇을 할까요?', you: '나', @@ -1440,15 +1644,16 @@ export const ko: typeof zhCN = { bailianVocabularyIdLabel: '핫워드 Vocabulary ID(선택)', bailianVocabularyIdNote: 'DashScope에서 핫워드 사전을 만들었다면 vocab-... ID를 입력하세요. 비워 두면 핫워드를 전송하지 않습니다.', - bailianProtocolLabel: "API 유형", - bailianProtocolNote: "수동 선택은 모델 이름 감지보다 우선하며 검증과 녹음에 사용할 채널별 설정으로 저장됩니다. 모델 문서에 따라 선택하세요.", + bailianProtocolLabel: 'API 유형', + bailianProtocolNote: + '수동 선택은 모델 이름 감지보다 우선하며 검증과 녹음에 사용할 채널별 설정으로 저장됩니다. 모델 문서에 따라 선택하세요.', bailianProtocolOptions: { - "auto": "자동 감지", - "dashscope-realtime": "실시간 (DashScope)", - "qwen-realtime": "실시간 (Qwen Realtime)", - "multimodal": "비실시간 동기 (Fun-ASR / Qwen-Audio)", - "qwen-multimodal": "비실시간 동기 (Qwen3-ASR)", - "async-transcription": "비실시간 비동기 (파일 전사)", + auto: '자동 감지', + 'dashscope-realtime': '실시간 (DashScope)', + 'qwen-realtime': '실시간 (Qwen Realtime)', + multimodal: '비실시간 동기 (Fun-ASR / Qwen-Audio)', + 'qwen-multimodal': '비실시간 동기 (Qwen3-ASR)', + 'async-transcription': '비실시간 비동기 (파일 전사)', }, bailianModelRealtimeHint: '실시간 모델 · 말하는 동안 바로 전사.', bailianModelSyncFileHint: '동기 녹음 모델 · 말을 마친 뒤 전체 전사(한 클립 ≤ 5분).', @@ -2152,8 +2357,7 @@ export const ko: typeof zhCN = { groupDownload: '다운로드 및 관리', groupOther: '기타', mirrorLabel: '다운로드 미러', - mirrorDesc: - 'HuggingFace, 커뮤니티 미러 또는 지원 모델의 공식 ModelScope 저장소를 선택합니다.', + mirrorDesc: 'HuggingFace, 커뮤니티 미러 또는 지원 모델의 공식 ModelScope 저장소를 선택합니다.', mirrorHuggingface: 'HuggingFace 공식 (huggingface.co)', mirrorHfMirror: '중국 미러 (hf-mirror.com)', mirrorModelscope: 'ModelScope 공식 (modelscope.cn)', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index bd06ec81..7fc137de 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -2,6 +2,147 @@ // 八个语言文件共享同一结构;新增或删除 key 时同步更新全部语言。 export const zhCN = { + cloudSyncE2ee: { + setupPromptTitle: '为配置开启加密备份?', + setupPromptBody: + '服务配置已完成。配置、服务 API 密钥和历史文本会先在此设备加密。查看同步范围并主动开启后,才会上传。', + setupPromptLater: '暂不开启', + setupPromptOpen: '了解加密同步', + title: '加密云同步', + description: '在本机加密后,通过 GitHub 账号跨设备同步。', + enable: '启用加密同步', + refresh: '刷新状态', + loading: '正在读取同步状态…', + signIn: '使用 GitHub 登录', + signOut: '退出登录', + account: '同步账号', + keyLocked: '此设备尚未解锁', + keyUnlocked: '此设备已解锁', + hasSnapshot: '云端已有加密备份。', + noSnapshot: '云端尚无加密备份。', + snapshotUnknown: '尚未检查云端备份。', + lastSync: '上次同步:{{time}}', + syncNow: '立即同步', + checkPending: '核对待确认结果', + unlock: '解锁', + lock: '锁定此设备', + changePassword: '更改同步密码', + restore: '审阅云端恢复', + delete: '删除云端备份', + working: '正在处理…', + cancelTask: '取消当前任务', + cancelRequested: '已请求取消,正在等待当前任务结束。', + done: '同步设置已更新。', + restored: '已恢复云端资料,请核对本设备的专属设置。', + retryAfter: '请在 {{seconds}} 秒后重试。', + passwordWarning: '请妥善保管同步密码。密码无法找回,遗失后可能无法读取云端资料。', + consentTitle: '启用端到端加密同步', + consentDescription: + '同步内容包含服务凭据和私密文本历史,资料会在本机加密后上传。请先了解完整范围。', + scopeSummary: '查看完整同步范围', + consentCheck: '我已了解完整范围,并同意加密同步这些资料。', + continue: '继续', + createTitle: '设置同步密码', + unlockTitle: '解锁云端备份', + passwordTitle: '更改同步密码', + create: '创建加密备份', + password: '同步密码', + newPassword: '新同步密码', + currentPassword: '当前同步密码', + confirmPassword: '再次输入新密码', + passwordPolicy: '使用 12–128 个字符,包含大写字母、小写字母和数字,避免常见弱密码。', + rememberKey: '在此设备的安全存储中记住解锁密钥', + disableTitle: '关闭加密同步?', + disableDescription: + '关闭后停止后续同步,云端备份和本机资料都会保留。删除云端备份是另一项独立操作。', + confirmDisable: '关闭同步', + deleteTitle: '删除云端加密备份?', + deleteDescription: + '仅删除当前账号的云端备份,本机资料保留。以后在其他设备恢复前,需要重新创建备份。', + backupRetention: '删除后,服务备份副本最多保留 {{days}} 天。', + deleteCheck: '我确认删除这份云端备份。', + confirmDelete: '删除云端备份', + restoreTitle: '恢复前审阅', + restoreDescription: '请检查下列资料类别,敏感值不会展示。确认之前不会恢复任何内容。', + deviceReview: '恢复后请核对 {{count}} 项设备配置,包括快捷键、模型目录、麦克风及系统授权。', + restoreMode: '恢复方式', + merge: '合并,并逐项处理冲突', + replace: '使用云端资料替换本机同步资料', + replaceWarning: '此操作会替换同步范围内的本机资料。若要保留本机修改,请选择合并。', + restoreCheck: '我已检查恢复方式和冲突处理选择。', + applyRestore: '确认恢复', + conflictProgress: '已处理 {{selected}} / {{total}} 项冲突', + conflictItem: '冲突 {{index}} · {{category}}', + conflictLocal: '保留本机', + conflictCloud: '使用云端', + previous: '上一页', + next: '下一页', + scope: { + preferences: '应用偏好与界面设置。', + credentials: 'ASR、LLM、Omni 渠道及服务 API keys、ID、access key、secret key 等凭据。', + personal: '词典、自定义词汇预设、纠错、风格包及图标。', + history: '全部听写与速记文本历史,以及活动统计。', + device: '设备配置档案,恢复后需在目标设备核对。', + excluded: + '不包含 OAuth 登录态、同步密码或派生密钥、操作系统凭据文件、PIN、设备权限、原始录音与视频,以及模型权重。', + }, + states: { + disabled: '同步已关闭', + sign_in_required: '需要登录 GitHub', + unlock_required: '请输入密码解锁', + ready: '已同步', + pending: '本机更改待同步', + syncing: '正在同步', + conflict: '请审阅冲突更改', + failed: '同步需要处理', + outcome_unknown: '云端结果待确认', + recovery_required: '需要恢复本机同步状态', + }, + categories: { + preferences: '应用偏好', + ui_preferences: '界面设置', + channels: '服务渠道与凭据', + provider_credentials: '服务凭据', + dictionary: '词典', + vocabulary_presets: '自定义词汇预设', + corrections: '纠错规则', + style_packs: '风格包与图标', + history: '文本历史', + activity: '活动统计', + device_profile: '设备配置档案', + other: '其他同步资料', + }, + conflictReasons: { + both_modified: '本机和云端都修改了这项资料。', + delete_modify: '一端删除了这项资料,另一端进行了修改。', + no_common_baseline: '没有共同的同步基线,请选择要保留的一端。', + other: '请选择本机或云端版本,具体值不会展示。', + }, + errors: { + unknown: '操作未能完成,请刷新状态后重试。', + signIn: '请重新登录 GitHub。', + unlock: '请先使用同步密码解锁此设备。', + unavailable: '加密同步暂不可用,请使用原生客户端并稍后检查服务。', + weakPassword: '请使用满足长度和字符要求、且不常见的强密码。', + passwordMismatch: '两次输入的密码不一致。', + invalidPassword: '密码无法解锁这份备份,或加密资料校验未通过。', + secureStorage: '安全存储拒绝访问。请检查设备授权,或取消记住密钥后解锁。', + changed: '审阅期间资料已改变。请关闭弹窗、刷新状态,并重新获取预览。', + accountChanged: 'GitHub 账号已改变,请刷新后再继续。', + busy: '已有同步任务在运行,请等待或先取消。', + cancelled: '任务已取消。', + network: '暂时无法连接同步服务,请检查网络后重试。', + rateLimited: '请求过于频繁,请稍后重试。', + outcomeUnknown: '云端结果尚未确认。请先核对待确认结果,不要重复创建备份。', + recovery: '本机同步状态需要恢复。请保留本机资料,不要覆盖云端备份。', + rolledBack: '恢复失败,本机更改已回滚。请刷新状态后重试。', + tooLarge: '加密备份超出服务的大小限制。', + reviewRequired: '请先确认完整同步范围,并审阅云端恢复预览。', + choicesRequired: '请为每项冲突选择本机或云端版本。', + invalidData: '加密备份未通过校验,本机资料未被替换。', + localData: '无法安全准备本机资料,请刷新后重试。', + }, + }, cloudSync: { title: '云同步', description: '使用 GitHub 账号,在设备之间同步词典、风格与个人偏好。', @@ -102,6 +243,18 @@ export const zhCN = { cancel: '取消', }, qa: { + compact: { + closeError: '关闭失败,请重试。', + sendError: '发送失败,输入已保留,请重试。', + microphoneError: '麦克风操作失败,请重试。', + modeError: '编辑模式未更改,请重试。', + conversation: '当前会话', + sending: '正在发送提问…', + addUnavailable: '添加内容 · 暂不可用', + browserUnavailable: '预览不执行指令', + answer: 'OpenLess 回答', + layoutError: '窗口展开失败,请关闭后重新打开。', + }, title: '划词追问', headerHint: '随时提问', thinking: '思考中…', @@ -133,6 +286,46 @@ export const zhCN = { editInstructionMode: '编辑指令', }, lessComputer: { + desktop: { + approvedSubmitted: '已提交允许', + deniedSubmitted: '已提交拒绝', + agents: '你的 Agents', + configured: '当前配置', + agentSettings: '在设置中选择', + historyUnavailable: '历史记录 · 暂不可用', + sessionUnavailable: '新会话 · 暂不可用', + agentHint: '在设置中选择使用的 Agent。', + workspace: '工作区', + multitask: '多任务', + unavailable: '暂不可用', + historyHint: '四个 Agent 的历史与多会话功能尚未开放。', + signedIn: '已登录', + signIn: '登录账号', + currentSession: '当前会话', + minimize: '最小化', + maximize: '最大化 / 还原', + windowError: '窗口操作未完成,请重试。', + emptyHint: '写下具体任务,或用已配置的说话快捷键,让想法开始行动。', + voiceHint: '按住说话快捷键', + idle: '等待指令', + waitingApproval: '等待你的确认', + submittingApproval: '正在提交…', + approvalError: '审批未提交成功,请重试。', + approvalExpired: '此轮已结束,审批已不可用。', + sendError: '发送失败,输入已保留。请重试。', + browserUnavailable: '浏览器预览不会执行指令,请在桌面应用中使用。', + cancelHint: '可从胶囊取消正在运行的任务。', + inputHint: 'Enter 发送 · Shift + Enter 换行', + activity: '任务动态', + noActivity: '尚无任务活动', + activityHint: '任务开始后,工具调用与审批会显示在这里。', + currentTask: '当前任务', + toolActive: '调用中', + toolReported: '已收到调用记录', + approval: '操作审批', + apiCost: '本轮 API 费用', + activitySource: '随当前任务更新。费用仅在服务返回时显示。', + }, title: 'Less Computer', subtitle: '想让电脑做什么?', you: '你', @@ -1391,15 +1584,16 @@ export const zhCN = { '按所选请求格式和模型支持的参数启用、关闭或降低思考,不向提示词注入控制指令。', bailianVocabularyIdLabel: '热词 Vocabulary ID(可选)', bailianVocabularyIdNote: '如已在百炼创建热词表,可填写 vocab-...;留空则不下发热词。', - bailianProtocolLabel: "接口类型", - bailianProtocolNote: "手动选择优先于模型名称,按渠道保存,同时用于验证和录音。请根据模型文档选择接口。", + bailianProtocolLabel: '接口类型', + bailianProtocolNote: + '手动选择优先于模型名称,按渠道保存,同时用于验证和录音。请根据模型文档选择接口。', bailianProtocolOptions: { - "auto": "自动识别", - "dashscope-realtime": "实时(DashScope)", - "qwen-realtime": "实时(Qwen Realtime)", - "multimodal": "非实时同步(Fun-ASR / Qwen-Audio)", - "qwen-multimodal": "非实时同步(Qwen3-ASR)", - "async-transcription": "非实时异步(文件转写)", + auto: '自动识别', + 'dashscope-realtime': '实时(DashScope)', + 'qwen-realtime': '实时(Qwen Realtime)', + multimodal: '非实时同步(Fun-ASR / Qwen-Audio)', + 'qwen-multimodal': '非实时同步(Qwen3-ASR)', + 'async-transcription': '非实时异步(文件转写)', }, bailianModelRealtimeHint: '实时模型 · 边说边出字。', bailianModelSyncFileHint: '同步录音模型 · 说完后整段转写(单条 ≤ 5 分钟)。', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index b9019074..b4f98e6f 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -2,6 +2,147 @@ import type { zhCN } from './zh-CN'; // 繁體中文資源,與其餘七種語言共用同一組文案 key。 export const zhTW: typeof zhCN = { + cloudSyncE2ee: { + setupPromptTitle: '為設定開啟加密備份?', + setupPromptBody: + '服務設定已完成。設定、服務 API 金鑰及歷史文字會先在此裝置加密。檢視同步範圍並主動開啟後,才會上傳。', + setupPromptLater: '暫不開啟', + setupPromptOpen: '了解加密同步', + title: '加密雲端同步', + description: '在本機加密後,透過 GitHub 帳號跨裝置同步。', + enable: '啟用加密同步', + refresh: '重新整理狀態', + loading: '正在讀取同步狀態…', + signIn: '使用 GitHub 登入', + signOut: '登出', + account: '同步帳號', + keyLocked: '此裝置尚未解鎖', + keyUnlocked: '此裝置已解鎖', + hasSnapshot: '雲端已有加密備份。', + noSnapshot: '雲端尚無加密備份。', + snapshotUnknown: '尚未檢查雲端備份。', + lastSync: '上次同步:{{time}}', + syncNow: '立即同步', + checkPending: '核對待確認結果', + unlock: '解鎖', + lock: '鎖定此裝置', + changePassword: '變更同步密碼', + restore: '檢閱雲端還原', + delete: '刪除雲端備份', + working: '正在處理…', + cancelTask: '取消目前工作', + cancelRequested: '已請求取消,正在等待目前工作結束。', + done: '同步設定已更新。', + restored: '已還原雲端資料,請核對此裝置的專屬設定。', + retryAfter: '請在 {{seconds}} 秒後重試。', + passwordWarning: '請妥善保管同步密碼。密碼無法找回,遺失後可能無法讀取雲端資料。', + consentTitle: '啟用端對端加密同步', + consentDescription: + '同步內容包含服務憑證和私人文字歷史,資料會在本機加密後上傳。請先了解完整範圍。', + scopeSummary: '查看完整同步範圍', + consentCheck: '我已了解完整範圍,並同意加密同步這些資料。', + continue: '繼續', + createTitle: '設定同步密碼', + unlockTitle: '解鎖雲端備份', + passwordTitle: '變更同步密碼', + create: '建立加密備份', + password: '同步密碼', + newPassword: '新同步密碼', + currentPassword: '目前同步密碼', + confirmPassword: '再次輸入新密碼', + passwordPolicy: '使用 12–128 個字元,包含大寫字母、小寫字母和數字,避免常見弱密碼。', + rememberKey: '在此裝置的安全儲存空間記住解鎖金鑰', + disableTitle: '關閉加密同步?', + disableDescription: + '關閉後停止後續同步,雲端備份和本機資料都會保留。刪除雲端備份是另一項獨立操作。', + confirmDisable: '關閉同步', + deleteTitle: '刪除雲端加密備份?', + deleteDescription: + '僅刪除目前帳號的雲端備份,本機資料保留。之後在其他裝置還原前,需要重新建立備份。', + backupRetention: '刪除後,服務備份副本最多保留 {{days}} 天。', + deleteCheck: '我確認刪除這份雲端備份。', + confirmDelete: '刪除雲端備份', + restoreTitle: '還原前檢閱', + restoreDescription: '請檢查下列資料類別,敏感值不會顯示。確認之前不會還原任何內容。', + deviceReview: '還原後請核對 {{count}} 項裝置設定,包括快捷鍵、模型目錄、麥克風及系統授權。', + restoreMode: '還原方式', + merge: '合併,並逐項處理衝突', + replace: '使用雲端資料取代本機同步資料', + replaceWarning: '此操作會取代同步範圍內的本機資料。若要保留本機修改,請選擇合併。', + restoreCheck: '我已檢查還原方式和衝突處理選擇。', + applyRestore: '確認還原', + conflictProgress: '已處理 {{selected}} / {{total}} 項衝突', + conflictItem: '衝突 {{index}} · {{category}}', + conflictLocal: '保留本機', + conflictCloud: '使用雲端', + previous: '上一頁', + next: '下一頁', + scope: { + preferences: '應用程式偏好與介面設定。', + credentials: 'ASR、LLM、Omni 渠道及服務 API keys、ID、access key、secret key 等憑證。', + personal: '詞典、自訂詞彙預設、修正、風格包及圖示。', + history: '全部聽寫與速記文字歷史,以及活動統計。', + device: '裝置設定檔,還原後需在目標裝置核對。', + excluded: + '不包含 OAuth 登入狀態、同步密碼或衍生金鑰、作業系統憑證檔案、PIN、裝置權限、原始錄音與影片,以及模型權重。', + }, + states: { + disabled: '同步已關閉', + sign_in_required: '需要登入 GitHub', + unlock_required: '請輸入密碼解鎖', + ready: '已同步', + pending: '本機變更待同步', + syncing: '正在同步', + conflict: '請檢閱衝突變更', + failed: '同步需要處理', + outcome_unknown: '雲端結果待確認', + recovery_required: '需要復原本機同步狀態', + }, + categories: { + preferences: '應用程式偏好', + ui_preferences: '介面設定', + channels: '服務渠道與憑證', + provider_credentials: '服務憑證', + dictionary: '詞典', + vocabulary_presets: '自訂詞彙預設', + corrections: '修正規則', + style_packs: '風格包與圖示', + history: '文字歷史', + activity: '活動統計', + device_profile: '裝置設定檔', + other: '其他同步資料', + }, + conflictReasons: { + both_modified: '本機和雲端都修改了這項資料。', + delete_modify: '一端刪除了這項資料,另一端進行了修改。', + no_common_baseline: '沒有共同的同步基準,請選擇要保留的一端。', + other: '請選擇本機或雲端版本,具體值不會顯示。', + }, + errors: { + unknown: '操作未能完成,請重新整理狀態後重試。', + signIn: '請重新登入 GitHub。', + unlock: '請先使用同步密碼解鎖此裝置。', + unavailable: '加密同步暫不可用,請使用原生用戶端並稍後檢查服務。', + weakPassword: '請使用符合長度和字元要求、且不常見的強密碼。', + passwordMismatch: '兩次輸入的密碼不一致。', + invalidPassword: '密碼無法解鎖這份備份,或加密資料驗證未通過。', + secureStorage: '安全儲存空間拒絕存取。請檢查裝置授權,或取消記住金鑰後解鎖。', + changed: '檢閱期間資料已變更。請關閉對話框、重新整理狀態,並重新取得預覽。', + accountChanged: 'GitHub 帳號已變更,請重新整理後再繼續。', + busy: '已有同步工作在執行,請等待或先取消。', + cancelled: '工作已取消。', + network: '暫時無法連線同步服務,請檢查網路後重試。', + rateLimited: '請求過於頻繁,請稍後重試。', + outcomeUnknown: '雲端結果尚未確認。請先核對待確認結果,不要重複建立備份。', + recovery: '本機同步狀態需要復原。請保留本機資料,不要覆寫雲端備份。', + rolledBack: '還原失敗,本機變更已回復。請重新整理狀態後重試。', + tooLarge: '加密備份超出服務的大小限制。', + reviewRequired: '請先確認完整同步範圍,並檢閱雲端還原預覽。', + choicesRequired: '請為每項衝突選擇本機或雲端版本。', + invalidData: '加密備份未通過驗證,本機資料未被取代。', + localData: '無法安全準備本機資料,請重新整理後重試。', + }, + }, cloudSync: { title: '雲端同步', description: '使用 GitHub 帳號,在裝置之間同步詞典、風格與個人偏好。', @@ -102,6 +243,18 @@ export const zhTW: typeof zhCN = { cancel: '取消', }, qa: { + compact: { + closeError: '關閉失敗,請重試。', + sendError: '傳送失敗,輸入已保留,請重試。', + microphoneError: '麥克風操作失敗,請重試。', + modeError: '編輯模式未變更,請重試。', + conversation: '目前對話', + sending: '正在傳送提問…', + addUnavailable: '新增內容 · 暫不可用', + browserUnavailable: '預覽不執行指令', + answer: 'OpenLess 回答', + layoutError: '視窗展開失敗,請關閉後重新開啟。', + }, title: '劃詞追問', headerHint: '隨時提問', thinking: '思考中…', @@ -133,6 +286,46 @@ export const zhTW: typeof zhCN = { editInstructionMode: '編輯指令', }, lessComputer: { + desktop: { + approvedSubmitted: '已提交允許', + deniedSubmitted: '已提交拒絕', + agents: '你的 Agents', + configured: '目前設定', + agentSettings: '在設定中選擇', + historyUnavailable: '歷史記錄 · 暫不可用', + sessionUnavailable: '新對話 · 暫不可用', + agentHint: '在設定中選擇使用的 Agent。', + workspace: '工作區', + multitask: '多工', + unavailable: '暫不可用', + historyHint: '四個 Agent 的歷史與多對話功能尚未開放。', + signedIn: '已登入', + signIn: '登入帳號', + currentSession: '目前對話', + minimize: '最小化', + maximize: '最大化 / 還原', + windowError: '視窗操作未完成,請重試。', + emptyHint: '寫下具體任務,或用已設定的說話快捷鍵,讓想法開始行動。', + voiceHint: '按住說話快捷鍵', + idle: '等待指令', + waitingApproval: '等待你的確認', + submittingApproval: '正在提交…', + approvalError: '審批未提交成功,請重試。', + approvalExpired: '此輪已結束,審批已不可用。', + sendError: '傳送失敗,輸入已保留。請重試。', + browserUnavailable: '瀏覽器預覽不會執行指令,請在桌面應用程式中使用。', + cancelHint: '可從膠囊取消正在執行的任務。', + inputHint: 'Enter 傳送 · Shift + Enter 換行', + activity: '任務動態', + noActivity: '尚無任務活動', + activityHint: '任務開始後,工具呼叫與審批會顯示在這裡。', + currentTask: '目前任務', + toolActive: '呼叫中', + toolReported: '已收到呼叫記錄', + approval: '操作審批', + apiCost: '本輪 API 費用', + activitySource: '隨目前任務更新。費用僅在服務回傳時顯示。', + }, title: 'Less Computer', subtitle: '想讓電腦做什麼?', you: '你', diff --git a/openless-all/app/src/lib/encryptedSyncUiBridge.ts b/openless-all/app/src/lib/encryptedSyncUiBridge.ts new file mode 100644 index 00000000..dc692ce1 --- /dev/null +++ b/openless-all/app/src/lib/encryptedSyncUiBridge.ts @@ -0,0 +1,203 @@ +// The main native window owns the device-local UI mirror. Every write shares +// this queue and a native revision, including the initial consent preparation. +import { invokeOrMock, isTauri } from './ipc/shared'; +import { + getLocalePreference, + setLocalePreference, + SUPPORTED_LOCALES, + type SupportedLocale, +} from '../i18n'; +import { readFontScale, setFontScale, type FontScaleId } from './fontScale'; + +type UiPreferences = { locale?: string; fontScale?: string }; +type UiSnapshot = { preferences: UiPreferences | null; revision: string | null }; +type UiKey = 'locale' | 'fontScale'; +type Status = { + sequence: string; + account: { githubId: string } | null; + vaultId: string | null; + consentVersion: string | null; +}; +type Restored = { sequence: string; accountId: string; vaultId: string; taskId: string | null }; +let installation: Promise | null = null; +let flushBridge: (() => Promise) | null = null; + +export async function flushEncryptedSyncUiPreferences(): Promise { + await installEncryptedSyncUiBridge(); + if (!flushBridge) throw new Error('cloud_sync_unavailable'); + await flushBridge(); +} + +export function installEncryptedSyncUiBridge(): Promise { + if (!isTauri || new URLSearchParams(location.search).has('window')) return Promise.resolve(); + return (installation ??= install()); +} + +async function install(): Promise { + let ready = false; + let choiceEpoch = 0; + let obsoleteThrough = 0; + let restoredSequence = 0n; + let restorationEpoch = 0; + let settledRestorationEpoch = 0; + let known: UiSnapshot = { preferences: null, revision: null }; + const choices = new Map(); + let queue: Promise = Promise.resolve(); + const unavailable = (): never => { + throw new Error('cloud_sync_unavailable'); + }; + const readStatus = () => invokeOrMock('cloud_sync_e2ee_status', undefined, unavailable); + const readUi = () => + invokeOrMock('cloud_sync_e2ee_get_ui_preferences_snapshot', undefined, unavailable); + const sameScope = (a: Status, b: Status) => + a.account?.githubId === b.account?.githubId && a.vaultId === b.vaultId; + const belongs = (state: Status, event: Restored) => + state.account?.githubId === event.accountId && state.vaultId === event.vaultId; + const failed = () => window.dispatchEvent(new Event('openless:sync-ui-persistence-failed')); + const enqueue = (work: () => Promise): Promise => { + const next = queue.catch(() => {}).then(work); + queue = next.catch(failed); + return next; + }; + const invalidate = (through: number) => { + obsoleteThrough = Math.max(obsoleteThrough, through); + for (const [key, choice] of choices) if (choice.epoch <= through) choices.delete(key); + }; + const apply = async (preferences: UiPreferences, expectedEpoch: number) => { + if ( + (choices.get('locale')?.epoch ?? 0) <= expectedEpoch && + (preferences.locale === 'system' || + SUPPORTED_LOCALES.some((locale) => locale === preferences.locale)) + ) { + await setLocalePreference(preferences.locale as SupportedLocale | 'system', 'sync-restore'); + } + if ( + (choices.get('fontScale')?.epoch ?? 0) <= expectedEpoch && + ['small', 'medium', 'large'].includes(preferences.fontScale ?? '') + ) { + setFontScale(preferences.fontScale as FontScaleId, 'sync-restore'); + } + }; + const reconcile = async (through: number) => { + const current = await readUi(); + known = current; + invalidate(through); + if (current.preferences) await apply(current.preferences, through); + }; + const persist = async (explicit: boolean, epoch: number) => { + if (!explicit && (epoch !== choiceEpoch || epoch <= obsoleteThrough)) return; + const status = await readStatus(); + if (!explicit && (epoch !== choiceEpoch || epoch <= obsoleteThrough || !status.consentVersion)) + return; + const current = await readUi(); + if (restorationEpoch !== settledRestorationEpoch) { + if (explicit) throw new Error('cloud_sync_ui_restore_pending'); + return; + } + // A restore or rollback may have happened without its notification reaching + // this WebView. Rebase from native, but never replay the old user's intent + // over that revision. A subsequent edit uses the refreshed revision. + if (current.revision !== known.revision) { + known = current; + invalidate(epoch); + if (current.preferences) await apply(current.preferences, epoch); + throw new Error('cloud_sync_ui_revision_changed'); + } + if (!explicit && epoch <= obsoleteThrough) return; + const pending = [...choices].filter( + ([, value]) => value.epoch > obsoleteThrough && value.epoch <= epoch, + ); + if (!pending.length && current.preferences) return; + const desired = { + locale: getLocalePreference(), + fontScale: readFontScale(), + ...current.preferences, + }; + for (const [key, choice] of pending) desired[key] = choice.value; + try { + await invokeOrMock( + 'cloud_sync_e2ee_set_ui_preferences_checked', + { + ...desired, + expectedRevision: current.revision, + }, + unavailable, + ); + await reconcile(epoch); + } catch (error) { + // Refresh even after CAS failure or an uncertain reply. Keeping the old + // revision would make future edits fail until the WebView restarts. + await reconcile(epoch); + throw error; + } + }; + const changed = (event: Event) => { + if (!ready || (event as CustomEvent<{ source?: string }>).detail?.source === 'sync-restore') + return; + const epoch = ++choiceEpoch; + const values = { locale: getLocalePreference(), fontScale: readFontScale() }; + const detailKey = (event as CustomEvent<{ key?: UiKey }>).detail?.key; + const storageKey = (event as StorageEvent).key; + const keys: UiKey[] = detailKey + ? [detailKey] + : storageKey === 'ol-font-scale' + ? ['fontScale'] + : storageKey + ? ['locale'] + : ['locale', 'fontScale']; + for (const key of keys) choices.set(key, { epoch, value: values[key] }); + void enqueue(() => persist(false, epoch)).catch(() => {}); + }; + const { listen } = await import('@tauri-apps/api/event'); + await listen('cloud-sync-e2ee:restored', ({ payload }) => { + if ( + !/^(0|[1-9]\d{0,19})$/.test(payload.sequence) || + BigInt(payload.sequence) <= restoredSequence + ) + return; + const eventChoiceEpoch = choiceEpoch; + const notificationEpoch = ++restorationEpoch; + // Pause old writes immediately, but retain their choices until native has + // verified this event's scope. A late event from another vault must not + // discard the current user's pending changes. + void enqueue(async () => { + try { + if (BigInt(payload.sequence) <= restoredSequence) return; + const before = await readStatus(); + if (!belongs(before, payload)) return; + const current = await readUi(); + const after = await readStatus(); + if ( + !belongs(after, payload) || + !sameScope(before, after) || + BigInt(payload.sequence) <= restoredSequence + ) + return; + invalidate(eventChoiceEpoch); + known = current; + restoredSequence = BigInt(payload.sequence); + if (current.preferences) await apply(current.preferences, eventChoiceEpoch); + } finally { + settledRestorationEpoch = notificationEpoch; + if (notificationEpoch === restorationEpoch && choices.size > 0) { + void enqueue(() => persist(false, choiceEpoch)).catch(() => {}); + } + } + }).catch(() => {}); + }); + window.addEventListener('openless:ui-preferences-changed', changed); + window.addEventListener('storage', (event) => { + if (event.key === 'ol-font-scale' || event.key?.includes('locale')) changed(event); + }); + flushBridge = () => enqueue(() => persist(true, choiceEpoch)); + try { + await enqueue(async () => { + known = await readUi(); + if (known.preferences) await apply(known.preferences, choiceEpoch); + }); + } catch { + // Settings remain available to retry a denied keychain read. + } finally { + ready = true; + } +} diff --git a/openless-all/app/src/lib/fontScale.ts b/openless-all/app/src/lib/fontScale.ts index 6ca8de61..bfc7c5b2 100644 --- a/openless-all/app/src/lib/fontScale.ts +++ b/openless-all/app/src/lib/fontScale.ts @@ -32,10 +32,11 @@ export function applyFontScale(id: FontScaleId): void { (document.documentElement.style as CSSStyleDeclaration & { zoom?: string }).zoom = String(scale); } -export function setFontScale(id: FontScaleId): void { +export function setFontScale(id: FontScaleId, source: 'user' | 'sync-restore' = 'user'): void { applyFontScale(id); try { window.localStorage.setItem(FONT_SCALE_KEY, id); + window.dispatchEvent(new CustomEvent('openless:ui-preferences-changed', { detail: { source, key: 'fontScale' } })); } catch { /* 忽略 */ } diff --git a/openless-all/app/src/lib/ipc/cloud-sync-e2ee.browser.test.ts b/openless-all/app/src/lib/ipc/cloud-sync-e2ee.browser.test.ts new file mode 100644 index 00000000..8f32a0ca --- /dev/null +++ b/openless-all/app/src/lib/ipc/cloud-sync-e2ee.browser.test.ts @@ -0,0 +1,48 @@ +// @ts-nocheck — Node-only runtime harness; production UI and IPC remain strictly typed. +import assert from 'node:assert/strict'; +import * as api from './cloud-sync-e2ee'; + +const operations: Array<() => Promise> = [ + api.cloudSyncE2eeStatus, + api.cloudSyncE2eeClaimSetupPrompt, + () => api.cloudSyncE2eePrepareEnable(api.CLOUD_SYNC_E2EE_CONSENT_VERSION), + () => + api.cloudSyncE2eeCreate({ + password: 'SyntheticOnly1A', + passwordConfirmation: 'SyntheticOnly1A', + rememberKey: true, + consentVersion: api.CLOUD_SYNC_E2EE_CONSENT_VERSION, + observedRevision: '0', + }), + () => api.cloudSyncE2eeUnlock({ password: 'SyntheticOnly1A', rememberKey: true }), + api.cloudSyncE2eeLock, + () => api.cloudSyncE2eeSetEnabled(true), + api.cloudSyncE2eeSyncNow, + () => api.cloudSyncE2eeCancel('task'), + () => api.cloudSyncE2eePreviewRestore('1'), + () => + api.cloudSyncE2eeApplyRestore({ previewId: 'preview', mode: 'replace', conflictChoices: [] }), + () => + api.cloudSyncE2eeChangePassword({ + currentPassword: 'SyntheticOnly1A', + newPassword: 'SyntheticOnly2A', + confirmation: 'SyntheticOnly2A', + rememberKey: true, + }), + () => + api.cloudSyncE2eeDeleteRemote({ + expectedVaultId: 'vault', + observedRevision: '1', + confirmed: true, + }), + api.cloudSyncE2eeSignOut, + api.cloudSyncE2eeGetUiPreferences, + api.mirrorEncryptedSyncUiPreferences, +]; +for (const invoke of operations) { + await assert.rejects( + invoke, + (error: unknown) => api.encryptedSyncErrorKey(error) === 'unavailable', + ); +} +console.log('cloud-sync-e2ee.browser.test.ts passed'); diff --git a/openless-all/app/src/lib/ipc/cloud-sync-e2ee.test.ts b/openless-all/app/src/lib/ipc/cloud-sync-e2ee.test.ts new file mode 100644 index 00000000..1e661457 --- /dev/null +++ b/openless-all/app/src/lib/ipc/cloud-sync-e2ee.test.ts @@ -0,0 +1,142 @@ +// @ts-nocheck — Node-only runtime harness; production UI and IPC remain strictly typed. +import assert from 'node:assert/strict'; +import type { EncryptedSyncStatus } from './cloud-sync-e2ee'; + +const calls: Array<{ command: string; args: unknown }> = []; +const response = { marker: 'native-result' }; +let failure: unknown = null; +const stored = new Map([ + ['ol.locale', 'fr'], + ['ol-font-scale', 'large'], +]); +Object.defineProperty(globalThis, 'window', { + configurable: true, + value: { + localStorage: { getItem: (key: string) => stored.get(key) ?? null }, + addEventListener() {}, + __TAURI_INTERNALS__: { + invoke: async (command: string, args: unknown) => { + if (command === 'get_startup_snapshot') + return { contractVersion: '2.0.0', backend: { running: true } }; + if (command === 'set_remote_locale') return; + calls.push({ command, args }); + if (failure) throw failure; + return response; + }, + }, + }, +}); + +try { + const api = await import('./cloud-sync-e2ee'); + const secret = 'OnlyA1SyntheticFixture'; + const create = { + password: secret, + passwordConfirmation: secret, + rememberKey: true, + consentVersion: api.CLOUD_SYNC_E2EE_CONSENT_VERSION, + observedRevision: '9007199254740993', + }; + const change = { + currentPassword: secret, + newPassword: `${secret}2`, + confirmation: `${secret}2`, + rememberKey: false, + }; + const apply = { + previewId: 'preview', + mode: 'merge' as const, + conflictChoices: [{ id: 'opaque', side: 'cloud' as const }], + }; + const deletion = { + expectedVaultId: 'vault', + observedRevision: '9007199254740993', + confirmed: true, + }; + const cases: Array<[string, unknown, () => Promise]> = [ + ['status', undefined, api.cloudSyncE2eeStatus], + ['claim_setup_prompt', undefined, api.cloudSyncE2eeClaimSetupPrompt], + [ + 'prepare_enable', + { consentVersion: api.CLOUD_SYNC_E2EE_CONSENT_VERSION }, + () => api.cloudSyncE2eePrepareEnable(api.CLOUD_SYNC_E2EE_CONSENT_VERSION), + ], + ['create', create, () => api.cloudSyncE2eeCreate(create)], + [ + 'unlock', + { password: secret, rememberKey: false }, + () => api.cloudSyncE2eeUnlock({ password: secret, rememberKey: false }), + ], + ['lock', undefined, api.cloudSyncE2eeLock], + ['set_enabled', { enabled: false }, () => api.cloudSyncE2eeSetEnabled(false)], + ['sync_now', undefined, api.cloudSyncE2eeSyncNow], + ['cancel', { taskId: 'task' }, () => api.cloudSyncE2eeCancel('task')], + [ + 'preview_restore', + { observedRevision: '9007199254740993' }, + () => api.cloudSyncE2eePreviewRestore('9007199254740993'), + ], + ['apply_restore', apply, () => api.cloudSyncE2eeApplyRestore(apply)], + ['change_password', change, () => api.cloudSyncE2eeChangePassword(change)], + ['delete_remote', deletion, () => api.cloudSyncE2eeDeleteRemote(deletion)], + ['sign_out', undefined, api.cloudSyncE2eeSignOut], + ['get_ui_preferences', undefined, api.cloudSyncE2eeGetUiPreferences], + ]; + for (const [command, args, invoke] of cases) { + assert.equal(await invoke(), response); + assert.deepEqual(calls.pop(), { command: `cloud_sync_e2ee_${command}`, args: args ?? {} }); + } + // The mirror uses the same revisioned queue as live changes; exercised by + // scripts/encrypted-sync-ui-bridge.test.mjs, including consent preparation. + assert.equal(stored.get('ol.locale'), 'fr'); + assert.equal(stored.get('ol-font-scale'), 'large'); + failure = { + code: 'provider', + details: { reason: 'revision_conflict' }, + message: 'DO_NOT_RENDER_PRIVATE_RESPONSE', + }; + assert.equal(await api.cloudSyncE2eeSyncNow().catch((error: unknown) => error), failure); + assert.equal(api.encryptedSyncErrorKey(failure), 'changed'); + assert.equal(api.encryptedSyncErrorKey({ message: 'DO_NOT_RENDER_PRIVATE_RESPONSE' }), 'unknown'); + assert.equal(api.encryptedSyncErrorKey({ details: { reason: '__proto__' } }), 'unknown'); + + const status = { + sequence: '9007199254740993', + account: { githubId: 'owner', login: 'name' }, + vaultId: 'vault', + taskId: 'task', + serviceOrigin: 'https://sync.example', + } as EncryptedSyncStatus; + const event = { + sequence: '9007199254740994', + accountId: 'owner', + vaultId: 'vault', + taskId: 'task', + }; + assert(api.matchesEncryptedSyncEvent(status, event, status.sequence)); + for (const invalid of [ + { ...event, sequence: status.sequence }, + { ...event, sequence: '01' }, + { ...event, sequence: '18446744073709551616' }, + { ...event, accountId: 'other' }, + { ...event, vaultId: 'other' }, + { ...event, taskId: 'old-task' }, + ]) + assert(!api.matchesEncryptedSyncEvent(status, invalid, status.sequence)); + assert( + api.matchesEncryptedSyncEvent(status, { ...event, taskId: 'new-task' }, status.sequence, false), + ); + assert.notEqual( + api.encryptedSyncScope(status), + api.encryptedSyncScope({ ...status, serviceOrigin: 'https://other.example' }), + ); + assert.equal(api.syncSequence('18446744073709551615'), 18446744073709551615n); + assert.equal(api.syncSequence('not-a-sequence'), null); + assert(api.syncPasswordMeetsBasicRequirements('LongFixture1Ab')); + assert(api.syncPasswordMeetsBasicRequirements('A1' + '界'.repeat(10)) === false); + assert(!api.syncPasswordMeetsBasicRequirements('shortA1')); + assert(!api.syncPasswordMeetsBasicRequirements('A1a' + '界'.repeat(126))); +} finally { + Reflect.deleteProperty(globalThis, 'window'); +} +console.log('cloud-sync-e2ee.test.ts passed'); diff --git a/openless-all/app/src/lib/ipc/cloud-sync-e2ee.ts b/openless-all/app/src/lib/ipc/cloud-sync-e2ee.ts new file mode 100644 index 00000000..2a47bb1a --- /dev/null +++ b/openless-all/app/src/lib/ipc/cloud-sync-e2ee.ts @@ -0,0 +1,243 @@ +import { invokeOrMock, isTauri } from './shared'; + +export const CLOUD_SYNC_E2EE_CONSENT_VERSION = 'encrypted-full-snapshot-v1'; + +export type EncryptedSyncState = + | 'disabled' + | 'sign_in_required' + | 'unlock_required' + | 'ready' + | 'pending' + | 'syncing' + | 'conflict' + | 'failed' + | 'outcome_unknown' + | 'recovery_required'; + +export interface EncryptedSyncStatus { + sequence: string; + enabled: boolean; + authState: 'signed_out' | 'signed_in' | 'expired'; + keyState: 'locked' | 'unlocked'; + syncState: EncryptedSyncState; + account: { githubId: string; login: string } | null; + vaultId: string | null; + keyId: string | null; + localGeneration: string; + lastSyncedLocalGeneration: string | null; + remoteRevision: string | null; + lastSuccessfulSyncAt: string | null; + pendingOperationId: string | null; + lastError: { code: string; retryAfterSeconds: number | null } | null; + recoveryRequired: boolean; + hasCloudSnapshot: boolean | null; + taskId: string | null; + serviceOrigin: string; + backupRetentionDays: number | null; + consentVersion: string | null; +} + +export interface EnablePreparation { + nextStep: 'create' | 'unlock' | 'restore_review' | 'ready'; + status: EncryptedSyncStatus; +} + +export interface SyncConflictChoice { + id: string; + side: 'local' | 'cloud'; +} +export interface RestorePreview { + previewId: string; + unconfirmedOperationId?: string | null; + observedRevision: string; + localGeneration: string; + counts: Record; + deviceSettingsToReview: string[]; + conflicts: Array<{ id: string; kind: string; reason: string }>; +} + +export interface EncryptedSyncEventScope { + sequence: string; + accountId: string | null; + vaultId: string | null; + taskId: string | null; +} +export interface EncryptedSyncEvent extends EncryptedSyncEventScope { + status: EncryptedSyncStatus; +} +export interface EncryptedSyncConflictEvent extends EncryptedSyncEventScope { + preview: RestorePreview; +} +export interface EncryptedSyncRestoreEvent extends EncryptedSyncEventScope { + localGeneration: string; + uiPreferences: Record; +} + +// Browser previews have neither a native vault nor a verified GitHub session. +// Never invent an enabled/unlocked/successful state in the preview adapter. +function unavailable(): never { + throw Object.assign(new Error('cloud_sync_e2ee_unavailable'), { + code: 'unsupported', + details: { reason: 'service_unavailable' }, + }); +} + +export const cloudSyncE2eeStatus = (): Promise => + invokeOrMock('cloud_sync_e2ee_status', undefined, unavailable); +export const cloudSyncE2eeClaimSetupPrompt = (): Promise => + invokeOrMock('cloud_sync_e2ee_claim_setup_prompt', undefined, unavailable); +export const cloudSyncE2eePrepareEnable = (consentVersion: string): Promise => + invokeOrMock('cloud_sync_e2ee_prepare_enable', { consentVersion }, unavailable); +export const cloudSyncE2eeCreate = (input: { + password: string; + passwordConfirmation: string; + rememberKey: boolean; + consentVersion: string; + observedRevision: string; +}): Promise => invokeOrMock('cloud_sync_e2ee_create', input, unavailable); +export const cloudSyncE2eeUnlock = (input: { + password: string; + rememberKey: boolean; +}): Promise => invokeOrMock('cloud_sync_e2ee_unlock', input, unavailable); +export const cloudSyncE2eeLock = (): Promise => + invokeOrMock('cloud_sync_e2ee_lock', undefined, unavailable); +export const cloudSyncE2eeSetEnabled = (enabled: boolean): Promise => + invokeOrMock('cloud_sync_e2ee_set_enabled', { enabled }, unavailable); +export const cloudSyncE2eeSyncNow = (): Promise => + invokeOrMock('cloud_sync_e2ee_sync_now', undefined, unavailable); +export const cloudSyncE2eeCancel = (taskId: string): Promise => + invokeOrMock('cloud_sync_e2ee_cancel', { taskId }, unavailable); +export const cloudSyncE2eePreviewRestore = (observedRevision: string): Promise => + invokeOrMock('cloud_sync_e2ee_preview_restore', { observedRevision }, unavailable); +export const cloudSyncE2eeApplyRestore = (input: { + previewId: string; + mode: 'replace' | 'merge'; + conflictChoices: SyncConflictChoice[]; +}): Promise => + invokeOrMock('cloud_sync_e2ee_apply_restore', input, unavailable); +export const cloudSyncE2eeChangePassword = (input: { + currentPassword: string; + newPassword: string; + confirmation: string; + rememberKey: boolean; +}): Promise => + invokeOrMock('cloud_sync_e2ee_change_password', input, unavailable); +export const cloudSyncE2eeDeleteRemote = (input: { + expectedVaultId: string; + observedRevision: string; + confirmed: boolean; +}): Promise => + invokeOrMock('cloud_sync_e2ee_delete_remote', input, unavailable); +export const cloudSyncE2eeSignOut = (): Promise => + invokeOrMock('cloud_sync_e2ee_sign_out', undefined, unavailable); +export const cloudSyncE2eeGetUiPreferences = (): Promise<{ + locale?: string; + fontScale?: string; +} | null> => invokeOrMock('cloud_sync_e2ee_get_ui_preferences', undefined, unavailable); + +export async function mirrorEncryptedSyncUiPreferences(): Promise { + if (!isTauri) unavailable(); + const { flushEncryptedSyncUiPreferences } = await import('../encryptedSyncUiBridge'); + await flushEncryptedSyncUiPreferences(); +} + +export function syncSequence(value: unknown): bigint | null { + if (typeof value !== 'string' || !/^(0|[1-9]\d{0,19})$/.test(value)) return null; + const sequence = BigInt(value); + return sequence <= 18446744073709551615n ? sequence : null; +} + +export function encryptedSyncScope(status: EncryptedSyncStatus): string { + return JSON.stringify([status.serviceOrigin, status.account?.githubId ?? null, status.vaultId]); +} + +/** State events establish task transitions; conflict/restore events must match it. */ +export function matchesEncryptedSyncEvent( + status: EncryptedSyncStatus, + event: EncryptedSyncEventScope, + watermark: string, + requireTask = true, +): boolean { + const incoming = syncSequence(event.sequence); + const previous = syncSequence(watermark); + return ( + incoming !== null && + previous !== null && + incoming > previous && + event.accountId === (status.account?.githubId ?? null) && + event.vaultId === status.vaultId && + (!requireTask || event.taskId === status.taskId) + ); +} + +// Only these value-free codes may influence UI text. Raw server messages, +// unknown detail fields and credential-bearing strings are never displayed. +const errorKeys = { + sign_in_required: 'signIn', + unlock_required: 'unlock', + sync_documents_locked: 'unlock', + service_unavailable: 'unavailable', + unsupported_protocol: 'unavailable', + weak_password: 'weakPassword', + password_confirmation_mismatch: 'passwordMismatch', + invalid_password_or_ciphertext: 'invalidPassword', + secure_storage_denied: 'secureStorage', + revision_conflict: 'changed', + cloud_deleted: 'changed', + conflict: 'reviewRequired', + stale_preview: 'changed', + sync_documents_source_changed: 'changed', + account_changed: 'accountChanged', + busy: 'busy', + runtime_busy: 'busy', + revision_rollback: 'invalidData', + cancelled: 'cancelled', + stale_task: 'changed', + transport_failed: 'network', + rate_limited: 'rateLimited', + service_failed: 'network', + outcome_unknown: 'outcomeUnknown', + recovery_required: 'recovery', + local_storage_unavailable: 'recovery', + sync_journal_unavailable: 'recovery', + sync_restore_rolled_back: 'rolledBack', + payload_too_large: 'tooLarge', + consent_required: 'reviewRequired', + restore_review_required: 'reviewRequired', + sync_conflict_choice_required: 'choicesRequired', + invalid_response: 'invalidData', + sync_documents_invalid: 'invalidData', + sync_documents_invalid_reference: 'invalidData', + sync_documents_duplicate_id: 'invalidData', + sync_documents_excluded_field: 'invalidData', + sync_documents_missing_tombstone: 'invalidData', + sync_documents_unsupported: 'unavailable', + sync_documents_capture_failed: 'localData', + secure_random_unavailable: 'localData', + crypto_worker_failed: 'localData', +} as const; +export type EncryptedSyncErrorKey = (typeof errorKeys)[keyof typeof errorKeys] | 'unknown'; + +export function encryptedSyncErrorKey(error: unknown): EncryptedSyncErrorKey { + if (!error || typeof error !== 'object') return 'unknown'; + const value = error as { message?: unknown; code?: unknown; details?: { reason?: unknown } }; + for (const reason of [value.details?.reason, value.code, value.message]) { + if (typeof reason === 'string' && Object.prototype.hasOwnProperty.call(errorKeys, reason)) { + return errorKeys[reason as keyof typeof errorKeys]; + } + } + return 'unknown'; +} + +/** Advisory form check only; Core owns normalization and the complete policy. */ +export function syncPasswordMeetsBasicRequirements(password: string): boolean { + const normalized = password.normalize('NFC'); + const length = Array.from(normalized).length; + return ( + length >= 12 && + length <= 128 && + /[a-z]/.test(normalized) && + /[A-Z]/.test(normalized) && + /[0-9]/.test(normalized) + ); +} diff --git a/openless-all/app/src/lib/ipc/index.ts b/openless-all/app/src/lib/ipc/index.ts index 971f257c..f2ef4c63 100644 --- a/openless-all/app/src/lib/ipc/index.ts +++ b/openless-all/app/src/lib/ipc/index.ts @@ -161,6 +161,8 @@ export { getQaHotkeyLabel, setQaHotkey, qaWindowDismiss, + qaWindowSetExpanded, + qaGetSnapshot, qaToggleRecording, qaSubmitText, qaSetEditInstructionMode, diff --git a/openless-all/app/src/lib/ipc/qa.ts b/openless-all/app/src/lib/ipc/qa.ts index b560f166..850e8b75 100644 --- a/openless-all/app/src/lib/ipc/qa.ts +++ b/openless-all/app/src/lib/ipc/qa.ts @@ -1,4 +1,4 @@ -import type { QaHotkeyBinding } from '../types'; +import type { QaHotkeyBinding, QaStatePayload } from '../types'; import { invokeOrMock } from './shared'; import { formatComboLabel, defaultQaShortcut } from '../hotkey'; @@ -16,12 +16,24 @@ export function qaWindowDismiss(): Promise { return invokeOrMock('qa_window_dismiss', undefined, () => undefined); } +export function qaWindowSetExpanded(expanded: boolean): Promise { + return invokeOrMock('qa_window_set_expanded', { expanded }, () => undefined); +} + export function qaToggleRecording(): Promise { return invokeOrMock('qa_toggle_recording', undefined, () => undefined); } -export function qaSubmitText(text: string): Promise { - return invokeOrMock('qa_submit_text', { text }, () => undefined); +export function qaSubmitText(text: string, expectedSessionId?: string | null): Promise { + return invokeOrMock( + 'qa_submit_text', + expectedSessionId === undefined ? { text } : { text, expectedSessionId, enforceContext: true }, + () => undefined, + ); +} + +export function qaGetSnapshot(): Promise { + return invokeOrMock('qa_get_snapshot', undefined, () => ({ kind: 'idle', messages: [] })); } export function qaSetEditInstructionMode(enabled: boolean): Promise { diff --git a/openless-all/app/src/main.tsx b/openless-all/app/src/main.tsx index 702ba5d0..057a5fe6 100644 --- a/openless-all/app/src/main.tsx +++ b/openless-all/app/src/main.tsx @@ -5,8 +5,10 @@ import { SplashVideo } from './components/SplashVideo'; import { detectOS } from './components/WindowChrome'; import { i18nReady } from './i18n'; import { initThemeMode } from './lib/themeMode'; +import { installEncryptedSyncUiBridge } from './lib/encryptedSyncUiBridge'; import './styles/tokens.css'; import './styles/global.css'; +import './styles/overlays.css'; import type { OS } from './components/WindowChrome'; @@ -46,4 +48,7 @@ const renderApp = () => { }; // Mount only after the selected local language chunk is ready; avoid mixed-language startup. -void i18nReady.then(renderApp); +void i18nReady.then(async () => { + if (isMainWindow) await installEncryptedSyncUiBridge().catch(() => {}); + renderApp(); +}); diff --git a/openless-all/app/src/pages/LessComputerPanel.tsx b/openless-all/app/src/pages/LessComputerPanel.tsx index 77ac390f..2ee9c483 100644 --- a/openless-all/app/src/pages/LessComputerPanel.tsx +++ b/openless-all/app/src/pages/LessComputerPanel.tsx @@ -1,26 +1,23 @@ -// LessComputerPanel.tsx — Less Computer 语音 Agent 浮窗(窗口 label = "less-computer")。 -// -// 结构 = 官方 shadcn base 组件文档 message-scroller-demo **同款骨架**: -// MessageScrollerProvider → Card(CardHeader 标题/副行/CardAction ✕ → -// CardContent(p-0) 内 MessageScroller / Empty 空状态 → CardFooter 内 -// InputGroup 输入组)。组件源码 1:1 来自官方 registry(components/chat/ui/, -// 仅按 CLI 规则改 import 路径),行为来自 @shadcn/react 官方 primitive。 -// -// 「电脑操控」形态:不带头像 —— 用户指令 = Bubble align="end"(官方 bubble-demo -// 同款);工具调用 = Marker + Spinner/✓ + shimmer(官方 marker-demo 同款); -// 上下文压缩 = Marker separator;思考 = 与转译胶囊一模一样的 SiriGL 流体圆点。 -// -// 事件流:`user`(fresh=true 清空重开)→ delta/tool/compaction/approval 交错 → -// completed(落成本)/ error / cancelled。浮窗首次创建时后端事件可能先于 -// listener 注册到达(webview 冷加载),丢掉 user 事件后其余事件必须自愈补轮, -// 不能对空轮次静默丢弃(真机「后端在跑、前端一片空白」的根因)。 -// -// 窗口固定尺寸(Rust 侧创建即定死 420×540),内容只在滚动框内滚动。 -// 关闭:Esc / ✕ → less_computer_window_dismiss → 后端隐藏窗口。 - +// Less Computer desktop workspace. The event replay and voice projection remain +// authoritative; history/multi-session placeholders never invent executable state. import { useEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from 'react'; import { useTranslation } from 'react-i18next'; -import { ArrowUpIcon, CheckIcon, MessageCircleDashedIcon, XIcon } from 'lucide-react'; +import { + ActivityIcon, + ArrowUpIcon, + CheckIcon, + ChevronRightIcon, + CircleDotIcon, + HistoryIcon, + LayersIcon, + Maximize2Icon, + MicIcon, + MinusIcon, + PlusIcon, + ShieldCheckIcon, + TerminalIcon, + XIcon, +} from 'lucide-react'; import { MessageScroller, MessageScrollerButton, @@ -29,47 +26,37 @@ import { MessageScrollerProvider, MessageScrollerViewport, } from '../components/chat/ui/message-scroller'; -import { - Card, - CardAction, - CardContent, - CardDescription, - CardFooter, - CardHeader, - CardTitle, -} from '../components/chat/ui/card'; -import { - Empty, - EmptyDescription, - EmptyHeader, - EmptyMedia, - EmptyTitle, -} from '../components/chat/ui/empty'; -import { - InputGroup, - InputGroupAddon, - InputGroupButton, - InputGroupInput, -} from '../components/chat/ui/input-group'; -import { Marker, MarkerContent, MarkerIcon } from '../components/chat/ui/marker'; -import { Bubble, BubbleContent } from '../components/chat/ui/bubble'; -import { Button } from '../components/chat/ui/button'; -import { Spinner } from '../components/chat/ui/spinner'; -import { ThinkingOrb } from '../components/chat/avatars'; +import { AgentBuddy, type BuddyColor } from '../components/chat/AgentBuddy'; +import { VoiceWaveform } from '../components/chat/VoiceWaveform'; import { AssistantMarkdown } from '../components/chat/markdown'; import { useChatPanelLifecycle } from '../components/chat/lifecycle'; -import { cn } from '../components/chat/lib/utils'; +import { GithubLoginModal } from '../components/GithubLoginModal'; import { chatPanelFocusKeyboard, + getSettings, isTauri, lessComputerApprove, lessComputerSubmitText, lessComputerSync, lessComputerWindowDismiss, + marketplaceAuthStatus, } from '../lib/ipc'; import { reconcileLessComputerReplay, reduceLessComputerVoice } from '../lib/lessComputerReplay'; -import type { LessComputerEvent, LessComputerVoiceEvent } from '../lib/types'; -import '../components/chat/chat.css'; +import type { + CodingAgentProviderId, + LessComputerEvent, + LessComputerVoiceEvent, + UserPreferences, +} from '../lib/types'; +import './less-computer-panel.css'; + +type Translate = ReturnType['t']; +const AGENTS: { id: CodingAgentProviderId; name: string; color: BuddyColor }[] = [ + { id: 'claude-code-cli', name: 'Claude Code', color: 'coral' }, + { id: 'opencode-cli', name: 'OpenCode', color: 'amber' }, + { id: 'codex-cli', name: 'Codex', color: 'violet' }, + { id: 'dsh-cli', name: 'dsh', color: 'mint' }, +]; type RunStatus = 'idle' | 'working' | 'done' | 'error' | 'cancelled'; @@ -81,7 +68,7 @@ interface TextSegment { interface ToolSegment { kind: 'tool'; name: string; - /** 后端没有工具结束事件:下一个事件(delta/tool/approval/收尾)到达即视为结束。 */ + /** 后端没有工具结束事件:下一个事件到达时仅停止活动指示,不推断工具成功。 */ running: boolean; } @@ -90,7 +77,9 @@ interface ApprovalSegment { token: string; command: string; reason: string; - /** 用户已点过的结果,决定按钮禁用态。undefined = 待处理。 */ + /** Only confirmed IPC results become decisions. Pending requests remain undecided. */ + pending?: boolean; + failed?: boolean; decision?: 'approved' | 'denied'; } @@ -129,75 +118,68 @@ function updateLastTurn(turns: Turn[], fn: (t: Turn) => Turn): Turn[] { return [...list.slice(0, -1), fn(list[list.length - 1])]; } -/** 把流里还在扫光的工具行停下来(下一个事件到达 = 上一个工具已结束)。 */ +/** 把流里还在扫光的工具行停下来(下一个事件到达仅停止工具活动指示)。 */ function settleRunningTools(segments: Segment[]): Segment[] { if (!segments.some((s) => s.kind === 'tool' && s.running)) return segments; return segments.map((s) => (s.kind === 'tool' && s.running ? { ...s, running: false } : s)); } -// 浏览器预览(vite dev,非 Tauri):?window=less-computer&demo=1 注入两轮演示对话 -// (第一轮完成态含成本行;第二轮进行中,覆盖「文本 → 工具行 → 压缩行 → 进行中 -// 工具行 → 审批卡」交错流与新轮次锚定),方便调样式。 -function getPreviewTurns(): Turn[] { - if (isTauri || typeof window === 'undefined') return []; - if (new URLSearchParams(window.location.search).get('demo') !== '1') return []; - return [ - { - user: '看一下下载文件夹里最大的三个文件是什么', - segments: [ - { kind: 'tool', name: 'Bash', running: false }, - { - kind: 'text', - content: - '最大的三个文件:\n1. `Xcode_26.5.xip` — 12.4 GB\n2. `ubuntu-24.04.iso` — 5.8 GB\n3. `设计素材包.zip` — 2.1 GB', - }, - ], - status: 'done', - errorMsg: '', - costUsd: 0.012, - }, - { - user: '帮我把桌面上的截图整理到「本周素材」文件夹', - segments: [ - { kind: 'text', content: '好的,我先看一下桌面上有哪些截图。' }, - { kind: 'tool', name: 'Bash', running: false }, - { kind: 'compaction' }, - { kind: 'text', content: '找到 6 张截图,正在移动并按日期重命名…' }, - { kind: 'tool', name: 'Bash', running: true }, - { - kind: 'approval', - token: 'demo', - command: 'mv ~/Desktop/Screenshot*.png ~/Documents/本周素材/', - reason: 'Moving files outside the working directory.', - }, - ], - status: 'working', - errorMsg: '', - costUsd: null, - }, - ]; -} - -/** macOS movableByWindowBackground 拖动把手(header 区域整条可拖,普通箭头指针)。 */ -const drag = { 'data-tauri-drag-region': true } as const; - -/** 已应用事件的最大 seq。放模块级而不是 effect 闭包:StrictMode/HMR 重挂载时 - * 组件 state 保留,若水位归零会把同一批积压重放两遍、轮次翻倍。后端 seq 全局 - * 单调不回卷(新会话只清缓冲),webview 整页重载时本变量归零、恰好与「需要 - * 完整重放」对齐。 */ +// Keep the replay watermark across StrictMode/HMR effect remounts. A whole +// WebView reload resets both state and watermark so the backend can replay it. let lcAppliedSeq = 0; export function LessComputerPanel() { const { t } = useTranslation(); - // 连续对话:每按一次说话键追加一轮(除非后端标记 fresh=新会话则清空重开)。 - const [turns, setTurns] = useState(getPreviewTurns); + const [turns, setTurns] = useState([]); const [voice, setVoice] = useState(null); - // 新会话计数:fresh 时 +1,作为壳 key 重放入场动画 —— 浮窗是常驻 webview - // (hide/show 复用),没有这个的话再次唤起时内容直接闪现,很突兀。 const [sessionSeq, setSessionSeq] = useState(0); - // 出现/消失动画:后端 show/hide 发 chat-panel:shown / chat-panel:closing。 + const [provider, setProvider] = useState(null); + const [signedIn, setSignedIn] = useState(null); + const [loginOpen, setLoginOpen] = useState(false); + const [windowError, setWindowError] = useState(false); + const turnEpoch = useRef(0); + const approvalRequests = useRef(new Map()); const { enterEpoch, closing } = useChatPanelLifecycle(); + // Read actual configuration and account status; browser previews stay unavailable. + useEffect(() => { + if (!isTauri) return; + let cancelled = false; + let revision = 0; + let unlisten: (() => void) | undefined; + const refresh = async () => { + const current = ++revision; + const results = await Promise.allSettled([getSettings(), marketplaceAuthStatus()]); + if (cancelled || current !== revision) return; + const [settings, account] = results; + setProvider(settings.status === 'fulfilled' ? settings.value.codingAgentProvider : null); + setSignedIn(account.status === 'fulfilled' ? account.value.signedIn : null); + }; + void (async () => { + try { + const { listen } = await import('@tauri-apps/api/event'); + const handle = await listen('prefs:changed', (event) => { + revision += 1; + setProvider(event.payload.codingAgentProvider); + }); + if (cancelled) { + handle(); + return; + } + unlisten = handle; + } catch { + /* focus refresh still works when the optional subscription fails */ + } + if (!cancelled) void refresh(); + })(); + window.addEventListener('focus', refresh); + return () => { + cancelled = true; + unlisten?.(); + window.removeEventListener('focus', refresh); + }; + }, [loginOpen]); + // ── 后端事件订阅(mount 一次)──────────────────────────────────────── // // 冷加载竞态补偿:webview 首次创建需要数百毫秒,后端在此期间 emit 的事件 @@ -243,6 +225,8 @@ export function LessComputerPanel() { if (cancelled) return; const reconciled = reconcileLessComputerReplay(lcAppliedSeq, replay, pending); if (reconciled.reset) { + turnEpoch.current += 1; + approvalRequests.current.clear(); setTurns([]); setVoice(null); } @@ -271,6 +255,8 @@ export function LessComputerPanel() { setVoice((previous) => reduceLessComputerVoice(previous, ev)); break; case 'user': { + turnEpoch.current += 1; + approvalRequests.current.clear(); // 一轮新对话。fresh=true(后端无可续会话→新会话)则清空历史重开;否则追加为后续轮次。 setTurns((prev) => (ev.fresh ? [emptyTurn(ev.text)] : [...prev, emptyTurn(ev.text)])); if (ev.fresh) setSessionSeq((seq) => seq + 1); @@ -366,120 +352,299 @@ export function LessComputerPanel() { } }; - const onApproval = (token: string, approved: boolean) => { - setTurns((prev) => - prev.map((tn) => ({ - ...tn, - segments: tn.segments.map((s) => - s.kind === 'approval' && s.token === token - ? { ...s, decision: approved ? 'approved' : ('denied' as const) } - : s, - ), - })), + const onApproval = async (token: string, approved: boolean) => { + const currentTurn = turns[turns.length - 1]; + if (!isTauri || currentTurn?.status !== 'working' || approvalRequests.current.has(token)) + return; + const card = currentTurn.segments.find( + (segment) => segment.kind === 'approval' && segment.token === token, ); - void lessComputerApprove(token, approved); + if (!card || card.kind !== 'approval' || card.decision) return; + const request = Symbol(token); + const epoch = turnEpoch.current; + approvalRequests.current.set(token, request); + const update = (patch: Partial) => + setTurns((previous) => + previous.map((turn) => ({ + ...turn, + segments: turn.segments.map((segment) => + segment.kind === 'approval' && segment.token === token + ? { ...segment, ...patch } + : segment, + ), + })), + ); + update({ pending: true, failed: false }); + try { + await lessComputerApprove(token, approved); + if (turnEpoch.current === epoch) + update({ pending: false, decision: approved ? 'approved' : 'denied' }); + } catch { + if (turnEpoch.current === epoch) update({ pending: false, failed: true }); + } finally { + if (approvalRequests.current.get(token) === request) approvalRequests.current.delete(token); + } }; - const onClose = () => void lessComputerWindowDismiss(); + const windowAction = async (action: 'hide' | 'minimize' | 'maximize') => { + if (!isTauri) return; + setWindowError(false); + try { + if (action === 'hide') await lessComputerWindowDismiss(); + else { + const { getCurrentWindow } = await import('@tauri-apps/api/window'); + const nativeWindow = getCurrentWindow(); + if (action === 'minimize') await nativeWindow.minimize(); + else await nativeWindow.toggleMaximize(); + } + } catch { + setWindowError(true); + } + }; - // ── Esc 关闭 ──────────────────────────────────────────────────────── useEffect(() => { const onKey = (event: KeyboardEvent) => { - if (event.key === 'Escape') { - event.preventDefault(); - void lessComputerWindowDismiss(); - } + if (event.key !== 'Escape' || event.isComposing || event.keyCode === 229) return; + event.preventDefault(); + if (loginOpen) { + event.stopPropagation(); + setLoginOpen(false); + } else void windowAction('hide'); }; window.addEventListener('keydown', onKey, true); return () => window.removeEventListener('keydown', onKey, true); - }, []); + }, [loginOpen]); - const working = turns.some((tn) => tn.status === 'working'); + const working = turns.some((turn) => turn.status === 'working'); + const latestTurn = turns[turns.length - 1]; + const status = runStatusLabel(latestTurn, t); - // ── 官方 message-scroller-demo 同款骨架 ───────────────────────────── return ( - - +
- - {t('lessComputer.title')} - - {working ? ( - - {t('lessComputer.working')} + + +
+
+ + {t('lessComputer.desktop.currentSession')} +
+
+ - - - - {turns.length === 0 ? ( - - - - - - {t('lessComputer.title')} - {t('lessComputer.subtitle')} - - - ) : ( - - - - {turns.map((turn, ti) => ( - - ))} - - - - + + + +
+
+ +
+ {windowError && ( +

+ {t('lessComputer.desktop.windowError')} +

)} - - +
+

{t('lessComputer.title')}

+ + + {status} + +
+
+ + {turns.length === 0 ? ( +
+ +

{t('lessComputer.subtitle')}

+

{t('lessComputer.desktop.emptyHint')}

+ + + {t('lessComputer.desktop.voiceHint')} + +
+ ) : ( + + + + {turns.map((turn, index) => ( + + ))} + + + + + )} +
+
-
- - +
+ +
+ {loginOpen && isTauri && ( + setLoginOpen(false)} + onSuccess={() => { + setSignedIn(true); + setLoginOpen(false); + }} + /> + )} + ); } -// ── 底部输入区:官方 demo 同款 InputGroup,打字 + 语音两种形式完整 ──── -// -// · 打字:单行输入 + 右下发送(官方 demo 的 block-end addon 布局);Enter 走 -// 表单提交;IME 组合中的 Enter(选字确认)不触发(isComposing/keyCode 229 -// 守卫)。点进输入框时 chat_panel_focus_keyboard 让非激活面板成为 key window -// (不激活 app,主窗口不动)。 -// · 语音:录音红光、转译思考黑光绕输入组一圈圈跑(olchat-ring),输入框本体 -// 保持可见;只显示Core提供的voice_state,与聊天事件共用seq重放和session归属。 +function runStatusLabel(turn: Turn | undefined, t: Translate): string { + if (!turn) return t('lessComputer.desktop.idle'); + if ( + turn.status === 'working' && + turn.segments.some((segment) => segment.kind === 'approval' && !segment.decision) + ) + return t('lessComputer.desktop.waitingApproval'); + if (turn.status === 'working') return t('lessComputer.working'); + if (turn.status === 'done') return t('lessComputer.done'); + if (turn.status === 'cancelled') return t('common.cancelled'); + if (turn.status === 'error') return t('lessComputer.error'); + return t('lessComputer.desktop.idle'); +} + +function voiceTime(elapsedMs: number): string { + const seconds = Math.floor(Math.max(0, Number.isFinite(elapsedMs) ? elapsedMs : 0) / 1000); + return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`; +} + function Composer({ working, voice, @@ -487,272 +652,365 @@ function Composer({ }: { working: boolean; voice: LessComputerVoiceEvent | null; - t: ReturnType['t']; + t: Translate; }) { const [text, setText] = useState(''); - const busy = working || (voice !== null && voice.phase !== 'idle'); - // 输入组环形光:录音红光 → 转译黑光 → 指令落定(agent 已在跑)即停。 - const ring = - voice?.phase === 'recording' - ? 'recording' - : (voice?.phase === 'starting' || voice?.phase === 'transcribing') && !working - ? 'thinking' - : undefined; - // IME 组合期间的 Enter 是「选字确认」不是「发送」。keydown 里 isComposing - // 已覆盖大部分场景,keyCode 229 兜底 WebKit 老行为。 + const [submitting, setSubmitting] = useState(false); + const [failed, setFailed] = useState(false); + const submittingRef = useRef(false); const composingRef = useRef(false); - - const send = () => { + const speaking = voice !== null && voice.phase !== 'idle'; + const busy = working || speaking || submitting; + const voiceLabel = + voice?.phase === 'recording' + ? t('overview.inAppDictation.recording') + : voice?.phase === 'starting' + ? t('common.loading') + : t('overview.inAppDictation.processing'); + const send = async () => { const trimmed = text.trim(); - if (!trimmed || busy) return; - setText(''); - void lessComputerSubmitText(trimmed); - }; - - const onKeyDown = (event: ReactKeyboardEvent) => { - if (event.key !== 'Enter') return; - if (composingRef.current || event.nativeEvent.isComposing || event.keyCode === 229) { - event.preventDefault(); + if (!isTauri || !trimmed || busy || submittingRef.current || composingRef.current) return; + submittingRef.current = true; + setSubmitting(true); + setFailed(false); + try { + await lessComputerSubmitText(trimmed); + // Keep a draft typed while the IPC was pending. The actual user event owns the chat. + setText((current) => (current === text ? '' : current)); + } catch { + setFailed(true); + } finally { + submittingRef.current = false; + setSubmitting(false); } }; - + const onKeyDown = (event: ReactKeyboardEvent) => { + if (event.key !== 'Enter' || event.shiftKey) return; + event.preventDefault(); + if (composingRef.current || event.nativeEvent.isComposing || event.keyCode === 229) return; + void send(); + }; return ( -
{ - event.preventDefault(); - send(); - }} - className="w-full" - > - - setText(event.currentTarget.value)} - onKeyDown={onKeyDown} - onCompositionStart={() => { - composingRef.current = true; - }} - onCompositionEnd={() => { - composingRef.current = false; - }} - onFocus={() => void chatPanelFocusKeyboard()} - onPointerDown={() => void chatPanelFocusKeyboard()} - /> - - {voice && voice.phase !== 'idle' && ( - - {voice.phase === 'recording' - ? t('overview.inAppDictation.recording') - : voice.phase === 'starting' - ? t('common.loading') - : t('overview.inAppDictation.processing')} - {voice.phase === 'recording' && ( - + { + event.preventDefault(); + void send(); + }} + > +
+