diff --git a/Cargo.lock b/Cargo.lock index 84682bd996..b8afdeaf23 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -275,18 +275,6 @@ 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 0.5.0", -] - [[package]] name = "arrayref" version = "0.3.9" @@ -723,15 +711,6 @@ 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-buffer" version = "0.10.4" @@ -6326,9 +6305,7 @@ dependencies = [ name = "openbitfun-relay-service" version = "1.0.0" dependencies = [ - "aes-gcm", "anyhow", - "argon2", "axum", "base64 0.22.1", "chrono", @@ -6337,12 +6314,13 @@ dependencies = [ "libsqlite3-sys", "openbitfun-page-function-runtime", "rand 0.8.7", + "reqwest", "rusqlite", + "rustls", "serde", "serde_json", "sha2", "sqlx", - "subtle", "tempfile", "tokio", "tokio-tungstenite", @@ -6350,6 +6328,7 @@ dependencies = [ "tower-http", "tracing", "uuid", + "webpki-roots 1.0.9", ] [[package]] @@ -6495,7 +6474,6 @@ dependencies = [ "aes", "aes-gcm", "anyhow", - "argon2", "async-trait", "base64 0.22.1", "bzip2 0.5.2", @@ -6507,6 +6485,7 @@ dependencies = [ "futures-util", "git2", "hex", + "hkdf", "hostname", "image 0.25.10", "keyring-core", @@ -7229,17 +7208,6 @@ dependencies = [ "subtle", ] -[[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" @@ -7260,7 +7228,7 @@ checksum = "83a0692ec44e4cf1ef28ca317f14f8f07da2d95ec3fa01f86e4467b725e60917" dependencies = [ "digest", "hmac", - "password-hash 0.4.2", + "password-hash", "sha2", ] diff --git a/Cargo.toml b/Cargo.toml index b1045de0ef..17b429e12c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -254,6 +254,7 @@ unic-langid = "0.9" x25519-dalek = { version = "2.0", features = ["static_secrets"] } aes-gcm = "0.10" sha2 = "0.10" +hkdf = "0.12" minisign-verify = "0.2" sha1 = "0.10" argon2 = "0.5" diff --git a/OpenBitFun-Installer/src/i18n/generatedLocaleContract.ts b/OpenBitFun-Installer/src/i18n/generatedLocaleContract.ts index c8ff617087..27ca795775 100644 --- a/OpenBitFun-Installer/src/i18n/generatedLocaleContract.ts +++ b/OpenBitFun-Installer/src/i18n/generatedLocaleContract.ts @@ -86,7 +86,6 @@ export const SHARED_TERMS_BY_APP_LANGUAGE = { }, "connectionMethods": { "lan": "LAN", - "ngrok": "Ngrok", "openbitfunServer": "OpenBitFun Server", "customServer": "Custom Server", "botFeishu": "Feishu Bot", @@ -136,7 +135,6 @@ export const SHARED_TERMS_BY_APP_LANGUAGE = { }, "connectionMethods": { "lan": "局域网", - "ngrok": "Ngrok", "openbitfunServer": "OpenBitFun Server", "customServer": "自定义服务器", "botFeishu": "飞书机器人", @@ -186,7 +184,6 @@ export const SHARED_TERMS_BY_APP_LANGUAGE = { }, "connectionMethods": { "lan": "區域網路", - "ngrok": "Ngrok", "openbitfunServer": "OpenBitFun Server", "customServer": "自訂伺服器", "botFeishu": "飛書機器人", diff --git a/deploy/miniapp-market/README.md b/deploy/miniapp-market/README.md index c3b0ffd2fb..e6dec705b1 100644 --- a/deploy/miniapp-market/README.md +++ b/deploy/miniapp-market/README.md @@ -472,3 +472,24 @@ curl -s -o /dev/null -w "%{http_code} %{redirect_url}\n" \ 每日备份保留 14 份,周备份保留 8 份。备份脚本的删除范围已经限制在市场专用 backup root;不要放宽该保护。 + +## 统一 GitHub 登录入口 + +`https://auth.openbitfun.com` 是市场与远控共用的登录入口,桌面授权完成页为 +`/complete`,身份 API 为 `/api/v1`。它复用本服务和数据库;不新增账号库。 +安装同目录 `nginx-auth.openbitfun.com.conf` 前,先确认该域名的 DNS、TLS/WAF +已覆盖,并配置当前可信 WAF 源网段。该配置只开放登录、身份、token 生命周期和 +页面静态资源,不代理市场写接口。 + +GitHub OAuth App 的注册 callback 保持 +`https://market.openbitfun.com/miniapp/api/v1/auth/github/callback`。回调仍在市场 +host 上写入两组 host-only Cookie(`/miniapp` 与 `/skin`),然后桌面授权跳转到 +独立完成页;不要通过 `Domain=.openbitfun.com` 扩大 Cookie 信任范围。桌面 +poll token 仍受一次性 transaction secret 约束。旧市场 API 与旧完成页路径保留, +已有安装无需手动迁移。 + +发布顺序:先更新本服务及新增 auth vhost,验证 `/complete`、`/sign-in` 跳转、 +匿名 `/api/v1/me` 返回 401、desktop start/poll 和旧市场 API;再发布使用新身份 +API 的 Relay/客户端。两个市场各自的网页仍在所属 Compose 项目中构建发布。 +回滚时先让新客户端/Relay 恢复旧 API,再撤回 auth vhost;不能让已发布客户端的 +身份地址失效。旧 Relay 和已有市场业务数据均不在这个 vhost 的变更范围内。 diff --git a/deploy/miniapp-market/nginx-auth.openbitfun.com.conf b/deploy/miniapp-market/nginx-auth.openbitfun.com.conf new file mode 100644 index 0000000000..97d87c5fed --- /dev/null +++ b/deploy/miniapp-market/nginx-auth.openbitfun.com.conf @@ -0,0 +1,50 @@ +# Shared GitHub sign-in facade. Install beside the market host; keep the +# registered GitHub callback on market.openbitfun.com so host-only marketplace +# cookies remain isolated from other subdomains. Requires relay-v1.conf's +# trusted WAF peer map and the existing miniapp_market_json log format. +server { + listen 80; + listen [::]:80; + server_name auth.openbitfun.com; + if ($relay_v1_trusted_origin_peer = 0) { return 403; } + access_log /var/log/nginx/auth.openbitfun.com.access.log miniapp_market_json; + error_log /dev/null crit; + real_ip_header X-Forwarded-For; + real_ip_recursive on; + set_real_ip_from 190.92.193.0/24; + set_real_ip_from 159.138.94.0/24; + set_real_ip_from 101.44.169.0/24; + set_real_ip_from 2405:f080:110::/120; + client_max_body_size 16k; + add_header X-Content-Type-Options nosniff always; + add_header Referrer-Policy no-referrer always; + add_header X-Frame-Options DENY always; + + proxy_set_header Host market.openbitfun.com; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_connect_timeout 5s; + proxy_read_timeout 30s; + + location = /sign-in { + proxy_pass http://127.0.0.1:9710/miniapp/api/v1/auth/github/start; + } + location = / { + proxy_pass http://127.0.0.1:9710/miniapp/auth/complete; + } + location = /complete { + proxy_pass http://127.0.0.1:9710/miniapp/auth/complete; + } + # Bearer-token APIs only; marketplace mutations are not exposed here. + location ~ ^/api/v1/(me|auth/desktop/(start|poll)|auth/(refresh|logout))$ { + rewrite ^/api/v1/(.*)$ /miniapp/api/v1/$1 break; + proxy_pass http://127.0.0.1:9710; + } + location ^~ /miniapp/assets/ { + proxy_pass http://127.0.0.1:9710; + } + location ~ ^/miniapp/(theme-init\.js|favicon\.(ico|svg)|apple-touch-icon\.png|site\.webmanifest)$ { + proxy_pass http://127.0.0.1:9710; + } + location / { return 404; } +} diff --git a/deploy/relay-v1/README.md b/deploy/relay-v1/README.md new file mode 100644 index 0000000000..f4d57ac3c8 --- /dev/null +++ b/deploy/relay-v1/README.md @@ -0,0 +1,32 @@ +# Official Relay v1 deployment + +The owner guide is [Relay Server](../../src/apps/relay-server/README.md). +Use this independent Compose project for `/v/1.0.0/`. Keep the older Relay +container, paths, image, database, and `/relay` proxy location intact. + +Deploy from a committed checkout at `/srv/openbitfun-relay-v1/app`. Set +`RELAY_GIT_COMMIT` to that checkout's verified full commit. Build mobile web +from the same checkout with `pnpm run build:mobile-web` and stage its `dist` +contents into `/srv/openbitfun-relay-v1/static`. Create `data` and `assets` +under that root owned by UID/GID 10001 before starting Compose. + +The Linux host network plus explicit `127.0.0.1:19700` listener lets the service +verify the immediate proxy peer before trusting its overwritten forwarded IP. +Do not publish this listener on a public interface. Install `nginx-http.conf` in the Nginx http context and include +`nginx-location.conf` in the existing remote server after the container passes +its health check. The new location accepts the existing explicit WAF origin +ranges and loopback; direct origin requests from other peers receive 403. +Forwarded client IPs are recursively resolved only for those trusted WAF +peers. Keep the range list synchronized with the WAF control plane. Raise +`worker_connections` to 8192 and retain a file descriptor limit of at least +16384; validate with `nginx -t` before a graceful reload. + +Published Pages are disabled with an explicit 503 until both isolated public +and sign-in origins are configured. This prevents uploaded content from sharing +the mobile controller's account origin. Enabling Pages requires dedicated +origins, their proxy routes, and the Page isolation verification in the owner +guide; setting an arbitrary origin value alone is insufficient. + +Before replacement, back up this version's database and assets and retain the +previous image tag. Roll back only this Compose project and its versioned +location. Never use the legacy relay Compose file to operate this deployment. diff --git a/deploy/relay-v1/compose.yml b/deploy/relay-v1/compose.yml new file mode 100644 index 0000000000..ccacf08505 --- /dev/null +++ b/deploy/relay-v1/compose.yml @@ -0,0 +1,50 @@ +name: openbitfun-relay-v1 +services: + relay-v1: + image: openbitfun-relay-v1:${RELAY_GIT_COMMIT:?Set the verified source commit} + build: + context: ../.. + dockerfile: src/apps/relay-server/Dockerfile + args: + RELAY_GIT_COMMIT: ${RELAY_GIT_COMMIT:?Set the verified source commit} + CARGO_BUILD_JOBS: "4" + container_name: openbitfun-relay-v1 + network_mode: host + user: "10001:10001" + read_only: true + cap_drop: [ALL] + security_opt: [no-new-privileges:true] + restart: unless-stopped + cpus: 4 + mem_limit: 4g + pids_limit: 256 + ulimits: + nofile: + soft: 16384 + hard: 16384 + tmpfs: + - /tmp:size=64m,mode=1777 + environment: + RELAY_PORT: "19700" + RELAY_LISTEN_ADDR: 127.0.0.1:19700 + RELAY_STATIC_DIR: /app/static + RELAY_DB_PATH: /app/data/relay.db + RELAY_ROOM_WEB_DIR: /app/room-web + RELAY_ASSET_STORE_MAX_BYTES: "1073741824" + RELAY_CORS_ALLOW_ORIGINS: https://remote.openbitfun.com + RUST_LOG: info + volumes: + - /srv/openbitfun-relay-v1/data:/app/data + - /srv/openbitfun-relay-v1/assets:/app/room-web + - /srv/openbitfun-relay-v1/static:/app/static:ro + logging: + driver: json-file + options: + max-size: 10m + max-file: "3" + healthcheck: + test: [CMD, curl, -fsS, http://127.0.0.1:19700/health] + interval: 15s + timeout: 5s + retries: 5 + start_period: 30s diff --git a/deploy/relay-v1/nginx-http.conf b/deploy/relay-v1/nginx-http.conf new file mode 100644 index 0000000000..84d91eb326 --- /dev/null +++ b/deploy/relay-v1/nginx-http.conf @@ -0,0 +1,23 @@ +# Install in /etc/nginx/conf.d/relay-v1.conf (http context). +map $http_upgrade $relay_v1_connection_upgrade { + default upgrade; + '' close; +} +# Same explicit Huawei WAF origin ranges as the existing market deployment. +# Refresh from the WAF control plane when its origin source ranges change. +geo $realip_remote_addr $relay_v1_trusted_origin_peer { + default 0; + 127.0.0.1 1; + ::1 1; + 190.92.193.0/24 1; + 159.138.94.0/24 1; + 101.44.169.0/24 1; + 2405:f080:110::/120 1; +} +limit_conn_zone $binary_remote_addr zone=relay_v1_client_connections:10m; +limit_conn_zone $server_name zone=relay_v1_global_connections:1m; +limit_req_zone $binary_remote_addr zone=relay_v1_client_requests:10m rate=100r/s; +limit_req_zone $server_name zone=relay_v1_global_requests:1m rate=2000r/s; +log_format relay_v1_json escape=json + '{"time":"$time_iso8601","method":"$request_method","path":"$uri",' + '"status":$status,"bytes":$body_bytes_sent,"requestTime":$request_time}'; diff --git a/deploy/relay-v1/nginx-location.conf b/deploy/relay-v1/nginx-location.conf new file mode 100644 index 0000000000..7ae1498819 --- /dev/null +++ b/deploy/relay-v1/nginx-location.conf @@ -0,0 +1,39 @@ +# Include inside the existing remote.openbitfun.com server block. +location = /v/1.0.0 { + return 308 /v/1.0.0/; +} +location ^~ /v/1.0.0/ { + # Trust forwarded addresses only from the existing explicit WAF peers. + # Recursive extraction selects the last non-trusted address, not a spoofed + # client-supplied leftmost XFF entry. Relay receives one overwritten value. + set_real_ip_from 190.92.193.0/24; + set_real_ip_from 159.138.94.0/24; + set_real_ip_from 101.44.169.0/24; + set_real_ip_from 2405:f080:110::/120; + real_ip_header X-Forwarded-For; + real_ip_recursive on; + if ($relay_v1_trusted_origin_peer = 0) { return 403; } + + limit_conn relay_v1_client_connections 256; + limit_conn relay_v1_global_connections 6144; + limit_conn_status 429; + limit_req zone=relay_v1_client_requests burst=200 nodelay; + limit_req zone=relay_v1_global_requests burst=1000 nodelay; + limit_req_status 429; + + proxy_pass http://127.0.0.1:19700/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $relay_v1_connection_upgrade; + proxy_buffering off; + proxy_request_buffering off; + client_max_body_size 49m; + client_body_timeout 15s; + proxy_connect_timeout 5s; + proxy_read_timeout 140s; + proxy_send_timeout 30s; + access_log /var/log/nginx/relay-v1-access.log relay_v1_json; +} diff --git a/docs/architecture/peer-device-mode.md b/docs/architecture/peer-device-mode.md index 5601e16f35..870d678466 100644 --- a/docs/architecture/peer-device-mode.md +++ b/docs/architecture/peer-device-mode.md @@ -284,8 +284,8 @@ FS) and must not be mixed with Peer Device Mode. lifetime. Disconnect joins cancellation; replacing or dropping the client retires the old socket and its reconnect attempts. A generation fence prevents an old connection from publishing state into its replacement. Initial dial - failure returns to `Disconnected`. Reconnect restores room/account context, - including a server-assigned room id, before admitting new outgoing commands. + failure returns to `Disconnected`. Reconnect verifies the selected Relay account and device context before + admitting new outgoing commands. Anonymous room contexts are not supported. The outgoing queue holds at most 64 messages and reports saturation explicitly; its failed-socket contents are never replayed. Dial/write deadlines are 15s, heartbeat cadence is 30s, with due heartbeats taking priority over queued diff --git a/docs/architecture/rust-build-dependency-boundaries.md b/docs/architecture/rust-build-dependency-boundaries.md index 1873ee621a..92d0943135 100644 --- a/docs/architecture/rust-build-dependency-boundaries.md +++ b/docs/architecture/rust-build-dependency-boundaries.md @@ -101,6 +101,7 @@ Plugin Source 和完整 domain feature 集合一起带回 Agent Runtime。产品 - 真正创建 client 的 app、service 或 adapter 必须在自身依赖声明中显式选择实际使用的 Reqwest feature 和 provider-neutral 的 `reqwest/rustls-no-provider`;只使用 `reqwest::Url` 的 contract/assembly 路径不加载传输能力; - capability crate 的每个 Reqwest owner feature 必须独立带齐自己的数据/传输 feature、`reqwest/rustls-no-provider` 和进程级 TLS provider owner,不能依赖 `product-full` 或其他 feature 的 Cargo feature-union 偶然补齐; - workspace 级 `rustls` 只统一兼容版本并关闭默认 feature;`services-core/tls-provider` 是内置 crypto provider 的唯一 owner,精确选择并安装 `ring`、`std` 和 `tls12`。产品进程入口或集中 client helper 必须在构造 TLS client 前确保该 provider 已安装; +- 独立 Docker 构建的 Relay 身份校验器是限定例外:它不链接 workspace 服务 facade,只能将显式 ring `ClientConfig` 绑定到自己的 Reqwest client,不能安装或替换进程级 provider。该例外只覆盖 `relay-service/src/identity.rs`,边界检查同时强制 client 绑定并禁止 `install_default`;嵌入式产品的进程 provider 仍由 `services-core` 拥有。 - 边界检查以 Cargo metadata 的解码结果看护全部直接 consumer,并检查 resolved Reqwest/Rustls feature union,拒绝缺失 provider、同时选择多个 provider、传递依赖重新激活 AWS-LC 或 Native TLS,以及绕过集中 helper 的 Reqwest client 构造; - 不并列启用 Native TLS 或 AWS-LC 兼容栈。只有真实产品场景无法由当前 Ring/Rustls 平台证书验证承载时,才以明确行为证据评审替换方案;替换时由同一 owner 切换 provider,不能在同一产品闭包叠加第二后端。 diff --git a/docs/development/communications-e2e.zh-CN.md b/docs/development/communications-e2e.zh-CN.md index 67057ee457..9d1729fd1b 100644 --- a/docs/development/communications-e2e.zh-CN.md +++ b/docs/development/communications-e2e.zh-CN.md @@ -284,7 +284,7 @@ SSH 与账号设备 RPC 两种传输各跑一轮;目标分别为 CLI daemon | EXT-08 | 模型 SSE/WebSocket:多字节/JSON 跨块、首包超时、流中断、429、5xx | 调用与执行状态保真;不把部分响应当完整,也不重复已有工具副作用 | | EXT-09 | AI relay 模型请求来自本地/peer/dispatch 不同执行端 | 使用声明的模型 provider 和凭据域;断线不能偷偷改变执行宿主 | | EXT-10 | Plugin Host IPC、SDK stdio:帧边界、correlation、取消、worker 崩溃、背压 | 各协议独立限制与生命周期成立;不复用客户端数复制 Runtime owner | -| EXT-11 | LAN、ngrok/自建 Relay、嵌入式 Relay 运行手机主线 | 同一共享路由契约;公网地址变化、端口占用和服务停止明确反映 | +| EXT-11 | 官方 Relay 与 LAN 嵌入式 Relay 运行同一账号设备主线 | 同一共享路由契约;公网地址变化、端口占用和服务停止明确反映 | | EXT-12 | 发布页面/附件上传、读取、账号 sync 大包与设备 RPC 并发 | 各路由认证和大小上限一致;没有把 HTTP body limit 当业务完整性保证 | ## 5. 本地自动验证入口 diff --git a/docs/interactive-capabilities/README.md b/docs/interactive-capabilities/README.md index a54bf7855e..41201e92aa 100644 --- a/docs/interactive-capabilities/README.md +++ b/docs/interactive-capabilities/README.md @@ -1,9 +1,9 @@ # OpenBitFun 功能与设置目录 / OpenBitFun Features & Settings -OpenBitFun Playbook 当前包含 **22 个功能**和 **21 个设置页**,共 **43 个**用户可理解的条目、**322 项**有源码证据的子能力。每个条目有独立 Markdown,并直接服务于说明书网站、OpenBitFun 全局搜索和 `OpenBitFunControl` Agent 工具。 +OpenBitFun Playbook 当前包含 **22 个功能**和 **21 个设置页**,共 **43 个**用户可理解的条目、**319 项**有源码证据的子能力。每个条目有独立 Markdown,并直接服务于说明书网站、OpenBitFun 全局搜索和 `OpenBitFunControl` Agent 工具。 -OpenBitFun Playbook currently contains **22 features**, **21 settings pages**, and **322** source-backed sub-capabilities across **43** user-facing entries. Every entry has its own Markdown page and directly powers the website, in-app global search, and the `OpenBitFunControl` agent tool. +OpenBitFun Playbook currently contains **22 features**, **21 settings pages**, and **319** source-backed sub-capabilities across **43** user-facing entries. Every entry has its own Markdown page and directly powers the website, in-app global search, and the `OpenBitFunControl` agent tool. ## 唯一事实源 / Single source of truth @@ -27,20 +27,20 @@ OpenBitFun Playbook currently contains **22 features**, **21 settings pages**, a - Generated per-item interaction audit: `docs/interactive-capabilities/technical/product-control-open-audit.json` - Generated low-level audit map: `docs/interactive-capabilities/technical/tauri-command-map.json` -说明书、网站、搜索和 Agent 只看“功能 + 设置 + 子能力”。每项子能力都必须引用已注册 Tauri Command 或可解析的源码标记;这些证据不会进入公开目录。当前 **666** 个 Tauri 命令只用于实现覆盖审计。产品 UI 交互源码会在生成和检查时扫描并校验,但不会保存成随普通 UI 改动频繁变化的版本化快照。 +说明书、网站、搜索和 Agent 只看“功能 + 设置 + 子能力”。每项子能力都必须引用已注册 Tauri Command 或可解析的源码标记;这些证据不会进入公开目录。当前 **643** 个 Tauri 命令只用于实现覆盖审计。产品 UI 交互源码会在生成和检查时扫描并校验,但不会保存成随普通 UI 改动频繁变化的版本化快照。 -Docs, website, search, and agents see only features, settings, and documented sub-capabilities. Every sub-capability must reference a registered Tauri command or a resolvable source marker; evidence is stripped from public projections. The **666** Tauri commands remain implementation-audit evidence only. Product UI interaction sources are scanned and validated during generation and checks, but are not stored as a versioned snapshot that churns with ordinary UI changes. +Docs, website, search, and agents see only features, settings, and documented sub-capabilities. Every sub-capability must reference a registered Tauri command or a resolvable source marker; evidence is stripped from public projections. The **643** Tauri commands remain implementation-audit evidence only. Product UI interaction sources are scanned and validated during generation and checks, but are not stored as a versioned snapshot that churns with ordinary UI changes. ## 控制边界 / Control boundary -- 每个子能力都明确标记为直接控制、委托给专用 Agent 工具、需交互打开或不支持;“打开页面”不会再被统计成“Agent 已控制”。当前覆盖:直接 **48**、委托 **61**、需交互 **213**、不支持 **0**。 +- 每个子能力都明确标记为直接控制、委托给专用 Agent 工具、需交互打开或不支持;“打开页面”不会再被统计成“Agent 已控制”。当前覆盖:直接 **48**、委托 **61**、需交互 **210**、不支持 **0**。 - 稳定行为声明为带 JSON 输入契约的 `operations` 或 `options`,并绑定原生产品控制 Provider;Agent 不接触原始 Tauri Command。 -- `OpenBitFunControl list` 和 `search` 都返回带 `nextCursor` 的精简分页结果;目录可持续增长,不靠固定总量上限。完整目录和 322 项子能力都不会写入 system prompt。 +- `OpenBitFunControl list` 和 `search` 都返回带 `nextCursor` 的精简分页结果;目录可持续增长,不靠固定总量上限。完整目录和 319 项子能力都不会写入 system prompt。 - 目录发现与契约读取不依赖 React 或可见窗口。普通配置型 option 统一由 Product Assembly 的共享 ConfigService 执行器读、写并回读,因此 Desktop、CLI 与 Headless 表面走同一份实现;只有宿主原生 operation/provider option 和界面导航按表面注册适配器,缺失时必须明确返回不可用,禁止静默回退本机。只读 Agent 只能发现和读取目录。 -- Every documented item is classified as direct control, delegated Agent control, interactive opening, or unsupported; opening a page is never counted as direct control. Current coverage is **48 direct**, **61 delegated**, **213 interactive**, and **0 unsupported**. +- Every documented item is classified as direct control, delegated Agent control, interactive opening, or unsupported; opening a page is never counted as direct control. Current coverage is **48 direct**, **61 delegated**, **210 interactive**, and **0 unsupported**. - Stable behavior becomes a typed `operation` or `option` with a JSON input contract and a native product-control provider. Agents never receive raw Tauri commands. -- `OpenBitFunControl list` and `search` return compact pages with a `nextCursor`; the catalog can grow without a fixed total-size ceiling. Neither the full catalog nor its 322 documented items enters the system prompt. +- `OpenBitFunControl list` and `search` return compact pages with a `nextCursor`; the catalog can grow without a fixed total-size ceiling. Neither the full catalog nor its 319 documented items enters the system prompt. - Discovery and contract lookup do not depend on React or a visible window. Ordinary config-backed options are read, written, and read back by one Product Assembly ConfigService executor shared by Desktop, CLI, and headless surfaces. Only host-native operations/provider options and presentation routes install surface adapters; missing adapters return explicit unavailability without local fallback. Read-only agents may only discover and inspect entries. ## 防腐化门禁 / Anti-drift gates diff --git a/docs/interactive-capabilities/capabilities.json b/docs/interactive-capabilities/capabilities.json index 267f045143..79004fea76 100644 --- a/docs/interactive-capabilities/capabilities.json +++ b/docs/interactive-capabilities/capabilities.json @@ -4,7 +4,7 @@ "title": "OpenBitFun Playbook", "origin": "https://playbook.openbitfun.com", "source": "src/shared/interactive-capabilities/catalog.json", - "digest": "409e6dbef7ebceafccc11606e2855227b9a33fa1ed71f5b4608da04e8a23de7d", + "digest": "896281a3cd5cac2b50ec607988e04624224444911ddb2d58ba59a5d7d06493c5", "ownerDigest": "c0e5c187cf62bc6ed06196ce8520b3eb427bf268cf24659b72d2552fb1d99c54", "searchAcceptance": [ { @@ -138,11 +138,11 @@ "features": 22, "settings": 21, "userFacing": 43, - "documentedItems": 322, + "documentedItems": 319, "controlCoverage": { "direct": 48, "delegated": 61, - "interactive": 213, + "interactive": 210, "unsupported": 0 } }, @@ -8666,11 +8666,8 @@ "start-stop-status", "network", "bots", - "relay-wizard", "account", "devices", - "session-sync", - "settings-sync", "peer-device" ], "kind": "query", @@ -8896,52 +8893,6 @@ "eventName": "openbitfun:open-remote-connect" } }, - { - "id": "feature.remote-connect:open:relay-wizard", - "capabilityId": "feature.remote-connect", - "itemIds": [ - "relay-wizard" - ], - "kind": "open", - "risk": "ui", - "executionHost": "presentationSurface", - "availability": { - "desktop": { - "available": true - }, - "cli": { - "available": false, - "reason": "This delivery profile has no live presentation surface" - }, - "peer": { - "available": true, - "requiredCapabilities": [ - "product_control_v1", - "product_control_presentation_v1" - ] - }, - "remoteControl": { - "available": true - }, - "detachedDispatch": { - "available": false, - "reason": "This delivery profile has no live presentation surface" - } - }, - "inputSchema": { - "type": "object", - "additionalProperties": false - }, - "outputSchema": { - "type": "object", - "additionalProperties": true - }, - "openReason": "unstructuredInteraction", - "presentationTarget": { - "kind": "event", - "eventName": "openbitfun:open-remote-connect" - } - }, { "id": "feature.remote-connect:open:account", "capabilityId": "feature.remote-connect", @@ -9034,98 +8985,6 @@ "eventName": "openbitfun:open-remote-connect" } }, - { - "id": "feature.remote-connect:open:session-sync", - "capabilityId": "feature.remote-connect", - "itemIds": [ - "session-sync" - ], - "kind": "open", - "risk": "ui", - "executionHost": "presentationSurface", - "availability": { - "desktop": { - "available": true - }, - "cli": { - "available": false, - "reason": "This delivery profile has no live presentation surface" - }, - "peer": { - "available": true, - "requiredCapabilities": [ - "product_control_v1", - "product_control_presentation_v1" - ] - }, - "remoteControl": { - "available": true - }, - "detachedDispatch": { - "available": false, - "reason": "This delivery profile has no live presentation surface" - } - }, - "inputSchema": { - "type": "object", - "additionalProperties": false - }, - "outputSchema": { - "type": "object", - "additionalProperties": true - }, - "openReason": "unstructuredInteraction", - "presentationTarget": { - "kind": "event", - "eventName": "openbitfun:open-remote-connect" - } - }, - { - "id": "feature.remote-connect:open:settings-sync", - "capabilityId": "feature.remote-connect", - "itemIds": [ - "settings-sync" - ], - "kind": "open", - "risk": "ui", - "executionHost": "presentationSurface", - "availability": { - "desktop": { - "available": true - }, - "cli": { - "available": false, - "reason": "This delivery profile has no live presentation surface" - }, - "peer": { - "available": true, - "requiredCapabilities": [ - "product_control_v1", - "product_control_presentation_v1" - ] - }, - "remoteControl": { - "available": true - }, - "detachedDispatch": { - "available": false, - "reason": "This delivery profile has no live presentation surface" - } - }, - "inputSchema": { - "type": "object", - "additionalProperties": false - }, - "outputSchema": { - "type": "object", - "additionalProperties": true - }, - "openReason": "unstructuredInteraction", - "presentationTarget": { - "kind": "event", - "eventName": "openbitfun:open-remote-connect" - } - }, { "id": "feature.remote-connect:open:peer-device", "capabilityId": "feature.remote-connect", @@ -23826,7 +23685,7 @@ "微信", "多设备", "Peer Device", - "账户同步" + "GitHub" ], "keywordsEn": [ "remote connect", @@ -23837,28 +23696,28 @@ "WeChat", "multi-device", "peer device", - "account sync" + "GitHub" ], "highlightsZh": [ - "通过局域网、Ngrok 或自建 Relay 连接", + "通过局域网或官方 Relay 连接", "接入飞书、Telegram 或微信 Bot", - "登录账户、同步会话并进入 Peer Device Mode" + "使用 GitHub 身份管理设备并进入 Peer Device Mode" ], "highlightsEn": [ - "Connect through LAN, Ngrok, or a self-hosted relay", + "Connect through LAN or the official Relay", "Use Feishu, Telegram, or WeChat bots", - "Sign in, sync sessions, and enter Peer Device Mode" + "Use GitHub identity to manage devices and enter Peer Device Mode" ], "items": [ { "id": "connection-methods", - "titleZh": "选择局域网、Ngrok、官方 Relay、自建 Relay 或自定义服务器", - "titleEn": "Choose LAN, Ngrok, the hosted relay, a self-hosted relay, or a custom server", + "titleZh": "选择局域网或官方 Relay", + "titleEn": "Choose LAN or the official Relay", "control": { "kind": "open", "reasonCode": "unstructuredInteraction", - "reasonZh": "“选择局域网、Ngrok、官方 Relay、自建 Relay 或自定义服务器”由多个实时状态相关步骤组成,目前没有一个能确定完成整个流程的单一结构化 Command;Agent 会打开精确入口,并把后续交互保留在用户可见界面。", - "reasonEn": "“Choose LAN, Ngrok, the hosted relay, a self-hosted relay, or a custom server” spans multiple live-state-dependent steps and currently has no single structured Command that can deterministically complete the whole workflow; the Agent opens the exact entry and keeps the remaining interaction visible to the user." + "reasonZh": "“选择局域网或官方 Relay”需要结合当前网络与设备状态,确认目标主机后才能连接;Agent 打开连接入口,由用户完成选择。", + "reasonEn": "“Choose LAN or the official Relay” depends on the current network and device state and requires the user to confirm the target host; the Agent opens the connection entry for that choice." } }, { @@ -23894,26 +23753,15 @@ "reasonEn": "“Configure Feishu, Telegram, WeChat, and other bots, and stop a bot independently” spans multiple live-state-dependent steps and currently has no single structured Command that can deterministically complete the whole workflow; the Agent opens the exact entry and keeps the remaining interaction visible to the user." } }, - { - "id": "relay-wizard", - "titleZh": "通过向导预检、安装 Docker、部署、注册并验证自建 Relay", - "titleEn": "Preflight, install Docker, deploy, register, and verify a self-hosted relay through the wizard", - "control": { - "kind": "open", - "reasonCode": "unstructuredInteraction", - "reasonZh": "“通过向导预检、安装 Docker、部署、注册并验证自建 Relay”由多个实时状态相关步骤组成,目前没有一个能确定完成整个流程的单一结构化 Command;Agent 会打开精确入口,并把后续交互保留在用户可见界面。", - "reasonEn": "“Preflight, install Docker, deploy, register, and verify a self-hosted relay through the wizard” spans multiple live-state-dependent steps and currently has no single structured Command that can deterministically complete the whole workflow; the Agent opens the exact entry and keeps the remaining interaction visible to the user." - } - }, { "id": "account", - "titleZh": "登录、退出并查看账户状态和凭据提示", - "titleEn": "Sign in, sign out, and inspect account status and credential hints", + "titleZh": "使用 GitHub 登录、退出并查看身份状态", + "titleEn": "Sign in with GitHub, sign out, and inspect identity status", "control": { "kind": "open", "reasonCode": "externalAuth", - "reasonZh": "OpenBitFun 账户登录必须由用户在外部认证页面确认;Agent 可以打开入口并读取非秘密状态,但不能冒充账户持有人。", - "reasonEn": "OpenBitFun account sign-in requires confirmation on an external authentication page; the Agent may open the entry and read non-secret status but cannot impersonate the account holder." + "reasonZh": "GitHub 账户登录必须由用户在外部认证页面确认;Agent 可以打开入口并读取非秘密状态,但不能冒充账户持有人。", + "reasonEn": "GitHub account sign-in requires confirmation on an external authentication page; the Agent may open the entry and read non-secret status but cannot impersonate the account holder." } }, { @@ -23927,28 +23775,6 @@ "reasonEn": "“List, connect, inspect online status, and remove same-account devices” spans multiple live-state-dependent steps and currently has no single structured Command that can deterministically complete the whole workflow; the Agent opens the exact entry and keeps the remaining interaction visible to the user." } }, - { - "id": "session-sync", - "titleZh": "同步、导出、导入、删除或发送会话到另一台设备", - "titleEn": "Sync, export, import, delete, or send sessions to another device", - "control": { - "kind": "open", - "reasonCode": "unstructuredInteraction", - "reasonZh": "“同步、导出、导入、删除或发送会话到另一台设备”由多个实时状态相关步骤组成,目前没有一个能确定完成整个流程的单一结构化 Command;Agent 会打开精确入口,并把后续交互保留在用户可见界面。", - "reasonEn": "“Sync, export, import, delete, or send sessions to another device” spans multiple live-state-dependent steps and currently has no single structured Command that can deterministically complete the whole workflow; the Agent opens the exact entry and keeps the remaining interaction visible to the user." - } - }, - { - "id": "settings-sync", - "titleZh": "在设备间自动或手动同步 OpenBitFun 设置", - "titleEn": "Synchronize OpenBitFun settings across devices automatically or on demand", - "control": { - "kind": "open", - "reasonCode": "unstructuredInteraction", - "reasonZh": "“在设备间自动或手动同步 OpenBitFun 设置”由多个实时状态相关步骤组成,目前没有一个能确定完成整个流程的单一结构化 Command;Agent 会打开精确入口,并把后续交互保留在用户可见界面。", - "reasonEn": "“Synchronize OpenBitFun settings across devices automatically or on demand” spans multiple live-state-dependent steps and currently has no single structured Command that can deterministically complete the whole workflow; the Agent opens the exact entry and keeps the remaining interaction visible to the user." - } - }, { "id": "peer-device", "titleZh": "进入 Peer Device Mode,把另一台 OpenBitFun 设备作为命令与事件数据面", @@ -24001,7 +23827,7 @@ "微信", "多设备", "Peer Device", - "账户同步", + "GitHub", "remote connect", "remote control", "mobile", @@ -24009,31 +23835,24 @@ "WeChat", "multi-device", "peer device", - "account sync", - "通过局域网、Ngrok 或自建 Relay 连接", + "通过局域网或官方 Relay 连接", "接入飞书、Telegram 或微信 Bot", - "登录账户、同步会话并进入 Peer Device Mode", - "Connect through LAN, Ngrok, or a self-hosted relay", + "使用 GitHub 身份管理设备并进入 Peer Device Mode", + "Connect through LAN or the official Relay", "Use Feishu, Telegram, or WeChat bots", - "Sign in, sync sessions, and enter Peer Device Mode", - "选择局域网、Ngrok、官方 Relay、自建 Relay 或自定义服务器", - "Choose LAN, Ngrok, the hosted relay, a self-hosted relay, or a custom server", + "Use GitHub identity to manage devices and enter Peer Device Mode", + "选择局域网或官方 Relay", + "Choose LAN or the official Relay", "启动、停止 Remote Connect 并查看实时连接状态和设备信息", "Start or stop Remote Connect and inspect live status and device information", "查看局域网 IP、网络信息与可分享的连接配置", "Inspect LAN IP, network details, and shareable connection configuration", "配置飞书、Telegram、微信等 Bot 并单独停止 Bot", "Configure Feishu, Telegram, WeChat, and other bots, and stop a bot independently", - "通过向导预检、安装 Docker、部署、注册并验证自建 Relay", - "Preflight, install Docker, deploy, register, and verify a self-hosted relay through the wizard", - "登录、退出并查看账户状态和凭据提示", - "Sign in, sign out, and inspect account status and credential hints", + "使用 GitHub 登录、退出并查看身份状态", + "Sign in with GitHub, sign out, and inspect identity status", "列出、连接、查看在线状态和删除同账户设备", "List, connect, inspect online status, and remove same-account devices", - "同步、导出、导入、删除或发送会话到另一台设备", - "Sync, export, import, delete, or send sessions to another device", - "在设备间自动或手动同步 OpenBitFun 设置", - "Synchronize OpenBitFun settings across devices automatically or on demand", "进入 Peer Device Mode,把另一台 OpenBitFun 设备作为命令与事件数据面", "Enter Peer Device Mode and use another OpenBitFun device as the command and event data plane", "打开 Remote Connect", @@ -24361,12 +24180,12 @@ } ], "stepsZh": [ - "登录 OpenBitFun 账户", + "使用 GitHub 登录", "打开 Pages", "选择页面并确认发布与可见性" ], "stepsEn": [ - "Sign in to a OpenBitFun account", + "Sign in to a GitHub account", "Open Pages", "Choose a page and confirm publishing and visibility" ], diff --git a/docs/interactive-capabilities/capabilities/feature.pages.md b/docs/interactive-capabilities/capabilities/feature.pages.md index 79ee6cd22f..d56b549459 100644 --- a/docs/interactive-capabilities/capabilities/feature.pages.md +++ b/docs/interactive-capabilities/capabilities/feature.pages.md @@ -34,8 +34,8 @@ Save page versions and publish them to public or private URLs while managing tit ## 怎么用 / How to use it -1. 登录 OpenBitFun 账户 - Sign in to a OpenBitFun account +1. 使用 GitHub 登录 + Sign in to a GitHub account 2. 打开 Pages Open Pages 3. 选择页面并确认发布与可见性 diff --git a/docs/interactive-capabilities/capabilities/feature.remote-connect.md b/docs/interactive-capabilities/capabilities/feature.remote-connect.md index 9828711268..8e0e01bbbd 100644 --- a/docs/interactive-capabilities/capabilities/feature.remote-connect.md +++ b/docs/interactive-capabilities/capabilities/feature.remote-connect.md @@ -17,24 +17,18 @@ Connect to this host from mobile, a bot, or another OpenBitFun device to inspect ## 完整功能清单 / Everything included -- **Agent 可定位入口,需交互完成 / Agent opens; interaction required** · 选择局域网、Ngrok、官方 Relay、自建 Relay 或自定义服务器 - - Choose LAN, Ngrok, the hosted relay, a self-hosted relay, or a custom server +- **Agent 可定位入口,需交互完成 / Agent opens; interaction required** · 选择局域网或官方 Relay + - Choose LAN or the official Relay - **Agent 可定位入口,需交互完成 / Agent opens; interaction required** · 启动、停止 Remote Connect 并查看实时连接状态和设备信息 - Start or stop Remote Connect and inspect live status and device information - **Agent 可定位入口,需交互完成 / Agent opens; interaction required** · 查看局域网 IP、网络信息与可分享的连接配置 - Inspect LAN IP, network details, and shareable connection configuration - **Agent 可定位入口,需交互完成 / Agent opens; interaction required** · 配置飞书、Telegram、微信等 Bot 并单独停止 Bot - Configure Feishu, Telegram, WeChat, and other bots, and stop a bot independently -- **Agent 可定位入口,需交互完成 / Agent opens; interaction required** · 通过向导预检、安装 Docker、部署、注册并验证自建 Relay - - Preflight, install Docker, deploy, register, and verify a self-hosted relay through the wizard -- **Agent 可定位入口,需交互完成 / Agent opens; interaction required** · 登录、退出并查看账户状态和凭据提示 - - Sign in, sign out, and inspect account status and credential hints +- **Agent 可定位入口,需交互完成 / Agent opens; interaction required** · 使用 GitHub 登录、退出并查看身份状态 + - Sign in with GitHub, sign out, and inspect identity status - **Agent 可定位入口,需交互完成 / Agent opens; interaction required** · 列出、连接、查看在线状态和删除同账户设备 - List, connect, inspect online status, and remove same-account devices -- **Agent 可定位入口,需交互完成 / Agent opens; interaction required** · 同步、导出、导入、删除或发送会话到另一台设备 - - Sync, export, import, delete, or send sessions to another device -- **Agent 可定位入口,需交互完成 / Agent opens; interaction required** · 在设备间自动或手动同步 OpenBitFun 设置 - - Synchronize OpenBitFun settings across devices automatically or on demand - **Agent 可定位入口,需交互完成 / Agent opens; interaction required** · 进入 Peer Device Mode,把另一台 OpenBitFun 设备作为命令与事件数据面 - Enter Peer Device Mode and use another OpenBitFun device as the command and event data plane diff --git a/docs/interactive-capabilities/technical/product-control-open-audit.json b/docs/interactive-capabilities/technical/product-control-open-audit.json index cf6eecf716..22f9609263 100644 --- a/docs/interactive-capabilities/technical/product-control-open-audit.json +++ b/docs/interactive-capabilities/technical/product-control-open-audit.json @@ -1,12 +1,12 @@ { "schemaVersion": 1, "generatedFrom": "src/shared/interactive-capabilities/catalog.json", - "catalogDigest": "409e6dbef7ebceafccc11606e2855227b9a33fa1ed71f5b4608da04e8a23de7d", - "count": 213, + "catalogDigest": "896281a3cd5cac2b50ec607988e04624224444911ddb2d58ba59a5d7d06493c5", + "count": 210, "reasonCounts": { "externalAuth": 4, "secretEntry": 5, - "unstructuredInteraction": 186, + "unstructuredInteraction": 183, "visualSelection": 18 }, "entries": [ @@ -1138,13 +1138,13 @@ "actionId": "surface.miniapps.open" }, "evidence": [ - "command:miniapp_market_auth_start", - "command:miniapp_market_auth_poll", + "command:account_github_start", + "command:account_github_poll", "command:miniapp_market_set_favorite", "command:miniapp_market_set_rating", "command:miniapp_market_installed_origins", - "command:miniapp_market_logout", - "command:miniapp_market_me" + "command:account_logout", + "command:account_github_info" ] }, { @@ -1847,18 +1847,17 @@ { "capabilityId": "feature.remote-connect", "itemId": "connection-methods", - "titleZh": "选择局域网、Ngrok、官方 Relay、自建 Relay 或自定义服务器", - "titleEn": "Choose LAN, Ngrok, the hosted relay, a self-hosted relay, or a custom server", + "titleZh": "选择局域网或官方 Relay", + "titleEn": "Choose LAN or the official Relay", "reasonCode": "unstructuredInteraction", - "reasonZh": "“选择局域网、Ngrok、官方 Relay、自建 Relay 或自定义服务器”由多个实时状态相关步骤组成,目前没有一个能确定完成整个流程的单一结构化 Command;Agent 会打开精确入口,并把后续交互保留在用户可见界面。", - "reasonEn": "“Choose LAN, Ngrok, the hosted relay, a self-hosted relay, or a custom server” spans multiple live-state-dependent steps and currently has no single structured Command that can deterministically complete the whole workflow; the Agent opens the exact entry and keeps the remaining interaction visible to the user.", + "reasonZh": "“选择局域网或官方 Relay”需要结合当前网络与设备状态,确认目标主机后才能连接;Agent 打开连接入口,由用户完成选择。", + "reasonEn": "“Choose LAN or the official Relay” depends on the current network and device state and requires the user to confirm the target host; the Agent opens the connection entry for that choice.", "presentationTarget": { "kind": "event", "eventName": "openbitfun:open-remote-connect" }, "evidence": [ - "command:remote_connect_get_methods", - "command:remote_connect_configure_custom_server" + "command:remote_connect_get_methods" ] }, { @@ -1895,7 +1894,8 @@ "evidence": [ "command:remote_connect_get_lan_ip", "command:remote_connect_get_lan_network_info", - "command:remote_connect_get_form_state" + "command:remote_connect_get_form_state", + "command:remote_connect_set_form_state" ] }, { @@ -1919,49 +1919,24 @@ "command:remote_connect_set_bot_verbose_mode" ] }, - { - "capabilityId": "feature.remote-connect", - "itemId": "relay-wizard", - "titleZh": "通过向导预检、安装 Docker、部署、注册并验证自建 Relay", - "titleEn": "Preflight, install Docker, deploy, register, and verify a self-hosted relay through the wizard", - "reasonCode": "unstructuredInteraction", - "reasonZh": "“通过向导预检、安装 Docker、部署、注册并验证自建 Relay”由多个实时状态相关步骤组成,目前没有一个能确定完成整个流程的单一结构化 Command;Agent 会打开精确入口,并把后续交互保留在用户可见界面。", - "reasonEn": "“Preflight, install Docker, deploy, register, and verify a self-hosted relay through the wizard” spans multiple live-state-dependent steps and currently has no single structured Command that can deterministically complete the whole workflow; the Agent opens the exact entry and keeps the remaining interaction visible to the user.", - "presentationTarget": { - "kind": "event", - "eventName": "openbitfun:open-remote-connect" - }, - "evidence": [ - "command:relay_deploy_preflight", - "command:relay_deploy_install_docker", - "command:relay_deploy_start", - "command:relay_deploy_register", - "command:relay_deploy_verify", - "command:relay_deploy_cancel", - "command:relay_deploy_poll" - ] - }, { "capabilityId": "feature.remote-connect", "itemId": "account", - "titleZh": "登录、退出并查看账户状态和凭据提示", - "titleEn": "Sign in, sign out, and inspect account status and credential hints", + "titleZh": "使用 GitHub 登录、退出并查看身份状态", + "titleEn": "Sign in with GitHub, sign out, and inspect identity status", "reasonCode": "externalAuth", - "reasonZh": "OpenBitFun 账户登录必须由用户在外部认证页面确认;Agent 可以打开入口并读取非秘密状态,但不能冒充账户持有人。", - "reasonEn": "OpenBitFun account sign-in requires confirmation on an external authentication page; the Agent may open the entry and read non-secret status but cannot impersonate the account holder.", + "reasonZh": "GitHub 账户登录必须由用户在外部认证页面确认;Agent 可以打开入口并读取非秘密状态,但不能冒充账户持有人。", + "reasonEn": "GitHub account sign-in requires confirmation on an external authentication page; the Agent may open the entry and read non-secret status but cannot impersonate the account holder.", "presentationTarget": { "kind": "event", "eventName": "openbitfun:open-remote-connect" }, "evidence": [ - "command:account_login", - "command:account_finalize_login", + "command:account_github_start", + "command:account_github_poll", + "command:account_github_info", "command:account_logout", - "command:account_status", - "command:account_get_credential_hint", - "command:account_cancel_pending_login", - "command:account_delegate_to_paired", - "command:account_token_expired" + "command:account_status" ] }, { @@ -1985,48 +1960,6 @@ "command:account_execute_on_device" ] }, - { - "capabilityId": "feature.remote-connect", - "itemId": "session-sync", - "titleZh": "同步、导出、导入、删除或发送会话到另一台设备", - "titleEn": "Sync, export, import, delete, or send sessions to another device", - "reasonCode": "unstructuredInteraction", - "reasonZh": "“同步、导出、导入、删除或发送会话到另一台设备”由多个实时状态相关步骤组成,目前没有一个能确定完成整个流程的单一结构化 Command;Agent 会打开精确入口,并把后续交互保留在用户可见界面。", - "reasonEn": "“Sync, export, import, delete, or send sessions to another device” spans multiple live-state-dependent steps and currently has no single structured Command that can deterministically complete the whole workflow; the Agent opens the exact entry and keeps the remaining interaction visible to the user.", - "presentationTarget": { - "kind": "event", - "eventName": "openbitfun:open-remote-connect" - }, - "evidence": [ - "command:account_sync_session", - "command:account_export_local_session", - "command:account_import_remote_sessions", - "command:account_delete_synced_session", - "command:account_send_session_to_device", - "command:account_export_all_sessions", - "command:account_fetch_session_turns", - "command:account_fetch_synced_sessions" - ] - }, - { - "capabilityId": "feature.remote-connect", - "itemId": "settings-sync", - "titleZh": "在设备间自动或手动同步 OpenBitFun 设置", - "titleEn": "Synchronize OpenBitFun settings across devices automatically or on demand", - "reasonCode": "unstructuredInteraction", - "reasonZh": "“在设备间自动或手动同步 OpenBitFun 设置”由多个实时状态相关步骤组成,目前没有一个能确定完成整个流程的单一结构化 Command;Agent 会打开精确入口,并把后续交互保留在用户可见界面。", - "reasonEn": "“Synchronize OpenBitFun settings across devices automatically or on demand” spans multiple live-state-dependent steps and currently has no single structured Command that can deterministically complete the whole workflow; the Agent opens the exact entry and keeps the remaining interaction visible to the user.", - "presentationTarget": { - "kind": "event", - "eventName": "openbitfun:open-remote-connect" - }, - "evidence": [ - "command:account_sync_settings", - "command:account_fetch_settings", - "command:account_auto_sync", - "command:remote_connect_set_form_state" - ] - }, { "capabilityId": "feature.remote-connect", "itemId": "peer-device", diff --git a/docs/interactive-capabilities/technical/tauri-command-map.json b/docs/interactive-capabilities/technical/tauri-command-map.json index 437a242365..a797664bdc 100644 --- a/docs/interactive-capabilities/technical/tauri-command-map.json +++ b/docs/interactive-capabilities/technical/tauri-command-map.json @@ -1,13 +1,13 @@ { "schemaVersion": 2, "generatedFrom": "src/shared/interactive-capabilities/catalog.json", - "catalogDigest": "409e6dbef7ebceafccc11606e2855227b9a33fa1ed71f5b4608da04e8a23de7d", - "commandCount": 666, + "catalogDigest": "896281a3cd5cac2b50ec607988e04624224444911ddb2d58ba59a5d7d06493c5", + "commandCount": 643, "coverage": { - "commandCount": 666, - "documentedCommandCount": 633, - "implementationCommandCount": 33, - "implementationDigest": "35539d9c1510287cb47f4a68fe35859b78f93bb06cd66b86e58d878a48d8c509" + "commandCount": 643, + "documentedCommandCount": 607, + "implementationCommandCount": 36, + "implementationDigest": "c9bbc0f2ca3695d8c18cff285386edeba7bcf3b11120adb57509a80b09ee3297" }, "commands": [ { @@ -58,38 +58,6 @@ "signature": "fn accept_session( app_handle: AppHandle, runtime: State<'_, DesktopRuntimeContext>, request: AcceptSessionRequest, ) -> Result", "remoteWorkspacePolicy": "LegacyUnaudited" }, - { - "id": "account_auto_sync", - "moduleId": "remote_connect", - "capabilityId": "feature.remote-connect", - "capabilityIds": [ - "feature.remote-connect" - ], - "documentedItemIds": [ - "feature.remote-connect:settings-sync" - ], - "visibility": "documented", - "rustPath": "api::remote_connect_api::account_auto_sync", - "sourceFile": "src/apps/desktop/src/api/remote_connect_api.rs", - "signature": "fn account_auto_sync( is_first_login: bool, workspace_path: String, config_json: String, sync_operation_id: u64, app_state: State<'_, crate::api::app_state::AppState>, path_manager: State<'_, Arc>, ) -> Result", - "remoteWorkspacePolicy": "WorkspaceAgnostic" - }, - { - "id": "account_cancel_pending_login", - "moduleId": "remote_connect", - "capabilityId": "feature.remote-connect", - "capabilityIds": [ - "feature.remote-connect" - ], - "documentedItemIds": [ - "feature.remote-connect:account" - ], - "visibility": "documented", - "rustPath": "api::remote_connect_api::account_cancel_pending_login", - "sourceFile": "src/apps/desktop/src/api/remote_connect_api.rs", - "signature": "fn account_cancel_pending_login( request: PendingAccountLoginRequest, ) -> Result", - "remoteWorkspacePolicy": "WorkspaceAgnostic" - }, { "id": "account_connect_devices", "moduleId": "remote_connect", @@ -106,22 +74,6 @@ "signature": "fn account_connect_devices() -> Result, String>", "remoteWorkspacePolicy": "WorkspaceAgnostic" }, - { - "id": "account_delegate_to_paired", - "moduleId": "remote_connect", - "capabilityId": "feature.remote-connect", - "capabilityIds": [ - "feature.remote-connect" - ], - "documentedItemIds": [ - "feature.remote-connect:account" - ], - "visibility": "documented", - "rustPath": "api::remote_connect_api::account_delegate_to_paired", - "sourceFile": "src/apps/desktop/src/api/remote_connect_api.rs", - "signature": "fn account_delegate_to_paired(correlation_id: String) -> Result", - "remoteWorkspacePolicy": "WorkspaceAgnostic" - }, { "id": "account_delete_device", "moduleId": "remote_connect", @@ -138,22 +90,6 @@ "signature": "fn account_delete_device(targetDeviceId: String) -> Result<(), String>", "remoteWorkspacePolicy": "WorkspaceAgnostic" }, - { - "id": "account_delete_synced_session", - "moduleId": "remote_connect", - "capabilityId": "feature.remote-connect", - "capabilityIds": [ - "feature.remote-connect" - ], - "documentedItemIds": [ - "feature.remote-connect:session-sync" - ], - "visibility": "documented", - "rustPath": "api::remote_connect_api::account_delete_synced_session", - "sourceFile": "src/apps/desktop/src/api/remote_connect_api.rs", - "signature": "fn account_delete_synced_session(session_id: String) -> Result<(), String>", - "remoteWorkspacePolicy": "WorkspaceAgnostic" - }, { "id": "account_device_rpc", "moduleId": "remote_connect", @@ -187,131 +123,71 @@ "remoteWorkspacePolicy": "WorkspaceAgnostic" }, { - "id": "account_export_all_sessions", - "moduleId": "remote_connect", - "capabilityId": "feature.remote-connect", - "capabilityIds": [ - "feature.remote-connect" - ], - "documentedItemIds": [ - "feature.remote-connect:session-sync" - ], - "visibility": "documented", - "rustPath": "api::remote_connect_api::account_export_all_sessions", - "sourceFile": "src/apps/desktop/src/api/remote_connect_api.rs", - "signature": "fn account_export_all_sessions( workspace_path: String, app_state: State<'_, crate::api::app_state::AppState>, path_manager: State<'_, Arc>, ) -> Result", - "remoteWorkspacePolicy": "WorkspaceAgnostic" - }, - { - "id": "account_export_local_session", - "moduleId": "remote_connect", - "capabilityId": "feature.remote-connect", - "capabilityIds": [ - "feature.remote-connect" - ], - "documentedItemIds": [ - "feature.remote-connect:session-sync" - ], - "visibility": "documented", - "rustPath": "api::remote_connect_api::account_export_local_session", - "sourceFile": "src/apps/desktop/src/api/remote_connect_api.rs", - "signature": "fn account_export_local_session( session_id: String, workspace_path: String, app_state: State<'_, crate::api::app_state::AppState>, path_manager: State<'_, Arc>, ) -> Result<(), String>", - "remoteWorkspacePolicy": "WorkspaceAgnostic" - }, - { - "id": "account_fetch_session_turns", - "moduleId": "remote_connect", - "capabilityId": "feature.remote-connect", - "capabilityIds": [ - "feature.remote-connect" - ], - "documentedItemIds": [ - "feature.remote-connect:session-sync" - ], - "visibility": "documented", - "rustPath": "api::remote_connect_api::account_fetch_session_turns", - "sourceFile": "src/apps/desktop/src/api/remote_connect_api.rs", - "signature": "fn account_fetch_session_turns( session_id: String, workspace_path: String, coordinator: State<'_, Arc>, app_state: State<'_, crate::api::app_state::AppState>, path_manager: State<'_, Arc>, ) -> Result", - "remoteWorkspacePolicy": "WorkspaceAgnostic" - }, - { - "id": "account_fetch_settings", - "moduleId": "remote_connect", - "capabilityId": "feature.remote-connect", - "capabilityIds": [ - "feature.remote-connect" - ], - "documentedItemIds": [ - "feature.remote-connect:settings-sync" - ], - "visibility": "documented", - "rustPath": "api::remote_connect_api::account_fetch_settings", - "sourceFile": "src/apps/desktop/src/api/remote_connect_api.rs", - "signature": "fn account_fetch_settings() -> Result, String>", - "remoteWorkspacePolicy": "WorkspaceAgnostic" - }, - { - "id": "account_fetch_synced_sessions", + "id": "account_get_credential_hint", "moduleId": "remote_connect", "capabilityId": "feature.remote-connect", "capabilityIds": [ "feature.remote-connect" ], - "documentedItemIds": [ - "feature.remote-connect:session-sync" - ], - "visibility": "documented", - "rustPath": "api::remote_connect_api::account_fetch_synced_sessions", + "documentedItemIds": [], + "visibility": "implementation", + "rustPath": "api::remote_connect_api::account_get_credential_hint", "sourceFile": "src/apps/desktop/src/api/remote_connect_api.rs", - "signature": "fn account_fetch_synced_sessions() -> Result, String>", + "signature": "fn account_get_credential_hint() -> Option", "remoteWorkspacePolicy": "WorkspaceAgnostic" }, { - "id": "account_finalize_login", - "moduleId": "remote_connect", - "capabilityId": "feature.remote-connect", + "id": "account_github_info", + "moduleId": "account_identity", + "capabilityId": null, "capabilityIds": [ + "feature.miniapps", "feature.remote-connect" ], "documentedItemIds": [ + "feature.miniapps:market-account", "feature.remote-connect:account" ], "visibility": "documented", - "rustPath": "api::remote_connect_api::account_finalize_login", - "sourceFile": "src/apps/desktop/src/api/remote_connect_api.rs", - "signature": "fn account_finalize_login(request: PendingAccountLoginRequest) -> Result<(), String>", + "rustPath": "api::account_identity_api::account_github_info", + "sourceFile": "src/apps/desktop/src/api/account_identity_api.rs", + "signature": "fn account_github_info() -> Result, String>", "remoteWorkspacePolicy": "WorkspaceAgnostic" }, { - "id": "account_get_credential_hint", - "moduleId": "remote_connect", - "capabilityId": "feature.remote-connect", + "id": "account_github_poll", + "moduleId": "account_identity", + "capabilityId": null, "capabilityIds": [ + "feature.miniapps", "feature.remote-connect" ], "documentedItemIds": [ + "feature.miniapps:market-account", "feature.remote-connect:account" ], "visibility": "documented", - "rustPath": "api::remote_connect_api::account_get_credential_hint", - "sourceFile": "src/apps/desktop/src/api/remote_connect_api.rs", - "signature": "fn account_get_credential_hint() -> Option", + "rustPath": "api::account_identity_api::account_github_poll", + "sourceFile": "src/apps/desktop/src/api/account_identity_api.rs", + "signature": "fn account_github_poll( app: AppHandle, request: GitHubAuthPollRequest, ) -> Result", "remoteWorkspacePolicy": "WorkspaceAgnostic" }, { - "id": "account_import_remote_sessions", - "moduleId": "remote_connect", - "capabilityId": "feature.remote-connect", + "id": "account_github_start", + "moduleId": "account_identity", + "capabilityId": null, "capabilityIds": [ + "feature.miniapps", "feature.remote-connect" ], "documentedItemIds": [ - "feature.remote-connect:session-sync" + "feature.miniapps:market-account", + "feature.remote-connect:account" ], "visibility": "documented", - "rustPath": "api::remote_connect_api::account_import_remote_sessions", - "sourceFile": "src/apps/desktop/src/api/remote_connect_api.rs", - "signature": "fn account_import_remote_sessions( workspace_path: String, coordinator: State<'_, Arc>, app_state: State<'_, crate::api::app_state::AppState>, path_manager: State<'_, Arc>, ) -> Result, String>", + "rustPath": "api::account_identity_api::account_github_start", + "sourceFile": "src/apps/desktop/src/api/account_identity_api.rs", + "signature": "fn account_github_start() -> Result", "remoteWorkspacePolicy": "WorkspaceAgnostic" }, { @@ -337,29 +213,29 @@ "capabilityIds": [ "feature.remote-connect" ], - "documentedItemIds": [ - "feature.remote-connect:account" - ], - "visibility": "documented", + "documentedItemIds": [], + "visibility": "implementation", "rustPath": "api::remote_connect_api::account_login", "sourceFile": "src/apps/desktop/src/api/remote_connect_api.rs", - "signature": "fn account_login(request: AccountAuthRequest) -> Result", + "signature": "fn account_login(_request: AccountAuthRequest) -> Result", "remoteWorkspacePolicy": "WorkspaceAgnostic" }, { "id": "account_logout", "moduleId": "remote_connect", - "capabilityId": "feature.remote-connect", + "capabilityId": null, "capabilityIds": [ + "feature.miniapps", "feature.remote-connect" ], "documentedItemIds": [ + "feature.miniapps:market-account", "feature.remote-connect:account" ], "visibility": "documented", "rustPath": "api::remote_connect_api::account_logout", "sourceFile": "src/apps/desktop/src/api/remote_connect_api.rs", - "signature": "fn account_logout() -> Result<(), String>", + "signature": "fn account_logout(app: tauri::AppHandle) -> Result<(), String>", "remoteWorkspacePolicy": "WorkspaceAgnostic" }, { @@ -378,22 +254,6 @@ "signature": "fn account_online_devices() -> Result, String>", "remoteWorkspacePolicy": "WorkspaceAgnostic" }, - { - "id": "account_send_session_to_device", - "moduleId": "remote_connect", - "capabilityId": "feature.remote-connect", - "capabilityIds": [ - "feature.remote-connect" - ], - "documentedItemIds": [ - "feature.remote-connect:session-sync" - ], - "visibility": "documented", - "rustPath": "api::remote_connect_api::account_send_session_to_device", - "sourceFile": "src/apps/desktop/src/api/remote_connect_api.rs", - "signature": "fn account_send_session_to_device( target_device_id: String, session_id: String, session_json: String, ) -> Result<(), String>", - "remoteWorkspacePolicy": "WorkspaceAgnostic" - }, { "id": "account_status", "moduleId": "remote_connect", @@ -410,38 +270,6 @@ "signature": "fn account_status() -> Result", "remoteWorkspacePolicy": "WorkspaceAgnostic" }, - { - "id": "account_sync_session", - "moduleId": "remote_connect", - "capabilityId": "feature.remote-connect", - "capabilityIds": [ - "feature.remote-connect" - ], - "documentedItemIds": [ - "feature.remote-connect:session-sync" - ], - "visibility": "documented", - "rustPath": "api::remote_connect_api::account_sync_session", - "sourceFile": "src/apps/desktop/src/api/remote_connect_api.rs", - "signature": "fn account_sync_session(session_id: String, session_json: String) -> Result<(), String>", - "remoteWorkspacePolicy": "WorkspaceAgnostic" - }, - { - "id": "account_sync_settings", - "moduleId": "remote_connect", - "capabilityId": "feature.remote-connect", - "capabilityIds": [ - "feature.remote-connect" - ], - "documentedItemIds": [ - "feature.remote-connect:settings-sync" - ], - "visibility": "documented", - "rustPath": "api::remote_connect_api::account_sync_settings", - "sourceFile": "src/apps/desktop/src/api/remote_connect_api.rs", - "signature": "fn account_sync_settings(settings_json: String) -> Result<(), String>", - "remoteWorkspacePolicy": "WorkspaceAgnostic" - }, { "id": "account_token_expired", "moduleId": "remote_connect", @@ -449,10 +277,8 @@ "capabilityIds": [ "feature.remote-connect" ], - "documentedItemIds": [ - "feature.remote-connect:account" - ], - "visibility": "documented", + "documentedItemIds": [], + "visibility": "implementation", "rustPath": "api::remote_connect_api::account_token_expired", "sourceFile": "src/apps/desktop/src/api/remote_connect_api.rs", "signature": "fn account_token_expired() -> bool", @@ -5766,38 +5592,6 @@ "signature": "fn miniapp_install_deps( state: State<'_, AppState>, app_id: String, ) -> Result", "remoteWorkspacePolicy": "LegacyUnaudited" }, - { - "id": "miniapp_market_auth_poll", - "moduleId": "miniapp_market", - "capabilityId": "feature.miniapps", - "capabilityIds": [ - "feature.miniapps" - ], - "documentedItemIds": [ - "feature.miniapps:market-account" - ], - "visibility": "documented", - "rustPath": "api::miniapp_market_api::miniapp_market_auth_poll", - "sourceFile": "src/apps/desktop/src/api/miniapp_market_api.rs", - "signature": "fn miniapp_market_auth_poll( app: AppHandle, request: DesktopAuthPollViewRequest, ) -> Result", - "remoteWorkspacePolicy": "WorkspaceAgnostic" - }, - { - "id": "miniapp_market_auth_start", - "moduleId": "miniapp_market", - "capabilityId": "feature.miniapps", - "capabilityIds": [ - "feature.miniapps" - ], - "documentedItemIds": [ - "feature.miniapps:market-account" - ], - "visibility": "documented", - "rustPath": "api::miniapp_market_api::miniapp_market_auth_start", - "sourceFile": "src/apps/desktop/src/api/miniapp_market_api.rs", - "signature": "fn miniapp_market_auth_start() -> Result", - "remoteWorkspacePolicy": "WorkspaceAgnostic" - }, { "id": "miniapp_market_browse", "moduleId": "miniapp_market", @@ -5942,38 +5736,6 @@ "signature": "fn miniapp_market_list_submissions() -> Result, String>", "remoteWorkspacePolicy": "WorkspaceAgnostic" }, - { - "id": "miniapp_market_logout", - "moduleId": "miniapp_market", - "capabilityId": "feature.miniapps", - "capabilityIds": [ - "feature.miniapps" - ], - "documentedItemIds": [ - "feature.miniapps:market-account" - ], - "visibility": "documented", - "rustPath": "api::miniapp_market_api::miniapp_market_logout", - "sourceFile": "src/apps/desktop/src/api/miniapp_market_api.rs", - "signature": "fn miniapp_market_logout(app: AppHandle) -> Result<(), String>", - "remoteWorkspacePolicy": "WorkspaceAgnostic" - }, - { - "id": "miniapp_market_me", - "moduleId": "miniapp_market", - "capabilityId": "feature.miniapps", - "capabilityIds": [ - "feature.miniapps" - ], - "documentedItemIds": [ - "feature.miniapps:market-account" - ], - "visibility": "documented", - "rustPath": "api::miniapp_market_api::miniapp_market_me", - "sourceFile": "src/apps/desktop/src/api/miniapp_market_api.rs", - "signature": "fn miniapp_market_me() -> Result, String>", - "remoteWorkspacePolicy": "WorkspaceAgnostic" - }, { "id": "miniapp_market_set_favorite", "moduleId": "miniapp_market", @@ -6855,118 +6617,6 @@ "signature": "fn reject_operation( app_handle: AppHandle, runtime: State<'_, DesktopRuntimeContext>, request: GetOperationSummaryRequest, ) -> Result", "remoteWorkspacePolicy": "LegacyUnaudited" }, - { - "id": "relay_deploy_cancel", - "moduleId": "relay_deploy", - "capabilityId": "feature.remote-connect", - "capabilityIds": [ - "feature.remote-connect" - ], - "documentedItemIds": [ - "feature.remote-connect:relay-wizard" - ], - "visibility": "documented", - "rustPath": "api::relay_deploy_api::relay_deploy_cancel", - "sourceFile": "src/apps/desktop/src/api/relay_deploy_api.rs", - "signature": "fn relay_deploy_cancel( state: State<'_, AppState>, connection_id: String, task: RelayDeployTask, ) -> Result<(), String>", - "remoteWorkspacePolicy": "WorkspaceAgnostic" - }, - { - "id": "relay_deploy_install_docker", - "moduleId": "relay_deploy", - "capabilityId": "feature.remote-connect", - "capabilityIds": [ - "feature.remote-connect" - ], - "documentedItemIds": [ - "feature.remote-connect:relay-wizard" - ], - "visibility": "documented", - "rustPath": "api::relay_deploy_api::relay_deploy_install_docker", - "sourceFile": "src/apps/desktop/src/api/relay_deploy_api.rs", - "signature": "fn relay_deploy_install_docker( state: State<'_, AppState>, connection_id: String, mirror_mode: Option, ) -> Result", - "remoteWorkspacePolicy": "WorkspaceAgnostic" - }, - { - "id": "relay_deploy_poll", - "moduleId": "relay_deploy", - "capabilityId": "feature.remote-connect", - "capabilityIds": [ - "feature.remote-connect" - ], - "documentedItemIds": [ - "feature.remote-connect:relay-wizard" - ], - "visibility": "documented", - "rustPath": "api::relay_deploy_api::relay_deploy_poll", - "sourceFile": "src/apps/desktop/src/api/relay_deploy_api.rs", - "signature": "fn relay_deploy_poll( state: State<'_, AppState>, connection_id: String, task: RelayDeployTask, cursor: u64, ) -> Result", - "remoteWorkspacePolicy": "WorkspaceAgnostic" - }, - { - "id": "relay_deploy_preflight", - "moduleId": "relay_deploy", - "capabilityId": "feature.remote-connect", - "capabilityIds": [ - "feature.remote-connect" - ], - "documentedItemIds": [ - "feature.remote-connect:relay-wizard" - ], - "visibility": "documented", - "rustPath": "api::relay_deploy_api::relay_deploy_preflight", - "sourceFile": "src/apps/desktop/src/api/relay_deploy_api.rs", - "signature": "fn relay_deploy_preflight( state: State<'_, AppState>, connection_id: String, port: Option, ) -> Result", - "remoteWorkspacePolicy": "WorkspaceAgnostic" - }, - { - "id": "relay_deploy_register", - "moduleId": "relay_deploy", - "capabilityId": "feature.remote-connect", - "capabilityIds": [ - "feature.remote-connect" - ], - "documentedItemIds": [ - "feature.remote-connect:relay-wizard" - ], - "visibility": "documented", - "rustPath": "api::relay_deploy_api::relay_deploy_register", - "sourceFile": "src/apps/desktop/src/api/relay_deploy_api.rs", - "signature": "fn relay_deploy_register( state: State<'_, AppState>, connection_id: String, username: String, password: String, ) -> Result<(), String>", - "remoteWorkspacePolicy": "WorkspaceAgnostic" - }, - { - "id": "relay_deploy_start", - "moduleId": "relay_deploy", - "capabilityId": "feature.remote-connect", - "capabilityIds": [ - "feature.remote-connect" - ], - "documentedItemIds": [ - "feature.remote-connect:relay-wizard" - ], - "visibility": "documented", - "rustPath": "api::relay_deploy_api::relay_deploy_start", - "sourceFile": "src/apps/desktop/src/api/relay_deploy_api.rs", - "signature": "fn relay_deploy_start( state: State<'_, AppState>, connection_id: String, port: Option, mirror_mode: Option, ) -> Result", - "remoteWorkspacePolicy": "WorkspaceAgnostic" - }, - { - "id": "relay_deploy_verify", - "moduleId": "relay_deploy", - "capabilityId": "feature.remote-connect", - "capabilityIds": [ - "feature.remote-connect" - ], - "documentedItemIds": [ - "feature.remote-connect:relay-wizard" - ], - "visibility": "documented", - "rustPath": "api::relay_deploy_api::relay_deploy_verify", - "sourceFile": "src/apps/desktop/src/api/relay_deploy_api.rs", - "signature": "fn relay_deploy_verify(relay_url: String) -> Result", - "remoteWorkspacePolicy": "WorkspaceAgnostic" - }, { "id": "reload_config", "moduleId": "config", @@ -7061,22 +6711,6 @@ "signature": "fn remote_connect_configure_bot(request: ConfigureBotRequest) -> Result<(), String>", "remoteWorkspacePolicy": "WorkspaceAgnostic" }, - { - "id": "remote_connect_configure_custom_server", - "moduleId": "remote_connect", - "capabilityId": "feature.remote-connect", - "capabilityIds": [ - "feature.remote-connect" - ], - "documentedItemIds": [ - "feature.remote-connect:connection-methods" - ], - "visibility": "documented", - "rustPath": "api::remote_connect_api::remote_connect_configure_custom_server", - "sourceFile": "src/apps/desktop/src/api/remote_connect_api.rs", - "signature": "fn remote_connect_configure_custom_server(url: String) -> Result<(), String>", - "remoteWorkspacePolicy": "WorkspaceAgnostic" - }, { "id": "remote_connect_get_bot_verbose_mode", "moduleId": "remote_connect", @@ -7197,7 +6831,7 @@ "feature.remote-connect" ], "documentedItemIds": [ - "feature.remote-connect:settings-sync" + "feature.remote-connect:network" ], "visibility": "documented", "rustPath": "api::remote_connect_api::remote_connect_set_form_state", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c1d75fc403..e330e12d4d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -262,9 +262,6 @@ importers: '@openbitfun/ui': specifier: workspace:^ version: link:../../design-system/packages/ui - qr-scanner: - specifier: ^1.4.2 - version: 1.4.2 react: specifier: ^18.3.1 version: 18.3.1 @@ -2331,9 +2328,6 @@ packages: '@types/normalize-package-data@2.4.4': resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} - '@types/offscreencanvas@2019.7.3': - resolution: {integrity: sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==} - '@types/path-browserify@1.0.3': resolution: {integrity: sha512-ZmHivEbNCBtAfcrFeBCiTjdIc2dey0l7oCGNGpSuRTy8jP6UVND7oUowlvDujBy8r2Hoa8bfFUOCiPWfmtkfxw==} @@ -4929,9 +4923,6 @@ packages: resolution: {integrity: sha512-ArbnyA3U5SGHokEvkfWjW+O8hOxV1RSJxOgriX/3A4xZRqixt9ZFHD0yPgZQF05Qj0oAqi8H/7stDorjoHY90Q==} engines: {node: '>=16.13.2'} - qr-scanner@1.4.2: - resolution: {integrity: sha512-kV1yQUe2FENvn59tMZW6mOVfpq9mGxGf8l6+EGaXUOd4RBOLg7tRC83OrirM5AtDvZRpdjdlXURsHreAOSPOUw==} - qrcode.react@4.2.0: resolution: {integrity: sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==} peerDependencies: @@ -7580,8 +7571,6 @@ snapshots: '@types/normalize-package-data@2.4.4': {} - '@types/offscreencanvas@2019.7.3': {} - '@types/path-browserify@1.0.3': {} '@types/prismjs@1.26.5': {} @@ -10984,10 +10973,6 @@ snapshots: - supports-color - utf-8-validate - qr-scanner@1.4.2: - dependencies: - '@types/offscreencanvas': 2019.7.3 - qrcode.react@4.2.0(react@18.3.1): dependencies: react: 18.3.1 diff --git a/scripts/check-core-boundaries.test.mjs b/scripts/check-core-boundaries.test.mjs index 7057a221e8..d72dfb3747 100644 --- a/scripts/check-core-boundaries.test.mjs +++ b/scripts/check-core-boundaries.test.mjs @@ -305,6 +305,16 @@ test('TLS source boundaries reject bypasses of the centralized provider owner', ); }); +test('standalone Relay TLS exception cannot install a process provider', () => { + const providerRule = forbiddenContentUnderRules.find(rule => rule.reason.includes('only owner allowed to install')); + const relayPath = 'src/crates/services/relay-service/src/identity.rs'; + assert.ok(providerRule.patterns[0].allowPaths.includes(relayPath)); + const scopedRule = forbiddenContentUnderRules.find(rule => rule.path === relayPath && rule.reason.includes('client-scoped')); + assert.ok(scopedRule); + assert.ok(scopedRule.patterns.some(pattern => pattern.regex.test('provider.install_default()'))); + assert.ok(scopedRule.patterns.every(pattern => !pattern.regex.test('ClientConfig::builder_with_provider(provider)'))); +}); + test('Core and ACP defaults preserve their explicit assembly contracts', async () => { const [coreManifest, acpManifest] = await Promise.all([ readFile(new URL('../src/crates/assembly/core/Cargo.toml', import.meta.url), 'utf8'), diff --git a/scripts/check-harmonyos-architecture.mjs b/scripts/check-harmonyos-architecture.mjs index b501acbc44..1becc13d06 100644 --- a/scripts/check-harmonyos-architecture.mjs +++ b/scripts/check-harmonyos-architecture.mjs @@ -144,13 +144,22 @@ const requiredPresentationFiles = [ ]; const missingPresentationFiles = requiredPresentationFiles .filter((file) => !fs.existsSync(path.join(pagesRoot, file))); -const localConversationSectionHosts = [ - path.join(pagesRoot, 'components/AppRootOverlaySurfaces.ets'), - path.join(pagesRoot, 'components/WideConversationHost.ets') +// Phones are remote controllers: check the runtime and both responsive hosts. +const localControllerOwners = [ + 'runtime/AppRootRuntimeComposition.ets', + 'runtime/AppRootRuntime.ets', + 'viewmodel/ConversationRuntime.ets', + 'viewmodel/SettingsController.ets', + 'viewmodel/VisibleConversationController.ets', + 'components/AppRootOverlaySurfaces.ets', + 'components/WideConversationHost.ets', + 'components/SettingsSheet.ets', + 'components/AppSidebar.ets' ]; -const hiddenLocalConversationSections = localConversationSectionHosts - .filter((file) => !fs.readFileSync(file, 'utf8').includes('showConversationSection: true')) - .map(relative); +const localControllerRuntimeLeaks = localControllerOwners + .filter((file) => /GeneralChat(?:Controller|ConfigStore|BootstrapController|CommandController|DraftLifecycleController|ConversationViewModel)|showConversationSection|ModelServiceSettingsPanel|generalPageState\.recentSessions/.test( + fs.readFileSync(path.join(pagesRoot, file), 'utf8'))) + .map((file) => relative(path.join(pagesRoot, file))); const chatTimelineSource = fs.readFileSync(path.join(pagesRoot, 'components/ChatTimeline.ets'), 'utf8'); const chatMessageContentSource = fs.readFileSync( path.join(pagesRoot, 'components/ChatMessageContent.ets'), @@ -405,7 +414,7 @@ const expected = { appRootRuntimeStateGetters: [], extractedOwnerForwards: [], missingPresentationFiles: [], - hiddenLocalConversationSections: [], + localControllerRuntimeLeaks: [], eagerChatTimeline: [], missingTimelineReuse: [], snapshottedTimelineRepeatItem: [], @@ -444,7 +453,7 @@ const actual = { appRootRuntimeStateGetters, extractedOwnerForwards, missingPresentationFiles, - hiddenLocalConversationSections, + localControllerRuntimeLeaks, eagerChatTimeline, missingTimelineReuse, snapshottedTimelineRepeatItem, diff --git a/scripts/core-boundaries/cargo-dependency-boundaries.mjs b/scripts/core-boundaries/cargo-dependency-boundaries.mjs index dd3ca537d7..3b1c3319db 100644 --- a/scripts/core-boundaries/cargo-dependency-boundaries.mjs +++ b/scripts/core-boundaries/cargo-dependency-boundaries.mjs @@ -132,6 +132,7 @@ function isProcMacroPackage(pkg) { } const SERVICES_INTEGRATIONS_TOKIO_FEATURES = new Map([ + ['account-identity', ['rt', 'sync']], ['announcement', ['fs', 'sync']], ['models-dev', ['fs', 'sync', 'time']], ['browser-control', ['time']], @@ -329,6 +330,11 @@ const REQWEST_PACKAGE_PROFILES = new Map([ optional: false, tlsProviderDependency: 'openbitfun-services-core', }], + // Relay verifies global identity over bounded JSON HTTPS; no streaming or form API. + ['openbitfun-relay-service', { + dependencyFeatures: ['json', 'rustls-no-provider'], + optional: false, + }], ['openbitfun-skin-market-service', { dependencyFeatures: ['http2', 'json', 'rustls-no-provider'], optional: false, @@ -1023,6 +1029,7 @@ export function findServicesIntegrationsReqwestFeatureViolations(pkg) { const featureGraph = pkg.features ?? {}; const ownerFeatures = new Set(servicesReqwestOwnerFeatures); const ownerFeatureReferences = new Map([ + ['account-identity', ['reqwest/json']], ['announcement', ['reqwest/json']], ['browser-control', ['reqwest/json']], ['mcp', ['reqwest/json', 'reqwest/stream']], diff --git a/scripts/core-boundaries/rules/feature-rules.mjs b/scripts/core-boundaries/rules/feature-rules.mjs index 6c466b2c4e..5ee5bb6226 100644 --- a/scripts/core-boundaries/rules/feature-rules.mjs +++ b/scripts/core-boundaries/rules/feature-rules.mjs @@ -1,6 +1,7 @@ // Boundary rules for feature assembly and optional dependency ownership. export const servicesReqwestOwnerFeatures = [ + 'account-identity', 'announcement', 'browser-control', 'mcp', @@ -276,11 +277,12 @@ export const optionalDependencyFeatureOwnerRules = [ depName: 'openbitfun-core-types', ownerFeatures: ['deep-research', 'remote-connect', 'speech'], }, - { depName: 'openbitfun-product-domains', ownerFeatures: ['canvas-runtime', 'function-agents', 'hook-import', 'miniapp-market', 'miniapp-runtime', 'miniapp-storage', 'plugin-source'] }, + { depName: 'openbitfun-product-domains', ownerFeatures: ['account-identity', 'canvas-runtime', 'function-agents', 'hook-import', 'miniapp-market', 'miniapp-runtime', 'miniapp-storage', 'plugin-source', 'remote-connect'] }, { depName: 'openbitfun-runtime-ports', ownerFeatures: ['deep-research', 'git', 'remote-connect', 'remote-ssh', 'remote-ssh-concrete', 'script-tool-runtime', 'web-tools'] }, { depName: 'openbitfun-services-core', ownerFeatures: [ + 'account-identity', 'announcement', 'browser-control', 'git', @@ -301,10 +303,10 @@ export const optionalDependencyFeatureOwnerRules = [ ], }, { depName: 'bzip2', ownerFeatures: ['speech'] }, - { depName: 'chrono', ownerFeatures: ['git', 'miniapp-market', 'remote-connect', 'remote-ssh-concrete', 'review-platform', 'speech', 'web-tools'] }, - { depName: 'dirs', ownerFeatures: ['browser-control', 'miniapp-runtime', 'remote-connect', 'remote-ssh-concrete'] }, + { depName: 'chrono', ownerFeatures: ['account-identity', 'git', 'miniapp-market', 'remote-connect', 'remote-ssh-concrete', 'review-platform', 'speech', 'web-tools'] }, + { depName: 'dirs', ownerFeatures: ['account-identity', 'browser-control', 'miniapp-runtime', 'remote-connect', 'remote-ssh-concrete'] }, { depName: 'dunce', ownerFeatures: ['plugin-source', 'workspace-search'] }, - { depName: 'fs2', ownerFeatures: ['plugin-source'] }, + { depName: 'fs2', ownerFeatures: ['plugin-source', 'remote-persistence', 'remote-connect'] }, { depName: 'futures', ownerFeatures: ['mcp', 'remote-connect', 'review-platform'] }, { depName: 'futures-util', ownerFeatures: ['speech', 'web-tools'] }, { depName: 'git2', ownerFeatures: ['git'] }, @@ -337,7 +339,7 @@ export const optionalDependencyFeatureOwnerRules = [ { depName: 'ssh_config', ownerFeatures: ['remote-ssh-concrete', 'ssh_config'] }, { depName: 'terminal-core', ownerFeatures: ['remote-ssh', 'remote-ssh-concrete'] }, { depName: 'tar', ownerFeatures: ['speech'] }, - { depName: 'thiserror', ownerFeatures: ['browser-control', 'git', 'hook-import', 'miniapp-market', 'plugin-source', 'remote-ssh', 'remote-ssh-concrete', 'review-platform', 'speech', 'web-tools', 'workspace-search'] }, + { depName: 'thiserror', ownerFeatures: ['account-identity', 'browser-control', 'git', 'hook-import', 'miniapp-market', 'plugin-source', 'remote-connect', 'remote-ssh', 'remote-ssh-concrete', 'review-platform', 'speech', 'web-tools', 'workspace-search'] }, { depName: 'tokio-tungstenite', ownerFeatures: ['remote-connect', 'speech-realtime'] }, { depName: 'tokio-util', ownerFeatures: ['remote-ssh', 'speech'] }, { depName: 'urlencoding', ownerFeatures: ['canvas-runtime', 'miniapp-market', 'remote-connect', 'review-platform'] }, diff --git a/scripts/core-boundaries/rules/source/forbidden-rules.mjs b/scripts/core-boundaries/rules/source/forbidden-rules.mjs index 03bffa4bbf..595bbf5439 100644 --- a/scripts/core-boundaries/rules/source/forbidden-rules.mjs +++ b/scripts/core-boundaries/rules/source/forbidden-rules.mjs @@ -4442,9 +4442,22 @@ export const forbiddenContentUnderRules = [ patterns: [ { regex: /\brustls::crypto::(?:ring|aws_lc_rs)\b/, - allowPaths: ['src/crates/services/services-core/src/tls_provider.rs'], + allowPaths: [ + 'src/crates/services/services-core/src/tls_provider.rs', + // Independently built Relay binds ring to one client; the separate + // rule below still forbids process-wide installation in that owner. + 'src/crates/services/relay-service/src/identity.rs', + ], message: 'delegate built-in Rustls provider selection to services-core::tls_provider', }, ], }, + { + path: 'src/crates/services/relay-service/src/identity.rs', + reason: 'standalone Relay identity TLS must remain client-scoped', + patterns: [{ + regex: /\binstall_default\b/, + message: 'Relay must not install or replace the process-wide TLS provider', + }], + }, ]; diff --git a/scripts/core-boundaries/rules/source/required-rules.mjs b/scripts/core-boundaries/rules/source/required-rules.mjs index 0bd032aa48..8281f96ae9 100644 --- a/scripts/core-boundaries/rules/source/required-rules.mjs +++ b/scripts/core-boundaries/rules/source/required-rules.mjs @@ -6254,8 +6254,8 @@ export const requiredContentRules = [ message: 'missing remote-connect encryption compatibility export', }, { - regex: /pub use pairing::\{[\s\S]*\bPairingChallenge\b[\s\S]*\bPairingProtocol\b[\s\S]*\bPairingResponse\b[\s\S]*\bPairingState\b[\s\S]*\bQrPayload\b[\s\S]*\}/, - message: 'missing remote-connect pairing compatibility export', + regex: /pub use pairing::PairingState/, + message: 'missing remote-connect bot pairing state export', }, { regex: /\bpub use qr_generator::QrGenerator\b/, @@ -6655,12 +6655,12 @@ export const requiredContentRules = [ reason: 'remote-connect owner crate must keep focused behavior contracts', patterns: [ { - regex: /\bremote_connect_pairing_primitives_live_in_services_owner\b/, - message: 'missing remote-connect pairing/encryption owner contract test', + regex: /\brelay_invitations_and_authentication_use_the_same_protocol_for_all_endpoints\b/, + message: 'missing authenticated relay invitation owner contract test', }, { - regex: /\bremote_connect_qr_and_relay_primitives_live_in_services_owner\b/, - message: 'missing remote-connect QR/relay owner contract test', + regex: /\bremote_connect_lan_url_builder_lives_in_services_owner\b/, + message: 'missing relay endpoint owner contract test', }, { regex: /\bremote_connect_command_wire_shape_lives_in_owner_contract\b/, @@ -10087,4 +10087,12 @@ export const requiredContentRules = [ }, ], }, + { + path: 'src/crates/services/relay-service/src/identity.rs', + reason: 'standalone Relay must bind its reviewed ring configuration to the identity client', + patterns: [{ + regex: /let tls = rustls::ClientConfig::builder_with_provider\(Arc::new\(\s*rustls::crypto::ring::default_provider\(\),?\s*\)\)[\s\S]*?\.tls_backend_preconfigured\(tls\)/, + message: 'Relay identity verification requires an explicit client-scoped ring config', + }], + }, ]; diff --git a/scripts/core-boundaries/self-test.mjs b/scripts/core-boundaries/self-test.mjs index 825b76706c..05be44e779 100644 --- a/scripts/core-boundaries/self-test.mjs +++ b/scripts/core-boundaries/self-test.mjs @@ -3855,8 +3855,7 @@ export function runManifestParserSelfTest({ 'pub mod relay_client', 'pub use device::DeviceIdentity', 'pub use encryption::{decrypt_from_base64, encrypt_to_base64, KeyPair}', - 'PairingProtocol', - 'QrPayload', + 'PairingState', 'pub use qr_generator::QrGenerator', 'RelayClient', 'RelayMessage', @@ -3935,8 +3934,8 @@ export function runManifestParserSelfTest({ { path: 'src/crates/services/services-integrations/tests/remote_connect_contracts.rs', contracts: [ - 'remote_connect_pairing_primitives_live_in_services_owner', - 'remote_connect_qr_and_relay_primitives_live_in_services_owner', + 'relay_invitations_and_authentication_use_the_same_protocol_for_all_endpoints', + 'remote_connect_lan_url_builder_lives_in_services_owner', 'remote_connect_command_wire_shape_lives_in_owner_contract', 'remote_connect_response_wire_shape_lives_in_owner_contract', 'remote_connect_model_catalog_delta_preserves_poll_invalidation_policy', diff --git a/src/apps/cli/src/account.rs b/src/apps/cli/src/account.rs index 4b02e38273..ffe8f1a6ef 100644 --- a/src/apps/cli/src/account.rs +++ b/src/apps/cli/src/account.rs @@ -1,6 +1,6 @@ //! CLI adapter for account-backed device routing. //! -//! Shared account identity, persistence, synchronization, and transitions are +//! Shared account identity, persistence and transitions are //! owned by [`AccountRuntime`]. This module contains only CLI Host effects: //! daemon retirement, Relay routing, and Peer Device Mode fan-out fencing. @@ -9,18 +9,13 @@ use std::time::Duration; use anyhow::{anyhow, Result}; use async_trait::async_trait; -use openbitfun_product_domains::account::{ - AccountDevice, AccountInfo, AccountSnapshotProjection, SettingsSyncProgress, SettingsSyncStatus, -}; +use openbitfun_product_domains::account::{AccountDevice, AccountInfo, AccountSnapshotProjection}; use tokio::sync::RwLock; -use openbitfun_core::product_runtime::CoreAgentRuntimeCompatibility; -use openbitfun_core::service::remote_connect::account::{ - ensure_relay_session_history_exportable, AccountSession, -}; +use openbitfun_core::service::remote_connect::account::AccountSession; use openbitfun_core::service::remote_connect::account_runtime::{ - build_session_backup, AccountRoutingStartRequest, AccountRuntime, AccountRuntimeHost, - AccountSessionBackup, AccountSessionBackupPort, BackgroundRoutingOwnerRetirementError, + AccountRoutingStartRequest, AccountRuntime, AccountRuntimeHost, + BackgroundRoutingOwnerRetirementError, }; use openbitfun_core::service::remote_connect::{ self, encryption, relay_client::RelayClient, relay_client::RelayEvent, session_store, @@ -32,14 +27,15 @@ pub(crate) struct CliAccountRuntimeParts { pub(crate) routing: Arc, } -pub(crate) fn build_account_runtime( - compatibility: CoreAgentRuntimeCompatibility, -) -> CliAccountRuntimeParts { - build_account_runtime_with_backup(Arc::new(CliAccountSessionBackupPort { compatibility })) +pub(crate) fn build_account_runtime() -> CliAccountRuntimeParts { + let routing = CliAccountRoutingHost::new(); + let runtime = AccountRuntime::new(routing.clone()); + routing.bind_runtime(Arc::downgrade(&runtime)); + CliAccountRuntimeParts { runtime, routing } } pub(crate) fn build_management_account_runtime() -> Arc { - build_account_runtime_with_backup(Arc::new(UnavailableSessionBackup)).runtime + build_account_runtime().runtime } pub(crate) fn account_snapshot_projection( @@ -47,7 +43,6 @@ pub(crate) fn account_snapshot_projection( ) -> AccountSnapshotProjection { AccountSnapshotProjection { logged_in: snapshot.logged_in, - pending_sync_choice: snapshot.pending_sync_choice, info: snapshot.info.map(|info| AccountInfo { user_id: info.user_id, relay_url: info.relay_url, @@ -63,58 +58,12 @@ pub(crate) fn account_snapshot_projection( online: device.online, }) .collect(), - sync: settings_sync_progress(snapshot.sync), - } -} - -pub(crate) fn settings_sync_progress( - progress: openbitfun_core::service::remote_connect::account_runtime::AccountSyncProgress, -) -> SettingsSyncProgress { - settings_sync_progress_from_core(progress) -} - -fn settings_sync_progress_from_core( - progress: openbitfun_core::service::remote_connect::account_runtime::AccountSyncProgress, -) -> SettingsSyncProgress { - SettingsSyncProgress { - operation_id: progress.operation_id, - status: match progress.status { - openbitfun_core::service::remote_connect::account_runtime::AccountSyncStatus::Idle => { - SettingsSyncStatus::Idle - } - openbitfun_core::service::remote_connect::account_runtime::AccountSyncStatus::Syncing => { - SettingsSyncStatus::Syncing - } - openbitfun_core::service::remote_connect::account_runtime::AccountSyncStatus::Done => { - SettingsSyncStatus::Done - } - openbitfun_core::service::remote_connect::account_runtime::AccountSyncStatus::Failed => { - SettingsSyncStatus::Failed - } - openbitfun_core::service::remote_connect::account_runtime::AccountSyncStatus::Cancelled => { - SettingsSyncStatus::Cancelled - } - }, - phase: progress.phase, - percent: progress.percent, - current: progress.current, - total: progress.total, - detail: progress.detail, - error: progress.error, - settings_synced: progress.settings_synced, - sessions_exported: progress.sessions_exported, } } pub(crate) fn account_login_status_message( result: &openbitfun_core::service::remote_connect::account_runtime::AccountLoginResult, ) -> String { - if result.has_cloud_settings { - return format!( - "Authenticated as user {} on {}. Choose cloud or local settings to finish login.", - result.user_id, result.relay_url - ); - } if result.routing_connected { format!( "Logged in as user {} on {}. Device routing connected.", @@ -153,61 +102,6 @@ fn bounded_account_error(message: &str) -> String { .collect() } -fn build_account_runtime_with_backup( - backup: Arc, -) -> CliAccountRuntimeParts { - let routing = CliAccountRoutingHost::new(); - let runtime = AccountRuntime::new(routing.clone(), backup); - routing.bind_runtime(Arc::downgrade(&runtime)); - CliAccountRuntimeParts { runtime, routing } -} - -struct UnavailableSessionBackup; - -#[async_trait] -impl AccountSessionBackupPort for UnavailableSessionBackup { - async fn list_session_backups( - &self, - _workspace_path: &std::path::Path, - ) -> Result> { - Err(anyhow!( - "Session backup is unavailable in a short-lived management command" - )) - } -} - -struct CliAccountSessionBackupPort { - compatibility: CoreAgentRuntimeCompatibility, -} - -#[async_trait] -impl AccountSessionBackupPort for CliAccountSessionBackupPort { - async fn list_session_backups( - &self, - workspace_path: &std::path::Path, - ) -> Result> { - let metadata = self - .compatibility - .list_persisted_sessions(workspace_path) - .await - .map_err(|error| anyhow!("list sessions: {error}"))?; - let mut backups = Vec::new(); - for item in &metadata { - if let Err(error) = ensure_relay_session_history_exportable(item) { - tracing::debug!("Skipping CLI account session export: {error}"); - continue; - } - let turns = self - .compatibility - .load_persisted_session_turns(workspace_path, &item.session_id, None) - .await - .map_err(|error| anyhow!("load turns: {error}"))?; - backups.push(build_session_backup(item, &turns)?); - } - Ok(backups) - } -} - /// CLI-owned routing effects injected into the shared Account Runtime. pub(crate) struct CliAccountRoutingHost { self_ref: Weak, @@ -432,6 +326,12 @@ impl CliAccountRoutingHost { } } RelayEvent::DevicePresence { devices } => { + if let Ok((session, _)) = runtime + .read_account_context_for_generation(account_generation) + .await + { + session.clear_peer_keys().await; + } tracing::info!("Device presence updated: {} online", devices.len()); if !self .routing_loop_is_current(account_generation, relay_client) @@ -450,7 +350,7 @@ impl CliAccountRoutingHost { encrypted_data, nonce, } => { - let Ok((session, _)) = runtime + let Ok((session, relay_url)) = runtime .read_account_context_for_generation(account_generation) .await else { @@ -463,11 +363,10 @@ impl CliAccountRoutingHost { { return; } - let plaintext = match encryption::decrypt_from_base64( - &session.master_key, - &encrypted_data, - &nonce, - ) { + let plaintext = match session + .decrypt_from_peer(&relay_url, &source_device_id, &encrypted_data, &nonce) + .await + { Ok(plaintext) => plaintext, Err(error) => { tracing::warn!("Failed to decrypt device message: {error}"); @@ -485,6 +384,16 @@ impl CliAccountRoutingHost { tracing::info!( "Device command from {source_device_id}: {command:?} corr={correlation_id}" ); + let peer_key = match session + .peer_message_key(&relay_url, &source_device_id) + .await + { + Ok(key) => key, + Err(error) => { + tracing::warn!("Failed to resolve peer message key: {error}"); + return; + } + }; let response = match &command { RemoteCommand::HostInvoke { command, args } => { crate::peer_host::handle_host_invoke(command, args.clone()).await @@ -492,7 +401,7 @@ impl CliAccountRoutingHost { RemoteCommand::DeviceEvent { .. } => { crate::peer_host::handle_device_event_command() } - other => RemoteServer::new(session.master_key).dispatch(other).await, + other => RemoteServer::new(peer_key).dispatch(other).await, }; if !self .routing_loop_is_current(account_generation, relay_client) @@ -509,7 +418,7 @@ impl CliAccountRoutingHost { }) }); let Ok((encrypted_response, response_nonce)) = - encryption::encrypt_to_base64(&session.master_key, &response_json) + encryption::encrypt_to_base64(&peer_key, &response_json) else { tracing::warn!("Failed to encrypt RPC response"); return; @@ -646,10 +555,6 @@ impl AccountRuntimeHost for CliAccountRoutingHost { async fn stop_device_routing(&self) { self.stop_routing().await; } - - fn notify_controllers_settings_changed(&self) { - crate::peer_host::notify_controllers_settings_changed(); - } } fn same_routing_client(current: Option<&Arc>, expected: &Arc) -> bool { @@ -711,6 +616,7 @@ impl PeerFanoutOwner { pub(crate) struct PeerFanoutLease { pub(crate) session: AccountSession, + pub(crate) relay_url: String, pub(crate) relay_client: Arc, _routing_lease: tokio::sync::OwnedRwLockReadGuard<()>, } @@ -728,7 +634,7 @@ pub(crate) async fn acquire_peer_fanout_lease(owner: &PeerFanoutOwner) -> Result if !runtime.account_context_is_current(owner.account_generation) { return Err(anyhow!("queued Peer event account changed")); } - let (session, _) = runtime + let (session, relay_url) = runtime .read_account_context_for_generation(owner.account_generation) .await?; let client = routing @@ -746,6 +652,7 @@ pub(crate) async fn acquire_peer_fanout_lease(owner: &PeerFanoutOwner) -> Result } Ok(PeerFanoutLease { session, + relay_url, relay_client: client, _routing_lease: routing_lease, }) diff --git a/src/apps/cli/src/actions.rs b/src/apps/cli/src/actions.rs index 1bf527c6ce..3ff84d4a20 100644 --- a/src/apps/cli/src/actions.rs +++ b/src/apps/cli/src/actions.rs @@ -918,7 +918,7 @@ static ACTION_SPECS: &[ActionSpec] = &[ id: "logout", name: "Logout", aliases: &["/logout"], - description: "Log out of OpenBitFun account", + description: "Log out of GitHub account", contexts: BOTH, availability: ActionAvailability::Always, handler: ActionHandler::Logout, diff --git a/src/apps/cli/src/daemon/runner.rs b/src/apps/cli/src/daemon/runner.rs index 849483ba47..a71c53fc5a 100644 --- a/src/apps/cli/src/daemon/runner.rs +++ b/src/apps/cli/src/daemon/runner.rs @@ -49,7 +49,6 @@ pub(crate) async fn run_daemon() -> Result<()> { // Continuous account settings sync (30s pull + debounced push) so this // always-on host converges with cloud changes made on other devices and // attached controllers see fresh config without reconnecting. - account.start_settings_sync_loop(); pid::write_pid_file()?; tracing::info!("openbitfun daemon running (pid {})", std::process::id()); diff --git a/src/apps/cli/src/main.rs b/src/apps/cli/src/main.rs index 9aec714368..f9a4fcb393 100644 --- a/src/apps/cli/src/main.rs +++ b/src/apps/cli/src/main.rs @@ -1090,16 +1090,6 @@ async fn run_interactive( } } - // 3.6 Continuous account settings sync (30s pull + debounced push). - // Safe to start before login: cycles skip while logged out. - if !shared { - runtime - .as_ref() - .expect("Embedded settings sync requires the CLI Runtime") - .account_runtime() - .start_settings_sync_loop(); - } - // Resolve the agent override against the execution owner's mode catalog. // Embedded and Shared both report the catalog through the same client // boundary, so a Shared controller never falls back to its local registry. diff --git a/src/apps/cli/src/management.rs b/src/apps/cli/src/management.rs index f84232347f..8ca23ed812 100644 --- a/src/apps/cli/src/management.rs +++ b/src/apps/cli/src/management.rs @@ -187,11 +187,6 @@ pub(crate) async fn set_default_model(model_id: &str) -> Result<()> { println!("Default model set to: {}", model_id); - // Short-lived management process: the sync loop never runs here, so push - // the change directly (no-op when logged out). - crate::account::build_management_account_runtime() - .push_settings_after_local_change() - .await; Ok(()) } diff --git a/src/apps/cli/src/modes/chat.rs b/src/apps/cli/src/modes/chat.rs index 1533b24971..e6c0b906a1 100644 --- a/src/apps/cli/src/modes/chat.rs +++ b/src/apps/cli/src/modes/chat.rs @@ -21,7 +21,7 @@ use std::time::{Duration, Instant}; use tokio::sync::broadcast::error::TryRecvError; use openbitfun_events::{AgenticEvent, ToolEventData, ToolEventIdentity}; -use openbitfun_product_domains::account::{AccountSnapshotProjection, SettingsSyncStatus}; +use openbitfun_product_domains::account::AccountSnapshotProjection; use openbitfun_product_domains::agent_catalog::{SkillSummary, SubagentSummary}; use openbitfun_product_domains::native_hooks::{ NativeHookOverview, NativeHookRuleSummary as NativeHookRuleView, @@ -444,7 +444,7 @@ fn terminal_event_allowed_while_local_effect_pending(event: &Event) -> bool { } const SESSION_OPERATION_SLOW_NOTICE: Duration = Duration::from_secs(15); -const SHARED_TUI_CHAT_STATUS: &str = "Shared TUI preview: this view controls sessions, including deleting an idle Session, turns, the current Session name, current Session Agent mode, and declarative context via /reload [skills|instructions]. Model, Skill, Subagent, and MCP management use this CLI process's local compatibility owner; MCP process state and tool registration are local to this CLI process and do not reconfigure an already-running Shared Runtime Host. Local extension, account-sync, usage, and other management remain Embedded."; +const SHARED_TUI_CHAT_STATUS: &str = "Shared TUI preview: this view controls sessions, including deleting an idle Session, turns, the current Session name, current Session Agent mode, and declarative context via /reload [skills|instructions]. Model, Skill, Subagent, and MCP management use this CLI process's local compatibility owner; MCP process state and tool registration are local to this CLI process and do not reconfigure an already-running Shared Runtime Host. Local extension, account, usage, and other management remain Embedded."; #[derive(Default)] struct NonKeyEventOutcome { diff --git a/src/apps/cli/src/modes/chat/account.rs b/src/apps/cli/src/modes/chat/account.rs index dcc88fc4ff..849e6782a9 100644 --- a/src/apps/cli/src/modes/chat/account.rs +++ b/src/apps/cli/src/modes/chat/account.rs @@ -40,96 +40,7 @@ impl ChatMode { chat_view.show_login_form(); return; }; - chat_view.show_account_panel(info, snapshot.devices, snapshot.sync); - } - - fn refresh_account_panel_live(&self, chat_view: &mut ChatView) -> bool { - if !chat_view.login_form_visible() { - return false; - } - let Ok(progress) = tokio::task::block_in_place(|| { - tokio::runtime::Handle::current().block_on(async { - let account = self.account_runtime.as_ref().ok_or_else(|| { - anyhow::anyhow!("Account management is unavailable for this TUI Host") - })?; - Ok::<_, anyhow::Error>(crate::account::settings_sync_progress( - account.current_sync_progress().await, - )) - }) - }) else { - return false; - }; - let progress = progress; - let devices = if matches!( - progress.status, - SettingsSyncStatus::Syncing | SettingsSyncStatus::Done - ) { - tokio::task::block_in_place(|| { - tokio::runtime::Handle::current().block_on(async { - let account = self.account_runtime.as_ref()?; - Some( - crate::account::account_snapshot_projection(account.snapshot().await) - .devices, - ) - }) - }) - } else { - None - }; - let syncing = progress.status == SettingsSyncStatus::Syncing; - chat_view.update_account_panel_progress(devices, progress); - syncing - } - - fn start_sync_and_show_account( - &self, - is_first_login: bool, - chat_view: &mut ChatView, - chat_state: &mut ChatState, - rt_handle: &tokio::runtime::Handle, - ) { - let result = tokio::task::block_in_place(|| { - rt_handle.block_on(async { - let account = self.account_runtime.as_ref().ok_or_else(|| { - anyhow::anyhow!("Account management is unavailable for this TUI Host") - })?; - if !account.is_logged_in().await { - anyhow::bail!("Account login must be finalized before settings sync starts") - } - if !account - .start_auto_sync_background( - format!("tui-account-{}", uuid::Uuid::new_v4()), - is_first_login, - std::path::PathBuf::from(self.agent.project_workspace_path_string()), - ) - .await - { - anyhow::bail!("Account settings sync is already in progress") - } - Ok::<(), anyhow::Error>(()) - }) - }); - if let Err(error) = result { - chat_state.add_system_message(format!("Account settings sync failed: {error}")); - return; - } - if let Ok(snapshot) = tokio::task::block_in_place(|| { - rt_handle.block_on(async { - let account = self.account_runtime.as_ref().ok_or_else(|| { - anyhow::anyhow!("Account management is unavailable for this TUI Host") - })?; - Ok::<_, anyhow::Error>(crate::account::account_snapshot_projection( - account.snapshot().await, - )) - }) - }) { - self.open_account_panel(chat_view, snapshot); - } - chat_state.add_system_message(if is_first_login { - "Sync started (use local / upload settings).".to_string() - } else { - "Sync started (use cloud / download settings).".to_string() - }); + chat_view.show_account_panel(info, snapshot.devices); } fn handle_login_form_action( @@ -140,173 +51,26 @@ impl ChatMode { rt_handle: &tokio::runtime::Handle, ) -> Result> { match action { - LoginFormAction::Submit(creds) => { + LoginFormAction::Submit(transaction_id) => { let result = tokio::task::block_in_place(|| { rt_handle.block_on(async { let account = self.account_runtime.as_ref().ok_or_else(|| { anyhow::anyhow!("Account management is unavailable for this TUI Host") })?; - let relay_url = creds.relay_url; - let username = creds.username; - let password = creds.password; - let result = account - .login_with_credentials(&relay_url, &username, &password) - .await - .map_err(|error| { - crate::account::redact_login_error( - error, - [&relay_url, &username, &password], - ) - })?; - let status_message = crate::account::account_login_status_message(&result); - Ok::<_, anyhow::Error>( - openbitfun_product_domains::account::AccountLoginProjection { - user_id: result.user_id, - relay_url: result.relay_url, - has_cloud_settings: result.has_cloud_settings, - status_message, - }, - ) + account.advance_github_login(transaction_id).await }) }); + use openbitfun_core::service::remote_connect::account_runtime::AccountLoginProgress; match result { - Ok(login) => { - chat_state.add_system_message(login.status_message.clone()); - if login.has_cloud_settings { - chat_view.show_sync_choice_panel(&login.user_id, &login.relay_url); - } else { - self.start_sync_and_show_account( - true, chat_view, chat_state, rt_handle, - ); - } - } - Err(e) => { - chat_view.login_form_set_error(format!("Login failed: {e}")); + Ok(AccountLoginProgress::Authorization(authorization)) => chat_view.login_form_set_authorization(authorization), + Ok(AccountLoginProgress::Waiting) => chat_view.login_form_set_status("Waiting for GitHub authorization. Complete it in your browser, then press Enter."), + Ok(AccountLoginProgress::Complete(login)) => { + chat_state.add_system_message(crate::account::account_login_status_message(&login)); + self.open_login_or_account_panel(chat_view, chat_state, rt_handle); } + Err(error) => chat_view.login_form_set_error(format!("Login failed: {error}")), } } - LoginFormAction::SyncUseLocal => { - let result = tokio::task::block_in_place(|| { - rt_handle.block_on(async { - let account = self.account_runtime.as_ref().ok_or_else(|| { - anyhow::anyhow!("Account management is unavailable for this TUI Host") - })?; - account.finalize_login_after_sync_choice().await?; - if !account - .start_auto_sync_background( - format!("tui-account-{}", uuid::Uuid::new_v4()), - true, - std::path::PathBuf::from( - self.agent.project_workspace_path_string(), - ), - ) - .await - { - anyhow::bail!("Account settings sync is already in progress") - } - Ok::<_, anyhow::Error>(crate::account::account_snapshot_projection( - account.snapshot().await, - )) - }) - }); - let snapshot = match result { - Ok(snapshot) => snapshot, - Err(error) => { - chat_view.login_form_set_error(format!("Finalize login failed: {error}")); - let _ = tokio::task::block_in_place(|| { - rt_handle.block_on(async { - let account = self.account_runtime.as_ref().ok_or_else(|| { - anyhow::anyhow!( - "Account management is unavailable for this TUI Host" - ) - })?; - account.logout().await?; - account - .mark_sync_cancelled(format!( - "tui-account-{}", - uuid::Uuid::new_v4() - )) - .await; - Ok::<(), anyhow::Error>(()) - }) - }); - chat_view.show_login_form(); - return Ok(None); - } - }; - self.open_account_panel(chat_view, snapshot); - chat_state - .add_system_message("Sync started (use local / upload settings).".to_string()); - } - LoginFormAction::SyncUseCloud => { - let result = tokio::task::block_in_place(|| { - rt_handle.block_on(async { - let account = self.account_runtime.as_ref().ok_or_else(|| { - anyhow::anyhow!("Account management is unavailable for this TUI Host") - })?; - account.finalize_login_after_sync_choice().await?; - if !account - .start_auto_sync_background( - format!("tui-account-{}", uuid::Uuid::new_v4()), - false, - std::path::PathBuf::from( - self.agent.project_workspace_path_string(), - ), - ) - .await - { - anyhow::bail!("Account settings sync is already in progress") - } - Ok::<_, anyhow::Error>(crate::account::account_snapshot_projection( - account.snapshot().await, - )) - }) - }); - let snapshot = match result { - Ok(snapshot) => snapshot, - Err(error) => { - chat_view.login_form_set_error(format!("Finalize login failed: {error}")); - let _ = tokio::task::block_in_place(|| { - rt_handle.block_on(async { - let account = self.account_runtime.as_ref().ok_or_else(|| { - anyhow::anyhow!( - "Account management is unavailable for this TUI Host" - ) - })?; - account.logout().await?; - account - .mark_sync_cancelled(format!( - "tui-account-{}", - uuid::Uuid::new_v4() - )) - .await; - Ok::<(), anyhow::Error>(()) - }) - }); - chat_view.show_login_form(); - return Ok(None); - } - }; - self.open_account_panel(chat_view, snapshot); - chat_state.add_system_message( - "Sync started (use cloud / download settings).".to_string(), - ); - } - LoginFormAction::SyncCancel => { - let _ = tokio::task::block_in_place(|| { - rt_handle.block_on(async { - let account = self.account_runtime.as_ref().ok_or_else(|| { - anyhow::anyhow!("Account management is unavailable for this TUI Host") - })?; - let progress = account - .cancel_sync(format!("tui-account-{}", uuid::Uuid::new_v4())) - .await?; - Ok::<_, anyhow::Error>(crate::account::settings_sync_progress(progress)) - }) - }); - chat_view.show_login_form(); - chat_state.add_system_message("Sync cancelled; logged out.".to_string()); - } LoginFormAction::Logout => { match tokio::task::block_in_place(|| { rt_handle.block_on(async { @@ -314,9 +78,6 @@ impl ChatMode { anyhow::anyhow!("Account management is unavailable for this TUI Host") })?; account.logout().await?; - account - .mark_sync_cancelled(format!("tui-account-{}", uuid::Uuid::new_v4())) - .await; Ok::<_, anyhow::Error>(crate::account::account_snapshot_projection( account.snapshot().await, )) diff --git a/src/apps/cli/src/modes/chat/input.rs b/src/apps/cli/src/modes/chat/input.rs index 795a43ca3d..1c46f91090 100644 --- a/src/apps/cli/src/modes/chat/input.rs +++ b/src/apps/cli/src/modes/chat/input.rs @@ -477,7 +477,6 @@ impl ChatMode { } if chat_view.login_form_visible() { - self.refresh_account_panel_live(chat_view); let action = chat_view.login_form_handle_key(key); return self.handle_login_form_action(action, chat_view, chat_state, rt_handle); } diff --git a/src/apps/cli/src/modes/chat/provider_models.rs b/src/apps/cli/src/modes/chat/provider_models.rs index 0c16641742..b05a03f81b 100644 --- a/src/apps/cli/src/modes/chat/provider_models.rs +++ b/src/apps/cli/src/modes/chat/provider_models.rs @@ -67,9 +67,7 @@ impl ChatMode { chat_view.set_status(Some(format!("Model added: {}", result.name))); chat_state.current_model_name = format!("{} / {}", result.model_name, result.name); tracing::info!("Added new AI model: {} ({})", model_id, result.model_name); - if let Some(account) = &self.account_runtime { - account.notify_local_settings_changed(); - } + if let Some(account) = &self.account_runtime {} } Err(error) => { tracing::error!("Failed to add AI model: {error}"); @@ -159,9 +157,7 @@ impl ChatMode { chat_view.set_status(Some(format!("Model updated: {}", result.name))); chat_state.current_model_name = format!("{} / {}", result.model_name, result.name); tracing::info!("Updated AI model: {model_id}"); - if let Some(account) = &self.account_runtime { - account.notify_local_settings_changed(); - } + if let Some(account) = &self.account_runtime {} } Err(error) => { tracing::error!("Failed to update AI model: {error}"); diff --git a/src/apps/cli/src/modes/chat/run.rs b/src/apps/cli/src/modes/chat/run.rs index e97608bc94..77bbffba77 100644 --- a/src/apps/cli/src/modes/chat/run.rs +++ b/src/apps/cli/src/modes/chat/run.rs @@ -855,11 +855,7 @@ impl ChatMode { external_source_rx = None; } - if chat_view.login_form_visible() { - if self.refresh_account_panel_live(&mut chat_view) { - needs_redraw = true; - } - } + if chat_view.login_form_visible() {} let mut did_render_this_loop = false; if needs_redraw && resize_redraw.can_render() { diff --git a/src/apps/cli/src/modes/chat/selection.rs b/src/apps/cli/src/modes/chat/selection.rs index 1eae86f0db..9643ba0e32 100644 --- a/src/apps/cli/src/modes/chat/selection.rs +++ b/src/apps/cli/src/modes/chat/selection.rs @@ -225,9 +225,6 @@ impl ChatMode { anyhow!("Account management is unavailable for this TUI Host") })?; account.logout().await?; - account - .mark_sync_cancelled(format!("tui-account-{}", uuid::Uuid::new_v4())) - .await; Ok::<_, anyhow::Error>(crate::account::account_snapshot_projection( account.snapshot().await, )) diff --git a/src/apps/cli/src/peer_host/commands/config.rs b/src/apps/cli/src/peer_host/commands/config.rs index 7f362ee769..1578e00d94 100644 --- a/src/apps/cli/src/peer_host/commands/config.rs +++ b/src/apps/cli/src/peer_host/commands/config.rs @@ -114,7 +114,6 @@ pub(crate) async fn set_config(state: &PeerHostState, args: &Value) -> Result Result Err( diff --git a/src/apps/cli/src/peer_host/deny.rs b/src/apps/cli/src/peer_host/deny.rs index e9766e744f..bbc1e1b5c1 100644 --- a/src/apps/cli/src/peer_host/deny.rs +++ b/src/apps/cli/src/peer_host/deny.rs @@ -143,12 +143,17 @@ mod tests { assert!(!is_local_only_command("git_get_repository_trust")); } - /// Previously the CLI list lacked this entry while the desktop and the - /// frontend refused it, and a CI exception hid the drift. One registry row - /// now answers for all three surfaces. + /// GitHub identity belongs to the controller across all three surfaces. #[test] - fn cancelling_a_pending_login_stays_on_the_controller_device() { - assert!(is_local_only_command("account_cancel_pending_login")); + fn github_identity_stays_on_the_controller_device() { + for command in [ + "account_github_start", + "account_github_poll", + "account_github_info", + "account_logout", + ] { + assert!(is_local_only_command(command), "{command}"); + } } /// Browser and OS automation are controller-local in the frontend adapter diff --git a/src/apps/cli/src/peer_host/fanout.rs b/src/apps/cli/src/peer_host/fanout.rs index a6514decf5..6562849db3 100644 --- a/src/apps/cli/src/peer_host/fanout.rs +++ b/src/apps/cli/src/peer_host/fanout.rs @@ -11,7 +11,6 @@ use openbitfun_agent_runtime::sdk::{ attach_session_event_cursor, AgentEventReceiver, PermissionRequestEvent, }; use openbitfun_agent_tools::effective_tool_invocation; -use openbitfun_core::service::remote_connect::encryption::encrypt_to_base64; use openbitfun_core::service::remote_connect::remote_server::RemoteCommand; use openbitfun_events::{project_agentic_frontend_event, AgenticEvent, ToolEventData}; use tokio::sync::{broadcast, mpsc}; @@ -683,13 +682,6 @@ async fn fanout_peer_device_event_once(queued: QueuedPeerDeviceEvent) { return; } }; - let (encrypted_data, nonce) = match encrypt_to_base64(&session.master_key, &envelope) { - Ok(encrypted) => encrypted, - Err(error) => { - tracing::warn!("Peer event fanout encryption failed: {error}"); - return; - } - }; let targets = retained_delivery_targets(&targets, &attached_controllers()); if targets.is_empty() { return; @@ -702,6 +694,16 @@ async fn fanout_peer_device_event_once(queued: QueuedPeerDeviceEvent) { let Some(_delivery_lease) = controller_delivery_lease(target).await else { continue; }; + let (encrypted_data, nonce) = match session + .encrypt_for_peer(&routing_lease.relay_url, target, &envelope) + .await + { + Ok(encrypted) => encrypted, + Err(error) => { + tracing::warn!("Peer event fanout encryption failed: {error}"); + continue; + } + }; let correlation_id = uuid::Uuid::new_v4().to_string(); if let Err(error) = relay_client .send_device_message(target, &correlation_id, &encrypted_data, &nonce) diff --git a/src/apps/cli/src/runtime/mod.rs b/src/apps/cli/src/runtime/mod.rs index 964f5e59cd..c154ae34c3 100644 --- a/src/apps/cli/src/runtime/mod.rs +++ b/src/apps/cli/src/runtime/mod.rs @@ -93,7 +93,7 @@ impl CliRuntimeContext { .context("Failed to build CLI Agent Runtime SDK")?; let compatibility = CoreAgentRuntimeCompatibility::build(agentic_system.coordinator.clone(), scheduler); - let account = build_account_runtime(compatibility.clone()); + let account = build_account_runtime(); let local_workspace_snapshot = CoreLocalWorkspaceSnapshot::build(); let token_usage_service = agentic_system.token_usage_service.clone(); diff --git a/src/apps/cli/src/ui/chat/popups.rs b/src/apps/cli/src/ui/chat/popups.rs index f22c460571..c23c03d9d3 100644 --- a/src/apps/cli/src/ui/chat/popups.rs +++ b/src/apps/cli/src/ui/chat/popups.rs @@ -756,6 +756,15 @@ impl ChatView { self.login_form.handle_key_event(key) } + pub(crate) fn login_form_set_authorization( + &mut self, + authorization: openbitfun_product_domains::account::GitHubAuthStart, + ) { + self.login_form.set_authorization(authorization); + } + pub(crate) fn login_form_set_status(&mut self, status: &str) { + self.login_form.set_status(status); + } pub(crate) fn login_form_set_error(&mut self, message: impl Into) { self.login_form.set_error(message); } @@ -768,25 +777,10 @@ impl ChatView { &mut self, info: openbitfun_product_domains::account::AccountInfo, devices: Vec, - sync_progress: openbitfun_product_domains::account::SettingsSyncProgress, ) { - self.login_form.show_account(info, devices, sync_progress); + self.login_form.show_account(info, devices); self.popup_stack.push(PopupType::LoginForm); } - - pub(crate) fn show_sync_choice_panel(&mut self, user_id: &str, relay_url: &str) { - self.login_form.show_sync_choice(user_id, relay_url); - self.popup_stack.push(PopupType::LoginForm); - } - - pub(crate) fn update_account_panel_progress( - &mut self, - devices: Option>, - sync_progress: openbitfun_product_domains::account::SettingsSyncProgress, - ) { - self.login_form - .update_account_progress(devices, sync_progress); - } } #[cfg(test)] diff --git a/src/apps/cli/src/ui/login_form.rs b/src/apps/cli/src/ui/login_form.rs index bd15f4f06b..8739444e55 100644 --- a/src/apps/cli/src/ui/login_form.rs +++ b/src/apps/cli/src/ui/login_form.rs @@ -1,29 +1,18 @@ -//! Full-viewport OpenBitFun account panel (Login / Sync choice / Account status). +//! Full-viewport GitHub account panel (Login / Account status). //! -//! Opened by `/login`. When already logged in, shows account info and sync -//! progress instead of the credential form. +//! Opened by `/login`. When already logged in, shows account info and connected devices instead of the credential form. -use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use crossterm::event::{KeyCode, KeyEvent}; use ratatui::{ layout::{Alignment, Constraint, Direction, Layout, Rect}, style::{Color, Modifier, Style}, text::{Line, Span}, - widgets::{Block, Borders, Clear, Paragraph}, + widgets::{Block, Borders, Clear, Paragraph, Wrap}, Frame, }; use crate::ui::theme::{StyleKind, Theme}; -use openbitfun_product_domains::account::{ - AccountDevice, AccountInfo, SettingsSyncProgress, SettingsSyncStatus, -}; - -/// Credentials collected by the login form. -#[derive(Debug, Clone)] -pub(crate) struct LoginCredentials { - pub relay_url: String, - pub username: String, - pub password: String, -} +use openbitfun_product_domains::account::{AccountDevice, AccountInfo, GitHubAuthStart}; /// Action returned after handling a key event. #[derive(Debug, Clone)] @@ -31,14 +20,8 @@ pub(crate) enum LoginFormAction { None, /// Close the panel (Esc on most views). Cancel, - /// Submit login credentials. - Submit(LoginCredentials), - /// User chose "Use local" on the sync conflict page. - SyncUseLocal, - /// User chose "Use cloud" on the sync conflict page. - SyncUseCloud, - /// User cancelled the sync choice (logout + back to login). - SyncCancel, + /// Start GitHub sign-in or check an existing transaction. + Submit(Option), /// User requested logout from the account page. Logout, } @@ -46,38 +29,9 @@ pub(crate) enum LoginFormAction { #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum PanelMode { Login, - SyncChoice, Account, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum LoginFocus { - AuthServer, - Username, - Password, - Login, -} - -const LOGIN_FOCUS_ORDER: [LoginFocus; 4] = [ - LoginFocus::AuthServer, - LoginFocus::Username, - LoginFocus::Password, - LoginFocus::Login, -]; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum SyncChoiceFocus { - UseLocal, - UseCloud, - Cancel, -} - -const SYNC_CHOICE_ORDER: [SyncChoiceFocus; 3] = [ - SyncChoiceFocus::UseLocal, - SyncChoiceFocus::UseCloud, - SyncChoiceFocus::Cancel, -]; - #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum AccountFocus { Logout, @@ -89,25 +43,14 @@ pub(crate) struct LoginFormState { visible: bool, mode: PanelMode, - // Login fields - auth_server: String, - username: String, - password: String, - login_focus: LoginFocus, - cursor: usize, + authorization: Option, error: Option, status: Option, - // Sync choice - sync_choice_focus: SyncChoiceFocus, - pending_user_id: String, - pending_relay: String, - // Account status account_focus: AccountFocus, account_info: Option, devices: Vec, - sync_progress: SettingsSyncProgress, } impl LoginFormState { @@ -115,20 +58,12 @@ impl LoginFormState { Self { visible: false, mode: PanelMode::Login, - auth_server: String::new(), - username: String::new(), - password: String::new(), - login_focus: LoginFocus::AuthServer, - cursor: 0, + authorization: None, error: None, status: None, - sync_choice_focus: SyncChoiceFocus::UseLocal, - pending_user_id: String::new(), - pending_relay: String::new(), account_focus: AccountFocus::Close, account_info: None, devices: Vec::new(), - sync_progress: SettingsSyncProgress::default(), } } @@ -139,14 +74,9 @@ impl LoginFormState { pub(crate) fn show(&mut self) { self.visible = true; self.mode = PanelMode::Login; - self.auth_server.clear(); - self.username.clear(); - self.password.clear(); - self.login_focus = LoginFocus::AuthServer; - self.cursor = 0; + self.authorization = None; self.error = None; self.status = None; - self.sync_choice_focus = SyncChoiceFocus::UseLocal; self.account_focus = AccountFocus::Close; } @@ -156,49 +86,19 @@ impl LoginFormState { self.status = None; } - pub(crate) fn show_sync_choice(&mut self, user_id: &str, relay_url: &str) { - self.visible = true; - self.mode = PanelMode::SyncChoice; - self.pending_user_id = user_id.to_string(); - self.pending_relay = relay_url.to_string(); - self.sync_choice_focus = SyncChoiceFocus::UseLocal; - self.error = None; - self.status = None; - } - - pub(crate) fn show_account( - &mut self, - info: AccountInfo, - devices: Vec, - sync_progress: SettingsSyncProgress, - ) { + pub(crate) fn show_account(&mut self, info: AccountInfo, devices: Vec) { self.visible = true; self.mode = PanelMode::Account; self.account_info = Some(info); self.devices = devices; - self.sync_progress = sync_progress; self.account_focus = AccountFocus::Close; self.error = None; self.status = None; } - pub(crate) fn update_account_progress( - &mut self, - devices: Option>, - sync_progress: SettingsSyncProgress, - ) { - if let Some(devices) = devices { - self.devices = devices; - } - self.sync_progress = sync_progress; - } - pub(crate) fn set_error(&mut self, message: impl Into) { self.error = Some(message.into()); self.status = None; - if self.mode == PanelMode::Login { - self.login_focus = LoginFocus::Login; - } } pub(crate) fn set_status(&mut self, message: impl Into) { @@ -206,90 +106,12 @@ impl LoginFormState { self.error = None; } - pub(crate) fn insert_paste(&mut self, text: &str) { - if !self.visible || self.mode != PanelMode::Login || self.login_focus == LoginFocus::Login { - return; - } - let cleaned: String = text - .chars() - .filter(|c| *c != '\n' && *c != '\r' && *c != '\t') - .collect(); - if cleaned.is_empty() { - return; - } - let cursor = self.cursor; - if let Some(buf) = self.active_buffer_mut() { - let byte = char_to_byte(buf, cursor); - buf.insert_str(byte, &cleaned); - self.cursor = cursor + cleaned.chars().count(); - } - self.error = None; - } - - fn active_buffer(&self) -> &str { - match self.login_focus { - LoginFocus::AuthServer => &self.auth_server, - LoginFocus::Username => &self.username, - LoginFocus::Password => &self.password, - LoginFocus::Login => "", - } - } - - fn active_buffer_mut(&mut self) -> Option<&mut String> { - match self.login_focus { - LoginFocus::AuthServer => Some(&mut self.auth_server), - LoginFocus::Username => Some(&mut self.username), - LoginFocus::Password => Some(&mut self.password), - LoginFocus::Login => None, - } - } - - fn move_login_focus(&mut self, delta: isize) { - let len = LOGIN_FOCUS_ORDER.len() as isize; - let idx = LOGIN_FOCUS_ORDER - .iter() - .position(|f| *f == self.login_focus) - .unwrap_or(0) as isize; - let next = (idx + delta).rem_euclid(len) as usize; - self.login_focus = LOGIN_FOCUS_ORDER[next]; - self.cursor = self.active_buffer().chars().count(); - } - - fn move_sync_focus(&mut self, delta: isize) { - let len = SYNC_CHOICE_ORDER.len() as isize; - let idx = SYNC_CHOICE_ORDER - .iter() - .position(|f| *f == self.sync_choice_focus) - .unwrap_or(0) as isize; - let next = (idx + delta).rem_euclid(len) as usize; - self.sync_choice_focus = SYNC_CHOICE_ORDER[next]; - } - - fn validate_login(&self) -> Option { - if self.auth_server.trim().is_empty() { - return Some("Auth Server is required".into()); - } - if self.username.trim().is_empty() { - return Some("Username is required".into()); - } - if self.password.is_empty() { - return Some("Password is required".into()); - } - None + pub(crate) fn set_authorization(&mut self, authorization: GitHubAuthStart) { + self.authorization = Some(authorization); + self.set_status("Complete GitHub authorization, then press Enter."); } - fn try_submit_login(&mut self) -> LoginFormAction { - if let Some(err) = self.validate_login() { - self.set_error(err); - return LoginFormAction::None; - } - self.set_status("Logging in..."); - LoginFormAction::Submit(LoginCredentials { - relay_url: self.auth_server.trim().to_string(), - username: self.username.trim().to_string(), - password: self.password.clone(), - }) - } + pub(crate) fn insert_paste(&mut self, _text: &str) {} pub(crate) fn handle_key_event(&mut self, key: KeyEvent) -> LoginFormAction { if !self.visible { @@ -297,114 +119,25 @@ impl LoginFormState { } match self.mode { PanelMode::Login => self.handle_login_key(key), - PanelMode::SyncChoice => self.handle_sync_choice_key(key), PanelMode::Account => self.handle_account_key(key), } } fn handle_login_key(&mut self, key: KeyEvent) -> LoginFormAction { - match (key.code, key.modifiers) { - (KeyCode::Esc, _) => { + match key.code { + KeyCode::Esc => { self.hide(); LoginFormAction::Cancel } - (KeyCode::Char('v'), KeyModifiers::CONTROL) => { - if let Ok(mut clipboard) = arboard::Clipboard::new() { - if let Ok(text) = clipboard.get_text() { - self.insert_paste(&text); - } - } - LoginFormAction::None - } - (KeyCode::Up, _) | (KeyCode::BackTab, _) => { - self.move_login_focus(-1); - LoginFormAction::None - } - (KeyCode::Down, _) | (KeyCode::Tab, _) => { - self.move_login_focus(1); - LoginFormAction::None - } - (KeyCode::Enter, _) => match self.login_focus { - LoginFocus::Login => self.try_submit_login(), - _ => { - self.move_login_focus(1); - LoginFormAction::None - } - }, - (KeyCode::Left, _) if self.login_focus != LoginFocus::Login => { - self.cursor = self.cursor.saturating_sub(1); - LoginFormAction::None - } - (KeyCode::Right, _) if self.login_focus != LoginFocus::Login => { - let len = self.active_buffer().chars().count(); - if self.cursor < len { - self.cursor += 1; - } - LoginFormAction::None - } - (KeyCode::Home, _) if self.login_focus != LoginFocus::Login => { - self.cursor = 0; - LoginFormAction::None - } - (KeyCode::End, _) if self.login_focus != LoginFocus::Login => { - self.cursor = self.active_buffer().chars().count(); - LoginFormAction::None - } - (KeyCode::Backspace, _) => { - let cursor = self.cursor; - if let Some(buf) = self.active_buffer_mut() { - if cursor > 0 { - let byte = char_to_byte(buf, cursor - 1); - let end = char_to_byte(buf, cursor); - buf.replace_range(byte..end, ""); - self.cursor = cursor - 1; - } - } - LoginFormAction::None - } - (KeyCode::Delete, _) => { - let cursor = self.cursor; - if let Some(buf) = self.active_buffer_mut() { - let len = buf.chars().count(); - if cursor < len { - let start = char_to_byte(buf, cursor); - let end = char_to_byte(buf, cursor + 1); - buf.replace_range(start..end, ""); - } - } - LoginFormAction::None - } - (KeyCode::Char(c), KeyModifiers::NONE | KeyModifiers::SHIFT) - if self.login_focus != LoginFocus::Login && !c.is_control() => - { - let cursor = self.cursor; - if let Some(buf) = self.active_buffer_mut() { - let byte = char_to_byte(buf, cursor); - buf.insert(byte, c); - self.cursor = cursor + 1; - } - LoginFormAction::None - } - _ => LoginFormAction::None, - } - } - - fn handle_sync_choice_key(&mut self, key: KeyEvent) -> LoginFormAction { - match (key.code, key.modifiers) { - (KeyCode::Esc, _) => LoginFormAction::SyncCancel, - (KeyCode::Up, _) | (KeyCode::BackTab, _) => { - self.move_sync_focus(-1); - LoginFormAction::None - } - (KeyCode::Down, _) | (KeyCode::Tab, _) => { - self.move_sync_focus(1); - LoginFormAction::None + KeyCode::Enter => LoginFormAction::Submit( + self.authorization + .as_ref() + .map(|a| a.transaction_id.clone()), + ), + KeyCode::Char('r') => { + self.authorization = None; + LoginFormAction::Submit(None) } - (KeyCode::Enter, _) => match self.sync_choice_focus { - SyncChoiceFocus::UseLocal => LoginFormAction::SyncUseLocal, - SyncChoiceFocus::UseCloud => LoginFormAction::SyncUseCloud, - SyncChoiceFocus::Cancel => LoginFormAction::SyncCancel, - }, _ => LoginFormAction::None, } } @@ -447,7 +180,6 @@ impl LoginFormState { frame.render_widget(Clear, area); match self.mode { PanelMode::Login => self.render_login(frame, area, theme), - PanelMode::SyncChoice => self.render_sync_choice(frame, area, theme), PanelMode::Account => self.render_account(frame, area, theme), } } @@ -456,147 +188,41 @@ impl LoginFormState { let outer = Block::default() .borders(Borders::ALL) .border_style(theme.style(StyleKind::Primary)) - .title(" OpenBitFun Account Login ") + .title(" OpenBitFun · GitHub Sign-in ") .title_alignment(Alignment::Center); let inner = outer.inner(area); frame.render_widget(outer, area); - - let form_width = inner.width.min(72).max(40); - let form_height = 15u16.min(inner.height.max(12)); - let form_area = Rect { - x: inner.x + (inner.width.saturating_sub(form_width)) / 2, - y: inner.y + (inner.height.saturating_sub(form_height)) / 2, - width: form_width, - height: form_height, - }; - let rows = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(1), - Constraint::Length(1), - Constraint::Length(1), - Constraint::Length(1), - Constraint::Length(1), - Constraint::Length(1), - Constraint::Length(1), - Constraint::Length(1), - Constraint::Length(1), - Constraint::Length(1), - Constraint::Length(1), - Constraint::Length(1), - Constraint::Min(1), - ]) - .split(form_area); - - self.render_field_label( - frame, - rows[0], - "Auth Server", - self.login_focus == LoginFocus::AuthServer, - theme, - ); - self.render_text_input(frame, rows[1], LoginFocus::AuthServer, theme); - self.render_field_label( - frame, - rows[3], - "Username", - self.login_focus == LoginFocus::Username, - theme, - ); - self.render_text_input(frame, rows[4], LoginFocus::Username, theme); - self.render_field_label( - frame, - rows[6], - "Password", - self.login_focus == LoginFocus::Password, - theme, - ); - self.render_text_input(frame, rows[7], LoginFocus::Password, theme); - self.render_button( - frame, - rows[9], - "[ Login ]", - self.login_focus == LoginFocus::Login, - theme, - ); - self.render_message(frame, rows[11], theme); - self.render_hints( - frame, - rows[12], - "Up/Down Select Enter Next / Submit Esc Cancel", - theme, - ); - } - - fn render_sync_choice(&self, frame: &mut Frame, area: Rect, theme: &Theme) { - let outer = Block::default() - .borders(Borders::ALL) - .border_style(theme.style(StyleKind::Primary)) - .title(" Cloud Settings Found ") - .title_alignment(Alignment::Center); - let inner = outer.inner(area); - frame.render_widget(outer, area); - - let form_width = inner.width.min(76).max(40); - let form_height = 14u16.min(inner.height.max(10)); - let form_area = Rect { - x: inner.x + (inner.width.saturating_sub(form_width)) / 2, - y: inner.y + (inner.height.saturating_sub(form_height)) / 2, - width: form_width, - height: form_height, - }; let rows = Layout::default() .direction(Direction::Vertical) .constraints([ + Constraint::Length(2), + Constraint::Min(3), Constraint::Length(2), Constraint::Length(1), - Constraint::Length(1), - Constraint::Length(1), - Constraint::Length(1), - Constraint::Length(1), - Constraint::Length(1), - Constraint::Length(1), - Constraint::Length(1), - Constraint::Min(1), ]) - .split(form_area); - - let notice = format!( - "Account {} already has cloud settings on {}.\nChoose how to sync this device.", - self.pending_user_id, self.pending_relay - ); + .split(inner); frame.render_widget( - Paragraph::new(notice).style(theme.style(StyleKind::Muted)), + Paragraph::new("Use the same GitHub account as the OpenBitFun marketplaces.") + .style(theme.style(StyleKind::Muted)) + .wrap(Wrap { trim: false }), rows[0], ); - - self.render_choice_row( - frame, - rows[2], - "Use local", - "Keep this device settings and upload them to cloud", - self.sync_choice_focus == SyncChoiceFocus::UseLocal, - theme, - ); - self.render_choice_row( - frame, - rows[4], - "Use cloud", - "Download cloud settings and overwrite this device", - self.sync_choice_focus == SyncChoiceFocus::UseCloud, - theme, - ); - self.render_button( - frame, - rows[6], - "[ Cancel / Logout ]", - self.sync_choice_focus == SyncChoiceFocus::Cancel, - theme, + let text = self + .authorization + .as_ref() + .map(|a| format!("Open this link in your browser:\n\n{}", a.authorization_url)) + .unwrap_or_else(|| "Press Enter to sign in with GitHub.".to_string()); + frame.render_widget( + Paragraph::new(text) + .style(theme.style(StyleKind::Primary)) + .wrap(Wrap { trim: false }), + rows[1], ); + self.render_message(frame, rows[2], theme); self.render_hints( frame, - rows[9], - "Up/Down Select Enter Confirm Esc Cancel", + rows[3], + "Enter Continue / Check R Restart sign-in Esc Close", theme, ); } @@ -605,7 +231,7 @@ impl LoginFormState { let outer = Block::default() .borders(Borders::ALL) .border_style(theme.style(StyleKind::Primary)) - .title(" OpenBitFun Account ") + .title(" GitHub Account ") .title_alignment(Alignment::Center); let inner = outer.inner(area); frame.render_widget(outer, area); @@ -614,7 +240,6 @@ impl LoginFormState { .direction(Direction::Vertical) .constraints([ Constraint::Length(4), // account info - Constraint::Length(3), // sync progress Constraint::Min(4), // devices Constraint::Length(1), // buttons Constraint::Length(1), // hints @@ -657,50 +282,6 @@ impl LoginFormState { ]; frame.render_widget(Paragraph::new(info_lines), rows[0]); - let sync = &self.sync_progress; - let sync_text = match sync.status { - SettingsSyncStatus::Idle => "Sync: idle".to_string(), - SettingsSyncStatus::Syncing => { - format!("Syncing: {} {}%", sync_phase_label(sync), sync.percent) - } - SettingsSyncStatus::Done => format!( - "Sync done — settings={} exported={}", - sync.settings_synced, sync.sessions_exported - ), - SettingsSyncStatus::Failed => format!( - "Sync failed: {}", - sync.error.as_deref().unwrap_or("unknown error") - ), - SettingsSyncStatus::Cancelled => "Sync cancelled".to_string(), - }; - let sync_style = match sync.status { - SettingsSyncStatus::Failed => theme.style(StyleKind::Error), - SettingsSyncStatus::Done => theme.style(StyleKind::Info), - SettingsSyncStatus::Syncing => theme.style(StyleKind::Primary), - SettingsSyncStatus::Idle => theme.style(StyleKind::Muted), - SettingsSyncStatus::Cancelled => theme.style(StyleKind::Muted), - }; - let bar_width = rows[1].width.saturating_sub(2) as usize; - let filled = if sync.status == SettingsSyncStatus::Syncing - || sync.status == SettingsSyncStatus::Done - { - ((sync.percent as usize) * bar_width) / 100 - } else { - 0 - }; - let bar = format!( - "[{}{}]", - "#".repeat(filled.min(bar_width)), - "-".repeat(bar_width.saturating_sub(filled)) - ); - frame.render_widget( - Paragraph::new(vec![ - Line::from(Span::styled(sync_text, sync_style)), - Line::from(Span::styled(bar, theme.style(StyleKind::Muted))), - ]), - rows[1], - ); - let mut device_lines = vec![Line::from(Span::styled( "Devices", theme.style(StyleKind::Primary).add_modifier(Modifier::BOLD), @@ -732,12 +313,12 @@ impl LoginFormState { ))); } } - frame.render_widget(Paragraph::new(device_lines), rows[2]); + frame.render_widget(Paragraph::new(device_lines), rows[1]); let btn_row = Layout::default() .direction(Direction::Horizontal) .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) - .split(rows[3]); + .split(rows[2]); self.render_button( frame, btn_row[0], @@ -754,68 +335,12 @@ impl LoginFormState { ); self.render_hints( frame, - rows[4], + rows[3], "Tab Switch Enter Activate Esc Close", theme, ); } - fn render_field_label( - &self, - frame: &mut Frame, - area: Rect, - text: &str, - active: bool, - theme: &Theme, - ) { - let style = if active { - theme.style(StyleKind::Primary).add_modifier(Modifier::BOLD) - } else { - theme.style(StyleKind::Muted) - }; - frame.render_widget(Paragraph::new(Line::from(Span::styled(text, style))), area); - } - - fn render_text_input(&self, frame: &mut Frame, area: Rect, item: LoginFocus, theme: &Theme) { - let active = self.login_focus == item; - let raw = match item { - LoginFocus::AuthServer => self.auth_server.as_str(), - LoginFocus::Username => self.username.as_str(), - LoginFocus::Password => self.password.as_str(), - LoginFocus::Login => "", - }; - let display = if item == LoginFocus::Password { - "*".repeat(raw.chars().count()) - } else { - raw.to_string() - }; - let prefix = if active { "> " } else { " " }; - let mut spans = vec![Span::styled( - prefix, - if active { - theme.style(StyleKind::Primary).add_modifier(Modifier::BOLD) - } else { - Style::default() - }, - )]; - if active { - let cursor = self.cursor.min(display.chars().count()); - let before: String = display.chars().take(cursor).collect(); - let after: String = display.chars().skip(cursor).collect(); - let cursor_char = after.chars().next().unwrap_or(' '); - let after_rest: String = after.chars().skip(1).collect(); - spans.push(Span::styled(before, Style::default().fg(Color::White))); - spans.push(Span::styled( - cursor_char.to_string(), - Style::default().fg(Color::Black).bg(Color::White), - )); - spans.push(Span::styled(after_rest, Style::default().fg(Color::White))); - } else if !display.is_empty() { - spans.push(Span::styled(display, Style::default().fg(Color::White))); - } - frame.render_widget(Paragraph::new(Line::from(spans)), area); - } - fn render_button( &self, frame: &mut Frame, @@ -838,30 +363,6 @@ impl LoginFormState { ); } - fn render_choice_row( - &self, - frame: &mut Frame, - area: Rect, - title: &str, - desc: &str, - active: bool, - theme: &Theme, - ) { - let marker = if active { "> " } else { " " }; - let title_style = if active { - theme.style(StyleKind::Primary).add_modifier(Modifier::BOLD) - } else { - Style::default().fg(Color::White) - }; - frame.render_widget( - Paragraph::new(Line::from(vec![ - Span::styled(marker, title_style), - Span::styled(format!("{title} — {desc}"), title_style), - ])), - area, - ); - } - fn render_message(&self, frame: &mut Frame, area: Rect, theme: &Theme) { if let Some(ref err) = self.error { frame.render_widget( @@ -896,34 +397,6 @@ impl LoginFormState { } } -fn sync_phase_label(progress: &SettingsSyncProgress) -> String { - match progress.phase.as_str() { - "uploading_settings" => "Uploading settings...".into(), - "downloading_settings" => "Downloading settings...".into(), - "applying_settings" => "Applying cloud settings...".into(), - "settings_done" => "Settings sync done".into(), - "listing_sessions" => "Listing local sessions...".into(), - "exporting_sessions" => { - if let (Some(current), Some(total)) = (progress.current, progress.total) { - format!("Uploading sessions ({current}/{total})...") - } else { - "Uploading sessions...".into() - } - } - "done" => format!("Sync complete (exported {})", progress.sessions_exported), - "starting" => "Starting sync...".into(), - other if other.is_empty() => "Sync".into(), - other => other.to_string(), - } -} - -fn char_to_byte(s: &str, char_idx: usize) -> usize { - s.char_indices() - .nth(char_idx) - .map(|(i, _)| i) - .unwrap_or(s.len()) -} - fn truncate_id(id: &str) -> String { if id.len() <= 8 { id.to_string() @@ -935,44 +408,27 @@ fn truncate_id(id: &str) -> String { #[cfg(test)] mod tests { use super::*; - use crossterm::event::KeyEventKind; - - fn key(code: KeyCode) -> KeyEvent { - KeyEvent { - code, - modifiers: KeyModifiers::NONE, - kind: KeyEventKind::Press, - state: crossterm::event::KeyEventState::empty(), - } - } - + use crossterm::event::KeyModifiers; #[test] - fn submit_requires_all_fields() { + fn enter_starts_or_checks_the_host_owned_authorization() { let mut form = LoginFormState::new(); form.show(); - form.login_focus = LoginFocus::Login; assert!(matches!( - form.handle_key_event(key(KeyCode::Enter)), - LoginFormAction::None + form.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), + LoginFormAction::Submit(None) )); - assert!(form.error.is_some()); - } - - #[test] - fn insert_paste_strips_newlines_into_active_field() { - let mut form = LoginFormState::new(); - form.show(); - form.insert_paste("https://example.com/relay\nextra"); - assert_eq!(form.auth_server, "https://example.com/relayextra"); - } - - #[test] - fn sync_choice_enter_use_local() { - let mut form = LoginFormState::new(); - form.show_sync_choice("u1", "https://relay"); + form.set_authorization(GitHubAuthStart { + transaction_id: "txn".into(), + authorization_url: "https://github.com/login/oauth/authorize".into(), + expires_at: 99, + poll_interval_seconds: 3, + }); + assert!( + matches!(form.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), LoginFormAction::Submit(Some(id)) if id == "txn") + ); assert!(matches!( - form.handle_key_event(key(KeyCode::Enter)), - LoginFormAction::SyncUseLocal + form.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)), + LoginFormAction::Cancel )); } } diff --git a/src/apps/cli/src/ui/startup.rs b/src/apps/cli/src/ui/startup.rs index 17137b1780..391f01a607 100644 --- a/src/apps/cli/src/ui/startup.rs +++ b/src/apps/cli/src/ui/startup.rs @@ -45,7 +45,6 @@ use std::time::Duration; use crate::account::{ account_login_status_message, account_snapshot_projection, redact_login_error, - settings_sync_progress, }; use crate::agent::runtime_client::{CliAgentMode as TuiAgentMode, CliAgentRuntimeClient}; use crate::model_selection::{ @@ -379,9 +378,6 @@ impl StartupPage { let mut event_reader = crate::ui::input::EventReader::default(); loop { - if self.login_form.is_visible() { - self.refresh_account_panel_live(); - } terminal.draw(|f| self.render(f))?; if let Some(events) = event_reader.read_event_batch(Duration::from_millis(50))? { @@ -916,7 +912,6 @@ impl StartupPage { } if self.login_form.is_visible() { - self.refresh_account_panel_live(); let action = self.login_form.handle_key_event(key); return self.handle_login_form_action(action); } @@ -1314,9 +1309,6 @@ impl StartupPage { anyhow::anyhow!("Account management is unavailable for this TUI Host") })?; account.logout().await?; - account - .mark_sync_cancelled(format!("tui-account-{}", uuid::Uuid::new_v4())) - .await; Ok::<(), anyhow::Error>(()) }) }) { @@ -1412,251 +1404,31 @@ impl StartupPage { self.login_form.show(); return; }; - self.login_form - .show_account(info, snapshot.devices, snapshot.sync); - } - - fn refresh_account_panel_live(&mut self) { - if !self.login_form.is_visible() { - return; - } - let Ok(progress) = tokio::task::block_in_place(|| { - tokio::runtime::Handle::current().block_on(async { - let account = self.account_runtime.as_ref().ok_or_else(|| { - anyhow::anyhow!("Account management is unavailable for this TUI Host") - })?; - Ok::<_, anyhow::Error>(settings_sync_progress( - account.current_sync_progress().await, - )) - }) - }) else { - return; - }; - let progress = progress; - // Refresh devices occasionally while syncing / after done. - let devices = if matches!( - progress.status, - openbitfun_product_domains::account::SettingsSyncStatus::Syncing - | openbitfun_product_domains::account::SettingsSyncStatus::Done - ) { - tokio::task::block_in_place(|| { - tokio::runtime::Handle::current().block_on(async { - let account = self.account_runtime.as_ref()?; - Some(account_snapshot_projection(account.snapshot().await).devices) - }) - }) - } else { - None - }; - self.login_form.update_account_progress(devices, progress); - } - - fn start_sync_and_show_account(&mut self, is_first_login: bool) { - let result = tokio::task::block_in_place(|| { - tokio::runtime::Handle::current().block_on(async { - let account = self.account_runtime.as_ref().ok_or_else(|| { - anyhow::anyhow!("Account management is unavailable for this TUI Host") - })?; - if !account.is_logged_in().await { - anyhow::bail!("Account login must be finalized before settings sync starts") - } - if !account - .start_auto_sync_background( - format!("tui-account-{}", uuid::Uuid::new_v4()), - is_first_login, - std::path::PathBuf::from(self.agent.workspace_path_string()), - ) - .await - { - anyhow::bail!("Account settings sync is already in progress") - } - Ok::<(), anyhow::Error>(()) - }) - }); - if let Err(error) = result { - self.status = Some(format!("Account settings sync failed: {error}")); - return; - } - if let Ok(snapshot) = tokio::task::block_in_place(|| { - tokio::runtime::Handle::current().block_on(async { - let account = self.account_runtime.as_ref().ok_or_else(|| { - anyhow::anyhow!("Account management is unavailable for this TUI Host") - })?; - Ok::<_, anyhow::Error>(account_snapshot_projection(account.snapshot().await)) - }) - }) { - self.open_account_panel(snapshot); - } - self.status = Some(if is_first_login { - "Sync started (use local / upload settings).".to_string() - } else { - "Sync started (use cloud / download settings).".to_string() - }); + self.login_form.show_account(info, snapshot.devices); } fn handle_login_form_action(&mut self, action: LoginFormAction) -> Option { match action { - LoginFormAction::Submit(creds) => { - let result = tokio::task::block_in_place(|| { - tokio::runtime::Handle::current().block_on(async { - let account = self.account_runtime.as_ref().ok_or_else(|| { - anyhow::anyhow!("Account management is unavailable for this TUI Host") - })?; - let relay_url = creds.relay_url; - let username = creds.username; - let password = creds.password; - let result = account - .login_with_credentials(&relay_url, &username, &password) - .await - .map_err(|error| { - redact_login_error(error, [&relay_url, &username, &password]) - })?; - let status_message = account_login_status_message(&result); - Ok::<_, anyhow::Error>( - openbitfun_product_domains::account::AccountLoginProjection { - user_id: result.user_id, - relay_url: result.relay_url, - has_cloud_settings: result.has_cloud_settings, - status_message, - }, - ) - }) - }); - match result { - Ok(login) => { - self.status = Some(login.status_message.clone()); - if login.has_cloud_settings { - self.login_form - .show_sync_choice(&login.user_id, &login.relay_url); - } else { - self.start_sync_and_show_account(true); - } - } - Err(e) => { - self.login_form.set_error(format!("Login failed: {e}")); - } - } - } - LoginFormAction::SyncUseLocal => { + LoginFormAction::Submit(transaction_id) => { let result = tokio::task::block_in_place(|| { tokio::runtime::Handle::current().block_on(async { let account = self.account_runtime.as_ref().ok_or_else(|| { anyhow::anyhow!("Account management is unavailable for this TUI Host") })?; - account.finalize_login_after_sync_choice().await?; - if !account - .start_auto_sync_background( - format!("tui-account-{}", uuid::Uuid::new_v4()), - true, - std::path::PathBuf::from(self.agent.workspace_path_string()), - ) - .await - { - anyhow::bail!("Account settings sync is already in progress") - } - Ok::<_, anyhow::Error>(account_snapshot_projection( - account.snapshot().await, - )) + account.advance_github_login(transaction_id).await }) }); + use openbitfun_core::service::remote_connect::account_runtime::AccountLoginProgress; match result { - Ok(snapshot) => { - self.open_account_panel(snapshot); - self.status = - Some("Sync started (use local / upload settings).".to_string()); - } - Err(error) => { - self.login_form - .set_error(format!("Finalize login failed: {error}")); - let _ = tokio::task::block_in_place(|| { - tokio::runtime::Handle::current().block_on(async { - let account = self.account_runtime.as_ref().ok_or_else(|| { - anyhow::anyhow!( - "Account management is unavailable for this TUI Host" - ) - })?; - account.logout().await?; - account - .mark_sync_cancelled(format!( - "tui-account-{}", - uuid::Uuid::new_v4() - )) - .await; - Ok::<(), anyhow::Error>(()) - }) - }); - self.login_form.show(); + Ok(AccountLoginProgress::Authorization(authorization)) => self.login_form.set_authorization(authorization), + Ok(AccountLoginProgress::Waiting) => self.login_form.set_status("Waiting for GitHub authorization. Complete it in your browser, then press Enter."), + Ok(AccountLoginProgress::Complete(login)) => { + self.status = Some(account_login_status_message(&login)); + self.show_login_form(); } + Err(error) => self.login_form.set_error(format!("Login failed: {error}")), } } - LoginFormAction::SyncUseCloud => { - let result = tokio::task::block_in_place(|| { - tokio::runtime::Handle::current().block_on(async { - let account = self.account_runtime.as_ref().ok_or_else(|| { - anyhow::anyhow!("Account management is unavailable for this TUI Host") - })?; - account.finalize_login_after_sync_choice().await?; - if !account - .start_auto_sync_background( - format!("tui-account-{}", uuid::Uuid::new_v4()), - false, - std::path::PathBuf::from(self.agent.workspace_path_string()), - ) - .await - { - anyhow::bail!("Account settings sync is already in progress") - } - Ok::<_, anyhow::Error>(account_snapshot_projection( - account.snapshot().await, - )) - }) - }); - match result { - Ok(snapshot) => { - self.open_account_panel(snapshot); - self.status = - Some("Sync started (use cloud / download settings).".to_string()); - } - Err(error) => { - self.login_form - .set_error(format!("Finalize login failed: {error}")); - let _ = tokio::task::block_in_place(|| { - tokio::runtime::Handle::current().block_on(async { - let account = self.account_runtime.as_ref().ok_or_else(|| { - anyhow::anyhow!( - "Account management is unavailable for this TUI Host" - ) - })?; - account.logout().await?; - account - .mark_sync_cancelled(format!( - "tui-account-{}", - uuid::Uuid::new_v4() - )) - .await; - Ok::<(), anyhow::Error>(()) - }) - }); - self.login_form.show(); - } - } - } - LoginFormAction::SyncCancel => { - let _ = tokio::task::block_in_place(|| { - tokio::runtime::Handle::current().block_on(async { - let account = self.account_runtime.as_ref().ok_or_else(|| { - anyhow::anyhow!("Account management is unavailable for this TUI Host") - })?; - Ok::<_, anyhow::Error>(settings_sync_progress( - account - .cancel_sync(format!("tui-account-{}", uuid::Uuid::new_v4())) - .await?, - )) - }) - }); - self.login_form.show(); - self.status = Some("Sync cancelled; logged out.".to_string()); - } LoginFormAction::Logout => { match tokio::task::block_in_place(|| { tokio::runtime::Handle::current().block_on(async { @@ -1664,9 +1436,6 @@ impl StartupPage { anyhow::anyhow!("Account management is unavailable for this TUI Host") })?; account.logout().await?; - account - .mark_sync_cancelled(format!("tui-account-{}", uuid::Uuid::new_v4())) - .await; Ok::<_, anyhow::Error>(account_snapshot_projection( account.snapshot().await, )) @@ -1843,7 +1612,6 @@ impl StartupPage { let Some(account) = self.account_runtime.as_ref() else { return Ok(()); }; - account.notify_local_settings_changed(); Ok::<_, anyhow::Error>(()) }) }); @@ -1906,9 +1674,7 @@ impl StartupPage { tracing::info!("Added new AI model: {}", model_id); let _ = tokio::task::block_in_place(|| { tokio::runtime::Handle::current().block_on(async { - if let Some(account) = self.account_runtime.as_ref() { - account.notify_local_settings_changed(); - } + if let Some(account) = self.account_runtime.as_ref() {} Ok::<_, anyhow::Error>(()) }) }); @@ -1990,9 +1756,7 @@ impl StartupPage { tracing::info!("Updated AI model: {}", model_id); let _ = tokio::task::block_in_place(|| { tokio::runtime::Handle::current().block_on(async { - if let Some(account) = self.account_runtime.as_ref() { - account.notify_local_settings_changed(); - } + if let Some(account) = self.account_runtime.as_ref() {} Ok::<_, anyhow::Error>(()) }) }); diff --git a/src/apps/cli/tests/cli_command_contracts/product_assembly_cli.rs b/src/apps/cli/tests/cli_command_contracts/product_assembly_cli.rs index 073f48d0f7..ae509ff608 100644 --- a/src/apps/cli/tests/cli_command_contracts/product_assembly_cli.rs +++ b/src/apps/cli/tests/cli_command_contracts/product_assembly_cli.rs @@ -172,16 +172,18 @@ fn remaining_cli_local_persistence_stays_behind_explicit_owner_boundaries() { assert!( ACCOUNT_RUNTIME.contains("pub struct AccountRuntime") && ACCOUNT_ADAPTER.contains("impl AccountRuntimeHost for CliAccountRoutingHost") - && ACCOUNT_ADAPTER.contains("impl AccountSessionBackupPort"), + && ACCOUNT_ADAPTER.contains("AccountRuntime::new(routing.clone())") + && !ACCOUNT_ADAPTER.contains("AccountSessionBackupPort"), "account state must live in the shared owner while CLI keeps narrow Host adapters" ); assert!( STARTUP_PAGE.contains("self.account_runtime") - && STARTUP_PAGE.contains("login_with_credentials") - && STARTUP_PAGE.contains("finalize_login_after_sync_choice") - && STARTUP_PAGE.contains("start_auto_sync_background") + && STARTUP_PAGE.contains("account.advance_github_login(transaction_id).await") + && STARTUP_PAGE.contains("account.logout().await") + && !STARTUP_PAGE.contains("finalize_login_after_sync_choice") + && !STARTUP_PAGE.contains("start_auto_sync_background") && STARTUP_PAGE.contains("account_snapshot_projection"), - "startup account and settings-sync operations must call AccountRuntime directly" + "startup identity operations must call AccountRuntime directly without cloud-sync policy" ); assert!( !CORE_RUNTIME_SERVICES.contains("pub fn persistence_manager"), @@ -222,8 +224,8 @@ fn embedded_account_management_uses_the_account_owner_directly() { !CLI_MAIN.contains("surface_services") && CLI_MAIN.contains("runtime.account_runtime().clone()") && STARTUP_PAGE.contains("Option>") - && STARTUP_PAGE.contains("login_with_credentials") - && STARTUP_PAGE.contains("finalize_login_after_sync_choice") + && STARTUP_PAGE.contains("account.advance_github_login(transaction_id).await") + && STARTUP_PAGE.contains("account.snapshot().await") && APP_SERVER_MANAGEMENT.contains("pub use owner::AppManagementService") && !CLI_MAIN.contains("mod tui_host") && !CLI_MAIN.contains("mod embedded_tui_backend"), diff --git a/src/apps/desktop/AGENTS-CN.md b/src/apps/desktop/AGENTS-CN.md index 2654f56db6..2da33d07c0 100644 --- a/src/apps/desktop/AGENTS-CN.md +++ b/src/apps/desktop/AGENTS-CN.md @@ -23,10 +23,10 @@ Peer Device Mode 的所有权和边界见 `docs/architecture/peer-device-mode.md`。 前端防回归清单见 `src/web-ui/src/infrastructure/peer-device/README.md`。 -账户登录(同步选择未完成前勿落盘)见 `src/api/remote_connect_api.rs` -(`PENDING_SYNC_CHOICE` / `account_finalize_login`)。 -一键部署 Relay:`src/api/relay_deploy_api.rs`,不变量见 -`src/web-ui/src/features/relay-deploy/README.md`。 +GitHub 身份统一由 `account_identity_api.rs` 提供;Relay 设备注册和生命周期位于 +`src/api/remote_connect_api.rs`。设置留在所属设备,不再提供云端/本地同步选择。 +Relay 部署向导已移除。保留 `src/apps/relay-server` 下供开发者使用的脚本和 +[运维文档](../relay-server/README.md),不要重新加入产品 UI。 如果改动影响多个运行时共享的行为,应把稳定契约、执行策略和服务放在各自的下层 owner crate;`src/crates/assembly/core` 只保留产品装配与兼容桥接。 diff --git a/src/apps/desktop/AGENTS.md b/src/apps/desktop/AGENTS.md index 9e7e379d2d..0a6329db6d 100644 --- a/src/apps/desktop/AGENTS.md +++ b/src/apps/desktop/AGENTS.md @@ -26,14 +26,14 @@ Peer Device Mode ownership and boundaries: Frontend regression guards: `src/web-ui/src/infrastructure/peer-device/README.md`. -Account login (pending sync choice / finalize) lives in -`src/api/remote_connect_api.rs` (`PENDING_SYNC_CHOICE`, `account_login`, -`account_finalize_login`). Do not persist a session before the user chooses -cloud vs local settings. - -One-click relay deploy: Tauri surface `src/api/relay_deploy_api.rs`, orchestration -in `openbitfun-services-integrations` `remote_ssh/relay_deploy.rs`. Feature invariants: -`src/web-ui/src/features/relay-deploy/README.md`. +GitHub identity is shared through `account_identity_api.rs`. Relay device +registration and lifecycle live in `src/api/remote_connect_api.rs`; settings +remain on their owning device and there is no cloud/local sync choice. + +The Relay deployment wizard is retired. Preserve the developer scripts under +`src/apps/relay-server` and their [operator guide](../relay-server/README.md). +The retained Tauri wrapper and services orchestration are compatibility tools, +not an entry point to restore in the product UI. If a change affects behavior shared by multiple runtimes, place stable contracts, execution policy, and services in their owning lower-layer crates. Keep only diff --git a/src/apps/desktop/src/api/account_identity_api.rs b/src/apps/desktop/src/api/account_identity_api.rs new file mode 100644 index 0000000000..4bca720a45 --- /dev/null +++ b/src/apps/desktop/src/api/account_identity_api.rs @@ -0,0 +1,66 @@ +//! Controller-local adapter for the shared OpenBitFun GitHub identity. +use openbitfun_product_domains::account::{ + GitHubAuthPollRequest, GitHubAuthPollResponse, GitHubAuthStart, +}; +use openbitfun_services_integrations::account_identity::{self, AccountIdentityClient, MarketMe}; +use tauri::{AppHandle, Emitter}; + +#[tauri::command] +pub async fn account_github_start() -> Result { + account_identity::start_auth_flow() + .await + .map_err(|error| error.to_string()) +} + +#[tauri::command] +pub async fn account_github_poll( + app: AppHandle, + request: GitHubAuthPollRequest, +) -> Result { + let response = account_identity::poll_auth_flow(request) + .await + .map_err(|error| error.to_string())?; + if response.status == "authorized" { + emit_identity_changed(&app, "signed-in"); + } + Ok(response) +} + +#[tauri::command] +pub async fn account_github_info() -> Result, String> { + let mut client = AccountIdentityClient::from_environment() + .await + .map_err(|error| error.to_string())?; + client.me().await.map_err(|error| error.to_string()) +} + +pub(crate) fn emit_identity_changed(app: &AppHandle, status: &'static str) { + let _ = app.emit( + "account-identity-changed", + serde_json::json!({ "status": status }), + ); +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn desktop_auth_views_never_serialize_oauth_secrets() { + let started = serde_json::to_value(GitHubAuthStart { + transaction_id: "transaction-1".to_string(), + authorization_url: "https://github.com/login/oauth/authorize".to_string(), + expires_at: 123, + poll_interval_seconds: 3, + }) + .unwrap(); + assert!(started.get("transactionSecret").is_none()); + + let polled = serde_json::to_value(GitHubAuthPollResponse { + status: "authorized".to_string(), + }) + .unwrap(); + assert!(polled.get("tokens").is_none()); + assert!(polled.get("accessToken").is_none()); + assert!(polled.get("refreshToken").is_none()); + } +} diff --git a/src/apps/desktop/src/api/agentic_api.rs b/src/apps/desktop/src/api/agentic_api.rs index 19cd3aeaa7..9e4872c627 100644 --- a/src/apps/desktop/src/api/agentic_api.rs +++ b/src/apps/desktop/src/api/agentic_api.rs @@ -1896,7 +1896,6 @@ pub async fn create_session( let session_id = session.session_id.clone(); // Notify auto-sync: new session created - crate::api::remote_connect_api::notify_session_changed(&session_id, &wp); if let Some(target_evidence) = request.review_target_evidence { coordinator diff --git a/src/apps/desktop/src/api/config_api.rs b/src/apps/desktop/src/api/config_api.rs index 522868d373..e873791789 100644 --- a/src/apps/desktop/src/api/config_api.rs +++ b/src/apps/desktop/src/api/config_api.rs @@ -228,7 +228,7 @@ pub async fn save_cloud_speech_config( match state.config_service.save_cloud_speech_config(request).await { Ok(result) => { state.ai_client_factory.invalidate_cache(); - crate::api::remote_connect_api::notify_settings_changed(); + info!( "Cloud speech configuration saved atomically: model_id={}, created={}", result.model_id, result.created @@ -303,7 +303,6 @@ pub async fn reset_config( } // Notify auto-sync: config reset, upload to relay - crate::api::remote_connect_api::notify_settings_changed(); Ok(message) } @@ -344,7 +343,6 @@ pub async fn import_config( if result.success { state.ai_client_factory.invalidate_cache(); info!("Config imported, AI client cache invalidated"); - crate::api::remote_connect_api::notify_settings_changed(); } Ok(to_json_value(result, "import config result")?) } diff --git a/src/apps/desktop/src/api/custom_agent_api.rs b/src/apps/desktop/src/api/custom_agent_api.rs index 21b72fee28..1c7eab7ea2 100644 --- a/src/apps/desktop/src/api/custom_agent_api.rs +++ b/src/apps/desktop/src/api/custom_agent_api.rs @@ -410,7 +410,6 @@ pub async fn delete_custom_agent( agent_id, error ); } else { - crate::api::remote_connect_api::notify_settings_changed(); } if let Err(error) = openbitfun_core::service::config::reload_global_config().await { diff --git a/src/apps/desktop/src/api/miniapp_market_api.rs b/src/apps/desktop/src/api/miniapp_market_api.rs index 61ea06aab3..878f999667 100644 --- a/src/apps/desktop/src/api/miniapp_market_api.rs +++ b/src/apps/desktop/src/api/miniapp_market_api.rs @@ -17,52 +17,16 @@ use openbitfun_product_domains::miniapp::market::{ }; use openbitfun_product_domains::product_release::OPENBITFUN_INITIAL_RELEASE_VERSION; use openbitfun_services_integrations::miniapp_market::{ - submit_installed_app, validate_market_package, DesktopAuthPollRequest, DesktopAuthPollResponse, - FavoriteAggregate, MarketBrowseRequest, MarketClient, MarketMe, RatingAggregate, - ValidatedMarketPackage, + submit_installed_app, validate_market_package, FavoriteAggregate, MarketBrowseRequest, + MarketClient, RatingAggregate, ValidatedMarketPackage, }; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::path::{Path, PathBuf}; -use std::sync::OnceLock; -use std::time::{SystemTime, UNIX_EPOCH}; use tauri::{AppHandle, Emitter, Manager, State, WebviewWindow}; use tokio::io::AsyncWriteExt; -use tokio::sync::Mutex; const MARKET_UPLOAD_PROGRESS_EVENT: &str = "miniapp-market-upload-progress"; -const MARKET_ACCOUNT_CHANGED_EVENT: &str = "miniapp-market-account-changed"; - -#[derive(Debug, Clone)] -struct PendingDesktopAuth { - request: DesktopAuthPollRequest, - expires_at: i64, -} - -fn pending_desktop_auth() -> &'static Mutex> { - static PENDING: OnceLock>> = OnceLock::new(); - PENDING.get_or_init(|| Mutex::new(HashMap::new())) -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct DesktopAuthStartView { - pub transaction_id: String, - pub authorization_url: String, - pub expires_at: i64, - pub poll_interval_seconds: u32, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct DesktopAuthPollViewRequest { - pub transaction_id: String, -} - -#[derive(Debug, Clone, Serialize)] -pub struct DesktopAuthPollViewResponse { - pub status: String, -} #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] @@ -187,90 +151,6 @@ pub async fn miniapp_market_get_listing( client.listing(&request.slug).await.map_err(market_error) } -#[tauri::command] -pub async fn miniapp_market_auth_start() -> Result { - let client = MarketClient::from_environment() - .await - .map_err(market_error)?; - let started = client.start_desktop_auth().await.map_err(market_error)?; - let mut pending = pending_desktop_auth().lock().await; - let now = unix_now(); - pending.retain(|_, transaction| transaction.expires_at > now); - pending.insert( - started.transaction_id.clone(), - PendingDesktopAuth { - request: DesktopAuthPollRequest { - transaction_id: started.transaction_id.clone(), - transaction_secret: started.transaction_secret, - }, - expires_at: started.expires_at, - }, - ); - Ok(DesktopAuthStartView { - transaction_id: started.transaction_id, - authorization_url: started.authorization_url, - expires_at: started.expires_at, - poll_interval_seconds: started.poll_interval_seconds, - }) -} - -#[tauri::command] -pub async fn miniapp_market_auth_poll( - app: AppHandle, - request: DesktopAuthPollViewRequest, -) -> Result { - let pending = { - let mut transactions = pending_desktop_auth().lock().await; - let Some(pending) = transactions.get(&request.transaction_id).cloned() else { - return Err("Desktop market authorization transaction was not found.".to_string()); - }; - if pending.expires_at <= unix_now() { - transactions.remove(&request.transaction_id); - return Ok(DesktopAuthPollViewResponse { - status: "expired".to_string(), - }); - } - pending - }; - let mut client = MarketClient::from_environment() - .await - .map_err(market_error)?; - let response: DesktopAuthPollResponse = client - .poll_desktop_auth(&pending.request) - .await - .map_err(market_error)?; - if matches!(response.status.as_str(), "authorized" | "expired") { - pending_desktop_auth() - .lock() - .await - .remove(&request.transaction_id); - } - if response.status == "authorized" { - emit_market_account_changed(&app, "signed-in"); - } - Ok(DesktopAuthPollViewResponse { - status: response.status, - }) -} - -#[tauri::command] -pub async fn miniapp_market_me() -> Result, String> { - let mut client = MarketClient::from_environment() - .await - .map_err(market_error)?; - client.me().await.map_err(market_error) -} - -#[tauri::command] -pub async fn miniapp_market_logout(app: AppHandle) -> Result<(), String> { - let mut client = MarketClient::from_environment() - .await - .map_err(market_error)?; - client.logout().await.map_err(market_error)?; - emit_market_account_changed(&app, "signed-out"); - Ok(()) -} - #[tauri::command] pub async fn miniapp_market_set_rating( request: MarketSetRatingRequest, @@ -822,29 +702,13 @@ fn emit_upload_progress( ); } -fn emit_market_account_changed(app: &AppHandle, status: &'static str) { - let _ = app.emit( - MARKET_ACCOUNT_CHANGED_EVENT, - serde_json::json!({ "status": status }), - ); -} - -fn unix_now() -> i64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|duration| duration.as_secs() as i64) - .unwrap_or(0) -} - fn market_error(error: impl Serialize + std::fmt::Display) -> String { serde_json::to_string(&error).unwrap_or_else(|_| error.to_string()) } #[cfg(test)] mod tests { - use super::{ - validate_minimum_openbitfun_version, DesktopAuthPollViewResponse, DesktopAuthStartView, - }; + use super::validate_minimum_openbitfun_version; #[test] fn rejects_minimum_versions_before_initial_openbitfun_release() { @@ -857,24 +721,4 @@ mod tests { assert!(validate_minimum_openbitfun_version(&pre_release_identity).is_err()); assert!(validate_minimum_openbitfun_version("1.0.0-rc.1").is_err()); } - - #[test] - fn desktop_auth_views_never_serialize_oauth_secrets() { - let started = serde_json::to_value(DesktopAuthStartView { - transaction_id: "transaction-1".to_string(), - authorization_url: "https://github.com/login/oauth/authorize".to_string(), - expires_at: 123, - poll_interval_seconds: 3, - }) - .unwrap(); - assert!(started.get("transactionSecret").is_none()); - - let polled = serde_json::to_value(DesktopAuthPollViewResponse { - status: "authorized".to_string(), - }) - .unwrap(); - assert!(polled.get("tokens").is_none()); - assert!(polled.get("accessToken").is_none()); - assert!(polled.get("refreshToken").is_none()); - } } diff --git a/src/apps/desktop/src/api/mod.rs b/src/apps/desktop/src/api/mod.rs index 38bb045244..a8aeb0272a 100644 --- a/src/apps/desktop/src/api/mod.rs +++ b/src/apps/desktop/src/api/mod.rs @@ -1,5 +1,6 @@ //! API layer module +pub mod account_identity_api; pub mod acp_client_api; pub mod agentic_api; pub mod announcement_api; @@ -39,7 +40,6 @@ pub mod miniapp_market_api; pub mod pages_api; pub mod path_target; pub mod peer_host_invoke; -pub mod relay_deploy_api; pub mod remote_connect_api; pub mod remote_workspace_policy; pub mod review_platform_api; diff --git a/src/apps/desktop/src/api/relay_deploy_api.rs b/src/apps/desktop/src/api/relay_deploy_api.rs deleted file mode 100644 index d16cf0577b..0000000000 --- a/src/apps/desktop/src/api/relay_deploy_api.rs +++ /dev/null @@ -1,190 +0,0 @@ -//! Relay server self-deploy Tauri commands. -//! -//! Lets a user deploy the open-source OpenBitFun relay server to their own host -//! over an existing SSH connection (preflight → Docker install → source -//! download + compose deploy → account import). The account is provisioned -//! locally: the plaintext password never leaves this machine — only Argon2id -//! derived artifacts are transferred and handed to `relay-admin import-user`. -//! -//! Orchestration: `openbitfun_services_integrations::remote_ssh::relay_deploy`. -//! Product invariants / wizard entry points: -//! `src/web-ui/src/features/relay-deploy/README.md`. - -use openbitfun_core::service::remote_ssh::relay_deploy::{ - self, RelayDeployTask, RelayMirrorMode, RelayPreflight, RelayTaskPoll, RelayTaskStart, -}; -use serde::Serialize; -use tauri::State; - -use super::app_state::AppState; - -#[tauri::command] -pub async fn relay_deploy_preflight( - state: State<'_, AppState>, - connection_id: String, - port: Option, -) -> Result { - let manager = state - .get_ssh_manager_async() - .await - .map_err(|e| e.to_string())?; - relay_deploy::run_preflight(&manager, &connection_id, port.unwrap_or(0)) - .await - .map_err(|e| e.to_string()) -} - -/// Stage the interactive Docker-install driver (run it in a remote PTY; poll via -/// `relay_deploy_poll` with task `install_docker`). -#[tauri::command] -pub async fn relay_deploy_install_docker( - state: State<'_, AppState>, - connection_id: String, - mirror_mode: Option, -) -> Result { - let manager = state - .get_ssh_manager_async() - .await - .map_err(|e| e.to_string())?; - relay_deploy::start_task( - &manager, - &connection_id, - RelayDeployTask::InstallDocker, - 0, - mirror_mode.unwrap_or_default(), - ) - .await - .map_err(|e| e.to_string()) -} - -/// Stage the interactive deploy driver (run it in a remote PTY; poll via -/// `relay_deploy_poll` with task `deploy`). -#[tauri::command] -pub async fn relay_deploy_start( - state: State<'_, AppState>, - connection_id: String, - port: Option, - mirror_mode: Option, -) -> Result { - let manager = state - .get_ssh_manager_async() - .await - .map_err(|e| e.to_string())?; - relay_deploy::start_task( - &manager, - &connection_id, - RelayDeployTask::Deploy, - port.unwrap_or(0), - mirror_mode.unwrap_or_default(), - ) - .await - .map_err(|e| e.to_string()) -} - -#[tauri::command] -pub async fn relay_deploy_poll( - state: State<'_, AppState>, - connection_id: String, - task: RelayDeployTask, - cursor: u64, -) -> Result { - let manager = state - .get_ssh_manager_async() - .await - .map_err(|e| e.to_string())?; - relay_deploy::poll_task(&manager, &connection_id, task, cursor) - .await - .map_err(|e| e.to_string()) -} - -/// Cancel a running install/deploy task (wizard closed or user navigated away). -#[tauri::command] -pub async fn relay_deploy_cancel( - state: State<'_, AppState>, - connection_id: String, - task: RelayDeployTask, -) -> Result<(), String> { - let manager = state - .get_ssh_manager_async() - .await - .map_err(|e| e.to_string())?; - relay_deploy::cancel_task(&manager, &connection_id, task) - .await - .map_err(|e| e.to_string()) -} - -/// Provision a relay account locally and import it into the deployed relay. -/// -/// The plaintext password is consumed only by the local Argon2id/AES-GCM -/// provisioning step; it is never transmitted to the server. -#[tauri::command] -pub async fn relay_deploy_register( - state: State<'_, AppState>, - connection_id: String, - username: String, - password: String, -) -> Result<(), String> { - let username = username.trim().to_string(); - if username.is_empty() || username.chars().any(char::is_whitespace) { - return Err("invalid username".to_string()); - } - if password.len() < 8 { - return Err("password must be at least 8 characters".to_string()); - } - let account = openbitfun_relay_service::admin::provision(&username, &password) - .map_err(|e| format!("provision account: {e}"))?; - let import = openbitfun_relay_service::admin::ImportableAccount { username, account }; - let json = serde_json::to_string(&import).map_err(|e| format!("serialize account: {e}"))?; - let manager = state - .get_ssh_manager_async() - .await - .map_err(|e| e.to_string())?; - relay_deploy::import_account(&manager, &connection_id, &json) - .await - .map_err(|e| e.to_string()) -} - -/// Client-side reachability check for a relay URL (catches firewalls / -/// security-group rules that block the relay port from the public internet). -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct RelayVerifyResult { - pub reachable: bool, - pub version: Option, -} - -#[tauri::command] -pub async fn relay_deploy_verify(relay_url: String) -> Result { - let base = relay_url.trim().trim_end_matches('/').to_string(); - if base.is_empty() { - return Err("empty relay url".to_string()); - } - crate::ensure_rustls_crypto_provider(); - let client = reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(8)) - .build() - .map_err(|e| e.to_string())?; - let health_ok = client - .get(format!("{base}/health")) - .send() - .await - .map(|r| r.status().is_success()) - .unwrap_or(false); - if !health_ok { - return Ok(RelayVerifyResult { - reachable: false, - version: None, - }); - } - let version = match client.get(format!("{base}/api/info")).send().await { - Ok(r) => r - .json::() - .await - .ok() - .and_then(|v| v.get("version").and_then(|x| x.as_str()).map(String::from)), - Err(_) => None, - }; - Ok(RelayVerifyResult { - reachable: true, - version, - }) -} diff --git a/src/apps/desktop/src/api/remote_connect_api.rs b/src/apps/desktop/src/api/remote_connect_api.rs index dbd61f0d4a..bcf286dd90 100644 --- a/src/apps/desktop/src/api/remote_connect_api.rs +++ b/src/apps/desktop/src/api/remote_connect_api.rs @@ -1,13 +1,10 @@ //! Tauri commands for Remote Connect. -use crate::api::session_storage_path::desktop_effective_session_storage_path; use crate::embedded_relay_host::DesktopEmbeddedRelayHost; use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; -use futures::stream::{self, StreamExt}; use openbitfun_core::agentic::coordination::{ get_global_coordinator, get_global_scheduler, ConversationCoordinator, }; -use openbitfun_core::agentic::persistence::PersistenceManager; use openbitfun_core::agentic::tools::account_login_capability::set_account_login_available; use openbitfun_core::agentic::tools::page_deploy_host::set_page_deploy_handler; use openbitfun_core::agentic::tools::page_publish_host::set_page_publish_handler; @@ -21,19 +18,14 @@ use openbitfun_core::service::remote_connect::session_store::{ }; use openbitfun_core::service::remote_connect::{ bot::{self, weixin, BotConfig}, - lan, session_store, sync_state, AccountClient, AccountPairingVerification, AccountSession, - ConnectionMethod, ConnectionResult, DelegatedIdentityAuthorization, DeviceIdentity, - PairingState, ProvisionedDeviceAuthorization, RemoteConnectConfig, RemoteConnectService, + lan, session_store, AccountClient, AccountSession, ConnectionMethod, ConnectionResult, + DeviceIdentity, RemoteConnectConfig, RemoteConnectService, }; -use openbitfun_core::service::session::{DialogTurnData, SessionMetadata}; use openbitfun_core::service::workspace::{get_global_workspace_service, WorkspaceKind}; use openbitfun_core::service::workspace_runtime::WorkspaceRuntimeService; use openbitfun_events::AI_MODEL_CATALOG_UPDATED_EVENT; use openbitfun_services_integrations::remote_connect::account::{ - ensure_relay_session_history_exportable, error_indicates_expired_token, - mark_relay_session_history_import_complete, mark_relay_session_history_import_pending, - relay_session_export_metadata, relay_session_history_import_is_complete, - relay_session_history_import_state, validate_relay_base_url, + error_indicates_expired_token, validate_relay_base_url, }; use openbitfun_services_integrations::remote_connect::{ deploy_page_version_on_relay, join_relay_url, list_pages_from_relay, @@ -42,12 +34,11 @@ use openbitfun_services_integrations::remote_connect::{ use regex::Regex; use serde::{Deserialize, Serialize}; use std::collections::HashMap; -use std::future::Future; use std::path::PathBuf; use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; use std::sync::{Arc, OnceLock}; use tauri::{AppHandle, Emitter, State}; -use tokio::sync::{Notify, RwLock}; +use tokio::sync::RwLock; static REMOTE_CONNECT_SERVICE: OnceLock>>> = OnceLock::new(); @@ -63,23 +54,16 @@ struct AccountContextState { static ACCOUNT_CONTEXT: OnceLock>>> = OnceLock::new(); -/// Serializes explicit login-time syncs and lets logout/new login invalidate -/// the active operation before account state is changed. -static ACCOUNT_AUTO_SYNC_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); +/// Serializes credential-bearing operations against login/logout transitions. +static ACCOUNT_OPERATION_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); /// Serializes credential verification attempts without hiding or disconnecting /// the currently active account. A successful candidate acquires the account /// transition guard only after all login-time network requests complete. static ACCOUNT_LOGIN_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); static ACCOUNT_CONTEXT_TRANSITION_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); -/// Serializes QR-room starts with account identity boundaries. -/// -/// An unpaired QR advertises the authentication mode that existed when it was -/// created, so login/logout must retire that stale invitation. An established -/// room is an independent control channel and survives the account boundary; -/// its account-derived authority is cleared separately during the transition. -static ACCOUNT_ROOM_BOUNDARY_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); -static ACCOUNT_AUTO_SYNC_CANCEL: OnceLock = OnceLock::new(); -static ACTIVE_ACCOUNT_AUTO_SYNC_OPERATION_ID: AtomicU64 = AtomicU64::new(0); +/// Serializes connection entry point changes and explicit disconnection. +static RELAY_START_STOP_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); +static ACCOUNT_TRANSITION_BOUNDARY_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); static ACCOUNT_CONTEXT_GENERATION: AtomicU64 = AtomicU64::new(1); static ACCOUNT_CONTEXT_TRANSITIONS: AtomicUsize = AtomicUsize::new(0); @@ -136,9 +120,6 @@ impl AccountContextTransitionPermit { fn begin() -> Self { ACCOUNT_CONTEXT_TRANSITIONS.fetch_add(1, Ordering::AcqRel); ACCOUNT_CONTEXT_GENERATION.fetch_add(1, Ordering::AcqRel); - ACTIVE_ACCOUNT_AUTO_SYNC_OPERATION_ID.store(0, Ordering::Release); - clear_last_finalized_pending_login(); - account_auto_sync_cancel().notify_waiters(); Self } } @@ -153,7 +134,7 @@ impl Drop for AccountContextTransitionPermit { } struct AccountContextTransitionGuard { - sync_guard: Option>, + operation_guard: Option>, transition: Option, transition_guard: Option>, } @@ -163,95 +144,45 @@ impl AccountContextTransitionGuard { /// mutex. Login-state listeners can now probe `account_status`, while a /// competing logout or replacement remains blocked until publication ends. fn make_context_observable(&mut self) { - drop(self.sync_guard.take()); + drop(self.operation_guard.take()); drop(self.transition.take()); } } -struct PendingLoginFinalizeGuard { - sync_guard: Option>, - transition_guard: Option>, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -struct FinalizedPendingLoginOwner { - pending_login_id: String, - account_generation: u64, - account_token: String, -} - -impl Drop for PendingLoginFinalizeGuard { - fn drop(&mut self) { - drop(self.sync_guard.take()); - drop(self.transition_guard.take()); - } -} - impl Drop for AccountContextTransitionGuard { fn drop(&mut self) { // Release the operation lock before reopening context discovery. A // queued operation that wins this handoff still fails on the gate. - drop(self.sync_guard.take()); + drop(self.operation_guard.take()); drop(self.transition.take()); drop(self.transition_guard.take()); } } -async fn lock_account_sync( +async fn lock_account_operation( generation: u64, ) -> Result, String> { - let guard = ACCOUNT_AUTO_SYNC_LOCK.lock().await; + let guard = ACCOUNT_OPERATION_LOCK.lock().await; if !account_context_is_current(generation) { - return Err("account sync cancelled".to_string()); + return Err("account context changed".to_string()); } Ok(guard) } -fn ensure_account_auto_sync_current(operation_id: u64) -> Result<(), String> { - if operation_id != 0 - && ACTIVE_ACCOUNT_AUTO_SYNC_OPERATION_ID.load(Ordering::Acquire) == operation_id - { - Ok(()) - } else { - Err("account sync cancelled".to_string()) - } -} - -fn account_auto_sync_cancel() -> &'static Notify { - ACCOUNT_AUTO_SYNC_CANCEL.get_or_init(Notify::new) -} - -async fn await_account_auto_sync(operation_id: u64, future: F) -> Result -where - F: Future, -{ - let mut cancelled = Box::pin(account_auto_sync_cancel().notified()); - cancelled.as_mut().enable(); - ensure_account_auto_sync_current(operation_id)?; - tokio::select! { - _ = &mut cancelled => Err("account sync cancelled".to_string()), - result = future => { - ensure_account_auto_sync_current(operation_id)?; - Ok(result) - } - } -} - -async fn cancel_and_wait_for_account_auto_sync() -> AccountContextTransitionGuard { +async fn begin_account_transition() -> AccountContextTransitionGuard { // Serialize transition creation so a stale invalidation can re-check its // generation before it makes the current account undiscoverable. let transition_guard = ACCOUNT_CONTEXT_TRANSITION_LOCK.lock().await; let transition = AccountContextTransitionPermit::begin(); - let sync_guard = ACCOUNT_AUTO_SYNC_LOCK.lock().await; - openbitfun_core::service::remote_connect::settings_sync::wait_for_sync_operations_idle().await; + let operation_guard = ACCOUNT_OPERATION_LOCK.lock().await; AccountContextTransitionGuard { - sync_guard: Some(sync_guard), + operation_guard: Some(operation_guard), transition: Some(transition), transition_guard: Some(transition_guard), } } -async fn cancel_and_wait_if_account_current( +async fn begin_account_transition_if_current( expected_generation: u64, ) -> Option { let transition_guard = ACCOUNT_CONTEXT_TRANSITION_LOCK.lock().await; @@ -259,91 +190,14 @@ async fn cancel_and_wait_if_account_current( return None; } let transition = AccountContextTransitionPermit::begin(); - let sync_guard = ACCOUNT_AUTO_SYNC_LOCK.lock().await; - openbitfun_core::service::remote_connect::settings_sync::wait_for_sync_operations_idle().await; + let operation_guard = ACCOUNT_OPERATION_LOCK.lock().await; Some(AccountContextTransitionGuard { - sync_guard: Some(sync_guard), + operation_guard: Some(operation_guard), transition: Some(transition), transition_guard: Some(transition_guard), }) } -fn pending_login_is_owned_by(expected_pending_login_id: &str) -> bool { - if expected_pending_login_id.is_empty() - || !PENDING_SYNC_CHOICE.load(std::sync::atomic::Ordering::Acquire) - { - return false; - } - PENDING_LOGIN_ID - .get_or_init(|| std::sync::Mutex::new(None)) - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .as_deref() - == Some(expected_pending_login_id) -} - -fn set_pending_login_id(pending_login_id: Option) { - let mut current = PENDING_LOGIN_ID - .get_or_init(|| std::sync::Mutex::new(None)) - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - *current = pending_login_id; - PENDING_SYNC_CHOICE.store(current.is_some(), std::sync::atomic::Ordering::Release); -} - -fn background_account_sync_is_allowed() -> bool { - !PENDING_SYNC_CHOICE.load(std::sync::atomic::Ordering::Acquire) -} - -fn clear_last_finalized_pending_login() { - *LAST_FINALIZED_PENDING_LOGIN - .get_or_init(|| std::sync::Mutex::new(None)) - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) = None; -} - -fn record_finalized_pending_login(owner: FinalizedPendingLoginOwner) { - *LAST_FINALIZED_PENDING_LOGIN - .get_or_init(|| std::sync::Mutex::new(None)) - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(owner); -} - -async fn finalized_pending_login_is_current(pending_login_id: &str) -> bool { - let owner = LAST_FINALIZED_PENDING_LOGIN - .get_or_init(|| std::sync::Mutex::new(None)) - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .clone(); - let Some(owner) = owner else { - return false; - }; - owner.pending_login_id == pending_login_id - && account_context_matches(owner.account_generation, &owner.account_token).await -} - -async fn lock_pending_login_for_finalize( - expected_pending_login_id: &str, -) -> Result { - let transition_guard = ACCOUNT_CONTEXT_TRANSITION_LOCK.lock().await; - let generation = account_context_generation(); - if !account_context_is_current(generation) - || !pending_login_is_owned_by(expected_pending_login_id) - { - return Err("pending login changed".to_string()); - } - let sync_guard = ACCOUNT_AUTO_SYNC_LOCK.lock().await; - if !account_context_is_current(generation) - || !pending_login_is_owned_by(expected_pending_login_id) - { - return Err("pending login changed".to_string()); - } - Ok(PendingLoginFinalizeGuard { - sync_guard: Some(sync_guard), - transition_guard: Some(transition_guard), - }) -} - /// Global handle to the DialogScheduler, set during app startup. Used by the /// device-routing background task to execute commands received from peer /// devices (ExecuteOnDevice). @@ -428,13 +282,6 @@ fn emit_device_presence(devices: &[(String, String)]) { emit_account_event("account://device-presence", payload); } -fn emit_settings_applied() { - emit_account_event( - "account://settings-applied", - serde_json::json!({ "applied": true }), - ); -} - async fn disconnect_peer_controllers(reason: &'static str) { let request_ids = crate::api::peer_host_invoke::disconnect_controllers(); if let Err(error) = @@ -466,32 +313,6 @@ async fn finish_device_routing_event_loop(owner: &DeviceRoutingOwner) { } /// Emit granular auto-sync progress for the account login / devices UI. -fn emit_sync_progress( - operation_id: u64, - phase: &str, - percent: u8, - current: Option, - total: Option, - detail: Option<&str>, -) { - emit_account_event( - "account://sync-progress", - serde_json::json!({ - "operation_id": operation_id, - "phase": phase, - "percent": percent.min(100), - "current": current, - "total": total, - "detail": detail, - }), - ); -} - -/// Push a UI event to all attached peer controllers. -/// -/// Events are queued and sent **sequentially** so high-frequency streams -/// (especially `agentic://text-chunk`) keep emission order. Concurrent -/// `tokio::spawn` per chunk previously scrambled peer remote chat text. pub fn fanout_peer_device_event(event: String, payload: serde_json::Value) { if crate::api::peer_host_invoke::attached_controllers().is_empty() { return; @@ -534,7 +355,7 @@ async fn fanout_peer_device_event_once(item: PeerEventFanoutItem) { if targets.is_empty() { return; } - let (session, _) = + let (session, relay_url) = match read_account_context_for_generation(item.routing_owner.account_generation).await { Ok(ctx) => ctx, Err(e) => { @@ -547,7 +368,6 @@ async fn fanout_peer_device_event_once(item: PeerEventFanoutItem) { { return; } - use openbitfun_core::service::remote_connect::encryption::encrypt_to_base64; use openbitfun_core::service::remote_connect::remote_server::RemoteCommand; let envelope = match serde_json::to_string(&RemoteCommand::DeviceEvent { event: item.event.clone(), @@ -559,14 +379,17 @@ async fn fanout_peer_device_event_once(item: PeerEventFanoutItem) { return; } }; - let (encrypted_data, nonce) = match encrypt_to_base64(&session.master_key, &envelope) { - Ok(v) => v, - Err(e) => { - log::warn!("peer event fanout encrypt failed: {e}"); - return; - } - }; for target in targets { + let (encrypted_data, nonce) = match session + .encrypt_for_peer(&relay_url, &target, &envelope) + .await + { + Ok(value) => value, + Err(error) => { + log::warn!("peer event fanout encrypt failed: {error}"); + continue; + } + }; let correlation_id = uuid::Uuid::new_v4().to_string(); if let Err(e) = send_device_message_with_routing_lease( &item.routing_owner, @@ -687,6 +510,7 @@ async fn send_device_message_with_routing_lease( async fn send_rpc_envelope( owner: &DeviceRoutingOwner, session: &AccountSession, + source_device_id: &str, correlation_id: &str, resp_value: serde_json::Value, ) -> bool { @@ -704,12 +528,18 @@ async fn send_rpc_envelope( .to_string() } }; - use openbitfun_core::service::remote_connect::encryption::encrypt_to_base64; - match encrypt_to_base64(&session.master_key, &resp_json) { + let Ok((_, relay_url)) = read_account_context_for_generation(owner.account_generation).await + else { + return false; + }; + match session + .encrypt_for_peer(&relay_url, source_device_id, &resp_json) + .await + { Ok((enc_resp, resp_nonce)) => { match send_device_message_with_routing_lease( owner, - "rpc", + source_device_id, correlation_id, &enc_resp, &resp_nonce, @@ -733,12 +563,14 @@ async fn send_rpc_envelope( async fn send_rpc_error( owner: &DeviceRoutingOwner, session: &AccountSession, + source_device_id: &str, correlation_id: &str, message: impl Into, ) { send_rpc_envelope( owner, session, + source_device_id, correlation_id, serde_json::json!({ "resp": "error", @@ -775,13 +607,12 @@ async fn invalidate_local_account_session_if_current( log::info!("Ignored auth failure from a stale account generation"); return false; } - let _room_boundary_guard = ACCOUNT_ROOM_BOUNDARY_LOCK.lock().await; + let _room_boundary_guard = ACCOUNT_TRANSITION_BOUNDARY_LOCK.lock().await; if !account_context_matches(expected_generation, expected_token).await { log::info!("Ignored auth failure from a stale account generation"); return false; } - retire_unpaired_room_for_account_boundary("account session expiry").await; - let Some(_transition_guard) = cancel_and_wait_if_account_current(expected_generation).await + let Some(_transition_guard) = begin_account_transition_if_current(expected_generation).await else { log::info!("Ignored auth failure from a stale account generation"); return false; @@ -799,12 +630,9 @@ async fn invalidate_local_account_session_if_current( TOKEN_EXPIRED.store(true, std::sync::atomic::Ordering::Relaxed); stop_and_clear_device_routing("Account session expired").await; if let Some(service) = get_service_holder().read().await.as_ref() { - service.clear_account_pairing_context().await; - service.clear_trusted_mobile_identity().await; service.clear_bot_delegated_identities().await; } *get_account_context().write().await = None; - set_pending_login_id(None); session_store::clear_session(); emit_account_event( "account://login-state", @@ -948,17 +776,13 @@ impl DispatchAccountDeviceProvisioning { } } -/// Mint a distinct full device credential for an SSH host. A finalized local -/// login is optional: callers receive `None` and skip account/daemon setup when -/// this Desktop is logged out or still awaiting the cloud/local sync choice. +/// Mint a distinct full device credential for an SSH host. Callers receive +/// `None` and skip account/daemon setup when this Desktop is logged out. pub(crate) async fn provision_dispatch_account_device( identity: &DispatchAccountDaemonIdentity, ) -> Result, String> { - if PENDING_SYNC_CHOICE.load(Ordering::Acquire) { - return Ok(None); - } let generation = account_context_generation(); - let Ok(_account_guard) = lock_account_sync(generation).await else { + let Ok(_account_guard) = lock_account_operation(generation).await else { return Ok(None); }; let (session, relay_url) = match read_account_context_for_generation(generation).await { @@ -966,6 +790,13 @@ pub(crate) async fn provision_dispatch_account_device( Err(error) if error == "not logged in" => return Ok(None), Err(error) => return Err(error), }; + let request_id = uuid::Uuid::new_v4(); + let target_secret = + openbitfun_services_integrations::remote_connect::device_crypto::provisioning_secret( + &session.master_key, + &identity.device_id, + &request_id.to_string(), + ); let issued = AccountClient::new() .provision_device_token( &relay_url, @@ -973,21 +804,19 @@ pub(crate) async fn provision_dispatch_account_device( &identity.device_id, &identity.device_name, "desktop", - uuid::Uuid::new_v4(), + request_id, + &target_secret, ) .await .map_err(|error| format!("provision remote account device: {error}"))?; - let target_session = AccountSession { - token: issued.token.clone(), - user_id: issued.user_id.clone(), - master_key: session.master_key, - }; + let target_session = + AccountSession::new(issued.token.clone(), issued.user_id.clone(), target_secret); let provisioning = DispatchAccountDeviceProvisioning { request: DispatchAccountDaemonProvisionRequest { schema_version: DISPATCH_ACCOUNT_DAEMON_PROVISIONING_SCHEMA_VERSION, token: issued.token, user_id: issued.user_id, - master_key_base64: BASE64.encode(session.master_key), + master_key_base64: BASE64.encode(target_secret), relay_url: relay_url.clone(), device_id: issued.device_id, }, @@ -1309,10 +1138,6 @@ fn normalize_relay_url(relay_url: &str) -> Result { Ok(parsed.as_str().trim_end_matches('/').to_string()) } -fn cloud_settings_exist_from_probe(result: Result, E>) -> Result { - result.map(|settings| settings.is_some()) -} - async fn revoke_login_candidate( client: &AccountClient, relay_url: &str, @@ -1378,119 +1203,6 @@ pub fn set_mobile_web_resource_path(path: PathBuf) { /// IM bots (global provider). Called after session is restored (startup) or /// after fresh login. async fn register_delegated_identity_providers() { - // Room-channel provider for mobile-web. - let account_context = get_account_context().clone(); - if let Some(service) = get_service_holder().read().await.as_ref() { - service - .set_delegated_identity_provider(move || { - let account_context = account_context.clone(); - Box::pin(async move { - let generation = account_context_generation(); - if !account_context_is_current(generation) { - return None; - } - // Core calls this provider while holding the room lifecycle - // lease. Acquire the account lease second and return it with - // the credentials so account replacement cannot begin until - // Core has encrypted and sent the response. - let account_lease = lock_account_sync(generation).await.ok()?; - let context = account_context.read().await.clone()?; - if !account_context_matches(generation, &context.session.token).await { - return None; - } - match AccountClient::new() - .delegate_token(&context.relay_url, &context.session) - .await - { - Ok(delegated) => { - if delegated.user_id != context.session.user_id { - log::warn!( - "Delegated identity user did not match the desktop account" - ); - return None; - } - if !account_context_matches(generation, &context.session.token).await { - return None; - } - Some(DelegatedIdentityAuthorization::with_host_lease( - delegated.token, - delegated.user_id, - context.session.master_key, - account_lease, - )) - } - Err(e) => { - log::warn!("Delegate token failed: {e}"); - None - } - } - }) - }) - .await; - - // Room-channel provider that adds a keyboard-less device (a watch) to - // this account. Same lease discipline as delegation above; the errors - // are returned rather than swallowed because a provisioning failure is - // shown to someone standing there waiting for it. - let account_context = get_account_context().clone(); - service - .set_peer_device_provisioner(move |device_id, device_name, request_id| { - let account_context = account_context.clone(); - Box::pin(async move { - // Minted by the device being provisioned so a retry anywhere - // along the chain replays one idempotent relay request. - let request_id = uuid::Uuid::parse_str(&request_id) - .map_err(|_| "Request id must be a UUID".to_string())?; - let generation = account_context_generation(); - if !account_context_is_current(generation) { - return Err("Desktop account changed; try again".to_string()); - } - let account_lease = lock_account_sync(generation) - .await - .map_err(|_| "Desktop account changed; try again".to_string())?; - let context = account_context.read().await.clone().ok_or_else(|| { - "Desktop is not logged into a OpenBitFun account".to_string() - })?; - if !account_context_matches(generation, &context.session.token).await { - return Err("Desktop account changed; try again".to_string()); - } - let provisioned = AccountClient::new() - .provision_device_token( - &context.relay_url, - &context.session, - &device_id, - &device_name, - "watch", - request_id, - ) - .await - .map_err(|e| { - log::warn!("Provision device token failed: {e}"); - format!("Could not add the device to your account: {e}") - })?; - if !account_context_matches(generation, &context.session.token).await { - return Err("Desktop account changed; try again".to_string()); - } - Ok(ProvisionedDeviceAuthorization::with_host_lease( - provisioned.token, - provisioned.user_id, - context.session.master_key, - provisioned.device_id, - account_lease, - )) - }) - }) - .await; - - // Account-mode mobile pairing: QR prefill + password verification. - register_account_pairing_context(service).await; - - // Login/restore may switch accounts; drop any prior URL-bound mobile - // identity so the next pair can bind to the current account user id. - service.clear_trusted_mobile_identity().await; - service.clear_bot_delegated_identities().await; - } - // Global provider for IM bots. let account_context = get_account_context().clone(); openbitfun_core::service::remote_connect::bot::set_delegated_identity_provider(move || { @@ -1511,7 +1223,7 @@ async fn register_delegated_identity_providers() { Ok(delegated) if account_context_is_current(generation) => Some(( context.relay_url, delegated.token, - context.session.master_key.to_vec(), + delegated.device_secret.to_vec(), )), Ok(_) => None, Err(e) => { @@ -1523,92 +1235,13 @@ async fn register_delegated_identity_providers() { }); } -/// Wire QR account prefill + verify-only password check for mobile pairing. -async fn register_account_pairing_context(service: &RemoteConnectService) { - // Always enable account mode when logged in; username prefill is best-effort. - let username = load_credential_hint() - .map(|hint| hint.username) - .unwrap_or_default(); - service.set_account_pairing_username(Some(username)).await; - - let account_context = get_account_context().clone(); - let pairing_attempts = Arc::new(tokio::sync::Mutex::new((0_u32, None::))); - service - .set_account_pairing_verifier(move |username, password| { - let account_context = account_context.clone(); - let pairing_attempts = pairing_attempts.clone(); - async move { - let generation = account_context_generation(); - if !account_context_is_current(generation) { - return Err("Desktop account is changing; scan again".to_string()); - } - { - let mut attempts = pairing_attempts.lock().await; - if let Some(locked_until) = attempts.1 { - if locked_until > std::time::Instant::now() { - return Err( - "Too many pairing attempts. Wait one minute and scan again." - .to_string(), - ); - } - *attempts = (0, None); - } - } - let context = - account_context.read().await.clone().ok_or_else(|| { - "Desktop is not logged into a OpenBitFun account".to_string() - })?; - if !account_context_is_current(generation) { - return Err("Desktop account is changing; scan again".to_string()); - } - let account_lease = lock_account_sync(generation) - .await - .map_err(|_| "Desktop account is changing; scan again".to_string())?; - if !account_context_matches(generation, &context.session.token).await { - return Err("Desktop account is changing; scan again".to_string()); - } - let verification = AccountClient::new() - .verify_password_for_master_key( - &context.relay_url, - &username, - &password, - &context.session.master_key, - ) - .await; - if !account_context_matches(generation, &context.session.token).await { - return Err("Desktop account changed; scan again".to_string()); - } - if let Err(error) = verification { - // Keep the real cause in desktop logs (network vs bad - // credentials); the mobile only gets the unified message. - log::warn!("Account pairing verification failed: {error}"); - let mut attempts = pairing_attempts.lock().await; - attempts.0 = attempts.0.saturating_add(1); - if attempts.0 >= 5 { - attempts.1 = - Some(std::time::Instant::now() + std::time::Duration::from_secs(60)); - return Err("Too many pairing attempts. Wait one minute and scan again." - .to_string()); - } - return Err("Invalid username or password".to_string()); - } - *pairing_attempts.lock().await = (0, None); - Ok(AccountPairingVerification::with_host_lease( - context.session.user_id, - account_lease, - )) - } - }) - .await; -} - pub fn init_on_startup() { register_page_deploy_host(); register_page_publish_host(); tokio::spawn(async { let startup_generation = account_context_generation(); // Restore persisted account session (if any) before anything else - // so that auto-sync, device routing, and bot delegation work on restart. + // so that device routing and bot delegation work on restart. match session_store::load_session_detailed() { Ok(Some(loaded)) => { let user_id = loaded.user_id.clone(); @@ -1627,7 +1260,7 @@ pub fn init_on_startup() { } }; let Some(restore_guard) = - cancel_and_wait_if_account_current(startup_generation).await + begin_account_transition_if_current(startup_generation).await else { log::info!( "Skipped persisted session restore after a newer account transition" @@ -1642,11 +1275,7 @@ pub fn init_on_startup() { log::warn!("Failed to adopt restored session device_id: {e}"); } } - let session = AccountSession { - token: loaded.token, - user_id: user_id.clone(), - master_key: loaded.master_key, - }; + let session = AccountSession::new(loaded.token, user_id.clone(), loaded.master_key); *get_account_context().write().await = Some(AccountContextState { session, relay_url: relay_url.clone(), @@ -1654,7 +1283,6 @@ pub fn init_on_startup() { sync_account_login_capability(true); // Keep the mirrored "Self-Hosted" server field in sync for // sessions restored from an older version without the mirror. - set_self_hosted_form_url(Some(&relay_url)); log::info!("Restored account session for user {user_id}"); drop(restore_guard); @@ -1699,7 +1327,6 @@ pub fn init_on_startup() { /// Synchronous cleanup called when the application exits. pub fn cleanup_on_exit() { - openbitfun_core::service::remote_connect::ngrok::cleanup_all_ngrok(); log::info!("Remote connect cleanup completed on exit"); } @@ -1733,6 +1360,13 @@ fn new_remote_connect_service(config: RemoteConnectConfig) -> anyhow::Result Option { return Some(dir); } - log::warn!("mobile-web dist directory not found; LAN/Ngrok modes will not serve static files"); + log::warn!("mobile-web dist directory not found; LAN mode will not serve static files"); None } @@ -1858,34 +1493,16 @@ fn is_valid_mobile_web_dir(dir: &std::path::Path) -> bool { #[derive(Debug, Deserialize)] pub struct StartRemoteConnectRequest { pub method: String, - pub custom_server_url: Option, pub lan_ip: Option, } #[derive(Debug, Serialize, Deserialize)] pub struct RemoteConnectStatusResponse { - pub is_connected: bool, - pub pairing_state: PairingState, - pub active_method: Option, - pub peer_device_name: Option, - pub peer_user_id: Option, - /// A browser/phone has reached this host through its authenticated account route. - /// This is independent of the temporary QR-room invitation and pairing state. - #[serde(default)] - pub account_control_connected: bool, - /// Source of the live account control channel, separate from `active_method`. - #[serde(default)] - pub account_control_relay_url: Option, - /// Live browser sessions; absent on hosts without client-level presence. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub account_control_clients: - Option>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub account_control_has_unidentified_clients: Option, - /// Independent bot connection info — e.g. "Telegram(7096812005)". - /// Present when a bot is active, regardless of relay pairing state. + pub relay_connected: bool, + pub relay_url: Option, + pub active_method: Option, + pub clients: Vec, pub bot_connected: Option, - /// Bot verbose mode setting — when true, intermediate progress is sent to users. pub bot_verbose_mode: bool, } @@ -2138,24 +1755,12 @@ pub async fn remote_connect_get_methods() -> Result, S available: true, description: "Same local network".into(), }, - ConnectionMethod::Ngrok => ConnectionMethodInfo { - id: "ngrok".into(), - name: "ngrok".into(), - available: true, - description: "Internet via ngrok tunnel".into(), - }, ConnectionMethod::OpenBitFunServer => ConnectionMethodInfo { id: "openbitfun_server".into(), name: "OpenBitFun Server".into(), available: true, description: "Official OpenBitFun relay".into(), }, - ConnectionMethod::CustomServer { url } => ConnectionMethodInfo { - id: "custom_server".into(), - name: "Custom Server".into(), - available: true, - description: format!("Self-hosted: {url}"), - }, ConnectionMethod::BotFeishu => ConnectionMethodInfo { id: "bot_feishu".into(), name: "Feishu Bot".into(), @@ -2182,18 +1787,13 @@ pub async fn remote_connect_get_methods() -> Result, S fn parse_connection_method( method: &str, - custom_url: Option, lan_ip: Option, ) -> Result { match method { "lan" => Ok(ConnectionMethod::Lan { ip: lan_ip.filter(|s| !s.is_empty()), }), - "ngrok" => Ok(ConnectionMethod::Ngrok), "openbitfun_server" => Ok(ConnectionMethod::OpenBitFunServer), - "custom_server" => Ok(ConnectionMethod::CustomServer { - url: custom_url.unwrap_or_default(), - }), "bot_feishu" => Ok(ConnectionMethod::BotFeishu), "bot_telegram" => Ok(ConnectionMethod::BotTelegram), "bot_weixin" => Ok(ConnectionMethod::BotWeixin), @@ -2205,29 +1805,65 @@ fn parse_connection_method( pub async fn remote_connect_start( request: StartRemoteConnectRequest, ) -> Result { - let _room_boundary_guard = ACCOUNT_ROOM_BOUNDARY_LOCK.lock().await; ensure_service().await?; - let method = - parse_connection_method(&request.method, request.custom_server_url, request.lan_ip)?; - - let holder = get_service_holder(); - let guard = holder.read().await; - let service = guard.as_ref().ok_or("service not initialized")?; - // Refresh account pairing context so a newly logged-in session is reflected - // in the QR (`auth=account&user=...`) before the room is created. - if read_account_context().await.is_ok() { - register_account_pairing_context(service).await; - } else { - service.clear_account_pairing_context().await; + let method = parse_connection_method(&request.method, request.lan_ip)?; + let _start_stop = RELAY_START_STOP_LOCK.lock().await; + if matches!( + method, + ConnectionMethod::BotFeishu | ConnectionMethod::BotTelegram | ConnectionMethod::BotWeixin + ) { + // IM transports also require the signed-in account before pairing. + if read_account_context().await.is_err() { + account_login(AccountAuthRequest {}).await?; + } + let generation = account_context_generation(); + let _account_guard = lock_account_operation(generation).await?; + let (session, _) = read_account_context_for_generation(generation).await?; + let holder = get_service_holder().read().await; + let service = holder.as_ref().ok_or("service not initialized")?; + service.set_bot_account(Some(session.user_id)).await; + return service + .start(method) + .await + .map_err(|e| format!("start remote connect: {e}")); + } + let relay_url = { + let holder = get_service_holder().read().await; + holder + .as_ref() + .ok_or("service not initialized")? + .prepare_relay(&method) + .await + .map_err(|e| e.to_string())? + }; + let result = async { + let current_url = read_account_context().await.ok().map(|(_, url)| url); + if current_url.as_deref() != Some(relay_url.as_str()) { + login_account_on_relay(relay_url).await?; + } + account_connect_devices().await?; + let holder = get_service_holder().read().await; + holder + .as_ref() + .ok_or("service not initialized")? + .start(method) + .await + .map_err(|e| format!("start remote connect: {e}")) } - service - .start(method) - .await - .map_err(|e| format!("start remote connect: {e}")) + .await; + if result.is_err() { + stop_and_clear_device_routing("Relay connection failed").await; + if let Some(service) = get_service_holder().read().await.as_ref() { + service.stop_relay().await; + } + } + result } #[tauri::command] pub async fn remote_connect_stop() -> Result<(), String> { + let _start_stop = RELAY_START_STOP_LOCK.lock().await; + stop_and_clear_device_routing("Relay stopped").await; let holder = get_service_holder(); let guard = holder.read().await; if let Some(service) = guard.as_ref() { @@ -2255,30 +1891,19 @@ pub async fn remote_connect_status() -> Result Result<(), String> { - let holder = get_service_holder(); - let mut guard = holder.write().await; - if guard.is_none() { - let config = RemoteConnectConfig { - custom_server_url: Some(url), - ..RemoteConnectConfig::default() - }; - let service = new_remote_connect_service(config).map_err(|e| format!("init: {e}"))?; - *guard = Some(service); - } - Ok(()) -} - #[derive(Debug, Deserialize)] pub struct ConfigureBotRequest { pub bot_type: String, @@ -2429,13 +2039,6 @@ pub async fn remote_connect_set_bot_verbose_mode(verbose: bool) -> Result<(), St #[derive(Serialize, Deserialize, Clone)] pub struct AccountLoginResult { pub user_id: String, - /// Opaque owner for the pending cloud/local decision. This is never an - /// account bearer token and is present only when a choice is required. - pub pending_login_id: Option, - /// Whether the relay already has a cloud settings blob for this account. - /// `true` = non-first login → the frontend should prompt the user before - /// overwriting local settings. `false` = first login → auto-upload local. - pub has_cloud_settings: bool, } /// Current account login status (no secrets exposed). @@ -2445,33 +2048,15 @@ pub struct AccountStatus { pub user_id: Option, } -/// Request payload for register/login (matches the frontend `request` wrapper). -#[derive(Deserialize)] -pub struct AccountAuthRequest { - pub relay_url: String, - pub username: String, - pub password: String, -} - +/// Login uses the shared GitHub credential; no per-Relay credentials or URL. #[derive(Deserialize)] -pub struct PendingAccountLoginRequest { - pub pending_login_id: String, -} +#[serde(deny_unknown_fields)] +pub struct AccountAuthRequest {} fn current_device_identity() -> Result { DeviceIdentity::from_current_machine().map_err(|e| format!("detect device: {e}")) } -/// True while credentials succeeded but the user has not yet chosen -/// cloud-vs-local settings. Session is held in memory only; a process kill -/// must not restore a logged-in state. -static PENDING_SYNC_CHOICE: std::sync::atomic::AtomicBool = - std::sync::atomic::AtomicBool::new(false); -static PENDING_LOGIN_ID: OnceLock>> = OnceLock::new(); -static LAST_FINALIZED_PENDING_LOGIN: OnceLock< - std::sync::Mutex>, -> = OnceLock::new(); - /// Persist the in-memory account session so restart restores login. async fn persist_account_session(device_id: Option<&str>) -> Result<(), String> { // Login installs the context while its transition permit is still held; @@ -2487,105 +2072,12 @@ async fn persist_account_session(device_id: Option<&str>) -> Result<(), String> .map_err(|e| format!("persist session: {e}")) } -/// Finish a login that was waiting on the cloud/local settings choice: -/// persist session, register providers, and emit the logged-in event. -/// -/// Pair with `PENDING_SYNC_CHOICE` / `account_login`: never persist or emit -/// logged-in before this runs when `has_cloud_settings` was true. Closing the -/// overwrite UI must conditionally cancel its opaque pending owner instead of -/// leaving a memory-only session. -#[tauri::command] -pub async fn account_finalize_login(request: PendingAccountLoginRequest) -> Result<(), String> { - if finalized_pending_login_is_current(&request.pending_login_id).await { - return Ok(()); - } - let _pending_guard = match lock_pending_login_for_finalize(&request.pending_login_id).await { - Ok(guard) => guard, - Err(error) => { - // A concurrent/retried call may arrive after the first invocation - // committed but before its transport response reached the UI. - if finalized_pending_login_is_current(&request.pending_login_id).await { - return Ok(()); - } - return Err(error); - } - }; - let account_generation = account_context_generation(); - let (session, _) = read_account_context_for_generation(account_generation).await?; - let finalized_owner = FinalizedPendingLoginOwner { - pending_login_id: request.pending_login_id, - account_generation, - account_token: session.token, - }; - let device = current_device_identity()?; - persist_account_session(Some(device.device_id.as_str())).await?; - set_pending_login_id(None); - TOKEN_EXPIRED.store(false, std::sync::atomic::Ordering::Relaxed); - sync_account_login_capability(true); - - register_delegated_identity_providers().await; - - let relay_url = read_account_context() - .await - .ok() - .map(|(_, relay_url)| relay_url); - emit_account_event( - "account://login-state", - serde_json::json!({ - "logged_in": true, - "relay_url": relay_url, - }), - ); - record_finalized_pending_login(finalized_owner); - log::info!("Account login finalized (sync choice accepted)"); - Ok(()) -} - -/// Abandon only the pending login identified by `pending_login_id`. A stale -/// component cleanup is a no-op and, importantly, does not begin an account -/// transition or increment the context generation. #[tauri::command] -pub async fn account_cancel_pending_login( - request: PendingAccountLoginRequest, -) -> Result { - let generation = account_context_generation(); - if !account_context_is_current(generation) - || !pending_login_is_owned_by(&request.pending_login_id) - { - return Ok(false); - } - let _room_boundary_guard = ACCOUNT_ROOM_BOUNDARY_LOCK.lock().await; - if !account_context_is_current(generation) - || !pending_login_is_owned_by(&request.pending_login_id) - { - return Ok(false); - } - retire_unpaired_room_for_account_boundary("pending account login cancellation").await; - let transition_guard = ACCOUNT_CONTEXT_TRANSITION_LOCK.lock().await; - if !account_context_is_current(generation) - || !pending_login_is_owned_by(&request.pending_login_id) - { - return Ok(false); - } - - let transition = AccountContextTransitionPermit::begin(); - let sync_guard = ACCOUNT_AUTO_SYNC_LOCK.lock().await; - openbitfun_core::service::remote_connect::settings_sync::wait_for_sync_operations_idle().await; - let _transition_guard = AccountContextTransitionGuard { - sync_guard: Some(sync_guard), - transition: Some(transition), - transition_guard: Some(transition_guard), - }; - if !pending_login_is_owned_by(&request.pending_login_id) { - return Ok(false); - } - clear_account_login_state(true).await; - log::info!("Pending account login cancelled"); - Ok(true) +pub async fn account_login(_request: AccountAuthRequest) -> Result { + login_account_on_relay(openbitfun_product_domains::account::DEFAULT_RELAY_URL.to_string()).await } -#[tauri::command] -pub async fn account_login(request: AccountAuthRequest) -> Result { +async fn login_account_on_relay(relay_url: String) -> Result { // Keep the old account fully usable while credentials are verified. Only a // successful candidate is allowed to begin the protected replacement // transition and retire the old account's runtime state. @@ -2594,40 +2086,25 @@ pub async fn account_login(request: AccountAuthRequest) -> Result has_cloud_settings, - Err(error) => { - let message = error.to_string(); - revoke_login_candidate(&client, &relay_url, &session, "settings probe failure") - .await; - return Err(message); - } - }; - - let _room_boundary_guard = ACCOUNT_ROOM_BOUNDARY_LOCK.lock().await; + let _room_boundary_guard = ACCOUNT_TRANSITION_BOUNDARY_LOCK.lock().await; if !account_context_is_current(expected_generation) { revoke_login_candidate(&client, &relay_url, &session, "account replacement race").await; return Err("account context changed".to_string()); } - retire_unpaired_room_for_account_boundary("account login or replacement").await; - let Some(mut transition_guard) = cancel_and_wait_if_account_current(expected_generation).await + let Some(mut transition_guard) = begin_account_transition_if_current(expected_generation).await else { revoke_login_candidate(&client, &relay_url, &session, "account replacement race").await; return Err("account context changed".to_string()); }; // The old account is now hidden, so account-backed tools must be hidden as - // well. A committed no-cloud login re-enables them after publication. + // well. A committed login re-enables them after publication. sync_account_login_capability(false); let replaced_account = select_replaced_account_for_revocation( get_account_context().read().await.clone(), @@ -2640,44 +2117,29 @@ pub async fn account_login(request: AccountAuthRequest) -> Result Result Result { - let pending = PENDING_SYNC_CHOICE.load(std::sync::atomic::Ordering::Relaxed); let context = read_account_context().await.ok(); - let logged_in = context.is_some() && !pending; + let logged_in = context.is_some(); Ok(AccountStatus { logged_in, user_id: if logged_in { @@ -2731,12 +2177,9 @@ pub async fn account_status() -> Result { /// `revoke_relay_token` is false after deleting this device because the relay /// deletion already revoked the current token along with the device row. async fn clear_account_login(revoke_relay_token: bool) { - // Invalidate the active operation first, then wait for it to observe the - // cancellation and release its guard. This ensures no settings apply or - // progress event can happen after logout completes. - let _room_boundary_guard = ACCOUNT_ROOM_BOUNDARY_LOCK.lock().await; - retire_unpaired_room_for_account_boundary("account logout").await; - let _sync_guard = cancel_and_wait_for_account_auto_sync().await; + // Retire account-bound operations before clearing credentials. + let _room_boundary_guard = ACCOUNT_TRANSITION_BOUNDARY_LOCK.lock().await; + let _operation_guard = begin_account_transition().await; clear_account_login_state(revoke_relay_token).await; } @@ -2748,12 +2191,11 @@ async fn clear_account_login_if_current( if !account_context_matches(expected_generation, expected_token).await { return false; } - let _room_boundary_guard = ACCOUNT_ROOM_BOUNDARY_LOCK.lock().await; + let _room_boundary_guard = ACCOUNT_TRANSITION_BOUNDARY_LOCK.lock().await; if !account_context_matches(expected_generation, expected_token).await { return false; } - retire_unpaired_room_for_account_boundary("account session clear").await; - let Some(_transition_guard) = cancel_and_wait_if_account_current(expected_generation).await + let Some(_transition_guard) = begin_account_transition_if_current(expected_generation).await else { return false; }; @@ -2776,8 +2218,6 @@ async fn clear_account_login_state(revoke_relay_token: bool) { // Disconnect device routing before clearing the session. stop_and_clear_device_routing("Account logged out").await; if let Some(service) = get_service_holder().read().await.as_ref() { - service.clear_account_pairing_context().await; - service.clear_trusted_mobile_identity().await; service.clear_bot_delegated_identities().await; } if revoke_relay_token { @@ -2789,11 +2229,9 @@ async fn clear_account_login_state(revoke_relay_token: bool) { } } *get_account_context().write().await = None; - set_pending_login_id(None); clear_credential_hint(); session_store::clear_session(); // Clear the mirrored "Self-Hosted" server field on logout. - set_self_hosted_form_url(None); TOKEN_EXPIRED.store(false, std::sync::atomic::Ordering::Relaxed); emit_account_event( "account://login-state", @@ -2801,56 +2239,17 @@ async fn clear_account_login_state(revoke_relay_token: bool) { ); } -fn pairing_room_requires_rotation(state: &PairingState) -> bool { - !matches!( - state, - PairingState::Idle | PairingState::Connected | PairingState::Disconnected - ) -} - -/// Retire only an invitation whose encoded account mode is now stale. -/// -/// Callers hold `ACCOUNT_ROOM_BOUNDARY_LOCK` and invoke this before acquiring -/// account sync/transition guards. That lock order lets an in-flight pairing -/// finish or be retired and avoids a room-lifecycle -> account-sync cycle. -/// A connected room retains its transport but loses account-derived authority -/// through the normal context cleanup below this boundary. -async fn retire_unpaired_room_for_account_boundary(reason: &str) { - let holder = get_service_holder(); - let guard = holder.read().await; - let Some(service) = guard.as_ref() else { - return; - }; - if service.active_method().await.is_none() { - return; - } - let pairing_state = service.pairing_state().await; - if pairing_room_requires_rotation(&pairing_state) { - log::info!("Retiring unpaired QR room at {reason}"); - service.stop_relay().await; - } else if pairing_state == PairingState::Connected { - log::info!("Preserving connected QR room across {reason}"); - } -} - #[tauri::command] -pub async fn account_logout() -> Result<(), String> { +pub async fn account_logout(app: tauri::AppHandle) -> Result<(), String> { + let mut identity = openbitfun_services_integrations::account_identity::AccountIdentityClient::from_environment() + .await.map_err(|error| error.to_string())?; + identity.logout().await.map_err(|error| error.to_string())?; clear_account_login(true).await; + super::account_identity_api::emit_identity_changed(&app, "signed-out"); log::info!("Account logged out"); Ok(()) } -/// Persist (or clear) the account relay URL in the Remote Connect -/// "Self-Hosted" form field so the pairing UI follows account login state. -fn set_self_hosted_form_url(url: Option<&str>) { - let value = url.unwrap_or_default(); - bot::update_bot_persistence(|data| { - if data.form_state.custom_server_url != value { - data.form_state.custom_server_url = value.to_string(); - } - }); -} - // ── P2: Device routing commands ────────────────────────────────────────── #[derive(Clone, Debug, Serialize, PartialEq, Eq)] @@ -2894,7 +2293,7 @@ async fn account_connect_devices_with_retry() -> Result, S #[tauri::command] pub async fn account_connect_devices() -> Result, String> { let account_generation = account_context_generation(); - let sync_guard = lock_account_sync(account_generation).await?; + let operation_guard = lock_account_operation(account_generation).await?; let (session, relay_url) = read_account_context_for_generation(account_generation).await?; let identity = current_device_identity()?; let device_name = identity.device_name.clone(); @@ -2946,7 +2345,7 @@ pub async fn account_connect_devices() -> Result, String> // Token invalidation re-enters the account transition path, so the // current account-operation lease must be released first. drop(routing_lifecycle); - drop(sync_guard); + drop(operation_guard); drop(holder); if error_indicates_expired_token(&msg) { invalidate_local_account_session_if_current( @@ -2981,6 +2380,7 @@ pub async fn account_connect_devices() -> Result, String> // Background task: consume events (presence / device messages / auth errors) // Note: AuthOk is consumed inside start_device_connection (adopt happens there). + let event_relay_url = relay_url.clone(); let event_session = session.clone(); let event_owner = routing_owner.clone(); tokio::spawn(async move { @@ -3010,6 +2410,7 @@ pub async fn account_connect_devices() -> Result, String> break; } RelayEvent::DevicePresence { devices } => { + event_session.clear_peer_keys().await; let Some(_routing_effect) = lock_current_device_routing(&event_owner).await else { break 'routing_events; @@ -3048,12 +2449,6 @@ pub async fn account_connect_devices() -> Result, String> .map(|d| (d.device_id.clone(), d.device_name.clone())) .collect(); emit_device_presence(&pairs); - // Another device came online — pull cloud settings if needed. - if devices.len() > 1 { - tokio::spawn(async move { - pull_and_reconcile(account_generation).await; - }); - } } RelayEvent::DeviceMessageReceived { source_device_id, @@ -3061,8 +2456,15 @@ pub async fn account_connect_devices() -> Result, String> encrypted_data, nonce, } => { - use openbitfun_core::service::remote_connect::encryption::decrypt_from_base64; - match decrypt_from_base64(&event_session.master_key, &encrypted_data, &nonce) { + match event_session + .decrypt_from_peer( + &event_relay_url, + &source_device_id, + &encrypted_data, + &nonce, + ) + .await + { Ok(plaintext) => { use openbitfun_core::service::remote_connect::remote_server::RemoteCommand; match serde_json::from_str::(&plaintext) { @@ -3150,34 +2552,7 @@ pub async fn account_connect_devices() -> Result, String> break 'routing_events; } } - Ok(RemoteCommand::SendSessionToDevice { - session_data, - session_id, - session_name: _, - }) => { - log::info!( - "SendSessionToDevice from {source_device_id}: \ - session={session_id} bytes={}", - session_data.len() - ); - let import_result = - import_session_bundle(&session_data, account_generation) - .await; - if !device_routing_owner_is_current(&event_owner).await { - break 'routing_events; - } - match import_result { - Ok(()) => { - log::info!("Session {session_id} imported from device {source_device_id}"); - } - Err(e) => { - log::warn!( - "Failed to import session {session_id}: {e}" - ); - } - } - } - Ok(cmd) if source_device_id == "rpc" => { + Ok(cmd) => { // The lease is taken here, on the loop, so a // retiring loop still notices it has been // replaced and stops reading events at once. @@ -3229,6 +2604,7 @@ pub async fn account_connect_devices() -> Result, String> let sent = send_rpc_envelope( &rpc_owner, &rpc_session, + &source_device_id, &correlation_id, resp_value, ) @@ -3254,6 +2630,7 @@ pub async fn account_connect_devices() -> Result, String> send_rpc_error( &rpc_owner, &rpc_session, + &source_device_id, &correlation_id, format!("RPC execute failed: {e}"), ) @@ -3262,13 +2639,9 @@ pub async fn account_connect_devices() -> Result, String> } }); } - Ok(cmd) => { - let _ = cmd; - log::info!("Received device command"); - } Err(e) => { log::warn!("Could not parse device command: {e}"); - if source_device_id == "rpc" { + if !correlation_id.is_empty() { let Some(_routing_effect) = lock_current_device_routing(&event_owner).await else { @@ -3277,6 +2650,7 @@ pub async fn account_connect_devices() -> Result, String> send_rpc_error( &event_owner, &event_session, + &source_device_id, &correlation_id, format!("invalid RPC command: {e}"), ) @@ -3290,7 +2664,7 @@ pub async fn account_connect_devices() -> Result, String> } Err(e) => { log::warn!("Failed to decrypt device message: {e}"); - if source_device_id == "rpc" { + if !correlation_id.is_empty() { let Some(_routing_effect) = lock_current_device_routing(&event_owner).await else { @@ -3299,6 +2673,7 @@ pub async fn account_connect_devices() -> Result, String> send_rpc_error( &event_owner, &event_session, + &source_device_id, &correlation_id, format!("failed to decrypt RPC request: {e}"), ) @@ -3370,496 +2745,8 @@ pub async fn account_online_devices() -> Result, String> { /// Send an encrypted session to a peer device. The `session_json` is encrypted /// with the master key before being sent over the relay. -#[tauri::command] -pub async fn account_send_session_to_device( - target_device_id: String, - session_id: String, - session_json: String, -) -> Result<(), String> { - let account_generation = account_context_generation(); - let (session, _) = read_account_context_for_generation(account_generation).await?; - - // Wrap the raw session JSON in a SendSessionToDevice command envelope so the - // receiving device knows what to do with the payload. - use openbitfun_core::service::remote_connect::remote_server::RemoteCommand; - let envelope = serde_json::to_string(&RemoteCommand::SendSessionToDevice { - session_data: session_json, - session_id: session_id.clone(), - session_name: None, - }) - .map_err(|e| format!("serialize envelope: {e}"))?; - - let _routing_effect = DEVICE_ROUTING_LIFECYCLE_LOCK.read().await; - let routing_owner = device_routing_owner_for_account(account_generation, &session.token) - .ok_or_else(|| "device routing not connected for current account".to_string())?; - if !device_routing_owner_is_current(&routing_owner).await { - return Err("device routing changed".to_string()); - } - use openbitfun_core::service::remote_connect::encryption::encrypt_to_base64; - let (encrypted_data, nonce) = - encrypt_to_base64(&session.master_key, &envelope).map_err(|e| format!("{e}"))?; - - let correlation_id = uuid::Uuid::new_v4().to_string(); - send_device_message_with_routing_lease( - &routing_owner, - &target_device_id, - &correlation_id, - &encrypted_data, - &nonce, - ) - .await -} - // ── P4: Session / settings sync commands ───────────────────────────────── -/// Upload a single session blob (encrypted client-side with the master key). -#[tauri::command] -pub async fn account_sync_session(session_id: String, session_json: String) -> Result<(), String> { - let (session, relay_url) = read_account_context().await?; - AccountClient::new() - .upload_session(&relay_url, &session, &session_id, &session_json) - .await - .map(|_| ()) - .map_err(|e| format!("{e}")) -} - -/// Fetch all synced session blobs (decrypted client-side). -#[derive(Serialize)] -pub struct SyncedSession { - pub session_id: String, - pub session_json: String, -} - -#[tauri::command] -pub async fn account_fetch_synced_sessions() -> Result, String> { - let (session, relay_url) = read_account_context().await?; - let sessions = AccountClient::new() - .fetch_sessions(&relay_url, &session, 0) - .await - .map_err(|e| format!("{e}"))?; - Ok(sessions - .into_iter() - .map(|s| SyncedSession { - session_id: s.session_id, - session_json: s.plaintext, - }) - .collect()) -} - -/// Delete a synced session blob from the relay. -#[tauri::command] -pub async fn account_delete_synced_session(session_id: String) -> Result<(), String> { - let generation = account_context_generation(); - let _sync_guard = lock_account_sync(generation).await?; - let (session, relay_url) = read_account_context().await?; - AccountClient::new() - .delete_session(&relay_url, &session, &session_id) - .await - .map_err(|e| format!("{e}"))?; - let mut state = sync_state::load(&session.user_id); - state.clear_uploaded_hash(&session_id); - let _ = sync_state::save(&session.user_id, &state); - Ok(()) -} - -/// Upload settings blob (encrypted client-side with the master key). -#[tauri::command] -pub async fn account_sync_settings(settings_json: String) -> Result<(), String> { - let generation = account_context_generation(); - let _sync_guard = lock_account_sync(generation).await?; - let (session, relay_url) = read_account_context().await?; - openbitfun_core::service::remote_connect::settings_sync::upload_settings_payload( - &session, - &relay_url, - &settings_json, - ) - .await - .map_err(|e| format!("{e}"))?; - Ok(()) -} - -/// Fetch and decrypt the settings blob. Returns null if none exists. -#[tauri::command] -pub async fn account_fetch_settings() -> Result, String> { - let (session, relay_url) = read_account_context().await?; - AccountClient::new() - .fetch_settings(&relay_url, &session) - .await - .map_err(|e| format!("{e}")) -} - -// ── High-level session sync (export / import / auto-sync) ───────────────── - -/// Max concurrent session blob POSTs during multi-session upload. -const UPLOAD_CONCURRENCY: usize = 5; - -/// A serializable session bundle: metadata + all dialog turns. -/// This is the unit of cross-device sync — encrypted with the master key -/// before upload to the relay. -#[derive(Serialize, Deserialize)] -pub struct SessionBundle { - pub session_id: String, - pub metadata: serde_json::Value, - pub turns: Vec, - pub source_device_id: Option, - pub source_device_name: Option, -} - -async fn load_account_visible_session_turns( - storage_path: &std::path::Path, - session_id: &str, -) -> Result, String> { - let coordinator = get_global_coordinator() - .ok_or_else(|| "Core coordinator is not initialized for session sync".to_string())?; - coordinator - .load_visible_persisted_session_turns(storage_path, session_id) - .await - .map_err(|error| format!("load visible session history: {error}")) -} - -/// Export a single local session as an encrypted blob and upload it to the relay. -/// Uses the workspace + session_id to load metadata and turns from disk. -#[tauri::command] -pub async fn account_export_local_session( - session_id: String, - workspace_path: String, - app_state: State<'_, crate::api::app_state::AppState>, - path_manager: State<'_, Arc>, -) -> Result<(), String> { - let generation = account_context_generation(); - let _sync_guard = lock_account_sync(generation).await?; - let (acct_session, relay_url) = read_account_context().await?; - - let storage_path = - desktop_effective_session_storage_path(&app_state, &workspace_path, None, None).await; - - let manager = PersistenceManager::new(path_manager.inner().clone()) - .map_err(|e| format!("create persistence manager: {e}"))?; - - // Load metadata - let metadata = manager - .load_session_metadata(&storage_path, &session_id) - .await - .map_err(|e| format!("load metadata: {e}"))? - .ok_or_else(|| format!("session not found: {session_id}"))?; - ensure_relay_session_history_exportable(&metadata)?; - - // Load all turns - let turns = load_account_visible_session_turns(&storage_path, &session_id).await?; - let metadata = relay_session_export_metadata(&metadata, turns.len()); - - // Serialize to bundle - let metadata_json = - serde_json::to_value(&metadata).map_err(|e| format!("serialize metadata: {e}"))?; - let turns_json: Vec = turns - .iter() - .map(|t| serde_json::to_value(t).unwrap_or(serde_json::Value::Null)) - .collect(); - - let device = current_device_identity()?; - let bundle = SessionBundle { - session_id: session_id.clone(), - metadata: metadata_json, - turns: turns_json, - source_device_id: Some(device.device_id.clone()), - source_device_name: Some(device.device_name.clone()), - }; - - let bundle_json = - serde_json::to_string(&bundle).map_err(|e| format!("serialize bundle: {e}"))?; - - let hash = sync_state::content_hash(&bundle_json); - AccountClient::new() - .upload_session(&relay_url, &acct_session, &session_id, &bundle_json) - .await - .map_err(|e| format!("{e}"))?; - let mut state = sync_state::load(&acct_session.user_id); - state.set_uploaded_hash(&session_id, hash); - let _ = sync_state::save(&acct_session.user_id, &state); - Ok(()) -} - -/// Export all local sessions for a workspace and upload them to the relay. -/// Returns the number of sessions synced. -#[tauri::command] -pub async fn account_export_all_sessions( - workspace_path: String, - app_state: State<'_, crate::api::app_state::AppState>, - path_manager: State<'_, Arc>, -) -> Result { - let generation = account_context_generation(); - let _sync_guard = lock_account_sync(generation).await?; - let (acct_session, relay_url) = read_account_context().await?; - - let storage_path = - desktop_effective_session_storage_path(&app_state, &workspace_path, None, None).await; - - let manager = PersistenceManager::new(path_manager.inner().clone()) - .map_err(|e| format!("create persistence manager: {e}"))?; - - let sessions = manager - .list_session_metadata(&storage_path) - .await - .map_err(|e| format!("list sessions: {e}"))?; - - let mut state = sync_state::load(&acct_session.user_id); - let mut pending: Vec<(String, String, String)> = Vec::new(); - for meta in &sessions { - if let Err(error) = ensure_relay_session_history_exportable(meta) { - log::debug!("Skipping account session export: {error}"); - continue; - } - let turns = load_account_visible_session_turns(&storage_path, &meta.session_id) - .await - .map_err(|e| format!("load turns for {}: {e}", meta.session_id))?; - let metadata = relay_session_export_metadata(meta, turns.len()); - - let metadata_json = - serde_json::to_value(metadata).map_err(|e| format!("serialize metadata: {e}"))?; - let turns_json: Vec = turns - .iter() - .map(|t| serde_json::to_value(t).unwrap_or(serde_json::Value::Null)) - .collect(); - - let bundle = SessionBundle { - session_id: meta.session_id.clone(), - metadata: metadata_json, - turns: turns_json, - source_device_id: None, - source_device_name: None, - }; - - let bundle_json = - serde_json::to_string(&bundle).map_err(|e| format!("serialize bundle: {e}"))?; - let hash = sync_state::content_hash(&bundle_json); - if state.uploaded_hash(&meta.session_id) == Some(hash.as_str()) { - continue; - } - pending.push((meta.session_id.clone(), bundle_json, hash)); - } - - let uploaded: Vec<(String, String)> = stream::iter(pending) - .map(|(session_id, bundle_json, hash)| { - let client = AccountClient::new(); - let relay_url = relay_url.clone(); - let acct_session = acct_session.clone(); - async move { - match client - .upload_session(&relay_url, &acct_session, &session_id, &bundle_json) - .await - { - Ok(_version) => Some((session_id, hash)), - Err(e) => { - log::warn!("Export session {session_id} failed: {e}"); - None - } - } - } - }) - .buffer_unordered(UPLOAD_CONCURRENCY) - .filter_map(|r| async move { r }) - .collect() - .await; - - let count = uploaded.len(); - for (session_id, hash) in uploaded { - state.set_uploaded_hash(&session_id, hash); - } - let _ = sync_state::save(&acct_session.user_id, &state); - log::info!("Exported {count} sessions to relay"); - Ok(count) -} - -/// Import all synced sessions from the relay into local storage. -/// Sessions that already exist locally are skipped (no overwrite). -/// Returns the number of newly imported sessions. -#[tauri::command] -pub async fn account_import_remote_sessions( - workspace_path: String, - coordinator: State<'_, Arc>, - app_state: State<'_, crate::api::app_state::AppState>, - path_manager: State<'_, Arc>, -) -> Result, String> { - let generation = account_context_generation(); - let _sync_guard = lock_account_sync(generation).await?; - let (acct_session, relay_url) = read_account_context().await?; - - coordinator - .ensure_workspace_runtime_ownership(std::path::Path::new(&workspace_path), None, None) - .map_err(|error| error.to_string())?; - - let storage_path = - desktop_effective_session_storage_path(&app_state, &workspace_path, None, None).await; - - let manager = PersistenceManager::new(path_manager.inner().clone()) - .map_err(|e| format!("create persistence manager: {e}"))?; - - let remote_sessions = AccountClient::new() - .fetch_sessions(&relay_url, &acct_session, 0) - .await - .map_err(|e| format!("{e}"))?; - - let mut imported = Vec::new(); - for fetched in remote_sessions { - let session_id = fetched.session_id; - let bundle_json = fetched.plaintext; - // Deserialize the bundle and write metadata as-is. The source device's - // workspace_path is preserved for display (read-only history). Tasks - // are always executed on the receiving device's own workspace, so - // cross-platform path differences don't affect execution. - let bundle: SessionBundle = - serde_json::from_str(&bundle_json).map_err(|e| format!("deserialize bundle: {e}"))?; - - let mut metadata: SessionMetadata = serde_json::from_value(bundle.metadata) - .map_err(|e| format!("deserialize metadata: {e}"))?; - if metadata.session_id != session_id { - log::warn!( - "Skipping remote session bundle with mismatched metadata identity: expected_session_id={}, metadata_session_id={}", - session_id, - metadata.session_id - ); - continue; - } - // Only write metadata — turns are lazy-loaded when the user opens - // the session (see `account_fetch_session_turns`). - mark_relay_session_history_import_pending(&mut metadata); - if !manager - .create_session_metadata_if_absent(&storage_path, &metadata) - .await - .map_err(|error| { - format!("persist imported metadata for session {session_id}: {error}") - })? - { - continue; - } - - imported.push(session_id); - } - - log::info!("Imported {} remote sessions", imported.len()); - Ok(imported) -} - -/// Lazy-load a session's turns from the relay on first open. -/// -/// When the periodic pull imports a remote session, it writes only metadata -/// (no turns) to keep the pull lightweight. When the user clicks into that -/// session, the frontend calls this command to fetch the full session bundle -/// from the relay and persist the turns locally. Subsequent opens read from -/// local disk without hitting the relay. -/// -/// Returns `true` if turns were fetched and written, `false` if the session -/// already had local turns (no relay fetch needed). -#[tauri::command] -pub async fn account_fetch_session_turns( - session_id: String, - workspace_path: String, - coordinator: State<'_, Arc>, - app_state: State<'_, crate::api::app_state::AppState>, - path_manager: State<'_, Arc>, -) -> Result { - let generation = account_context_generation(); - // Soft-skip before any disk IO so accidental callers cannot fail-closed - // Peer hydrate on metadata load errors. History comes from the peer host. - if crate::api::peer_host_invoke::is_peer_controller_active() { - log::info!("Skipping cloud session turn fetch in Peer Device Mode (session={session_id})"); - return Ok(false); - } - - coordinator - .ensure_workspace_runtime_ownership(std::path::Path::new(&workspace_path), None, None) - .map_err(|error| error.to_string())?; - - let storage_path = - desktop_effective_session_storage_path(&app_state, &workspace_path, None, None).await; - let manager = PersistenceManager::new(path_manager.inner().clone()) - .map_err(|e| format!("create persistence manager: {e}"))?; - - // Ordinary local sessions carry no relay marker and return without an - // account or network lookup. Only the durable complete marker proves that - // the imported turn batch finished; a partial prefix remains pending. - let Some(metadata) = manager - .load_session_metadata(&storage_path, &session_id) - .await - .map_err(|error| format!("load imported metadata: {error}"))? - else { - return Ok(false); - }; - if relay_session_history_import_state(&metadata).is_none() { - return Ok(false); - } - - if relay_session_history_import_is_complete(&metadata) { - return Ok(false); - } - - // Fetch the full bundle from the relay (which includes turns). Keep the - // account lease through the local commit so an account switch cannot write - // a stale account's history after it completes. - let _sync_guard = lock_account_sync(generation).await?; - let (acct_session, relay_url) = read_account_context().await?; - let fetched = AccountClient::new() - .fetch_session(&relay_url, &acct_session, &session_id) - .await - .map_err(|e| format!("{e}"))? - .ok_or_else(|| "session not found on relay".to_string())?; - - let bundle: SessionBundle = - serde_json::from_str(&fetched.plaintext).map_err(|e| format!("deserialize bundle: {e}"))?; - - let metadata: SessionMetadata = serde_json::from_value(bundle.metadata.clone()) - .map_err(|e| format!("deserialize metadata: {e}"))?; - if metadata.session_id != session_id { - return Err("relay session metadata identity does not match request".to_string()); - } - let turns = bundle - .turns - .iter() - .map(|turn| { - serde_json::from_value::(turn.clone()) - .map_err(|error| format!("deserialize turn: {error}")) - }) - .collect::, _>>()?; - if turns.iter().any(|turn| turn.session_id != session_id) { - return Err("relay session turn identity does not match request".to_string()); - } - - let coordinator = get_global_coordinator() - .ok_or_else(|| "Core coordinator is not initialized for session import".to_string())?; - let scheduler = get_global_scheduler() - .ok_or_else(|| "Core scheduler is not initialized for session import".to_string())?; - let compatibility = CoreAgentRuntimeCompatibility::build(coordinator, scheduler); - let _history_write = compatibility - .begin_external_persisted_history_write(&storage_path, &session_id) - .await - .map_err(|error| format!("session import is unavailable during undo or redo: {error}"))?; - - manager - .create_session_metadata_if_absent(&storage_path, &metadata) - .await - .map_err(|e| format!("persist imported metadata: {e}"))?; - - // Each turn save refreshes counts through an owner-side metadata RMW. - for turn in &turns { - manager - .save_dialog_turn(&storage_path, turn) - .await - .map_err(|e| format!("persist imported turn: {e}"))?; - } - manager - .update_session_metadata(&storage_path, &session_id, |metadata| { - mark_relay_session_history_import_complete(metadata); - }) - .await - .map_err(|e| format!("mark imported turns complete: {e}"))?; - - log::info!( - "Lazy-loaded {} turns for session {session_id}", - bundle.turns.len() - ); - Ok(true) -} - /// Execute a task on a remote device — sends an ExecuteOnDevice command /// over the device-messaging WS pathway. #[tauri::command] @@ -3871,7 +2758,7 @@ pub async fn account_execute_on_device( workspace_path: Option, ) -> Result<(), String> { let account_generation = account_context_generation(); - let (session, _) = read_account_context_for_generation(account_generation).await?; + let (session, relay_url) = read_account_context_for_generation(account_generation).await?; use openbitfun_core::service::remote_connect::remote_server::RemoteCommand; let envelope = serde_json::to_string(&RemoteCommand::ExecuteOnDevice { @@ -3888,9 +2775,10 @@ pub async fn account_execute_on_device( if !device_routing_owner_is_current(&routing_owner).await { return Err("device routing changed".to_string()); } - use openbitfun_core::service::remote_connect::encryption::encrypt_to_base64; - let (encrypted_data, nonce) = - encrypt_to_base64(&session.master_key, &envelope).map_err(|e| format!("{e}"))?; + let (encrypted_data, nonce) = session + .encrypt_for_peer(&relay_url, &target_device_id, &envelope) + .await + .map_err(|e| format!("{e}"))?; let correlation_id = uuid::Uuid::new_v4().to_string(); send_device_message_with_routing_lease( @@ -4012,748 +2900,27 @@ pub async fn account_device_rpc( Ok(response) } -/// Delegate the account identity to a paired mobile-web/IM client. -/// Called by the frontend after pairing succeeds. -#[tauri::command] -pub async fn account_delegate_to_paired(correlation_id: String) -> Result { - let account_generation = account_context_generation(); - let (session, relay_url) = read_account_context_for_generation(account_generation).await?; - let client = AccountClient::new(); - - // Capture the room owner before requesting a token. A later secret check - // rejects a pairing that changed while the relay request was in flight. - let holder = get_service_holder().read().await; - if !account_context_matches(account_generation, &session.token).await { - return Err("account context changed".to_string()); +/// Result of an auto-sync operation, returned to the frontend. +fn resolve_requested_local_workspace_path(workspace_path: Option<&str>) -> Result { + let requested = workspace_path + .map(str::trim) + .filter(|path| !path.is_empty()) + .ok_or_else(|| "workspace_path is required".to_string())?; + let path = std::path::PathBuf::from(requested); + if !path.is_absolute() { + return Err("workspace_path must be absolute".to_string()); } - let service = holder - .as_ref() - .ok_or_else(|| "remote connect service not initialized".to_string())?; - let pairing_secret = service - .pairing_shared_secret() - .await - .ok_or_else(|| "no paired device".to_string())?; - if !account_context_matches(account_generation, &session.token).await { - return Err("account context changed".to_string()); + let canonical = path + .canonicalize() + .map_err(|error| format!("resolve workspace_path: {error}"))?; + if !canonical.is_dir() { + return Err("workspace_path is not a directory".to_string()); } - - // 1. Get a delegated token from the relay - let delegated = client - .delegate_token(&relay_url, &session) - .await - .map_err(|e| format!("{e}"))?; - if !account_context_matches(account_generation, &session.token).await { - return Err("account context changed".to_string()); - } - if delegated.user_id != session.user_id { - return Err("delegated identity does not match the current account".to_string()); - } - - let current_pairing_secret = service.pairing_shared_secret().await; - if !account_context_matches(account_generation, &session.token).await { - return Err("account context changed".to_string()); - } - if current_pairing_secret.as_ref() != Some(&pairing_secret) { - return Err("paired device changed".to_string()); - } - - // 2. Build the delegated identity JSON (master_key as base64) - use base64::{engine::general_purpose::STANDARD as B64, Engine}; - let device_id = current_device_identity()?.device_id; - let identity_json = serde_json::json!({ - "resp": "delegate_identity", - "token": delegated.token, - "user_id": delegated.user_id, - "master_key": B64.encode(session.master_key), - "device_id": device_id, - }); - let identity_str = - serde_json::to_string(&identity_json).map_err(|e| format!("serialize identity: {e}"))?; - - // 3. Encrypt with the captured room secret and atomically verify that the - // service still owns that pairing before sending. - use openbitfun_core::service::remote_connect::encryption::encrypt_to_base64; - let (enc, nonce) = encrypt_to_base64(&pairing_secret, &identity_str) - .map_err(|e| format!("encrypt delegated identity: {e}"))?; - if !account_context_matches(account_generation, &session.token).await { - return Err("account context changed".to_string()); - } - let expected_token = session.token.clone(); - let sent = service - .send_room_response_if_pairing_secret_authorized( - &pairing_secret, - &correlation_id, - &enc, - &nonce, - || async move { - let account_lease = lock_account_sync(account_generation).await?; - if !account_context_matches(account_generation, &expected_token).await { - return Err("account context changed".to_string()); - } - Ok(account_lease) - }, - ) - .await - .map_err(|e| format!("send delegated identity: {e}"))?; - if !account_context_matches(account_generation, &session.token).await { - return Err("account context changed".to_string()); - } - if !sent { - return Err("paired device changed".to_string()); - } - log::info!("Delegated identity sent to paired device (corr={correlation_id})"); - - Ok(identity_str) -} - -/// Result of an auto-sync operation, returned to the frontend. -#[derive(Serialize)] -pub struct AutoSyncResult { - pub settings_synced: bool, - pub sessions_exported: usize, - pub sessions_imported: usize, -} - -/// Perform the full auto-sync flow. Called by the frontend after login -/// (first login) or after the user confirms cloud-settings overwrite -/// (non-first login). -#[tauri::command] -pub async fn account_auto_sync( - is_first_login: bool, - workspace_path: String, - config_json: String, - sync_operation_id: u64, - app_state: State<'_, crate::api::app_state::AppState>, - path_manager: State<'_, Arc>, -) -> Result { - if sync_operation_id == 0 { - return Err("sync operation id must be non-zero".to_string()); - } - // Capture the account generation before queueing. A logout or replacement - // login that wins the lock invalidates this call instead of letting a stale - // request start against the newly installed account. - let generation = account_context_generation(); - let _sync_guard = lock_account_sync(generation).await?; - ACTIVE_ACCOUNT_AUTO_SYNC_OPERATION_ID.store(sync_operation_id, Ordering::Release); - let result = account_auto_sync_inner( - is_first_login, - workspace_path, - config_json, - sync_operation_id, - app_state, - path_manager, - ) - .await; - let _ = ACTIVE_ACCOUNT_AUTO_SYNC_OPERATION_ID.compare_exchange( - sync_operation_id, - 0, - Ordering::AcqRel, - Ordering::Acquire, - ); - result -} - -async fn account_auto_sync_inner( - is_first_login: bool, - workspace_path: String, - config_json: String, - sync_operation_id: u64, - app_state: State<'_, crate::api::app_state::AppState>, - path_manager: State<'_, Arc>, -) -> Result { - // Soft no-op while controlling a peer: cloud sync would rewrite the - // controller's local disk mid-remote. Match account_fetch_session_turns. - if crate::api::peer_host_invoke::is_peer_controller_active() { - log::info!("Skipping account auto-sync while Peer Device Mode is active"); - return Ok(AutoSyncResult { - settings_synced: false, - sessions_exported: 0, - sessions_imported: 0, - }); - } - ensure_account_auto_sync_current(sync_operation_id)?; - let (acct_session, relay_url) = read_account_context().await?; - let client = AccountClient::new(); - use openbitfun_core::service::remote_connect::settings_sync; - - // 1. Settings sync - let settings_synced = if is_first_login { - emit_sync_progress(sync_operation_id, "uploading_settings", 5, None, None, None); - await_account_auto_sync( - sync_operation_id, - settings_sync::upload_settings_payload(&acct_session, &relay_url, &config_json), - ) - .await? - .map_err(|e| format!("upload settings: {e}"))?; - ensure_account_auto_sync_current(sync_operation_id)?; - log::info!("First login: uploaded local settings to cloud"); - emit_sync_progress(sync_operation_id, "settings_done", 15, None, None, None); - true - } else { - emit_sync_progress( - sync_operation_id, - "downloading_settings", - 5, - None, - None, - None, - ); - let cloud = await_account_auto_sync( - sync_operation_id, - client.fetch_settings_with_version(&relay_url, &acct_session), - ) - .await? - .map_err(|e| format!("fetch settings: {e}"))?; - ensure_account_auto_sync_current(sync_operation_id)?; - if let Some(blob) = cloud { - emit_sync_progress(sync_operation_id, "applying_settings", 10, None, None, None); - // Explicit user choice — always apply, even when the cursor says - // this device already has this version. Applies into the global - // config service, invalidates the AI client cache, reloads, and - // emits `account://settings-applied`. - await_account_auto_sync( - sync_operation_id, - settings_sync::apply_settings_blob(&acct_session, &blob, true), - ) - .await? - .map_err(|e| format!("apply cloud config: {e}"))?; - ensure_account_auto_sync_current(sync_operation_id)?; - log::info!( - "Applied cloud settings to local device (version={})", - blob.version - ); - emit_sync_progress(sync_operation_id, "settings_done", 15, None, None, None); - true - } else { - emit_sync_progress(sync_operation_id, "settings_done", 15, None, None, None); - false - } - }; - - // 2. Session sync: upload local sessions only (backup). Do NOT import cloud - // sessions into local disk — Remote peer mode reads the peer's live disk. - ensure_account_auto_sync_current(sync_operation_id)?; - emit_sync_progress(sync_operation_id, "listing_sessions", 18, None, None, None); - let storage_path = - desktop_effective_session_storage_path(&app_state, &workspace_path, None, None).await; - let manager = PersistenceManager::new(path_manager.inner().clone()) - .map_err(|e| format!("create persistence manager: {e}"))?; - - let local_sessions = manager - .list_session_metadata(&storage_path) - .await - .map_err(|e| format!("list sessions: {e}"))?; - - let export_candidates = local_sessions.len(); - emit_sync_progress( - sync_operation_id, - "exporting_sessions", - 20, - Some(0), - Some(export_candidates), - None, - ); - - let mut sync_state_local = sync_state::load(&acct_session.user_id); - let mut pending_uploads: Vec<(String, String, String)> = Vec::new(); - for meta in local_sessions.iter() { - ensure_account_auto_sync_current(sync_operation_id)?; - if let Err(error) = ensure_relay_session_history_exportable(meta) { - log::debug!("Skipping account auto-sync export: {error}"); - continue; - } - let turns = load_account_visible_session_turns(&storage_path, &meta.session_id) - .await - .map_err(|e| format!("load turns: {e}"))?; - let metadata = relay_session_export_metadata(meta, turns.len()); - let metadata_json = - serde_json::to_value(metadata).map_err(|e| format!("serialize metadata: {e}"))?; - let turns_json: Vec = turns - .iter() - .map(|t| serde_json::to_value(t).unwrap_or(serde_json::Value::Null)) - .collect(); - let bundle = SessionBundle { - session_id: meta.session_id.clone(), - metadata: metadata_json, - turns: turns_json, - source_device_id: None, - source_device_name: None, - }; - let bundle_json = - serde_json::to_string(&bundle).map_err(|e| format!("serialize bundle: {e}"))?; - let hash = sync_state::content_hash(&bundle_json); - if sync_state_local.uploaded_hash(&meta.session_id) == Some(hash.as_str()) { - continue; - } - pending_uploads.push((meta.session_id.clone(), bundle_json, hash)); - } - - let upload_total = pending_uploads.len(); - emit_sync_progress( - sync_operation_id, - "exporting_sessions", - 20, - Some(0), - Some(upload_total), - None, - ); - - let completed = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); - let upload_outcomes: Vec> = stream::iter(pending_uploads) - .map(|(session_id, bundle_json, hash)| { - let client = AccountClient::new(); - let relay_url = relay_url.clone(); - let acct_session = acct_session.clone(); - let completed = completed.clone(); - async move { - if ensure_account_auto_sync_current(sync_operation_id).is_err() { - return Err("account sync cancelled".to_string()); - } - let result = match await_account_auto_sync( - sync_operation_id, - client.upload_session(&relay_url, &acct_session, &session_id, &bundle_json), - ) - .await - { - Ok(result) => result, - Err(e) => return Err(e), - }; - match result { - Ok(version) => { - let done = completed.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1; - let percent = if upload_total == 0 { - 95u8 - } else { - 20 + ((75 * done) / upload_total) as u8 - }; - if ensure_account_auto_sync_current(sync_operation_id).is_err() { - return Err("account sync cancelled".to_string()); - } - emit_sync_progress( - sync_operation_id, - "exporting_sessions", - percent.min(95), - Some(done), - Some(upload_total), - Some(session_id.as_str()), - ); - Ok((session_id, hash, version)) - } - Err(e) => { - log::warn!("Auto-sync upload {session_id} failed: {e}"); - Err(format!("{session_id}: {e}")) - } - } - } - }) - .buffer_unordered(UPLOAD_CONCURRENCY) - .collect() - .await; - - ensure_account_auto_sync_current(sync_operation_id)?; - - let mut uploaded = Vec::new(); - let mut upload_errors = Vec::new(); - for outcome in upload_outcomes { - match outcome { - Ok(item) => uploaded.push(item), - Err(err) => upload_errors.push(err), - } - } - - let exported = uploaded.len(); - let mut max_uploaded_version = sync_state_local.last_session_since; - for (session_id, hash, version) in uploaded { - sync_state_local.set_uploaded_hash(&session_id, hash); - if version > max_uploaded_version { - max_uploaded_version = version; - } - } - if max_uploaded_version > sync_state_local.last_session_since { - sync_state_local.last_session_since = max_uploaded_version; - } - let _ = sync_state::save(&acct_session.user_id, &sync_state_local); - - ensure_session_backup_complete(upload_total, exported, &upload_errors)?; - - log::info!("Auto-sync: settings={settings_synced} exported={exported} imported=0"); - emit_sync_progress( - sync_operation_id, - "done", - 100, - Some(exported), - Some(0), - None, - ); - Ok(AutoSyncResult { - settings_synced, - sessions_exported: exported, - sessions_imported: 0, - }) -} - -fn ensure_session_backup_complete( - total: usize, - uploaded: usize, - upload_errors: &[String], -) -> Result<(), String> { - if uploaded == total { - return Ok(()); - } - let detail = upload_errors - .first() - .map(|err| err.as_str()) - .unwrap_or("retry will resume remaining sessions"); - Err(format!( - "session backup incomplete: uploaded {uploaded} of {total}; {detail}" - )) -} - -// ── Auto-sync: debounced upload on session changes ───────────────────────── -// -// Settings sync (debounced push + 30s pull) is owned by the shared engine in -// `openbitfun_core::service::remote_connect::settings_sync`; this module only -// keeps the desktop-specific session backup loop and wires engine hooks. - -use std::time::Duration; -use tokio::sync::mpsc; - -/// What to sync. Each variant maps to a single relay operation. -#[derive(Debug, Clone)] -enum SyncRequest { - /// Upload (or replace) a session blob — fired on create/turn-save/metadata/rename. - SessionUpsert { - session_id: String, - workspace_path: String, - }, - /// Tombstone a session on the relay — fired on delete. Prevents re-import. - SessionDelete { session_id: String }, -} - -/// Global channel for notifying the sync background task. -static SYNC_TX: OnceLock> = OnceLock::new(); - -/// Called once at app startup to start the settings sync engine and the -/// debounced session sync background task. -pub fn init_auto_sync() { - start_settings_sync_engine(); - let (tx, rx) = mpsc::unbounded_channel::(); - let _ = SYNC_TX.set(tx); - tokio::spawn(sync_background_loop(rx)); -} - -/// Start the shared settings sync engine with desktop hooks. -fn start_settings_sync_engine() { - use openbitfun_core::service::remote_connect::settings_sync; - let hooks = settings_sync::SettingsSyncHooks { - account_context: Some(std::sync::Arc::new(|| { - Box::pin(async { - if !background_account_sync_is_allowed() { - return Err(anyhow::anyhow!( - "account login is waiting for a settings choice" - )); - } - let generation = account_context_generation(); - if !account_context_is_current(generation) { - return Err(anyhow::anyhow!("account context is transitioning")); - } - let (account, relay_url) = - read_account_context().await.map_err(anyhow::Error::msg)?; - if !account_context_is_current(generation) { - return Err(anyhow::anyhow!("account context changed while reading")); - } - if !background_account_sync_is_allowed() { - return Err(anyhow::anyhow!( - "account login is waiting for a settings choice" - )); - } - Ok((account, relay_url, generation)) - }) - })), - is_account_context_current: Some(std::sync::Arc::new(account_context_is_current)), - should_pause: Some(std::sync::Arc::new(|| { - crate::api::peer_host_invoke::is_peer_controller_active() - })), - on_settings_applied: Some(std::sync::Arc::new(|| { - emit_settings_applied(); - fanout_peer_device_event( - "account://settings-applied".to_string(), - serde_json::json!({ "applied": true }), - ); - })), - on_settings_pushed: Some(std::sync::Arc::new(|| { - fanout_peer_device_event( - "account://settings-applied".to_string(), - serde_json::json!({ "applied": true }), - ); - })), - on_token_expired: Some(std::sync::Arc::new(|| { - TOKEN_EXPIRED.store(true, std::sync::atomic::Ordering::Relaxed); - })), - ..Default::default() - }; - settings_sync::start_settings_sync_engine(hooks); -} - -/// Non-blocking notification that a session was created/modified. Called from -/// `save_session_turn`, `save_session_metadata`, `create_session`, -/// `update_session_title` Tauri commands. -pub fn notify_session_changed(session_id: &str, workspace_path: &str) { - if let Some(tx) = SYNC_TX.get() { - let _ = tx.send(SyncRequest::SessionUpsert { - session_id: session_id.to_string(), - workspace_path: workspace_path.to_string(), - }); - } -} - -/// Non-blocking notification that a session was deleted. Called from -/// `delete_session` and `delete_persisted_session` Tauri commands. Sends a -/// tombstone to the relay so the deleted session is not re-imported. -pub fn notify_session_deleted(session_id: &str) { - if let Some(tx) = SYNC_TX.get() { - let _ = tx.send(SyncRequest::SessionDelete { - session_id: session_id.to_string(), - }); - } -} - -/// Non-blocking notification that config was changed. Called from `set_config`. -/// Forwards to the shared settings sync engine (debounced + hash-deduped). -pub fn notify_settings_changed() { - openbitfun_core::service::remote_connect::settings_sync::notify_settings_changed(); -} - -/// Background loop: collects session sync requests, debounces 5 seconds, -/// then uploads. Settings push/pull is handled by the settings sync engine. -async fn sync_background_loop(mut rx: mpsc::UnboundedReceiver) { - let debounce = Duration::from_secs(5); - loop { - // Wait for the next session sync request, then drain during the - // debounce window. - let Some(first) = rx.recv().await else { - return; - }; - let mut pending_upserts: HashMap = HashMap::new(); - let mut pending_deletes: std::collections::HashSet = - std::collections::HashSet::new(); - match first { - SyncRequest::SessionUpsert { - session_id, - workspace_path, - } => { - pending_upserts.insert(session_id, workspace_path); - } - SyncRequest::SessionDelete { session_id } => { - pending_deletes.insert(session_id); - } - } - - let deadline = tokio::time::sleep(debounce); - tokio::pin!(deadline); - loop { - tokio::select! { - _ = &mut deadline => break, - Some(req) = rx.recv() => { - match req { - SyncRequest::SessionUpsert { session_id, workspace_path } => { - pending_deletes.remove(&session_id); - pending_upserts.insert(session_id, workspace_path); - } - SyncRequest::SessionDelete { session_id } => { - pending_upserts.remove(&session_id); - pending_deletes.insert(session_id); - } - } - } - } - } - - execute_debounced_sync(pending_upserts, pending_deletes).await; - } -} - -/// Execute the debounced sync: upload changed sessions, tombstone deleted -/// sessions. -async fn execute_debounced_sync( - upserts: HashMap, - deletes: std::collections::HashSet, -) { - if !background_account_sync_is_allowed() { - log::debug!("Debounced sync skipped while account login awaits a settings choice"); - return; - } - if crate::api::peer_host_invoke::is_peer_controller_active() { - log::debug!("Debounced sync skipped while peer controller mode is active"); - return; - } - let generation = account_context_generation(); - let Ok(_sync_guard) = lock_account_sync(generation).await else { - return; - }; - if !background_account_sync_is_allowed() { - return; - } - // Need to be logged in - let (acct_session, relay_url) = match read_account_context().await { - Ok(ctx) => ctx, - Err(_) => return, // not logged in — silently skip - }; - let client = AccountClient::new(); - - // Tombstone deleted sessions on the relay - let mut sync_state_local = sync_state::load(&acct_session.user_id); - for session_id in &deletes { - if let Err(e) = client - .delete_session(&relay_url, &acct_session, session_id) - .await - { - if is_token_expired_error(&e) { - TOKEN_EXPIRED.store(true, std::sync::atomic::Ordering::Relaxed); - } - log::warn!("Auto-sync delete {session_id} failed: {e}"); - } else { - sync_state_local.clear_uploaded_hash(session_id); - log::debug!("Auto-synced tombstone for session {session_id}"); - } - } - - // Upload changed sessions (hash-skip + concurrency) - let upsert_list: Vec<(String, String)> = upserts.into_iter().collect(); - let upload_results: Vec<(String, Option)> = stream::iter(upsert_list) - .map(|(session_id, workspace_path)| { - let client = AccountClient::new(); - let relay_url = relay_url.clone(); - let acct_session = acct_session.clone(); - let known_hash = sync_state_local - .uploaded_hash(&session_id) - .map(str::to_string); - async move { - match export_and_upload_session( - &client, - &acct_session, - &relay_url, - &session_id, - &workspace_path, - known_hash.as_deref(), - ) - .await - { - Ok(Some(hash)) => { - log::debug!("Auto-synced session {session_id}"); - (session_id, Some(hash)) - } - Ok(None) => { - log::debug!("Auto-sync skip unchanged session {session_id}"); - (session_id, None) - } - Err(e) => { - if is_token_expired_error(&e) { - TOKEN_EXPIRED.store(true, std::sync::atomic::Ordering::Relaxed); - } - log::warn!("Auto-sync session {session_id} failed: {e}"); - (session_id, None) - } - } - } - }) - .buffer_unordered(UPLOAD_CONCURRENCY) - .collect() - .await; - - for (session_id, hash) in upload_results { - if let Some(hash) = hash { - sync_state_local.set_uploaded_hash(&session_id, hash); - } - } - let _ = sync_state::save(&acct_session.user_id, &sync_state_local); -} - -/// Load a single session from disk, serialize to bundle, and upload. -/// Returns `Some(hash)` when a POST succeeded, `None` when content was unchanged. -async fn export_and_upload_session( - client: &AccountClient, - acct_session: &AccountSession, - relay_url: &str, - session_id: &str, - workspace_path: &str, - known_hash: Option<&str>, -) -> anyhow::Result> { - // Resolve storage path — we need app_state for desktop_effective_session_storage_path - // but in this background context we don't have it. Use the path_manager approach. - let path_manager = std::sync::Arc::new( - openbitfun_core::infrastructure::PathManager::new() - .map_err(|e| anyhow::anyhow!("create path manager: {e}"))?, - ); - let storage_path = - openbitfun_core::service::remote_ssh::workspace_state::get_effective_session_path( - workspace_path, - None, - None, - ) - .await; - - let manager = PersistenceManager::new(path_manager) - .map_err(|e| anyhow::anyhow!("create persistence manager: {e}"))?; - - let metadata = manager - .load_session_metadata(&storage_path, session_id) - .await? - .ok_or_else(|| anyhow::anyhow!("session not found: {session_id}"))?; - ensure_relay_session_history_exportable(&metadata).map_err(anyhow::Error::msg)?; - - let turns = load_account_visible_session_turns(&storage_path, session_id) - .await - .map_err(anyhow::Error::msg)?; - let metadata = relay_session_export_metadata(&metadata, turns.len()); - - let metadata_json = serde_json::to_value(&metadata)?; - let turns_json: Vec = turns - .iter() - .map(|t| serde_json::to_value(t).unwrap_or(serde_json::Value::Null)) - .collect(); - - let bundle = SessionBundle { - session_id: session_id.to_string(), - metadata: metadata_json, - turns: turns_json, - source_device_id: None, - source_device_name: None, - }; - let bundle_json = serde_json::to_string(&bundle)?; - let hash = sync_state::content_hash(&bundle_json); - if known_hash == Some(hash.as_str()) { - return Ok(None); - } - client - .upload_session(relay_url, acct_session, session_id, &bundle_json) - .await?; - Ok(Some(hash)) -} - -/// The legacy one-way execution command is path-addressed. It must never -/// silently choose an unrelated local project when the sender omitted or -/// mistyped the target path. -fn resolve_requested_local_workspace_path(workspace_path: Option<&str>) -> Result { - let requested = workspace_path - .map(str::trim) - .filter(|path| !path.is_empty()) - .ok_or_else(|| "workspace_path is required".to_string())?; - let path = std::path::PathBuf::from(requested); - if !path.is_absolute() { - return Err("workspace_path must be absolute".to_string()); - } - let canonical = path - .canonicalize() - .map_err(|error| format!("resolve workspace_path: {error}"))?; - if !canonical.is_dir() { - return Err("workspace_path is not a directory".to_string()); - } - canonical - .to_str() - .map(ToOwned::to_owned) - .ok_or_else(|| "workspace_path is not valid UTF-8".to_string()) -} + canonical + .to_str() + .map(ToOwned::to_owned) + .ok_or_else(|| "workspace_path is not valid UTF-8".to_string()) +} /// Execute a RemoteCommand locally (for RPC requests from other devices). /// Returns the RemoteResponse serialized as JSON to be encrypted and sent back. @@ -4851,86 +3018,6 @@ async fn execute_local_remote_command( } } -/// Import a SessionBundle JSON into local storage. Tries all workspace session -/// directories and writes to the first one found (or creates one if none exist). -async fn import_session_bundle(bundle_json: &str, account_generation: u64) -> anyhow::Result<()> { - let _sync_guard = lock_account_sync(account_generation) - .await - .map_err(anyhow::Error::msg)?; - // A queued event from a disconnected account must not write into a new - // account's local session view even if its encrypted payload was already - // received before the socket closed. - read_account_context().await.map_err(anyhow::Error::msg)?; - let bundle: SessionBundle = serde_json::from_str(bundle_json)?; - - let path_manager = std::sync::Arc::new(openbitfun_core::infrastructure::PathManager::new()?); - let manager = PersistenceManager::new(path_manager.clone())?; - let workspace = get_global_workspace_service() - .ok_or_else(|| anyhow::anyhow!("workspace service is unavailable"))? - .get_current_workspace() - .await - .ok_or_else(|| anyhow::anyhow!("no active workspace is available for session import"))?; - if workspace.workspace_kind == WorkspaceKind::Remote { - return Err(anyhow::anyhow!( - "session import requires an active local workspace" - )); - } - get_global_coordinator() - .ok_or_else(|| anyhow::anyhow!("Agent Runtime coordinator is unavailable"))? - .ensure_workspace_runtime_ownership(&workspace.root_path, None, None) - .map_err(|error| anyhow::anyhow!(error.to_string()))?; - let target_dir = WorkspaceRuntimeService::new(path_manager.clone()) - .context_for_local_workspace(&workspace.root_path) - .sessions_dir; - - let mut metadata: SessionMetadata = serde_json::from_value(bundle.metadata.clone())?; - if metadata.session_id != bundle.session_id { - return Err(anyhow::anyhow!( - "relay session metadata identity does not match bundle" - )); - } - - // Only write metadata — turns are lazy-loaded when the user opens the - // session. This keeps the import fast and avoids writing potentially - // large turn data that may never be read. - mark_relay_session_history_import_pending(&mut metadata); - manager - .create_session_metadata_if_absent(&target_dir, &metadata) - .await - .map_err(|e| anyhow::anyhow!("save metadata: {e}"))?; - - Ok(()) -} - -/// One-shot cloud settings pull, triggered when another same-account device -/// comes online. The periodic pull lives in the shared settings sync engine. -async fn pull_and_reconcile(account_generation: u64) { - if !background_account_sync_is_allowed() { - log::debug!("Pull: skip while account login awaits a settings choice"); - return; - } - if crate::api::peer_host_invoke::is_peer_controller_active() { - log::debug!("Pull: skip while peer controller mode is active"); - return; - } - let Ok(_sync_guard) = lock_account_sync(account_generation).await else { - return; - }; - if !background_account_sync_is_allowed() { - return; - } - let Ok((acct_session, relay_url)) = read_account_context().await else { - return; - }; - use openbitfun_core::service::remote_connect::settings_sync; - if let Err(e) = settings_sync::pull_and_apply_settings(&acct_session, &relay_url).await { - if is_token_expired_error(&e) { - TOKEN_EXPIRED.store(true, std::sync::atomic::Ordering::Relaxed); - } - log::debug!("Pull: fetch_settings failed: {e}"); - } -} - #[cfg(test)] mod sync_state_tests { use super::*; @@ -5066,11 +3153,7 @@ mod sync_state_tests { let _test_guard = ACCOUNT_CONTEXT_TEST_LOCK.lock().await; let relay_url = "https://relay.example/base/"; *get_account_context().write().await = Some(AccountContextState { - session: AccountSession { - token: "control-token".into(), - user_id: "control-user".into(), - master_key: [7; 32], - }, + session: AccountSession::new("control-token".into(), "control-user".into(), [7; 32]), relay_url: relay_url.into(), }); let owner = new_device_routing_owner(account_context_generation(), "control-token", 1); @@ -5113,67 +3196,26 @@ mod sync_state_tests { } #[test] - fn account_control_status_preserves_legacy_room_payloads() { - let legacy = serde_json::json!({ - "is_connected": false, - "pairing_state": "waiting_for_scan", - "active_method": "OpenBitFunServer", - "peer_device_name": null, - "peer_user_id": null, - "bot_connected": null, - "bot_verbose_mode": false, - }); - let mut status: RemoteConnectStatusResponse = - serde_json::from_value(legacy.clone()).unwrap(); - assert!(!status.account_control_connected); - assert!(status.account_control_relay_url.is_none()); - status.account_control_connected = true; - status.account_control_relay_url = Some("https://relay.example/base".into()); - status.account_control_clients = Some(vec![ - openbitfun_services_integrations::remote_connect::RemoteControlClient { - id: "phone".into(), - name: "Safari".into(), - }, - ]); - status.account_control_has_unidentified_clients = Some(true); - let mut serialized = serde_json::to_value(&status).unwrap(); - assert_eq!(serialized["pairing_state"], "waiting_for_scan"); - assert_eq!(serialized["is_connected"], false); - assert_eq!( - serialized - .as_object_mut() - .unwrap() - .remove("account_control_connected"), - Some(serde_json::json!(true)) - ); - assert_eq!( - serialized - .as_object_mut() - .unwrap() - .remove("account_control_relay_url"), - Some(serde_json::json!("https://relay.example/base")) - ); - let round_trip: RemoteConnectStatusResponse = - serde_json::from_value(serialized.clone()).unwrap(); - assert_eq!( - round_trip.account_control_clients, - status.account_control_clients - ); - assert_eq!( - serialized - .as_object_mut() - .unwrap() - .remove("account_control_clients"), - Some(serde_json::json!([{"id": "phone", "name": "Safari"}])) - ); - assert_eq!( - serialized - .as_object_mut() - .unwrap() - .remove("account_control_has_unidentified_clients"), - Some(serde_json::json!(true)) - ); - assert_eq!(serialized, legacy); + fn relay_status_has_one_account_device_contract_for_both_endpoints() { + for (method, endpoint) in [ + ( + serde_json::json!("openbitfun_server"), + "https://remote.openbitfun.com/v/1.0.0", + ), + ( + serde_json::json!({"lan":{"ip":"192.168.1.2"}}), + "http://192.168.1.2:9700", + ), + ] { + let payload = serde_json::json!({ + "relay_connected": true, "relay_url": endpoint, "active_method": method, + "clients": [{"id":"phone","name":"Safari"}], + "bot_connected": null, "bot_verbose_mode": false, + }); + let status: RemoteConnectStatusResponse = + serde_json::from_value(payload.clone()).unwrap(); + assert_eq!(serde_json::to_value(status).unwrap(), payload); + } } #[test] @@ -5184,31 +3226,6 @@ mod sync_state_tests { ); } - #[test] - fn account_boundaries_rotate_invitations_but_preserve_connected_rooms() { - assert!(pairing_room_requires_rotation( - &PairingState::WaitingForScan - )); - assert!(pairing_room_requires_rotation(&PairingState::Handshaking)); - assert!(pairing_room_requires_rotation(&PairingState::Verifying)); - assert!(pairing_room_requires_rotation(&PairingState::Failed { - reason: "verification failed".to_string(), - })); - assert!(!pairing_room_requires_rotation(&PairingState::Connected)); - assert!(!pairing_room_requires_rotation(&PairingState::Idle)); - assert!(!pairing_room_requires_rotation(&PairingState::Disconnected)); - } - - #[test] - fn settings_probe_errors_are_not_treated_as_an_empty_cloud() { - assert!(!cloud_settings_exist_from_probe::(Ok(None)).unwrap()); - assert!(cloud_settings_exist_from_probe::(Ok(Some(1))).unwrap()); - assert_eq!( - cloud_settings_exist_from_probe::(Err("relay unavailable")), - Err("relay unavailable") - ); - } - #[test] fn device_rpc_timeout_is_bounded_for_peer_requests() { assert_eq!( @@ -5226,36 +3243,10 @@ mod sync_state_tests { ); } - #[test] - fn login_result_exposes_only_an_opaque_pending_owner() { - let value = serde_json::to_value(AccountLoginResult { - user_id: "user-a".to_string(), - pending_login_id: Some("pending-a".to_string()), - has_cloud_settings: true, - }) - .unwrap(); - - assert_eq!(value["pending_login_id"], "pending-a"); - assert!(value.get("token").is_none()); - } - - #[test] - fn background_sync_is_fail_closed_while_login_choice_is_pending() { - let _test_guard = ACCOUNT_CONTEXT_TEST_LOCK.blocking_lock(); - set_pending_login_id(Some("pending-a".to_string())); - assert!(!background_account_sync_is_allowed()); - set_pending_login_id(None); - assert!(background_account_sync_is_allowed()); - } - #[test] fn replaced_token_revocation_never_selects_the_published_credential() { let account = |token: &str, relay_url: &str| AccountContextState { - session: AccountSession { - token: token.to_string(), - user_id: "user-a".to_string(), - master_key: [7; 32], - }, + session: AccountSession::new(token.to_string(), "user-a".to_string(), [7; 32]), relay_url: relay_url.to_string(), }; @@ -5281,34 +3272,11 @@ mod sync_state_tests { assert_eq!(other_relay.relay_url, "https://relay-a.example.com"); } - #[tokio::test(flavor = "current_thread")] - async fn account_transition_cancels_an_in_flight_auto_sync_future() { - let _test_guard = ACCOUNT_CONTEXT_TEST_LOCK.lock().await; - let operation_id = u64::MAX - 41; - ACTIVE_ACCOUNT_AUTO_SYNC_OPERATION_ID.store(operation_id, Ordering::Release); - let waiter = tokio::spawn(async move { - await_account_auto_sync(operation_id, std::future::pending::<()>()).await - }); - tokio::task::yield_now().await; - - let permit = AccountContextTransitionPermit::begin(); - let result = tokio::time::timeout(std::time::Duration::from_secs(1), waiter) - .await - .expect("sync cancellation should not wait for the network timeout") - .expect("cancellation task should join"); - drop(permit); - assert_eq!(result.unwrap_err(), "account sync cancelled"); - } - #[tokio::test(flavor = "current_thread")] async fn external_account_reads_are_hidden_during_transition() { let _test_guard = ACCOUNT_CONTEXT_TEST_LOCK.lock().await; *get_account_context().write().await = Some(AccountContextState { - session: AccountSession { - token: "token-a".to_string(), - user_id: "user-a".to_string(), - master_key: [7; 32], - }, + session: AccountSession::new("token-a".to_string(), "user-a".to_string(), [7; 32]), relay_url: "https://relay.example.com".to_string(), }); assert!(read_account_context().await.is_ok()); @@ -5327,7 +3295,7 @@ mod sync_state_tests { let transition_guard = ACCOUNT_CONTEXT_TRANSITION_LOCK.lock().await; let transition = AccountContextTransitionPermit::begin(); let mut guard = AccountContextTransitionGuard { - sync_guard: None, + operation_guard: None, transition: Some(transition), transition_guard: Some(transition_guard), }; @@ -5341,63 +3309,6 @@ mod sync_state_tests { assert!(ACCOUNT_CONTEXT_TRANSITION_LOCK.try_lock().is_ok()); } - #[tokio::test(flavor = "current_thread")] - async fn stale_pending_login_id_cannot_finalize_or_cancel_replacement() { - let _test_guard = ACCOUNT_CONTEXT_TEST_LOCK.lock().await; - *get_account_context().write().await = Some(AccountContextState { - session: AccountSession { - token: "token-b".to_string(), - user_id: "user-b".to_string(), - master_key: [9; 32], - }, - relay_url: "https://relay.example.com".to_string(), - }); - set_pending_login_id(Some("pending-b".to_string())); - let generation_before = account_context_generation(); - - assert!(lock_pending_login_for_finalize("pending-a").await.is_err()); - assert!(!account_cancel_pending_login(PendingAccountLoginRequest { - pending_login_id: "pending-a".to_string(), - }) - .await - .unwrap()); - assert_eq!(account_context_generation(), generation_before); - assert!(pending_login_is_owned_by("pending-b")); - - set_pending_login_id(None); - assert!(!PENDING_SYNC_CHOICE.load(std::sync::atomic::Ordering::Acquire)); - assert!(!pending_login_is_owned_by("pending-b")); - *get_account_context().write().await = None; - } - - #[tokio::test(flavor = "current_thread")] - async fn finalize_retry_after_commit_is_idempotent_only_for_the_same_account_owner() { - let _test_guard = ACCOUNT_CONTEXT_TEST_LOCK.lock().await; - *get_account_context().write().await = Some(AccountContextState { - session: AccountSession { - token: "token-a".to_string(), - user_id: "user-a".to_string(), - master_key: [7; 32], - }, - relay_url: "https://relay.example.com".to_string(), - }); - let generation = account_context_generation(); - record_finalized_pending_login(FinalizedPendingLoginOwner { - pending_login_id: "pending-a".to_string(), - account_generation: generation, - account_token: "token-a".to_string(), - }); - - assert!(finalized_pending_login_is_current("pending-a").await); - assert!(!finalized_pending_login_is_current("pending-b").await); - - let transition = AccountContextTransitionPermit::begin(); - assert!(!finalized_pending_login_is_current("pending-a").await); - drop(transition); - *get_account_context().write().await = None; - clear_last_finalized_pending_login(); - } - #[test] fn stale_routing_owner_cannot_clear_or_update_replacement() { let _test_guard = ACCOUNT_CONTEXT_TEST_LOCK.blocking_lock(); @@ -5463,86 +3374,6 @@ mod sync_state_tests { assert!(device_presence_for_account(20, "token-replaced").is_none()); clear_device_routing_state(); } - - #[test] - fn partial_session_backup_is_not_reported_as_success() { - assert!(ensure_session_backup_complete(3, 3, &[]).is_ok()); - let error = ensure_session_backup_complete( - 3, - 2, - &["s1: relay returned HTTP 507 Insufficient Storage".into()], - ) - .unwrap_err(); - assert!(error.contains("uploaded 2 of 3")); - assert!(error.contains("HTTP 507")); - } - - #[test] - fn content_hash_is_stable() { - let a = sync_state::content_hash(r#"{"session_id":"x"}"#); - let b = sync_state::content_hash(r#"{"session_id":"x"}"#); - let c = sync_state::content_hash(r#"{"session_id":"y"}"#); - assert_eq!(a, b); - assert_ne!(a, c); - assert_eq!(a.len(), 64); - } - - #[test] - fn advance_session_since_takes_max() { - let mut state = sync_state::AccountSyncState::default(); - state.advance_session_since([1, 5, 3]); - assert_eq!(state.last_session_since, 5); - state.advance_session_since([4]); - assert_eq!(state.last_session_since, 5); - state.advance_session_since([9]); - assert_eq!(state.last_session_since, 9); - } - - #[test] - fn pending_relay_turn_imports_are_never_exportable() { - let mut metadata = SessionMetadata::new( - "session".to_string(), - "Session".to_string(), - "agentic".to_string(), - "primary".to_string(), - ); - metadata.turn_count = 2; - - assert_eq!(relay_session_history_import_state(&metadata), None); - assert!(!relay_session_history_import_is_complete(&metadata)); - assert!(ensure_relay_session_history_exportable(&metadata).is_ok()); - mark_relay_session_history_import_pending(&mut metadata); - assert_eq!( - relay_session_history_import_state(&metadata), - Some("pending") - ); - assert!(!relay_session_history_import_is_complete(&metadata)); - assert!(ensure_relay_session_history_exportable(&metadata).is_err()); - mark_relay_session_history_import_complete(&mut metadata); - assert!(relay_session_history_import_is_complete(&metadata)); - assert!(ensure_relay_session_history_exportable(&metadata).is_ok()); - - metadata.custom_metadata = Some(serde_json::json!({ - "relayTurnsImportState": "unknown" - })); - assert!(ensure_relay_session_history_exportable(&metadata).is_err()); - } - - #[test] - fn account_export_metadata_matches_the_visible_history_projection() { - let mut metadata = SessionMetadata::new( - "session".to_string(), - "Session".to_string(), - "agentic".to_string(), - "primary".to_string(), - ); - metadata.turn_count = 3; - - let exported = relay_session_export_metadata(&metadata, 2); - - assert_eq!(metadata.turn_count, 3); - assert_eq!(exported.turn_count, 2); - } } #[cfg(test)] diff --git a/src/apps/desktop/src/api/session_api.rs b/src/apps/desktop/src/api/session_api.rs index 4d3f58cf23..58bb08c172 100644 --- a/src/apps/desktop/src/api/session_api.rs +++ b/src/apps/desktop/src/api/session_api.rs @@ -510,10 +510,7 @@ pub async fn save_session_turn( .map_err(|error| format!("Failed to save session turn: {error}"))?; // Notify the auto-sync background task (debounced upload to relay) - crate::api::remote_connect_api::notify_session_changed( - &request.turn_data.session_id, - &request.workspace_path, - ); + Ok(()) } diff --git a/src/apps/desktop/src/api/speech_api.rs b/src/apps/desktop/src/api/speech_api.rs index ec58ce4421..fb7b343a94 100644 --- a/src/apps/desktop/src/api/speech_api.rs +++ b/src/apps/desktop/src/api/speech_api.rs @@ -313,7 +313,6 @@ pub async fn speech_save_realtime_config( .set_config("app.voice_call", &config) .await .map_err(|error| format!("Failed to save controller realtime voice settings: {error}"))?; - crate::api::remote_connect_api::notify_settings_changed(); Ok(SpeechRealtimeConfig { enabled: config.enabled, diff --git a/src/apps/desktop/src/embedded_relay_host.rs b/src/apps/desktop/src/embedded_relay_host.rs index efb8d31bd8..d9677fa69b 100644 --- a/src/apps/desktop/src/embedded_relay_host.rs +++ b/src/apps/desktop/src/embedded_relay_host.rs @@ -1,8 +1,8 @@ -//! Desktop-owned embedded relay host for LAN and Ngrok Remote Connect modes. +//! Desktop-owned embedded relay host for LAN Remote Connect modes. use log::{info, warn}; use openbitfun_core::service::remote_connect::embedded_relay_host::EmbeddedRelayHost; -use openbitfun_relay_service::{build_relay_router, MemoryAssetStore, RoomManager}; +use openbitfun_relay_service::{build_relay_router, MemoryAssetStore}; use std::sync::Arc; use tokio::sync::Mutex; @@ -18,7 +18,6 @@ pub(crate) struct DesktopEmbeddedRelayHost { struct EmbeddedRelayRuntime { shutdown: Option>, server_task: Option>, - cleanup_task: Option>, } impl EmbeddedRelayRuntime { @@ -26,10 +25,6 @@ impl EmbeddedRelayRuntime { if let Some(shutdown) = self.shutdown.take() { let _ = shutdown.send(()); } - - if let Some(cleanup_task) = self.cleanup_task.take() { - cleanup_task.abort(); - } } async fn stop(mut self) { @@ -89,17 +84,34 @@ impl EmbeddedRelayHost for DesktopEmbeddedRelayHost { anyhow::anyhow!("failed to bind embedded relay on port {port}: {error}") })?; - let room_manager = RoomManager::new(); + // Each locally hosted Relay owns its account directory and credentials, + // using exactly the same database schema and router as the official host. + #[cfg(not(test))] + let database_path = { + let root = std::env::var_os("OPENBITFUN_HOME") + .or_else(|| std::env::var_os("OPENBITFUN_E2E_HOME")) + .map(std::path::PathBuf::from) + .filter(|path| !path.as_os_str().is_empty()) + .or_else(|| { + dirs::home_dir().map(|home| { + home.join(openbitfun_core_types::product_identity::hidden_data_directory()) + }) + }) + .ok_or_else(|| { + anyhow::anyhow!("Cannot determine product home for the local Relay") + })?; + let directory = root.join("relay-v1.0.0").join("local-server"); + tokio::fs::create_dir_all(&directory).await?; + directory.join("relay.db").to_string_lossy().into_owned() + }; + #[cfg(test)] + let database_path = ":memory:".to_string(); + let database = Arc::new(openbitfun_relay_service::db::connect(&database_path).await?); let asset_store = Arc::new(MemoryAssetStore::new()); let start_time = std::time::Instant::now(); - let mut app = build_relay_router( - room_manager.clone(), - asset_store, - start_time, - None, - env!("CARGO_PKG_VERSION"), - ); + let mut app = + build_relay_router(asset_store, start_time, database, env!("CARGO_PKG_VERSION")); if let Some(dir) = static_dir.as_deref() { info!("Embedded relay: serving static files from {dir}"); @@ -116,14 +128,6 @@ impl EmbeddedRelayHost for DesktopEmbeddedRelayHost { info!("Embedded relay started on 0.0.0.0:{port}"); - let cleanup_room_manager = room_manager.clone(); - let cleanup_task = tokio::spawn(async move { - loop { - tokio::time::sleep(std::time::Duration::from_secs(60)).await; - cleanup_room_manager.cleanup_stale_rooms(300); - } - }); - let (shutdown, shutdown_rx) = tokio::sync::oneshot::channel::<()>(); let server_task = tokio::spawn(async move { axum::serve( @@ -138,12 +142,11 @@ impl EmbeddedRelayHost for DesktopEmbeddedRelayHost { }); // Keep the candidate local until readiness completes. If the start - // future is cancelled, Drop aborts both tasks and releases the bound + // future is cancelled, Drop aborts the server task and releases the bound // listener instead of leaving a hidden active runtime in the host. let candidate = EmbeddedRelayRuntime { shutdown: Some(shutdown), server_task: Some(server_task), - cleanup_task: Some(cleanup_task), }; #[cfg(test)] diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index d113f3e0d9..f83fb1f480 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -1270,7 +1270,6 @@ pub async fn run() { // paired bots start listening immediately on app startup. let step_started = Instant::now(); api::remote_connect_api::init_on_startup(); - api::remote_connect_api::init_auto_sync(); startup_trace.record_elapsed_step( "native_setup", "remote_connect_init_on_startup", @@ -1862,38 +1861,26 @@ pub async fn run() { api::remote_connect_api::remote_connect_status, api::remote_connect_api::remote_connect_get_form_state, api::remote_connect_api::remote_connect_set_form_state, - api::remote_connect_api::remote_connect_configure_custom_server, api::remote_connect_api::remote_connect_configure_bot, api::remote_connect_api::remote_connect_weixin_qr_start, api::remote_connect_api::remote_connect_weixin_qr_poll, api::remote_connect_api::remote_connect_get_bot_verbose_mode, api::remote_connect_api::remote_connect_set_bot_verbose_mode, // Account API + api::account_identity_api::account_github_start, + api::account_identity_api::account_github_poll, + api::account_identity_api::account_github_info, api::remote_connect_api::account_login, - api::remote_connect_api::account_finalize_login, - api::remote_connect_api::account_cancel_pending_login, api::remote_connect_api::account_status, api::remote_connect_api::account_logout, api::remote_connect_api::account_connect_devices, api::remote_connect_api::account_online_devices, - api::remote_connect_api::account_send_session_to_device, - api::remote_connect_api::account_sync_session, - api::remote_connect_api::account_fetch_synced_sessions, - api::remote_connect_api::account_delete_synced_session, - api::remote_connect_api::account_sync_settings, - api::remote_connect_api::account_fetch_settings, - api::remote_connect_api::account_export_local_session, - api::remote_connect_api::account_export_all_sessions, - api::remote_connect_api::account_import_remote_sessions, - api::remote_connect_api::account_fetch_session_turns, api::remote_connect_api::account_execute_on_device, - api::remote_connect_api::account_auto_sync, api::remote_connect_api::account_get_credential_hint, api::remote_connect_api::account_token_expired, api::remote_connect_api::account_list_devices, api::remote_connect_api::account_delete_device, api::remote_connect_api::account_device_rpc, - api::remote_connect_api::account_delegate_to_paired, // OpenBitFun Page API api::pages_api::page_publish, api::pages_api::page_save_version, @@ -1953,11 +1940,7 @@ pub async fn run() { api::miniapp_api::miniapp_decline_builtin_update, api::miniapp_market_api::miniapp_market_browse, api::miniapp_market_api::miniapp_market_get_listing, - api::miniapp_market_api::miniapp_market_auth_start, - api::miniapp_market_api::miniapp_market_auth_poll, api::miniapp_market_api::miniapp_market_capture_window, - api::miniapp_market_api::miniapp_market_me, - api::miniapp_market_api::miniapp_market_logout, api::miniapp_market_api::miniapp_market_set_rating, api::miniapp_market_api::miniapp_market_set_favorite, api::miniapp_market_api::miniapp_market_list_submissions, @@ -2068,13 +2051,6 @@ pub async fn run() { api::dispatch_api::dispatch_load_transcript, api::dispatch_api::dispatch_save_transcript, // Relay self-deploy API - api::relay_deploy_api::relay_deploy_preflight, - api::relay_deploy_api::relay_deploy_install_docker, - api::relay_deploy_api::relay_deploy_start, - api::relay_deploy_api::relay_deploy_poll, - api::relay_deploy_api::relay_deploy_cancel, - api::relay_deploy_api::relay_deploy_register, - api::relay_deploy_api::relay_deploy_verify, // Announcement / feature-demo / tips API api::announcement_api::get_pending_announcements, api::announcement_api::mark_announcement_seen, @@ -2463,14 +2439,6 @@ fn configure_workspace_search_daemon_env() -> Option { path } -/// Return the session whose durable metadata must be synchronized for an event. -fn session_changed_id_for_sync(event: &AgenticEvent) -> Option<&str> { - match event { - AgenticEvent::SessionTitleGenerated { session_id, .. } => Some(session_id), - _ => None, - } -} - /// Deliver one event to the WebView and, when peer controllers are attached, /// fan it out to paired devices. Text chunks arrive here already coalesced by /// `TextChunkCoalescer`. @@ -2479,9 +2447,6 @@ async fn deliver_event_to_webview( event: AgenticEvent, session_event_journal: &SessionEventJournal, ) { - if let Some(session_id) = session_changed_id_for_sync(&event) { - api::remote_connect_api::notify_session_changed(session_id, ""); - } let cursor = session_event_journal.record(&event); let Some(mut projected) = openbitfun_events::project_agentic_frontend_event(event) else { log::warn!("Unhandled AgenticEvent type in desktop delivery"); @@ -2939,21 +2904,6 @@ mod event_loop_driver_tests { } } - #[test] - fn session_title_events_request_durable_session_sync() { - let title_event = AgenticEvent::SessionTitleGenerated { - session_id: "renamed-session".to_string(), - title: "Renamed".to_string(), - method: "manual".to_string(), - }; - - assert_eq!( - session_changed_id_for_sync(&title_event), - Some("renamed-session") - ); - assert_eq!(session_changed_id_for_sync(&text_chunk("hello")), None); - } - /// Regression test for the P1 scheduling issue: the window timer is only /// polled at the outer `select!`, so a drain loop that never finds an /// empty queue (sustained producer load) must still honor the deadline diff --git a/src/apps/desktop/src/openbitfun_control_host.rs b/src/apps/desktop/src/openbitfun_control_host.rs index 005eb8598e..8225f6552e 100644 --- a/src/apps/desktop/src/openbitfun_control_host.rs +++ b/src/apps/desktop/src/openbitfun_control_host.rs @@ -338,7 +338,7 @@ async fn rollback_config_option( { apply_backend_config_effects(app, state, &applied.changed_path, &applied.effective_value) .await?; - crate::api::remote_connect_api::notify_settings_changed(); + if option_requires_presentation_commit(option) { synchronize_required_effect( capability_id, @@ -457,9 +457,7 @@ async fn configure_option_transaction( } else { return Err("Product-control option has no executable handler".to_string()); }; - if notify_settings { - crate::api::remote_connect_api::notify_settings_changed(); - } + if notify_settings {} let presentation_sync = if option_requires_presentation_commit(option) { match synchronize_required_effect( @@ -667,7 +665,7 @@ async fn rollback_legacy_config_transaction( apply_legacy_config_mutation(&state.config_service, path, previous_value).await?; apply_backend_config_effects(app, state, path, &rolled_back.effective_value).await?; apply_binding_backend_effects(app, state, &rolled_back.controlled_bindings).await?; - crate::api::remote_connect_api::notify_settings_changed(); + synchronize_legacy_bindings(app, state, &rolled_back.controlled_bindings, "rollback") .await .map(|_| ()) @@ -721,7 +719,6 @@ pub(crate) async fn set_config_from_gui( if let Err(error) = backend_effect_result { return Err(failed_legacy_transaction(app, &state, path, previous_value, error).await); } - crate::api::remote_connect_api::notify_settings_changed(); let presentation_sync = match synchronize_legacy_bindings( app, @@ -848,7 +845,7 @@ async fn select_companion( .set_config("app.ai_experience", &experience) .await .map_err(|error| error.to_string())?; - crate::api::remote_connect_api::notify_settings_changed(); + companion_state(state).await } @@ -974,7 +971,7 @@ async fn execute_desktop_provider_operation( .await .map_err(|error| error.to_string())?; } - crate::api::remote_connect_api::notify_settings_changed(); + let state_value = companion_state(&state).await?; let presentation_sync = emit_applied( "setting.application.pet", diff --git a/src/apps/desktop/src/runtime/mod.rs b/src/apps/desktop/src/runtime/mod.rs index 7c3bd23471..549396aa75 100644 --- a/src/apps/desktop/src/runtime/mod.rs +++ b/src/apps/desktop/src/runtime/mod.rs @@ -280,32 +280,29 @@ mod tests { ); } - for (mutation, end) in [ - ( - "pub async fn account_import_remote_sessions", - "pub async fn account_fetch_session_turns", - ), - ( - "pub async fn account_fetch_session_turns", - "pub async fn account_execute_on_device", - ), - ( - "async fn import_session_bundle", - "async fn pull_and_reconcile", - ), + for retired_mutation in [ + "pub async fn account_import_remote_sessions", + "pub async fn account_fetch_session_turns", + "async fn import_session_bundle", + "async fn pull_and_reconcile", ] { - let source = remote_connect_api - .split_once(mutation) - .unwrap_or_else(|| panic!("missing relay mutation: {mutation}")) - .1 - .split_once(end) - .unwrap_or_else(|| panic!("missing relay mutation boundary: {end}")) - .0; assert!( - source.contains("ensure_workspace_runtime_ownership"), - "relay mutation {mutation} must pass through the Core ownership owner" + !remote_connect_api.contains(retired_mutation), + "retired cloud import {retired_mutation} must not mutate controller sessions" ); } + let device_dispatch = remote_connect_api + .split_once("async fn execute_local_remote_command(") + .expect("device command dispatcher") + .1 + .split_once("#[cfg(test)]") + .expect("device command dispatcher boundary") + .0; + assert!( + device_dispatch.contains("crate::api::peer_host_invoke::dispatch(command, args.clone())") + && device_dispatch.contains("server.dispatch(other).await"), + "device commands must reuse the host and RemoteServer dispatchers that own runtime access" + ); for mutation in [ "pub async fn rollback_session", diff --git a/src/apps/desktop/src/runtime/session_application.rs b/src/apps/desktop/src/runtime/session_application.rs index 9248f2e180..f5b0a7caa6 100644 --- a/src/apps/desktop/src/runtime/session_application.rs +++ b/src/apps/desktop/src/runtime/session_application.rs @@ -273,8 +273,6 @@ fn choose_remote_ssh_host( #[async_trait] pub(crate) trait DesktopSessionHostEffects: Send + Sync { async fn release_session(&self, session_id: &str); - fn notify_session_changed(&self, session_id: &str, workspace_path: &str); - fn notify_session_deleted(&self, session_id: &str); } #[derive(Clone)] @@ -618,7 +616,6 @@ impl DesktopSessionApplication { "At least one session metadata field is required".to_string(), )); } - let workspace_path = request.workspace_path.clone(); let scope = self.resolved_scope(request).await; self.ensure_runtime_ownership(&scope)?; let storage_path = self.storage_path(&scope); @@ -629,8 +626,6 @@ impl DesktopSessionApplication { }) .await .map_err(|error| DesktopSessionApplicationError::Core(error.to_string()))?; - self.host_effects - .notify_session_changed(&session_id, &workspace_path); Ok(()) } @@ -754,8 +749,6 @@ impl DesktopSessionApplication { }) .await .map_err(desktop_runtime_session_error)?; - self.host_effects - .notify_session_changed(&session_id, &scope.workspace_path); return Ok(normalized_title); } @@ -773,7 +766,6 @@ impl DesktopSessionApplication { .update_loaded_session_title(&session_id, &title) .await .map_err(desktop_core_session_error)?; - self.host_effects.notify_session_changed(&session_id, ""); Ok(updated_title) } @@ -941,7 +933,6 @@ async fn delete_session_with_host_effects( }) .await .map_err(|error| DesktopSessionApplicationError::Runtime(error.into_message()))?; - host_effects.notify_session_deleted(&session_id); Ok(()) } @@ -1158,12 +1149,6 @@ mod tests { async fn release_session(&self, _session_id: &str) { self.events.lock().unwrap().push("release"); } - - fn notify_session_changed(&self, _session_id: &str, _workspace_path: &str) {} - - fn notify_session_deleted(&self, _session_id: &str) { - self.events.lock().unwrap().push("relay_delete"); - } } fn delete_test_scope() -> ResolvedDesktopSessionScope { @@ -1378,7 +1363,7 @@ mod tests { assert_eq!( events.lock().unwrap().as_slice(), - ["release", "durable_delete", "relay_delete"] + ["release", "durable_delete"] ); assert_eq!( workspace_path.lock().unwrap().as_deref(), diff --git a/src/apps/desktop/src/runtime/session_host_effects.rs b/src/apps/desktop/src/runtime/session_host_effects.rs index 347fda297c..fe281d3030 100644 --- a/src/apps/desktop/src/runtime/session_host_effects.rs +++ b/src/apps/desktop/src/runtime/session_host_effects.rs @@ -21,12 +21,4 @@ impl DesktopSessionHostEffects for ProductionDesktopSessionHostEffects { service.release_openbitfun_session(session_id).await; } } - - fn notify_session_changed(&self, session_id: &str, workspace_path: &str) { - crate::api::remote_connect_api::notify_session_changed(session_id, workspace_path); - } - - fn notify_session_deleted(&self, session_id: &str) { - crate::api::remote_connect_api::notify_session_deleted(session_id); - } } diff --git a/src/apps/desktop/src/sleep_prevention.rs b/src/apps/desktop/src/sleep_prevention.rs index faee209ddf..ff38906287 100644 --- a/src/apps/desktop/src/sleep_prevention.rs +++ b/src/apps/desktop/src/sleep_prevention.rs @@ -326,7 +326,6 @@ async fn set_prevent_sleep_enabled_impl( ) .await?; - crate::api::remote_connect_api::notify_settings_changed(); Ok(()) } diff --git a/src/apps/desktop/src/tray.rs b/src/apps/desktop/src/tray.rs index 646a6fba73..a43de713a2 100644 --- a/src/apps/desktop/src/tray.rs +++ b/src/apps/desktop/src/tray.rs @@ -149,7 +149,6 @@ async fn tray_toggle_desktop_pet(app: &AppHandle) -> Result<(), String> { }) .await .map_err(|e| e.to_string())?; - crate::api::remote_connect_api::notify_settings_changed(); if show { crate::appearance::show_agent_companion_desktop_pet(app.clone()).await?; diff --git a/src/apps/mobile/README.md b/src/apps/mobile/README.md index 1c0ab9100e..02b2c1f380 100644 --- a/src/apps/mobile/README.md +++ b/src/apps/mobile/README.md @@ -6,6 +6,11 @@ This directory contains the native mobile product surfaces for OpenBitFun: - `ios/`: iOS application code and resources. - `harmonyos/`: HarmonyOS application code and resources. +The mobile apps are remote controllers: GitHub login and the account device +directory select a desktop or CLI host that owns configuration and Agent Runtime +execution. Phones submit tasks and display results; they do not synchronize model +configuration or execute agents locally. + Each platform directory owns its native UI, lifecycle, permissions, packaging, and platform adapters. Product logic and stable contracts should remain in the platform-agnostic Rust layers and be exposed to these apps through explicit diff --git a/src/apps/mobile/android/app/src/androidTest/kotlin/com/openbitfun/mobile/app/MobileScreenTest.kt b/src/apps/mobile/android/app/src/androidTest/kotlin/com/openbitfun/mobile/app/MobileScreenTest.kt index 3cd429e403..901b8b0d82 100644 --- a/src/apps/mobile/android/app/src/androidTest/kotlin/com/openbitfun/mobile/app/MobileScreenTest.kt +++ b/src/apps/mobile/android/app/src/androidTest/kotlin/com/openbitfun/mobile/app/MobileScreenTest.kt @@ -84,7 +84,7 @@ class MobileScreenTest { // the scanner, so both entry modes stay visible behind the closing drawer. waitForText("Choose how to connect") composeRule.onNodeWithText("Scan to connect").assertIsDisplayed() - composeRule.onNodeWithText("Sign in to OpenBitFun account").assertIsDisplayed() + composeRule.onNodeWithText("Sign in with GitHub").assertIsDisplayed() composeRule.onNodeWithTag(SIDEBAR_TEST_TAG).assertIsNotDisplayed() } @@ -106,11 +106,11 @@ class MobileScreenTest { composeRule.onNodeWithTag(MENU_TEST_TAG).performClick() composeRule.onNodeWithTag(SIDEBAR_TEST_TAG).assertIsDisplayed() - val signedOut = composeRule.onAllNodesWithText("Sign in to OpenBitFun account") + val signedOut = composeRule.onAllNodesWithText("Sign in with GitHub") .fetchSemanticsNodes() .isNotEmpty() if (signedOut) { - composeRule.onNodeWithText("Sign in to OpenBitFun account").performClick() + composeRule.onNodeWithText("Sign in with GitHub").performClick() } else { // The signed-in exchange: settings first, and the profile row there // is what leads on to the account. The drawer is over the general @@ -120,7 +120,7 @@ class MobileScreenTest { composeRule.onNodeWithTag(GENERAL_SETTINGS_PROFILE_TEST_TAG).performClick() } - waitForText(if (signedOut) "Sign in to OpenBitFun" else "Account") + waitForText(if (signedOut) "Sign in with GitHub" else "Account") composeRule.onNodeWithTag(SIDEBAR_TEST_TAG).assertIsNotDisplayed() } diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/platform/DeviceInstall.kt b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/platform/DeviceInstall.kt index f4c16c842b..bbb9142ad6 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/platform/DeviceInstall.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/platform/DeviceInstall.kt @@ -2,7 +2,7 @@ package com.openbitfun.mobile.app.platform import android.content.Context import android.os.Build -import com.openbitfun.mobile.core.feature.pairing.DeviceIdentity +import com.openbitfun.mobile.core.feature.account.MobileDeviceIdentity import java.util.UUID private const val PREFS = "openbitfun_install" @@ -21,13 +21,13 @@ internal val LEGACY_MOBILE_DEVICE_NAMES: Set = setOf( * random id per install gives it that without carrying a device fingerprint off * the phone. Clearing app data intentionally produces a new device. */ -internal fun Context.deviceIdentity(): DeviceIdentity { +internal fun Context.deviceIdentity(): MobileDeviceIdentity { val prefs = getSharedPreferences(PREFS, Context.MODE_PRIVATE) val existing = prefs.getString(KEY_INSTALL_ID, null) val installId = existing ?: "android-${UUID.randomUUID()}".also { prefs.edit().putString(KEY_INSTALL_ID, it).apply() } - return DeviceIdentity( + return MobileDeviceIdentity( installId = installId, displayName = Build.MODEL.ifBlank { "Android" }, ) diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/state/AppShellState.kt b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/state/AppShellState.kt index 1b320e70fe..9fb2a39234 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/state/AppShellState.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/state/AppShellState.kt @@ -17,7 +17,6 @@ import androidx.compose.runtime.setValue * displace the conversation the user was reading. */ internal enum class MobileSurface { - GENERAL_CHAT, REMOTE, } @@ -194,7 +193,7 @@ internal class AppShellState( }, restore = { AppShellState( - surface = MobileSurface.valueOf(it[0] as String), + surface = MobileSurface.REMOTE, showSettings = it[1] as Boolean, settingsMode = SettingsMode.valueOf(it[2] as String), showAccount = it[3] as Boolean, @@ -213,7 +212,7 @@ internal class AppShellState( @Composable internal fun rememberAppShellState(): AppShellState = rememberSaveable(saver = AppShellState.Saver) { AppShellState( - surface = MobileSurface.GENERAL_CHAT, + surface = MobileSurface.REMOTE, showSettings = false, settingsMode = SettingsMode.GENERAL, showAccount = false, diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/account/AccountScreen.kt b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/account/AccountScreen.kt index 566004cc4b..599961a697 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/account/AccountScreen.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/account/AccountScreen.kt @@ -72,10 +72,10 @@ internal fun AccountScreen( AccountUiState.Idle, AccountUiState.Restoring -> Box(modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator() } - AccountUiState.SigningIn, AccountUiState.SignedOut, is AccountUiState.Failed -> AccountLoginPage( + AccountUiState.SigningIn, AccountUiState.SignedOut, is AccountUiState.Authorizing, is AccountUiState.Failed -> AccountLoginPage( state = current, onBack = onBack, - onLogin = { relay, username, password -> viewModel.dispatch(AccountIntent.Login(relay, username, password)) }, + onLogin = { viewModel.dispatch(AccountIntent.Login) }, modifier = modifier, ) is AccountUiState.Ready -> AccountProfilePage( @@ -93,15 +93,12 @@ internal fun AccountScreen( private fun AccountLoginPage( state: AccountUiState, onBack: () -> Unit, - onLogin: (String, String, String) -> Unit, + onLogin: () -> Unit, modifier: Modifier, ) { - var relayUrl by rememberSaveable { mutableStateOf("https://remote.openbitfun.com/relay") } - var username by rememberSaveable { mutableStateOf("") } - var password by rememberSaveable { mutableStateOf("") } - var passwordVisible by rememberSaveable { mutableStateOf(false) } - val busy = state is AccountUiState.SigningIn - val canSubmit = relayUrl.isNotBlank() && username.isNotBlank() && password.isNotEmpty() && !busy + val busy = state is AccountUiState.SigningIn || state is AccountUiState.Authorizing + val canSubmit = !busy + val uriHandler = androidx.compose.ui.platform.LocalUriHandler.current Box(modifier.fillMaxSize()) { Column( modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()) @@ -109,35 +106,17 @@ private fun AccountLoginPage( ) { Text(stringResource(R.string.account_login_title), fontSize = 32.sp, lineHeight = 38.sp, fontWeight = FontWeight.Bold) Text(stringResource(R.string.account_login_body), fontSize = 15.sp, lineHeight = 22.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 12.dp, bottom = 42.dp)) - AccountInput(username, stringResource(R.string.account_username_placeholder), { username = it }, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next)) - AccountInput( - password, - stringResource(R.string.account_password_placeholder), - { password = it }, - visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(), - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Next), - trailing = { - IconButton(onClick = { passwordVisible = !passwordVisible }) { - Icon(painterResource(if (passwordVisible) R.drawable.ic_symbol_eye else R.drawable.ic_symbol_eye_slash), contentDescription = null, modifier = Modifier.size(24.dp)) - } - }, - modifier = Modifier.padding(top = 14.dp), - ) - Text(stringResource(R.string.account_login_server), fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(start = 4.dp, top = 26.dp, bottom = 8.dp)) - AccountInput( - relayUrl, - stringResource(R.string.account_relay_url_placeholder), - { relayUrl = it }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Done), - keyboardActions = KeyboardActions(onDone = { if (canSubmit) onLogin(relayUrl, username, password) }), - modifier = Modifier.height(52.dp), - ) + (state as? AccountUiState.Authorizing)?.let { authorization -> + Button(onClick = { uriHandler.openUri(authorization.authorizationUrl) }, modifier = Modifier.fillMaxWidth()) { + Text(stringResource(R.string.account_open_github)) + } + } (state as? AccountUiState.Failed)?.let { failure -> Text(stringResource(failure.reason.messageRes()), color = MaterialTheme.colorScheme.error, fontSize = 13.sp, lineHeight = 19.sp, modifier = Modifier.padding(top = 12.dp)) } Spacer(Modifier.height(if (state is AccountUiState.Failed) 22.dp else 30.dp)) Button( - onClick = { onLogin(relayUrl, username, password) }, + onClick = onLogin, enabled = canSubmit, shape = RoundedCornerShape(18.dp), colors = ButtonDefaults.buttonColors( @@ -153,43 +132,6 @@ private fun AccountLoginPage( } } -@Composable -private fun AccountInput( - value: String, - placeholder: String, - onValueChange: (String) -> Unit, - modifier: Modifier = Modifier, - visualTransformation: VisualTransformation = VisualTransformation.None, - keyboardOptions: KeyboardOptions = KeyboardOptions.Default, - keyboardActions: KeyboardActions = KeyboardActions.Default, - trailing: (@Composable (() -> Unit))? = null, -) { - TextField( - value = value, - onValueChange = onValueChange, - placeholder = { Text(placeholder, fontSize = 17.sp) }, - singleLine = true, - visualTransformation = visualTransformation, - keyboardOptions = keyboardOptions, - keyboardActions = keyboardActions, - trailingIcon = trailing, - colors = TextFieldDefaults.colors( - focusedContainerColor = MaterialTheme.colorScheme.surface, - unfocusedContainerColor = MaterialTheme.colorScheme.surface, - disabledContainerColor = MaterialTheme.colorScheme.surface, - focusedIndicatorColor = openBitFunColors.transparent, - unfocusedIndicatorColor = openBitFunColors.transparent, - cursorColor = MaterialTheme.colorScheme.onSurface, - focusedTextColor = MaterialTheme.colorScheme.onSurface, - unfocusedTextColor = MaterialTheme.colorScheme.onSurface, - focusedPlaceholderColor = MaterialTheme.colorScheme.onSurfaceVariant, - unfocusedPlaceholderColor = MaterialTheme.colorScheme.onSurfaceVariant, - ), - shape = RoundedCornerShape(18.dp), - modifier = modifier.fillMaxWidth().height(58.dp), - ) -} - @Composable private fun AccountProfilePage( state: AccountUiState.Ready, diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/remote/ConnectView.kt b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/remote/ConnectView.kt index 348457b6dc..d35c7f363a 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/remote/ConnectView.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/remote/ConnectView.kt @@ -58,9 +58,6 @@ import androidx.compose.ui.unit.sp import com.openbitfun.mobile.app.R import com.openbitfun.mobile.app.ui.common.SignedOutConnectionActions import com.openbitfun.mobile.app.ui.theme.openBitFunColors -import com.openbitfun.mobile.core.feature.pairing.PairingIntent -import com.openbitfun.mobile.core.feature.pairing.PairingUiState -import com.openbitfun.mobile.core.feature.pairing.inspectPairingLink import com.google.mlkit.vision.barcode.common.Barcode import com.google.mlkit.vision.codescanner.GmsBarcodeScannerOptions import com.google.mlkit.vision.codescanner.GmsBarcodeScanning @@ -82,9 +79,7 @@ internal const val CONNECT_SUBMIT_TEST_TAG: String = "connect-submit" */ @Composable internal fun ConnectView( - state: PairingUiState, - onSubmit: (PairingIntent.Submit) -> Unit, - onDismiss: () -> Unit, + onSubmit: (String) -> Unit, onBack: () -> Unit, onOpenAccount: () -> Unit, startScanning: Boolean = false, @@ -94,22 +89,8 @@ internal fun ConnectView( var manual by rememberSaveable { mutableStateOf(false) } var scanning by rememberSaveable { mutableStateOf(startScanning) } var url by rememberSaveable { mutableStateOf("") } - var userId by rememberSaveable { mutableStateOf("") } - // Never rememberSaveable: a password must not reach saved instance state. - var password by remember { mutableStateOf("") } var scanFailed by rememberSaveable { mutableStateOf(false) } - // Ports `ConnectionErrorResult.shouldShowRemoteUrlInput`: a link that is - // itself at fault puts the field back on screen, because the scan button - // that got the user here cannot fix a link that has expired. Keyed on the - // state rather than run on every recomposition, so tapping back out of the - // form while the failure is still showing stays out. - LaunchedEffect(state) { - if (state is PairingUiState.Failed && state.failure.reopensLinkInput) manual = true - } - - val hints = remember(url) { inspectPairingLink(url) } - val connecting = state is PairingUiState.Connecting val context = LocalContext.current val scanner = remember(context) { GmsBarcodeScanning.getClient( @@ -127,14 +108,7 @@ internal fun ConnectView( val scanned = barcode.rawValue.orEmpty().trim() if (scanned.isNotEmpty()) { url = scanned - // A room that wants an account cannot be entered from the - // code alone, so the scan hands over to the form instead of - // failing a connect the user did not know needed a password. - if (inspectPairingLink(scanned).requiresAccount) { - manual = true - } else { - onSubmit(PairingIntent.Submit(scanned, userId, "")) - } + onSubmit(scanned) } } .addOnFailureListener { scanFailed = true } @@ -158,19 +132,17 @@ internal fun ConnectView( if (scanning) { ScanPairing( scanFailed = scanFailed, - connecting = connecting, + connecting = false, onBack = { scanning = false }, onManual = { manual = true }, ) } else { IntroPairing( - state = state, - connecting = connecting, + connecting = false, onScan = { scanning = true scan() }, - onDismiss = onDismiss, onBack = onBack, onOpenAccount = onOpenAccount, ) @@ -178,19 +150,11 @@ internal fun ConnectView( } if (manual) { ManualPairing( - state = state, url = url, - userId = userId, - password = password, - requiresAccount = hints.requiresAccount, - suggestedUserId = hints.suggestedUserId, - connecting = connecting, + connecting = false, onUrlChange = { url = it }, - onUserIdChange = { userId = it }, - onPasswordChange = { password = it }, onBack = { manual = false }, - onDismiss = onDismiss, - onSubmit = { onSubmit(PairingIntent.Submit(url, userId, password)) }, + onSubmit = { onSubmit(url) }, modifier = Modifier.fillMaxSize(), ) } @@ -199,10 +163,8 @@ internal fun ConnectView( @Composable private fun ColumnScope.IntroPairing( - state: PairingUiState, connecting: Boolean, onScan: () -> Unit, - onDismiss: () -> Unit, onBack: () -> Unit, onOpenAccount: () -> Unit, ) { @@ -243,9 +205,6 @@ private fun ColumnScope.IntroPairing( fontWeight = FontWeight.Bold, textAlign = TextAlign.Center, ) - if (state is PairingUiState.Failed) { - PairingFailureCard(state, onDismiss) - } } SignedOutConnectionActions( @@ -323,23 +282,14 @@ private fun ColumnScope.ScanPairing( @Composable private fun ManualPairing( - state: PairingUiState, url: String, - userId: String, - password: String, - requiresAccount: Boolean, - suggestedUserId: String, connecting: Boolean, onUrlChange: (String) -> Unit, - onUserIdChange: (String) -> Unit, - onPasswordChange: (String) -> Unit, onBack: () -> Unit, - onDismiss: () -> Unit, onSubmit: () -> Unit, modifier: Modifier, ) { - val canSubmit = url.isNotBlank() && !connecting && - (!requiresAccount || ((userId.ifBlank { suggestedUserId }).isNotBlank() && password.isNotBlank())) + val canSubmit = url.isNotBlank() && !connecting val consumeTouches = remember { MutableInteractionSource() } Box( modifier = modifier @@ -387,36 +337,6 @@ private fun ManualPairing( enabled = !connecting, testTag = CONNECT_PAIRING_CODE_TEST_TAG, ) - if (requiresAccount) { - PairingPillField( - value = userId, - onValueChange = onUserIdChange, - placeholder = suggestedUserId.ifBlank { stringResource(R.string.pairing_user_label) }, - height = 56.dp, - fontSize = 18.sp, - enabled = !connecting, - ) - PairingPillField( - value = password, - onValueChange = onPasswordChange, - placeholder = stringResource(R.string.pairing_password_label), - height = 56.dp, - fontSize = 18.sp, - keyboardType = KeyboardType.Password, - visualTransformation = PasswordVisualTransformation(), - enabled = !connecting, - ) - Text( - stringResource(R.string.pairing_password_hint), - fontSize = 13.sp, - lineHeight = 18.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.fillMaxWidth(), - ) - } - if (state is PairingUiState.Failed) { - PairingFailureCard(state, onDismiss) - } Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp), @@ -608,27 +528,3 @@ private fun Centered(text: String, fontSize: androidx.compose.ui.unit.TextUnit = modifier = Modifier.fillMaxWidth(0.84f), ) } - -@Composable -private fun PairingFailureCard(state: PairingUiState.Failed, onDismiss: () -> Unit) { - Card(modifier = Modifier.fillMaxWidth()) { - Column( - modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Text( - state.failure.message(), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.error, - ) - // The desktop's own wording, shown under our heading rather than - // instead of it: it is written by the peer and is not localized. - state.failure.remoteMessage?.let { - Text(it, style = MaterialTheme.typography.bodySmall) - } - TextButton(onClick = onDismiss) { - Text(stringResource(R.string.pairing_dismiss)) - } - } - } -} diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/remote/PairingScreen.kt b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/remote/PairingScreen.kt index a4022c78e1..c07bce6a0c 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/remote/PairingScreen.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/remote/PairingScreen.kt @@ -37,14 +37,8 @@ import com.openbitfun.mobile.app.R import com.openbitfun.mobile.app.ui.chat.ConversationView import com.openbitfun.mobile.app.ui.common.CircleControl import com.openbitfun.mobile.app.ui.shell.MENU_TEST_TAG -import com.openbitfun.mobile.app.viewmodel.PairingViewModel import com.openbitfun.mobile.core.feature.connection.ConnectionPhase import com.openbitfun.mobile.core.feature.layout.SettingsPlacement -import com.openbitfun.mobile.core.feature.connection.connectionPhase -import com.openbitfun.mobile.core.feature.pairing.ConnectionLiveness -import com.openbitfun.mobile.core.feature.pairing.PairedWorkspace -import com.openbitfun.mobile.core.feature.pairing.PairingIntent -import com.openbitfun.mobile.core.feature.pairing.PairingUiState import com.openbitfun.mobile.core.feature.session.ConversationHeaderPresenter import com.openbitfun.mobile.core.feature.session.RemoteSessionUiState import com.openbitfun.mobile.core.feature.workspace.RemoteWorkspaceIntent @@ -68,6 +62,7 @@ internal fun PairingScreen( onOpenSidebar: (() -> Unit)? = null, onBack: () -> Unit = {}, onOpenAccount: () -> Unit = {}, + onDeviceLink: (String) -> Unit = {}, compact: Boolean = true, requestedSessionId: String? = null, creatingSession: Boolean = false, @@ -76,65 +71,15 @@ internal fun PairingScreen( onRemoteHome: () -> Unit = {}, startScanning: Boolean = false, onScanStarted: () -> Unit = {}, - viewModel: PairingViewModel = viewModel(factory = PairingViewModel.Factory), ) { - val state by viewModel.state.collectAsStateWithLifecycle() - val remoteState by viewModel.remoteState.collectAsStateWithLifecycle() - val workspaceState by viewModel.workspaceState.collectAsStateWithLifecycle() - // The heartbeat runs only while this surface is both composed and resumed: - // a ping every fifteen seconds from a backgrounded app buys nothing and - // costs a wake-up, and coming back is exactly when the answer is stale. - LifecycleResumeEffect(viewModel) { - viewModel.dispatch(PairingIntent.Foreground) - onPauseOrDispose { viewModel.dispatch(PairingIntent.Background) } - } - - when (val current = state) { - is PairingUiState.Paired -> { - RemoteConnectedScreen( - remoteState = remoteState, - workspaceState = workspaceState, - phase = current.connectionPhase(), - settingsPlacement = settingsPlacement, - sessionDetailsPlacement = sessionDetailsPlacement, - viewSettingsPlacement = viewSettingsPlacement, - onOpenRemoteSettings = onOpenRemoteSettings, - deviceId = current.workspace.roomLabel, - createDevices = emptyList(), - desktopName = "", - onCreateDevicePick = {}, - onSessionIntent = viewModel::dispatchSession, - onWorkspaceIntent = viewModel::dispatchWorkspace, - onOpenSidebar = onOpenSidebar, - compact = compact, - requestedSessionId = requestedSessionId, - creatingSession = creatingSession, - onOpenSession = onOpenSession, - onCreateSession = onCreateSession, - onRemoteHome = onRemoteHome, - connectionDetails = { - PairedDetails( - workspace = current.workspace, - liveness = current.liveness, - onVerify = { viewModel.dispatch(PairingIntent.Verify) }, - onDisconnect = { viewModel.dispatch(PairingIntent.Disconnect) }, - ) - }, - modifier = modifier, - ) - } - - else -> ConnectView( - state = current, - onSubmit = viewModel::dispatch, - onDismiss = { viewModel.dispatch(PairingIntent.Dismiss) }, - onBack = onBack, - onOpenAccount = onOpenAccount, - startScanning = startScanning, - onScanStarted = onScanStarted, - modifier = modifier, - ) - } + ConnectView( + onSubmit = onDeviceLink, + onBack = onBack, + onOpenAccount = onOpenAccount, + startScanning = startScanning, + onScanStarted = onScanStarted, + modifier = modifier, + ) } /** The account-device route, which bypasses the QR pairing form entirely. */ @@ -507,61 +452,3 @@ internal fun RemoteWorkspacePanel( } } } - - -internal const val CONNECTION_RETRY_TEST_TAG: String = "connection-retry" - -@Composable -internal fun PairedDetails( - workspace: PairedWorkspace, - liveness: ConnectionLiveness, - onVerify: () -> Unit, - onDisconnect: () -> Unit, -) { - Text(stringResource(R.string.paired_title), style = MaterialTheme.typography.headlineSmall) - Text( - stringResource(R.string.paired_room, workspace.roomLabel), - style = MaterialTheme.typography.bodyMedium, - ) - Text( - if (workspace.hasWorkspace && workspace.projectName != null) { - stringResource(R.string.paired_project, workspace.projectName!!) - } else { - stringResource(R.string.paired_no_workspace) - }, - style = MaterialTheme.typography.bodyMedium, - ) - workspace.authenticatedUserId?.let { - Text( - stringResource(R.string.paired_user, it), - style = MaterialTheme.typography.bodyMedium, - ) - } - // A desktop that stopped answering has not un-paired: the room, its key and - // its transport are all still here, so the way out is another ping rather - // than the connect form. Re-pairing is a separate, manual act because an - // account room's password is never kept. - when (liveness) { - ConnectionLiveness.LIVE -> Unit - ConnectionLiveness.CHECKING -> Text( - stringResource(R.string.connection_checking), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - - ConnectionLiveness.LOST -> { - Text( - stringResource(R.string.connection_lost_detail), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.error, - ) - TextButton( - onClick = onVerify, - modifier = Modifier.testTag(CONNECTION_RETRY_TEST_TAG), - ) { Text(stringResource(R.string.connection_check_again)) } - } - } - Button(onClick = onDisconnect, modifier = Modifier.fillMaxWidth()) { - Text(stringResource(R.string.pairing_disconnect)) - } -} diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/remote/PairingStrings.kt b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/remote/PairingStrings.kt deleted file mode 100644 index 8a39247dd1..0000000000 --- a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/remote/PairingStrings.kt +++ /dev/null @@ -1,42 +0,0 @@ -package com.openbitfun.mobile.app.ui.remote - -import androidx.compose.runtime.Composable -import androidx.compose.ui.res.pluralStringResource -import androidx.compose.ui.res.stringResource -import com.openbitfun.mobile.app.R -import com.openbitfun.mobile.core.feature.pairing.PairingFailure -import com.openbitfun.mobile.core.feature.pairing.PairingFailureReason - -/** - * The whole localization seam, in one `when`. - * - * The core reports a cause and the app decides the wording, which is why this - * mapping is exhaustive without an `else`: a reason added upstream breaks the - * build here rather than silently rendering as blank. - * - * It resolves the string rather than returning an id because one of the reasons - * needs a quantity — [PairingFailureReason.TooManyAttempts] carries how long the - * cooldown still has to run, and "try again in 1 seconds" is not a sentence. - */ -@Composable -internal fun PairingFailure.message(): String = when (reason) { - PairingFailureReason.PairingLinkEmpty -> stringResource(R.string.failure_link_empty) - PairingFailureReason.PairingLinkIncomplete -> stringResource(R.string.failure_link_incomplete) - PairingFailureReason.PairingLinkUndecodable -> stringResource(R.string.failure_link_undecodable) - PairingFailureReason.PairingLinkKeyUnusable -> stringResource(R.string.failure_link_key_unusable) - PairingFailureReason.AccountUsernameRequired -> stringResource(R.string.failure_account_username) - PairingFailureReason.AccountPasswordRequired -> stringResource(R.string.failure_account_password) - PairingFailureReason.Rejected -> stringResource(R.string.failure_rejected) - PairingFailureReason.RoomNotFound -> stringResource(R.string.failure_room_not_found) - PairingFailureReason.RateLimited -> stringResource(R.string.failure_rate_limited) - PairingFailureReason.RelayUnavailable -> stringResource(R.string.failure_relay_unavailable) - PairingFailureReason.NetworkUnreachable -> stringResource(R.string.failure_network) - PairingFailureReason.Timeout -> stringResource(R.string.failure_timeout) - PairingFailureReason.ProtocolMismatch -> stringResource(R.string.failure_protocol) - PairingFailureReason.DesktopRejected -> stringResource(R.string.failure_desktop_rejected) - PairingFailureReason.TooManyAttempts -> pluralStringResource( - R.plurals.failure_too_many_attempts, - retryAfterSeconds, - retryAfterSeconds, - ) -} diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/settings/CurrentControlCard.kt b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/settings/CurrentControlCard.kt index 8954106eaf..78c117bb90 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/settings/CurrentControlCard.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/settings/CurrentControlCard.kt @@ -134,7 +134,6 @@ internal fun CurrentControlCard( } private fun RemoteControlSource.sourceLabelRes(): Int = when (this) { - RemoteControlSource.QR_PAIRING -> R.string.remote_settings_source_qr RemoteControlSource.ACCOUNT_DEVICE -> R.string.remote_settings_source_account_device RemoteControlSource.NONE -> R.string.remote_settings_source_none } diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/settings/GeneralSettingsScreen.kt b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/settings/GeneralSettingsScreen.kt index 29caf19c49..b5fec0ecef 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/settings/GeneralSettingsScreen.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/settings/GeneralSettingsScreen.kt @@ -45,67 +45,27 @@ import com.openbitfun.mobile.app.R import com.openbitfun.mobile.app.platform.AppLocale import com.openbitfun.mobile.app.platform.AppLocaleController import com.openbitfun.mobile.app.ui.theme.generated.MobileDesignGeometry -import com.openbitfun.mobile.core.feature.generalchat.GeneralChatConfigFailure -import com.openbitfun.mobile.core.feature.generalchat.GeneralChatConfigUi -import com.openbitfun.mobile.core.feature.generalchat.GeneralChatConnectionTestUi -import com.openbitfun.mobile.core.feature.generalchat.GeneralChatIntent -import com.openbitfun.mobile.core.feature.generalchat.GeneralChatModelUi internal const val GENERAL_SETTINGS_TEST_TAG: String = "general-settings" internal const val GENERAL_SETTINGS_PROFILE_TEST_TAG: String = "general-settings-profile" internal const val GENERAL_SETTINGS_MODEL_TEST_TAG: String = "general-settings-model" internal const val GENERAL_SETTINGS_CLOSE_TEST_TAG: String = "general-settings-close" -/** - * The app's own settings page, ported from `pages/components/SettingsSheet.ets`. - * - * The counterpart of [SettingsScreen]. The source's sidebar gear always opens - * this root page; remote-control settings is reached by its own remote action. - * This page is about the phone — who is signed in, which model the app talks to - * on its own, and what build this is — and mentions no desktop anywhere. - * - * Its chrome is deliberately not the remote page's. The source rounds these cards - * at 8 rather than 24 and left-aligns the title rather than centring it: this page - * is a list of settings, and that page is a report on one connection. - * - * @param accountUsername what the row says underneath "Profile", falling back to - * whether anyone is signed in at all when the session has no name to give — - * `this.accountUsername || (this.authenticatedUserId.length > 0 ? … : …)`. - * @param accountUserId only whether it is blank, which is that fallback's - * question. The account surface behind the row loads its own store. - * @param config the general-chat provider, shown as the model row's value and - * edited in the panel the row opens. - * @param connectionTest belongs to that panel rather than to this page, and is - * threaded through because the panel is drawn over this one, as does - * [onSaveConfig] — whose Boolean is the panel's own "was that accepted". - */ +/** Phone preferences; model configuration belongs to the controlled host. */ @Composable internal fun GeneralSettingsScreen( modifier: Modifier, accountUserId: String?, accountUsername: String, - config: GeneralChatConfigUi, - models: List, - activeModelId: String, - configFailure: GeneralChatConfigFailure?, - connectionTest: GeneralChatConnectionTestUi, - onChatIntent: (GeneralChatIntent) -> Unit, - onSaveConfig: (GeneralChatIntent.SaveConfig) -> Boolean, onOpenAccount: () -> Unit, onClose: () -> Unit, ) { - // The provider editor covers this page rather than opening beside it, the way - // `if (this.showModelService) { this.ModelServicePanel() }` stacks it over the - // settings column. A second bottom sheet on top of this one would be a sheet - // over a sheet, which Compose will draw and no phone can make sense of. - var showModelService by rememberSaveable { mutableStateOf(false) } var showLanguagePicker by rememberSaveable { mutableStateOf(false) } val context = LocalContext.current val selectedLocale = AppLocaleController.current(LocalConfiguration.current) - BackHandler(enabled = showLanguagePicker || showModelService) { + BackHandler(enabled = showLanguagePicker) { showLanguagePicker = false - showModelService = false } Box(modifier = modifier.fillMaxSize().testTag(GENERAL_SETTINGS_TEST_TAG)) { @@ -159,22 +119,7 @@ internal fun GeneralSettingsScreen( }, onClick = { showLanguagePicker = true }, ) - HorizontalDivider( - modifier = Modifier.fillMaxWidth(0.84f).align(Alignment.CenterHorizontally), - ) - GeneralSettingsRow( - icon = R.drawable.ic_symbol_square_grid_2x2, - title = stringResource(R.string.model_service_title), - // `modelServiceStatus()`: the model that would answer, which - // is not the local form's model name — with no local model - // configured, an account model is still an answer, and the - // row would otherwise read "not configured" beside a chat - // that works. - value = models.firstOrNull { it.id == activeModelId }?.label - ?: stringResource(R.string.model_service_not_configured), - onClick = { showModelService = true }, - modifier = Modifier.testTag(GENERAL_SETTINGS_MODEL_TEST_TAG), - ) + } } @@ -235,26 +180,7 @@ internal fun GeneralSettingsScreen( ) } - if (showModelService) { - // Opaque and full-bleed rather than a card floating on the settings - // column: it is the only thing to interact with while it is up, and - // letting the rows behind it show through would invite a tap that - // lands on a page it is covering. Its own header carries the way out, - // so this page adds no chrome of its own. - ModelServiceScreen( - config = config, - models = models, - activeModelId = activeModelId, - failure = configFailure, - connectionTest = connectionTest, - onIntent = onChatIntent, - onSave = onSaveConfig, - onClose = { showModelService = false }, - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.background), - ) - } + } } diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/shell/MobileScreen.kt b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/shell/MobileScreen.kt index aca2c7e39a..59fd05131d 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/shell/MobileScreen.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/shell/MobileScreen.kt @@ -1,6 +1,5 @@ package com.openbitfun.mobile.app.ui.shell -import android.content.Intent import androidx.activity.compose.BackHandler import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -34,7 +33,6 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource @@ -48,7 +46,6 @@ import com.openbitfun.mobile.app.state.MobileSurface import com.openbitfun.mobile.app.state.SettingsMode import com.openbitfun.mobile.app.state.rememberAppShellState import com.openbitfun.mobile.app.ui.account.AccountScreen -import com.openbitfun.mobile.app.ui.chat.GeneralChatScreen import com.openbitfun.mobile.app.ui.common.AdaptiveModalSurface import com.openbitfun.mobile.app.ui.remote.AccountRemoteScreen import com.openbitfun.mobile.app.ui.remote.ConnectAccountDeviceScreen @@ -58,27 +55,20 @@ import com.openbitfun.mobile.app.ui.settings.GeneralSettingsScreen import com.openbitfun.mobile.app.ui.settings.SettingsScreen import com.openbitfun.mobile.app.ui.shell.sidebar.AppSidebar import com.openbitfun.mobile.app.viewmodel.AccountViewModel -import com.openbitfun.mobile.app.viewmodel.GeneralChatViewModel -import com.openbitfun.mobile.app.viewmodel.PairingViewModel import com.openbitfun.mobile.core.feature.account.AccountIntent import com.openbitfun.mobile.core.feature.account.AccountUiState import com.openbitfun.mobile.core.feature.connection.ConnectionPhase import com.openbitfun.mobile.core.feature.connection.RemoteControlPresenter import com.openbitfun.mobile.core.feature.connection.RemoteControlSource import com.openbitfun.mobile.core.feature.connection.allowsRemoteCommands -import com.openbitfun.mobile.core.feature.connection.connectionPhase -import com.openbitfun.mobile.core.feature.generalchat.GeneralChatIntent import com.openbitfun.mobile.core.feature.layout.ConversationLayoutPolicy import com.openbitfun.mobile.core.feature.layout.AdaptiveLayoutInput import com.openbitfun.mobile.core.feature.layout.FilePreviewPlacement import com.openbitfun.mobile.core.feature.layout.FilePreviewPlacementPolicy import com.openbitfun.mobile.core.feature.layout.SettingsPlacementPolicy import com.openbitfun.mobile.core.feature.layout.SettingsSheetKind -import com.openbitfun.mobile.core.feature.pairing.PairingIntent -import com.openbitfun.mobile.core.feature.pairing.PairingUiState import com.openbitfun.mobile.core.feature.session.RemoteSessionUiState import com.openbitfun.mobile.core.feature.session.RemoteSessionIntent -import com.openbitfun.mobile.core.feature.shell.SidebarSessionRow import com.openbitfun.mobile.core.feature.workspace.RemoteFilePreviewUiState import com.openbitfun.mobile.core.feature.workspace.RemoteFileDownloadUiState import com.openbitfun.mobile.core.feature.workspace.RemoteWorkspaceIntent @@ -137,35 +127,44 @@ internal fun MobileScreen() { var compactDrawerOpen by rememberSaveable { mutableStateOf(false) } val shell = rememberAppShellState() - val pairingViewModel: PairingViewModel = viewModel(factory = PairingViewModel.Factory) val accountViewModel: AccountViewModel = viewModel(factory = AccountViewModel.Factory) - val generalChatViewModel: GeneralChatViewModel = - viewModel(factory = GeneralChatViewModel.Factory) - val pairingState by pairingViewModel.state.collectAsStateWithLifecycle() - val pairingWorkspaceState by pairingViewModel.workspaceState.collectAsStateWithLifecycle() val accountWorkspaceState by accountViewModel.workspaceState.collectAsStateWithLifecycle() val accountState by accountViewModel.state.collectAsStateWithLifecycle() - val pairingRemoteState by pairingViewModel.remoteState.collectAsStateWithLifecycle() val accountRemoteState by accountViewModel.remoteState.collectAsStateWithLifecycle() val accountPhase by accountViewModel.connectionPhase.collectAsStateWithLifecycle() - val generalChatState by generalChatViewModel.state.collectAsStateWithLifecycle() - val pairingPhase: ConnectionPhase = pairingState.connectionPhase() val readyAccount = accountState as? AccountUiState.Ready - val accountUserId = readyAccount?.userId - LaunchedEffect(pairingState) { - if (pairingState is PairingUiState.Paired) shell.closeRemoteScanner() + val linkContext = androidx.compose.ui.platform.LocalContext.current + var pendingDeviceLink by rememberSaveable { mutableStateOf(null) } + val connectDeviceLink: (String) -> Unit = { url -> + val result = com.openbitfun.mobile.core.feature.account.resolveAccountDeviceLink(url, accountState) + when (result.status) { + com.openbitfun.mobile.core.feature.account.AccountDeviceLinkStatus.READY -> { + pendingDeviceLink = null + accountViewModel.selectDevice(result.deviceId!!) + shell.closeRemoteScanner() + } + com.openbitfun.mobile.core.feature.account.AccountDeviceLinkStatus.SIGN_IN_REQUIRED -> { + pendingDeviceLink = url + accountViewModel.dispatch(com.openbitfun.mobile.core.feature.account.AccountIntent.SelectRelay(result.relayUrl!!)) + shell.closeRemoteScanner() + shell.openAccount() + } + else -> { + pendingDeviceLink = null + android.widget.Toast.makeText(linkContext, + linkContext.getString(if (result.status == com.openbitfun.mobile.core.feature.account.AccountDeviceLinkStatus.INVALID) + R.string.account_device_link_invalid else R.string.account_device_link_unavailable), + android.widget.Toast.LENGTH_LONG).show() + } + } + } + LaunchedEffect(readyAccount) { + if (readyAccount != null) pendingDeviceLink?.let(connectDeviceLink) } - // Which desktop this phone is driving is the one fact neither store holds on - // its own: the pairing store knows a room, the account store knows a device, - // and only together do they make one connection with a provenance. The - // shared presenter decides which of the two wins, so the sheet renders it - // rather than working it out a second time. - val controlSummary = remember(pairingState, pairingPhase, readyAccount, accountPhase) { + val accountUserId = readyAccount?.userId + val controlSummary = remember(readyAccount, accountPhase) { RemoteControlPresenter.summarize( - pairingPhase = pairingPhase, - pairedRoomLabel = (pairingState as? PairingUiState.Paired) - ?.workspace?.roomLabel.orEmpty(), accountDeviceId = readyAccount?.selectedDeviceId.orEmpty(), accountDeviceName = readyAccount?.selectedDeviceName.orEmpty(), accountPhase = accountPhase, @@ -173,19 +172,16 @@ internal fun MobileScreen() { } val phase = controlSummary.phase val activeWorkspaceState = when (controlSummary.source) { - RemoteControlSource.QR_PAIRING -> pairingWorkspaceState RemoteControlSource.ACCOUNT_DEVICE -> accountWorkspaceState RemoteControlSource.NONE -> RemoteWorkspaceUiState.Idle } val activeRemoteState = when (controlSummary.source) { - RemoteControlSource.QR_PAIRING -> pairingRemoteState RemoteControlSource.ACCOUNT_DEVICE -> accountRemoteState RemoteControlSource.NONE -> RemoteSessionUiState.Idle } fun dispatchActiveWorkspace(intent: RemoteWorkspaceIntent) { when (controlSummary.source) { - RemoteControlSource.QR_PAIRING -> pairingViewModel.dispatchWorkspace(intent) RemoteControlSource.ACCOUNT_DEVICE -> accountViewModel.dispatchWorkspace(intent) RemoteControlSource.NONE -> Unit } @@ -193,64 +189,11 @@ internal fun MobileScreen() { fun dispatchActiveSession(intent: RemoteSessionIntent) { when (controlSummary.source) { - RemoteControlSource.QR_PAIRING -> pairingViewModel.dispatchSession(intent) RemoteControlSource.ACCOUNT_DEVICE -> accountViewModel.dispatchSession(intent) RemoteControlSource.NONE -> Unit } } - // The export labels belong to whichever surface asked for one, so they are - // read here: the drawer can export a conversation the content area is not - // showing, and the sheet that hands it over is the shell's. - val untitledTitle = stringResource(R.string.sidebar_untitled) - val userLabel = stringResource(R.string.general_chat_role_user) - val assistantLabel = stringResource(R.string.general_chat_role_assistant) - val context = LocalContext.current - - // Handing the export to the share sheet is the platform half of the intent; - // clearing it immediately keeps a rotation from re-opening the chooser. At - // the shell rather than in the chat screen so that exporting from the drawer - // works while the remote surface is the one on screen. - LaunchedEffect(generalChatState.export) { - val export = generalChatState.export ?: return@LaunchedEffect - val share = Intent(Intent.ACTION_SEND).apply { - type = "text/plain" - putExtra(Intent.EXTRA_TITLE, export.title) - putExtra(Intent.EXTRA_SUBJECT, export.title) - putExtra(Intent.EXTRA_TEXT, export.markdown) - } - context.startActivity(Intent.createChooser(share, export.title)) - generalChatViewModel.dispatch(GeneralChatIntent.ClearExport) - } - - // The account's models follow whoever is signed in. Keyed on the user id - // rather than on the whole state so that a device-list refresh does not - // re-fetch the settings blob, and so that signing out hands over null — - // which is what drops the previous account's models rather than leaving one - // user's providers listed for the next. - LaunchedEffect(accountUserId) { - generalChatViewModel.bindCloudSettings( - accountUserId?.let { accountViewModel.cloudSettingsSource() }, - ) - } - - // Handed over whole: which rows are pinned, recent or archived, and which the - // search leaves standing, is `SidebarPresentation`'s answer inside the drawer. - // Filtering here would decide it twice and let the archive count drift. - val sidebarSessions = remember(generalChatState.sessions) { - generalChatState.sessions.map { session -> - SidebarSessionRow( - id = session.id, - title = session.title, - status = session.status, - pinned = session.pinned, - createdAt = session.createdAt, - updatedAt = session.updatedAt, - messageCount = session.messageCount, - ) - } - } - fun closeDrawer() { // A no-op while the sidebar is permanent, which is why the sidebar's // callbacks are the same lambdas in both shapes: only the container that @@ -347,18 +290,11 @@ internal fun MobileScreen() { workspaceState = activeWorkspaceState, remoteActive = shell.surface == MobileSurface.REMOTE, remoteSelectedSessionId = shell.remoteSessionId, - sessions = sidebarSessions, - // Only while general chat is on screen: the highlight names - // what the content area is showing, not what the store last - // opened behind the remote surface. - selectedSessionId = generalChatState.sessionId - .takeIf { shell.surface == MobileSurface.GENERAL_CHAT }, query = shell.sidebarQuery, searchOpen = shell.searchOpen, onQueryChange = shell::search, onToggleSearch = shell::toggleSearch, onScanDesktop = { - pairingViewModel.dispatch(PairingIntent.Disconnect) // The sidebar row opens the choose-connection page, not the // camera: ML Kit's scanner is a full-screen system activity, so // launching it from the drawer would leave the user no way to @@ -399,32 +335,6 @@ internal fun MobileScreen() { shell.closeRemoteSession() closeDrawer() }, - onNewChat = { - generalChatViewModel.dispatch(GeneralChatIntent.NewSession) - shell.show(MobileSurface.GENERAL_CHAT) - closeDrawer() - }, - onOpenSession = { session -> - generalChatViewModel.dispatch(GeneralChatIntent.SelectSession(session.id)) - shell.show(MobileSurface.GENERAL_CHAT) - closeDrawer() - }, - onArchiveSession = { id, archived -> - generalChatViewModel.dispatch(GeneralChatIntent.ArchiveSession(id, archived)) - }, - onExportSession = { session -> - generalChatViewModel.dispatch( - GeneralChatIntent.ExportSession( - session.id, - untitledTitle, - userLabel, - assistantLabel, - ), - ) - }, - onDeleteSession = { id -> - generalChatViewModel.dispatch(GeneralChatIntent.DeleteSession(id)) - }, onDeleteRemoteSession = { id -> dispatchActiveSession(RemoteSessionIntent.DeleteSession(id)) }, onOpenSettings = { // HarmonyOS' `onSidebar.settings` always opens root settings. @@ -458,17 +368,9 @@ internal fun MobileScreen() { ) { insets -> Box(Modifier.padding(insets)) { when (shell.surface) { - MobileSurface.GENERAL_CHAT -> GeneralChatScreen( - modifier = Modifier, - modelServicePlacement = settingsPlacement, - onOpenSidebar = if (showMenu) { - { compactDrawerOpen = true } - } else { - null - }, - ) MobileSurface.REMOTE -> if (shell.remoteScanRequested) { PairingScreen( + onDeviceLink = connectDeviceLink, modifier = Modifier, settingsPlacement = settingsPlacement, sessionDetailsPlacement = sessionDetailsPlacement, @@ -481,7 +383,7 @@ internal fun MobileScreen() { }, onBack = { shell.closeRemoteScanner() - shell.show(MobileSurface.GENERAL_CHAT) + shell.openRemoteConnect() }, onOpenAccount = { shell.closeRemoteScanner() @@ -527,41 +429,20 @@ internal fun MobileScreen() { modifier = Modifier, ) - RemoteControlSource.QR_PAIRING -> PairingScreen( - modifier = Modifier, - settingsPlacement = settingsPlacement, - sessionDetailsPlacement = sessionDetailsPlacement, - viewSettingsPlacement = remoteViewSettingsPlacement, - onOpenRemoteSettings = { shell.openSettings(SettingsMode.REMOTE) }, - onOpenSidebar = if (showMenu) { - { compactDrawerOpen = true } - } else { - null - }, - onBack = { shell.show(MobileSurface.GENERAL_CHAT) }, - onOpenAccount = { shell.openAccount() }, - compact = !wide, - requestedSessionId = shell.remoteSessionId, - creatingSession = shell.remoteCreating, - onOpenSession = shell::openRemoteSession, - onCreateSession = shell::createRemoteSession, - onRemoteHome = shell::closeRemoteSession, - ) - RemoteControlSource.NONE -> if (readyAccount != null) { ConnectAccountDeviceScreen( state = readyAccount, - onBack = { shell.show(MobileSurface.GENERAL_CHAT) }, + onBack = { shell.openRemoteConnect() }, onRefresh = { accountViewModel.dispatch(AccountIntent.RefreshDevices) }, onSelect = accountViewModel::selectDevice, onOpenScanner = { - pairingViewModel.dispatch(PairingIntent.Disconnect) shell.openRemoteScanner() }, modifier = Modifier, ) } else { PairingScreen( + onDeviceLink = connectDeviceLink, modifier = Modifier, settingsPlacement = settingsPlacement, sessionDetailsPlacement = sessionDetailsPlacement, @@ -572,7 +453,7 @@ internal fun MobileScreen() { } else { null }, - onBack = { shell.show(MobileSurface.GENERAL_CHAT) }, + onBack = { shell.openRemoteConnect() }, onOpenAccount = { shell.openAccount() }, compact = !wide, requestedSessionId = shell.remoteSessionId, @@ -680,16 +561,6 @@ internal fun MobileScreen() { modifier = contentModifier, accountUserId = accountUserId, accountUsername = readyAccount?.username.orEmpty(), - config = generalChatState.config, - models = generalChatState.models, - activeModelId = generalChatState.activeModelId, - configFailure = generalChatState.configFailure, - connectionTest = generalChatState.connectionTest, - onChatIntent = generalChatViewModel::dispatch, - onSaveConfig = { intent -> - generalChatViewModel.dispatch(intent) - generalChatViewModel.state.value.configFailure == null - }, onOpenAccount = shell::openAccount, onClose = shell::dismissSettings, ) @@ -703,12 +574,10 @@ internal fun MobileScreen() { // asking the other one would answer for a connection this // page is not describing, or for none at all. remoteState = when (controlSummary.source) { - RemoteControlSource.QR_PAIRING -> pairingRemoteState RemoteControlSource.ACCOUNT_DEVICE -> accountRemoteState RemoteControlSource.NONE -> RemoteSessionUiState.Idle }, onSessionIntent = when (controlSummary.source) { - RemoteControlSource.QR_PAIRING -> pairingViewModel::dispatchSession RemoteControlSource.ACCOUNT_DEVICE -> accountViewModel::dispatchSession RemoteControlSource.NONE -> { {} @@ -716,15 +585,12 @@ internal fun MobileScreen() { }, onClose = shell::dismissSettings, onOpenAccount = shell::openAccount, - onDisconnect = { pairingViewModel.dispatch(PairingIntent.Disconnect) }, + onDisconnect = { accountViewModel.disconnectDevice() }, onReconnect = { // A room is re-checked where it stands; a device is asked // for again, which is the same command its row in the // account sends. Neither re-pairs behind the user's back. when (controlSummary.source) { - RemoteControlSource.QR_PAIRING -> - pairingViewModel.dispatch(PairingIntent.Verify) - RemoteControlSource.ACCOUNT_DEVICE -> { val deviceId = readyAccount?.selectedDeviceId if (deviceId != null) { @@ -737,7 +603,6 @@ internal fun MobileScreen() { }, onConnectByLink = { shell.dismissSettings() - pairingViewModel.dispatch(PairingIntent.Disconnect) shell.openRemoteScanner() }, ) diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/shell/sidebar/AppSidebar.kt b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/shell/sidebar/AppSidebar.kt index fd6f6b6c10..a77d9cbc89 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/shell/sidebar/AppSidebar.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/shell/sidebar/AppSidebar.kt @@ -1,6 +1,8 @@ package com.openbitfun.mobile.app.ui.shell.sidebar import androidx.compose.foundation.background +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -43,8 +45,6 @@ import com.openbitfun.mobile.core.feature.session.SessionActionScope import com.openbitfun.mobile.core.feature.session.RemoteSessionUiState import com.openbitfun.mobile.core.feature.layout.SettingsPlacement import com.openbitfun.mobile.core.feature.shell.RemoteSidebarSessionRow -import com.openbitfun.mobile.core.feature.shell.SidebarPresentation -import com.openbitfun.mobile.core.feature.shell.SidebarSessionRow import com.openbitfun.mobile.core.feature.workspace.RemoteWorkspaceUiState internal const val SIDEBAR_TEST_TAG: String = "app-sidebar" @@ -78,8 +78,6 @@ internal fun AppSidebar( workspaceState: RemoteWorkspaceUiState, remoteActive: Boolean, remoteSelectedSessionId: String?, - sessions: List, - selectedSessionId: String?, query: String, searchOpen: Boolean, onQueryChange: (String) -> Unit, @@ -90,28 +88,15 @@ internal fun AppSidebar( onOpenRemoteSession: (String) -> Unit, onCreateRemoteInWorkspace: (String) -> Unit, onOpenRemoteWorkspace: (String) -> Unit, - onNewChat: () -> Unit, - onOpenSession: (SidebarSessionRow) -> Unit, - onArchiveSession: (String, Boolean) -> Unit, - onExportSession: (SidebarSessionRow) -> Unit, - onDeleteSession: (String) -> Unit, onDeleteRemoteSession: (String) -> Unit, onOpenSettings: () -> Unit, onOpenAccount: () -> Unit, modifier: Modifier, ) { val signedIn = !accountUserId.isNullOrBlank() - val sections = remember(sessions, query) { SidebarPresentation.sections(sessions, query) } - - // Ids rather than rows: the list behind these sheets keeps updating while - // they are open, and a captured row would go stale the moment a reply lands. - var actionSessionId by rememberSaveable { mutableStateOf(null) } - var actionAnchor by remember { mutableStateOf(IntRect.Zero) } - var detailsSessionId by rememberSaveable { mutableStateOf(null) } var remoteActionSession by remember { mutableStateOf(null) } var remoteActionAnchor by remember { mutableStateOf(IntRect.Zero) } var remoteDetailsSessionId by rememberSaveable { mutableStateOf(null) } - var archivedExpanded by rememberSaveable { mutableStateOf(false) } Box(modifier = modifier.fillMaxSize().testTag(SIDEBAR_TEST_TAG)) { Column( @@ -123,22 +108,10 @@ internal fun AppSidebar( if (signedIn) { SidebarAuthenticatedHeader(searchOpen, query, onQueryChange, onToggleSearch) } else { - SidebarSignedOutHeader(onNewChat) + Text(stringResource(R.string.app_name), style = MaterialTheme.typography.titleLarge) } - SidebarSessionList( - sections = sections, - selectedSessionId = selectedSessionId, - activeActionSessionId = actionSessionId, - searching = query.isNotBlank(), - archivedExpanded = archivedExpanded, - onToggleArchived = { archivedExpanded = !archivedExpanded }, - onOpenSession = onOpenSession, - onOpenActions = { session, anchor -> - actionAnchor = anchor - actionSessionId = session.id - }, - workspaceContent = { + Column(Modifier.weight(1f).verticalScroll(rememberScrollState()).padding(bottom = 142.dp)) { SidebarRemoteWorkspaceSection( connectionPhase = connectionPhase, controlSource = remoteControlSource, @@ -159,14 +132,7 @@ internal fun AppSidebar( onCreateInWorkspace = onCreateRemoteInWorkspace, onOpenWorkspace = onOpenRemoteWorkspace, ) - }, - footerRoom = if (!signedIn && connectionPhase != ConnectionPhase.CONNECTED) { - 142.dp - } else { - 84.dp - }, - modifier = Modifier.weight(1f), - ) + } } // Over the list, not after it: the 84dp tail the list reserves is what @@ -178,7 +144,7 @@ internal fun AppSidebar( .padding(start = 20.dp, end = 20.dp, bottom = 16.dp), ) { if (signedIn) { - SidebarAuthenticatedFooter(onNewChat, onOpenSettings) + SidebarAuthenticatedFooter(onScanDesktop, onOpenSettings) } else { SidebarSignedOutFooter( showScan = connectionPhase != ConnectionPhase.CONNECTED, @@ -189,53 +155,6 @@ internal fun AppSidebar( } } - actionSessionId?.let { id -> - val session = sessions.firstOrNull { it.id == id } - if (session == null) { - actionSessionId = null - return@let - } - val actionSurface: @Composable () -> Unit = { - if (permanent) { - SessionActionPopup( - anchorBounds = actionAnchor, - title = session.title, - status = session.status, - capabilities = SessionActionPolicy.resolve( - SessionActionScope.GENERAL, - GENERAL_CHAT_AGENT_TYPE, - false, - ), - onViewDetails = { detailsSessionId = id }, - onArchive = { onArchiveSession(id, !session.status.equals(ARCHIVED, ignoreCase = true)) }, - onExport = { onExportSession(session) }, - onDelete = { onDeleteSession(id) }, - onDismiss = { actionSessionId = null }, - ) - } else { - SessionActionSheet( - title = session.title, - status = session.status, - // Every sidebar row is a local general chat, so the policy is asked - // with that agent type rather than one carried on the row. - capabilities = SessionActionPolicy.resolve( - SessionActionScope.GENERAL, - GENERAL_CHAT_AGENT_TYPE, - false, - ), - onViewDetails = { detailsSessionId = id }, - onArchive = { - onArchiveSession(id, !session.status.equals(ARCHIVED, ignoreCase = true)) - }, - onExport = { onExportSession(session) }, - onDelete = { onDeleteSession(id) }, - onDismiss = { actionSessionId = null }, - ) - } - } - actionSurface() - } - remoteActionSession?.let { session -> val busy = (remoteState as? RemoteSessionUiState.Ready)?.busy == true val capabilities = SessionActionPolicy.resolve( @@ -286,28 +205,4 @@ internal fun AppSidebar( ) } - detailsSessionId?.let { id -> - val session = sessions.firstOrNull { it.id == id } - if (session == null) { - detailsSessionId = null - return@let - } - SessionDetailsSheet( - title = session.title, - agentType = stringResource(R.string.session_group_chat), - status = session.status, - // A locally stored conversation has no desktop workspace behind it. - workspaceName = null, - workspacePath = null, - createdAt = session.createdAt, - updatedAt = session.updatedAt, - messageCount = session.messageCount, - placement = sessionDetailsPlacement, - onDismiss = { detailsSessionId = null }, - ) - } - } - -private const val GENERAL_CHAT_AGENT_TYPE = "general_chat" -private const val ARCHIVED = "archived" diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/shell/sidebar/SidebarFooter.kt b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/shell/sidebar/SidebarFooter.kt index 96388d3643..120a311f99 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/shell/sidebar/SidebarFooter.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/shell/sidebar/SidebarFooter.kt @@ -42,8 +42,8 @@ internal const val SIDEBAR_SETTINGS_TEST_TAG: String = "app-sidebar-settings" * down the history a user has gone. */ @Composable -internal fun SidebarAuthenticatedFooter(onNewChat: () -> Unit, onOpenSettings: () -> Unit) { - val newChatLabel = stringResource(R.string.sidebar_new_chat) +internal fun SidebarAuthenticatedFooter(onConnect: () -> Unit, onOpenSettings: () -> Unit) { + val newChatLabel = stringResource(R.string.sidebar_add_connection) Row( modifier = Modifier.fillMaxWidth().height(56.dp), verticalAlignment = Alignment.CenterVertically, @@ -56,7 +56,7 @@ internal fun SidebarAuthenticatedFooter(onNewChat: () -> Unit, onOpenSettings: ( .clip(RoundedCornerShape(23.dp)) .background(MaterialTheme.colorScheme.surface) .border(1.dp, MaterialTheme.colorScheme.outlineVariant, RoundedCornerShape(23.dp)) - .clickable(role = Role.Button, onClick = onNewChat) + .clickable(role = Role.Button, onClick = onConnect) .semantics(mergeDescendants = true) { contentDescription = newChatLabel } diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/shell/sidebar/SidebarRemoteWorkspaceSection.kt b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/shell/sidebar/SidebarRemoteWorkspaceSection.kt index 0afdaee3ea..763f626104 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/shell/sidebar/SidebarRemoteWorkspaceSection.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/shell/sidebar/SidebarRemoteWorkspaceSection.kt @@ -81,25 +81,8 @@ internal fun SidebarRemoteWorkspaceSection( ) { val connected = ConnectionStatusPresenter.canReachSessions(connectionPhase) val addConnectionLabel = stringResource(R.string.sidebar_add_connection) - val transientDeviceKey = remember(deviceName) { "qr:$deviceName" } - val projectedDevices = remember(devices, controlSource, deviceName) { - if ( - controlSource == RemoteControlSource.QR_PAIRING && - deviceName.isNotBlank() && - devices.none { it.name == deviceName } - ) { - listOf(AccountDeviceUi(transientDeviceKey, deviceName, true, null)) + devices - } else { - devices - } - } - val activeDeviceId = when (controlSource) { - RemoteControlSource.QR_PAIRING -> projectedDevices.firstOrNull { - it.id == transientDeviceKey || it.name == deviceName - }?.id - RemoteControlSource.ACCOUNT_DEVICE -> selectedDeviceId - RemoteControlSource.NONE -> null - } + val projectedDevices = devices + val activeDeviceId = if (controlSource == RemoteControlSource.ACCOUNT_DEVICE) selectedDeviceId else null var expandedDeviceIds by rememberSaveable { mutableStateOf(emptyList()) } var visibleDeviceCount by rememberSaveable { mutableStateOf(DEVICES_PER_BATCH) } var cachedRemoteStates by remember { @@ -183,8 +166,6 @@ internal fun SidebarRemoteWorkspaceSection( projectedDevices.take(visibleDeviceCount).forEach { device -> val expanded = device.id in expandedDeviceIds val active = device.id == activeDeviceId - val transient = controlSource == RemoteControlSource.QR_PAIRING && - device.id == activeDeviceId val cachedRemote = cachedRemoteStates[device.id] val cachedWorkspace = cachedWorkspaceStates[device.id] val shownRemote = if (active) { @@ -219,7 +200,7 @@ internal fun SidebarRemoteWorkspaceSection( } else { expandedDeviceIds + device.id } - } else if (device.online && !transient) { + } else if (device.online) { activeDeviceId?.let { currentId -> (remoteState as? RemoteSessionUiState.Ready)?.let { ready -> cachedRemoteStates = cachedRemoteStates + (currentId to ready) diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/viewmodel/AccountViewModel.kt b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/viewmodel/AccountViewModel.kt index 1571c05f87..6d2d36eed3 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/viewmodel/AccountViewModel.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/viewmodel/AccountViewModel.kt @@ -8,7 +8,6 @@ import androidx.lifecycle.viewModelScope import com.openbitfun.mobile.app.platform.LogcatCoreLog import com.openbitfun.mobile.app.platform.LEGACY_MOBILE_DEVICE_NAMES import com.openbitfun.mobile.app.platform.deviceIdentity -import com.openbitfun.mobile.core.feature.CloudSettingsSource import com.openbitfun.mobile.core.feature.account.AccountIntent import com.openbitfun.mobile.core.feature.account.AccountStore import com.openbitfun.mobile.core.feature.account.AccountUiState @@ -65,7 +64,6 @@ internal class AccountViewModel(application: Application) : AndroidViewModel(app } /** The handle General Chat reads the account's synced models through. */ - fun cloudSettingsSource(): CloudSettingsSource? = store.cloudSettingsSource() fun dispatchSession(intent: RemoteSessionIntent) { remoteStore?.dispatch(intent) @@ -91,6 +89,10 @@ internal class AccountViewModel(application: Application) : AndroidViewModel(app if (deviceId == activeTarget) bindTarget(deviceId) else store.dispatch(AccountIntent.SelectDevice(deviceId)) } + fun disconnectDevice() { + bindTarget(null) + } + private fun bindTarget(target: String?) { remoteJob?.cancel() connectionJob?.cancel() diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/viewmodel/PairingViewModel.kt b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/viewmodel/PairingViewModel.kt deleted file mode 100644 index a9e077a624..0000000000 --- a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/viewmodel/PairingViewModel.kt +++ /dev/null @@ -1,111 +0,0 @@ -package com.openbitfun.mobile.app.viewmodel - -import android.app.Application -import androidx.lifecycle.AndroidViewModel -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider -import androidx.lifecycle.viewModelScope -import com.openbitfun.mobile.app.platform.LogcatCoreLog -import com.openbitfun.mobile.app.platform.deviceIdentity -import com.openbitfun.mobile.core.feature.pairing.PairingIntent -import com.openbitfun.mobile.core.feature.pairing.PairingStore -import com.openbitfun.mobile.core.feature.pairing.PairingUiState -import com.openbitfun.mobile.core.feature.pairing.create -import com.openbitfun.mobile.core.feature.session.RemoteSessionIntent -import com.openbitfun.mobile.core.feature.session.RemoteSessionStore -import com.openbitfun.mobile.core.feature.session.RemoteSessionUiState -import com.openbitfun.mobile.core.feature.workspace.RemoteWorkspaceIntent -import com.openbitfun.mobile.core.feature.workspace.RemoteWorkspaceStore -import com.openbitfun.mobile.core.feature.workspace.RemoteWorkspaceUiState -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.collect -import kotlinx.coroutines.launch - -/** - * Owns the store for as long as the screen exists. - * - * The store is scoped to [viewModelScope], so a rotation mid-handshake does not - * restart it and leaving the screen cancels it. - */ -internal class PairingViewModel(application: Application) : AndroidViewModel(application) { - private val store = PairingStore.create( - scope = viewModelScope, - // The credential cooldown outlives this view model — a counter that a - // rotation resets would not be one — so the store is handed the - // application rather than kept in memory here. - context = application, - device = application.deviceIdentity(), - log = LogcatCoreLog, - ) - - val state: StateFlow = store.state - private val _remoteState = MutableStateFlow(RemoteSessionUiState.Idle) - val remoteState: StateFlow = _remoteState.asStateFlow() - private var remoteStore: RemoteSessionStore? = null - private var remoteStateJob: Job? = null - private val _workspaceState = MutableStateFlow(RemoteWorkspaceUiState.Idle) - val workspaceState: StateFlow = _workspaceState.asStateFlow() - private var workspaceStore: RemoteWorkspaceStore? = null - private var workspaceStateJob: Job? = null - - init { - viewModelScope.launch { - store.state.collect { current -> - if (current is PairingUiState.Paired && remoteStore == null) { - val created = store.createSessionStore(viewModelScope) ?: return@collect - remoteStore = created - remoteStateJob = launch { - created.state.collect { _remoteState.value = it } - } - created.dispatch(RemoteSessionIntent.Load) - val workspace = store.createWorkspaceStore(viewModelScope) - workspaceStore = workspace - workspaceStateJob = workspace?.let { workspaceStore -> - launch { - workspaceStore.state.collect { _workspaceState.value = it } - } - } - workspace?.dispatch(RemoteWorkspaceIntent.Load) - } else if (current !is PairingUiState.Paired && remoteStore != null) { - remoteStateJob?.cancel() - remoteStateJob = null - remoteStore?.dispatch(RemoteSessionIntent.Stop) - remoteStore = null - _remoteState.value = RemoteSessionUiState.Idle - workspaceStateJob?.cancel() - workspaceStateJob = null - workspaceStore?.dispatch(RemoteWorkspaceIntent.Stop) - workspaceStore = null - _workspaceState.value = RemoteWorkspaceUiState.Idle - } - } - } - } - - fun dispatch(intent: PairingIntent) { - store.dispatch(intent) - } - - fun dispatchSession(intent: RemoteSessionIntent) { - remoteStore?.dispatch(intent) - } - - fun dispatchWorkspace(intent: RemoteWorkspaceIntent) { - workspaceStore?.dispatch(intent) - } - - companion object { - val Factory: ViewModelProvider.Factory = object : ViewModelProvider.Factory { - @Suppress("UNCHECKED_CAST") - override fun create( - modelClass: Class, - extras: androidx.lifecycle.viewmodel.CreationExtras, - ): T = PairingViewModel( - extras[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY]!!, - ) as T - } - } -} diff --git a/src/apps/mobile/android/app/src/main/res/values-zh/strings.xml b/src/apps/mobile/android/app/src/main/res/values-zh/strings.xml index 6f557bea23..f4907a0c0b 100644 --- a/src/apps/mobile/android/app/src/main/res/values-zh/strings.xml +++ b/src/apps/mobile/android/app/src/main/res/values-zh/strings.xml @@ -24,15 +24,15 @@ 浅色 深色 账号 - 登录 OpenBitFun - 登录后即可同步设备、会话和远程控制状态。 + 使用 GitHub 登录 + 使用 GitHub 登录,连接你的电脑并远程操作会话。 登录服务器 OpenBitFun 用户名 OpenBitFun 密码 Relay 地址,例如 https://relay.example.com 正在登录… 个人资料 - OpenBitFun 账号 + GitHub 账号 当前连接已通过账号 %1$s 验证。密码不会保存到手机。 连接 当前控制 @@ -40,7 +40,7 @@ 中继地址 用户名 密码 - 登录 + 通过 GitHub 登录 退出登录 离线 在线 @@ -329,7 +329,7 @@ 未命名会话 聊天 新聊天 - 登录 OpenBitFun 账号 + 使用 GitHub 登录 扫码连接 选择连接方式 打开侧边栏 @@ -431,4 +431,7 @@ OpenBitFun 用户 已认证 加载更早消息 + 打开 GitHub 授权 + 请使用当前版本的 OpenBitFun 设备二维码。 + 该设备已离线,或不属于当前 GitHub 账户。 diff --git a/src/apps/mobile/android/app/src/main/res/values/strings.xml b/src/apps/mobile/android/app/src/main/res/values/strings.xml index 08ac024e13..3e2a3fea3d 100644 --- a/src/apps/mobile/android/app/src/main/res/values/strings.xml +++ b/src/apps/mobile/android/app/src/main/res/values/strings.xml @@ -25,15 +25,15 @@ Light Dark Account - Sign in to OpenBitFun - Sign in to sync devices, sessions, and remote control status. + Sign in with GitHub + Sign in with GitHub to connect to your computers and control their sessions. Sign-in server OpenBitFun username OpenBitFun password Relay URL, for example https://relay.example.com Signing in… Profile - OpenBitFun account + GitHub account This connection is verified by account %1$s. Your password is not stored on this phone. Connect Controlling @@ -41,7 +41,7 @@ Relay URL Username Password - Sign in + Sign in with GitHub Sign out Offline Online @@ -342,7 +342,7 @@ Untitled session Chat New chat - Sign in to OpenBitFun account + Sign in with GitHub Scan to connect Choose how to connect Open sidebar @@ -446,4 +446,7 @@ This device ID OpenBitFun user Authenticated + Open GitHub authorization + Use a current OpenBitFun device QR code. + This device is offline or does not belong to your GitHub account. diff --git a/src/apps/mobile/harmonyos/AGENTS.md b/src/apps/mobile/harmonyos/AGENTS.md index 7946d259f0..f447897f62 100644 --- a/src/apps/mobile/harmonyos/AGENTS.md +++ b/src/apps/mobile/harmonyos/AGENTS.md @@ -2,6 +2,15 @@ These rules apply to all changes under `src/apps/mobile/harmonyos`. +## Controller product boundary + +Phone, tablet, and foldable entrypoints are remote controllers. They must not +instantiate a local Agent Runtime, model provider, local chat command owner, or +model configuration store. GitHub identity and the authenticated device directory +are the authority for selection and reconnect; QR data supplies only a device id. +Keep old persisted records readable and retained without activating legacy room +credentials or local execution. Compact and wide hosts enforce the same boundary. + ## MVVM Refactor Boundaries This app has one `entry` module, so MVVM is the file-organization boundary for diff --git a/src/apps/mobile/harmonyos/README.md b/src/apps/mobile/harmonyos/README.md index bc042c8290..e6bc4c61b3 100644 --- a/src/apps/mobile/harmonyos/README.md +++ b/src/apps/mobile/harmonyos/README.md @@ -1,7 +1,13 @@ # OpenBitFun HarmonyOS -Native HarmonyOS client for OpenBitFun. The application provides general chat and -remote control of OpenBitFun desktop sessions on phone and tablet devices. +Native HarmonyOS controller for OpenBitFun desktop and CLI hosts on phones, +foldables, and tablets. Sign in with GitHub, select an account device, then send +tasks and view results from that host. The phone does not run an Agent Runtime or +store model-provider configuration. Model selection applies to the selected host. + +Device QR codes identify a target; the authenticated account directory authorizes +access. Old room records and local conversation data remain on disk during an +upgrade, but do not automatically reconnect or start a local runtime. ## Project Layout diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/EnUsMessages.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/EnUsMessages.ets index 68e4ed9754..e764e69f68 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/EnUsMessages.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/EnUsMessages.ets @@ -147,7 +147,7 @@ export const EN_US_MESSAGES: [string, string][] = [ ['sidebar.noSearchResult', 'No matching sessions'], ['sidebar.signedOutNewChat', 'New chat'], ['sidebar.scanToConnect', 'Scan to connect a computer'], - ['sidebar.signInOpenBitFunAccount', 'Sign in to OpenBitFun'], + ['sidebar.signInOpenBitFunAccount', 'Sign in with GitHub'], ['sidebar.collapse', 'Collapse sidebar'], ['sidebar.restore', 'Expand sidebar'], @@ -190,6 +190,7 @@ export const EN_US_MESSAGES: [string, string][] = [ ['code.emptySessionTitle', 'No remote sessions'], ['code.emptySessionText', 'After connecting a local workspace, create a remote task to work on the project.'], + ['remote.actionUnsupported', 'This action is not supported by the connected device.'], ['remote.title', 'Remote'], ['remote.chats', 'Chats'], ['remote.searchChats', 'Search chat history'], @@ -243,16 +244,16 @@ export const EN_US_MESSAGES: [string, string][] = [ ['remote.settings.noDesktop', 'No desktop connected yet'], ['remote.settings.openbitfunUser', 'OpenBitFun user'], ['remote.settings.profileDetails', 'Details'], - ['remote.settings.account', 'OpenBitFun account'], + ['remote.settings.account', 'GitHub account'], ['remote.settings.accountSignedIn', 'Signed in'], ['remote.settings.accountNotSignedIn', 'Signed out'], ['remote.settings.accountTemporary', 'Temporary access'], ['remote.settings.accountSignedInBody', 'This connection is verified as account {0}. The password is not saved on the phone.'], ['remote.settings.accountNotSignedInBody', 'If the desktop asks for account verification while scanning, the phone completes it for this pairing.'], - ['remote.settings.accountLoginTitle', 'Sign in to OpenBitFun'], - ['remote.settings.accountLoginBody', 'Sign in to sync devices, sessions, and remote control state.'], + ['remote.settings.accountLoginTitle', 'Sign in with GitHub'], + ['remote.settings.accountLoginBody', 'Sign in with GitHub to connect to your computers. Tasks and model settings stay on the controlled computer.'], ['remote.settings.loginServer', 'Sign-in server'], - ['remote.settings.accountSignIn', 'Sign in'], + ['remote.settings.accountSignIn', 'Sign in with GitHub'], ['remote.settings.accountSigningIn', 'Signing in…'], ['remote.settings.accountLoginFailed', 'Sign-in failed. Check the account details or server settings.'], ['remote.settings.relayUrlPlaceholder', 'Relay URL, for example https://relay.example.com'], @@ -297,6 +298,7 @@ export const EN_US_MESSAGES: [string, string][] = [ ['connect.localTitle', 'Connect a local workspace'], ['connect.chooseConnectionTitle', 'Connect a computer'], + ['connect.legacySignInRequired', 'Sign in with GitHub to reconnect this device. Previous connection records have been retained.'], ['connect.subtitle', 'After connecting the desktop, OpenBitFun can access repositories, run commands, and handle local development tasks.'], ['connect.obtainPairCode', 'Get a pairing code'], ['connect.obtainPairCodeBody', 'Open Remote Control in OpenBitFun Desktop to get a QR code or pairing code.'], @@ -311,12 +313,14 @@ export const EN_US_MESSAGES: [string, string][] = [ ['connect.deviceLastUsed', 'Last connected'], ['connect.otherConnectionMethods', 'Other ways to connect'], ['connect.switchManualPair', 'Pair manually instead'], + ['connect.deviceLinkInvalid', 'Use a current OpenBitFun device QR code.'], + ['connect.deviceLinkUnavailable', 'This device is offline or does not belong to your GitHub account.'], ['connect.manualPair', 'Manual pairing'], ['connect.manualPairBody', 'Enter the pairing code shown on the desktop.'], ['connect.accountPairTitle', 'Account pairing'], - ['connect.accountPairIntro', 'The desktop is signed in to a OpenBitFun account. Verify the same account to continue.'], + ['connect.accountPairIntro', 'The desktop is signed in to a GitHub account. Verify the same account to continue.'], ['connect.accountPairBody', 'The password is only used for this encrypted pairing and is not saved on the phone.'], - ['connect.enterAccountToPair', 'Enter the OpenBitFun account password to finish pairing'], + ['connect.enterAccountToPair', 'Sign in with GitHub to connect this device'], ['connect.accountUsernamePlaceholder', 'OpenBitFun username'], ['connect.accountPasswordPlaceholder', 'OpenBitFun password'], ['connect.pairCodePlaceholder', 'Pairing code'], @@ -586,7 +590,7 @@ export const EN_US_MESSAGES: [string, string][] = [ ['errors.tooManyAttempts', 'Too many attempts. Try again in {0} seconds.'], ['errors.userIdRequired', 'Enter a user ID.'], ['errors.accountUsernameRequired', 'Enter a OpenBitFun username.'], - ['errors.accountPasswordRequired', 'Enter the OpenBitFun account password.'], + ['errors.accountPasswordRequired', 'Sign in with GitHub to continue.'], ['errors.remoteDataInvalid', 'The relay returned data that could not be parsed. Confirm the desktop and mobile versions match.'], ['errors.pairRejected', 'The relay rejected the pairing request. Generate a new connection QR code on the desktop and retry.'], ['errors.roomNotFound', 'This remote room was not found. Confirm the QR code has not expired, or reopen mobile connect on the desktop.'], @@ -609,7 +613,7 @@ export const EN_US_MESSAGES: [string, string][] = [ ['errors.voiceInputUnavailable', 'Speech recognition is unavailable. Try again later.'], ['errors.voiceInputFailed', 'Speech recognition failed ({0}). Try again later.'], - ['watchProvision.title', 'Sign in to OpenBitFun on this watch?'], + ['watchProvision.title', 'Sign in with GitHub on this watch?'], ['watchProvision.deviceId', 'Device {0}'], ['watchProvision.body', 'If you allow this, the watch will sign in to your account and can connect to a desktop. Confirm the watch is in your hands.'], ['watchProvision.approve', 'Allow'], @@ -622,10 +626,10 @@ export const EN_US_MESSAGES: [string, string][] = [ ['watchProvision.doneBody', '{0} is signed in and ready to use OpenBitFun.'], ['watchProvision.rejected', 'Denied on the phone.'], ['watchProvision.busy', 'The phone is handling another device request. Try again later.'], - ['watchProvision.errors.noDesktop', 'Sign in to OpenBitFun on the phone, or connect a desktop signed in to the same account.'], + ['watchProvision.errors.noDesktop', 'Sign in with GitHub on the phone, or connect a desktop signed in to the same account.'], ['watchProvision.errors.accountUnavailable', 'The account could not be verified. Check the phone network and try again.'], ['watchProvision.errors.desktopUnreachable', 'The desktop could not be reached. Make sure it is online and try again.'], - ['watchProvision.errors.desktopAuthorizationFailed', 'The desktop could not sign in the watch. Make sure it uses the same OpenBitFun account.'], + ['watchProvision.errors.desktopAuthorizationFailed', 'The desktop could not sign in the watch. Make sure it uses the same GitHub account.'], ['watchProvision.errors.passwordFailed', 'Account verification failed. Check the password or network and retry.'], ['watchProvision.errors.handoffFailed', 'Authorization finished, but the credential could not be sent to the watch. Retry on the watch.'], diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/ZhCnMessages.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/ZhCnMessages.ets index 7026d6da2e..b52d959f4a 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/ZhCnMessages.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/ZhCnMessages.ets @@ -147,7 +147,7 @@ export const ZH_CN_MESSAGES: [string, string][] = [ ['sidebar.noSearchResult', '没有匹配的会话'], ['sidebar.signedOutNewChat', '新聊天'], ['sidebar.scanToConnect', '扫码连接电脑'], - ['sidebar.signInOpenBitFunAccount', '登录 OpenBitFun 账号'], + ['sidebar.signInOpenBitFunAccount', '使用 GitHub 登录'], ['sidebar.collapse', '收起侧边栏'], ['sidebar.restore', '展开侧边栏'], @@ -190,6 +190,7 @@ export const ZH_CN_MESSAGES: [string, string][] = [ ['code.emptySessionTitle', '暂无远程会话'], ['code.emptySessionText', '连接本地工作区后,可以新建远程任务处理项目问题。'], + ['remote.actionUnsupported', '已连接的设备不支持此操作。'], ['remote.title', '远程'], ['remote.chats', '聊天'], ['remote.searchChats', '搜索聊天记录'], @@ -243,16 +244,16 @@ export const ZH_CN_MESSAGES: [string, string][] = [ ['remote.settings.noDesktop', '尚未连接桌面端'], ['remote.settings.openbitfunUser', 'OpenBitFun 用户'], ['remote.settings.profileDetails', '资料'], - ['remote.settings.account', 'OpenBitFun 账号'], + ['remote.settings.account', 'GitHub 账号'], ['remote.settings.accountSignedIn', '已登录'], ['remote.settings.accountNotSignedIn', '未登录'], ['remote.settings.accountTemporary', '临时登录'], ['remote.settings.accountSignedInBody', '当前连接已通过账号 {0} 验证。密码不会保存到手机。'], ['remote.settings.accountNotSignedInBody', '扫码连接时,如果桌面端要求账号验证,手机会在本次配对中完成认证。'], - ['remote.settings.accountLoginTitle', '登录 OpenBitFun'], - ['remote.settings.accountLoginBody', '登录后即可同步设备、会话和远程控制状态。'], + ['remote.settings.accountLoginTitle', '使用 GitHub 登录'], + ['remote.settings.accountLoginBody', '使用 GitHub 登录并连接自己的电脑。任务和模型配置保留在被控电脑上。'], ['remote.settings.loginServer', '登录服务器'], - ['remote.settings.accountSignIn', '登录'], + ['remote.settings.accountSignIn', '使用 GitHub 登录'], ['remote.settings.accountSigningIn', '正在登录…'], ['remote.settings.accountLoginFailed', '登录失败,请检查账号密码或服务器设置。'], ['remote.settings.relayUrlPlaceholder', 'Relay 地址,例如 https://relay.example.com'], @@ -297,6 +298,7 @@ export const ZH_CN_MESSAGES: [string, string][] = [ ['connect.localTitle', '连接本地工作区'], ['connect.chooseConnectionTitle', '连接电脑'], + ['connect.legacySignInRequired', '请使用 GitHub 登录后重新连接设备。原有连接记录已保留。'], ['connect.subtitle', '连接桌面端后,OpenBitFun 可以访问代码仓库、运行命令和处理本地开发任务。'], ['connect.obtainPairCode', '获取配对码'], ['connect.obtainPairCodeBody', '在电脑端打开 OpenBitFun 的远程控制,获取二维码或配对码。'], @@ -311,12 +313,14 @@ export const ZH_CN_MESSAGES: [string, string][] = [ ['connect.deviceLastUsed', '上次连接'], ['connect.otherConnectionMethods', '其他连接方式'], ['connect.switchManualPair', '改为手动配对'], + ['connect.deviceLinkInvalid', '请使用当前版本的 OpenBitFun 设备二维码。'], + ['connect.deviceLinkUnavailable', '该设备已离线,或不属于当前 GitHub 账户。'], ['connect.manualPair', '手动配对'], ['connect.manualPairBody', '请输入桌面上显示的配对码。'], ['connect.accountPairTitle', '账号配对验证'], - ['connect.accountPairIntro', '桌面端已登录 OpenBitFun 账号,需要验证同一账号后继续连接。'], + ['connect.accountPairIntro', '桌面端已使用 GitHub 登录,需要验证同一账号后继续连接。'], ['connect.accountPairBody', '密码只用于本次加密配对,不会保存到手机。'], - ['connect.enterAccountToPair', '请输入 OpenBitFun 账号密码完成配对'], + ['connect.enterAccountToPair', '请使用 GitHub 登录后连接设备'], ['connect.accountUsernamePlaceholder', 'OpenBitFun 用户名'], ['connect.accountPasswordPlaceholder', 'OpenBitFun 密码'], ['connect.pairCodePlaceholder', '配对码'], @@ -586,7 +590,7 @@ export const ZH_CN_MESSAGES: [string, string][] = [ ['errors.tooManyAttempts', '尝试次数过多,请 {0} 秒后再试。'], ['errors.userIdRequired', '请输入用户 ID。'], ['errors.accountUsernameRequired', '请输入 OpenBitFun 用户名。'], - ['errors.accountPasswordRequired', '请输入 OpenBitFun 账号密码。'], + ['errors.accountPasswordRequired', '请使用 GitHub 登录以继续。'], ['errors.remoteDataInvalid', '中继返回了无法解析的数据,请确认桌面端和移动端版本匹配。'], ['errors.pairRejected', '中继拒绝了配对请求,请在桌面端重新生成连接二维码后再试。'], ['errors.roomNotFound', '没有找到这个远程房间,请确认二维码未过期,或在桌面端重新打开移动端连接。'], @@ -609,7 +613,7 @@ export const ZH_CN_MESSAGES: [string, string][] = [ ['errors.voiceInputUnavailable', '语音识别暂不可用,请稍后重试。'], ['errors.voiceInputFailed', '语音识别失败({0}),请稍后重试。'], - ['watchProvision.title', '在这块手表上登录 OpenBitFun?'], + ['watchProvision.title', '在这块手表上使用 GitHub 登录?'], ['watchProvision.deviceId', '设备编号 {0}'], ['watchProvision.body', '同意后,手表会登录你的账号并可以连接桌面端。请确认这块手表就在你手里。'], ['watchProvision.approve', '允许'], @@ -622,10 +626,10 @@ export const ZH_CN_MESSAGES: [string, string][] = [ ['watchProvision.doneBody', '{0} 已登录,可以在手表上使用 OpenBitFun 了。'], ['watchProvision.rejected', '已在手机上拒绝。'], ['watchProvision.busy', '手机正在处理另一台设备的请求,请稍后再试。'], - ['watchProvision.errors.noDesktop', '请先在手机上登录 OpenBitFun 账号,或连接已登录同一账号的桌面端。'], + ['watchProvision.errors.noDesktop', '请先在手机上使用 GitHub 登录,或连接已登录同一账号的桌面端。'], ['watchProvision.errors.accountUnavailable', '暂时无法验证账号,请检查手机网络后重试。'], ['watchProvision.errors.desktopUnreachable', '暂时无法连接桌面端,请确认桌面端在线后重试。'], - ['watchProvision.errors.desktopAuthorizationFailed', '桌面端没能完成手表登录,请确认桌面端已登录同一 OpenBitFun 账号。'], + ['watchProvision.errors.desktopAuthorizationFailed', '桌面端没能完成手表登录,请确认桌面端已登录同一 GitHub 账号。'], ['watchProvision.errors.passwordFailed', '账号验证失败,请检查密码或网络后重试。'], ['watchProvision.errors.handoffFailed', '授权已完成,但没能把凭证发给手表,请在手表上重试一次。'], diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/model/RemoteModels.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/model/RemoteModels.ets index 253d2ffee2..11055ddda4 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/model/RemoteModels.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/model/RemoteModels.ets @@ -1,18 +1,4 @@ -export interface RemoteDescriptor { - relayUrl: string; - roomId: string; - publicKey: string; - accountAuth: boolean; - accountUsername: string; -} -export interface PairState { - descriptor?: RemoteDescriptor; - connected: boolean; - deviceId: string; - userId: string; - message: string; -} export class EncryptedPayload { encrypted_data: string = ''; diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/AppRootPresentationActions.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/AppRootPresentationActions.ets index 68ee424994..fec9ce2fa6 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/AppRootPresentationActions.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/AppRootPresentationActions.ets @@ -69,8 +69,6 @@ export interface SidebarPresentationActions { readonly settings: () => void; readonly openAccount: () => void; readonly openSession: (session: RemoteSession) => void; - readonly archive: (session: RemoteSession, archived: boolean) => void; - readonly exportSession: (session: RemoteSession) => void; readonly deleteSession: (session: RemoteSession) => void; } @@ -81,15 +79,13 @@ export interface SettingsPresentationActions { readonly clearPairing: () => void; readonly reconnect: () => void; readonly openAccount: () => void; - readonly cloudLogin: (relayUrl: string, username: string, password: string) => Promise; + readonly cloudCancelLogin: () => void; + readonly cloudLogin: () => Promise; readonly cloudLogout: () => Promise; readonly cloudListDevices: () => Promise; readonly cloudSelectDevice: (device: CloudAccountDevice) => Promise; readonly getPermissionMode: () => Promise; readonly setPermissionMode: (mode: RemotePermissionMode) => Promise; - readonly testGeneral: (url: string, key: string, model: string, clear: boolean) => Promise; - readonly saveGeneral: (url: string, key: string, model: string, clear: boolean) => Promise; - readonly selectGeneralModel: (modelId: string) => Promise; readonly setLanguage: (language: string) => Promise; } @@ -128,17 +124,16 @@ export function emptyAppRootPresentationActions(): AppRootPresentationActions { onSidebar: { close: () => {}, newChat: () => {}, enterCode: () => {}, scanDesktop: () => {}, addDesktop: () => {}, refreshDevices: async () => {}, settings: () => {}, openAccount: () => {}, - openSession: () => {}, archive: () => {}, exportSession: () => {}, deleteSession: () => {} + openSession: () => {}, deleteSession: () => {} }, onSettings: { close: () => {}, addConnection: () => {}, disconnect: () => {}, clearPairing: () => {}, reconnect: () => {}, openAccount: () => {}, + cloudCancelLogin: () => {}, cloudLogin: async () => '', cloudLogout: async () => {}, cloudListDevices: async () => [], cloudSelectDevice: async () => {}, getPermissionMode: async () => 'ask', setPermissionMode: async (mode: RemotePermissionMode) => mode, - testGeneral: async () => '', saveGeneral: async () => '', - selectGeneralModel: async () => false, setLanguage: async () => {} }, onConnect: { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/ConversationIntentDispatcher.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/ConversationIntentDispatcher.ets index c9f8dfd93e..40c8a8f11d 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/ConversationIntentDispatcher.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/ConversationIntentDispatcher.ets @@ -8,16 +8,7 @@ export interface ConversationIntentDispatcherHooks { readonly openSidebar: () => void; readonly back: () => void; readonly newRemoteSession: () => void; - readonly newGeneralSession: () => void; - readonly activeGeneralSession: () => RemoteSession; - readonly activeGeneralSessionId: () => string; - readonly isGeneralBusy: () => boolean; - readonly isPinned: (sessionId: string) => boolean; - readonly pin: (session: RemoteSession, pinned: boolean, busy: boolean) => Promise; - readonly archive: (session: RemoteSession) => Promise; - readonly delete: (session: RemoteSession) => Promise; - readonly showToast: (text: string) => void; - readonly uploadedFileCount: () => number; + readonly unsupported: () => void; readonly stop: () => Promise; readonly loadOlder: () => Promise; readonly approve: (toolId: string, input?: Object) => Promise; @@ -49,23 +40,12 @@ export class ConversationIntentDispatcher { switch (intent.type) { case ConversationIntentType.OpenSidebar: this.hooks.openSidebar(); return; case ConversationIntentType.Back: this.hooks.back(); return; - case ConversationIntentType.NewSession: - route === AppRoute.RemoteChat ? this.hooks.newRemoteSession() : this.hooks.newGeneralSession(); return; + case ConversationIntentType.NewSession: this.hooks.newRemoteSession(); return; case ConversationIntentType.TogglePin: - if (this.isGeneralSession(route)) { - const session = this.hooks.activeGeneralSession(); - void this.hooks.pin(session, !this.hooks.isPinned(session.id), this.hooks.isGeneralBusy()); - } - return; case ConversationIntentType.Archive: - if (this.isGeneralSession(route)) void this.hooks.archive(this.hooks.activeGeneralSession()); - return; case ConversationIntentType.Delete: - if (this.isGeneralSession(route)) void this.hooks.delete(this.hooks.activeGeneralSession()); - return; case ConversationIntentType.ShowUploadedFiles: - const count = this.hooks.uploadedFileCount(); - this.hooks.showToast(count > 0 ? `当前会话已上传 ${count} 个文件` : '当前会话暂无已上传文件'); return; + this.hooks.unsupported(); return; case ConversationIntentType.Stop: void this.hooks.stop(); return; case ConversationIntentType.LoadOlder: void this.hooks.loadOlder(); return; case ConversationIntentType.ApproveTool: void this.hooks.approve(intent.toolId, intent.updatedInput); return; @@ -93,7 +73,4 @@ export class ConversationIntentDispatcher { } } - private isGeneralSession(route: AppRoute): boolean { - return AppRouteContract.isGeneralComposerRoute(route) && this.hooks.activeGeneralSessionId().length > 0; - } } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppRootOverlaySurfaces.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppRootOverlaySurfaces.ets index 8be421a15f..a5ad0c82f8 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppRootOverlaySurfaces.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppRootOverlaySurfaces.ets @@ -34,17 +34,11 @@ export struct AppSidebarSurface { build() { AppSidebar({ - sessions: this.generalPageState.recentSessions(), - sessionDetailsPlacement: this.sessionDetailsPlacement, - pinnedSessionId: this.generalPageState.pinnedSessionId(), - selectedSessionId: AppRouteContract.isGeneralComposerRoute(this.shellState.activeRoute) ? - this.generalPageState.conversation.activeSession.sessionId : '', connectionState: this.remotePageState.connectionState, accountUserId: this.remotePageState.accountUserId, controlTargetType: this.remotePageState.controlTargetType, showViewSettingsButton: false, showWorkspaceSection: true, - showConversationSection: true, contentSlot: () => { this.WorkspaceContent() }, @@ -58,10 +52,6 @@ export struct AppSidebarSurface { }, onOpenSettings: this.actions.onSidebar.settings, onOpenAccount: this.actions.onSidebar.openAccount, - onOpenSession: this.actions.onSidebar.openSession, - onArchiveSession: this.actions.onSidebar.archive, - onExportSession: this.actions.onSidebar.exportSession, - onDeleteSession: this.actions.onSidebar.deleteSession }) } @@ -144,6 +134,7 @@ export struct AppSettingsSurface { onOpenAccount: this.actions.onSettings.openAccount, onAddConnection: this.actions.onSettings.addConnection, cloudLogin: this.actions.onSettings.cloudLogin, + cloudCancelLogin: this.actions.onSettings.cloudCancelLogin, cloudLogout: this.actions.onSettings.cloudLogout, cloudListDevices: this.actions.onSettings.cloudListDevices, cloudSelectDevice: this.actions.onSettings.cloudSelectDevice, @@ -156,11 +147,6 @@ export struct AppSettingsSurface { } else { SettingsSheet({ sheetState: this.shellState.settingsSheet, - generalChatApiUrl: this.generalPageState.apiUrl, - generalChatModelName: this.generalPageState.modelName, - hasGeneralChatApiKey: this.generalPageState.hasApiKey, - generalChatModelCatalog: this.generalPageState.conversation.modelCatalog, - selectedGeneralChatModelId: this.generalPageState.conversation.selectedModelId, accountUsername: this.remotePageState.accountUsername, authenticatedUserId: this.remotePageState.accountUserId, fallbackUserId: this.remotePageState.userId, @@ -177,9 +163,6 @@ export struct AppSettingsSurface { cloudLogout: this.actions.onSettings.cloudLogout, cloudListDevices: this.actions.onSettings.cloudListDevices, cloudSelectDevice: this.actions.onSettings.cloudSelectDevice, - onTestGeneralChatConfig: this.actions.onSettings.testGeneral, - onSaveGeneralChatConfig: this.actions.onSettings.saveGeneral, - onSelectGeneralChatModel: this.actions.onSettings.selectGeneralModel, onSetLanguage: this.actions.onSettings.setLanguage, onClose: this.actions.onSettings.close }) diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppRootPresentation.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppRootPresentation.ets index 74e4f5fae3..fee810df12 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppRootPresentation.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppRootPresentation.ets @@ -170,7 +170,7 @@ export struct AppRootPresentation { @Builder NavigationContent() { Stack() { - Navigation(this.navigationStack) { this.RouteContent(AppRoute.ChatHome) } + Navigation(this.navigationStack) { this.RouteContent(AppRoute.RemoteHome) } .id('openbitfunHomeNavigation') .width('100%').height('100%').hideTitleBar(true) .mode(NavigationMode.Stack) diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppSidebar.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppSidebar.ets index f5bdbb777b..a0387ef7ec 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppSidebar.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppSidebar.ets @@ -1,56 +1,19 @@ import { MobileDesignGeometry, MobileDesignTypography } from '../../generated/MobileDesignTokens'; -import { RemoteSession } from '../../model/RemoteModels'; import { RemoteI18n } from '../../i18n/RemoteI18n'; -import { - CARD, - INK, - LINE, - MUTED, - PAGE_BG, - PAGE_BG_FADE, - SCRIM, - SHADOW_FAINT, - SHADOW_SUBTLE, - SOFT, - SUBTLE, - TRANSPARENT -} from './Theme'; +import { CARD, INK, LINE, PAGE_BG, PAGE_BG_FADE, SHADOW_FAINT, SHADOW_SUBTLE, SOFT, SUBTLE, TRANSPARENT } from './Theme'; import { SidebarToggleButton } from './SidebarToggleButton'; -import { SessionActionPresentation, SessionActionSurface } from './SessionActionSurface'; -import { AdaptiveSheetOptions } from './AdaptiveSheetOptions'; -import { - SettingsPlacement, - SettingsPlacementPolicy, - SettingsSheetKind -} from '../policy/SettingsPlacementPolicy'; -import { SessionActionCapabilities, SessionActionPolicy, SessionActionScope } from '../policy/SessionActionPolicy'; -import { SessionDetailsView } from './SessionDetailsView'; import { SidebarGlyph } from './SidebarGlyphs'; import { SidebarConnectionActionPolicy } from '../policy/SidebarConnectionActionPolicy'; -/** - * How many conversations show before the rest go behind an overflow row. - * - * The conversations and the workspaces share one scroll. Recent history comes - * after the device/workspace navigation, so cap each expansion to keep the - * bottom section browseable without turning the sidebar into an unbounded list. - */ -const RECENT_BATCH: number = 6; - +/** Device and workspace navigation shared by compact and wide controllers. */ @ComponentV2 export struct AppSidebar { - @Param sessions: RemoteSession[] = []; - @Param sessionDetailsPlacement: SettingsPlacement = - SettingsPlacementPolicy.compactBottom(SettingsSheetKind.SessionDetails); - @Param pinnedSessionId: string = ''; - @Param selectedSessionId: string = ''; @Param connectionState: string = 'idle'; @Param accountUserId: string = ''; @Param controlTargetType: string = 'none'; @Param showCollapseButton: boolean = false; @Param showViewSettingsButton: boolean = false; @Param showWorkspaceSection: boolean = false; - @Param showConversationSection: boolean = true; @Event onClose: () => void = () => {}; @Event onNewChat: () => void = () => {}; @Event onEnterCode: () => void = () => {}; @@ -60,21 +23,10 @@ export struct AppSidebar { @Event onSearchQueryChange: (query: string) => void = (_query: string) => {}; @Event onOpenSettings: () => void = () => {}; @Event onOpenAccount: () => void = () => {}; - @Event onOpenSession: (session: RemoteSession) => void = (_session: RemoteSession) => {}; - @Event onArchiveSession: (session: RemoteSession, archived: boolean) => void = - (_session: RemoteSession, _archived: boolean) => {}; - @Event onExportSession: (session: RemoteSession) => void = (_session: RemoteSession) => {}; - @Event onDeleteSession: (session: RemoteSession) => void = (_session: RemoteSession) => {}; - @Local activeActionSessionId: string = ''; - @Local showSessionActionSheet: boolean = false; - @Local detailsSessionId: string = ''; - @Local showSessionDetails: boolean = false; @Local showSearch: boolean = false; @Local sessionSearchQuery: string = ''; - @Local archivedSessionsExpanded: boolean = false; - @Local visibleRecentCount: number = RECENT_BATCH; /** - * The device/workspace section, rendered before recent conversations. + * The authenticated device/workspace section. * * It is a slot rather than a component this file constructs because the * sidebar has no business knowing about remote state; the host wires it. @@ -95,17 +47,12 @@ export struct AppSidebar { } Stack({ alignContent: Alignment.Bottom }) { - // Places are the primary navigation, so devices and workspaces come - // first. Recent conversations are history and stay at the bottom of - // the same scroll instead of pushing the current working context down. + // Device sections own their remote workspace and session rows. Scroll() { Column() { if (this.showWorkspaceSection) { this.contentSlot() } - if (this.showConversationSection) { - this.ConversationSection() - } } .width('100%') .alignItems(HorizontalAlign.Start) @@ -144,75 +91,12 @@ export struct AppSidebar { .height('100%') .padding({ left: 20, right: 20, top: 4, bottom: 16 }) .backgroundColor(PAGE_BG) - .bindSheet($$this.showSessionActionSheet, this.SessionActionSheet(), this.sessionActionSheetOptions()) - .bindSheet($$this.showSessionDetails, this.SessionDetailsSheet(), this.sessionDetailsSheetOptions()) } @Builder private EmptySlot() { } - @Builder - ConversationSection() { - Column() { - if (this.visiblePinnedSessions().length > 0) { - Text(RemoteI18n.t('sidebar.pinned')) - .fontSize(MobileDesignTypography.bodyMedium.size).fontWeight(FontWeight.Medium).fontColor(MUTED) - .width('100%').margin({ top: 16, bottom: 6 }) - ForEach(this.visiblePinnedSessions(), (session: RemoteSession) => { - this.PinnedRow(session) - }, (session: RemoteSession) => session.id) - } - - Text(RemoteI18n.t('sidebar.conversations')) - .fontSize(MobileDesignTypography.bodyMedium.size) - .fontWeight(FontWeight.Medium) - .fontColor(MUTED) - .width('100%') - .margin({ top: 16, bottom: 6 }) - - if (this.visibleRecentSessions().length === 0) { - this.EmptyRecent() - } - ForEach(this.cappedRecentSessions(), (session: RemoteSession) => { - this.RecentRow(session) - }, (session: RemoteSession) => session.id) - if (this.visibleRecentCount < this.visibleRecentSessions().length) { - this.MoreConversationsRow() - } - if (this.archivedSessionCount() > 0) { - this.ArchivedDisclosureRow() - } - if (this.archivedSessionsExpanded) { - ForEach(this.visibleArchivedSessions(), (session: RemoteSession) => { - this.RecentRow(session) - }, (session: RemoteSession) => session.id) - } - } - .width('100%') - .alignItems(HorizontalAlign.Start) - } - - @Builder - private MoreConversationsRow() { - Row({ space: 8 }) { - Text('···') - .fontSize(MobileDesignTypography.bodySmall.size) - .fontColor(MUTED) - Text(RemoteI18n.f('sidebar.moreSessions', - String(this.visibleRecentSessions().length - this.visibleRecentCount))) - .fontSize(MobileDesignTypography.bodySmall.size) - .fontColor(MUTED) - } - .width('100%') - .height(40) - .padding({ left: 12 }) - .alignItems(VerticalAlign.Center) - .onClick(() => { - this.visibleRecentCount += RECENT_BATCH; - }) - } - @Builder private PrimaryNavigationHeader() { Row() { @@ -297,12 +181,12 @@ export struct AppSidebar { private SignedOutHeader() { Row() { Row({ space: 14 }) { - SymbolGlyph($r('sys.symbol.square_and_pencil')) + SymbolGlyph($r('sys.symbol.desktop')) .fontSize(23) .fontColor([INK]) .width(24) .height(24) - Text(RemoteI18n.t('sidebar.signedOutNewChat')) + Text(RemoteI18n.t('sidebar.devices')) .fontSize(MobileDesignTypography.bodyLarge.size) .fontWeight(FontWeight.Medium) .fontColor(INK) @@ -331,8 +215,8 @@ export struct AppSidebar { Row() { Button() { Row({ space: 8 }) { - SidebarGlyph({ kind: 'edit' }) - Text(RemoteI18n.t('sidebar.newChat')) + SidebarGlyph({ kind: 'remote' }) + Text(RemoteI18n.t('sidebar.devices')) .fontSize(MobileDesignTypography.titleMedium.size) .fontWeight(FontWeight.Medium) .fontColor(INK) @@ -417,333 +301,6 @@ export struct AppSidebar { .zIndex(2) } - @Builder - ArchivedDisclosureRow() { - Row({ space: 12 }) { - SymbolGlyph($r('sys.symbol.archivebox')) - .fontSize(20) - .fontColor([MUTED]) - .width(22) - .height(22) - Text(RemoteI18n.t('sidebar.archived')) - .fontSize(MobileDesignTypography.bodyMedium.size) - .fontWeight(FontWeight.Medium) - .fontColor(INK) - Blank() - Row({ space: 8 }) { - Text(`${this.archivedSessionCount()}`) - .fontSize(MobileDesignTypography.labelSmall.size) - .fontWeight(FontWeight.Medium) - .fontColor(MUTED) - .width(24) - .height(22) - .textAlign(TextAlign.Center) - .backgroundColor(SOFT) - .borderRadius(11) - Stack({ alignContent: Alignment.Center }) { - if (this.archivedSessionsExpanded) { - SymbolGlyph($r('sys.symbol.chevron_up')) - .fontSize(13) - .fontColor([MUTED]) - } else { - SymbolGlyph($r('sys.symbol.chevron_down')) - .fontSize(13) - .fontColor([MUTED]) - } - } - .width(20) - .height(22) - } - .width(52) - .height(22) - .alignItems(VerticalAlign.Center) - } - .width('100%') - .height(46) - .padding({ left: 12, right: 68 }) - .margin({ top: 8 }) - .backgroundColor(this.archivedSessionsExpanded ? SOFT : TRANSPARENT) - .borderRadius(10) - .onClick(() => { - this.archivedSessionsExpanded = !this.archivedSessionsExpanded; - this.closeSessionActions(); - }) - } - - @Builder - PinnedRow(session: RemoteSession) { - Row({ space: 8 }) { - SymbolGlyph($r('sys.symbol.checkmark_circle')) - .fontSize(18).fontColor([MUTED]).width(19).height(19) - Text(session.title || RemoteI18n.t('sidebar.untitled')) - .fontSize(MobileDesignTypography.titleSmall.size).fontColor(INK).layoutWeight(1) - .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis }) - this.SessionMoreButton(session) - } - .width('100%').height(44) - .padding({ left: 12, right: 4 }) - .backgroundColor(this.selectedSessionId === session.id ? SOFT : TRANSPARENT) - .borderRadius(10) - .onClick(() => this.openSession(session)) - .gesture(LongPressGesture({ repeat: false }).onAction(() => this.openSessionActions(session))) - .bindPopup(this.showCollapseButton && this.activeActionSessionId === session.id, { - builder: () => { this.SessionActionPopover() }, - placement: Placement.Right, - popupColor: TRANSPARENT, - enableArrow: false, - autoCancel: true, - mask: false, - targetSpace: 6, - onStateChange: (event) => { - if (!event.isVisible) { - this.closeSessionActions(); - } - } - }) - } - - @Builder - RecentRow(item: RemoteSession) { - Row({ space: 8 }) { - Text(item.title || RemoteI18n.t('sidebar.untitled')) - .fontSize(MobileDesignTypography.titleSmall.size) - .fontWeight(FontWeight.Regular) - .fontColor(INK) - .layoutWeight(1) - .maxLines(1) - .textOverflow({ overflow: TextOverflow.Ellipsis }) - this.SessionMoreButton(item) - } - .width('100%') - .height(44) - .padding({ left: 12, right: 4 }) - .backgroundColor(this.selectedSessionId === item.id || this.activeActionSessionId === item.id ? - SOFT : TRANSPARENT) - .borderRadius(10) - .onClick(() => { - this.openSession(item); - }) - .gesture(LongPressGesture({ repeat: false }).onAction(() => this.openSessionActions(item))) - .bindPopup(this.showCollapseButton && this.activeActionSessionId === item.id, { - builder: () => { this.SessionActionPopover() }, - placement: Placement.Right, - popupColor: TRANSPARENT, - enableArrow: false, - autoCancel: true, - mask: false, - targetSpace: 6, - onStateChange: (event) => { - if (!event.isVisible) { - this.closeSessionActions(); - } - } - }) - } - - @Builder - private SessionMoreButton(session: RemoteSession) { - Stack({ alignContent: Alignment.Center }) { - SidebarGlyph({ kind: 'session_more' }) - } - .width(34) - .height(40) - .opacity(0.62) - .accessibilityText(RemoteI18n.t('session.actions')) - .onClick(() => this.openSessionActions(session)) - } - - @Builder - private SessionActionSheet() { - this.SessionActionContent(SessionActionPresentation.BottomSheet) - } - - @Builder - private SessionActionPopover() { - this.SessionActionContent(SessionActionPresentation.Popover) - } - - @Builder - private SessionActionContent(presentation: SessionActionPresentation) { - SessionActionSurface({ - presentation, - sessionTitle: this.actionSessionTitle(), - archived: this.actionSessionArchived(), - canViewDetails: this.actionCapabilities().canViewDetails, - canArchive: this.actionCapabilities().canArchive, - canExport: this.actionCapabilities().canExport, - canDelete: this.actionCapabilities().canDelete, - onViewDetails: () => this.openActionSessionDetails(), - onArchive: () => this.archiveActionSession(), - onExport: () => this.exportActionSession(), - onDelete: () => this.deleteActionSession(), - onClose: () => this.closeSessionActions() - }) - } - - @Builder - private SessionDetailsSheet() { - SessionDetailsView({ - session: this.detailsSession(), - onClose: () => this.closeSessionDetails() - }) - } - - private openSession(item: RemoteSession): void { - if (item.agentType === 'chat' || - this.connectionState === 'connected' || this.connectionState === 'reconnecting') { - this.onOpenSession(item); - return; - } - this.onEnterCode(); - } - - @Builder - EmptyRecent() { - Column() { - Text(this.sessionSearchQuery.trim().length > 0 ? - RemoteI18n.t('sidebar.noSearchResult') : RemoteI18n.t('sidebar.emptyRecentTitle')) - .fontSize(MobileDesignTypography.bodyMedium.size) - .fontWeight(FontWeight.Medium) - .fontColor(MUTED) - .width('100%') - } - .width('100%') - .padding({ top: 8, bottom: 8 }) - } - - private visibleRecentSessions(): RemoteSession[] { - const query = this.sessionSearchQuery.trim().toLowerCase(); - return this.sessions.filter((session: RemoteSession) => { - if (session.agentType !== 'chat' || session.status === 'archived' || session.id === this.pinnedSessionId) { - return false; - } - return query.length === 0 || session.title.toLowerCase().indexOf(query) >= 0; - }); - } - - private cappedRecentSessions(): RemoteSession[] { - const recent = this.visibleRecentSessions(); - return recent.length <= this.visibleRecentCount ? recent : recent.slice(0, this.visibleRecentCount); - } - - private visibleArchivedSessions(): RemoteSession[] { - const query = this.sessionSearchQuery.trim().toLowerCase(); - return this.sessions.filter((session: RemoteSession) => { - return session.agentType === 'chat' && session.status === 'archived' && - (query.length === 0 || session.title.toLowerCase().indexOf(query) >= 0); - }); - } - - private archivedSessionCount(): number { - return this.sessions.filter((session: RemoteSession) => { - return session.agentType === 'chat' && session.status === 'archived'; - }).length; - } - - private visiblePinnedSessions(): RemoteSession[] { - const query = this.sessionSearchQuery.trim().toLowerCase(); - return this.sessions.filter((session: RemoteSession) => { - return session.agentType === 'chat' && session.id === this.pinnedSessionId && - session.status !== 'archived' && - (query.length === 0 || session.title.toLowerCase().indexOf(query) >= 0); - }); - } - - private openSessionActions(session: RemoteSession): void { - this.activeActionSessionId = session.id; - if (!this.showCollapseButton) { - this.showSessionActionSheet = true; - } - } - - private closeSessionActions(): void { - this.showSessionActionSheet = false; - this.activeActionSessionId = ''; - } - - private actionSession(): RemoteSession | undefined { - return this.sessions.find((session: RemoteSession) => session.id === this.activeActionSessionId); - } - - private actionSessionTitle(): string { - const session = this.actionSession(); - return session ? session.title : ''; - } - - private actionSessionArchived(): boolean { - const session = this.actionSession(); - return session ? session.status === 'archived' : false; - } - - private actionSessionIsGeneralChat(): boolean { - const session = this.actionSession(); - return session ? session.agentType === 'chat' : false; - } - - private actionCapabilities(): SessionActionCapabilities { - const session = this.actionSession(); - return SessionActionPolicy.resolve( - SessionActionScope.General, - session ? session.agentType : '', - session === undefined - ); - } - - private archiveActionSession(): void { - const session = this.actionSession(); - if (session) { - this.onArchiveSession(session, session.status !== 'archived'); - } - } - - private exportActionSession(): void { - const session = this.actionSession(); - if (session) { - this.onExportSession(session); - } - } - - private deleteActionSession(): void { - const session = this.actionSession(); - if (session) { - this.onDeleteSession(session); - } - } - - private openActionSessionDetails(): void { - const session = this.actionSession(); - if (session) { - this.detailsSessionId = session.id; - this.showSessionDetails = true; - } - } - - private closeSessionDetails(): void { - this.showSessionDetails = false; - this.detailsSessionId = ''; - } - - private detailsSession(): RemoteSession { - const session = this.sessions.find((item: RemoteSession) => item.id === this.detailsSessionId); - return session || { - id: '', title: '', agentType: '', status: '', updatedAt: '', createdAt: '', messageCount: 0 - }; - } - - private sessionActionSheetOptions(): SheetOptions { - return { - height: this.actionSessionIsGeneralChat() ? 380 : 300, - backgroundColor: TRANSPARENT, - maskColor: SCRIM, - showClose: false, - dragBar: false - }; - } - - private sessionDetailsSheetOptions(): SheetOptions { - return AdaptiveSheetOptions.fromPlacement(this.sessionDetailsPlacement); - } - private shouldShowPrimaryNavigation(): boolean { return SidebarConnectionActionPolicy.shouldShowPrimaryNavigation( this.accountUserId, diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConnectManualPairingOverlay.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConnectManualPairingOverlay.ets index 76b408d300..93ea10deca 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConnectManualPairingOverlay.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConnectManualPairingOverlay.ets @@ -25,14 +25,12 @@ export struct ConnectManualPairingOverlay { .onClick(this.onCancel) Column({ space: 20 }) { - Text(this.requiresAccountAuth ? - RemoteI18n.t('connect.accountPairTitle') : RemoteI18n.t('connect.manualPair')) + Text(RemoteI18n.t('connect.manualPair')) .fontSize(MobileDesignTypography.displayLarge.size) .fontWeight(FontWeight.Bold) .fontColor(INK) .width('100%') - Text(this.requiresAccountAuth ? - RemoteI18n.t('connect.accountPairIntro') : RemoteI18n.t('connect.manualPairBody')) + Text(RemoteI18n.t('connect.manualPairBody')) .fontSize(MobileDesignTypography.titleMedium.size) .lineHeight(MobileDesignTypography.titleMedium.lineHeight) .fontColor(MUTED) @@ -48,37 +46,6 @@ export struct ConnectManualPairingOverlay { .padding({ left: 20, right: 20 }) .defaultFocus(true) .onChange(this.onRemoteUrlChange) - if (this.requiresAccountAuth) { - TextInput({ - placeholder: RemoteI18n.t('connect.accountUsernamePlaceholder'), - text: this.userIdInput - }) - .height(56) - .fontSize(MobileDesignTypography.bodyLarge.size) - .fontColor(INK) - .backgroundColor(CARD) - .border({ width: 0.5, color: LINE }) - .borderRadius(28) - .shadow({ radius: 14, color: SHADOW_FAINT, offsetY: 5 }) - .padding({ left: 20, right: 20 }) - .onChange(this.onUserIdChange) - TextInput({ placeholder: RemoteI18n.t('connect.accountPasswordPlaceholder'), text: this.password }) - .height(56) - .fontSize(MobileDesignTypography.bodyLarge.size) - .fontColor(INK) - .backgroundColor(CARD) - .border({ width: 0.5, color: LINE }) - .borderRadius(28) - .shadow({ radius: 14, color: SHADOW_FAINT, offsetY: 5 }) - .padding({ left: 20, right: 20 }) - .type(InputType.Password) - .onChange(this.onPasswordChange) - Text(RemoteI18n.t('connect.accountPairBody')) - .fontSize(MobileDesignTypography.bodySmall.size) - .lineHeight(MobileDesignTypography.bodySmall.lineHeight) - .fontColor(MUTED) - .width('100%') - } Row({ space: 12 }) { AppActionButton({ label: RemoteI18n.t('common.cancel'), diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConnectView.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConnectView.ets index 3f7b590618..1bdc8f5441 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConnectView.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConnectView.ets @@ -440,7 +440,7 @@ export struct ConnectView { onSubmit: () => { this.ensureUserId(); this.showManualPairing = false; - this.onConnect(this.accountPassword); + this.onConnect(); } }) } @@ -492,17 +492,8 @@ export struct ConnectView { return; } if (this.remoteUrl.trim().length > 0) { - if (this.requiresAccountAuth && this.accountPassword.length === 0) { - if (this.isAccountAuthenticated()) { - this.pairingStep = ConnectSheetStep.Account; - return; - } - this.showManualPairing = true; - this.onRemoteUrlInputVisibleChange(true); - return; - } this.ensureUserId(); - this.onConnect(this.accountPassword); + this.onConnect(); return; } this.onRemoteUrlInputVisibleChange(true); @@ -511,13 +502,7 @@ export struct ConnectView { } private canConnect(): boolean { - if (this.remoteUrl.trim().length === 0 || this.isBusy) { - return false; - } - if (!this.requiresAccountAuth) { - return true; - } - return this.displayUserIdInput().trim().length > 0 && this.accountPassword.length > 0; + return this.remoteUrl.trim().length > 0 && !this.isBusy; } private closeManualPairing(): void { @@ -563,22 +548,11 @@ export struct ConnectView { private handleScannedRemoteUrl(text: string): void { const action = this.onRemoteUrlDetected(text); - if (action === DetectedUrlAction.PROMPT_ACCOUNT_PASSWORD) { - this.showManualPairing = true; - this.onRemoteUrlInputVisibleChange(true); - this.pairingStep = ConnectSheetStep.Scan; - return; - } if (action === DetectedUrlAction.SHOW_CLOUD_DEVICES || action === DetectedUrlAction.USE_CLOUD_DEVICE) { this.pairingStep = ConnectSheetStep.Account; return; } - if (action === DetectedUrlAction.PAIR_NOW) { - this.ensureUserId(); - this.onConnect(); - return; - } this.inlineScanError = this.statusText || RemoteI18n.t('connect.hintInvalidLink'); this.scanRestartRevision += 1; } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationRouteSurface.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationRouteSurface.ets index ca77442803..8d4a31a0f5 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationRouteSurface.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationRouteSurface.ets @@ -19,7 +19,7 @@ import { PAGE_BG } from './Theme'; @ComponentV2 export struct ConversationRouteSurface { - @Param route: AppRoute = AppRoute.ChatHome; + @Param route: AppRoute = AppRoute.RemoteHome; @Param remotePageState: RemotePageState = new RemotePageState(); @Param generalPageState: GeneralChatPageState = new GeneralChatPageState(); @Param filePreviewState: FilePreviewState = new FilePreviewState(); @@ -34,7 +34,7 @@ export struct ConversationRouteSurface { build() { Column() { - if (this.route === AppRoute.RemoteHome) { + if (this.route !== AppRoute.RemoteChat) { RemoteSurfaceHost({ mode: RemoteSurfaceMode.CompactHome, remotePageState: this.remotePageState, diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/OpenBitFunAccountLoginPage.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/OpenBitFunAccountLoginPage.ets index bc19a45d5a..c05fb1baea 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/OpenBitFunAccountLoginPage.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/OpenBitFunAccountLoginPage.ets @@ -23,16 +23,15 @@ const CREDENTIAL_ROW_HEIGHT: number = 60; @ComponentV2 export struct OpenBitFunAccountLoginPage { - @Event cloudLogin: (relayUrl: string, username: string, password: string) => Promise = - async (_relayUrl: string, _username: string, _password: string): Promise => ''; + @Event cloudLogin: () => Promise = + async (): Promise => ''; + @Event cloudCancelLogin: () => void = () => {}; @Event onClose: () => void = () => {}; @Event onLoginSuccess: () => void = () => {}; - @Local relayUrl: string = DEFAULT_CLOUD_RELAY_URL; - @Local username: string = ''; - @Local password: string = ''; @Local errorText: string = ''; @Local isBusy: boolean = false; - @Local showAdvanced: boolean = false; + + aboutToDisappear(): void { this.cloudCancelLogin(); } build() { Column({ space: 0 }) { @@ -88,8 +87,6 @@ export struct OpenBitFunAccountLoginPage { .textAlign(TextAlign.Center) .margin({ top: 8, bottom: 24 }) - this.CredentialCard() - this.AdvancedCard() if (this.errorText.length > 0) { Text(this.errorText) @@ -105,150 +102,14 @@ export struct OpenBitFunAccountLoginPage { .alignItems(HorizontalAlign.Center) } - @Builder - private CredentialCard() { - Column() { - Row({ space: 12 }) { - SymbolGlyph($r('sys.symbol.person')) - .fontSize(21) - .fontColor([MUTED]) - .width(24) - .height(24) - TextInput({ - placeholder: RemoteI18n.t('connect.accountUsernamePlaceholder'), - text: this.username - }) - .height(CREDENTIAL_ROW_HEIGHT) - .layoutWeight(1) - .fontSize(MobileDesignTypography.bodyLarge.size) - .fontColor(INK) - .placeholderColor(SUBTLE) - .backgroundColor(PAGE_BG_FADE) - .padding({ left: 0, right: 4 }) - .onChange((value: string) => { this.username = value; }) - } - .width('100%') - .height(CREDENTIAL_ROW_HEIGHT) - .padding({ left: 18, right: 12 }) - .alignItems(VerticalAlign.Center) - - Divider() - .strokeWidth(1) - .color(LINE) - .margin({ left: 54, right: 16 }) - - Row({ space: 12 }) { - SymbolGlyph($r('sys.symbol.lock')) - .fontSize(20) - .fontColor([MUTED]) - .width(24) - .height(24) - TextInput({ - placeholder: RemoteI18n.t('connect.accountPasswordPlaceholder'), - text: this.password - }) - .height(CREDENTIAL_ROW_HEIGHT) - .layoutWeight(1) - .fontSize(MobileDesignTypography.bodyLarge.size) - .fontColor(INK) - .placeholderColor(SUBTLE) - .backgroundColor(PAGE_BG_FADE) - .padding({ left: 0, right: 4 }) - .type(InputType.Password) - .showPasswordIcon(true) - .onChange((value: string) => { this.password = value; }) - } - .width('100%') - .height(CREDENTIAL_ROW_HEIGHT) - .padding({ left: 18, right: 12 }) - .alignItems(VerticalAlign.Center) - } - .width('100%') - .backgroundColor(CARD) - .border({ width: 1, color: LINE }) - .borderRadius(24) - .shadow({ radius: 16, color: SHADOW_FAINT, offsetY: 5 }) - .clip(true) - } - - @Builder - private AdvancedCard() { - Column() { - Row({ space: 14 }) { - SymbolGlyph($r('sys.symbol.gearshape')) - .fontSize(21) - .fontColor([MUTED]) - .width(24) - .height(24) - Text(RemoteI18n.t('sheet.advancedOptions')) - .fontSize(MobileDesignTypography.titleSmall.size) - .fontWeight(FontWeight.Medium) - .fontColor(INK) - .layoutWeight(1) - SymbolGlyph(this.showAdvanced ? - $r('sys.symbol.chevron_up') : $r('sys.symbol.chevron_down')) - .fontSize(13) - .fontColor([MUTED]) - } - .width('100%') - .height(58) - .padding({ left: 18, right: 18 }) - .alignItems(VerticalAlign.Center) - .onClick(() => { - this.showAdvanced = !this.showAdvanced; - }) - - if (this.showAdvanced) { - Divider() - .strokeWidth(1) - .color(LINE) - .margin({ left: 18, right: 18 }) - - Column({ space: 8 }) { - Text(RemoteI18n.t('remote.settings.loginServer')) - .fontSize(MobileDesignTypography.labelSmall.size) - .fontColor(MUTED) - .width('100%') - - TextInput({ - placeholder: RemoteI18n.t('remote.settings.relayUrlPlaceholder'), - text: this.relayUrl - }) - .height(48) - .fontSize(MobileDesignTypography.bodyMedium.size) - .fontColor(INK) - .placeholderColor(SUBTLE) - .backgroundColor(PAGE_BG_FADE) - .border({ width: 1, color: LINE }) - .borderRadius(14) - .padding({ left: 14, right: 14 }) - .onChange((value: string) => { this.relayUrl = value; }) - } - .width('100%') - .padding({ left: 18, right: 18, top: 14, bottom: 18 }) - .alignItems(HorizontalAlign.Start) - } - } - .width('100%') - .backgroundColor(CARD) - .border({ width: 1, color: LINE }) - .borderRadius(20) - .clip(true) - .margin({ top: 14 }) - } - - private canSubmit(): boolean { - return !this.isBusy && this.relayUrl.trim().length > 0 && - this.username.trim().length > 0 && this.password.length > 0; - } + private canSubmit(): boolean { return !this.isBusy; } private async submit(): Promise { if (!this.canSubmit()) return; this.isBusy = true; this.errorText = ''; try { - await this.cloudLogin(this.relayUrl, this.username, this.password); - this.password = ''; + await this.cloudLogin(); this.onLoginSuccess(); } catch (err) { this.errorText = err instanceof Error ? err.message : RemoteI18n.t('remote.settings.accountLoginFailed'); diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteControlSettingsSheet.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteControlSettingsSheet.ets index 4e25bf41ac..53eb5c79d4 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteControlSettingsSheet.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteControlSettingsSheet.ets @@ -31,7 +31,8 @@ export struct RemoteControlSettingsSheet { @Event onClose: () => void = () => {}; @Event onOpenAccount: () => void = () => {}; @Event onAddConnection: () => void = () => {}; - @Event cloudLogin: (relayUrl: string, username: string, password: string) => Promise = async (_relayUrl: string, _username: string, _password: string): Promise => ''; + @Event cloudCancelLogin: () => void = () => {}; + @Event cloudLogin: () => Promise = async (): Promise => ''; @Event cloudLogout: () => Promise = async (): Promise => {}; @Event cloudListDevices: () => Promise = async (): Promise => []; @Event cloudSelectDevice: (device: CloudAccountDevice) => Promise = @@ -434,6 +435,7 @@ export struct RemoteControlSettingsSheet { private LoginPage() { OpenBitFunAccountLoginPage({ cloudLogin: this.cloudLogin, + cloudCancelLogin: this.cloudCancelLogin, onClose: (): void => this.leaveAccountPage(), onLoginSuccess: (): void => { this.showLogin = false; diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SettingsSheet.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SettingsSheet.ets index c5e60b8d96..52b43d857b 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SettingsSheet.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SettingsSheet.ets @@ -1,12 +1,9 @@ import { MobileDesignGeometry, MobileDesignTypography } from '../../generated/MobileDesignTokens'; import { RemoteI18n } from '../../i18n/RemoteI18n'; -import { RemoteModelCatalog, RemoteModelConfig } from '../../model/RemoteModels'; -import { ModelServiceSettingsPolicy } from '../policy/ModelServiceSettingsPolicy'; import { SettingsSheetState } from '../state/SettingsSheetState'; import { CARD, INK, LINE, MUTED, PAGE_BG, SOFT, STATUS_DANGER } from './Theme'; import { AccountProfilePanel } from './AccountProfilePanel'; import { LanguageSettingsPanel } from './LanguageSettingsPanel'; -import { ModelServiceSettingsPanel } from './ModelServiceSettingsPanel'; import { SheetCloseHeader } from './SheetCloseHeader'; import { SHEET_HORIZONTAL_PADDING, SHEET_TOP_RADIUS } from './SheetLayout'; import { CloudAccountDevice } from '../../services/CloudAccountClient'; @@ -14,15 +11,6 @@ import { CloudAccountDevice } from '../../services/CloudAccountClient'; @ComponentV2 export struct SettingsSheet { @Param sheetState: SettingsSheetState = new SettingsSheetState(); - @Param generalChatApiUrl: string = ''; - @Param generalChatModelName: string = ''; - @Param hasGeneralChatApiKey: boolean = false; - @Param generalChatModelCatalog: RemoteModelCatalog = { - version: 0, - models: [], - default_models: {} - }; - @Param selectedGeneralChatModelId: string = ''; @Param accountUsername: string = ''; @Param authenticatedUserId: string = ''; @Param fallbackUserId: string = ''; @@ -43,43 +31,10 @@ export struct SettingsSheet { @Event cloudSelectDevice: (device: CloudAccountDevice) => Promise = async (_device: CloudAccountDevice): Promise => {}; @Event onSetLanguage: (language: string) => Promise = async (_language: string) => {}; - @Event onSelectGeneralChatModel: (modelId: string) => Promise = async (_modelId: string) => false; - @Event onSaveGeneralChatConfig: ( - apiUrl: string, - apiKey: string, - modelName: string, - clearApiKey: boolean - ) => Promise = async ( - _apiUrl: string, - _apiKey: string, - _modelName: string, - _clearApiKey: boolean - ) => ''; - @Event onTestGeneralChatConfig: ( - apiUrl: string, - apiKey: string, - modelName: string, - clearApiKey: boolean - ) => Promise = async ( - _apiUrl: string, - _apiKey: string, - _modelName: string, - _clearApiKey: boolean - ) => ''; - aboutToAppear(): void { - this.syncGeneralChatConfig(this.generalChatApiUrl, this.generalChatModelName, this.hasGeneralChatApiKey); - } - build() { Stack({ alignContent: Alignment.TopEnd }) { if (this.sheetState.page === 'language') { this.LanguagePanel() - } else if (this.sheetState.page === 'overview') { - this.ModelOverviewSheet() - } else if (this.sheetState.page === 'account') { - this.AccountModelsSheet() - } else if (this.sheetState.page === 'local') { - this.LocalEditorSheet() } else { this.SettingsHome() } @@ -228,8 +183,6 @@ export struct SettingsSheet { private GeneralSettingsSection() { this.SectionTitle(RemoteI18n.t('settings.general.section')) this.GeneralCard() - this.SectionTitle(RemoteI18n.t('settings.model.section')) - this.ModelCard() } @Builder @@ -268,25 +221,6 @@ export struct SettingsSheet { .margin({ bottom: 24 }) } - @Builder - ModelCard() { - Column() { - this.SettingsRow( - RemoteI18n.t('settings.model.default'), - this.modelServiceStatus(), - true, - () => { - this.sheetState.setPage('overview'); - }, - $r('sys.symbol.square_grid_2x2') - ) - } - .width('100%') - .backgroundColor(CARD) - .borderRadius(MobileDesignGeometry.settingsCardRadius) - .margin({ bottom: 24 }) - } - @Builder AboutCard() { Column() { @@ -359,7 +293,7 @@ export struct SettingsSheet { LanguageSettingsPanel({ selectedLanguage: RemoteI18n.language(), onClose: () => { - this.sheetState.setPage(ModelServiceSettingsPolicy.parentSettingsPage('language')); + this.sheetState.setPage('main'); }, onSelect: (language: string) => { return this.onSetLanguage(language); @@ -367,93 +301,6 @@ export struct SettingsSheet { }) } - @Builder - ModelOverviewSheet() { - ModelServiceSettingsPanel({ - page: 'overview', - sheetState: this.sheetState, - apiUrl: this.sheetState.savedGeneralChatApiUrl, - modelName: this.sheetState.savedGeneralChatModelName, - hasApiKey: this.sheetState.savedGeneralChatHasApiKey, - modelCatalog: this.generalChatModelCatalog, - selectedModelId: this.selectedGeneralChatModelId, - onClose: () => { - this.sheetState.setPage(ModelServiceSettingsPolicy.parentSettingsPage('overview')); - }, - onOpenAccountModels: () => { - this.sheetState.setPage('account'); - }, - onOpenLocalEditor: () => { - this.sheetState.setPage('local'); - }, - onSelectModel: (modelId: string) => { - void this.selectGeneralChatModel(modelId); - } - }) - } - - @Builder - AccountModelsSheet() { - ModelServiceSettingsPanel({ - page: 'account', - sheetState: this.sheetState, - apiUrl: this.sheetState.savedGeneralChatApiUrl, - modelName: this.sheetState.savedGeneralChatModelName, - hasApiKey: this.sheetState.savedGeneralChatHasApiKey, - modelCatalog: this.generalChatModelCatalog, - selectedModelId: this.selectedGeneralChatModelId, - onClose: () => { - this.sheetState.setPage(ModelServiceSettingsPolicy.parentSettingsPage('account')); - }, - onSelectModel: (modelId: string) => { - void this.selectGeneralChatModel(modelId).then((selected: boolean) => { - if (selected) { - this.sheetState.setPage('overview'); - } - }); - } - }) - } - - @Builder - LocalEditorSheet() { - ModelServiceSettingsPanel({ - page: 'local', - sheetState: this.sheetState, - apiUrl: this.sheetState.savedGeneralChatApiUrl, - modelName: this.sheetState.savedGeneralChatModelName, - hasApiKey: this.sheetState.savedGeneralChatHasApiKey, - modelCatalog: this.generalChatModelCatalog, - selectedModelId: this.selectedGeneralChatModelId, - onClose: () => { - this.sheetState.setPage(ModelServiceSettingsPolicy.parentSettingsPage('local')); - }, - onSaved: ( - apiUrl: string, - modelName: string, - hasApiKey: boolean - ) => { - this.syncGeneralChatConfig(apiUrl, modelName, hasApiKey); - }, - onTest: ( - apiUrl: string, - apiKey: string, - modelName: string, - clearApiKey: boolean - ) => { - return this.onTestGeneralChatConfig(apiUrl, apiKey, modelName, clearApiKey); - }, - onSave: ( - apiUrl: string, - apiKey: string, - modelName: string, - clearApiKey: boolean - ) => { - return this.onSaveGeneralChatConfig(apiUrl, apiKey, modelName, clearApiKey); - } - }) - } - private hasControlTarget(): boolean { return this.controlTargetType === 'room' || this.controlTargetType === 'account_device'; } @@ -482,23 +329,4 @@ export struct SettingsSheet { RemoteI18n.t('remote.settings.accountDevice'); } - private modelServiceStatus(): string { - const model = this.selectedGeneralChatModel(); - return ModelServiceSettingsPolicy.modelLabel(model, RemoteI18n.t('settings.modelService.notConfigured')); - } - - private selectedGeneralChatModel(): RemoteModelConfig | undefined { - return ModelServiceSettingsPolicy.currentModel( - this.generalChatModelCatalog, - this.selectedGeneralChatModelId - ); - } - - private async selectGeneralChatModel(modelId: string): Promise { - return this.onSelectGeneralChatModel(modelId); - } - - private syncGeneralChatConfig(apiUrl: string, modelName: string, hasApiKey: boolean): void { - this.sheetState.syncSaved(apiUrl, modelName, hasApiKey); - } } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/WideConversationHost.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/WideConversationHost.ets index 0fe3683599..c14a78e9b9 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/WideConversationHost.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/WideConversationHost.ets @@ -38,7 +38,7 @@ const WIDE_DETAIL_CONTENT_MAX_WIDTH: number = 920; @ComponentV2 export struct WideConversationHost { - @Param route: AppRoute = AppRoute.ChatHome; + @Param route: AppRoute = AppRoute.RemoteHome; @Param shellState: AppShellState = new AppShellState(); @Param remotePageState: RemotePageState = new RemotePageState(); @Param deviceDirectoryState: DeviceDirectoryState = new DeviceDirectoryState(); @@ -65,9 +65,7 @@ export struct WideConversationHost { @Event onOpenRemoteViewSettings: () => void = () => {}; build() { - if (this.route === AppRoute.ChatHome || this.route === AppRoute.GeneralChat) { - this.GeneralChatContent(); - } else if (this.showsRemoteConversation() && + if (this.showsRemoteConversation() && this.filePreviewLayout.placement === FilePreviewPlacement.WideMasterPreviewFocus) { this.RemoteMasterPreviewFocusContent(); } else if (this.showsRemoteConversation() && @@ -80,18 +78,6 @@ export struct WideConversationHost { } } - @Builder - private GeneralChatContent() { - Row() { - if (!this.wideMasterPaneCollapsed) { - this.MasterPane() - this.MasterDetailGap() - } - this.ConversationDetail(false) - } - .width('100%').height('100%').backgroundColor(PAGE_BG) - } - @Builder private RemoteHomeContent() { Row() { @@ -144,17 +130,11 @@ export struct WideConversationHost { Column() { Column() { AppSidebar({ - sessions: this.generalPageState.recentSessions(), - sessionDetailsPlacement: this.sessionDetailsPlacement, - pinnedSessionId: this.generalPageState.pinnedSessionId(), - selectedSessionId: AppRouteContract.isGeneralComposerRoute(this.route) ? - this.generalPageState.conversation.activeSession.sessionId : '', connectionState: this.remotePageState.connectionState, accountUserId: this.remotePageState.accountUserId, showCollapseButton: true, showViewSettingsButton: false, showWorkspaceSection: true, - showConversationSection: true, contentSlot: () => { this.WorkspaceMasterContent() }, @@ -169,10 +149,6 @@ export struct WideConversationHost { }, onOpenSettings: this.actions.onSidebar.settings, onOpenAccount: this.actions.onSidebar.openAccount, - onOpenSession: this.actions.onSidebar.openSession, - onArchiveSession: this.actions.onSidebar.archive, - onExportSession: this.actions.onSidebar.exportSession, - onDeleteSession: this.actions.onSidebar.deleteSession }) } .width('100%').height('100%').backgroundColor(CARD) diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/navigation/AppRouteContract.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/navigation/AppRouteContract.ets index 6eac84fdea..ab625f238a 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/navigation/AppRouteContract.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/navigation/AppRouteContract.ets @@ -39,19 +39,24 @@ export class AppNavigationPathSpec { } export class AppRouteContract { + // Read legacy navigation names without reopening the retired local runtime. + static controllerRoute(route: AppRoute): AppRoute { + return route === AppRoute.RemoteChat ? AppRoute.RemoteChat : AppRoute.RemoteHome; + } + static currentRoute(pathNames: string[]): AppRoute { if (pathNames.length === 0) { - return AppRoute.ChatHome; + return AppRoute.RemoteHome; } - return pathNames[pathNames.length - 1] as AppRoute; + return AppRouteContract.controllerRoute(pathNames[pathNames.length - 1] as AppRoute); } static isGeneralComposerRoute(route: AppRoute): boolean { - return route === AppRoute.ChatHome || route === AppRoute.GeneralChat; + return false; } static isSessionRoute(route: AppRoute): boolean { - return route === AppRoute.GeneralChat || route === AppRoute.RemoteChat; + return route === AppRoute.RemoteChat; } /** @@ -89,6 +94,8 @@ export class AppRouteContract { } static pathSpec(currentRoute: AppRoute, route: AppRoute, sessionId: string = ''): AppNavigationPathSpec | undefined { + route = AppRouteContract.controllerRoute(route); + currentRoute = AppRouteContract.controllerRoute(currentRoute); if (currentRoute === route) { return undefined; } @@ -99,12 +106,9 @@ export class AppRouteContract { if (isSidebarOpen) { return AppNavigationBackAction.CloseSidebar; } - if (route === AppRoute.GeneralChat || route === AppRoute.RemoteChat) { + if (route === AppRoute.RemoteChat) { return AppNavigationBackAction.CloseActiveChat; } - if (route === AppRoute.RemoteHome) { - return AppNavigationBackAction.PopRemoteHome; - } return AppNavigationBackAction.AllowSystem; } } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntime.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntime.ets index 37ceee34b1..fcae894191 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntime.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntime.ets @@ -41,7 +41,6 @@ export class AppRootRuntime extends AppRootRuntimeComposition { await this.remoteChatCache.init(this.host.context()); await this.remoteSessionListCache.init(this.host.context()); await this.restoreCachedRemoteSessions(); - await this.generalChatBootstrapController.restore(this.host.context()); await this.settingsController.initializeCloudAccount(this.host.context()); if (this.settingsController.hasCloudAccountSession()) { try { @@ -51,7 +50,6 @@ export class AppRootRuntime extends AppRootRuntimeComposition { RemoteLogger.warn(`device directory restore failed: ${err instanceof Error ? err.message : String(err)}`); } } - await this.settingsController.refreshModelCatalog(); await this.restoreIdentity(); await this.startWatchProvisioning(); } @@ -98,7 +96,6 @@ export class AppRootRuntime extends AppRootRuntimeComposition { } this.remoteConnectionCoordinator.invalidate(); this.remotePageState.setBusy(false); - this.generalChatDraftLifecycleController.persistVisible(this.generalChatPageState.chatInput); } aboutToDisappear(): void { @@ -108,9 +105,6 @@ export class AppRootRuntime extends AppRootRuntimeComposition { this.remoteActivityViewModel.invalidate(); this.remoteConnectionCoordinator.invalidate(); this.remotePageState.setBusy(false); - this.generalChatDraftLifecycleController.persistVisible(this.generalChatPageState.chatInput); - this.generalChatConversationViewModel.stop(true, 'failed'); - this.generalChatDraftLifecycleController.cancel(); this.remoteFileDownloadController.cancel(); this.filePreviewController.close(); this.voiceInputLifecycleController.cancel(`${this.appShellViewModel.currentRoute()}`, () => { @@ -144,7 +138,7 @@ export class AppRootRuntime extends AppRootRuntimeComposition { return true; } if (action === AppNavigationBackAction.PopRemoteHome) { - this.appShellViewModel.popRoute(AppRoute.ChatHome); + this.appShellViewModel.popRoute(AppRoute.RemoteHome); return true; } return false; @@ -164,64 +158,23 @@ export class AppRootRuntime extends AppRootRuntimeComposition { async restoreIdentity(): Promise { - // Always restore the install identity first. Account-device startup used to - // return before this happened, so the phone presented a new random device - // id on every launch and could not reliably exclude itself from the list. + // Read the install id and old target hint without reconnecting an old room. + // The authenticated directory is the sole authority for restoring control. await this.remoteConnectionController.restoreWithoutReconnect(this.host.context()); - const controlTargetPreference = this.remoteConnectionController.controlTargetPreference(); - let preferredTarget = this.settingsController.preferredCloudTarget(); - - if (controlTargetPreference === 'room') { - await this.remoteConnectionController.reconnectRestoredPairing(); - if ((this.remotePageState.connectionState as ConnectionState) === ConnectionState.Connected) { - return; - } - // A rotated room may fall back only to the same physical desktop through - // the account path. Never jump to an unrelated remembered account device. - if (preferredTarget?.deviceId !== this.remotePageState.desktopId) { - preferredTarget = await this.settingsController.migrateLegacyPairingTarget( - this.remotePageState.remoteUrl, - this.remotePageState.desktopId, - this.remotePageState.desktopName - ); - } - if (preferredTarget?.deviceId === this.remotePageState.desktopId) { - await this.restoreAccountDeviceTarget(false); + if (!this.settingsController.hasCloudAccountSession()) { + this.remotePageState.clearControlTarget(); + this.remotePageState.setConnectionState(ConnectionState.Disconnected); + if (this.remotePageState.remoteUrl.length > 0) { + this.remotePageState.setStatusText(RemoteI18n.t('connect.legacySignInRequired')); } return; } - - if (!preferredTarget && this.settingsController.hasCloudAccountSession()) { - preferredTarget = await this.settingsController.migrateLegacyPairingTarget( - this.remotePageState.remoteUrl, - this.remotePageState.desktopId, - this.remotePageState.desktopName - ); - } - if (preferredTarget) { + if (this.settingsController.preferredCloudTarget()) { await this.restoreAccountDeviceTarget(false); - return; - } - if (this.settingsController.hasCloudAccountSession()) { - // Legacy records have no explicit control-target preference. With no - // stable same-device target, leave the user on the hydrated directory - // instead of guessing whether an old QR should supersede the account. - RemoteLogger.info('account session restored without a target preference; skipped ambiguous QR reconnect'); - return; } - await this.remoteConnectionController.reconnectRestoredPairing(); } - /** - * Reconnects to the desktop the signed-in account was last driving. - * - * The scanned-room path restores itself from its pairing snapshot, but the - * account path used to do nothing at launch: the phone came up holding a - * session list and a desktop name with no link behind either, and the only - * way back was for the user to walk the device picker again. Announcing - * `reconnecting` up front is what keeps that from reading as connected while - * the device lookup is still in flight. - */ + /** Restores only a device proven by the current authenticated directory. */ private async restoreAccountDeviceTarget(navigateHome: boolean): Promise { const preferredTarget = this.settingsController.preferredCloudTarget(); const deviceId = preferredTarget?.deviceId || this.remotePageState.controlTargetDeviceId.trim(); @@ -238,14 +191,7 @@ export class AppRootRuntime extends AppRootRuntimeComposition { } async connect(autoReconnect: boolean = false, accountPassword: string = ''): Promise { - await this.remoteConnectionController.connect(autoReconnect, accountPassword); - if ((this.remotePageState.connectionState as ConnectionState) !== ConnectionState.Connected) { - return; - } - await this.settingsController.persistDelegatedAccountSession( - this.remotePageState.desktopId, - this.remotePageState.desktopName - ); + this.handleDetectedRemoteUrl(this.remotePageState.remoteUrl); } async reconnect(): Promise { @@ -253,7 +199,7 @@ export class AppRootRuntime extends AppRootRuntimeComposition { await this.restoreAccountDeviceTarget(true); return; } - await this.remoteConnectionController.reconnect(); + this.appShellState.openSettings('account'); } async disconnect(clearPairing: boolean): Promise { @@ -403,7 +349,6 @@ export class AppRootRuntime extends AppRootRuntimeComposition { if (this.conversationController.visibleVoiceListening()) { await this.stopVoiceInput(false); } - this.generalChatDraftLifecycleController.persistVisible(this.generalChatPageState.chatInput); const activeRemoteSessionId = this.remotePageState.isConversationDismissed ? '' : (this.remotePageState.activeSession.sessionId || ''); const target = AppRouteContract.remoteSurfaceDestination( @@ -436,7 +381,6 @@ export class AppRootRuntime extends AppRootRuntimeComposition { if (this.conversationController.visibleVoiceListening()) { await this.stopVoiceInput(false); } - this.generalChatDraftLifecycleController.persistVisible(this.generalChatPageState.chatInput); const activeRemoteSessionId = RemoteUiState.canUseRemote((this.remotePageState.connectionState as ConnectionState)) && !this.remotePageState.isConversationDismissed ? (this.remotePageState.activeSession.sessionId || '') : ''; @@ -663,18 +607,7 @@ export class AppRootRuntime extends AppRootRuntimeComposition { } currentActiveTurnId(): string { - if (!this.appShellViewModel.isGeneralChatVisible()) { - return this.conversationController.remoteActiveTurnId(); - } - const activeTurnMessage = this.generalChatPageState.activeTurnMessage; - if (activeTurnMessage.turnId && activeTurnMessage.turnId.length > 0) { - return activeTurnMessage.turnId; - } - const activePrefix = 'active-'; - if (activeTurnMessage.id.indexOf(activePrefix) === 0) { - return activeTurnMessage.id.slice(activePrefix.length); - } - return ''; + return this.conversationController.remoteActiveTurnId(); } hasRemoteBindingForResume(): boolean { @@ -684,19 +617,13 @@ export class AppRootRuntime extends AppRootRuntimeComposition { (this.remotePageState.connectionState as ConnectionState) !== ConnectionState.Idle && (this.remotePageState.connectionState as ConnectionState) !== ConnectionState.Disconnected; } - return this.remotePageState.remoteUrl.trim().length > 0 && - this.remotePageState.userId.trim().length > 0 && - (this.remotePageState.connectionState as ConnectionState) !== ConnectionState.Idle && - (this.remotePageState.connectionState as ConnectionState) !== ConnectionState.Parsing && - (this.remotePageState.connectionState as ConnectionState) !== ConnectionState.Pairing && - (this.remotePageState.connectionState as ConnectionState) !== ConnectionState.Disconnected; + return false; } async reconnectActiveRemote(): Promise { const preferredTarget = this.settingsController.preferredCloudTarget(); if (!preferredTarget) { - await this.connect(true); - return; + throw new Error(RemoteI18n.t('connect.legacySignInRequired')); } const targetId = preferredTarget.deviceId; const device = (await this.settingsController.listCloudAccountDevices()) @@ -708,12 +635,7 @@ export class AppRootRuntime extends AppRootRuntimeComposition { } hasRemoteBindingForCodeHome(): boolean { - return this.remotePageState.remoteUrl.trim().length > 0 && - this.remotePageState.userId.trim().length > 0 && - ((this.remotePageState.connectionState as ConnectionState) === ConnectionState.Connected || - (this.remotePageState.connectionState as ConnectionState) === ConnectionState.Reconnecting || - (this.remotePageState.connectionState as ConnectionState) === ConnectionState.Pairing || - (this.remotePageState.connectionState as ConnectionState) === ConnectionState.Parsing); + return this.hasRemoteBindingForResume(); } } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntimeComposition.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntimeComposition.ets index 7867df838f..affaeaf11b 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntimeComposition.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntimeComposition.ets @@ -1,3 +1,4 @@ +import { accountDeviceLink } from '../../services/AccountDeviceLink'; import { ChatMessage, RemoteModelCatalog, @@ -15,21 +16,6 @@ import { ClipboardService } from '../../services/ClipboardService'; import { ConnectionErrorPolicy } from '../../services/ConnectionErrorPolicy'; import { ConnectionStatusPresenter } from '../../services/ConnectionStatusPresenter'; import { ImagePickerService } from '../../services/ImagePickerService'; -import { - GeneralChatConfigSnapshot, - GeneralChatConfigStore -} from '../../services/general-chat/GeneralChatConfigStore'; -import { GeneralChatBootstrapController } from '../../services/general-chat/GeneralChatBootstrapController'; -import { GeneralChatCommandController } from '../../services/general-chat/GeneralChatCommandController'; -import { GeneralChatController } from '../../services/general-chat/GeneralChatController'; -import { GeneralChatDraftController } from '../../services/general-chat/GeneralChatDraftController'; -import { GeneralChatDraftLifecycleController } from '../../services/general-chat/GeneralChatDraftLifecycleController'; -import { GeneralChatServiceStatus } from '../../services/general-chat/GeneralChatServiceState'; -import { GeneralChatStreamLifecycleController } from '../../services/general-chat/GeneralChatStreamLifecycleController'; -import { - GeneralChatSendResult, - GeneralChatStreamCallbacks -} from '../../services/general-chat/GeneralChatPort'; import { MobileIdentityStore } from '../../services/MobileIdentityStore'; import { CloudAccountClient, CloudAccountDevice } from '../../services/CloudAccountClient'; import { CloudAccountSessionStore } from '../../services/CloudAccountSessionStore'; @@ -46,7 +32,6 @@ import { LocaleController } from '../viewmodel/LocaleController'; import { ConversationController } from '../viewmodel/ConversationController'; import { RemoteLogger } from '../../services/RemoteLogger'; import { RemoteModelController } from '../../services/RemoteModelController'; -import { RemotePairingPolicy } from '../../services/RemotePairingPolicy'; import { DetectedUrlAction } from '../../services/ConnectScanDecisionPolicy'; import { RemoteSessionController } from '../../services/RemoteSessionController'; import { RemoteSessionListCache } from '../../services/RemoteSessionListCache'; @@ -94,8 +79,6 @@ import { FilePreviewState } from '../state/FilePreviewState'; import { FilePreviewRequest } from '../../model/FilePreviewTarget'; import { RemoteWorkspaceViewModel } from '../viewmodel/RemoteWorkspaceViewModel'; import { RemoteSessionViewModel } from '../viewmodel/RemoteSessionViewModel'; -import { GeneralChatConversationViewModel } from '../viewmodel/GeneralChatConversationViewModel'; -import { ModelProviderGeneralChatAdapter } from '../../services/general-chat/ModelProviderGeneralChatAdapter'; import { HarmonyTaskCompletionNotificationPort } from '../../services/HarmonyTaskCompletionNotificationPort'; import { TaskCompletionNotificationController, @@ -112,8 +95,6 @@ export enum ConnectionState { Disconnected = 'disconnected' } -const GENERAL_CHAT_HOME_DRAFT_ID: string = 'new-chat'; -const GENERAL_CHAT_DRAFT_SAVE_DELAY_MS: number = 250; export abstract class AppRootRuntimeComposition { readonly host: AppRootHostPort; @@ -175,12 +156,9 @@ export abstract class AppRootRuntimeComposition { readonly clipboardService: ClipboardService = new ClipboardService(); readonly qrScanService: QrScanService = new QrScanService(); readonly imagePickerService: ImagePickerService = new ImagePickerService(); - readonly remotePairingPolicy: RemotePairingPolicy = new RemotePairingPolicy(); readonly remoteConnectionCoordinator: RemoteConnectionCoordinator = new RemoteConnectionCoordinator( this.sessionManager, - this.identityStore, - this.remotePairingPolicy, this.remoteConnectionGate ); readonly generalChatPageState: GeneralChatPageState = new GeneralChatPageState(); @@ -188,64 +166,21 @@ export abstract class AppRootRuntimeComposition { readonly deviceDirectoryState: DeviceDirectoryState = new DeviceDirectoryState(); readonly watchProvisionState: WatchProvisionState = new WatchProvisionState(); private readonly watchProvisionPort: WatchProvisionPort = { - // Two ways to reach a credential, and the phone's own account is the - // better one: it works with the desktop asleep. The room channel stays as - // the fallback for a phone that only ever scanned a QR code and so has no - // account of its own to mint from. - canProvision: (): boolean => this.settingsController.canMintWatchCredential() || - this.canProvisionViaDesktop(), + canProvision: (): boolean => this.settingsController.canMintWatchCredential(), provision: (deviceId: string, deviceName: string, requestId: string, password: string): Promise => this.provisionWatchDevice(deviceId, deviceName, requestId, password) }; readonly watchProvisionController: WatchProvisionController = new WatchProvisionController(this.watchProvisionState, this.watchProvisionPort); - private canProvisionViaDesktop(): boolean { - return (this.remotePageState.connectionState as ConnectionState) === ConnectionState.Connected && - this.sessionManager.hasRoomChannel(); - } - - /** - * Mints the watch's credential, preferring this phone's own account. - * - * The relay gates minting on holding a device token, which a password login - * on this phone produces — the desktop was never uniquely entitled, it was - * just the only party the old code asked. Going direct means a watch can be - * onboarded with the desktop closed, and skips a 45-second round trip when - * it is open. - * - * `undefined` back from the account path means "not entitled" (a token - * delegated by a room pairing rather than a login), which is the one case - * worth falling back for. A network failure throws instead, so a blip is not - * reported as a missing desktop. - */ private async provisionWatchDevice( deviceId: string, deviceName: string, requestId: string, password: string ): Promise { - const minted = await this.settingsController.provisionWatchCredential(deviceId, deviceName, requestId, password); - if (minted) { - return minted; - } - if (!this.canProvisionViaDesktop()) { - return AppRootRuntimeComposition.provisionUnavailable(); - } - const outcome = await this.sessionManager.provisionPeerDevice(deviceId, deviceName, requestId); - return { - ok: outcome.ok, - passwordRequired: false, - // The desktop mints against the relay its room lives on, which is not - // necessarily the one this phone's account is on. - relayUrl: outcome.ok ? this.sessionManager.roomRelayEndpoint() : '', - token: outcome.token, - userId: outcome.userId, - masterKeyBase64: outcome.masterKeyBase64, - deviceId: outcome.deviceId, - failure: outcome.failure, - desktopReported: outcome.desktopReported - }; + return await this.settingsController.provisionWatchCredential(deviceId, deviceName, requestId, password) || + AppRootRuntimeComposition.provisionUnavailable(); } private static provisionUnavailable(): WatchProvisionOutcome { @@ -255,23 +190,6 @@ export abstract class AppRootRuntimeComposition { }; } readonly localePreferenceStore: LocalePreferenceStore = new LocalePreferenceStore(); - readonly generalChatConfigStore: GeneralChatConfigStore = new GeneralChatConfigStore(); - readonly generalChatController: GeneralChatController = - GeneralChatController.createDefault(this.generalChatConfigStore); - readonly generalChatDraftController: GeneralChatDraftController = - new GeneralChatDraftController( - this.generalChatController, - GENERAL_CHAT_DRAFT_SAVE_DELAY_MS, - (err: Error) => { - RemoteLogger.warn(`general chat draft operation failed: ${ConnectionErrorPolicy.errorText(err)}`); - } - ); - readonly generalChatDraftLifecycleController: GeneralChatDraftLifecycleController = - new GeneralChatDraftLifecycleController( - this.generalChatDraftController, - GENERAL_CHAT_HOME_DRAFT_ID, - (): string => this.conversationController.visibleGeneralChatDraftId() - ); readonly chatTimelineStore: ConversationViewModel = new ConversationViewModel(); // Scoped to the desktop currently being controlled: session ids are issued by // the desktop, so two of them on one account can name different conversations @@ -293,58 +211,6 @@ export abstract class AppRootRuntimeComposition { await this.remoteChatCache.clear(); await this.remoteSessionListCache.clear(); }; - readonly generalChatCommandController: GeneralChatCommandController = - new GeneralChatCommandController( - this.generalChatController, - { - onSessions: (sessions: RemoteSession[]) => { - this.generalChatPageState.setSessions(sessions); - }, - onSessionPrepared: (sessionId: string) => { - this.conversationController.resetGeneralTimeline(sessionId); - this.remoteModelController.clearCatalog(); - }, - onActiveSession: (session: SessionSummary) => { - this.generalChatPageState.setActiveSession(session); - }, - onMessagesLoaded: (messages: ChatMessage[]) => { - this.chatTimelineStore.setPersistedMessages(messages); - this.conversationController.syncGeneralTimeline(); - }, - onClearComposer: () => { - this.generalChatPageState.clearComposer(); - }, - onChatInput: (text: string) => { - this.generalChatPageState.setChatInput(text); - }, - onStatusText: (statusText: string) => { - this.generalChatPageState.setStatus(statusText); - }, - onBusy: (isBusy: boolean) => { - this.generalChatPageState.setBusy(isBusy); - }, - onToast: (statusText: string) => { - this.conversationController.showHomeToast(statusText); - } - } - ); - readonly generalChatBootstrapController: GeneralChatBootstrapController = - new GeneralChatBootstrapController( - this.generalChatConfigStore, - this.generalChatCommandController, - this.generalChatDraftLifecycleController, - { - onConfigRestored: (snapshot: GeneralChatConfigSnapshot) => { - this.settingsController.apply(snapshot); - }, - onHomeDraftRestored: (text: string) => { - this.generalChatPageState.setChatInput(text); - }, - onStatusText: (statusText: string) => { - this.generalChatPageState.setStatus(statusText); - } - } - ); readonly voiceInputService: VoiceInputService = new VoiceInputService(); readonly remoteActivityLifecycleController: RemoteActivityLifecycleController = new RemoteActivityLifecycleController((): Promise => { @@ -382,8 +248,6 @@ export abstract class AppRootRuntimeComposition { } } ); - readonly generalChatStreamLifecycleController: GeneralChatStreamLifecycleController = - new GeneralChatStreamLifecycleController(); readonly remoteWorkspaceViewModel: RemoteWorkspaceViewModel = new RemoteWorkspaceViewModel( this.remotePageState, @@ -708,32 +572,6 @@ export abstract class AppRootRuntimeComposition { } } ); - readonly generalChatConversationViewModel: GeneralChatConversationViewModel = - new GeneralChatConversationViewModel( - this.generalChatPageState, - this.generalChatCommandController, - this.generalChatDraftLifecycleController, - this.generalChatStreamLifecycleController, - this.chatTimelineStore, - { - isVisible: (sessionId: string): boolean => this.generalChatPageState.activeSession.sessionId === sessionId && - this.appShellViewModel.isGeneralChatVisible(), - currentActiveTurnId: (): string => this.currentActiveTurnId(), - latestUserMessageText: (): string => this.conversationController.latestUserMessageText(), - syncTimeline: (): void => this.conversationController.syncGeneralTimeline(), - refreshSessions: (): void => this.generalChatCommandController.refreshSessions(), - onTaskStarted: (sessionId: string, turnId: string): void => { - this.taskCompletionNotificationController.track({ source: 'general', sessionId, turnId }); - }, - onTaskCompleted: (sessionId: string, turnId: string): void => { - this.taskCompletionNotificationController.complete('general', sessionId, turnId); - }, - onTaskStopped: (sessionId: string, turnId: string): void => { - this.taskCompletionNotificationController.cancel('general', sessionId, turnId); - } - } - ); - protected trackRemoteTaskCompletion(sessionId: string, activeTurn: ChatMessage): void { const status = (activeTurn.status || '').toLowerCase(); if (status !== 'active' && status !== 'completed' && status !== 'done' && status !== 'success') { @@ -748,7 +586,6 @@ export abstract class AppRootRuntimeComposition { new RemoteConnectionController( this.remotePageState, this.identityStore, - this.remotePairingPolicy, this.remoteConnectionCoordinator, this.remoteSessionController, this.remoteModelController, @@ -770,13 +607,6 @@ export abstract class AppRootRuntimeComposition { ); readonly settingsController: SettingsController = new SettingsController( - this.generalChatConfigStore, - this.generalChatPageState, - { - probeConfiguration: async (apiUrl: string, apiKey: string, modelName: string): Promise => { - await ModelProviderGeneralChatAdapter.probeConfiguration(apiUrl, apiKey, modelName); - } - }, { client: new CloudAccountClient(), sessionStore: new CloudAccountSessionStore(), @@ -784,6 +614,7 @@ export abstract class AppRootRuntimeComposition { remoteState: this.remotePageState, hooks: { deviceId: (): string => this.remoteConnectionController.getDeviceId(), + openAuthorization: async (url: string): Promise => this.host.openExternalLink ? await this.host.openExternalLink(url) : false, remoteAvailable: (): boolean => this.remoteConnectionController.ensureAvailable(), invalidatePreview: (): void => this.filePreviewController.invalidate(), invalidateRemoteActivity: (): void => this.remoteActivityViewModel.invalidate(), @@ -853,9 +684,6 @@ export abstract class AppRootRuntimeComposition { settings: this.settingsController, appShell: this.appShellViewModel, filePreview: this.filePreviewController, - generalCommands: this.generalChatCommandController, - generalConversation: this.generalChatConversationViewModel, - generalDrafts: this.generalChatDraftLifecycleController, hooks: { isConversationContext: (sessionId: string): boolean => this.isRemoteConversationContext(sessionId), isFilePreviewVisible: (): boolean => this.filePreviewState.visible, @@ -872,23 +700,7 @@ export abstract class AppRootRuntimeComposition { openSidebar: (): void => this.openAppSidebar(), back: (): void => this.exitActiveChat(), newRemoteSession: (): void => { this.conversationController.createRemoteSession('code'); }, - newGeneralSession: (): void => this.conversationController.prepareNewGeneralChat(), - activeGeneralSession: (): RemoteSession => this.conversationController.activeGeneralChatAsRemoteSession(), - activeGeneralSessionId: (): string => this.generalChatPageState.activeSession.sessionId, - isGeneralBusy: (): boolean => this.generalChatPageState.isBusy, - isPinned: (sessionId: string): boolean => this.generalChatPageState.pinnedSessionId() === sessionId, - pin: async (session: RemoteSession, pinned: boolean, busy: boolean): Promise => { - await this.generalChatCommandController.pinSession(session, pinned, busy); - }, - archive: async (session: RemoteSession): Promise => { - await this.conversationController.archiveHomeSession(session, true); - }, - delete: async (session: RemoteSession): Promise => { - await this.conversationController.deleteHomeSession(session); - this.conversationController.prepareNewGeneralChat(); - }, - showToast: (text: string): void => this.conversationController.showHomeToast(text), - uploadedFileCount: (): number => this.conversationController.activeGeneralUploadedFileCount(), + unsupported: (): void => this.conversationController.showHomeToast(RemoteI18n.t('remote.actionUnsupported')), stop: async (): Promise => { await this.conversationController.stopVisibleTask(); }, loadOlder: async (): Promise => { await this.conversationController.loadOlderRemoteMessages(); }, approve: async (id: string, input?: Object): Promise => { @@ -973,7 +785,7 @@ export abstract class AppRootRuntimeComposition { }, onSidebar: { close: (): void => this.closeAppSidebar(), - newChat: (): void => { this.closeAppSidebar(); this.conversationController.prepareNewGeneralChat(); }, + newChat: (): void => { this.closeAppSidebar(); this.enterRemoteSurface(); }, enterCode: (): void => { this.closeAppSidebar(); this.enterCodeEntry(); }, scanDesktop: (): void => { this.closeAppSidebar(); this.openConnectSheet(CONNECT_INTENT_SCAN); }, addDesktop: (): void => { @@ -992,10 +804,6 @@ export abstract class AppRootRuntimeComposition { this.closeAppSidebar(); this.conversationController.openHomeSession(session); }, - archive: (session: RemoteSession, archived: boolean): void => { - this.conversationController.archiveHomeSession(session, archived); - }, - exportSession: (session: RemoteSession): void => { this.conversationController.exportHomeSession(session); }, deleteSession: (session: RemoteSession): void => { this.conversationController.deleteHomeSession(session); } }, onSettings: { @@ -1008,8 +816,16 @@ export abstract class AppRootRuntimeComposition { }, reconnect: (): void => { this.reconnect(); }, openAccount: (): void => { this.appShellState.openSettings('account'); }, - cloudLogin: (relayUrl: string, username: string, password: string): Promise => - this.settingsController.loginCloudAccount(relayUrl, username, password), + cloudCancelLogin: (): void => this.settingsController.cancelCloudLogin(), + cloudLogin: async (): Promise => { + const user = await this.settingsController.loginCloudAccount(); + const pending = this.pendingAccountDeviceId; + this.pendingAccountDeviceId = ''; + if (pending.length > 0) { + await this.connectAccountDeviceLink(pending); + } + return user; + }, cloudLogout: (): Promise => this.settingsController.logoutCloudAccount(), cloudListDevices: (): Promise => this.settingsController.listCloudAccountDevices(), cloudSelectDevice: (device: CloudAccountDevice): Promise => @@ -1017,12 +833,6 @@ export abstract class AppRootRuntimeComposition { getPermissionMode: (): Promise => this.settingsController.getRemotePermissionMode(), setPermissionMode: (mode: RemotePermissionMode): Promise => this.settingsController.setRemotePermissionMode(mode), - testGeneral: async (url: string, key: string, model: string, clear: boolean): Promise => - this.settingsController.test(url, key, model, clear), - saveGeneral: async (url: string, key: string, model: string, clear: boolean): Promise => - this.settingsController.save(url, key, model, clear), - selectGeneralModel: async (modelId: string): Promise => - this.settingsController.selectModel(modelId), setLanguage: async (language: string): Promise => { await this.localeController.setLanguage(language); } @@ -1037,7 +847,7 @@ export abstract class AppRootRuntimeComposition { this.connect(false, password || ''); }, clearPairing: (): void => { this.appShellState.setConnectSheetVisible(false); this.disconnect(true); }, - urlChanged: (url: string): void => { this.remotePageState.setRemoteUrl(url); this.remoteConnectionController.projectRemoteUrl(url); }, + urlChanged: (url: string): void => { this.remotePageState.setRemoteUrl(url); }, userChanged: (user: string): void => this.remotePageState.setUserId(user), detected: (url: string): string => this.handleDetectedRemoteUrl(url), inputVisible: (visible: boolean): void => this.remotePageState.setRemoteUrlInputVisible(visible), @@ -1053,7 +863,7 @@ export abstract class AppRootRuntimeComposition { download: (path: string): void => this.conversationController.downloadVisibleFile(path), openLink: (reference: string, label: string): void => this.filePreviewController.openLink(reference, label) }, - generalStatus: (): string => this.conversationController.generalChatHomeStatusText() + generalStatus: (): string => '' }; readonly navigationStack: NavPathStack = this.appShellViewModel.navigationStack; @@ -1134,28 +944,45 @@ export abstract class AppRootRuntimeComposition { this.remotePageState.downloadedFilePath, RemoteI18n.retranslate(this.remotePageState.fileDownloadStatus) ); - this.generalChatPageState.setStatus(GeneralChatServiceStatus.userMessage( - this.generalChatPageState.serviceState, - RemoteI18n.retranslate(this.generalChatPageState.statusText) - )); this.filePreviewState.errorText = RemoteI18n.retranslate(this.filePreviewState.errorText); } + private pendingAccountDeviceId: string = ''; + handleDetectedRemoteUrl(url: string): string { - const result = this.remoteConnectionController.handleDetectedUrl( - url, - this.settingsController.hasCloudAccountSession(), - this.remotePageState.accountUsername, - this.settingsController.cloudRelayEndpoint() - ); - if (result.action === DetectedUrlAction.USE_CLOUD_DEVICE) { - void this.settingsController.restoreCloudTarget( - result.cloudDeviceId, - this.remotePageState.desktopName, - true - ); + const link = accountDeviceLink(url); + if (!link) { + this.remotePageState.setStatusText(RemoteI18n.t('connect.deviceLinkInvalid')); + this.remotePageState.setConnectionState(ConnectionState.Failed); + return DetectedUrlAction.INVALID; + } + const deviceId = link.deviceId; + this.remotePageState.setRemoteUrl(url); + if (this.settingsController.cloudRelayEndpoint() !== link.relayUrl) { + this.settingsController.selectAccountRelay(link.relayUrl); + } + if (!this.settingsController.hasCloudAccountSession()) { + this.pendingAccountDeviceId = deviceId; + this.appShellState.setConnectSheetVisible(false); + this.appShellState.openSettings('account'); + return DetectedUrlAction.SHOW_CLOUD_DEVICES; + } + void this.connectAccountDeviceLink(deviceId); + return DetectedUrlAction.USE_CLOUD_DEVICE; + } + + private async connectAccountDeviceLink(deviceId: string): Promise { + try { + const devices = await this.settingsController.listCloudAccountDevices(); + const device = devices.find((item: CloudAccountDevice): boolean => item.deviceId === deviceId && item.online); + if (!device) { + throw new Error(RemoteI18n.t('connect.deviceLinkUnavailable')); + } + await this.settingsController.selectCloudAccountDevice(device); + } catch (err) { + this.remotePageState.setStatusText(ConnectionErrorPolicy.errorText(err)); + this.remotePageState.setConnectionState(ConnectionState.Failed); } - return result.action; } async scanRemotePairCode(): Promise { @@ -1164,8 +991,5 @@ export abstract class AppRootRuntimeComposition { return; } const action = this.handleDetectedRemoteUrl(text); - if (action === DetectedUrlAction.PAIR_NOW) { - void this.connect(false, ''); - } } } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/AppShellState.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/AppShellState.ets index 668e352f55..9e16b3c53d 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/AppShellState.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/AppShellState.ets @@ -13,7 +13,7 @@ export class AppShellState { * surfaces that live outside Navigation — the drawer above all — have no way * to follow the route without this traced copy. */ - @Trace activeRoute: AppRoute = AppRoute.ChatHome; + @Trace activeRoute: AppRoute = AppRoute.RemoteHome; @Trace showSidebar: boolean = false; @Trace showSettings: boolean = false; @Trace settingsMode: string = 'general'; diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/AppShellViewModel.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/AppShellViewModel.ets index 889c48082d..6e6e7c387e 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/AppShellViewModel.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/AppShellViewModel.ets @@ -42,7 +42,7 @@ export class AppShellViewModel { replaceRoute(route: AppRoute, sessionId: string = ''): void { this.navigationStack.clear(); - if (route !== AppRoute.ChatHome) { + if (route !== AppRoute.RemoteHome) { this.pushRoute(route, sessionId); } this.syncActiveRoute(); @@ -65,7 +65,7 @@ export class AppShellViewModel { return; } this.navigationStack.clear(false); - if (route !== AppRoute.ChatHome) { + if (route !== AppRoute.RemoteHome) { this.pushRoute(route, sessionId, false); } this.syncActiveRoute(); diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/ConversationController.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/ConversationController.ets index 896ca38433..74192c6821 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/ConversationController.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/ConversationController.ets @@ -51,14 +51,12 @@ export class ConversationController { }); this.createFlow = new RemoteCreateFlowController(remote, remoteRuntime); this.visible = new VisibleConversationController( - general, remote, remoteRuntime, this.transcript, this.createFlow, (route: AppRoute, value: string) => this.setChatInput(route, value), (statusText: string) => this.setVisibleStatusText(statusText), - (route: AppRoute) => this.isGeneralComposerRoute(route), (message: string) => this.showHomeToast(message) ); } @@ -192,14 +190,6 @@ export class ConversationController { this.visible.openHomeSession(session, inPlace); } async deleteHomeSession(session: RemoteSession): Promise { await this.visible.deleteHomeSession(session); } - activeGeneralChatAsRemoteSession(): RemoteSession { return this.visible.activeGeneralChatAsRemoteSession(); } - activeGeneralUploadedFileCount(): number { return this.visible.activeGeneralUploadedFileCount(); } - async archiveHomeSession(session: RemoteSession, archived: boolean): Promise { - await this.visible.archiveHomeSession(session, archived); - } - async exportHomeSession(session: RemoteSession): Promise { await this.visible.exportHomeSession(session); } - async openGeneralSession(item: RemoteSession): Promise { await this.visible.openGeneralSession(item); } - async startGeneralChat(text: string): Promise { await this.visible.startGeneralChat(text); } async sendVisibleMessage(): Promise { await this.visible.sendVisibleMessage(); } async stopVisibleTask(): Promise { await this.visible.stopVisibleTask(); } closeActiveChat(): void { this.visible.closeActiveChat(); } @@ -207,18 +197,9 @@ export class ConversationController { async retryVisibleMessage(text: string): Promise { await this.visible.retryVisibleMessage(text); } downloadVisibleFile(path: string): void { this.visible.downloadVisibleFile(path); } async selectVisibleModel(modelId: string): Promise { await this.visible.selectVisibleModel(modelId); } - startVisibleGeneralChat(): void { this.visible.startVisibleGeneralChat(); } - generalChatHomeStatusText(): string { return this.visible.generalChatHomeStatusText(); } - prepareNewGeneralChat(): void { this.visible.prepareNewGeneralChat(); } onVisibleChatInputChange(route: AppRoute, value: string): void { this.visible.onVisibleChatInputChange(route, value); } - visibleGeneralChatDraftId(): string { return this.visible.visibleGeneralChatDraftId(); } - async restoreGeneralChatDraft(draftId: string): Promise { - await this.visible.restoreGeneralChatDraft(draftId); - } latestUserMessageText(): string { return this.visible.latestUserMessageText(); } - resetGeneralTimeline(sessionId: string): void { this.visible.resetGeneralTimeline(sessionId); } - syncGeneralTimeline(): void { this.visible.syncGeneralTimeline(); } showHomeToast(message: string): void { this.visible.showHomeToast(message); } } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/ConversationRuntime.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/ConversationRuntime.ets index e2f3a49bff..8e99cd755e 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/ConversationRuntime.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/ConversationRuntime.ets @@ -1,7 +1,5 @@ import { ImagePickerService } from '../../services/ImagePickerService'; import { ClipboardService } from '../../services/ClipboardService'; -import { GeneralChatCommandController } from '../../services/general-chat/GeneralChatCommandController'; -import { GeneralChatDraftLifecycleController } from '../../services/general-chat/GeneralChatDraftLifecycleController'; import { RemoteChatCache } from '../../services/RemoteChatCache'; import { RemoteChatCommandController } from '../../services/RemoteChatCommandController'; import { RemoteChatPollingLifecycleController } from '../../services/RemoteChatPollingLifecycleController'; @@ -17,9 +15,7 @@ import { FilePreviewController } from './FilePreviewController'; import { RemoteConnectionController } from './RemoteConnectionController'; import { RemoteSessionViewModel } from './RemoteSessionViewModel'; import { SettingsController } from './SettingsController'; -import { GeneralChatConversationViewModel } from './GeneralChatConversationViewModel'; -export const GENERAL_CHAT_HOME_DRAFT_ID: string = 'new-chat'; export interface ConversationControllerHooks { readonly currentRoute: () => AppRoute; @@ -50,9 +46,6 @@ export interface RemoteConversationDependencies { readonly settings: SettingsController; readonly appShell: AppShellViewModel; readonly filePreview: FilePreviewController; - readonly generalCommands: GeneralChatCommandController; - readonly generalConversation: GeneralChatConversationViewModel; - readonly generalDrafts: GeneralChatDraftLifecycleController; readonly hooks: RemoteConversationHooks; } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteConnectionController.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteConnectionController.ets index 7aa6021c5c..c68ce5fe86 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteConnectionController.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteConnectionController.ets @@ -1,26 +1,18 @@ import { - InitialSyncResult, WorkspaceInfo } from '../../model/RemoteModels'; import { RemoteI18n } from '../../i18n/RemoteI18n'; import { ConnectionErrorPolicy } from '../../services/ConnectionErrorPolicy'; -import { ConnectionErrorResult } from '../../services/ConnectionErrorPolicy'; import { ClipboardService } from '../../services/ClipboardService'; import { Encoding } from '../../services/Encoding'; import { MobileIdentitySnapshot, MobileIdentityStore } from '../../services/MobileIdentityStore'; -import { RemoteDescriptorParser } from '../../services/RemoteDescriptorParser'; import { RemoteFileDownloadController } from '../../services/RemoteFileDownloadController'; import { RemoteModelController } from '../../services/RemoteModelController'; -import { - ConnectScanDecisionPolicy, - DetectedRemoteUrlResult, - DetectedUrlAction -} from '../../services/ConnectScanDecisionPolicy'; -import { RemotePairingPolicy } from '../../services/RemotePairingPolicy'; import { RemoteSessionController } from '../../services/RemoteSessionController'; +import { accountDeviceLink } from '../../services/AccountDeviceLink'; import { RemoteUiState } from '../../services/RemoteUiState'; import { QrScanService } from '../../services/QrScanService'; -import { RemoteConnectionCoordinator, RemoteConnectionRequest } from '../../services/RemoteConnectionCoordinator'; +import { RemoteConnectionCoordinator } from '../../services/RemoteConnectionCoordinator'; import { RemotePageState } from '../state/RemotePageState'; import { AppRoute } from '../navigation/AppRouteContract'; import { RemoteLogger } from '../../services/RemoteLogger'; @@ -38,7 +30,6 @@ export enum RemoteConnectionState { export class RemoteConnectionController { private readonly pageState: RemotePageState; private readonly identity: MobileIdentityStore; - private readonly pairing: RemotePairingPolicy; private readonly connection: RemoteConnectionCoordinator; private readonly sessions: RemoteSessionController; private readonly models: RemoteModelController; @@ -64,7 +55,6 @@ export class RemoteConnectionController { constructor( pageState: RemotePageState, identity: MobileIdentityStore, - pairing: RemotePairingPolicy, connection: RemoteConnectionCoordinator, sessions: RemoteSessionController, models: RemoteModelController, @@ -84,7 +74,6 @@ export class RemoteConnectionController { ) { this.pageState = pageState; this.identity = identity; - this.pairing = pairing; this.connection = connection; this.sessions = sessions; this.models = models; @@ -112,7 +101,7 @@ export class RemoteConnectionController { } async rememberControlTargetType(controlTargetType: string): Promise { - const normalized = controlTargetType === 'room' || controlTargetType === 'account_device' ? + const normalized = controlTargetType === 'account_device' ? controlTargetType : ''; this.restoredControlTargetType = normalized; try { @@ -132,24 +121,13 @@ export class RemoteConnectionController { await this.restoreWithReconnectPolicy(context, false); } - async reconnectRestoredPairing(): Promise { - if (this.shouldAutoReconnect()) { - this.autoReconnectAttempted = true; - this.setState(RemoteConnectionState.Reconnecting); - this.pageState.setStatusText(RemoteI18n.t('status.restoringConnection')); - await this.connect(true); - } - } - private async restoreWithReconnectPolicy(context: Context, reconnect: boolean): Promise { try { const snapshot: MobileIdentitySnapshot = await this.identity.init(context); this.deviceId = snapshot.installId; this.pageState.setUserId(snapshot.userId || snapshot.installId); this.pageState.setRemoteUrl(snapshot.remoteUrl); - this.restoredControlTargetType = snapshot.controlTargetType === 'room' || - snapshot.controlTargetType === 'account_device' ? snapshot.controlTargetType : ''; - this.applyPairingProjection(snapshot.remoteUrl); + this.restoredControlTargetType = snapshot.controlTargetType === 'account_device' ? snapshot.controlTargetType : ''; this.models.setPreferredModelId(await this.identity.getLastModelId()); this.failureCount = snapshot.userIdFailureCount; this.lockUntil = snapshot.userIdLockUntil > Date.now() ? snapshot.userIdLockUntil : 0; @@ -158,113 +136,13 @@ export class RemoteConnectionController { await this.identity.clearUserIdProtection(); } this.pageState.setRemoteUrlInputVisible(snapshot.remoteUrl.length > 0); - if (reconnect) { - await this.reconnectRestoredPairing(); - } } catch (err) { this.pageState.setStatusText(ConnectionErrorPolicy.errorText(err)); this.setState(RemoteConnectionState.Failed); } } - async connect(autoReconnect: boolean = false, accountPassword: string = ''): Promise { - if (this.pageState.isBusy) { - return; - } - const recovering = autoReconnect || this.pageState.connectionState === RemoteConnectionState.Reconnecting; - try { - RemoteLogger.info(`connect start auto=${autoReconnect ? '1' : '0'}`); - this.pageState.setBusy(true); - this.pageState.setLoadingHome(true); - this.pageState.setConnectionFailureKind(''); - if (this.lockUntil > 0 && !ConnectionErrorPolicy.isLocked(this.lockUntil)) { - this.failureCount = 0; - this.lockUntil = 0; - await this.identity.clearUserIdProtection(); - } - if (!autoReconnect && ConnectionErrorPolicy.isLocked(this.lockUntil)) { - throw new Error(RemoteI18n.f('errors.tooManyAttempts', `${ConnectionErrorPolicy.remainingLockSeconds(this.lockUntil)}`)); - } - this.setState(this.pageState.connectionState === RemoteConnectionState.Reconnecting ? - RemoteConnectionState.Reconnecting : RemoteConnectionState.Parsing); - this.pageState.setStatusText(RemoteI18n.t('status.parsingUrl')); - this.applyPairingProjection(this.pageState.remoteUrl); - this.setState(RemoteConnectionState.Pairing); - this.pageState.setStatusText(RemoteI18n.t('status.pairing')); - const request: RemoteConnectionRequest = { - remoteUrl: this.pageState.remoteUrl, - userId: this.pageState.userId, - deviceId: this.deviceId, - accountPassword, - autoReconnect - }; - const initialSync: InitialSyncResult = await this.connection.connect(request); - this.failureCount = 0; - this.lockUntil = 0; - this.applyWorkspace(initialSync.workspace); - this.pageState.setAuthenticatedUserId(initialSync.authenticatedUserId); - this.pageState.setHostCapabilities(initialSync.capabilities || []); - this.pageState.setControlTarget('room', this.pageState.desktopId, this.pageState.desktopName); - await this.rememberControlTargetType('room'); - this.sessions.setSessions(initialSync.sessions, initialSync.hasMoreSessions); - this.setState(RemoteConnectionState.Connected); - this.pageState.setConnectionFailureKind(''); - this.pageState.setStatusText(RemoteI18n.t('connection.connected')); - this.closeConnectSheet(); - this.startHeartbeat(); - // Left running rather than awaited: this scan costs one round trip per - // recent workspace, and everything it finds is an addition to a session - // list that is already on screen. Its own failures are logged inside. - void this.loadRecentWorkspaces(); - this.pageState.setLoadingHome(false); - RemoteLogger.info( - `connect success sessions=${initialSync.sessions.length} has_more=${initialSync.hasMoreSessions ? '1' : '0'}` - ); - } catch (err) { - if (!this.connection.isCurrentRequest()) { - return; - } - const result: ConnectionErrorResult = await ConnectionErrorPolicy.connectionErrorResult(err, autoReconnect, { - failureCount: this.failureCount, - lockUntil: this.lockUntil - }, this.identity); - if (!this.connection.isCurrentRequest()) { - return; - } - this.pageState.setStatusText(result.message); - this.pageState.setConnectionFailureKind(result.failureKind); - this.failureCount = result.failureCount; - this.lockUntil = result.lockUntil; - if (result.shouldShowRemoteUrlInput) { - this.pageState.setRemoteUrlInputVisible(true); - } - const terminalFailure = result.failureKind === 'expired_room' || - result.failureKind === 'invalid_link' || result.failureKind === 'protected_user'; - this.setState(recovering && !terminalFailure ? - RemoteConnectionState.Reconnecting : RemoteConnectionState.Failed); - RemoteLogger.error(`connect failed stage=connection message=${ConnectionErrorPolicy.errorText(err)}`); - if (!recovering) { - this.openConnectSheet(); - } - } finally { - if (this.connection.isCurrentRequest()) { - this.pageState.setLoadingHome(false); - this.pageState.setBusy(false); - } - } - } - - async reconnect(): Promise { - if (this.pageState.isBusy) { - return; - } - this.setState(RemoteConnectionState.Reconnecting); - this.pageState.setStatusText(RemoteI18n.t('status.reconnecting')); - await this.connect(true); - } - async disconnect(clearPairing: boolean): Promise { - const clearingPreferredRoom = clearPairing && this.restoredControlTargetType === 'room'; this.connection.invalidate(); this.stopPolling(); this.stopHeartbeat(); @@ -292,9 +170,7 @@ export class RemoteConnectionController { this.pageState.setAccountPairing(false, ''); this.pageState.setRemoteUrlInputVisible(false); await this.identity.clearPairingInput(); - if (clearingPreferredRoom) { - await this.rememberControlTargetType(''); - } + await this.rememberControlTargetType(''); // Unpairing is the user saying they are done with that desktop. Leaving // its sessions on disk would put them back on Remote Home at the next // launch, with nothing behind them to open. @@ -315,7 +191,7 @@ export class RemoteConnectionController { } this.applyRemoteUrl(text); try { - RemoteDescriptorParser.parse(text); + if (!accountDeviceLink(text)) throw new Error(RemoteI18n.t('connect.deviceLinkInvalid')); this.pageState.setStatusText(RemoteI18n.t('status.clipboardUrlFilled')); } catch (_err) { this.pageState.setStatusText(RemoteI18n.t('status.clipboardNeedsConfirm')); @@ -342,49 +218,6 @@ export class RemoteConnectionController { } } - /** - * Apply a scanned pair URL and say what should happen next. - * - * This method must not start a connect and must not close the sheet: the - * caller owns both, so a camera callback cannot pair twice and cannot jump - * off the progress the sheet is showing. - */ - handleDetectedUrl( - remoteUrl: string, - hasCloudAccountSession: boolean = false, - cloudUsername: string = '', - cloudRelayUrl: string = '' - ): DetectedRemoteUrlResult { - try { - this.applyRemoteUrl(remoteUrl); - const descriptor = RemoteDescriptorParser.parse(remoteUrl); - this.pageState.setStatusText(RemoteI18n.t('status.scannedUrl')); - const action = ConnectScanDecisionPolicy.decide( - descriptor.accountAuth, - hasCloudAccountSession, - cloudUsername, - descriptor.accountUsername, - RemoteUiState.desktopIdFromRemoteUrl(remoteUrl), - cloudRelayUrl, - descriptor.relayUrl - ); - if (action === DetectedUrlAction.PROMPT_ACCOUNT_PASSWORD) { - this.pageState.setStatusText(RemoteI18n.t('connect.enterAccountToPair')); - this.openConnectSheet(); - return new DetectedRemoteUrlResult(action); - } - return new DetectedRemoteUrlResult( - action, - action === DetectedUrlAction.USE_CLOUD_DEVICE ? - RemoteUiState.desktopIdFromRemoteUrl(remoteUrl) : '' - ); - } catch (err) { - this.pageState.setRemoteUrlInputVisible(true); - this.pageState.setStatusText(ConnectionErrorPolicy.errorText(err)); - return new DetectedRemoteUrlResult(DetectedUrlAction.INVALID); - } - } - ensureAvailable(): boolean { if (RemoteUiState.canUseRemote(this.pageState.connectionState)) { return true; @@ -393,10 +226,6 @@ export class RemoteConnectionController { return false; } - projectRemoteUrl(remoteUrl: string): void { - this.applyPairingProjection(remoteUrl); - } - applyWorkspace(workspace: WorkspaceInfo): void { this.pageState.setWorkspace(workspace.name, workspace.path, workspace.assistantId || '', workspace.gitBranch, workspace.workspaceKind || 'normal'); @@ -404,32 +233,11 @@ export class RemoteConnectionController { private applyRemoteUrl(remoteUrl: string): void { this.pageState.setRemoteUrl(remoteUrl); - this.applyPairingProjection(remoteUrl); this.pageState.setConnectionFailureKind(''); this.pageState.setRemoteUrlInputVisible(true); } - private applyPairingProjection(remoteUrl: string): void { - this.pageState.setDesktopIdentity( - remoteUrl.trim().length > 0 ? RemoteUiState.desktopNameFromRemoteUrl(remoteUrl) : this.pageState.desktopName, - remoteUrl.trim().length > 0 ? RemoteUiState.desktopIdFromRemoteUrl(remoteUrl) : '' - ); - const projection = this.pairing.projection(remoteUrl); - this.pageState.setAccountPairing(projection.requiresAccountAuth, projection.accountUsername); - const projectedUserId = this.pairing.userIdAfterProjection(this.pageState.userId, this.deviceId, projection); - if (projectedUserId !== this.pageState.userId) { - this.pageState.setUserId(projectedUserId); - } - } - private shouldAutoReconnect(): boolean { - return this.pairing.shouldAutoReconnect({ - autoReconnectAttempted: this.autoReconnectAttempted, - remoteUrl: this.pageState.remoteUrl, - userId: this.pageState.userId, - requiresAccountAuth: this.pageState.requiresAccountAuth - }); - } private setState(state: RemoteConnectionState): void { this.pageState.setConnectionState(state); diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/SettingsController.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/SettingsController.ets index 7968825cd6..6bbc2a74f0 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/SettingsController.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/SettingsController.ets @@ -1,6 +1,8 @@ +import { normalizeAccountRelayUrl } from '../../services/AccountDeviceLink'; import { RemoteI18n } from '../../i18n/RemoteI18n'; import { stampRemoteSessionsDevice } from '../../services/RemoteSessionIdentity'; import { + DEFAULT_CLOUD_RELAY_URL, CloudAccountBinding, CloudAccountClient, CloudAccountDevice, @@ -10,31 +12,16 @@ import { import { CloudAccountSessionStore, PersistedCloudAccountSession } from '../../services/CloudAccountSessionStore'; import { ConnectionErrorPolicy } from '../../services/ConnectionErrorPolicy'; import { Encoding } from '../../services/Encoding'; -import { - GeneralChatConfigSnapshot, - GeneralChatConfigStore, - GeneralChatConfigUpdate, - GeneralChatConfigValidator, - GeneralChatModelSelectionPolicy -} from '../../services/general-chat/GeneralChatConfigStore'; -import { GeneralChatCloudConfigPolicy } from '../../services/general-chat/GeneralChatCloudConfigPolicy'; -import { GeneralChatServiceStatus } from '../../services/general-chat/GeneralChatServiceState'; import { RemoteHeartbeatController } from '../../services/RemoteHeartbeatController'; import { RemoteLogger } from '../../services/RemoteLogger'; -import { RemoteDescriptorParser } from '../../services/RemoteDescriptorParser'; -import { ConnectScanDecisionPolicy } from '../../services/ConnectScanDecisionPolicy'; import { RemoteSessionManager } from '../../services/RemoteSessionManager'; import { WatchProvisionOutcome } from '../../services/WatchProvisionController'; import { RemotePermissionMode } from '../../model/RemoteModels'; -import { GeneralChatPageState } from '../state/GeneralChatPageState'; import { RemotePageState } from '../state/RemotePageState'; -export interface SettingsControllerHooks { - readonly probeConfiguration: (apiUrl: string, apiKey: string, modelName: string) => Promise; -} - export interface CloudAccountSettingsHooks { readonly deviceId: () => string; + readonly openAuthorization: (url: string) => Promise; readonly remoteAvailable: () => boolean; readonly invalidatePreview: () => void; readonly invalidateRemoteActivity: () => void; @@ -75,14 +62,13 @@ export interface PreferredCloudTarget { /** How often presence is refetched while device rows are on screen. */ const PRESENCE_POLL_INTERVAL_MS: number = 20000; -/** Owns general-chat model service settings and their presentation projection. */ +/** Owns the authenticated account and remote device selection. */ export class SettingsController { - private readonly store: GeneralChatConfigStore; - private readonly state: GeneralChatPageState; - private readonly hooks: SettingsControllerHooks; private readonly cloud?: CloudAccountSettingsDependencies; private cloudSession?: CloudAccountSession; private cloudRelayUrl: string = ''; + private selectedRelayUrl: string = DEFAULT_CLOUD_RELAY_URL; + private accountLoginVersion: number = 0; /** Account login identity; pairing projections must never overwrite it. */ private cloudAccountUsername: string = ''; private accountDevices: CloudAccountDevice[] = []; @@ -94,97 +80,10 @@ export class SettingsController { private preferredTargetDeviceId: string = ''; private preferredTargetDeviceName: string = ''; - constructor( - store: GeneralChatConfigStore, - state: GeneralChatPageState, - hooks: SettingsControllerHooks, - cloud?: CloudAccountSettingsDependencies - ) { - this.store = store; - this.state = state; - this.hooks = hooks; + constructor(cloud: CloudAccountSettingsDependencies) { this.cloud = cloud; } - async save( - apiUrl: string, - apiKey: string, - modelName: string, - clearApiKey: boolean - ): Promise { - const update = this.update(apiUrl, apiKey, modelName, clearApiKey); - try { - const validationError = await this.validate(update); - if (validationError.length > 0) { - return validationError; - } - if (!update.clearApiKey) { - const probeError = await this.probe(update); - if (probeError.length > 0) { - return probeError; - } - } - const catalogBeforeSave = await this.store.modelCatalog(); - const snapshot = await this.store.save(update); - if (GeneralChatModelSelectionPolicy.shouldActivateSavedLocalModel(catalogBeforeSave)) { - await this.store.selectLocalModel(); - } - this.apply(snapshot); - await this.refreshModelCatalog(); - return ''; - } catch (err) { - return ConnectionErrorPolicy.errorText(err); - } - } - - async test( - apiUrl: string, - apiKey: string, - modelName: string, - clearApiKey: boolean - ): Promise { - const update = this.update(apiUrl, apiKey, modelName, clearApiKey); - try { - const validationError = await this.validate(update); - if (validationError.length > 0) { - return validationError; - } - if (update.clearApiKey) { - return RemoteI18n.t('settings.modelService.testNeedsKey'); - } - return await this.probe(update); - } catch (err) { - return ConnectionErrorPolicy.errorText(err); - } - } - - apply(snapshot: GeneralChatConfigSnapshot): void { - this.state.setConfiguration( - snapshot.apiUrl, - snapshot.modelName, - snapshot.hasApiKey, - GeneralChatServiceStatus.fromConfiguration(snapshot.apiUrl, snapshot.modelName, snapshot.hasApiKey) - ); - } - - async refreshModelCatalog(): Promise { - const catalog = await this.store.modelCatalog(); - const selectedModelId = catalog.session_model_id || catalog.default_models.primary || ''; - this.state.setModelCatalog(catalog, selectedModelId); - const active = await this.store.activeSnapshot(); - this.state.setServiceState( - GeneralChatServiceStatus.fromConfiguration(active.apiUrl, active.modelName, active.hasApiKey) - ); - } - - async selectModel(modelId: string): Promise { - if (!await this.store.selectModel(modelId)) { - return false; - } - await this.refreshModelCatalog(); - return true; - } - async initializeCloudAccount(context: Context): Promise { const cloud = this.requireCloud(); await cloud.sessionStore.init(context); @@ -198,10 +97,8 @@ export class SettingsController { /** * Whether this phone might be able to add a device to the account itself. * - * Deliberately "might": the session here is either a real password login or - * one delegated by a room pairing, and only the first carries a token the - * relay will mint from. The two are indistinguishable locally, so the answer - * is optimistic and `provisionWatchCredential` reports the refusal. + * The relay checks the credential's provisioning scope. A local session + * makes the action available; a server refusal is reported to the caller. */ canMintWatchCredential(): boolean { return this.cloudSession !== undefined && this.cloudRelayUrl.length > 0; @@ -210,14 +107,9 @@ export class SettingsController { /** * Mints a watch's own account credential straight from this phone. * - * Returns `undefined` when this phone is not entitled to mint — no session, - * or a delegated token the relay refuses — which is the caller's cue to fall - * back to asking the paired desktop. Any other failure throws, because a - * network blip is not the same answer as "you may not". - * - * The account master key never leaves for the relay: it is read from the - * session this phone already holds and handed back for sealing to the - * watch's ephemeral key. + * Returns `undefined` without a signed-in session and propagates server + * refusals. The watch receives its own newly generated device secret, + * sealed by the provisioning transport; the phone's secret stays local. */ async provisionWatchCredential( deviceId: string, @@ -231,26 +123,6 @@ export class SettingsController { } const cloud = this.requireCloud(); try { - if (password.length > 0) { - const username = this.cloudAccountUsername; - if (username.length === 0) { - throw new Error('The signed-in account name is unavailable.'); - } - const provisioned = await cloud.client.loginWatch( - this.cloudRelayUrl, username, password, session, deviceId, deviceName, requestId); - RemoteLogger.info(`watch credential obtained through account login device=${provisioned.deviceId}`); - return { - ok: true, - passwordRequired: false, - relayUrl: this.cloudRelayUrl, - token: provisioned.token, - userId: provisioned.userId, - masterKeyBase64: Encoding.bytesToBase64(session.masterKey), - deviceId: provisioned.deviceId, - failure: '', - desktopReported: false - }; - } const provisioned = await cloud.client.provisionDevice( this.cloudRelayUrl, session, deviceId, deviceName, requestId); RemoteLogger.info(`watch credential minted from the phone account device=${provisioned.deviceId}`); @@ -260,94 +132,41 @@ export class SettingsController { relayUrl: this.cloudRelayUrl, token: provisioned.token, userId: provisioned.userId, - masterKeyBase64: Encoding.bytesToBase64(session.masterKey), + masterKeyBase64: Encoding.bytesToBase64(provisioned.deviceSecret), deviceId: provisioned.deviceId, failure: '', desktopReported: false }; } catch (err) { - // Once the user is confirming the account, every failure belongs to the - // login attempt itself. Do not reinterpret a rejected password or an old - // relay response as a cue to ask the desktop. - if (password.length > 0) { - throw err instanceof Error ? err : new Error('Watch account verification failed.'); - } - if (err instanceof CloudAccountRequestError && (err.statusCode === 401 || err.statusCode === 403)) { - RemoteLogger.info('phone account may not mint a device credential; deferring to the desktop'); - return undefined; - } - if (err instanceof CloudAccountRequestError && - (err.statusCode === 404 || err.statusCode === 409 || err.statusCode >= 500) && - this.cloudAccountUsername.length > 0) { - RemoteLogger.info('relay cannot provision watch directly; requesting account confirmation'); - return { - ok: false, - passwordRequired: true, - relayUrl: '', token: '', userId: '', masterKeyBase64: '', deviceId: '', failure: '', - desktopReported: false - }; - } throw err instanceof Error ? err : new Error('Watch credential provisioning failed.'); } } - async persistDelegatedAccountSession(targetDeviceId: string = '', targetDeviceName: string = ''): Promise { - const cloud = this.requireCloud(); - const delegated = cloud.sessionManager.delegatedAccountSession(); - if (!delegated) { - // A room and an account target are independent recovery paths. Choosing - // an ordinary QR room must not erase the last account device; the saved - // control-target type decides which one the next launch tries first. - return; - } - const existing = this.cloudSession; - if (existing && (existing.userId !== delegated.session.userId || - !ConnectScanDecisionPolicy.relayEndpointsCompatible(this.cloudRelayUrl, delegated.relayUrl))) { - RemoteLogger.warn('delegated account session ignored because the signed-in account or relay differs'); - if (this.preferredTargetDeviceId.length > 0) { - this.clearPreferredCloudTarget(); - try { - await this.persistCurrentCloudSessionTarget('', ''); - } catch (err) { - RemoteLogger.warn(`account target clear persistence failed: ${String(err)}`); - } - } - return; - } - const session = existing || delegated.session; - const relayUrl = existing && this.cloudRelayUrl.length > 0 ? this.cloudRelayUrl : delegated.relayUrl; - const username = existing ? (this.cloudAccountUsername || session.userId) : delegated.session.userId; - if (!existing) { - this.applyCloudAccountSession(session, relayUrl, username); - } - const persisted: PersistedCloudAccountSession = { - relayUrl, - username, - token: session.token, - userId: session.userId, - masterKey: Encoding.bytesToBase64(session.masterKey) - }; - const targetId = targetDeviceId.trim(); - if (targetId.length > 0) { - persisted.targetDeviceId = targetId; - persisted.targetDeviceName = targetDeviceName.trim() || targetId; - this.setPreferredCloudTarget(targetId, persisted.targetDeviceName); - } - try { - await cloud.sessionStore.save(persisted); - RemoteLogger.info(`delegated account session persisted after room pairing target=${targetId.length > 0 ? '1' : '0'}`); - } catch (err) { - // The transport is already authenticated and useful. Storage failure - // means this launch cannot be resumed after a kill; it must not tear down - // the link the user just established. - RemoteLogger.warn(`delegated account session persistence failed: ${String(err)}`); + selectAccountRelay(relayUrl: string): void { + const endpoint = normalizeAccountRelayUrl(relayUrl); + if (!endpoint) throw new Error('Invalid Relay URL'); + if (this.cloudRelayUrl !== endpoint) { + this.teardownCloudAccountProjection(this.requireCloud()); + this.selectedRelayUrl = endpoint; } } - async loginCloudAccount(relayUrl: string, username: string, password: string): Promise { + async loginCloudAccount(): Promise { + const relayUrl = this.selectedRelayUrl; + const generation = ++this.accountLoginVersion; const cloud = this.requireCloud(); RemoteLogger.info('cloud account UI login requested'); - const session = await cloud.client.login(relayUrl, username, password, cloud.hooks.deviceId()); + const deviceSecret = await cloud.sessionStore.deviceSecret(); + if (generation !== this.accountLoginVersion) { deviceSecret.fill(0); throw new Error('Account login cancelled'); } + let session: CloudAccountSession; + try { + session = await cloud.client.login(relayUrl, cloud.hooks.deviceId(), cloud.hooks.openAuthorization, deviceSecret); + } finally { deviceSecret.fill(0); } + if (generation !== this.accountLoginVersion) { + session.masterKey.fill(0); + throw new Error('Account login cancelled'); + } + const username = session.userId; this.clearPreferredCloudTarget(); this.applyCloudAccountSession(session, relayUrl, username); try { @@ -358,7 +177,6 @@ export class SettingsController { } catch (err) { RemoteLogger.warn(`cloud account session persistence failed: ${String(err)}`); } - await this.loadGeneralChatAccountModels(session, relayUrl); RemoteLogger.info('cloud account session active, refreshing account devices'); RemoteLogger.info(`cloud account login success user=${session.userId}`); return session.userId; @@ -368,27 +186,24 @@ export class SettingsController { const remoteState = this.requireCloud().remoteState; this.cloudSession = session; this.cloudRelayUrl = relayUrl.trim(); + this.selectedRelayUrl = this.cloudRelayUrl; this.cloudAccountUsername = username.trim(); remoteState.setAccountUserId(session.userId); remoteState.setAccountUsername(this.cloudAccountUsername); } + cancelCloudLogin(): void { this.cloud?.client.cancelAuthorization(); } + async logoutCloudAccount(): Promise { + this.cancelCloudLogin(); const cloud = this.requireCloud(); - const preserveRoom = this.hasRoomControlTarget(cloud); - if (!preserveRoom) { - cloud.hooks.invalidatePreview(); - } - this.teardownCloudAccountProjection(cloud, preserveRoom); - this.store.replaceAccountModels([]); - await this.refreshModelCatalog(); + cloud.hooks.invalidatePreview(); + this.teardownCloudAccountProjection(cloud); await cloud.sessionStore.clear(); await cloud.hooks.clearCachedRemoteData(); - if (!preserveRoom) { - cloud.remoteState.clearControlTarget(); - cloud.remoteState.setHostCapabilities([]); - await this.saveControlTargetType(''); - } + cloud.remoteState.clearControlTarget(); + cloud.remoteState.setHostCapabilities([]); + await this.saveControlTargetType(''); RemoteLogger.info('cloud account logout success'); } @@ -421,51 +236,6 @@ export class SettingsController { }; } - /** - * Upgrades an older account record that predates persisted target devices. - * - * An account-auth QR directly identifies a stable account device. An ordinary - * QR may migrate only when the refreshed account roster independently proves - * that the same desktop device exists on the same relay. This prevents a - * stale room from silently switching control to another remembered desktop. - */ - async migrateLegacyPairingTarget( - remoteUrl: string, - deviceId: string, - deviceName: string - ): Promise { - const targetId = deviceId.trim(); - if (!this.cloudSession || targetId.length === 0) { - return undefined; - } - if (this.preferredTargetDeviceId.length > 0) { - return this.preferredTargetDeviceId === targetId ? this.preferredCloudTarget() : undefined; - } - try { - const descriptor = RemoteDescriptorParser.parse(remoteUrl); - const knownAccountDevice = this.knownAccountDevice(targetId); - if ((!descriptor.accountAuth && !knownAccountDevice) || !ConnectScanDecisionPolicy.relayEndpointsCompatible( - this.cloudRelayUrl, - descriptor.relayUrl - )) { - return undefined; - } - const targetName = knownAccountDevice?.deviceName || deviceName.trim() || targetId; - this.setPreferredCloudTarget(targetId, targetName); - this.restorePreferredCloudTargetProjection(); - try { - await this.persistCurrentCloudSessionTarget(targetId, targetName); - } catch (err) { - RemoteLogger.warn(`legacy account target persistence failed: ${String(err)}`); - } - RemoteLogger.info(`legacy QR pairing migrated to account target device=${targetId}`); - return this.preferredCloudTarget(); - } catch (err) { - RemoteLogger.warn(`legacy QR pairing target migration skipped: ${String(err)}`); - return undefined; - } - } - /** * Polls presence for as long as device rows are on screen. * @@ -812,44 +582,6 @@ export class SettingsController { } } - private update( - apiUrl: string, - apiKey: string, - modelName: string, - clearApiKey: boolean - ): GeneralChatConfigUpdate { - return { apiUrl, apiKey, modelName, clearApiKey }; - } - - private async validate(update: GeneralChatConfigUpdate): Promise { - const snapshot = await this.store.snapshot(); - return GeneralChatConfigValidator.validate(update, snapshot.hasApiKey); - } - - private async probe(update: GeneralChatConfigUpdate): Promise { - const apiKey = await this.effectiveApiKey(update); - if (apiKey.length === 0) { - return RemoteI18n.t('settings.modelService.apiKeyRequired'); - } - try { - await this.hooks.probeConfiguration(update.apiUrl, apiKey, update.modelName); - return ''; - } catch (err) { - return ConnectionErrorPolicy.errorText(err); - } - } - - private async effectiveApiKey(update: GeneralChatConfigUpdate): Promise { - const directKey = update.apiKey.trim(); - if (directKey.length > 0) { - return directKey; - } - if (update.clearApiKey) { - return ''; - } - return (await this.store.accessToken()).trim(); - } - private async restoreCloudAccountSession(): Promise { const cloud = this.requireCloud(); try { @@ -864,7 +596,6 @@ export class SettingsController { }; this.applyCloudAccountSession(session, persisted.relayUrl, persisted.username || session.userId); this.restorePersistedControlTarget(persisted); - await this.loadGeneralChatAccountModels(session, persisted.relayUrl); } catch (err) { RemoteLogger.warn(`cloud account restore failed: ${err instanceof Error ? err.message : 'unknown error'}`); // Retain the encrypted record. A temporary keystore/storage failure must @@ -891,66 +622,27 @@ export class SettingsController { this.restorePreferredCloudTargetProjection(); } - private async loadGeneralChatAccountModels(session: CloudAccountSession, relayUrl: string): Promise { - const cloud = this.requireCloud(); - this.store.replaceAccountModels([]); - try { - const blob = await cloud.client.fetchSettings(relayUrl, session); - if (!blob) { - this.store.replaceAccountModels([]); - await this.refreshModelCatalog(); - RemoteLogger.info('cloud model catalog is empty'); - return; - } - const models = GeneralChatCloudConfigPolicy.models(blob.plaintext); - this.store.replaceAccountModels(models); - await this.refreshModelCatalog(); - RemoteLogger.info(`cloud model catalog loaded count=${models.length} version=${blob.version}`); - } catch (err) { - await this.refreshModelCatalog(); - RemoteLogger.warn(`cloud model catalog load failed: ${err instanceof Error ? err.message : 'unknown error'}`); - } - } - private async expireCloudAccountSession(): Promise { const cloud = this.requireCloud(); - const preserveRoom = this.hasRoomControlTarget(cloud); - if (!preserveRoom) { - cloud.hooks.invalidatePreview(); - } - // Publish the signed-out projection before awaiting storage. A revoked - // credential must never leave stale account devices actionable merely - // because preferences or cache cleanup is slow (or fails). - this.teardownCloudAccountProjection(cloud, preserveRoom); - this.store.replaceAccountModels([]); - await this.refreshModelCatalog(); - await cloud.sessionStore.clear(); - await cloud.hooks.clearCachedRemoteData(); - if (!preserveRoom) { - await this.saveControlTargetType(''); - } + cloud.hooks.invalidatePreview(); + // Revocation removes live authority, not the user's encrypted record or + // cached remote sessions. Only explicit logout may erase those records. + this.teardownCloudAccountProjection(cloud); RemoteLogger.info('cloud account session expired'); } /** Publishes the complete signed-out projection as one synchronous transition. */ private teardownCloudAccountProjection( - cloud: CloudAccountSettingsDependencies, - preserveRoom: boolean + cloud: CloudAccountSettingsDependencies ): void { // Invalidate late device-selection completions before touching observable // state. During an in-flight switch the target is deliberately `none`, but // its busy/status/session projection still belongs to the account. this.accountDeviceSelectionVersion += 1; - if (!preserveRoom) { - this.resetAccountDeviceConnection(true); - } + this.resetAccountDeviceConnection(true); this.clearCloudAccountIdentity(cloud); } - private hasRoomControlTarget(cloud: CloudAccountSettingsDependencies): boolean { - return cloud.remoteState.controlTargetType === 'room'; - } - private async saveControlTargetType(controlTargetType: string): Promise { try { await this.requireCloud().hooks.saveControlTargetType(controlTargetType); @@ -962,6 +654,8 @@ export class SettingsController { /** Drops the remaining account identity facts after remote teardown. */ private clearCloudAccountIdentity(cloud: CloudAccountSettingsDependencies): void { this.stopPresencePolling(); + this.accountLoginVersion += 1; + this.cloudSession?.masterKey.fill(0); this.cloudSession = undefined; this.cloudRelayUrl = ''; this.cloudAccountUsername = ''; diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/VisibleConversationController.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/VisibleConversationController.ets index 07f46f85e4..92c345813e 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/VisibleConversationController.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/VisibleConversationController.ets @@ -1,283 +1,70 @@ import { RemoteSession } from '../../model/RemoteModels'; -import { ChatTimelineItem } from '../../model/ChatTimelineModels'; -import { ChatTimelineState } from '../../services/ChatTimelineStore'; -import { RemoteI18n } from '../../i18n/RemoteI18n'; -import { RemoteLogger } from '../../services/RemoteLogger'; -import { RemoteUiState } from '../../services/RemoteUiState'; -import { - GeneralChatServiceState, - GeneralChatServiceStatus -} from '../../services/general-chat/GeneralChatServiceState'; import { AppRoute } from '../navigation/AppRouteContract'; -import { GeneralChatPageState } from '../state/GeneralChatPageState'; import { RemotePageState } from '../state/RemotePageState'; -import { - GENERAL_CHAT_HOME_DRAFT_ID, - RemoteConversationDependencies, - requireRemoteRuntime -} from './ConversationRuntime'; +import { RemoteConversationDependencies, requireRemoteRuntime } from './ConversationRuntime'; import { RemoteCreateFlowController } from './RemoteCreateFlowController'; import { RemoteTranscriptController } from './RemoteTranscriptController'; +/** All visible conversation operations execute on the selected remote host. */ export class VisibleConversationController { - private readonly general: GeneralChatPageState; private readonly remote: RemotePageState; - private readonly remoteRuntime?: RemoteConversationDependencies; + private readonly remoteRuntime: RemoteConversationDependencies | undefined; private readonly transcript: RemoteTranscriptController; private readonly createFlow: RemoteCreateFlowController; private readonly setChatInputRef: (route: AppRoute, value: string) => void; - private readonly setVisibleStatusTextRef: (statusText: string) => void; - private readonly isGeneralComposerRouteRef: (route: AppRoute) => boolean; - private readonly notify: (message: string) => void; + private readonly setVisibleStatusTextRef: (text: string) => void; + private readonly notify: (text: string) => void; constructor( - general: GeneralChatPageState, remote: RemotePageState, remoteRuntime: RemoteConversationDependencies | undefined, transcript: RemoteTranscriptController, createFlow: RemoteCreateFlowController, setChatInputRef: (route: AppRoute, value: string) => void, - setVisibleStatusTextRef: (statusText: string) => void, - isGeneralComposerRouteRef: (route: AppRoute) => boolean, - notify: (message: string) => void + setVisibleStatusTextRef: (text: string) => void, + notify: (text: string) => void ) { - this.general = general; this.remote = remote; this.remoteRuntime = remoteRuntime; this.transcript = transcript; this.createFlow = createFlow; this.setChatInputRef = setChatInputRef; this.setVisibleStatusTextRef = setVisibleStatusTextRef; - this.isGeneralComposerRouteRef = isGeneralComposerRouteRef; this.notify = notify; } openHomeSession(session: RemoteSession, inPlace: boolean = false): void { requireRemoteRuntime(this.remoteRuntime).filePreview.close(); - if (session.agentType === 'chat') { - this.openGeneralSession(session); - return; - } void this.createFlow.openRemoteSession(session, inPlace).catch((err: Object) => { - const message = err instanceof Error ? err.message : String(err); - this.notify(message); + this.notify(err instanceof Error ? err.message : String(err)); }); } async deleteHomeSession(session: RemoteSession): Promise { - if (session.agentType !== 'chat') { - await this.createFlow.deleteRemoteSession(session); - return; - } - await requireRemoteRuntime(this.remoteRuntime).generalCommands.deleteSession(session, this.general.isBusy); - } - - activeGeneralChatAsRemoteSession(): RemoteSession { - const active = this.general.activeSession; - return { - id: active.sessionId, - title: active.title, - agentType: 'chat', - status: 'ready', - updatedAt: '', - createdAt: '', - messageCount: this.general.timelineItems.length, - workspacePath: active.workspacePath - }; - } - - activeGeneralUploadedFileCount(): number { - let count = 0; - this.general.timelineItems.forEach((item: ChatTimelineItem) => { - if (item.message && item.message.images) { - count += item.message.images.length; - } - }); - return count; - } - - async archiveHomeSession(session: RemoteSession, archived: boolean): Promise { - await requireRemoteRuntime(this.remoteRuntime).generalCommands.archiveSession(session, archived, this.general.isBusy); - } - - async exportHomeSession(session: RemoteSession): Promise { - const runtime = requireRemoteRuntime(this.remoteRuntime); - await runtime.generalCommands.exportSession( - session, - this.general.isBusy, - async (text: string): Promise => runtime.clipboard.writeText(text) - ); - } - - async openGeneralSession(item: RemoteSession): Promise { - const runtime = requireRemoteRuntime(this.remoteRuntime); - if (this.general.isBusy) { - return; - } - runtime.polling.stop(); - runtime.generalConversation.stop(false); - await runtime.generalCommands.openSession( - item, - this.general.isBusy, - async (sessionId: string): Promise => runtime.generalDrafts.restore(sessionId), - (_sessionId: string): void => runtime.appShell.replaceRouteWithoutAnimation(AppRoute.ChatHome) - ); + await this.createFlow.deleteRemoteSession(session); } - async startGeneralChat(text: string): Promise { - const runtime = requireRemoteRuntime(this.remoteRuntime); - const trimmed = text.trim(); - if (trimmed.length === 0 || this.general.isBusy) { - return; - } - runtime.polling.stop(); - runtime.generalConversation.stop(false); - runtime.generalDrafts.cancel(); - const created = await runtime.generalCommands.createSession( - trimmed, - this.general.isBusy, - async (): Promise => runtime.generalDrafts.clearHomeNow(), - (_sessionId: string): void => runtime.appShell.replaceRouteWithoutAnimation(AppRoute.ChatHome) - ); - if (created) { - await runtime.generalConversation.sendMessage(); - } - } - - async sendVisibleMessage(): Promise { - const runtime = requireRemoteRuntime(this.remoteRuntime); - if (runtime.appShell.isGeneralChatVisible()) { - if ((this.general.activeSession.sessionId || '').length === 0) { - this.startVisibleGeneralChat(); - return; - } - await runtime.generalConversation.sendMessage(); - return; - } - await this.transcript.sendRemoteMessage(); - } - - async stopVisibleTask(): Promise { - const runtime = requireRemoteRuntime(this.remoteRuntime); - if (runtime.appShell.isGeneralChatVisible()) { - runtime.generalConversation.stop(true); - return; - } - await this.transcript.stopRemoteTask(); - } + async sendVisibleMessage(): Promise { await this.transcript.sendRemoteMessage(); } + async stopVisibleTask(): Promise { await this.transcript.stopRemoteTask(); } + async renameVisibleSession(title: string): Promise { await this.transcript.renameRemoteSession(title); } + async retryVisibleMessage(text: string): Promise { this.transcript.retryRemoteMessage(text); } + downloadVisibleFile(path: string): void { void this.transcript.downloadRemoteFile(path); } + async selectVisibleModel(modelId: string): Promise { await this.transcript.selectRemoteModel(modelId); } closeActiveChat(): void { const runtime = requireRemoteRuntime(this.remoteRuntime); runtime.filePreview.close(); - runtime.hooks.stopVoiceInput(); - if (runtime.appShell.isRoute(AppRoute.GeneralChat)) { - runtime.generalDrafts.persistVisible(this.general.chatInput); - runtime.generalConversation.stop(true); - runtime.appShell.popRoute(AppRoute.ChatHome); - this.restoreGeneralChatDraft(GENERAL_CHAT_HOME_DRAFT_ID); - return; - } + void runtime.hooks.stopVoiceInput(); runtime.polling.stop(); this.remote.setConversationDismissed(true); runtime.appShell.replaceRouteWithoutAnimation(AppRoute.RemoteHome); } - async renameVisibleSession(title: string): Promise { - const runtime = requireRemoteRuntime(this.remoteRuntime); - if (runtime.appShell.isGeneralChatVisible()) { - await runtime.generalCommands.renameActiveSession(this.general.activeSession, title); - return; - } - await this.transcript.renameRemoteSession(title); - } - - async retryVisibleMessage(text: string): Promise { - const runtime = requireRemoteRuntime(this.remoteRuntime); - if (runtime.appShell.isGeneralChatVisible()) { - const prepared = await runtime.generalCommands.retryMessage( - this.general.activeSession.sessionId || '', text, this.general.isBusy - ); - if (prepared) { - await runtime.generalConversation.sendMessage(); - } - return; - } - this.transcript.retryRemoteMessage(text); - } - - downloadVisibleFile(path: string): void { - if (requireRemoteRuntime(this.remoteRuntime).appShell.isGeneralChatVisible()) { - this.general.setStatus(RemoteI18n.t('generalChat.fileDownloadMock')); - return; - } - this.transcript.downloadRemoteFile(path); - } - - async selectVisibleModel(modelId: string): Promise { - const runtime = requireRemoteRuntime(this.remoteRuntime); - if (runtime.appShell.isGeneralChatVisible()) { - await runtime.settings.selectModel(modelId); - return; - } - await this.transcript.selectRemoteModel(modelId); - } - - startVisibleGeneralChat(): void { - const rawText = this.general.chatInput.trim(); - const text = rawText.length > 0 ? rawText : - (this.general.selectedImages.length > 0 ? RemoteI18n.t('chat.analyzeImageDefault') : ''); - if (text.length === 0 || this.general.isBusy) { - return; - } - if (this.general.serviceState === GeneralChatServiceState.Unconfigured) { - const statusText = GeneralChatServiceStatus.userMessage(this.general.serviceState); - this.general.setStatus(statusText); - this.notify(statusText); - return; - } - this.startGeneralChat(text); - } - - generalChatHomeStatusText(): string { - if (this.general.serviceState === GeneralChatServiceState.Ready || - this.general.serviceState === GeneralChatServiceState.Sending || - this.general.serviceState === GeneralChatServiceState.Streaming) { - return ''; - } - return GeneralChatServiceStatus.userMessage(this.general.serviceState, this.general.statusText); - } - - prepareNewGeneralChat(): void { - const runtime = requireRemoteRuntime(this.remoteRuntime); - runtime.hooks.stopVoiceInput(); - runtime.generalConversation.stop(true); - runtime.generalDrafts.clearHome(); - this.general.clearComposer(); - this.general.clearActiveSession(); - this.resetGeneralTimeline(''); - runtime.appShell.replaceRouteWithoutAnimation(AppRoute.ChatHome); - } - onVisibleChatInputChange(route: AppRoute, value: string): void { this.setChatInputRef(route, value); - if (this.isGeneralComposerRouteRef(route)) { - requireRemoteRuntime(this.remoteRuntime).generalDrafts.scheduleVisible(value); - } - } - - visibleGeneralChatDraftId(): string { - return requireRemoteRuntime(this.remoteRuntime).appShell.isGeneralChatVisible() ? - this.general.activeSession.sessionId || GENERAL_CHAT_HOME_DRAFT_ID : ''; - } - - async restoreGeneralChatDraft(draftId: string): Promise { - this.general.setChatInput(await requireRemoteRuntime(this.remoteRuntime).generalDrafts.restore(draftId)); } latestUserMessageText(): string { - if (requireRemoteRuntime(this.remoteRuntime).appShell.isGeneralChatVisible()) { - return this.general.latestUserMessageText(); - } const candidates = this.remote.persistedMessages.concat(this.remote.optimisticMessages); for (let index = candidates.length - 1; index >= 0; index--) { if (candidates[index].role === 'user' && candidates[index].text.trim().length > 0) { @@ -287,32 +74,8 @@ export class VisibleConversationController { return ''; } - resetGeneralTimeline(sessionId: string): void { - requireRemoteRuntime(this.remoteRuntime).timeline.reset(sessionId); - this.syncGeneralTimeline(); - } - - syncGeneralTimeline(): void { - const runtime = requireRemoteRuntime(this.remoteRuntime); - const state: ChatTimelineState = runtime.timeline.snapshotState(); - const projectedItems = runtime.timeline.viewState(false); - this.general.setTimelineProjection( - state.persistedMessages, - state.optimisticMessages, - state.activeTurn || RemoteUiState.emptyActiveTurn(), - false, - projectedItems - ); - const itemSummary = projectedItems.map((item: ChatTimelineItem) => { - const message = item.message; - return `${item.type}:${item.id}:${message ? message.status : ''}:${message ? message.text.length : 0}`; - }).join(','); - RemoteLogger.info(`general chat projection revision=${this.general.timelineRevision} persisted=${state.persistedMessages.length} active=${state.activeTurn ? state.activeTurn.id : 'none'} items=${itemSummary}`); - } - showHomeToast(message: string): void { - const runtime = requireRemoteRuntime(this.remoteRuntime); - if (!runtime.hooks.showToast(message)) { + if (!requireRemoteRuntime(this.remoteRuntime).hooks.showToast(message)) { this.setVisibleStatusTextRef(message); } } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/AccountDeviceLink.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/AccountDeviceLink.ets new file mode 100644 index 0000000000..a8105d7e37 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/AccountDeviceLink.ets @@ -0,0 +1,31 @@ +export const OFFICIAL_RELAY_URL: string = 'https://remote.openbitfun.com/v/1.0.0'; +export interface AccountDeviceLink { relayUrl: string; deviceId: string; } + +export function normalizeAccountRelayUrl(value: string): string { + const normalized = value.trim().replace(/\/+$/, ''); + if (normalized === OFFICIAL_RELAY_URL) return normalized; + const match = /^(https?:\/\/)(localhost|\[[0-9a-fA-F:]+\]|[0-9.]+)(?::([0-9]{1,5}))?$/.exec(normalized); + if (!match) return ''; + if (match[3] && (Number(match[3]) < 1 || Number(match[3]) > 65535)) return ''; + const host = match[2].toLowerCase(); + const octets = host.split('.').map((part: string): number => Number(part)); + const local = host === 'localhost' || host === '[::1]' || + /^\[(?:f[cd][0-9a-f]{2}|fe[89ab][0-9a-f]):/.test(host) || + (octets.length === 4 && octets.every((part: number): boolean => Number.isInteger(part) && part >= 0 && part <= 255) && + (octets[0] === 10 || octets[0] === 127 || (octets[0] === 192 && octets[1] === 168) || + (octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31) || (octets[0] === 169 && octets[1] === 254))); + return local ? normalized : ''; +} + +/** Endpoint and target are hints; the account directory is the authority. */ +export function accountDeviceLink(value: string): AccountDeviceLink | undefined { + const text = value.trim(); + if (text.length > 8192) return undefined; + const parts = text.split('/#/pair?did='); + if (parts.length !== 2) return undefined; + const relayUrl = normalizeAccountRelayUrl(parts[0]); + let deviceId = ''; + try { deviceId = decodeURIComponent(parts[1]); } catch (_error) { return undefined; } + if (!relayUrl || !/^[A-Za-z0-9_.-]{1,128}$/.test(deviceId) || deviceId === '.' || deviceId === '..') return undefined; + return { relayUrl, deviceId }; +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/CloudAccountClient.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/CloudAccountClient.ets index 932360f89c..d403841eb3 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/CloudAccountClient.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/CloudAccountClient.ets @@ -1,5 +1,7 @@ +import { normalizeAccountRelayUrl, OFFICIAL_RELAY_URL } from './AccountDeviceLink'; import { http } from '@kit.NetworkKit'; -import { CloudAccountCrypto, CloudAccountKdfParams } from './CloudAccountCrypto'; +import { CloudAccountCrypto } from './CloudAccountCrypto'; +import { X25519 } from './X25519'; import { Encoding } from './Encoding'; import { HarmonyRemoteCryptoCipher } from './RemoteCrypto'; import { OhosError } from './OhosError'; @@ -7,27 +9,19 @@ import { RemoteLogger } from './RemoteLogger'; import { RemoteI18n } from '../i18n/RemoteI18n'; import { CommandStatusResponse, EncryptedPayload, RemoteCommand } from '../model/RemoteModels'; -interface AccountChallenge { - salt: string; - kdf_salt: string; - argon2_params: string; - wrapped_master_key: string; +interface AccountAuthResponse { token: string; user_id: string; } +interface GitHubStart { + transactionId: string; transactionSecret: string; authorizationUrl: string; + expiresAt: number; pollIntervalSeconds: number; } - -interface AccountAuthResponse { - token: string; - user_id: string; -} - -interface LoginChallengeRequest { username: string; } +interface GitHubPollRequest { transactionId: string; transactionSecret: string; } +interface GitHubTokens { accessToken: string; } +interface GitHubPoll { status: string; tokens?: GitHubTokens; } interface LoginRequest { - username: string; - password_hash: string; - device_id: string; - device_name: string; - device_kind: string; - request_id?: string; + access_token: string; device_id: string; device_name: string; device_kind: string; + public_key: string; request_id: string; } +interface PeerPublicKey { public_key: string; } interface RelayErrorResponse { error?: string; } export class CloudAccountRequestError extends Error { @@ -66,9 +60,11 @@ export interface CloudProvisionedDevice { token: string; userId: string; deviceId: string; + deviceSecret: Uint8Array; } interface ProvisionDeviceRequest { + public_key: string; device_id: string; device_name: string; device_kind: string; @@ -89,19 +85,8 @@ interface CloudAccountDeviceWire { last_seen_at?: number; } -interface SyncSettingsEntry { - encrypted_data: string; - nonce: string; - version: number; -} - -export interface CloudSettingsBlob { - plaintext: string; - version: number; -} - /** Default OpenBitFun cloud relay used by the desktop account flow. */ -export const DEFAULT_CLOUD_RELAY_URL: string = 'https://remote.openbitfun.com/relay'; +export const DEFAULT_CLOUD_RELAY_URL: string = OFFICIAL_RELAY_URL; /** Device kinds the relay accepts; mirrors `relay-service/src/db.rs::DEVICE_KINDS`. */ const DEVICE_KIND_DESKTOP: string = 'desktop'; @@ -143,109 +128,54 @@ function isDesktopDeviceRow(device: CloudAccountDeviceWire, selfDeviceId: string } /** Client for the current desktop relay account protocol. */ +interface DeviceKeyPair { privateKey: Uint8Array; publicKey: Uint8Array; } + export class CloudAccountClient { private readonly cipher: HarmonyRemoteCryptoCipher = new HarmonyRemoteCryptoCipher(); - async login(relayUrl: string, username: string, password: string, deviceId: string): Promise { - return this.loginDevice( - relayUrl, username, password, deviceId, 'HarmonyOS Phone', DEVICE_KIND_MOBILE); - } + private authorizationGeneration: number = 0; - /** - * Compatibility path for relays whose dedicated device-provisioning route is - * present but cannot mint against their existing account database. - * - * This is still a real device login: the watch receives its own token and - * device row. Only the password proof travels to the relay, while the - * plaintext password and the account master key stay on this phone. - */ - async loginWatch( - relayUrl: string, - username: string, - password: string, - currentSession: CloudAccountSession, - deviceId: string, - deviceName: string, - requestId: string - ): Promise { - const watchSession = await this.loginDevice( - relayUrl, username, password, deviceId, deviceName, DEVICE_KIND_WATCH, requestId); - if (watchSession.userId !== currentSession.userId || - !CloudAccountClient.sameBytes(watchSession.masterKey, currentSession.masterKey)) { - throw new Error('The confirmed account does not match the signed-in account.'); - } - return { token: watchSession.token, userId: watchSession.userId, deviceId }; - } + cancelAuthorization(): void { this.authorizationGeneration++; } - private async loginDevice( - relayUrl: string, - username: string, - password: string, - deviceId: string, - deviceName: string, - deviceKind: string, - requestId: string = '' - ): Promise { - const normalizedRelayUrl = relayUrl.trim() || DEFAULT_CLOUD_RELAY_URL; - const normalizedUser = username.trim(); - const startedAt = Date.now(); - RemoteLogger.info(`cloud device login start relay=${normalizedRelayUrl} kind=${deviceKind}`); - if (normalizedUser.length === 0 || normalizedUser.length > 128 || password.length === 0 || password.length > 1024) { - throw new Error('Invalid account credentials.'); + async login(relayUrl: string, deviceId: string, openAuthorization: (url: string) => Promise, deviceSecret: Uint8Array): Promise { + const generation = ++this.authorizationGeneration; + const startRequest: Record = {}; + const start = await this.post(relayUrl, '/api/auth/github/start', startRequest); + if (generation !== this.authorizationGeneration) throw new Error('Sign-in cancelled.'); + if (!start.authorizationUrl.startsWith('https://github.com/login/oauth/authorize?')) { + throw new Error('Untrusted account authorization URL.'); } - const challengeRequest: LoginChallengeRequest = { username: normalizedUser }; - const challengeStartedAt = Date.now(); - const challenge = await this.post(normalizedRelayUrl, '/api/auth/login/challenge', challengeRequest); - RemoteLogger.info(`cloud login challenge received elapsed_ms=${Date.now() - challengeStartedAt}`); - const params = JSON.parse(challenge.argon2_params) as CloudAccountKdfParams; - const salt = Encoding.base64ToBytes(challenge.salt); - const kdfSalt = Encoding.base64ToBytes(challenge.kdf_salt); - const kek = await CloudAccountCrypto.derivePasswordHash(password, salt, params); - RemoteLogger.info(`cloud login password proof derived elapsed_ms=${Date.now() - startedAt}`); - const masterKey = await this.unwrapMasterKey(kek, challenge.wrapped_master_key); - RemoteLogger.info(`cloud login master key unwrapped elapsed_ms=${Date.now() - startedAt}`); - const passwordHash = await CloudAccountCrypto.derivePasswordHash(password, kdfSalt, params); - const loginRequest: LoginRequest = { - username: normalizedUser, - password_hash: Encoding.bytesToBase64(passwordHash), - device_id: deviceId, - device_name: deviceName, - device_kind: deviceKind - }; - if (requestId.length > 0) { - loginRequest.request_id = requestId; - } - const auth = await this.post(normalizedRelayUrl, '/api/auth/login', loginRequest); - RemoteLogger.info(`cloud login authenticated elapsed_ms=${Date.now() - startedAt}`); - return { token: auth.token, userId: auth.user_id, masterKey }; - } - - private static sameBytes(left: Uint8Array, right: Uint8Array): boolean { - if (left.length !== right.length) { - return false; - } - let difference = 0; - for (let index = 0; index < left.length; index += 1) { - difference |= left[index] ^ right[index]; + if (!await openAuthorization(start.authorizationUrl)) throw new Error('Could not open GitHub sign-in.'); + const deadline = Math.min(start.expiresAt * 1000, Date.now() + 600000); + while (Date.now() < deadline && generation === this.authorizationGeneration) { + await new Promise((resolve) => setTimeout(resolve, Math.min(30, Math.max(1, start.pollIntervalSeconds)) * 1000)); + if (generation !== this.authorizationGeneration) break; + const request: GitHubPollRequest = { transactionId: start.transactionId, transactionSecret: start.transactionSecret }; + const poll = await this.post(relayUrl, '/api/auth/github/poll', request); + if (generation !== this.authorizationGeneration) break; + if (poll.status === 'authorized' && poll.tokens?.accessToken) { + if (deviceSecret.length !== 32) throw new Error('Invalid device identity.'); + const keys: DeviceKeyPair = { privateKey: new Uint8Array(deviceSecret), publicKey: X25519.scalarMultBase(deviceSecret) }; + const body: LoginRequest = { + access_token: poll.tokens.accessToken, device_id: deviceId, device_name: 'HarmonyOS Phone', + device_kind: DEVICE_KIND_MOBILE, public_key: Encoding.bytesToBase64(keys.publicKey), + request_id: Encoding.randomId('').slice(1) + }; + try { + const auth = await this.post(relayUrl, '/api/auth/login', body); + if (generation !== this.authorizationGeneration) throw new Error('Sign-in cancelled.'); + if (!auth.token || !auth.user_id) throw new Error('Invalid account identity.'); + return { token: auth.token, userId: auth.user_id, masterKey: keys.privateKey }; + } catch (err) { + keys.privateKey.fill(0); + throw OhosError.wrap('GitHub sign-in', err); + } + } + if (poll.status === 'expired' || poll.status === 'denied') break; } - return difference === 0; + throw new Error('GitHub sign-in expired or was cancelled.'); } - /** - * Adds another device to this account and returns its own credential. - * - * The relay gates `/api/auth/provision-device` on nothing but - * `AuthToken::is_device_token()` (`relay-service/src/routes/auth.rs`), so a - * phone that signed in with the account password is as entitled to mint here - * as the desktop is — the desktop was never special, it just happened to be - * the one holding a session. A token delegated by a room pairing is - * `delegated_control` rather than `device` and is refused with 403; that is - * the caller's cue to fall back to asking the desktop. - * - * The account master key is deliberately not part of this exchange. The relay - * never sees it, and the caller already holds its own copy — a watch gets it - * sealed to its ephemeral key, not from here. - */ async provisionDevice( relayUrl: string, session: CloudAccountSession, @@ -253,7 +183,9 @@ export class CloudAccountClient { deviceName: string, requestId: string ): Promise { + const keys = X25519.generateKeyPair(); const body: ProvisionDeviceRequest = { + public_key: Encoding.bytesToBase64(keys.publicKey), device_id: deviceId, device_name: deviceName, device_kind: DEVICE_KIND_WATCH, @@ -269,26 +201,7 @@ export class CloudAccountClient { // in as somebody else. Refuse rather than pass it on. throw new Error('Relay returned a mismatched provisioned device identity.'); } - return { token, userId, deviceId: provisionedDeviceId }; - } - - async fetchSettings(relayUrl: string, session: CloudAccountSession): Promise { - let entry: SyncSettingsEntry; - try { - entry = await this.request(relayUrl, '/api/sync/settings', 'GET', undefined, session.token); - } catch (err) { - if (err instanceof CloudAccountRequestError && err.statusCode === 404) { - return undefined; - } - throw err instanceof Error ? err : new Error('Cloud settings request failed.'); - } - if (!entry || !entry.encrypted_data || !entry.nonce) { - return undefined; - } - return { - plaintext: await this.decryptSyncPayload(session.masterKey, entry.encrypted_data, entry.nonce), - version: entry.version - }; + return { token, userId, deviceId: provisionedDeviceId, deviceSecret: keys.privateKey }; } /** @@ -331,10 +244,13 @@ export class CloudAccountClient { if (target.length === 0) { throw new Error('Remote target device is required.'); } + const peer = await this.request(relayUrl, + `/api/devices/${encodeURIComponent(target)}/key`, 'GET', undefined, session.token); + const messageKey = await CloudAccountCrypto.deriveDeviceKey(session.masterKey, Encoding.base64ToBytes(peer.public_key)); const nonce = Encoding.randomBytes(12); const encrypted = await this.cipher.encrypt( Encoding.utf8ToBytes(JSON.stringify(command)), - session.masterKey, + messageKey, nonce ); const body: EncryptedPayload = { @@ -352,7 +268,7 @@ export class CloudAccountClient { ); const plain = await this.cipher.decrypt( Encoding.base64ToBytes(response.encrypted_data), - session.masterKey, + messageKey, Encoding.base64ToBytes(response.nonce) ); const parsed = Encoding.parseJsonObject(Encoding.bytesToUtf8(plain)); @@ -362,32 +278,6 @@ export class CloudAccountClient { return parsed; } - private async decryptSyncPayload(masterKey: Uint8Array, data: string, nonceText: string): Promise { - const plain = await this.cipher.decrypt(Encoding.base64ToBytes(data), masterKey, Encoding.base64ToBytes(nonceText)); - return Encoding.bytesToUtf8(plain); - } - - private async unwrapMasterKey(kek: Uint8Array, packed: string): Promise { - const parts = packed.split('.'); - if (parts.length !== 2) { - throw new Error('Invalid wrapped master key.'); - } - const ciphertext = Encoding.base64ToBytes(parts[0]); - const nonce = Encoding.base64ToBytes(parts[1]); - if (kek.length !== 32 || nonce.length !== 12) { - throw new Error('Invalid account encryption parameters.'); - } - try { - const plain = await this.cipher.decrypt(ciphertext, kek, nonce); - if (plain.length !== 32) { - throw new Error('Invalid master key length.'); - } - return plain; - } catch (_err) { - throw new Error('Invalid username or password.'); - } - } - private async post(relayUrl: string, path: string, body: Object): Promise { return this.request(relayUrl, path, 'POST', body); } @@ -401,7 +291,8 @@ export class CloudAccountClient { readTimeoutMs: number = 120000 ): Promise { const request = http.createHttp(); - const base = relayUrl.replace(/\/$/, ''); + const base = normalizeAccountRelayUrl(relayUrl); + if (!base) throw new Error('Invalid Relay URL'); // Timed so the watch has something to be slow relative to. Phone and watch // reach the same relay and the same desktop, so a gap between them is the // client's own cost and a shared floor is the round trip's. diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/CloudAccountCrypto.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/CloudAccountCrypto.ets index d392ff20ec..9830ad4cce 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/CloudAccountCrypto.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/CloudAccountCrypto.ets @@ -1,60 +1,27 @@ +import { cryptoFramework } from '@kit.CryptoArchitectureKit'; import { Encoding } from './Encoding'; -import openbitfunCrypto from 'libopenbitfun_crypto.so'; +import { X25519 } from './X25519'; -export interface CloudAccountKdfParams { - m: number; - t: number; - p: number; -} - -/** - * Desktop-compatible Argon2id derivation. Keep this adapter isolated so the - * relay protocol and the UI never depend on a particular crypto provider. - */ +/** Per-device ECDH and the Relay v1.0.0 domain-separated HKDF contract. */ export class CloudAccountCrypto { - private static readonly KDF_TIMEOUT_MS: number = 30000; - - static async derivePasswordHash(password: string, salt: Uint8Array, params: CloudAccountKdfParams): Promise { - CloudAccountCrypto.validateParams(params, salt); - return CloudAccountCrypto.withTimeout( - openbitfunCrypto.argon2idRaw(Encoding.utf8ToBytes(password), salt, params.m, params.t, params.p), - CloudAccountCrypto.KDF_TIMEOUT_MS - ); - } - - private static withTimeout(operation: Promise, timeoutMs: number): Promise { - return new Promise((resolve, reject) => { - let settled = false; - const timeoutId = setTimeout(() => { - if (settled) { - return; - } - settled = true; - reject(new Error('Argon2id operation timed out.')); - }, timeoutMs); - operation.then((value: Uint8Array) => { - if (settled) { - return; - } - settled = true; - clearTimeout(timeoutId); - resolve(value); - }).catch((err: Object) => { - if (settled) { - return; - } - settled = true; - clearTimeout(timeoutId); - reject(err); - }); - }); - } - - private static validateParams(params: CloudAccountKdfParams, salt: Uint8Array): void { - if (salt.length < 8 || salt.length > 64 || - params.m < 8 * 1024 || params.m > 256 * 1024 || - params.t < 1 || params.t > 10 || params.p < 1 || params.p > 16) { - throw new Error('Invalid Argon2id parameters.'); + static async deriveDeviceKey(secret: Uint8Array, peerPublicKey: Uint8Array): Promise { + const shared = X25519.scalarMult(secret, peerPublicKey); + if (shared.every((value: number): boolean => value === 0)) { + throw new Error('Invalid peer public key.'); + } + const own = X25519.scalarMultBase(secret); + let ownFirst = true; + for (let i = 0; i < 32; i++) { + if (own[i] !== peerPublicKey[i]) { ownFirst = own[i] < peerPublicKey[i]; break; } } + const params: cryptoFramework.HKDFSpec = { + algName: 'HKDF', key: shared, + salt: Encoding.utf8ToBytes('OpenBitFun Relay v1.0.0 device key'), + info: ownFirst ? Encoding.concat(own, peerPublicKey) : Encoding.concat(peerPublicKey, own), + keySize: 32 + }; + try { + return (await cryptoFramework.createKdf('HKDF|SHA256|EXTRACT_AND_EXPAND').generateSecret(params)).data; + } finally { shared.fill(0); } } } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/CloudAccountSessionStore.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/CloudAccountSessionStore.ets index 75838c7754..b84233d4ec 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/CloudAccountSessionStore.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/CloudAccountSessionStore.ets @@ -5,8 +5,8 @@ import { HarmonyUpgradeIdentityContract } from './HarmonyUpgradeIdentityContract import { OhosError } from './OhosError'; const STORE_NAME: string = HarmonyUpgradeIdentityContract.CLOUD_ACCOUNT_STORE; -const CIPHER_KEY: string = 'session_cipher'; -const IV_KEY: string = 'session_iv'; +const CIPHER_KEY: string = 'github_device_session_v1_cipher'; +const IV_KEY: string = 'github_device_session_v1_iv'; const HUKS_ALIAS: string = HarmonyUpgradeIdentityContract.CLOUD_ACCOUNT_HUKS_ALIAS; export interface PersistedCloudAccountSession { @@ -22,6 +22,7 @@ export interface PersistedCloudAccountSession { /** Persists only an HUKS-encrypted account session, never plaintext secrets. */ export class CloudAccountSessionStore { private store?: preferences.Preferences; + private deviceKeyWork?: Promise; async init(context: Context): Promise { try { @@ -31,7 +32,31 @@ export class CloudAccountSessionStore { } } + async deviceSecret(): Promise { + if (!this.deviceKeyWork) this.deviceKeyWork = this.loadOrCreateDeviceSecret(); + try { return new Uint8Array(await this.deviceKeyWork); } + catch (err) { this.deviceKeyWork = undefined; throw OhosError.wrap('device identity', err); } + } + + private async loadOrCreateDeviceSecret(): Promise { + const value = await this.readSealed('device_private_key_v1_cipher', 'device_private_key_v1_iv'); + if (value) { + const secret = Encoding.base64ToBytes(value); + if (secret.length !== 32) throw new Error('Stored device identity is invalid.'); + return secret; + } + const previous = await this.load(); + const secret = previous ? Encoding.base64ToBytes(previous.masterKey) : Encoding.randomBytes(32); + if (secret.length !== 32) throw new Error('Stored device identity is invalid.'); + await this.writeSealed('device_private_key_v1_cipher', 'device_private_key_v1_iv', Encoding.bytesToBase64(secret)); + return secret; + } + async save(session: PersistedCloudAccountSession): Promise { + await this.writeSealed(CIPHER_KEY, IV_KEY, JSON.stringify(session)); + } + + private async writeSealed(cipherKey: string, ivKey: string, plaintext: string): Promise { try { const store = this.requireStore(); await this.ensureKey(); @@ -40,13 +65,13 @@ export class CloudAccountSessionStore { const handle = await huks.initSession(HUKS_ALIAS, options); const encrypted = await huks.finishSession(handle.handle, { properties: options.properties, - inData: Encoding.utf8ToBytes(JSON.stringify(session)) + inData: Encoding.utf8ToBytes(plaintext) }); if (!encrypted.outData || encrypted.outData.length === 0) { throw new Error('Cloud account session encryption failed.'); } - await store.put(CIPHER_KEY, Encoding.bytesToBase64(encrypted.outData)); - await store.put(IV_KEY, Encoding.bytesToBase64(iv)); + await store.put(cipherKey, Encoding.bytesToBase64(encrypted.outData)); + await store.put(ivKey, Encoding.bytesToBase64(iv)); await store.flush(); } catch (err) { throw OhosError.wrap('cloud account session save', err); @@ -54,10 +79,15 @@ export class CloudAccountSessionStore { } async load(): Promise { + const text = await this.readSealed(CIPHER_KEY, IV_KEY); + return text ? JSON.parse(text) as PersistedCloudAccountSession : undefined; + } + + private async readSealed(cipherKey: string, ivKey: string): Promise { try { const store = this.requireStore(); - const cipher = await store.get(CIPHER_KEY, ''); - const ivText = await store.get(IV_KEY, ''); + const cipher = await store.get(cipherKey, ''); + const ivText = await store.get(ivKey, ''); if (typeof cipher !== 'string' || typeof ivText !== 'string' || cipher.length === 0 || ivText.length === 0) { return undefined; } @@ -71,7 +101,7 @@ export class CloudAccountSessionStore { if (!decrypted.outData) { return undefined; } - return JSON.parse(Encoding.bytesToUtf8(decrypted.outData)) as PersistedCloudAccountSession; + return Encoding.bytesToUtf8(decrypted.outData); } catch (err) { throw OhosError.wrap('cloud account session load', err); } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/ConnectScanDecisionPolicy.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/ConnectScanDecisionPolicy.ets index a47846f55a..b79c502b6b 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/ConnectScanDecisionPolicy.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/ConnectScanDecisionPolicy.ets @@ -1,74 +1,5 @@ export class DetectedUrlAction { static readonly INVALID: string = 'invalid'; - static readonly PAIR_NOW: string = 'pair_now'; - static readonly PROMPT_ACCOUNT_PASSWORD: string = 'prompt_account_password'; static readonly USE_CLOUD_DEVICE: string = 'use_cloud_device'; static readonly SHOW_CLOUD_DEVICES: string = 'show_cloud_devices'; } - -export class DetectedRemoteUrlResult { - readonly action: string; - readonly cloudDeviceId: string; - - constructor(action: string, cloudDeviceId: string = '') { - this.action = action; - this.cloudDeviceId = cloudDeviceId; - } -} - -/** - * What a scanned pair URL should do next. Applying the URL and starting a - * connect are separate steps: the detector never connects by itself. - * - * An OpenBitFun cloud session on this phone is already the account proof. The - * account device directory is the authoritative membership check for a QR - * target; display usernames are not, because aliases and restored legacy - * records can legitimately differ. Reuse is still limited to the same relay, - * then device selection verifies the scanned desktop id through the - * authenticated directory instead of asking for the password based on a - * string mismatch. - */ -export class ConnectScanDecisionPolicy { - static decide( - accountAuth: boolean, - hasCloudAccountSession: boolean, - _cloudUsername: string, - _qrUsername: string, - desktopId: string, - cloudRelayUrl: string = '', - qrRelayUrl: string = '' - ): string { - if (!accountAuth) { - return DetectedUrlAction.PAIR_NOW; - } - if (hasCloudAccountSession && - ConnectScanDecisionPolicy.relayEndpointsCompatible(cloudRelayUrl, qrRelayUrl)) { - return desktopId.trim().length > 0 ? - DetectedUrlAction.USE_CLOUD_DEVICE : - DetectedUrlAction.SHOW_CLOUD_DEVICES; - } - return DetectedUrlAction.PROMPT_ACCOUNT_PASSWORD; - } - - static relayEndpointsCompatible(cloudRelayUrl: string, qrRelayUrl: string): boolean { - const cloud = ConnectScanDecisionPolicy.normalizedRelayEndpoint(cloudRelayUrl); - const qr = ConnectScanDecisionPolicy.normalizedRelayEndpoint(qrRelayUrl); - return cloud.length > 0 && cloud === qr; - } - - private static normalizedRelayEndpoint(value: string): string { - let normalized = value.trim().toLowerCase(); - if (normalized.indexOf('wss://') === 0) { - normalized = `https://${normalized.slice(6)}`; - } else if (normalized.indexOf('ws://') === 0) { - normalized = `http://${normalized.slice(5)}`; - } - while (normalized.length > 0 && normalized.charAt(normalized.length - 1) === '/') { - normalized = normalized.slice(0, normalized.length - 1); - } - if (normalized.endsWith('/ws')) { - normalized = normalized.slice(0, normalized.length - 3); - } - return normalized; - } -} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/MobileIdentityStore.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/MobileIdentityStore.ets index d42e662cce..e31e136b03 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/MobileIdentityStore.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/MobileIdentityStore.ets @@ -2,7 +2,7 @@ import { preferences } from '@kit.ArkData'; import { Encoding } from './Encoding'; import { HarmonyUpgradeIdentityContract } from './HarmonyUpgradeIdentityContract'; import { OhosError } from './OhosError'; -import { RemoteDescriptorParser } from './RemoteDescriptorParser'; +import { accountDeviceLink } from './AccountDeviceLink'; import { RemoteLogger } from './RemoteLogger'; import { RemoteModelPreferenceStore } from './RemoteModelController'; @@ -53,7 +53,7 @@ export class MobileIdentityStore implements RemoteModelPreferenceStore { }; if (snapshot.remoteUrl.length > 0) { RemoteLogger.info( - `pairing snapshot restored room=${this.roomReference(snapshot.remoteUrl)} saved_at=${snapshot.pairingSavedAt}` + `pairing snapshot restored device=${this.deviceReference(snapshot.remoteUrl)} saved_at=${snapshot.pairingSavedAt}` ); } return snapshot; @@ -73,7 +73,7 @@ export class MobileIdentityStore implements RemoteModelPreferenceStore { } catch (err) { throw OhosError.wrap('mobile identity save pairing snapshot', err); } - RemoteLogger.info(`pairing snapshot saved room=${this.roomReference(normalizedUrl)} saved_at=${savedAt}`); + RemoteLogger.info(`pairing snapshot saved device=${this.deviceReference(normalizedUrl)} saved_at=${savedAt}`); } async clearPairingInput(): Promise { @@ -155,9 +155,9 @@ export class MobileIdentityStore implements RemoteModelPreferenceStore { } } - private roomReference(remoteUrl: string): string { + private deviceReference(remoteUrl: string): string { try { - return RemoteDescriptorParser.parse(remoteUrl).roomId.slice(0, 8); + return accountDeviceLink(remoteUrl)?.deviceId.slice(0, 8) || 'unavailable'; } catch (_err) { return 'invalid'; } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RelayHttpClient.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RelayHttpClient.ets deleted file mode 100644 index 9fc50aed1d..0000000000 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RelayHttpClient.ets +++ /dev/null @@ -1,338 +0,0 @@ -import { http } from '@kit.NetworkKit'; -import { RemoteI18n } from '../i18n/RemoteI18n'; -import { RemoteCommandFactory } from './RemoteCommandFactory'; -import { RemoteCrypto } from './RemoteCrypto'; -import { RemoteLogger } from './RemoteLogger'; -import { ChallengeCommand, CommandStatusResponse, DelegatedIdentityResponse, EncryptedPayload, InitialSyncResponse, PairChallengeResponse, PairRequest, PeerDeviceProvisionedResponse, RemoteCommand, RemoteDescriptor } from '../model/RemoteModels'; - -export interface PairIdentity { - userId: string; - password?: string; -} - -/** - * Result of a `provision_peer_device` round trip, reported rather than thrown - * because the caller has to tell two failures apart and an `Error` cannot - * carry that distinction back through the transport's error collapsing. - * - * `desktopReported` means the desktop answered and refused, and its message is - * worth showing verbatim. Everything else — no answer at all — covers both a - * desktop that is offline and a desktop too old to know the command: an older - * build decrypts the payload, fails to deserialize the unknown variant, and - * replies with nothing, which on this side is indistinguishable from silence. - */ -export interface PeerDeviceProvisionOutcome { - ok: boolean; - token: string; - userId: string; - masterKeyBase64: string; - deviceId: string; - failure: string; - desktopReported: boolean; -} - -export class RelayHttpClient { - private descriptor?: RemoteDescriptor; - private crypto?: RemoteCrypto; - private readonly deviceName: string = 'HarmonyOS Phone'; - delegatedToken: string = ''; - delegatedMasterKey: string = ''; - delegatedUserId: string = ''; - pairedDeviceId: string = ''; - homeDeviceId: string = ''; - - bind(descriptor: RemoteDescriptor, crypto: RemoteCrypto): void { - this.descriptor = descriptor; - this.crypto = crypto; - } - - reset(): void { - this.descriptor = undefined; - this.crypto = undefined; - this.clearDelegatedIdentity(); - this.pairedDeviceId = ''; - this.homeDeviceId = ''; - } - - async pair(deviceId: string, identity: PairIdentity): Promise { - const descriptor = this.requireDescriptor(); - const crypto = this.requireCrypto(); - const startedAt = Date.now(); - RemoteLogger.info(`pair start room=${RelayHttpClient.shortRoomId(descriptor.roomId)}`); - crypto.deriveSharedKey(descriptor.publicKey); - const userId = identity.userId.trim(); - - const pairRequest: PairRequest = { - public_key: crypto.getPublicKeyBase64(), - device_id: deviceId, - device_name: this.deviceName - }; - const pairResponse = await this.postJson(`/api/rooms/${descriptor.roomId}/pair`, pairRequest); - RemoteLogger.info(`pair challenge received ms=${Date.now() - startedAt}`); - const challenge = await crypto.decryptJson(pairResponse); - const challengeCommand: ChallengeCommand = { - challenge_echo: challenge.challenge, - device_id: deviceId, - device_name: this.deviceName, - mobile_install_id: deviceId, - user_id: userId - }; - if (identity.password && identity.password.length > 0) { - challengeCommand.password = identity.password; - } - const commandPayload = await crypto.encryptJson(challengeCommand); - const encryptedInitialSync = await this.postJson(`/api/rooms/${descriptor.roomId}/command`, commandPayload); - const initialSync = await crypto.decryptJson(encryptedInitialSync); - if (initialSync.resp === 'error') { - throw new Error(initialSync.message || 'Desktop rejected the pairing request.'); - } - RemoteLogger.info(`pair complete ms=${Date.now() - startedAt}`); - return initialSync; - } - - async requestDelegatedIdentity(force: boolean = false): Promise { - if (!force && this.hasDelegatedIdentity()) { - return true; - } - if (force) { - this.clearDelegatedIdentity(); - } - try { - const response = await this.sendCommand({ cmd: 'get_delegated_identity' }, 30000); - if (response.resp === 'delegate_identity' && response.token && response.master_key) { - this.delegatedToken = response.token; - this.delegatedMasterKey = response.master_key; - this.delegatedUserId = response.user_id || ''; - if (response.device_id) { - this.homeDeviceId = response.device_id; - if (!this.pairedDeviceId) { - this.pairedDeviceId = response.device_id; - } - } - return true; - } - } catch (_err) { - return false; - } - return false; - } - - /** - * Mint a full account device credential for a device that cannot type a - * password. Pinned to the room channel on purpose: only the desktop's room - * loop holds the trusted pairing identity that authorizes provisioning, so - * the account-device transport would answer "not available on this host". - */ - async provisionPeerDevice( - deviceId: string, - deviceName: string, - requestId: string, - readTimeoutMs: number = 45000 - ): Promise { - const command: RemoteCommand = RemoteCommandFactory.provisionPeerDevice(deviceId, deviceName, requestId); - command._request_id = `req_provision_${requestId}`; - let response: PeerDeviceProvisionedResponse; - try { - response = await this.sendCommand(command, readTimeoutMs, false); - } catch (err) { - const message = err instanceof Error ? err.message : JSON.stringify(err); - RemoteLogger.error(`provision peer device transport failure: ${message}`); - return RelayHttpClient.provisionFailure(message, false); - } - if (response.resp === 'error') { - return RelayHttpClient.provisionFailure(response.message || '', true); - } - const token = response.token || ''; - const userId = response.user_id || ''; - const masterKey = response.master_key || ''; - const provisionedDeviceId = response.device_id || ''; - if (response.resp !== 'peer_device_provisioned' || token.length === 0 || userId.length === 0 || - masterKey.length === 0) { - // A well-formed reply that is missing the credential is a desktop we do - // not understand, not a desktop that refused. Treat it as silence. - RemoteLogger.error(`provision peer device unexpected response resp=${response.resp || 'unknown'}`); - return RelayHttpClient.provisionFailure('', false); - } - if (provisionedDeviceId !== deviceId) { - // The desktop already checks this, so reaching here means the reply did - // not come from the desktop we asked. Refuse rather than hand the watch - // a credential minted for some other device. - RemoteLogger.error('provision peer device returned a mismatched device id'); - return RelayHttpClient.provisionFailure('', false); - } - return { - ok: true, - token, - userId, - masterKeyBase64: masterKey, - deviceId: provisionedDeviceId, - failure: '', - desktopReported: false - }; - } - - clearDelegatedIdentity(): void { - this.delegatedToken = ''; - this.delegatedMasterKey = ''; - this.delegatedUserId = ''; - } - - hasDelegatedIdentity(): boolean { - return this.delegatedToken.length > 0 && this.delegatedMasterKey.length > 0; - } - - async sendCommand( - command: RemoteCommand, - readTimeoutMs: number = 30000, - throwOnRemoteError: boolean = true - ): Promise { - const descriptor = this.requireDescriptor(); - const crypto = this.requireCrypto(); - const commandName = command.cmd || 'unknown'; - const requestId = RelayHttpClient.shortRequestId(command._request_id || ''); - const startedAt = Date.now(); - let stage = 'encrypt'; - try { - RemoteLogger.info(`command encrypt start cmd=${commandName} request=${requestId}`); - const encrypted = await crypto.encryptJson(command); - RemoteLogger.info(`command encrypt done cmd=${commandName} request=${requestId} ms=${Date.now() - startedAt}`); - - stage = 'http'; - const httpStartedAt = Date.now(); - RemoteLogger.info(`command http start cmd=${commandName} request=${requestId} timeout=${readTimeoutMs}`); - const encryptedResponse = await this.postJson( - `/api/rooms/${descriptor.roomId}/command`, - encrypted, - readTimeoutMs - ); - RemoteLogger.info(`command http done cmd=${commandName} request=${requestId} ms=${Date.now() - httpStartedAt}`); - - stage = 'decrypt'; - const decryptStartedAt = Date.now(); - RemoteLogger.info(`command decrypt start cmd=${commandName} request=${requestId}`); - const response = await crypto.decryptJson(encryptedResponse); - RemoteLogger.info(`command decrypt done cmd=${commandName} request=${requestId} resp=${response.resp || 'unknown'} ms=${Date.now() - decryptStartedAt}`); - if (response.resp === 'error') { - RemoteLogger.error(`command remote error cmd=${commandName} request=${requestId} message=${response.message || 'unknown'}`); - if (throwOnRemoteError) { - throw new Error(response.message || 'Remote command failed.'); - } - return response; - } - RemoteLogger.info(`command complete cmd=${commandName} request=${requestId} ms=${Date.now() - startedAt}`); - return response; - } catch (err) { - RemoteLogger.error(`command failed cmd=${commandName} request=${requestId} stage=${stage} ms=${Date.now() - startedAt}`); - const message = err instanceof Error ? err.message : JSON.stringify(err); - throw new Error(message); - } - } - - private async postJson(path: string, payload: Object, readTimeoutMs: number = 30000): Promise { - const descriptor = this.requireDescriptor(); - const request = http.createHttp(); - try { - const response = await request.request(`${descriptor.relayUrl}${path}`, { - method: http.RequestMethod.POST, - header: { - 'Content-Type': 'application/json', - 'Accept': 'application/json' - }, - extraData: JSON.stringify(payload), - expectDataType: http.HttpDataType.STRING, - connectTimeout: 15000, - readTimeout: readTimeoutMs - }); - if (response.responseCode < 200 || response.responseCode >= 300) { - throw new Error(this.httpErrorMessage(response.responseCode)); - } - const text = typeof response.result === 'string' ? response.result : JSON.stringify(response.result); - try { - return JSON.parse(text) as T; - } catch (_err) { - throw new Error(RemoteI18n.t('errors.remoteDataInvalid')); - } - } catch (err) { - throw new Error(this.networkErrorMessage(err)); - } finally { - request.destroy(); - } - } - - private httpErrorMessage(code: number): string { - if (code === 401 || code === 403) { - return RemoteI18n.t('errors.pairRejected'); - } - if (code === 404) { - return RemoteI18n.t('errors.roomNotFound'); - } - if (code === 408 || code === 504) { - return RemoteI18n.t('errors.relayTimeout'); - } - if (code === 429) { - return RemoteI18n.t('errors.rateLimited'); - } - if (code >= 500) { - return RemoteI18n.f('errors.relayUnavailable', `${code}`); - } - return RemoteI18n.f('errors.relayHttp', `${code}`); - } - - private networkErrorMessage(err: Object): string { - const raw = err instanceof Error ? err.message : JSON.stringify(err); - if (raw === RemoteI18n.t('errors.pairRejected') || - raw === RemoteI18n.t('errors.roomNotFound') || - raw === RemoteI18n.t('errors.relayTimeout') || - raw === RemoteI18n.t('errors.rateLimited') || - raw === RemoteI18n.t('errors.remoteDataInvalid') || - raw.indexOf('HTTP ') >= 0) { - return raw; - } - const text = raw.toLowerCase(); - if (text.indexOf('timeout') >= 0 || text.indexOf('timed out') >= 0) { - return RemoteI18n.t('errors.relayTimeoutDetail'); - } - if (text.indexOf('refused') >= 0 || text.indexOf('unreachable') >= 0 || text.indexOf('failed') >= 0 || - text.indexOf('network') >= 0 || text.indexOf('dns') >= 0 || text.indexOf('resolve') >= 0) { - return RemoteI18n.t('errors.relayNetwork'); - } - return raw || RemoteI18n.t('errors.remoteConnectFailed'); - } - - private requireDescriptor(): RemoteDescriptor { - if (!this.descriptor) { - throw new Error('Remote descriptor is not configured.'); - } - return this.descriptor; - } - - private requireCrypto(): RemoteCrypto { - if (!this.crypto) { - throw new Error('Remote crypto is not configured.'); - } - return this.crypto; - } - - private static provisionFailure(message: string, desktopReported: boolean): PeerDeviceProvisionOutcome { - return { - ok: false, - token: '', - userId: '', - masterKeyBase64: '', - deviceId: '', - failure: message, - desktopReported - }; - } - - private static shortRequestId(requestId: string): string { - if (requestId.length === 0) { - return 'none'; - } - return requestId.length <= 12 ? requestId : requestId.slice(requestId.length - 12); - } - - private static shortRoomId(roomId: string): string { - return roomId.length <= 8 ? roomId : roomId.slice(0, 8); - } -} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteCommandTransport.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteCommandTransport.ets index 8f0f567d24..370fe1096c 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteCommandTransport.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteCommandTransport.ets @@ -1,33 +1,14 @@ import { CloudAccountClient, CloudAccountSession } from './CloudAccountClient'; import { CommandStatusResponse, RemoteCommand } from '../model/RemoteModels'; -import { RelayHttpClient } from './RelayHttpClient'; /** Interactive directory rows must fail fast enough to leave a usable retry. */ export const INTERACTIVE_DIRECTORY_TIMEOUT_MS: number = 15000; -/** Relay room commands may legitimately wait up to 60 s for the desktop. */ -export const ROOM_COMMAND_TIMEOUT_MS: number = 65000; export interface RemoteCommandTransport { send(command: RemoteCommand, timeoutMs?: number): Promise; reset(): void; } -export class RoomRemoteCommandTransport implements RemoteCommandTransport { - private readonly client: RelayHttpClient; - - constructor(client: RelayHttpClient) { - this.client = client; - } - - async send(command: RemoteCommand, timeoutMs: number = ROOM_COMMAND_TIMEOUT_MS): Promise { - return this.client.sendCommand(command, timeoutMs); - } - - reset(): void { - this.client.reset(); - } -} - export class AccountDeviceCommandTransport implements RemoteCommandTransport { private readonly client: CloudAccountClient; private readonly relayUrl: string; diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteConnectionCoordinator.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteConnectionCoordinator.ets index b128ae4106..ecf36d3296 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteConnectionCoordinator.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteConnectionCoordinator.ets @@ -1,85 +1,18 @@ -import { InitialSyncResult } from '../model/RemoteModels'; -import { MobileIdentityStore } from './MobileIdentityStore'; -import { RemoteDescriptorParser } from './RemoteDescriptorParser'; -import { RemotePairingPolicy } from './RemotePairingPolicy'; import { RemoteSessionManager } from './RemoteSessionManager'; import { AsyncLifecycleGate } from './AsyncLifecycleGate'; -import { RemoteLogger } from './RemoteLogger'; -export interface RemoteConnectionRequest { - remoteUrl: string; - userId: string; - deviceId: string; - accountPassword: string; - autoReconnect: boolean; -} - -/** Owns remote protocol setup; UI state is returned to the page as a result. */ +/** Connection lifetime shared by account selection and the activity monitor. */ export class RemoteConnectionCoordinator { private readonly sessionManager: RemoteSessionManager; - private readonly identityStore: MobileIdentityStore; - private readonly pairingPolicy: RemotePairingPolicy; private readonly lifecycleGate: AsyncLifecycleGate; - private activeToken: number = 0; - constructor( - sessionManager: RemoteSessionManager, - identityStore: MobileIdentityStore, - pairingPolicy: RemotePairingPolicy, - lifecycleGate: AsyncLifecycleGate - ) { + constructor(sessionManager: RemoteSessionManager, lifecycleGate: AsyncLifecycleGate) { this.sessionManager = sessionManager; - this.identityStore = identityStore; - this.pairingPolicy = pairingPolicy; this.lifecycleGate = lifecycleGate; } - async connect(request: RemoteConnectionRequest): Promise { - const token: number = this.lifecycleGate.begin(); - this.activeToken = token; - const descriptor = RemoteDescriptorParser.parse(request.remoteUrl); - const identity = this.pairingPolicy.identityForConnect( - descriptor, - request.userId, - request.deviceId, - request.accountPassword, - request.autoReconnect - ); - const initialSync: InitialSyncResult = await this.sessionManager.connect( - descriptor, - identity, - request.deviceId - ); - if (!this.lifecycleGate.isCurrent(token)) { - throw new Error('Remote connection was invalidated'); - } - try { - await this.identityStore.savePairingInput(identity.userId, request.remoteUrl); - await this.identityStore.clearUserIdProtection(); - } catch (err) { - // Pairing has already succeeded. Keep the live channel usable and make - // the loss of restart durability explicit in logs instead of reporting - // the whole connection as failed. - RemoteLogger.warn(`pairing snapshot persistence failed: ${String(err)}`); - } - if (!this.lifecycleGate.isCurrent(token)) { - throw new Error('Remote connection was invalidated'); - } - return initialSync; - } - - invalidate(): void { - this.lifecycleGate.invalidate(); - } - - isCurrentRequest(): boolean { - return this.activeToken > 0 && this.lifecycleGate.isCurrent(this.activeToken); - } - - async ping(): Promise { - await this.sessionManager.ping(); - } - + invalidate(): void { this.lifecycleGate.invalidate(); } + async ping(): Promise { await this.sessionManager.ping(); } reset(): void { this.invalidate(); this.sessionManager.reset(); diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteDescriptorParser.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteDescriptorParser.ets deleted file mode 100644 index d6b1f608af..0000000000 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteDescriptorParser.ets +++ /dev/null @@ -1,106 +0,0 @@ -import { RemoteDescriptor } from '../model/RemoteModels'; -import { RemoteI18n } from '../i18n/RemoteI18n'; - -export class RemoteDescriptorParser { - static parse(input: string): RemoteDescriptor { - const value = input.trim(); - if (!value) { - throw new Error(RemoteI18n.t('errors.remoteUrlRequired')); - } - - const query = RemoteDescriptorParser.extractQuery(value); - const params = RemoteDescriptorParser.parseQuery(query); - const roomId = params.get('room') || ''; - const publicKey = params.get('pk') || ''; - const relay = params.get('relay') || ''; - const protocolVersion = (params.get('v') || '1').trim(); - const accountAuth = params.get('auth') === 'account'; - const accountUsername = (params.get('user') || '').trim(); - - if (!roomId || !publicKey) { - throw new Error(RemoteI18n.t('errors.remoteUrlMissingParams')); - } - if (protocolVersion !== '1') { - throw new Error(RemoteI18n.t('errors.remoteUrlInvalid')); - } - - return { - relayUrl: RemoteDescriptorParser.resolveRelayBaseUrl(value, relay), - roomId, - publicKey, - accountAuth, - accountUsername - }; - } - - static accountAuthRequired(input: string): boolean { - try { - return RemoteDescriptorParser.parse(input).accountAuth; - } catch (_err) { - return false; - } - } - - static accountUsername(input: string): string { - try { - return RemoteDescriptorParser.parse(input).accountUsername; - } catch (_err) { - return ''; - } - } - - private static extractQuery(value: string): string { - const pairIndex = value.indexOf('#/pair?'); - if (pairIndex >= 0) { - return value.slice(pairIndex + '#/pair?'.length); - } - const questionIndex = value.indexOf('?'); - if (questionIndex >= 0) { - return value.slice(questionIndex + 1); - } - return value; - } - - private static parseQuery(query: string): Map { - const params = new Map(); - const pairs = query.split('&'); - pairs.forEach((pair: string) => { - const eq = pair.indexOf('='); - const rawKey = eq >= 0 ? pair.slice(0, eq) : pair; - const rawValue = eq >= 0 ? pair.slice(eq + 1) : ''; - if (rawKey) { - params.set(decodeURIComponent(rawKey), decodeURIComponent(rawValue)); - } - }); - return params; - } - - private static resolveRelayBaseUrl(fullUrl: string, relayParam: string): string { - const relay = relayParam - .replace(/^wss:\/\//, 'https://') - .replace(/^ws:\/\//, 'http://') - .replace(/\/ws\/?$/, '') - .replace(/\/$/, ''); - if (relay) { - return relay; - } - - const hashIndex = fullUrl.indexOf('#'); - const withoutHash = hashIndex >= 0 ? fullUrl.slice(0, hashIndex) : fullUrl; - const questionIndex = withoutHash.indexOf('?'); - const withoutQuery = questionIndex >= 0 ? withoutHash.slice(0, questionIndex) : withoutHash; - const relayRouteIndex = withoutQuery.indexOf('/r/'); - if (relayRouteIndex >= 0) { - return withoutQuery.slice(0, relayRouteIndex).replace(/\/$/, ''); - } - - const schemeIndex = withoutQuery.indexOf('://'); - if (schemeIndex >= 0) { - const pathIndex = withoutQuery.indexOf('/', schemeIndex + 3); - const origin = pathIndex >= 0 ? withoutQuery.slice(0, pathIndex) : withoutQuery; - return origin.replace(/\/$/, ''); - } - - return withoutQuery.replace(/\/[^/]*$/, '').replace(/\/$/, ''); - } -} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemotePairingPolicy.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemotePairingPolicy.ets deleted file mode 100644 index 0f1e6bea20..0000000000 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemotePairingPolicy.ets +++ /dev/null @@ -1,100 +0,0 @@ -import { RemoteI18n } from '../i18n/RemoteI18n'; -import { RemoteDescriptor } from '../model/RemoteModels'; -import { - ConnectScanDecisionPolicy, - DetectedUrlAction -} from './ConnectScanDecisionPolicy'; -import { PairIdentity } from './RelayHttpClient'; -import { RemoteDescriptorParser } from './RemoteDescriptorParser'; - -export interface RemotePairingProjection { - requiresAccountAuth: boolean; - accountUsername: string; -} - -export interface RemotePairingReconnectState { - autoReconnectAttempted: boolean; - remoteUrl: string; - userId: string; - requiresAccountAuth: boolean; -} - -export class RemotePairingPolicy { - projection(remoteUrl: string): RemotePairingProjection { - if (remoteUrl.trim().length === 0) { - return { - requiresAccountAuth: false, - accountUsername: '' - }; - } - return { - requiresAccountAuth: RemoteDescriptorParser.accountAuthRequired(remoteUrl), - accountUsername: RemoteDescriptorParser.accountUsername(remoteUrl) - }; - } - - userIdAfterProjection(currentUserId: string, deviceId: string, projection: RemotePairingProjection): string { - if (!projection.requiresAccountAuth || projection.accountUsername.length === 0) { - return currentUserId; - } - return this.isDefaultUserId(currentUserId, deviceId) ? projection.accountUsername : currentUserId; - } - - shouldAutoReconnect(state: RemotePairingReconnectState): boolean { - return !state.autoReconnectAttempted && - !state.requiresAccountAuth && - state.remoteUrl.trim().length > 0 && - state.userId.trim().length > 0; - } - - identityForConnect( - descriptor: RemoteDescriptor, - currentUserId: string, - deviceId: string, - accountPassword: string, - autoReconnect: boolean - ): PairIdentity { - if (descriptor.accountAuth) { - if (autoReconnect) { - throw new Error(RemoteI18n.t('errors.accountPasswordRequired')); - } - const username = currentUserId.trim() || descriptor.accountUsername.trim(); - if (username.length === 0) { - throw new Error(RemoteI18n.t('errors.accountUsernameRequired')); - } - if (accountPassword.length === 0) { - throw new Error(RemoteI18n.t('errors.accountPasswordRequired')); - } - return { - userId: username, - password: accountPassword - }; - } - - return { - userId: currentUserId.trim() || deviceId - }; - } - - shouldPromptForAccount( - descriptor: RemoteDescriptor, - hasCloudAccountSession: boolean = false, - cloudUsername: string = '', - cloudRelayUrl: string = '' - ): boolean { - return ConnectScanDecisionPolicy.decide( - descriptor.accountAuth, - hasCloudAccountSession, - cloudUsername, - descriptor.accountUsername, - '', - cloudRelayUrl, - descriptor.relayUrl - ) === DetectedUrlAction.PROMPT_ACCOUNT_PASSWORD; - } - - private isDefaultUserId(userId: string, deviceId: string): boolean { - const value = userId.trim(); - return value.length === 0 || value === deviceId || value.indexOf('harmony-') === 0; - } -} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteSessionManager.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteSessionManager.ets index 3b2b91f596..1d92a89de9 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteSessionManager.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteSessionManager.ets @@ -1,16 +1,13 @@ -import { AssistantEntry, AssistantListResponse, ChatMessageItemResponse, ChatMessageResponse, CommandStatusResponse, CreateSessionOptions, CreateSessionResponse, FileInfo, FileInfoResponse, InitialSyncResponse, InitialSyncResult, ModelCatalogResponse, PermissionModeResponse, PollSessionResponse, PollSessionResult, ReadFileChunkResponse, ReadFileChunkResult, ReadFileResult, RecentWorkspaceEntry, RecentWorkspaceListResponse, RemoteCommand, RemoteDescriptor, RemoteImageContext, RemoteModelCatalog, RemotePermissionMode, RemoteQuestionAnswerPayload, RemoteSession, SendMessageResponse, SessionListResponse, SessionListResult, SessionMessagesResponse, SessionMessagesResult, SessionSummary, SetAssistantResponse, SetSessionModelResponse, SetWorkspaceResponse, SteerTurnResponse, SteerTurnResult, WorkspaceInfo, WorkspaceInfoResponse } from '../model/RemoteModels'; +import { AssistantEntry, AssistantListResponse, ChatMessageItemResponse, ChatMessageResponse, CommandStatusResponse, CreateSessionOptions, CreateSessionResponse, FileInfo, FileInfoResponse, InitialSyncResult, ModelCatalogResponse, PermissionModeResponse, PollSessionResponse, PollSessionResult, ReadFileChunkResponse, ReadFileChunkResult, ReadFileResult, RecentWorkspaceEntry, RecentWorkspaceListResponse, RemoteCommand, RemoteImageContext, RemoteModelCatalog, RemotePermissionMode, RemoteQuestionAnswerPayload, RemoteSession, SendMessageResponse, SessionListResponse, SessionListResult, SessionMessagesResponse, SessionMessagesResult, SessionSummary, SetAssistantResponse, SetSessionModelResponse, SetWorkspaceResponse, SteerTurnResponse, SteerTurnResult, WorkspaceInfo, WorkspaceInfoResponse } from '../model/RemoteModels'; import { Encoding } from './Encoding'; -import { PairIdentity, PeerDeviceProvisionOutcome, RelayHttpClient } from './RelayHttpClient'; import { CloudAccountClient, CloudAccountRequestError, CloudAccountSession } from './CloudAccountClient'; import { RemoteI18n } from '../i18n/RemoteI18n'; import { AccountDeviceCommandTransport, INTERACTIVE_DIRECTORY_TIMEOUT_MS, - RemoteCommandTransport, - RoomRemoteCommandTransport + RemoteCommandTransport } from './RemoteCommandTransport'; import { RemoteCommandFactory } from './RemoteCommandFactory'; -import { RemoteCrypto } from './RemoteCrypto'; import { RemoteChatCommandClient } from './RemoteChatCommandController'; import { RemoteFileDownloadClient } from './RemoteFileDownloadController'; import { RemoteLogger } from './RemoteLogger'; @@ -21,20 +18,6 @@ import { TranscriptIntegrityPolicy } from './TranscriptIntegrityPolicy'; import { RemoteSessionClient } from './RemoteSessionController'; import { RemoteToolActionClient } from './RemoteToolActionController'; -export interface DelegatedAccountSession { - relayUrl: string; - session: CloudAccountSession; -} - -/** A credential minted for a peer device, paired with the relay it belongs to. */ -export interface ProvisionedPeerDevice { - relayUrl: string; - token: string; - userId: string; - masterKeyBase64: string; - deviceId: string; -} - /** * How long the first command of an account-device connect may take. * @@ -49,79 +32,19 @@ export interface ProvisionedPeerDevice { const ACCOUNT_HANDSHAKE_TIMEOUT_MS: number = 15000; export class RemoteSessionManager implements RemoteChatCommandClient, RemoteFileDownloadClient, RemoteModelClient, RemoteSessionClient, RemoteToolActionClient { - private roomClient?: RelayHttpClient; private transport?: RemoteCommandTransport; private transportGeneration: number = 0; private workspace?: WorkspaceInfo; - private roomRelayUrl: string = ''; - private readonly roomClientFactory: () => RelayHttpClient; - private readonly cryptoFactory: () => RemoteCrypto; - - constructor( - roomClientFactory: () => RelayHttpClient = () => new RelayHttpClient(), - cryptoFactory: () => RemoteCrypto = () => new RemoteCrypto() - ) { - this.roomClientFactory = roomClientFactory; - this.cryptoFactory = cryptoFactory; - } reset(): void { - this.transportGeneration += 1; - this.workspace = undefined; - this.roomRelayUrl = ''; - const hadTransport = this.transport !== undefined; - this.transport?.reset(); - this.transport = undefined; - if (!hadTransport) { - this.roomClient?.reset(); - } - this.roomClient = undefined; - } - - async connect(descriptor: RemoteDescriptor, identity: PairIdentity, deviceId: string): Promise { - const generation = this.beginTransportReplacement(); - const crypto = this.cryptoFactory(); - const roomClient = this.roomClientFactory(); - const transport = new RoomRemoteCommandTransport(roomClient); - roomClient.bind(descriptor, crypto); - let initialSync: InitialSyncResponse; - try { - initialSync = await roomClient.pair(deviceId, identity); - this.requireCurrentGeneration(generation); - // Account inheritance is optional for room pairing. A desktop without an - // account, or an older desktop build, must not make normal QR control fail. - await roomClient.requestDelegatedIdentity(); - this.requireCurrentGeneration(generation); - } catch (err) { - transport.reset(); - throw err instanceof Error ? err : new Error(String(err)); - } - - const workspace = RemoteResponseMapper.workspaceFromInitialSync(initialSync); - this.roomClient = roomClient; - this.transport = transport; - this.roomRelayUrl = descriptor.relayUrl; - this.workspace = workspace; - return { - workspace, - sessions: RemoteResponseMapper.sessions(initialSync.sessions || []), - hasMoreSessions: initialSync.has_more_sessions || false, - authenticatedUserId: initialSync.authenticated_user_id || '', - capabilities: initialSync.capabilities || [] - }; + this.beginTransportReplacement(); } private beginTransportReplacement(): number { this.transportGeneration += 1; - const hadTransport = this.transport !== undefined; this.transport?.reset(); this.transport = undefined; - if (!hadTransport) { - this.roomClient?.reset(); - } - this.roomClient = undefined; this.workspace = undefined; - this.roomRelayUrl = ''; return this.transportGeneration; } @@ -131,54 +54,6 @@ export class RemoteSessionManager implements RemoteChatCommandClient, RemoteFile } } - delegatedAccountSession(): DelegatedAccountSession | undefined { - const roomClient = this.roomClient; - if (!roomClient || !roomClient.hasDelegatedIdentity() || roomClient.delegatedUserId.trim().length === 0 || - this.roomRelayUrl.trim().length === 0) { - return undefined; - } - return { - relayUrl: this.roomRelayUrl, - session: { - token: roomClient.delegatedToken, - userId: roomClient.delegatedUserId, - masterKey: Encoding.base64ToBytes(roomClient.delegatedMasterKey) - } - }; - } - - /** - * True when a QR-paired room channel is live. Provisioning a peer device is - * only answerable there, so the caller uses this to decide whether the - * feature is offerable at all rather than letting the user find out after a - * 45-second wait. - */ - hasRoomChannel(): boolean { - return this.roomRelayUrl.trim().length > 0; - } - - /** - * Relay a watch's provisioning request to the paired desktop. Goes straight - * to the room client rather than through `send()`, because the account-device - * transport reaches a different desktop handler that cannot mint credentials. - */ - async provisionPeerDevice( - deviceId: string, - deviceName: string, - requestId: string - ): Promise { - const roomClient = this.roomClient; - if (!this.hasRoomChannel() || !roomClient) { - throw new Error('Remote room channel is not connected.'); - } - return roomClient.provisionPeerDevice(deviceId, deviceName, requestId); - } - - /** Relay the provisioned credential belongs to — the one this room lives on. */ - roomRelayEndpoint(): string { - return this.roomRelayUrl; - } - async connectAccountDevice( accountClient: CloudAccountClient, relayUrl: string, diff --git a/src/apps/mobile/harmonyos/entry/src/main/resources/base/element/string.json b/src/apps/mobile/harmonyos/entry/src/main/resources/base/element/string.json index 5dc8986c6f..67deb3e908 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/resources/base/element/string.json +++ b/src/apps/mobile/harmonyos/entry/src/main/resources/base/element/string.json @@ -26,7 +26,7 @@ }, { "name": "permission_distributed_datasync_reason", - "value": "Used to authorize a watch on the same OpenBitFun account so you do not have to type the password on the watch." + "value": "Used to authorize a watch on the same GitHub account using a separate device credential." } ] } diff --git a/src/apps/mobile/harmonyos/entry/src/main/resources/en_US/element/string.json b/src/apps/mobile/harmonyos/entry/src/main/resources/en_US/element/string.json index 5dc8986c6f..67deb3e908 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/resources/en_US/element/string.json +++ b/src/apps/mobile/harmonyos/entry/src/main/resources/en_US/element/string.json @@ -26,7 +26,7 @@ }, { "name": "permission_distributed_datasync_reason", - "value": "Used to authorize a watch on the same OpenBitFun account so you do not have to type the password on the watch." + "value": "Used to authorize a watch on the same GitHub account using a separate device credential." } ] } diff --git a/src/apps/mobile/harmonyos/entry/src/main/resources/zh_CN/element/string.json b/src/apps/mobile/harmonyos/entry/src/main/resources/zh_CN/element/string.json index d5ea3bf187..721cc8ef38 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/resources/zh_CN/element/string.json +++ b/src/apps/mobile/harmonyos/entry/src/main/resources/zh_CN/element/string.json @@ -26,7 +26,7 @@ }, { "name": "permission_distributed_datasync_reason", - "value": "用于把 OpenBitFun 账号授权给同账号下的手表,免去在手表上输入密码。" + "value": "用于把 GitHub 账号授权给同账号下的手表,并为手表生成独立的设备凭据。" } ] } diff --git a/src/apps/mobile/harmonyos/entry/src/test/AppRootLifecycleUnit.test.ets b/src/apps/mobile/harmonyos/entry/src/test/AppRootLifecycleUnit.test.ets index 9d4e7361c6..94de6a1266 100644 --- a/src/apps/mobile/harmonyos/entry/src/test/AppRootLifecycleUnit.test.ets +++ b/src/apps/mobile/harmonyos/entry/src/test/AppRootLifecycleUnit.test.ets @@ -39,22 +39,12 @@ class TestAppRootRuntime extends AppRootRuntime { export default function appRootLifecycleUnitTest() { describe('AppRootRuntime page hide lifecycle', () => { - it('keeps backgrounded general chat running on page hide', 0, () => { + it('keeps the controller rooted in the device directory across lifecycle changes', 0, () => { const runtime = new TestAppRootRuntime(); - runtime.generalChatStreamLifecycleController.begin('session-1'); - + expect(runtime.appShellViewModel.currentRoute()).assertEqual(AppRoute.RemoteHome); runtime.onPageHide(); - - expect(runtime.generalChatStreamLifecycleController.hasActiveStream()).assertTrue(); - }); - - it('still performs general chat cleanup when the app truly disappears', 0, () => { - const runtime = new TestAppRootRuntime(); - runtime.generalChatStreamLifecycleController.begin('session-1'); - runtime.aboutToDisappear(); - - expect(runtime.generalChatStreamLifecycleController.hasActiveStream()).assertFalse(); + expect(runtime.appShellViewModel.currentRoute()).assertEqual(AppRoute.RemoteHome); }); it('routes HTTP Markdown links through the host without opening file preview', 0, async () => { @@ -83,9 +73,8 @@ export default function appRootLifecycleUnitTest() { ); await new Promise((resolve: () => void) => setTimeout(resolve, 0)); - expect(runtime.generalChatPageState.conversation.statusText) + expect(runtime.remotePageState.conversation.statusText) .assertEqual(RemoteI18n.t('errors.operationFailed')); - expect(runtime.remotePageState.conversation.statusText).assertEqual(''); }); it('closes preview before applying conversation navigation back', 0, () => { diff --git a/src/apps/mobile/harmonyos/entry/src/test/AppRootRuntimeStartupUnit.test.ets b/src/apps/mobile/harmonyos/entry/src/test/AppRootRuntimeStartupUnit.test.ets index 0d4d2e8560..449cd0fb1d 100644 --- a/src/apps/mobile/harmonyos/entry/src/test/AppRootRuntimeStartupUnit.test.ets +++ b/src/apps/mobile/harmonyos/entry/src/test/AppRootRuntimeStartupUnit.test.ets @@ -34,7 +34,7 @@ export default function appRootRuntimeStartupUnitTest() { expect(runtime.remotePageState.accountUsername).assertEqual('alice'); expect(runtime.remotePageState.controlTargetType).assertEqual('none'); expect(runtime.remotePageState.controlTargetDeviceId).assertEqual(''); - expect(runtime.appShellViewModel.currentRoute()).assertEqual(AppRoute.ChatHome); + expect(runtime.appShellViewModel.currentRoute()).assertEqual(AppRoute.RemoteHome); }); }); } diff --git a/src/apps/mobile/harmonyos/entry/src/test/ArchitectureUnit.test.ets b/src/apps/mobile/harmonyos/entry/src/test/ArchitectureUnit.test.ets index 210893db24..d877ddccfb 100644 --- a/src/apps/mobile/harmonyos/entry/src/test/ArchitectureUnit.test.ets +++ b/src/apps/mobile/harmonyos/entry/src/test/ArchitectureUnit.test.ets @@ -118,27 +118,27 @@ export default function architectureUnitTest() { expect(shell.backAction(AppRoute.RemoteHome)).assertEqual(AppNavigationBackAction.CloseSidebar); shell.state.setSidebarVisible(false); shell.replaceRoute(AppRoute.GeneralChat, 'session-1'); - expect(shell.currentRoute()).assertEqual(AppRoute.GeneralChat); - expect(shell.isGeneralChatVisible()).assertTrue(); + expect(shell.currentRoute()).assertEqual(AppRoute.RemoteHome); + expect(shell.isGeneralChatVisible()).assertFalse(); }); it('replaces conversation sources without growing cross-source history', 0, () => { const shell = new AppShellViewModel(); shell.pushRoute(AppRoute.GeneralChat, 'general-1'); shell.pushRoute(AppRoute.RemoteHome); - expect(shell.navigationStack.getAllPathName().length).assertEqual(2); + expect(shell.navigationStack.getAllPathName().length).assertEqual(0); shell.replaceRouteWithoutAnimation(AppRoute.RemoteChat, 'remote-1'); expect(shell.currentRoute()).assertEqual(AppRoute.RemoteChat); expect(shell.navigationStack.getAllPathName().length).assertEqual(1); shell.replaceRouteWithoutAnimation(AppRoute.ChatHome); - expect(shell.currentRoute()).assertEqual(AppRoute.ChatHome); + expect(shell.currentRoute()).assertEqual(AppRoute.RemoteHome); expect(shell.navigationStack.getAllPathName().length).assertEqual(0); shell.replaceRouteWithoutAnimation(AppRoute.RemoteHome); expect(shell.currentRoute()).assertEqual(AppRoute.RemoteHome); - expect(shell.navigationStack.getAllPathName().length).assertEqual(1); + expect(shell.navigationStack.getAllPathName().length).assertEqual(0); }); it('keeps same-route state updates from rebuilding navigation', 0, () => { @@ -146,7 +146,7 @@ export default function architectureUnitTest() { shell.pushRoute(AppRoute.RemoteHome); shell.replaceRouteWithoutAnimation(AppRoute.RemoteHome); expect(shell.currentRoute()).assertEqual(AppRoute.RemoteHome); - expect(shell.navigationStack.getAllPathName().length).assertEqual(1); + expect(shell.navigationStack.getAllPathName().length).assertEqual(0); }); // Switching sessions is a state change, not a navigation. Treating the @@ -159,7 +159,7 @@ export default function architectureUnitTest() { shell.pushRoute(AppRoute.RemoteChat, 'session-a'); shell.replaceRouteWithoutAnimation(AppRoute.RemoteChat, 'session-b'); expect(shell.currentRoute()).assertEqual(AppRoute.RemoteChat); - expect(shell.navigationStack.getAllPathName().length).assertEqual(2); + expect(shell.navigationStack.getAllPathName().length).assertEqual(1); }); it('keeps wide layout geometry pure and deterministic', 0, () => { diff --git a/src/apps/mobile/harmonyos/entry/src/test/ConversationPresentationUnit.test.ets b/src/apps/mobile/harmonyos/entry/src/test/ConversationPresentationUnit.test.ets index d628e4fee8..64277afbc5 100644 --- a/src/apps/mobile/harmonyos/entry/src/test/ConversationPresentationUnit.test.ets +++ b/src/apps/mobile/harmonyos/entry/src/test/ConversationPresentationUnit.test.ets @@ -47,7 +47,6 @@ import { SidebarDeviceProjectionPolicy } from '../main/ets/pages/policy/SidebarD import { HarnessProfilePolicy } from '../main/ets/pages/policy/HarnessProfilePolicy'; import { CONNECT_INTENT_AUTO, CONNECT_INTENT_SCAN } from '../main/ets/pages/state/AppShellState'; import { - ConnectScanDecisionPolicy, DetectedUrlAction } from '../main/ets/services/ConnectScanDecisionPolicy'; @@ -610,7 +609,7 @@ export default function conversationPresentationUnitTest() { expect(RemoteI18n.t('sidebar.devices')).assertEqual('设备'); expect(RemoteI18n.t('sidebar.connectDesktop')).assertEqual('连接电脑'); expect(RemoteI18n.t('sidebar.scanToConnect')).assertEqual('扫码连接电脑'); - expect(RemoteI18n.t('sidebar.signInOpenBitFunAccount')).assertEqual('登录 OpenBitFun 账号'); + expect(RemoteI18n.t('sidebar.signInOpenBitFunAccount')).assertEqual('使用 GitHub 登录'); expect(RemoteI18n.t('connect.chooseConnectionTitle')).assertEqual('连接电脑'); expect(RemoteI18n.t('sidebar.local')).assertEqual('sidebar.local'); expect(RemoteI18n.t('sidebar.code')).assertEqual('sidebar.code'); @@ -746,49 +745,6 @@ export default function conversationPresentationUnitTest() { }); }); - describe('ConnectScanDecisionPolicy', () => { - it('pairs an ordinary QR immediately without asking for an account password', 0, () => { - expect(ConnectScanDecisionPolicy.decide(false, false, '', '', '')) - .assertEqual(DetectedUrlAction.PAIR_NOW); - expect(ConnectScanDecisionPolicy.decide(false, true, 'alice', '', 'desktop-1')) - .assertEqual(DetectedUrlAction.PAIR_NOW); - }); - - it('asks for a password only when the QR needs account auth and this phone is signed out', 0, () => { - expect(ConnectScanDecisionPolicy.decide(true, false, '', 'alice', 'desktop-1')) - .assertEqual(DetectedUrlAction.PROMPT_ACCOUNT_PASSWORD); - }); - - it('reuses the signed-in cloud session instead of asking for the password again', 0, () => { - expect(ConnectScanDecisionPolicy.decide(true, true, 'alice', 'alice', 'desktop-1', 'https://relay.example.com', 'https://relay.example.com/')) - .assertEqual(DetectedUrlAction.USE_CLOUD_DEVICE); - expect(ConnectScanDecisionPolicy.decide(true, true, 'alice', 'alice', '', 'https://relay.example.com', 'wss://relay.example.com/ws')) - .assertEqual(DetectedUrlAction.SHOW_CLOUD_DEVICES); - expect(ConnectScanDecisionPolicy.decide(true, true, 'alice', '', 'desktop-1', 'https://relay.example.com', 'https://relay.example.com')) - .assertEqual(DetectedUrlAction.USE_CLOUD_DEVICE); - }); - - it('does not reuse an account session from a different relay', 0, () => { - expect(ConnectScanDecisionPolicy.decide( - true, true, 'alice', 'alice', 'desktop-1', - 'https://relay-a.example.com', 'https://relay-b.example.com' - )).assertEqual(DetectedUrlAction.PROMPT_ACCOUNT_PASSWORD); - }); - - it('uses the authenticated device directory instead of display-name equality', 0, () => { - expect(ConnectScanDecisionPolicy.decide( - true, true, 'alice', 'bob', 'desktop-1', - 'https://relay.example.com', 'wss://relay.example.com/ws' - )) - .assertEqual(DetectedUrlAction.USE_CLOUD_DEVICE); - expect(ConnectScanDecisionPolicy.decide( - true, true, '', 'renamed-account', 'desktop-1', - 'https://relay.example.com', 'https://relay.example.com/' - )) - .assertEqual(DetectedUrlAction.USE_CLOUD_DEVICE); - }); - }); - describe('ConversationHeaderPolicy', () => { it('keeps local chat on a single-line title and only shows actions once a timeline exists', 0, () => { const empty = ConversationHeaderPolicy.present(ChatSurface.General, '今日计划', '', '', false); diff --git a/src/apps/mobile/harmonyos/entry/src/test/ConversationStateUnit.test.ets b/src/apps/mobile/harmonyos/entry/src/test/ConversationStateUnit.test.ets index 7419bf6d62..17f64f02d4 100644 --- a/src/apps/mobile/harmonyos/entry/src/test/ConversationStateUnit.test.ets +++ b/src/apps/mobile/harmonyos/entry/src/test/ConversationStateUnit.test.ets @@ -51,7 +51,6 @@ import { MockGeneralChatAdapter } from '../main/ets/services/general-chat/MockGe import { MarkdownParser } from '../main/ets/services/MarkdownParser'; import { RemoteCommandFactory } from '../main/ets/services/RemoteCommandFactory'; import { RemoteCrypto, RemoteCryptoCipher } from '../main/ets/services/RemoteCrypto'; -import { RemoteDescriptorParser } from '../main/ets/services/RemoteDescriptorParser'; import { RemoteActivityLifecycleController } from '../main/ets/services/RemoteActivityLifecycleController'; import { RemoteChatCommandClient, RemoteChatCommandController } from '../main/ets/services/RemoteChatCommandController'; import { RemoteChatPollingLifecycleController } from '../main/ets/services/RemoteChatPollingLifecycleController'; @@ -66,7 +65,6 @@ import { RemoteModelController, RemoteModelPreferenceStore } from '../main/ets/services/RemoteModelController'; -import { RemotePairingPolicy } from '../main/ets/services/RemotePairingPolicy'; import { RemoteResponseMapper } from '../main/ets/services/RemoteResponseMapper'; import { RemoteSessionClient, RemoteSessionController } from '../main/ets/services/RemoteSessionController'; import { RemoteSessionManager } from '../main/ets/services/RemoteSessionManager'; @@ -1029,7 +1027,7 @@ export default function conversationStateUnitTest() { }); describe('ConversationController', () => { - it('keeps composer state isolated while the visible route changes', 0, () => { + it('routes legacy and current composer destinations to the remote controller', 0, () => { const general = new GeneralChatPageState(); const remote = new RemotePageState(); let route = AppRoute.ChatHome; @@ -1042,10 +1040,10 @@ export default function conversationStateUnitTest() { controller.setChatInput(AppRoute.ChatHome, 'general draft'); controller.setChatInput(AppRoute.RemoteChat, 'remote draft'); - expect(controller.visibleChatInput()).assertEqual('general draft'); + expect(controller.visibleChatInput()).assertEqual('remote draft'); route = AppRoute.RemoteChat; expect(controller.visibleChatInput()).assertEqual('remote draft'); - expect(general.chatInput).assertEqual('general draft'); + expect(general.chatInput).assertEqual(''); expect(remote.chatInput).assertEqual('remote draft'); }); diff --git a/src/apps/mobile/harmonyos/entry/src/test/LifecycleUnit.test.ets b/src/apps/mobile/harmonyos/entry/src/test/LifecycleUnit.test.ets index ea3dd59409..88d5511fba 100644 --- a/src/apps/mobile/harmonyos/entry/src/test/LifecycleUnit.test.ets +++ b/src/apps/mobile/harmonyos/entry/src/test/LifecycleUnit.test.ets @@ -51,7 +51,6 @@ import { MockGeneralChatAdapter } from '../main/ets/services/general-chat/MockGe import { MarkdownParser } from '../main/ets/services/MarkdownParser'; import { RemoteCommandFactory } from '../main/ets/services/RemoteCommandFactory'; import { RemoteCrypto, RemoteCryptoCipher } from '../main/ets/services/RemoteCrypto'; -import { RemoteDescriptorParser } from '../main/ets/services/RemoteDescriptorParser'; import { RemoteActivityLifecycleController } from '../main/ets/services/RemoteActivityLifecycleController'; import { RemoteChatCommandClient, RemoteChatCommandController } from '../main/ets/services/RemoteChatCommandController'; import { RemoteChatPollingLifecycleController } from '../main/ets/services/RemoteChatPollingLifecycleController'; @@ -66,7 +65,6 @@ import { RemoteModelController, RemoteModelPreferenceStore } from '../main/ets/services/RemoteModelController'; -import { RemotePairingPolicy } from '../main/ets/services/RemotePairingPolicy'; import { RemoteResponseMapper } from '../main/ets/services/RemoteResponseMapper'; import { RemoteSessionClient, RemoteSessionController } from '../main/ets/services/RemoteSessionController'; import { RemoteSessionManager } from '../main/ets/services/RemoteSessionManager'; diff --git a/src/apps/mobile/harmonyos/entry/src/test/LocalTestFixtures.ets b/src/apps/mobile/harmonyos/entry/src/test/LocalTestFixtures.ets index 51e746ce76..48ec178867 100644 --- a/src/apps/mobile/harmonyos/entry/src/test/LocalTestFixtures.ets +++ b/src/apps/mobile/harmonyos/entry/src/test/LocalTestFixtures.ets @@ -50,7 +50,6 @@ import { MockGeneralChatAdapter } from '../main/ets/services/general-chat/MockGe import { MarkdownParser } from '../main/ets/services/MarkdownParser'; import { RemoteCommandFactory } from '../main/ets/services/RemoteCommandFactory'; import { RemoteCrypto, RemoteCryptoCipher } from '../main/ets/services/RemoteCrypto'; -import { RemoteDescriptorParser } from '../main/ets/services/RemoteDescriptorParser'; import { RemoteActivityLifecycleController } from '../main/ets/services/RemoteActivityLifecycleController'; import { RemoteChatCache, @@ -76,7 +75,6 @@ import { RemoteModelController, RemoteModelPreferenceStore } from '../main/ets/services/RemoteModelController'; -import { RemotePairingPolicy } from '../main/ets/services/RemotePairingPolicy'; import { RemoteResponseMapper } from '../main/ets/services/RemoteResponseMapper'; import { RemoteSessionClient, RemoteSessionController } from '../main/ets/services/RemoteSessionController'; import { RemoteSessionManager } from '../main/ets/services/RemoteSessionManager'; diff --git a/src/apps/mobile/harmonyos/entry/src/test/RemoteControllersUnit.test.ets b/src/apps/mobile/harmonyos/entry/src/test/RemoteControllersUnit.test.ets index 73cc841a98..f577fc5b4e 100644 --- a/src/apps/mobile/harmonyos/entry/src/test/RemoteControllersUnit.test.ets +++ b/src/apps/mobile/harmonyos/entry/src/test/RemoteControllersUnit.test.ets @@ -61,7 +61,6 @@ import { } from '../main/ets/services/CodeSyntaxHighlighter'; import { RemoteCommandFactory } from '../main/ets/services/RemoteCommandFactory'; import { RemoteCrypto, RemoteCryptoCipher } from '../main/ets/services/RemoteCrypto'; -import { RemoteDescriptorParser } from '../main/ets/services/RemoteDescriptorParser'; import { RemoteActivityLifecycleController } from '../main/ets/services/RemoteActivityLifecycleController'; import { RemoteChatCommandClient, RemoteChatCommandController } from '../main/ets/services/RemoteChatCommandController'; import { RemoteChatPollingLifecycleController } from '../main/ets/services/RemoteChatPollingLifecycleController'; @@ -84,7 +83,6 @@ import { RemoteModelController, RemoteModelPreferenceStore } from '../main/ets/services/RemoteModelController'; -import { RemotePairingPolicy } from '../main/ets/services/RemotePairingPolicy'; import { RemoteResponseMapper } from '../main/ets/services/RemoteResponseMapper'; import { RemoteSessionClient, RemoteSessionController } from '../main/ets/services/RemoteSessionController'; import { RemoteSessionManager } from '../main/ets/services/RemoteSessionManager'; @@ -2375,8 +2373,8 @@ export default function remoteControllersUnitTest() { describe('ChatComposerCapabilities', () => { it('keeps route-to-surface composer ownership explicit', 0, () => { - expect(AppRouteContract.isGeneralComposerRoute(AppRoute.ChatHome)).assertTrue(); - expect(AppRouteContract.isGeneralComposerRoute(AppRoute.GeneralChat)).assertTrue(); + expect(AppRouteContract.isGeneralComposerRoute(AppRoute.ChatHome)).assertFalse(); + expect(AppRouteContract.isGeneralComposerRoute(AppRoute.GeneralChat)).assertFalse(); expect(AppRouteContract.isGeneralComposerRoute(AppRoute.RemoteHome)).assertFalse(); expect(AppRouteContract.isGeneralComposerRoute(AppRoute.RemoteChat)).assertFalse(); }); @@ -2455,14 +2453,14 @@ export default function remoteControllersUnitTest() { expect(`${AppRoute.RemoteChat}`).assertEqual('RemoteChat'); expect(AppRouteContract.isSessionRoute(AppRoute.ChatHome)).assertFalse(); expect(AppRouteContract.isSessionRoute(AppRoute.RemoteHome)).assertFalse(); - expect(AppRouteContract.isSessionRoute(AppRoute.GeneralChat)).assertTrue(); + expect(AppRouteContract.isSessionRoute(AppRoute.GeneralChat)).assertFalse(); expect(AppRouteContract.isSessionRoute(AppRoute.RemoteChat)).assertTrue(); expect(generalParam.sessionId).assertEqual('general-session-1'); expect(remoteParam.sessionId).assertEqual('remote-session-1'); }); - it('derives current route from NavPathStack path names with ChatHome as root', 0, () => { - expect(AppRouteContract.currentRoute([])).assertEqual(AppRoute.ChatHome); + it('derives current route from NavPathStack path names with RemoteHome as root', 0, () => { + expect(AppRouteContract.currentRoute([])).assertEqual(AppRoute.RemoteHome); expect(AppRouteContract.currentRoute([AppRoute.RemoteHome])).assertEqual(AppRoute.RemoteHome); expect(AppRouteContract.currentRoute([ AppRoute.RemoteHome, @@ -2470,7 +2468,7 @@ export default function remoteControllersUnitTest() { ])).assertEqual(AppRoute.RemoteChat); expect(AppRouteContract.currentRoute([ AppRoute.GeneralChat - ])).assertEqual(AppRoute.GeneralChat); + ])).assertEqual(AppRoute.RemoteHome); }); it('builds push path specs and suppresses duplicate current routes', 0, () => { @@ -2492,32 +2490,31 @@ export default function remoteControllersUnitTest() { expect(remoteChatSpec.name).assertEqual(AppRoute.RemoteChat); expect(remoteChatSpec.hasSessionParam()).assertTrue(); expect(remoteChatSpec.routeParam().sessionId).assertEqual('remote-session-1'); - expect(generalChatSpec.name).assertEqual(AppRoute.GeneralChat); - expect(generalChatSpec.routeParam().sessionId).assertEqual('general-session-1'); + expect(generalChatSpec === undefined).assertTrue(); }); it('classifies back actions without depending on ArkUI NavPathStack', 0, () => { expect(AppRouteContract.backAction(AppRoute.ChatHome, true)) .assertEqual(AppNavigationBackAction.CloseSidebar); expect(AppRouteContract.backAction(AppRoute.GeneralChat, false)) - .assertEqual(AppNavigationBackAction.CloseActiveChat); + .assertEqual(AppNavigationBackAction.AllowSystem); expect(AppRouteContract.backAction(AppRoute.RemoteChat, false)) .assertEqual(AppNavigationBackAction.CloseActiveChat); expect(AppRouteContract.backAction(AppRoute.RemoteHome, false)) - .assertEqual(AppNavigationBackAction.PopRemoteHome); + .assertEqual(AppNavigationBackAction.AllowSystem); expect(AppRouteContract.backAction(AppRoute.ChatHome, false)) .assertEqual(AppNavigationBackAction.AllowSystem); }); - it('splits remote routes from the general composer without overlap', 0, () => { + it('maps retired local route names to the remote controller', 0, () => { // The two predicates are complements by construction. Asserting both // directions on every route is what keeps a later edit from creating a // route that is somehow neither, or both. - expect(AppRouteContract.isRemoteRoute(AppRoute.ChatHome)).assertFalse(); - expect(AppRouteContract.isRemoteRoute(AppRoute.GeneralChat)).assertFalse(); + expect(AppRouteContract.isRemoteRoute(AppRoute.ChatHome)).assertTrue(); + expect(AppRouteContract.isRemoteRoute(AppRoute.GeneralChat)).assertTrue(); expect(AppRouteContract.isRemoteRoute(AppRoute.RemoteHome)).assertTrue(); expect(AppRouteContract.isRemoteRoute(AppRoute.RemoteChat)).assertTrue(); - expect(AppRouteContract.isGeneralComposerRoute(AppRoute.ChatHome)).assertTrue(); + expect(AppRouteContract.isGeneralComposerRoute(AppRoute.ChatHome)).assertFalse(); }); it('resumes an in-flight remote session instead of landing on the picker', 0, () => { diff --git a/src/apps/mobile/harmonyos/entry/src/test/TransportAndGeneralChatUnit.test.ets b/src/apps/mobile/harmonyos/entry/src/test/TransportAndGeneralChatUnit.test.ets index 0d21063cd7..244b10e8b2 100644 --- a/src/apps/mobile/harmonyos/entry/src/test/TransportAndGeneralChatUnit.test.ets +++ b/src/apps/mobile/harmonyos/entry/src/test/TransportAndGeneralChatUnit.test.ets @@ -1,3 +1,4 @@ +import { accountDeviceLink } from '../main/ets/services/AccountDeviceLink'; import { describe, it, expect } from '@ohos/hypium'; import { RemoteI18n } from '../main/ets/i18n/RemoteI18n'; import { ChatSessionController, ChatSessionSnapshot } from '../main/ets/services/ChatSessionController'; @@ -55,7 +56,6 @@ import { MarkdownParser } from '../main/ets/services/MarkdownParser'; import { RemoteCommandFactory } from '../main/ets/services/RemoteCommandFactory'; import { toRemoteQuestionAnswer } from '../main/ets/pages/state/ConversationUiModels'; import { RemoteCrypto, RemoteCryptoCipher } from '../main/ets/services/RemoteCrypto'; -import { RemoteDescriptorParser } from '../main/ets/services/RemoteDescriptorParser'; import { RemoteActivityLifecycleController } from '../main/ets/services/RemoteActivityLifecycleController'; import { RemoteChatCommandClient, RemoteChatCommandController } from '../main/ets/services/RemoteChatCommandController'; import { RemoteChatPollingLifecycleController } from '../main/ets/services/RemoteChatPollingLifecycleController'; @@ -70,11 +70,9 @@ import { RemoteModelController, RemoteModelPreferenceStore } from '../main/ets/services/RemoteModelController'; -import { RemotePairingPolicy } from '../main/ets/services/RemotePairingPolicy'; -import { PairIdentity, RelayHttpClient } from '../main/ets/services/RelayHttpClient'; import { RemoteResponseMapper } from '../main/ets/services/RemoteResponseMapper'; import { RemoteSessionClient, RemoteSessionController } from '../main/ets/services/RemoteSessionController'; -import { DelegatedAccountSession, RemoteSessionManager } from '../main/ets/services/RemoteSessionManager'; +import { RemoteSessionManager } from '../main/ets/services/RemoteSessionManager'; import { RemoteWorkspaceCoordinator } from '../main/ets/services/RemoteWorkspaceCoordinator'; import { RemoteWorkspaceDataSource } from '../main/ets/services/RemoteWorkspaceRepository'; import { RemoteToolActionClient, RemoteToolActionController } from '../main/ets/services/RemoteToolActionController'; @@ -132,7 +130,6 @@ import { RemoteQuestionAnswerPayload, InitialSyncResult, InitialSyncResponse, - RemoteDescriptor, RemoteModelCatalog, ReadFileResult, RemoteCommand, @@ -298,13 +295,8 @@ class InMemorySettingsConfigStore extends GeneralChatConfigStore { class ScriptedAccountSessionManager extends RemoteSessionManager { readonly unreachable: Set = new Set(); readonly dialled: string[] = []; - delegated?: DelegatedAccountSession; resets: number = 0; - delegatedAccountSession(): DelegatedAccountSession | undefined { - return this.delegated; - } - async connectAccountDevice( _accountClient: CloudAccountClient, _relayUrl: string, @@ -335,93 +327,6 @@ class ScriptedAccountSessionManager extends RemoteSessionManager { } } -class RecordingRoomClient extends RelayHttpClient { - readonly events: string[] = []; - private descriptorReady: boolean = false; - - bind(descriptor: RemoteDescriptor, _crypto: RemoteCrypto): void { - this.descriptorReady = true; - this.events.push(`bind:${descriptor.roomId}`); - } - - reset(): void { - this.descriptorReady = false; - this.events.push('reset'); - } - - async pair(_deviceId: string, _identity: PairIdentity): Promise { - if (!this.descriptorReady) { - throw new Error('Remote descriptor is not configured.'); - } - this.events.push('pair'); - return { - resp: 'initial_sync', - has_workspace: true, - path: '/workspace', - project_name: 'workspace', - sessions: [{ - session_id: 'session-after-retry', - name: 'Recovered session', - agent_type: 'agentic' - }] - }; - } - - async requestDelegatedIdentity(_force: boolean = false): Promise { - return false; - } -} - -/** Holds one room handshake so a newer scan can win the transport generation. */ -class GatedRoomClient extends RelayHttpClient { - private readonly sessionId: string; - private readonly gated: boolean; - private releasePairing: () => void = () => {}; - private markPairingStarted: () => void = () => {}; - private readonly pairingGate: Promise; - readonly pairingStarted: Promise; - - constructor(sessionId: string, gated: boolean) { - super(); - this.sessionId = sessionId; - this.gated = gated; - this.pairingGate = new Promise((resolve: () => void) => { - this.releasePairing = resolve; - }); - this.pairingStarted = new Promise((resolve: () => void) => { - this.markPairingStarted = resolve; - }); - } - - bind(_descriptor: RemoteDescriptor, _crypto: RemoteCrypto): void { - } - - reset(): void { - } - - async pair(_deviceId: string, _identity: PairIdentity): Promise { - this.markPairingStarted(); - if (this.gated) { - await this.pairingGate; - } - return { - resp: 'initial_sync', - has_workspace: true, - path: `/${this.sessionId}`, - project_name: this.sessionId, - sessions: [{ session_id: this.sessionId, name: this.sessionId, agent_type: 'agentic' }] - }; - } - - async requestDelegatedIdentity(_force: boolean = false): Promise { - return false; - } - - release(): void { - this.releasePairing(); - } -} - class RecordingAccountDeviceClient extends CloudAccountClient { async deviceRpc( _relayUrl: string, @@ -565,20 +470,6 @@ class ProvisioningFailureAccountClient extends CloudAccountClient { } } -class WatchLoginFailureAccountClient extends CloudAccountClient { - async loginWatch( - _relayUrl: string, - _username: string, - _password: string, - _currentSession: CloudAccountSession, - _deviceId: string, - _deviceName: string, - _requestId: string - ): Promise { - throw new CloudAccountRequestError('login rejected', 401); - } -} - class AccountDeviceSwitchHarness { readonly remoteState: RemotePageState = new RemotePageState(); readonly sessionManager: ScriptedAccountSessionManager; @@ -602,6 +493,7 @@ class AccountDeviceSwitchHarness { ) { this.sessionManager = sessionManager; const hooks: CloudAccountSettingsHooks = { + openAuthorization: async (_url: string): Promise => true, deviceId: (): string => 'phone', remoteAvailable: (): boolean => true, invalidatePreview: (): void => {}, @@ -640,16 +532,7 @@ class AccountDeviceSwitchHarness { remoteState: this.remoteState, hooks }; - this.controller = new SettingsController( - new InMemorySettingsConfigStore(), - new GeneralChatPageState(), - { - probeConfiguration: async ( - _apiUrl: string, _apiKey: string, _modelName: string - ): Promise => {} - }, - cloud - ); + this.controller = new SettingsController(cloud); if (applyExistingSession) { this.controller.applyCloudAccountSession( { token: 't', userId: 'u', masterKey: new Uint8Array(32) }, @@ -665,223 +548,35 @@ class AccountDeviceSwitchHarness { } export default function transportAndGeneralChatUnitTest() { - describe('RemoteDescriptorParser', () => { - it('parses hash route URLs', 0, () => { - const descriptor = RemoteDescriptorParser.parse('https://relay.example.com/r/mobile#/pair?room=room-a&pk=public-key'); - expect(descriptor.roomId).assertEqual('room-a'); - expect(descriptor.publicKey).assertEqual('public-key'); - expect(descriptor.relayUrl).assertEqual('https://relay.example.com'); - }); - - it('normalizes relay websocket URLs', 0, () => { - const descriptor = RemoteDescriptorParser.parse('https://app.example.com/#/pair?room=room-b&pk=key-b&relay=wss%3A%2F%2Frelay.example.com%2Fws'); - expect(descriptor.relayUrl).assertEqual('https://relay.example.com'); - }); - - it('parses account pairing metadata', 0, () => { - const descriptor = RemoteDescriptorParser.parse('https://relay.example.com/#/pair?room=room-account&pk=key-account&auth=account&user=alice'); - expect(descriptor.accountAuth).assertTrue(); - expect(descriptor.accountUsername).assertEqual('alice'); - }); - - it('parses raw query strings', 0, () => { - const descriptor = RemoteDescriptorParser.parse('room=room-c&pk=key%2Bc%3D&relay=http%3A%2F%2F127.0.0.1%3A30333'); - expect(descriptor.roomId).assertEqual('room-c'); - expect(descriptor.publicKey).assertEqual('key+c='); - expect(descriptor.relayUrl).assertEqual('http://127.0.0.1:30333'); - }); - - it('rejects URLs missing room', 0, () => { - let didThrow = false; - try { - RemoteDescriptorParser.parse('https://relay.example.com/#/pair?pk=key-only'); - } catch (_err) { - didThrow = true; - } - expect(didThrow).assertEqual(true); - }); - - it('rejects URLs missing pk', 0, () => { - let didThrow = false; - try { - RemoteDescriptorParser.parse('https://relay.example.com/#/pair?room=room-only'); - } catch (_err) { - didThrow = true; + describe('Account device links', () => { + it('uses the same device selector for official and LAN endpoints', 0, () => { + for (const endpoint of ['https://remote.openbitfun.com/v/1.0.0', 'http://192.168.1.10:9700']) { + const parsed = accountDeviceLink(`${endpoint}/#/pair?did=desktop-1`); + expect(parsed?.deviceId).assertEqual('desktop-1'); + expect(parsed?.relayUrl).assertEqual(endpoint); } - expect(didThrow).assertEqual(true); }); - - it('accepts legacy links as v1 and rejects unsupported QR protocol versions', 0, () => { - const legacy = RemoteDescriptorParser.parse( - 'https://relay.example.com/#/pair?room=legacy-room&pk=legacy-key' - ); - expect(legacy.roomId).assertEqual('legacy-room'); - let didThrow = false; - try { - RemoteDescriptorParser.parse( - 'https://relay.example.com/#/pair?room=future-room&pk=future-key&v=2' - ); - } catch (_err) { - didThrow = true; + it('rejects credentials, legacy rooms, duplicate targets and public lookalikes', 0, () => { + const prefix = 'https://remote.openbitfun.com/v/1.0.0/#/pair?'; + for (const suffix of ['did=desktop-1&pk=untrusted', 'did=desktop-1&relay=evil', + 'did=desktop-1&did=desktop-2', 'did=%00', 'did=%ZZ', 'room=old&pk=old']) { + expect(accountDeviceLink(prefix + suffix)).assertUndefined(); } - expect(didThrow).assertTrue(); + expect(accountDeviceLink('https://evil.test/v/1.0.0/#/pair?did=desktop-1')).assertUndefined(); + expect(accountDeviceLink('https://remote.openbitfun.com@evil.test/v/1.0.0/#/pair?did=desktop-1')).assertUndefined(); }); }); - describe('RemoteSessionManager room reconnect', () => { - it('keeps the newly scanned descriptor and its initial sessions after a prior room transport', 0, async () => { - const roomClient = new RecordingRoomClient(); - const manager = new RemoteSessionManager(() => roomClient, () => new RemoteCrypto({ - cipher: new FakeRemoteCryptoCipher(), - keyPair: { - privateKey: new Uint8Array(32), - publicKey: new Uint8Array(32) - }, - randomBytes: fixedRandomBytes - })); - const identity: PairIdentity = { userId: 'harmony-device' }; - const first: RemoteDescriptor = { - relayUrl: 'https://relay.example.com', - roomId: 'expired-room', - publicKey: 'expired-key', - accountAuth: false, - accountUsername: '' - }; - const replacement: RemoteDescriptor = { - relayUrl: 'https://relay.example.com', - roomId: 'fresh-room', - publicKey: 'fresh-key', - accountAuth: false, - accountUsername: '' - }; - - await manager.connect(first, identity, 'harmony-device'); - const result = await manager.connect(replacement, identity, 'harmony-device'); - - expect(roomClient.events.join(',')) - .assertEqual('bind:expired-room,pair,reset,bind:fresh-room,pair'); - expect(result.sessions.length).assertEqual(1); - expect(result.sessions[0].id).assertEqual('session-after-retry'); - }); - - it('replaces room state and loads sessions when switching to an account device', 0, async () => { - const roomClient = new RecordingRoomClient(); - const manager = new RemoteSessionManager(() => roomClient, () => new RemoteCrypto({ - cipher: new FakeRemoteCryptoCipher(), - keyPair: { - privateKey: new Uint8Array(32), - publicKey: new Uint8Array(32) - }, - randomBytes: fixedRandomBytes - })); - await manager.connect({ - relayUrl: 'https://relay.example.com', - roomId: 'room-before-account', - publicKey: 'room-key', - accountAuth: false, - accountUsername: '' - }, { userId: 'harmony-device' }, 'harmony-device'); - - const result = await manager.connectAccountDevice( - new RecordingAccountDeviceClient(), - 'https://relay.example.com', - { token: 'token', userId: 'account-user', masterKey: new Uint8Array(32) }, - 'desktop-device' - ); - - expect(manager.hasRoomChannel()).assertFalse(); - expect(result.workspace.path).assertEqual('/account-workspace'); - expect(result.sessions.length).assertEqual(1); - expect(result.sessions[0].id).assertEqual('account-device-session'); - }); - - it('does not let a slower old QR handshake replace a newer room', 0, async () => { - const slow = new GatedRoomClient('old-session', true); - const fast = new GatedRoomClient('new-session', false); - const clients: GatedRoomClient[] = [slow, fast]; - let clientIndex = 0; - const manager = new RemoteSessionManager( - (): RelayHttpClient => clients[clientIndex++], - () => new RemoteCrypto({ - cipher: new FakeRemoteCryptoCipher(), - keyPair: { privateKey: new Uint8Array(32), publicKey: new Uint8Array(32) }, - randomBytes: fixedRandomBytes - }) - ); - const identity: PairIdentity = { userId: 'harmony-device' }; - const oldConnect = manager.connect({ - relayUrl: 'https://relay.example.com', roomId: 'old-room', publicKey: 'old-key', - accountAuth: false, accountUsername: '' - }, identity, 'harmony-device'); - await slow.pairingStarted; - const newResult = await manager.connect({ - relayUrl: 'https://relay.example.com', roomId: 'new-room', publicKey: 'new-key', - accountAuth: false, accountUsername: '' - }, identity, 'harmony-device'); - slow.release(); - let oldRejected = false; - try { - await oldConnect; - } catch (_err) { - oldRejected = true; + describe('RemoteSessionManager account connection', () => { + it('loads account device sessions through either relay endpoint', 0, async () => { + const manager = new RemoteSessionManager(); + for (const endpoint of ['https://remote.openbitfun.com/v/1.0.0', 'http://192.168.1.10:9700']) { + const result = await manager.connectAccountDevice(new RecordingAccountDeviceClient(), endpoint, + { token: 'token', userId: 'account-user', masterKey: new Uint8Array(32) }, 'desktop-device'); + expect(result.workspace.path).assertEqual('/account-workspace'); + expect(result.sessions[0].id).assertEqual('account-device-session'); + expect(result.authenticatedUserId).assertEqual('account-user'); } - - expect(oldRejected).assertTrue(); - expect(newResult.sessions[0].id).assertEqual('new-session'); - }); - }); - - describe('RemotePairingPolicy', () => { - it('prefills account username from QR only over the default install id', 0, () => { - const policy = new RemotePairingPolicy(); - const projection = policy.projection('https://relay.example.com/#/pair?room=room-a&pk=key-a&auth=account&user=alice'); - - expect(projection.requiresAccountAuth).assertTrue(); - expect(projection.accountUsername).assertEqual('alice'); - expect(policy.userIdAfterProjection('harmony-device', 'harmony-device', projection)).assertEqual('alice'); - expect(policy.userIdAfterProjection('manual-user', 'harmony-device', projection)).assertEqual('manual-user'); - }); - - it('blocks passwordless account reconnects and builds encrypted pairing identity', 0, () => { - const policy = new RemotePairingPolicy(); - const descriptor = RemoteDescriptorParser.parse('https://relay.example.com/#/pair?room=room-a&pk=key-a&auth=account&user=alice'); - - expect(policy.shouldAutoReconnect({ - autoReconnectAttempted: false, - remoteUrl: 'https://relay.example.com/#/pair?room=room-a&pk=key-a&auth=account&user=alice', - userId: 'alice', - requiresAccountAuth: true - })).assertFalse(); - - const identity = policy.identityForConnect(descriptor, '', 'harmony-device', 'secret-password', false); - expect(identity.userId).assertEqual('alice'); - expect(identity.password).assertEqual('secret-password'); - }); - - it('keeps ordinary pairing passwordless and auto reconnectable', 0, () => { - const policy = new RemotePairingPolicy(); - const descriptor = RemoteDescriptorParser.parse('https://relay.example.com/#/pair?room=room-a&pk=key-a'); - const identity = policy.identityForConnect(descriptor, '', 'harmony-device', '', true); - - expect(identity.userId).assertEqual('harmony-device'); - expect(identity.password || '').assertEqual(''); - expect(policy.shouldAutoReconnect({ - autoReconnectAttempted: false, - remoteUrl: 'https://relay.example.com/#/pair?room=room-a&pk=key-a', - userId: 'harmony-device', - requiresAccountAuth: false - })).assertTrue(); - }); - - it('does not prompt for an account password when this phone already has that cloud session', 0, () => { - const policy = new RemotePairingPolicy(); - const descriptor = RemoteDescriptorParser.parse( - 'https://relay.example.com/#/pair?room=room-a&pk=key-a&auth=account&user=alice' - ); - - expect(policy.shouldPromptForAccount(descriptor)).assertTrue(); - expect(policy.shouldPromptForAccount(descriptor, true, 'alice', descriptor.relayUrl)).assertFalse(); - expect(policy.shouldPromptForAccount(descriptor, true, 'bob', descriptor.relayUrl)).assertFalse(); }); }); @@ -1405,60 +1100,6 @@ export default function transportAndGeneralChatUnitTest() { }); describe('SettingsController', () => { - it('migrates a legacy account QR on the same relay to a stable device target', 0, async () => { - const harness = new AccountDeviceSwitchHarness(); - - const target = await harness.controller.migrateLegacyPairingTarget( - 'https://relay.example.com/#/pair?room=old-room&pk=old-key&auth=account&user=someone', - 'desk-a', - 'Desktop A' - ); - - expect(target?.deviceId).assertEqual('desk-a'); - expect(harness.remoteState.controlTargetType).assertEqual('account_device'); - expect(harness.sessionStore.saved[0].targetDeviceId).assertEqual('desk-a'); - }); - - it('migrates an ordinary QR only when the same desktop is visible in the account', 0, async () => { - const harness = new AccountDeviceSwitchHarness(new OfflineAccountClient()); - await harness.controller.listCloudAccountDevices(); - - const target = await harness.controller.migrateLegacyPairingTarget( - 'https://relay.example.com/#/pair?room=old-room&pk=old-key', - 'desk-a', - 'QR name' - ); - - expect(target?.deviceId).assertEqual('desk-a'); - expect(target?.deviceName).assertEqual('Desktop A'); - expect(harness.sessionStore.saved[0].targetDeviceId).assertEqual('desk-a'); - }); - - it('does not migrate an ordinary QR to an unknown account device', 0, async () => { - const harness = new AccountDeviceSwitchHarness(); - - const target = await harness.controller.migrateLegacyPairingTarget( - 'https://relay.example.com/#/pair?room=old-room&pk=old-key', - 'desk-unknown', - 'Unknown desktop' - ); - - expect(target === undefined).assertTrue(); - expect(harness.sessionStore.saved.length).assertEqual(0); - }); - - it('does not migrate a legacy QR from another relay', 0, async () => { - const harness = new AccountDeviceSwitchHarness(); - - const target = await harness.controller.migrateLegacyPairingTarget( - 'https://other-relay.example.com/#/pair?room=old-room&pk=old-key&auth=account&user=someone', - 'desk-a', - 'Desktop A' - ); - - expect(target === undefined).assertTrue(); - expect(harness.sessionStore.saved.length).assertEqual(0); - }); it('keeps an offline account target resumable for the heartbeat', 0, async () => { const harness = new AccountDeviceSwitchHarness(new OfflineAccountClient()); @@ -1490,125 +1131,24 @@ export default function transportAndGeneralChatUnitTest() { expect(harness.sessionStore.clears).assertEqual(0); }); - it('asks for account confirmation when a legacy relay has no provisioning route', 0, async () => { - const harness = new AccountDeviceSwitchHarness(new ProvisioningFailureAccountClient(404)); - - const outcome = await harness.controller.provisionWatchCredential( - 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', - 'HarmonyOS Watch', - '12345678-1234-4234-8234-123456789abc' - ); - - expect(outcome !== undefined).assertTrue(); - expect(outcome?.passwordRequired).assertTrue(); - }); - - it('asks for account confirmation when the watch is already registered', 0, async () => { - const harness = new AccountDeviceSwitchHarness(new ProvisioningFailureAccountClient(409)); - // An expired QR pairing projects an empty username into the shared page - // state during startup. The cloud account identity must survive that UI - // projection because its encrypted session is still valid. - harness.remoteState.setAccountPairing(false, ''); - - const outcome = await harness.controller.provisionWatchCredential( - 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', - 'HarmonyOS Watch', - '12345678-1234-4234-8234-123456789abc' - ); - - expect(outcome !== undefined).assertTrue(); - expect(outcome?.passwordRequired).assertTrue(); - }); - - it('does not treat an authorization refusal as a legacy relay', 0, async () => { - const harness = new AccountDeviceSwitchHarness(new ProvisioningFailureAccountClient(403)); - - const outcome = await harness.controller.provisionWatchCredential( - 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', - 'HarmonyOS Watch', - '12345678-1234-4234-8234-123456789abc' - ); - - expect(outcome === undefined).assertTrue(); - }); - - it('reports a rejected compatibility login instead of deferring to the desktop', 0, async () => { - const harness = new AccountDeviceSwitchHarness(new WatchLoginFailureAccountClient()); - harness.remoteState.setAccountPairing(false, ''); - let statusCode = 0; - - try { - await harness.controller.provisionWatchCredential( - 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', - 'HarmonyOS Watch', - '12345678-1234-4234-8234-123456789abc', - 'wrong-password' - ); - } catch (err) { - if (err instanceof CloudAccountRequestError) { - statusCode = err.statusCode; + it('reports provisioning refusals without falling back to password login', 0, async () => { + const statuses: number[] = [401, 403, 404, 409]; + for (const expectedStatus of statuses) { + const harness = new AccountDeviceSwitchHarness(new ProvisioningFailureAccountClient(expectedStatus)); + harness.remoteState.setAccountPairing(false, ''); + let statusCode = 0; + try { + await harness.controller.provisionWatchCredential( + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + 'HarmonyOS Watch', + '12345678-1234-4234-8234-123456789abc' + ); + } catch (err) { + if (err instanceof CloudAccountRequestError) statusCode = err.statusCode; } + expect(statusCode).assertEqual(expectedStatus); + expect(harness.sessionStore.clears).assertEqual(0); } - - expect(statusCode).assertEqual(401); - }); - - it('tests model configuration with the stored key when the form keeps it unchanged', 0, async () => { - const store = new InMemorySettingsConfigStore(); - store.snapshotResult = { - apiUrl: 'https://chat.example.com', - modelName: 'model-a', - hasApiKey: true - }; - store.accessTokenResult = ' stored-key '; - let probedApiKey = ''; - const controller = new SettingsController(store, new GeneralChatPageState(), { - probeConfiguration: async (_apiUrl: string, apiKey: string, _modelName: string): Promise => { - probedApiKey = apiKey; - } - }); - - const error = await controller.test('https://chat.example.com', '', 'model-a', false); - - expect(error).assertEqual(''); - expect(probedApiKey).assertEqual('stored-key'); - }); - - it('saves the first local model and projects its catalog into page state', 0, async () => { - const store = new InMemorySettingsConfigStore(); - store.modelCatalogResults = [ - { version: 1, models: [], default_models: {} }, - { - version: 2, - models: [{ - id: 'local-general-chat', - name: 'model-a', - provider: 'local', - base_url: 'https://chat.example.com', - model_name: 'model-a', - enabled: true, - capabilities: ['text_chat'] - }], - default_models: { primary: 'local-general-chat' }, - session_model_id: 'local-general-chat' - } - ]; - const state = new GeneralChatPageState(); - const controller = new SettingsController(store, state, { - probeConfiguration: async (_apiUrl: string, _apiKey: string, _modelName: string): Promise => { - } - }); - - const error = await controller.save( - 'https://chat.example.com', 'new-key', 'model-a', false - ); - - expect(error).assertEqual(''); - expect(store.saveRequests.length).assertEqual(1); - expect(store.selectLocalModelCalls).assertEqual(1); - expect(state.apiUrl).assertEqual('https://chat.example.com'); - expect(state.conversation.selectedModelId).assertEqual('local-general-chat'); - expect(state.serviceState).assertEqual(GeneralChatServiceState.Ready); }); it('clears the published device roster when the account token expires', 0, async () => { @@ -1628,8 +1168,8 @@ export default function transportAndGeneralChatUnitTest() { expect(harness.accountDeviceSnapshots[0].length).assertEqual(1); expect(harness.accountDeviceSnapshots[1].length).assertEqual(0); expect(harness.controller.hasCloudAccountSession()).assertFalse(); - expect(harness.sessionStore.clears).assertEqual(1); - expect(harness.cachedRemoteClears).assertEqual(1); + expect(harness.sessionStore.clears).assertEqual(0); + expect(harness.cachedRemoteClears).assertEqual(0); }); it('atomically clears account-owned conversation state on logout', 0, async () => { @@ -1678,105 +1218,34 @@ export default function transportAndGeneralChatUnitTest() { expect(harness.timelineResets).assertEqual(1); }); - it('keeps a separately paired room connected after account logout', 0, async () => { + it('revokes a legacy room projection together with explicit account logout', 0, async () => { const harness = new AccountDeviceSwitchHarness(); harness.remoteState.setControlTarget('room', 'room-device', 'Paired Desktop'); harness.remoteState.setConnectionState('connected'); harness.remoteState.setHostCapabilities(['remote_chat']); - harness.remoteState.setActiveSession({ - sessionId: 'room-session', title: 'Paired room', workspacePath: '/room', agentType: 'code' - }); - harness.remoteState.setStatusText('Room is connected'); - await harness.controller.logoutCloudAccount(); - expect(harness.remoteState.accountUserId).assertEqual(''); - expect(harness.remoteState.controlTargetType).assertEqual('room'); - expect(harness.remoteState.controlTargetDeviceId).assertEqual('room-device'); - expect(harness.remoteState.connectionState).assertEqual('connected'); - expect(harness.remoteState.activeSession.sessionId).assertEqual('room-session'); - expect(harness.remoteState.statusText).assertEqual('Room is connected'); - expect(harness.remoteState.hostCapabilities.join(',')).assertEqual('remote_chat'); - expect(harness.timelineResets).assertEqual(0); - expect(harness.sessionManager.resets).assertEqual(0); - expect(harness.savedControlTargetTypes.length).assertEqual(0); + expect(harness.remoteState.controlTargetType).assertEqual('none'); + expect(harness.remoteState.controlTargetDeviceId).assertEqual(''); + expect(harness.remoteState.connectionState).assertEqual('disconnected'); + expect(harness.remoteState.hostCapabilities.length).assertEqual(0); + expect(harness.timelineResets).assertEqual(1); + expect(harness.sessionManager.resets).assertEqual(1); + expect(harness.sessionStore.clears).assertEqual(1); }); - it('keeps a paired room connected when the phone account expires', 0, async () => { - const client = new ExpiringAccountClient(); - const harness = new AccountDeviceSwitchHarness(client); + it('expires legacy live authority while retaining encrypted records and caches', 0, async () => { + const harness = new AccountDeviceSwitchHarness(new ExpiringAccountClient()); await harness.controller.listCloudAccountDevices(); harness.remoteState.setControlTarget('room', 'room-device', 'Paired Desktop'); harness.remoteState.setConnectionState('connected'); - harness.remoteState.setStatusText('Room is connected'); - - try { - await harness.controller.listCloudAccountDevices(); - } catch (_err) { - } - + try { await harness.controller.listCloudAccountDevices(); } catch (_err) {} expect(harness.controller.hasCloudAccountSession()).assertFalse(); - expect(harness.remoteState.controlTargetType).assertEqual('room'); - expect(harness.remoteState.connectionState).assertEqual('connected'); - expect(harness.remoteState.statusText).assertEqual('Room is connected'); - expect(harness.sessionManager.resets).assertEqual(0); - }); - - it('promotes a delegated QR pairing to the cold-start account target', 0, async () => { - const manager = new ScriptedAccountSessionManager(); - manager.delegated = { - relayUrl: 'https://relay.example.com', - session: { token: 'delegated', userId: 'u', masterKey: new Uint8Array(32) } - }; - const harness = new AccountDeviceSwitchHarness(new CloudAccountClient(), manager, false); - - await harness.controller.persistDelegatedAccountSession('desk-a', 'Desktop A'); - - expect(harness.controller.hasCloudAccountSession()).assertTrue(); - expect(harness.sessionStore.saved.length).assertEqual(1); - expect(harness.sessionStore.saved[0].token).assertEqual('delegated'); - expect(harness.sessionStore.saved[0].targetDeviceId).assertEqual('desk-a'); - expect(harness.sessionStore.saved[0].targetDeviceName).assertEqual('Desktop A'); - }); - - it('keeps the phone login while attaching a same-account QR desktop target', 0, async () => { - const manager = new ScriptedAccountSessionManager(); - manager.delegated = { - relayUrl: 'https://relay.example.com', - session: { token: 'delegated', userId: 'u', masterKey: new Uint8Array(32) } - }; - const harness = new AccountDeviceSwitchHarness(new CloudAccountClient(), manager); - - await harness.controller.persistDelegatedAccountSession('desk-a', 'Desktop A'); - - expect(harness.sessionStore.saved.length).assertEqual(1); - expect(harness.sessionStore.saved[0].token).assertEqual('t'); - expect(harness.sessionStore.saved[0].username).assertEqual('someone'); - expect(harness.sessionStore.saved[0].targetDeviceId).assertEqual('desk-a'); - }); - - it('does not erase a remembered account target after choosing an ordinary room', 0, async () => { - const harness = new AccountDeviceSwitchHarness(); - await harness.controller.selectCloudAccountDevice(harness.device('desk-a'), false); - const savesBeforeRoom = harness.sessionStore.saved.length; - - await harness.controller.persistDelegatedAccountSession('desk-b', 'Desktop B'); - - expect(harness.controller.preferredCloudTarget()?.deviceId).assertEqual('desk-a'); - expect(harness.sessionStore.saved.length).assertEqual(savesBeforeRoom); - }); - - it('does not replace a signed-in account with a different delegated QR identity', 0, async () => { - const manager = new ScriptedAccountSessionManager(); - manager.delegated = { - relayUrl: 'https://relay.example.com', - session: { token: 'delegated', userId: 'other-user', masterKey: new Uint8Array(32) } - }; - const harness = new AccountDeviceSwitchHarness(new CloudAccountClient(), manager); - - await harness.controller.persistDelegatedAccountSession('desk-a', 'Desktop A'); - - expect(harness.sessionStore.saved.length).assertEqual(0); + expect(harness.remoteState.controlTargetType).assertEqual('none'); + expect(harness.remoteState.connectionState).assertEqual('disconnected'); + expect(harness.sessionManager.resets).assertEqual(1); + expect(harness.sessionStore.clears).assertEqual(0); + expect(harness.cachedRemoteClears).assertEqual(0); }); it('migrates the same desktop from a QR room to the account transport', 0, async () => { diff --git a/src/apps/mobile/ios/OpenBitFun.xcodeproj/project.pbxproj b/src/apps/mobile/ios/OpenBitFun.xcodeproj/project.pbxproj index f13084a32c..e3845262d1 100644 --- a/src/apps/mobile/ios/OpenBitFun.xcodeproj/project.pbxproj +++ b/src/apps/mobile/ios/OpenBitFun.xcodeproj/project.pbxproj @@ -43,7 +43,6 @@ A10000000000000000000035 /* AccountSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000035 /* AccountSettingsView.swift */; }; A10000000000000000000036 /* MobilePresentationModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000036 /* MobilePresentationModels.swift */; }; A10000000000000000000037 /* MobileLaunchConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000037 /* MobileLaunchConfiguration.swift */; }; - A10000000000000000000038 /* PairingFailureCopy.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000038 /* PairingFailureCopy.swift */; }; A20000000000000000000001 /* RemoteCodeSessionSendUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000001 /* RemoteCodeSessionSendUITests.swift */; }; /* End PBXBuildFile section */ @@ -89,7 +88,6 @@ B10000000000000000000035 /* AccountSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountSettingsView.swift; sourceTree = ""; }; B10000000000000000000036 /* MobilePresentationModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MobilePresentationModels.swift; sourceTree = ""; }; B10000000000000000000037 /* MobileLaunchConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MobileLaunchConfiguration.swift; sourceTree = ""; }; - B10000000000000000000038 /* PairingFailureCopy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PairingFailureCopy.swift; sourceTree = ""; }; B20000000000000000000000 /* OpenBitFunUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = OpenBitFunUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; B20000000000000000000001 /* RemoteCodeSessionSendUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteCodeSessionSendUITests.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -110,7 +108,6 @@ D10000000000000000000015 /* Account */ = {isa = PBXGroup; children = (B10000000000000000000035 /* AccountSettingsView.swift */); path = Account; sourceTree = ""; }; D10000000000000000000004 /* Chat */ = {isa = PBXGroup; children = (B10000000000000000000030 /* ConversationHomeViews.swift */, B10000000000000000000005 /* ConversationHeader.swift */, B10000000000000000000006 /* ChatTimelineView.swift */, B10000000000000000000007 /* ComposerBar.swift */); path = Chat; sourceTree = ""; }; D10000000000000000000005 /* Shell */ = {isa = PBXGroup; children = (B10000000000000000000003 /* OpenBitFunTheme.swift */, B10000000000000000000004 /* SidebarView.swift */, B10000000000000000000008 /* MobileShellView.swift */, B10000000000000000000019 /* SessionActionComponents.swift */, B10000000000000000000020 /* RemoteCreateSessionView.swift */, B10000000000000000000021 /* RemoteFilePreviewView.swift */, B10000000000000000000022 /* RemoteSettingsViews.swift */); path = Shell; sourceTree = ""; }; - D10000000000000000000008 /* Infrastructure */ = {isa = PBXGroup; children = (B10000000000000000000002 /* MobileAppModel.swift */, B10000000000000000000010 /* MobileCoreAdapter.swift */, B10000000000000000000017 /* MobileLocalization.swift */, B10000000000000000000024 /* MobileAppModel+FilePreview.swift */, B10000000000000000000025 /* MobileAppModel+Account.swift */, B10000000000000000000026 /* MobileAppModel+RemoteSession.swift */, B10000000000000000000027 /* MobileAppModel+GeneralChat.swift */, B10000000000000000000028 /* RemoteAuthorityGate.swift */, B10000000000000000000029 /* AccountFailureCopy.swift */, B10000000000000000000038 /* PairingFailureCopy.swift */, D10000000000000000000017 /* Platform */); path = Infrastructure; sourceTree = ""; }; D10000000000000000000011 /* Resources */ = {isa = PBXGroup; children = (B10000000000000000000016 /* Localizable.xcstrings */); path = Resources; sourceTree = ""; }; D10000000000000000000016 /* Presentation */ = {isa = PBXGroup; children = (D10000000000000000000018 /* Models */); path = Presentation; sourceTree = ""; }; D10000000000000000000018 /* Models */ = {isa = PBXGroup; children = (B10000000000000000000036 /* MobilePresentationModels.swift */); path = Models; sourceTree = ""; }; @@ -118,6 +115,7 @@ D10000000000000000000009 /* Products */ = {isa = PBXGroup; children = (B10000000000000000000000 /* OpenBitFun.app */, B20000000000000000000000 /* OpenBitFunUITests.xctest */); name = Products; sourceTree = ""; }; D20000000000000000000001 /* OpenBitFunUITests */ = {isa = PBXGroup; children = (B20000000000000000000001 /* RemoteCodeSessionSendUITests.swift */); path = OpenBitFunUITests; sourceTree = ""; }; D10000000000000000000010 /* DesignSystem */ = {isa = PBXGroup; children = (B10000000000000000000013 /* GeneratedMobileDesignTokens.swift */, B10000000000000000000014 /* GeneratedMobilePreviewScenarios.swift */, B10000000000000000000015 /* MobileDesignGallery.swift */, B10000000000000000000018 /* AdaptiveModalComponents.swift */); path = DesignSystem; sourceTree = ""; }; + D10000000000000000000008 /* Infrastructure */ = {isa = PBXGroup; children = (B10000000000000000000002 /* MobileAppModel.swift */, B10000000000000000000010 /* MobileCoreAdapter.swift */, B10000000000000000000017 /* MobileLocalization.swift */, B10000000000000000000024 /* MobileAppModel+FilePreview.swift */, B10000000000000000000025 /* MobileAppModel+Account.swift */, B10000000000000000000026 /* MobileAppModel+RemoteSession.swift */, B10000000000000000000027 /* MobileAppModel+GeneralChat.swift */, B10000000000000000000028 /* RemoteAuthorityGate.swift */, B10000000000000000000029 /* AccountFailureCopy.swift */, D10000000000000000000017 /* Platform */); path = Infrastructure; sourceTree = ""; }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ diff --git a/src/apps/mobile/ios/OpenBitFun/App/MobileLaunchConfiguration.swift b/src/apps/mobile/ios/OpenBitFun/App/MobileLaunchConfiguration.swift index 255dd117d9..662e74968e 100644 --- a/src/apps/mobile/ios/OpenBitFun/App/MobileLaunchConfiguration.swift +++ b/src/apps/mobile/ios/OpenBitFun/App/MobileLaunchConfiguration.swift @@ -88,11 +88,6 @@ enum MobileLaunchConfiguration { model.downloadStatusText = model.localized("正在保存") model.downloadExporterOpen = true } - if let relay = arguments.value(after: "--relay-url"), - let username = arguments.value(after: "--username"), - let password = arguments.value(after: "--password") { - model.loginAccount(relayURL: relay, username: username, password: password) - } if arguments.contains("--drawer") { model.drawerOpen = true } @@ -103,10 +98,6 @@ enum MobileLaunchConfiguration { model.surface = .remote model.remoteControlSettingsOpen = true } - if arguments.contains("--model-settings") { - model.settingsOpen = true - model.generalConfigOpen = true - } if arguments.contains("--composer-model-picker") || ProcessInfo.processInfo.environment["OPENBITFUN_COMPOSER_MODEL_PICKER"] == "1" { model.composerModelPickerPreview = true @@ -116,7 +107,7 @@ enum MobileLaunchConfiguration { ComposerModelOption( id: "preview-codex", primaryLabel: "GPT-5.6 Codex", - secondaryLabel: "OpenBitFun 账号", + secondaryLabel: "GitHub 账号", source: "ACCOUNT", selected: true ), @@ -218,10 +209,11 @@ enum MobileLaunchConfiguration { private extension MobileAppModel { func configureConnectedPreview() { - directPairingConnected = true + accountUser = "preview" + accountSelectedDeviceID = "preview-desktop" surface = .remote remoteConnected = true - remoteExpectedDeviceKey = "pairing" + remoteExpectedDeviceKey = "account:preview-desktop" remoteInitialSessionReady = true remoteInitialWorkspaceReady = true remoteCreateWorkspacePhase = .ready diff --git a/src/apps/mobile/ios/OpenBitFun/Features/Account/AccountSettingsView.swift b/src/apps/mobile/ios/OpenBitFun/Features/Account/AccountSettingsView.swift index 2e74fa0661..b407e76263 100644 --- a/src/apps/mobile/ios/OpenBitFun/Features/Account/AccountSettingsView.swift +++ b/src/apps/mobile/ios/OpenBitFun/Features/Account/AccountSettingsView.swift @@ -4,11 +4,6 @@ import SwiftUI struct AccountSettingsView: View { @ObservedObject var model: MobileAppModel var onClose: (() -> Void)? = nil - @State private var relayURL = AccountDefaults.shared.CLOUD_RELAY_URL - @State private var username = "" - @State private var password = "" - @State private var advancedOpen = false - var body: some View { Group { if model.accountFailureStage == "DEVICE_LIST", model.accountFailureCanRetry { @@ -30,7 +25,7 @@ struct AccountSettingsView: View { ScrollView(showsIndicators: false) { VStack(spacing: 0) { - Text(model.localized("登录 OpenBitFun 账号")) + Text(model.localized("使用 GitHub 登录")) .font(MobileDesignTypography.displayMedium.font) .foregroundStyle(OpenBitFunTheme.ink) .multilineTextAlignment(.center) @@ -44,67 +39,10 @@ struct AccountSettingsView: View { .padding(.top, 8) .padding(.bottom, 24) - VStack(spacing: 0) { - accountCredentialRow( - icon: "person", - placeholder: model.localized("用户名"), - text: $username, - secure: false - ) - Divider() - .overlay(OpenBitFunTheme.line) - .padding(.leading, 54) - .padding(.trailing, 16) - accountCredentialRow( - icon: "lock", - placeholder: model.localized("密码"), - text: $password, - secure: true - ) + if let url = model.accountAuthorizationURL { + Link(model.localized("打开 GitHub 授权"), destination: url) + .padding(.vertical, 24) } - .background(OpenBitFunTheme.card) - .clipShape(RoundedRectangle(cornerRadius: 24)) - .overlay(RoundedRectangle(cornerRadius: 24).stroke(OpenBitFunTheme.line, lineWidth: 1)) - .shadow(color: MobileDesignColors.shadowFaint, radius: 16, y: 5) - - VStack(spacing: 0) { - Button { advancedOpen.toggle() } label: { - HStack(spacing: 14) { - Image(systemName: "gearshape") - .font(.system(size: 21, weight: .regular)) - .foregroundStyle(OpenBitFunTheme.muted) - .frame(width: 24, height: 24) - Text(model.localized("高级选项")) - .font(MobileDesignTypography.titleSmall.font) - .foregroundStyle(OpenBitFunTheme.ink) - Spacer(minLength: 8) - Image(systemName: advancedOpen ? "chevron.up" : "chevron.down") - .font(.system(size: 13, weight: .medium)) - .foregroundStyle(OpenBitFunTheme.muted) - } - .padding(.horizontal, 18) - .frame(height: 58) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - - if advancedOpen { - Divider().overlay(OpenBitFunTheme.line).padding(.horizontal, 18) - VStack(alignment: .leading, spacing: 8) { - Text(model.localized("登录服务器")) - .font(MobileDesignTypography.labelSmall.font) - .foregroundStyle(OpenBitFunTheme.muted) - accountRelayField - } - .padding(.horizontal, 18) - .padding(.top, 14) - .padding(.bottom, 18) - } - } - .background(OpenBitFunTheme.card) - .clipShape(RoundedRectangle(cornerRadius: 20)) - .overlay(RoundedRectangle(cornerRadius: 20).stroke(OpenBitFunTheme.line, lineWidth: 1)) - .padding(.top, 14) if let error = model.coreErrorMessage, !error.isEmpty { Text(error) @@ -122,12 +60,11 @@ struct AccountSettingsView: View { } Button { - model.loginAccount(relayURL: relayURL, username: username, password: password) - password = "" + model.loginAccount() } label: { HStack(spacing: 8) { if model.accountBusy { ProgressView().tint(OpenBitFunTheme.contentOnAction) } - Text(model.localized(model.accountBusy ? "正在登录" : "登录")) + Text(model.localized(model.accountBusy ? "正在登录" : "通过 GitHub 登录")) } .font(.system(size: 17, weight: .bold)) .foregroundStyle(OpenBitFunTheme.contentOnAction) @@ -143,51 +80,6 @@ struct AccountSettingsView: View { } } - private var accountRelayField: some View { - TextField(model.localized("Relay 地址"), text: $relayURL) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - .keyboardType(.URL) - .font(MobileDesignTypography.bodyMedium.font) - .foregroundStyle(OpenBitFunTheme.ink) - .padding(.horizontal, 14) - .frame(height: 48) - .background(OpenBitFunTheme.soft) - .clipShape(RoundedRectangle(cornerRadius: 14)) - .overlay(RoundedRectangle(cornerRadius: 14).stroke(OpenBitFunTheme.line, lineWidth: 1)) - } - - @ViewBuilder - private func accountCredentialRow( - icon: String, - placeholder: String, - text: Binding, - secure: Bool - ) -> some View { - HStack(spacing: 12) { - Image(systemName: icon) - .font(.system(size: 20, weight: .regular)) - .foregroundStyle(OpenBitFunTheme.muted) - .frame(width: 24, height: 24) - Group { - if secure { - SecureField(placeholder, text: text) - .textContentType(.password) - } else { - TextField(placeholder, text: text) - .textContentType(.username) - } - } - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - .font(MobileDesignTypography.bodyLarge.font) - .foregroundStyle(OpenBitFunTheme.ink) - } - .padding(.leading, 18) - .padding(.trailing, 12) - .frame(height: 60) - } - private var deviceListRetryPage: some View { VStack(alignment: .leading, spacing: 0) { Button { close() } label: { @@ -279,7 +171,7 @@ struct AccountSettingsView: View { VStack(alignment: .leading, spacing: 10) { HStack { - Text(model.localized("OpenBitFun 账号")) + Text(model.localized("GitHub 账号")) .font(.system(size: 17, weight: .bold)) .foregroundStyle(OpenBitFunTheme.ink) Spacer() @@ -413,10 +305,7 @@ struct AccountSettingsView: View { } private var canLogin: Bool { - !model.accountBusy && - !relayURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && - !username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && - !password.isEmpty + !model.accountBusy } private func close() { diff --git a/src/apps/mobile/ios/OpenBitFun/Features/Chat/ComposerBar.swift b/src/apps/mobile/ios/OpenBitFun/Features/Chat/ComposerBar.swift index b347938aab..85bad29810 100644 --- a/src/apps/mobile/ios/OpenBitFun/Features/Chat/ComposerBar.swift +++ b/src/apps/mobile/ios/OpenBitFun/Features/Chat/ComposerBar.swift @@ -40,12 +40,11 @@ struct ComposerBar: View { private var canSend: Bool { hasContent && !model.busy && !model.isSending && - (model.surface == .local || model.connectionPhase != .disconnected) + model.connectionPhase != .disconnected } private var primaryActionKind: ComposerPrimaryAction { if speech.isListening { return .stopListening } - if model.isSending, model.surface == .local { return .stopTurn } if hasContent { return canSend ? .send : .sendBlocked } if model.isSending { return .stopTurn } return model.busy ? .voiceBlocked : .voice @@ -231,7 +230,6 @@ struct ComposerBar: View { .onSubmit { if canSend { model.send() } } - .onChange(of: model.draft) { _ in model.syncDraftToCore() } if showsSupplementalVoice, !expanded { supplementalVoiceAction } @@ -509,7 +507,6 @@ struct ComposerBar: View { model.draft = [existing, transcript] .filter { !$0.isEmpty } .joined(separator: existing.isEmpty ? "" : " ") - model.syncDraftToCore() }, onFailure: { message in model.showToast(model.localized(message)) } ) diff --git a/src/apps/mobile/ios/OpenBitFun/Features/Chat/ConversationHeader.swift b/src/apps/mobile/ios/OpenBitFun/Features/Chat/ConversationHeader.swift index a32c8c6667..1330a32512 100644 --- a/src/apps/mobile/ios/OpenBitFun/Features/Chat/ConversationHeader.swift +++ b/src/apps/mobile/ios/OpenBitFun/Features/Chat/ConversationHeader.swift @@ -22,7 +22,7 @@ struct ConversationHeader: View { if model.surface == .local && model.localSessionSelected { return model.localized("本地会话") } if model.remoteConnected { return model.accountDeviceName - ?? model.directPairingDeviceName + ?? model.localized("已连接桌面端") } return nil @@ -202,20 +202,8 @@ struct ConversationActionsPopover: View { .foregroundStyle(OpenBitFunTheme.muted) .frame(height: 28) .padding(.leading, 8) - if model.surface == .local { - action( - model.selectedSession?.pinned == true ? "取消置顶" : "置顶", - icon: "checkmark.circle", - selected: model.selectedSession?.pinned == true, - perform: model.togglePinSelectedSession - ) - } action("已上传文件", icon: "cloud", perform: model.showUploadedFiles) - if model.surface == .local { - Divider().overlay(OpenBitFunTheme.line).padding(.vertical, 8) - action("归档", icon: "folder", perform: model.archiveSelectedSession) - action("删除", icon: "gearshape", perform: model.deleteSelectedSession) - } else if model.isSending { + if model.isSending { Divider().overlay(OpenBitFunTheme.line).padding(.vertical, 8) action("停止", icon: "gearshape", perform: model.stopSending) } diff --git a/src/apps/mobile/ios/OpenBitFun/Features/Pairing/PairingSheet.swift b/src/apps/mobile/ios/OpenBitFun/Features/Pairing/PairingSheet.swift index 43e18facf2..509e4e183c 100644 --- a/src/apps/mobile/ios/OpenBitFun/Features/Pairing/PairingSheet.swift +++ b/src/apps/mobile/ios/OpenBitFun/Features/Pairing/PairingSheet.swift @@ -8,11 +8,8 @@ struct PairingSheet: View { @Environment(\.dismiss) private var dismiss @State private var step: Step = .intro @State private var pairingURL = MobileLaunchConfiguration.pairingAccountPreview - ? "https://relay.example.com/#/pair?room=preview-room&pk=preview-key&auth=account&user=preview" + ? "https://remote.openbitfun.com/v/1.0.0/#/pair?did=preview-device" : "" - @State private var pairingUserID = "" - // Intentionally transient: pairing passwords must never enter saved scene state. - @State private var pairingPassword = "" @State private var manualOpen = false @State private var scanError: String? @State private var switchingDeviceID: String? @@ -265,7 +262,7 @@ struct PairingSheet: View { Spacer(minLength: 12) SignedOutConnectionActions( scanTitle: model.localized("扫码连接"), - accountTitle: model.localized("登录 OpenBitFun 账号"), + accountTitle: model.localized("使用 GitHub 登录"), onScan: { scanError = nil step = .scan @@ -387,12 +384,7 @@ struct PairingSheet: View { private func handleScannedCode(_ code: String) { pairingURL = code scanError = nil - if PairingLinkHintsKt.inspectPairingLink(url: code).requiresAccount { - manualOpen = true - focused = true - } else { - model.submitPairing(url: code) - } + model.submitPairing(url: code) } private func scanCornerAlignment(_ index: Int) -> Alignment { @@ -432,31 +424,21 @@ struct PairingSheet: View { } private var manualPairingOverlay: some View { - let hints = PairingLinkHintsKt.inspectPairingLink(url: pairingURL) - let effectiveUserID = pairingUserID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - ? hints.suggestedUserId - : pairingUserID.trimmingCharacters(in: .whitespacesAndNewlines) let canSubmit = !model.pairingBusy && - !pairingURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && - (!hints.requiresAccount || (!effectiveUserID.isEmpty && !pairingPassword.isEmpty)) + !pairingURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty return ZStack { OpenBitFunTheme.scrim .ignoresSafeArea() .onTapGesture { if !model.pairingBusy { - pairingPassword = "" manualOpen = false } } VStack(alignment: .leading, spacing: 20) { - Text(model.localized(hints.requiresAccount ? "账号认证配对" : "手动输入配对码")) + Text(model.localized("手动输入配对码")) .font(.system(size: 24, weight: .bold)).foregroundStyle(OpenBitFunTheme.ink) - Text(model.localized( - hints.requiresAccount - ? "此桌面要求使用 OpenBitFun 账号验证身份。" - : "输入桌面端显示的配对链接或代码。" - )) + Text(model.localized("输入桌面端显示的配对链接或代码。")) .font(.system(size: 17)).foregroundStyle(OpenBitFunTheme.muted).lineSpacing(5) TextField(model.localized("配对码或连接链接"), text: $pairingURL) .textInputAutocapitalization(.never) @@ -467,51 +449,16 @@ struct PairingSheet: View { .padding(.horizontal, 20).frame(minHeight: 62) .background(OpenBitFunTheme.soft).clipShape(Capsule()) .focused($focused) - if hints.requiresAccount { - TextField( - hints.suggestedUserId.isEmpty - ? model.localized("OpenBitFun 用户名") - : hints.suggestedUserId, - text: $pairingUserID - ) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - .textContentType(.username) - .font(.system(size: 18)).foregroundStyle(OpenBitFunTheme.ink) - .padding(.horizontal, 20).frame(minHeight: 56) - .background(OpenBitFunTheme.soft).clipShape(Capsule()) - - SecureField(model.localized("OpenBitFun 密码"), text: $pairingPassword) - .textContentType(.password) - .font(.system(size: 18)).foregroundStyle(OpenBitFunTheme.ink) - .padding(.horizontal, 20).frame(minHeight: 56) - .background(OpenBitFunTheme.soft).clipShape(Capsule()) - - Text(model.localized("账号凭据只用于本次加密配对,不会保存。")) - .font(.system(size: 13)) - .foregroundStyle(OpenBitFunTheme.muted) - .lineSpacing(3) - } if let error = model.pairingError { Text(error).font(.system(size: 13)).foregroundStyle(OpenBitFunTheme.statusDanger) } HStack(spacing: 12) { pairingButton("取消", primary: false) { - pairingPassword = "" manualOpen = false focused = false } pairingButton(model.pairingBusy ? "正在连接" : "配对", primary: true) { - if hints.requiresAccount { - model.submitPairing( - url: pairingURL, - userID: effectiveUserID, - password: pairingPassword - ) - pairingPassword = "" - } else { - model.submitPairing(url: pairingURL) - } + model.submitPairing(url: pairingURL) focused = false } .disabled(!canSubmit) diff --git a/src/apps/mobile/ios/OpenBitFun/Features/Settings/AppSettingsView.swift b/src/apps/mobile/ios/OpenBitFun/Features/Settings/AppSettingsView.swift index ddb84bdc72..b9e7892525 100644 --- a/src/apps/mobile/ios/OpenBitFun/Features/Settings/AppSettingsView.swift +++ b/src/apps/mobile/ios/OpenBitFun/Features/Settings/AppSettingsView.swift @@ -49,17 +49,6 @@ struct SettingsView: View { } .buttonStyle(.plain) } - SettingsGroup(title: "模型") { - Button { model.generalConfigOpen = true } label: { - SettingsValueRow( - icon: "square.grid.2x2", - title: "默认模型", - value: selectedModelName, - showsChevron: true - ) - } - .buttonStyle(.plain) - } accountDevicesSection SettingsGroup(title: "关于") { VStack(spacing: 0) { @@ -103,9 +92,6 @@ struct SettingsView: View { if model.languagePickerOpen { LanguagePickerSheet(model: model) .transition(.move(edge: .trailing).combined(with: .opacity)) - } else if model.generalConfigOpen { - GeneralChatConfigSheet(model: model) - .transition(.move(edge: .trailing).combined(with: .opacity)) } else if accountOpen { AccountSettingsView(model: model, onClose: { accountOpen = false }) .transition(.move(edge: .trailing).combined(with: .opacity)) @@ -113,12 +99,11 @@ struct SettingsView: View { } .background(OpenBitFunTheme.page) .animation(.easeInOut(duration: 0.2), value: model.languagePickerOpen) - .animation(.easeInOut(duration: 0.2), value: model.generalConfigOpen) .animation(.easeInOut(duration: 0.2), value: accountOpen) } private var showsCurrentConnection: Bool { - model.remoteConnected || model.accountDeviceName != nil || model.directPairingDeviceName != nil + model.remoteConnected || model.accountDeviceName != nil } private var currentConnectionSection: some View { @@ -132,7 +117,7 @@ struct SettingsView: View { VStack(alignment: .leading, spacing: 3) { Text( model.accountDeviceName - ?? model.directPairingDeviceName + ?? model.localized("尚未连接桌面端") ) .font(MobileDesignTypography.bodyLarge.font.weight(.medium)) diff --git a/src/apps/mobile/ios/OpenBitFun/Features/Shell/MobileShellView.swift b/src/apps/mobile/ios/OpenBitFun/Features/Shell/MobileShellView.swift index fcf75483e4..b31cb1e63e 100644 --- a/src/apps/mobile/ios/OpenBitFun/Features/Shell/MobileShellView.swift +++ b/src/apps/mobile/ios/OpenBitFun/Features/Shell/MobileShellView.swift @@ -53,8 +53,8 @@ struct MobileShellView: View { session: session, presentation: .popover, canViewDetails: true, - canArchive: !remote, - canExport: !remote, + canArchive: false, + canExport: false, canDelete: true, onViewDetails: { sidebarActionSession = nil @@ -62,11 +62,10 @@ struct MobileShellView: View { model.showSessionDetails(session) } }, - onArchive: { if !remote { model.archiveLocalSession(session) } }, - onExport: { if !remote { model.exportLocalSession(session) } }, + onArchive: {}, + onExport: {}, onDelete: { - if remote { model.deleteRemoteSession(session) } - else { model.deleteLocalSession(session) } + model.deleteRemoteSession(session) }, onClose: { sidebarActionSession = nil } ) @@ -104,14 +103,7 @@ struct MobileShellView: View { case .failure: model.finishDownloadExport(success: false) } } - .fileExporter( - isPresented: $model.generalExportOpen, - document: MobileDownloadDocument(data: model.generalExportData), - contentType: UTType(filenameExtension: "md") ?? .plainText, - defaultFilename: model.generalExportName - ) { _ in - model.finishGeneralExport() - } + } @ViewBuilder @@ -346,13 +338,9 @@ struct MobileShellView: View { } if model.surface == .remote && !model.remoteConnected { RemoteHomeView(model: model) - ComposerBar(model: model) } else if model.surface == .remote && !model.remoteSessionSelected { RemoteConnectedHomeView(model: model) ComposerBar(model: model) - } else if model.surface == .local && !model.localSessionSelected { - LocalHomeView() - ComposerBar(model: model) } else { ChatTimelineView(model: model) ComposerBar(model: model) diff --git a/src/apps/mobile/ios/OpenBitFun/Features/Shell/RemoteSettingsViews.swift b/src/apps/mobile/ios/OpenBitFun/Features/Shell/RemoteSettingsViews.swift index cc2566ff9b..33af7fb359 100644 --- a/src/apps/mobile/ios/OpenBitFun/Features/Shell/RemoteSettingsViews.swift +++ b/src/apps/mobile/ios/OpenBitFun/Features/Shell/RemoteSettingsViews.swift @@ -222,7 +222,7 @@ struct RemoteControlSettingsView: View { .font(.system(size: 28, weight: .regular)) .foregroundStyle(OpenBitFunTheme.muted) .frame(width: 34, height: 34) - Text(model.localized(model.accountUser == nil ? "登录 OpenBitFun 账号" : "个人资料")) + Text(model.localized(model.accountUser == nil ? "使用 GitHub 登录" : "个人资料")) .font(.system(size: 18, weight: .medium)) .foregroundStyle(OpenBitFunTheme.ink) Spacer() @@ -476,339 +476,3 @@ struct RemoteControlSettingsView: View { return model.localized("未连接") } } - -struct GeneralChatConfigSheet: View { - private enum Page { case overview, account, local } - - @ObservedObject var model: MobileAppModel - @State private var page: Page = .overview - @State private var baseURL = "" - @State private var modelName = "" - @State private var apiKey = "" - @State private var clearAPIKey = false - - private var selectedModel: ComposerModelOption? { - model.modelOptions.first(where: \.selected) - } - - private var accountModels: [ComposerModelOption] { - model.modelOptions.filter { $0.source == "ACCOUNT" } - } - - private var localModel: ComposerModelOption? { - model.modelOptions.first { $0.source == "LOCAL" } - } - - private var localComplete: Bool { - !model.generalConfigBaseURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && - !model.generalConfigModel.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && - model.generalConfigHasAPIKey - } - - var body: some View { - VStack(alignment: .leading, spacing: 0) { - modelHeader - Divider().overlay(OpenBitFunTheme.line) - switch page { - case .overview: overview - case .account: accountSelection - case .local: localEditor - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) - .background(OpenBitFunTheme.card) - .onAppear { - baseURL = model.generalConfigBaseURL - modelName = model.generalConfigModel - } - } - - private var modelHeader: some View { - HStack(spacing: 8) { - if page != .overview { - Button { page = .overview } label: { - Image(systemName: "chevron.left") - .font(.system(size: 18, weight: .medium)) - .frame(width: 42, height: 42) - } - .buttonStyle(.plain) - .foregroundStyle(OpenBitFunTheme.ink) - .accessibilityLabel(model.localized("返回")) - } - Text(model.localized(headerTitle)) - .font(MobileDesignTypography.headlineSmall.font) - .foregroundStyle(OpenBitFunTheme.ink) - .lineLimit(1) - Spacer(minLength: 8) - Button { model.generalConfigOpen = false } label: { - Image(systemName: "xmark") - .font(.system(size: 18, weight: .regular)) - .foregroundStyle(OpenBitFunTheme.muted) - .frame( - width: MobileDesignGeometry.selectionCloseSize, - height: MobileDesignGeometry.selectionCloseSize - ) - } - .buttonStyle(.plain) - .accessibilityLabel(model.localized("关闭")) - } - .padding(.horizontal, 16) - .frame(height: MobileDesignGeometry.sheetHeaderHeight) - } - - private var headerTitle: String { - switch page { - case .overview: "普通对话模型" - case .account: "选择账号模型" - case .local: "本机自定义模型" - } - } - - private var overview: some View { - ScrollView(showsIndicators: false) { - VStack(alignment: .leading, spacing: MobileDesignGeometry.modelSectionGap) { - VStack(alignment: .leading, spacing: 8) { - sectionTitle("当前使用") - modelOverviewRow( - icon: "checkmark.circle.fill", - title: selectedModel?.primaryLabel ?? model.localized("未配置"), - subtitle: selectedModel.map { sourceLabel($0.source) } ?? "", - height: MobileDesignGeometry.modelCurrentRowHeight - ) - } - VStack(alignment: .leading, spacing: 8) { - sectionTitle("模型来源") - VStack(spacing: 0) { - Button { page = .account } label: { - sourceRow( - icon: "cloud", - title: "云端账号模型", - subtitle: accountModels.isEmpty - ? model.localized("暂无可用的账号模型") - : model.localizedFormat("已同步 %d 个", accountModels.count), - chevronAction: nil - ) - } - .buttonStyle(.plain) - Divider().overlay(OpenBitFunTheme.line).padding(.leading, 56) - HStack(spacing: 0) { - Button { - if localComplete, let localModel { model.selectModel(localModel.id) } - else { page = .local } - } label: { - sourceRow( - icon: "wrench.and.screwdriver", - title: localComplete ? model.generalConfigModel : model.localized("未配置"), - subtitle: localComplete ? model.localized("本机") : "", - chevronAction: nil - ) - } - .buttonStyle(.plain) - Button { page = .local } label: { - Image(systemName: "chevron.right") - .font(.system(size: 14, weight: .medium)) - .foregroundStyle(OpenBitFunTheme.muted) - .frame(width: 44, height: MobileDesignGeometry.modelSourceRowHeight) - } - .buttonStyle(.plain) - } - } - .background(OpenBitFunTheme.soft) - .clipShape(RoundedRectangle(cornerRadius: MobileDesignGeometry.settingsCompactCardRadius)) - } - } - .padding(.horizontal, 16) - .padding(.top, MobileDesignGeometry.modelOverviewTopPadding) - .padding(.bottom, MobileDesignGeometry.modelOverviewBottomPadding) - } - } - - private var accountSelection: some View { - Group { - if accountModels.isEmpty { - Text(model.localized("暂无可用的账号模型")) - .font(MobileDesignTypography.bodyMedium.font) - .foregroundStyle(OpenBitFunTheme.muted) - .frame(maxWidth: .infinity, minHeight: MobileDesignGeometry.modelEmptyAccountHeight, alignment: .leading) - .padding(.horizontal, 16) - } else { - ScrollView(showsIndicators: true) { - LazyVStack(spacing: MobileDesignGeometry.modelAccountRowGap) { - ForEach(accountModels) { option in - Button { - model.selectModel(option.id) - page = .overview - } label: { - HStack(spacing: 10) { - Image(systemName: option.selected ? "checkmark.circle" : "circle") - .foregroundStyle(option.selected ? OpenBitFunTheme.ink : OpenBitFunTheme.transparent) - .frame(width: 20, height: 20) - VStack(alignment: .leading, spacing: 2) { - Text(option.primaryLabel) - .font(MobileDesignTypography.titleSmall.font) - .foregroundStyle(OpenBitFunTheme.ink) - .lineLimit(1) - Text(model.localized("云端账号")) - .font(MobileDesignTypography.labelSmall.font) - .foregroundStyle(OpenBitFunTheme.muted) - } - Spacer() - } - .padding(.horizontal, 10) - .frame(height: MobileDesignGeometry.modelAccountRowHeight) - .background(option.selected ? OpenBitFunTheme.soft : OpenBitFunTheme.transparent) - .clipShape(RoundedRectangle(cornerRadius: 9)) - } - .buttonStyle(.plain) - } - } - .padding(.horizontal, 10) - .padding(.top, MobileDesignGeometry.modelListTopPadding) - .padding(.bottom, MobileDesignGeometry.modelListBottomPadding) - } - } - } - } - - private var localEditor: some View { - ScrollView(showsIndicators: false) { - VStack(alignment: .leading, spacing: 20) { - labeledField("API URL", placeholder: "https://api.example.com", text: $baseURL, secure: false) - labeledField( - "API Key", - placeholder: model.generalConfigHasAPIKey ? "API Key(留空则保留)" : "请输入 API Key", - text: $apiKey, - secure: true - ) - if model.generalConfigHasAPIKey { - Button { - clearAPIKey.toggle() - apiKey = "" - } label: { - Text(model.localized(clearAPIKey ? "保留已保存的 Key" : "清除已保存的 API Key")) - .font(MobileDesignTypography.bodySmall.font) - .foregroundStyle(clearAPIKey ? OpenBitFunTheme.ink : OpenBitFunTheme.statusDanger) - } - .buttonStyle(.plain) - } - labeledField("模型名称", placeholder: "例如 chat-model", text: $modelName, secure: false) - HStack(spacing: 12) { - editorAction(title: model.generalConnectionTestRunning ? "测试中…" : "测试连接", primary: false) { - model.testGeneralConnection( - baseURL: baseURL, model: modelName, apiKey: apiKey, clearAPIKey: clearAPIKey - ) - } - .disabled(model.generalConnectionTestRunning || (apiKey.isEmpty && (!model.generalConfigHasAPIKey || clearAPIKey))) - editorAction(title: "保存", primary: true) { - model.saveGeneralConfig( - baseURL: baseURL, model: modelName, apiKey: apiKey, clearAPIKey: clearAPIKey - ) - } - } - if apiKey.isEmpty && (!model.generalConfigHasAPIKey || clearAPIKey) { - Text(model.localized("保留或输入 API Key 后可测试连接。")) - .font(MobileDesignTypography.labelSmall.font) - .foregroundStyle(MobileDesignColors.subtle) - } - if let failure = model.generalConfigFailure { - Text(configFailureText(failure)) - .font(MobileDesignTypography.bodySmall.font).foregroundStyle(OpenBitFunTheme.statusDanger) - } - if let message = model.generalConnectionTestMessage { - Text(message).font(MobileDesignTypography.bodySmall.font) - .foregroundStyle(message == model.localized("连接成功") ? OpenBitFunTheme.statusSuccess : OpenBitFunTheme.statusDanger) - } - } - .padding(.horizontal, 16) - .padding(.top, 18) - .padding(.bottom, 30) - } - } - - private func sectionTitle(_ title: String) -> some View { - Text(model.localized(title)) - .font(MobileDesignTypography.labelMedium.font) - .foregroundStyle(OpenBitFunTheme.muted) - } - - private func modelOverviewRow(icon: String, title: String, subtitle: String, height: CGFloat) -> some View { - HStack(spacing: 12) { - Image(systemName: icon).font(.system(size: 23)).frame(width: 28, height: 28) - VStack(alignment: .leading, spacing: 3) { - Text(title).font(MobileDesignTypography.bodyLarge.font.weight(.medium)).lineLimit(1) - if !subtitle.isEmpty { - Text(subtitle).font(MobileDesignTypography.labelSmall.font).foregroundStyle(OpenBitFunTheme.muted) - } - } - Spacer() - } - .foregroundStyle(OpenBitFunTheme.ink) - .padding(.horizontal, 16) - .frame(maxWidth: .infinity, minHeight: height) - .background(OpenBitFunTheme.soft) - .clipShape(RoundedRectangle(cornerRadius: MobileDesignGeometry.settingsCompactCardRadius)) - } - - private func sourceRow(icon: String, title: String, subtitle: String, chevronAction: (() -> Void)?) -> some View { - HStack(spacing: 12) { - Image(systemName: icon).font(.system(size: 21)).foregroundStyle(OpenBitFunTheme.muted).frame(width: 28, height: 28) - VStack(alignment: .leading, spacing: 3) { - Text(model.localized(title)).font(MobileDesignTypography.titleSmall.font).foregroundStyle(OpenBitFunTheme.ink).lineLimit(1) - if !subtitle.isEmpty { - Text(subtitle).font(MobileDesignTypography.labelSmall.font).foregroundStyle(OpenBitFunTheme.muted).lineLimit(1) - } - } - Spacer() - if chevronAction != nil { - Image(systemName: "chevron.right").font(.system(size: 14, weight: .medium)).foregroundStyle(OpenBitFunTheme.muted) - } - } - .padding(.horizontal, 16) - .frame(maxWidth: .infinity, minHeight: MobileDesignGeometry.modelSourceRowHeight) - } - - private func sourceLabel(_ source: String) -> String { - model.localized(source == "LOCAL" ? "本机" : "云端账号") - } - - @ViewBuilder - private func labeledField(_ label: String, placeholder: String, text: Binding, secure: Bool) -> some View { - VStack(alignment: .leading, spacing: 8) { - Text(model.localized(label)) - .font(MobileDesignTypography.labelMedium.font) - .foregroundStyle(OpenBitFunTheme.ink) - Group { - if secure { SecureField(model.localized(placeholder), text: text) } - else { TextField(model.localized(placeholder), text: text) } - } - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - .font(MobileDesignTypography.bodyMedium.font) - .padding(.horizontal, 14) - .frame(height: 52) - .background(OpenBitFunTheme.soft) - .clipShape(RoundedRectangle(cornerRadius: MobileDesignGeometry.settingsCompactCardRadius)) - } - } - - private func editorAction(title: String, primary: Bool, action: @escaping () -> Void) -> some View { - Button(action: action) { - Text(model.localized(title)) - .font(MobileDesignTypography.bodyLarge.font.weight(.medium)) - .foregroundStyle(primary ? OpenBitFunTheme.contentOnAction : OpenBitFunTheme.ink) - .frame(maxWidth: .infinity, minHeight: 50) - .background(primary ? OpenBitFunTheme.accent : OpenBitFunTheme.soft) - .clipShape(Capsule()) - } - .buttonStyle(.plain) - } - - private func configFailureText(_ failure: String) -> String { - switch failure { - case "INVALID_URL": model.localized("请输入有效的服务地址") - case "MODEL_REQUIRED": model.localized("请输入模型名称") - case "API_KEY_REQUIRED": model.localized("请输入 API Key") - default: model.localized("配置无法保存,请稍后重试") - } - } -} diff --git a/src/apps/mobile/ios/OpenBitFun/Features/Shell/SidebarView.swift b/src/apps/mobile/ios/OpenBitFun/Features/Shell/SidebarView.swift index e24f3ae02b..1dc67520db 100644 --- a/src/apps/mobile/ios/OpenBitFun/Features/Shell/SidebarView.swift +++ b/src/apps/mobile/ios/OpenBitFun/Features/Shell/SidebarView.swift @@ -59,20 +59,9 @@ struct SidebarView: View { !model.remoteStatusFilter.isEmpty } - private var directoryEntries: [MobileDeviceDirectoryEntry] { - var entries = model.deviceDirectory - if let direct = model.directPairingDirectoryEntry, - !entries.contains(where: { $0.id == direct.id }) { - entries.insert(direct, at: 0) - } - return entries - } + private var directoryEntries: [MobileDeviceDirectoryEntry] { model.deviceDirectory } private var selectedDirectoryEntry: MobileDeviceDirectoryEntry? { - if model.directPairingConnected, - let direct = directoryEntries.first(where: { $0.id == model.directPairingSidebarDeviceID }) { - return direct - } if let selectedID = model.accountSelectedDeviceID, let selected = directoryEntries.first(where: { $0.id == selectedID }) { return selected @@ -90,7 +79,6 @@ struct SidebarView: View { ScrollView(showsIndicators: false) { VStack(alignment: .leading, spacing: 0) { workspaceSection - recentSection } .padding(.bottom, model.accountUser == nil && !model.remoteConnected ? 142 : 84) } @@ -112,15 +100,14 @@ struct SidebarView: View { session: session, presentation: .bottomSheet, canViewDetails: true, - canArchive: model.surface == .local, - canExport: model.surface == .local, + canArchive: false, + canExport: false, canDelete: true, onViewDetails: { openDetails(afterClosing: session) }, - onArchive: { if model.surface == .local { model.archiveLocalSession(session) } }, - onExport: { if model.surface == .local { model.exportLocalSession(session) } }, + onArchive: {}, + onExport: {}, onDelete: { - if model.surface == .remote { model.deleteRemoteSession(session) } - else { model.deleteLocalSession(session) } + model.deleteRemoteSession(session) }, onClose: { compactActionSession = nil } ) @@ -226,11 +213,11 @@ struct SidebarView: View { private var signedOutHeader: some View { HStack(spacing: 8) { - Button { model.newLocalChat() } label: { + Button { model.connectRemote() } label: { HStack(spacing: 8) { Image(systemName: "square.and.pencil") .font(.system(size: 17, weight: .medium)) - Text(model.localized("聊天")) + Text(model.localized("连接桌面端")) .font(.system(size: 15, weight: .medium)) } .foregroundStyle(OpenBitFunTheme.ink) @@ -269,60 +256,6 @@ struct SidebarView: View { } } - private var recentSection: some View { - VStack(alignment: .leading, spacing: 0) { - Text(model.localized("最近对话")) - .font(.system(size: 14, weight: .medium)) - .foregroundStyle(OpenBitFunTheme.muted) - .padding(.top, 16) - .padding(.bottom, 6) - if shownRecentSessions.isEmpty { - Text(model.localized(search.isEmpty ? "暂无最近会话" : "没有匹配的会话")) - .font(.system(size: 13)) - .foregroundStyle(OpenBitFunTheme.muted) - .padding(.horizontal, 12) - .frame(height: 44, alignment: .leading) - } - ForEach(shownRecentSessions) { session in - SidebarRecentRow( - model: model, - session: session, - selected: model.surface == .local && session.id == model.selectedSessionID, - onOpen: { - model.surface = .local - model.select(session) - }, - onActions: { - model.surface = .local - if permanent { onPermanentActions?(session) } - else { compactActionSession = session } - } - ) - } - if visibleRecentCount < recentSessions.count && search.isEmpty { - Button { - visibleRecentCount = min(visibleRecentCount + 6, recentSessions.count) - } label: { - HStack(spacing: 8) { - Text(verbatim: "···") - Text( - model.localizedFormat( - "还有 %lld 个会话", - Int64(recentSessions.count - visibleRecentCount) - ) - ) - } - .frame(maxWidth: .infinity, alignment: .leading) - } - .buttonStyle(.plain) - .font(.system(size: 13)) - .foregroundStyle(OpenBitFunTheme.muted) - .frame(height: 40, alignment: .leading) - .padding(.leading, 12) - } - } - } - private func openDetails(afterClosing session: ChatSession) { compactActionSession = nil DispatchQueue.main.asyncAfter(deadline: .now() + 0.24) { @@ -435,8 +368,7 @@ struct SidebarView: View { private func selectDirectoryDevice(_ device: MobileDeviceDirectoryEntry) { guard device.online, selectedDirectoryEntry?.id != device.id else { return } - guard device.id != model.directPairingSidebarDeviceID, - let accountDevice = model.accountDevices.first(where: { $0.id == device.id }) else { return } + guard let accountDevice = model.accountDevices.first(where: { $0.id == device.id }) else { return } model.selectRemoteDevice(accountDevice) } @@ -649,7 +581,7 @@ struct SidebarView: View { withAnimation(.easeOut(duration: 0.18)) { remoteChatsCollapsed.toggle() } } label: { HStack(spacing: 8) { - Text(model.localized("聊天")) + Text(model.localized("连接桌面端")) .font(.system(size: 14, weight: .medium)) .foregroundStyle(OpenBitFunTheme.muted) Text(verbatim: "\(sessions.count)") @@ -819,7 +751,7 @@ struct SidebarView: View { if model.accountUser == nil { SignedOutConnectionActions( scanTitle: model.localized("扫码连接"), - accountTitle: model.localized("登录 OpenBitFun 账号"), + accountTitle: model.localized("使用 GitHub 登录"), onScan: model.scanRemote, onOpenAccount: { model.accountSheetOpen = true; model.drawerOpen = false }, showScan: !model.remoteConnected @@ -832,10 +764,10 @@ struct SidebarView: View { private var authenticatedFooter: some View { HStack(spacing: 0) { - Button { model.newLocalChat() } label: { + Button { model.connectRemote() } label: { HStack(spacing: 9) { ReferenceImage(assetName: "SidebarEditGlyph", width: 24, height: 24) - Text(model.localized("聊天")) + Text(model.localized("连接桌面端")) .font(.system(size: 15, weight: .medium)) .foregroundStyle(OpenBitFunTheme.ink) } diff --git a/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel+Account.swift b/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel+Account.swift index 25e098536b..7a20022aba 100644 --- a/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel+Account.swift +++ b/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel+Account.swift @@ -16,23 +16,11 @@ extension MobileAppModel { connectionPhase = .disconnected } - private func invalidateRemoteTarget(for operation: (accountGeneration: UInt64, remoteTargetEpoch: UInt64, preservePairing: Bool)) { + private func invalidateRemoteTarget(for operation: (accountGeneration: UInt64, remoteTargetEpoch: UInt64)) { committedRemoteCreate = nil remoteLastAppliedAuthority = nil accountGeneration = operation.accountGeneration - pendingAccountOperationPreservesPairing = ( - generation: operation.accountGeneration, - preserve: operation.preservePairing - ) remoteTargetEpoch = operation.remoteTargetEpoch - if operation.preservePairing && directPairingConnected { - remoteExpectedDeviceKey = "pairing" - remoteConnected = true - pendingDirectorySession = nil - pendingDirectoryWorkspace = nil - pendingDirectoryRemoteDraft = nil - return - } remoteExpectedDeviceKey = nil remoteConnected = false pendingDirectorySession = nil @@ -57,10 +45,6 @@ extension MobileAppModel { let targetKey = "account:\(device.id)" guard remoteExpectedDeviceKey != targetKey else { return } invalidateTargetScopedFileTransfers() - directPairingConnected = false - directPairingDeviceName = nil - directPairingDirectoryEntry = nil - pairingIntentInFlight = false remoteTargetEpoch &+= 1 remoteExpectedDeviceKey = "account:\(device.id)" remoteInitialSessionReady = false @@ -77,7 +61,7 @@ extension MobileAppModel { remoteLastAppliedAuthority = nil accountBusy = true remoteSessionSelected = false - remoteConnected = directPairingConnected + remoteConnected = false remoteSessions = [] remoteWorkspaces = [] workspaceCatalog = [] @@ -95,12 +79,10 @@ extension MobileAppModel { } func logoutAccount() { - var preservePairing = directPairingConnected && remoteExpectedDeviceKey == "pairing" - if coreAdapter?.currentRemoteTargetKey != "pairing" { + invalidateTargetScopedFileTransfers() - } + if let operation = coreAdapter?.beginAccountOperation() { - preservePairing = operation.preservePairing invalidateRemoteTarget(for: operation) } else { accountGeneration &+= 1 @@ -120,7 +102,7 @@ extension MobileAppModel { pendingRemoteAssistantCreate = false remoteSessionSelected = false } - if !preservePairing { + do { remoteExpectedDeviceKey = nil remoteConnected = false pendingDirectorySession = nil @@ -128,7 +110,7 @@ extension MobileAppModel { pendingDirectoryRemoteDraft = nil } accountDirectoryGeneration &+= 1 - coreAdapter?.logoutAccount(preservePairing: preservePairing) + coreAdapter?.logoutAccount() accountUser = nil accountUserID = nil accountDeviceName = nil @@ -136,10 +118,10 @@ extension MobileAppModel { accountDevices = [] accountSelectedDeviceID = nil coreAdapter?.syncDeviceDirectory([]) - if !preservePairing { + do { remoteConnected = false } - if !directPairingConnected { + remoteSessionSelected = false remoteSessions = [] remoteWorkspaces = [] @@ -150,13 +132,13 @@ extension MobileAppModel { pendingRemoteAssistantCreate = false selectedRemoteWorkspaceKind = "" surface = .local - } + } - func loginAccount(relayURL: String, username: String, password: String) { - if coreAdapter?.currentRemoteTargetKey != "pairing" { + func loginAccount() { + invalidateTargetScopedFileTransfers() - } + if let operation = coreAdapter?.beginAccountOperation() { invalidateRemoteTarget(for: operation) } else { @@ -181,7 +163,7 @@ extension MobileAppModel { accountFailureStage = nil accountFailureCanRetry = false coreErrorMessage = nil - coreAdapter?.loginAccount(relayURL: relayURL, username: username, password: password) + coreAdapter?.loginAccount() } func retryAccountFailure() { @@ -193,18 +175,9 @@ extension MobileAppModel { func apply(accountState state: AccountUiState, generation: UInt64) { guard !accountLoginPreview, !localActionPreview, !remoteCreatePreview, generation == accountGeneration else { return } - let preserveDirectPairing = - (directPairingConnected && remoteExpectedDeviceKey == "pairing") || - (pendingAccountOperationPreservesPairing?.generation == generation && - pendingAccountOperationPreservesPairing?.preserve == true) - let preservePendingPairing = preserveDirectPairing && - directPairingConnected && - remoteExpectedDeviceKey == "pairing" && - pendingDirectoryRemoteDraft?.targetKey == "pairing" && - pendingDirectoryRemoteDraft?.epoch == remoteTargetEpoch - pendingAccountOperationPreservesPairing = nil accountGeneration = generation - accountBusy = state is AccountUiStateSigningIn + accountBusy = state is AccountUiStateSigningIn || state is AccountUiStateAuthorizing + accountAuthorizationURL = (state as? AccountUiStateAuthorizing).flatMap { URL(string: $0.authorizationUrl) } if let ready = state as? AccountUiStateReady { let readyTargetKey = ready.selectedDeviceId.map { "account:\($0)" } if let adapterTargetKey = coreAdapter?.currentRemoteTargetKey, @@ -233,14 +206,19 @@ extension MobileAppModel { ) } accountDirectoryGeneration = coreAdapter?.syncDeviceDirectory(accountDevices) ?? (accountDirectoryGeneration &+ 1) - if !directPairingConnected, - ready.selectedDeviceId == nil, + if let link = pendingDeviceLink { + pendingDeviceLink = nil + submitPairing(url: link) + if pairingError != nil { pairingSheetOpen = true } + return + } + if ready.selectedDeviceId == nil, let target = ready.devices.first(where: { $0.online }) { accountBusy = true coreAdapter?.selectAccountDevice(id: target.id) return } - remoteConnected = directPairingConnected || ready.selectedDeviceId != nil + remoteConnected = ready.selectedDeviceId != nil surface = .remote connectionPhase = .connected if ready.refreshFailure != nil { @@ -251,14 +229,14 @@ extension MobileAppModel { accountFailureStage = failed.stage.name accountFailureCanRetry = failed.canRetry coreErrorMessage = accountErrorMessage(failed.reason.name, stage: failed.stage.name) - if pendingDirectoryRemoteDraft != nil, !preservePendingPairing { + if pendingDirectoryRemoteDraft != nil { pendingDirectoryRemoteDraft = nil showToast(localized("远程会话连接已失效,请重新选择设备后重试")) } if remoteCreateOpen { remoteCreateDeviceError = coreErrorMessage } - if !preserveDirectPairing { connectionPhase = .disconnected } + connectionPhase = .disconnected if failed.reason.name == "AUTHENTICATION" { accountUser = nil accountUserID = nil @@ -269,13 +247,13 @@ extension MobileAppModel { accountRefreshing = false pendingDirectorySession = nil pendingDirectoryWorkspace = nil - if !preservePendingPairing { + pendingDirectoryRemoteDraft = nil - } + accountDirectoryGeneration = coreAdapter?.syncDeviceDirectory([]) ?? (accountDirectoryGeneration &+ 1) - if !preserveDirectPairing { + invalidateTerminalAccountAuthority() - } + } } else if state is AccountUiStateSignedOut { accountBusy = false @@ -291,13 +269,13 @@ extension MobileAppModel { accountRefreshing = false pendingDirectorySession = nil pendingDirectoryWorkspace = nil - if !preservePendingPairing { + pendingDirectoryRemoteDraft = nil - } + accountDirectoryGeneration = coreAdapter?.syncDeviceDirectory([]) ?? (accountDirectoryGeneration &+ 1) - if !preserveDirectPairing { + invalidateTerminalAccountAuthority() - } + } } diff --git a/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel+GeneralChat.swift b/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel+GeneralChat.swift index b610d091a8..17bda58691 100644 --- a/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel+GeneralChat.swift +++ b/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel+GeneralChat.swift @@ -2,103 +2,14 @@ import Foundation import OpenBitFunMobileCore extension MobileAppModel { - func send() { - if surface == .remote { - sendRemote() - return - } - let value = draft.trimmingCharacters(in: .whitespacesAndNewlines) - guard !value.isEmpty || !composerImages.isEmpty else { return } - guard !isSending && !busy else { return } - if surface == .local { - localSessionSelected = true - if selectedSession == nil, let first = sessions.first { - selectedSessionID = first.id - } - } - let optimisticMessage = ChatMessage(id: UUID(), role: .user, text: value) - messages.append(optimisticMessage) - timelineRows.append(Self.simpleTimelineRow(optimisticMessage, images: composerImages)) - draft = "" - isSending = true - busy = true - coreAdapter?.updateDraft(value) - coreAdapter?.setGeneralChatImages(composerImages) - composerImages = [] - coreAdapter?.send() - } + func send() { sendRemote() } func select(_ session: ChatSession) { pendingDirectoryRemoteDraft = nil selectedSessionID = session.id - if surface == .remote { - remoteSessionSelected = true - coreAdapter?.openRemoteSession(sessionID: session.id) - } else { - localSessionSelected = true - coreAdapter?.selectGeneralSession(sessionID: session.id) - } - drawerOpen = false - } - - func newLocalChat() { - pendingDirectoryRemoteDraft = nil - surface = .local + remoteSessionSelected = true + coreAdapter?.openRemoteSession(sessionID: session.id) drawerOpen = false - localSessionSelected = false - selectedSessionID = "" - messages = [] - timelineRows = [] - draft = "" - composerImages = [] - coreAdapter?.newGeneralSession() - } - - func archiveLocalSession(_ session: ChatSession) { - coreAdapter?.archiveGeneralSession( - sessionID: session.id, - archived: session.status.lowercased() != "archived" - ) - } - - func deleteLocalSession(_ session: ChatSession) { - coreAdapter?.deleteGeneralSession(sessionID: session.id) - if selectedSessionID == session.id { - localSessionSelected = false - } - } - - func saveGeneralConfig(baseURL: String, model: String, apiKey: String, clearAPIKey: Bool) { - coreAdapter?.saveGeneralConfig( - baseURL: baseURL, model: model, apiKey: apiKey, clearAPIKey: clearAPIKey - ) - } - - func testGeneralConnection(baseURL: String, model: String, apiKey: String, clearAPIKey: Bool) { - coreAdapter?.testGeneralConnection( - baseURL: baseURL, model: model, apiKey: apiKey, clearAPIKey: clearAPIKey - ) - } - - func exportSelectedSession() { - guard surface == .local, let session = selectedSession else { return } - coreAdapter?.exportGeneralSession(sessionID: session.id) - } - - func exportLocalSession(_ session: ChatSession) { - coreAdapter?.exportGeneralSession(sessionID: session.id) - } - - func finishGeneralExport() { - generalExportOpen = false - generalExportData = Data() - coreAdapter?.clearGeneralExport() - } - - func syncDraftToCore() { - if surface == .local { - coreAdapter?.updateDraft(draft) - } } func addComposerImage(data: Data, mimeType: String) { @@ -106,98 +17,16 @@ extension MobileAppModel { showToast(localized("最多添加 4 张且每张不超过 10 MB 的图片")) return } - composerImages.append( - ComposerAttachment(id: UUID().uuidString, data: data, mimeType: mimeType) - ) - if surface == .local { - coreAdapter?.setGeneralChatImages(composerImages) - } + composerImages.append(ComposerAttachment(id: UUID().uuidString, data: data, mimeType: mimeType)) } func removeComposerImage(id: String) { composerImages.removeAll { $0.id == id } - if surface == .local { - coreAdapter?.setGeneralChatImages(composerImages) - } } func selectModel(_ modelID: String) { guard selectedSession != nil else { return } - if surface == .remote { - coreAdapter?.selectRemoteModel(sessionID: selectedSessionID, modelID: modelID) - } else { - coreAdapter?.selectGeneralModel(modelID: modelID) - } - } - - func apply(coreState state: GeneralChatUiState) { - if designGalleryPreview { return } - generalConfigured = state.configured - generalConfigBaseURL = state.config.baseUrl - generalConfigModel = state.config.model - generalConfigHasAPIKey = state.config.hasApiKey - generalConfigFailure = state.configFailure?.name - generalConnectionTestRunning = state.connectionTest.running - if state.connectionTest.passed { - generalConnectionTestMessage = localized("连接成功") - } else if let failure = state.connectionTest.failure { - generalConnectionTestMessage = localizedFormat("连接失败:%@", failure.name) - } else { - generalConnectionTestMessage = nil - } - if let exported = state.export { - let safeTitle = exported.title - .replacingOccurrences(of: "/", with: "-") - .replacingOccurrences(of: "\\", with: "-") - .replacingOccurrences(of: ":", with: "-") - .trimmingCharacters(in: .whitespacesAndNewlines) - generalExportName = safeTitle.isEmpty ? "conversation.md" : "\(safeTitle).md" - generalExportData = Data(exported.markdown.utf8) - generalExportOpen = true - } - if !state.sessions.isEmpty { - sessions = state.sessions.map { session in - ChatSession( - id: session.id, - title: session.title.isEmpty ? localized("未命名会话") : session.title, - updatedLabel: session.updatedAt, - pinned: session.pinned, - status: session.status, - ) - } - } - if !state.messages.isEmpty { - messages = state.messages.map { message in - let text = message.blocks.map(\.text).joined(separator: "\n") - return ChatMessage( - id: UUID(uuidString: message.id) ?? UUID(), - role: message.role.lowercased() == "user" ? .user : .assistant, - text: text, - ) - } - timelineRows = messages.map(Self.simpleTimelineRow) - } - if !composerModelPickerPreview, draft != state.draft { draft = state.draft } - isSending = state.busy - busy = state.busy - if !composerModelPickerPreview { - modelOptions = state.models.map { model in - ComposerModelOption( - id: model.id, - primaryLabel: model.label, - secondaryLabel: model.source.name, - source: model.source.name, - selected: model.id == state.activeModelId - ) - } - } - if !accountLoginPreview { - if let failure = state.failure { - coreErrorMessage = failure.name - } else { - coreErrorMessage = nil - } - } + coreAdapter?.selectRemoteModel(sessionID: selectedSessionID, modelID: modelID) } static func simpleTimelineRow(_ message: ChatMessage) -> MobileConversationRow { diff --git a/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel+RemoteSession.swift b/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel+RemoteSession.swift index f049f0ac5f..94b903b11c 100644 --- a/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel+RemoteSession.swift +++ b/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel+RemoteSession.swift @@ -95,9 +95,7 @@ extension MobileAppModel { remoteCreateRequestDeviceKey = nil remoteCreateError = nil remoteCreateDeviceError = nil - if directPairingConnected && targetKey == "pairing" { - updateDirectPairingDirectoryEntry() - } + } func apply(directoryState state: DeviceDirectoryUiState, generation: UInt64) { @@ -143,36 +141,22 @@ extension MobileAppModel { } func toggleDeviceDirectory(_ device: MobileDeviceDirectoryEntry) { - if device.id == directPairingSidebarDeviceID { - directPairingDirectoryEntry = MobileDeviceDirectoryEntry( - id: device.id, name: device.name, online: device.online, - expanded: !device.expanded, status: device.status, error: device.error, - workspaces: device.workspaces, sessions: device.sessions - ) - return - } + coreAdapter?.toggleDeviceDirectory(device.id, expanded: !device.expanded) } func retryDeviceDirectory(_ device: MobileDeviceDirectoryEntry) { - if device.id == directPairingSidebarDeviceID { - guard directPairingConnected else { return } - coreAdapter?.loadRemoteWorkspaces() - coreAdapter?.refreshRemoteSessions() - return - } + coreAdapter?.retryDeviceDirectory(device.id) } private func directoryTargetKey(forRawDeviceKey rawDeviceKey: String) -> String { - rawDeviceKey == directPairingSidebarDeviceID ? "pairing" : "account:\(rawDeviceKey)" + "account:\(rawDeviceKey)" } private var authoritativeDirectoryRawDeviceKey: String? { guard let targetKey = remoteExpectedDeviceKey else { return nil } - if targetKey == "pairing" { - return directPairingConnected ? directPairingSidebarDeviceID : nil - } + let prefix = "account:" guard targetKey.hasPrefix(prefix) else { return nil } let rawDeviceKey = String(targetKey.dropFirst(prefix.count)) @@ -187,27 +171,13 @@ extension MobileAppModel { showToast(localized("远程会话当前不可创建,请重试")) return } - let targetKey: String - let accountDevice: MobileAccountDevice? - if device.id == directPairingSidebarDeviceID { - guard device.online, directPairingConnected else { - showToast(localized("这台桌面设备当前离线")) - return - } - targetKey = "pairing" - accountDevice = nil - } else { - guard let matched = accountDevices.first(where: { $0.id == device.id }) else { - showToast(localized("远程会话连接已失效,请重新选择设备后重试")) - return - } - guard matched.online, device.online else { - showToast(localized("这台桌面设备当前离线")) - return - } - targetKey = "account:\(matched.id)" - accountDevice = matched + guard let matched = accountDevices.first(where: { $0.id == device.id }), + matched.online, device.online else { + showToast(localized("这台桌面设备当前离线")) + return } + let targetKey = "account:\(matched.id)" + let accountDevice = matched pendingDirectorySession = nil pendingDirectoryWorkspace = nil @@ -238,11 +208,7 @@ extension MobileAppModel { } return } - guard let accountDevice else { - pendingDirectoryRemoteDraft = nil - showToast(localized("远程会话连接已失效,请重新选择设备后重试")) - return - } + selectRemoteDevice(accountDevice) } @@ -746,9 +712,7 @@ extension MobileAppModel { remoteCreateSubmitting = false clearRemoteCreateRequestMetadata() remoteCreateError = detail - if directPairingConnected && targetKey == "pairing" { - updateDirectPairingDirectoryEntry() - } + } return } @@ -793,7 +757,7 @@ extension MobileAppModel { workspacePath: session.workspacePath, workspaceName: session.workspaceName, createdAt: session.createdAt, - messageCount: Int(session.messageCount), + messageCount: Int(session.messageCount) ) } if let committed, projectionDecision.protectCommittedRowAndSelection { @@ -801,9 +765,7 @@ extension MobileAppModel { remoteSessions.insert(committed.session, at: 0) } rebuildRemoteWorkspaceGroups() - if directPairingConnected { - updateDirectPairingDirectoryEntry() - } + if let protected = committedRemoteCreate, protected.targetKey == targetKey, protected.epoch == epoch { @@ -915,9 +877,7 @@ extension MobileAppModel { MobileAssistantOption(path: $0.path, name: $0.name) } rebuildRemoteWorkspaceGroups() - if directPairingConnected { - updateDirectPairingDirectoryEntry() - } + apply(filePreviewState: ready.preview) apply(downloadState: ready.download) if let pending = pendingRemoteWorkspaceCreate, @@ -933,20 +893,6 @@ extension MobileAppModel { advancePendingDirectoryRemoteDraftIfReady() } - private func updateDirectPairingDirectoryEntry() { - guard let name = directPairingDeviceName else { return } - directPairingDirectoryEntry = MobileDeviceDirectoryEntry( - id: directPairingSidebarDeviceID, - name: name, - online: remoteConnected, - expanded: directPairingDirectoryEntry?.expanded ?? true, - status: remoteConnected ? "READY" : "FAILED", - error: remoteConnected ? nil : "DISCONNECTED", - workspaces: remoteWorkspaces, - sessions: remoteSessions - ) - } - func rebuildRemoteWorkspaceGroups() { let selectedPath = workspaceCatalog.first(where: { $0.selected })?.path remoteWorkspaces = workspaceCatalog.map { workspace in diff --git a/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel.swift b/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel.swift index 028d698699..5dc8fc8c5a 100644 --- a/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel.swift +++ b/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel.swift @@ -5,7 +5,7 @@ import OpenBitFunMobileCore @MainActor final class MobileAppModel: ObservableObject { @Published var appLanguage: MobileLanguage = MobileLocalization.restoredLanguage() - @Published var surface: MobileSurface = .local + @Published var surface: MobileSurface = .remote @Published var sessions: [ChatSession] @Published var remoteSessions: [ChatSession] = [] @Published var remoteQuery = "" @@ -27,17 +27,6 @@ final class MobileAppModel: ObservableObject { @Published var remoteCreateSubmitting = false @Published var remoteCreateError: String? @Published var remoteCreateDeviceError: String? - @Published var generalConfigOpen = false - @Published var generalConfigured = false - @Published var generalConfigBaseURL = "" - @Published var generalConfigModel = "" - @Published var generalConfigHasAPIKey = false - @Published var generalConfigFailure: String? - @Published var generalConnectionTestRunning = false - @Published var generalConnectionTestMessage: String? - @Published var generalExportOpen = false - @Published var generalExportName = "conversation.md" - @Published var generalExportData = Data() @Published var selectedSessionID: String @Published var messages: [ChatMessage] @Published var timelineRows: [MobileConversationRow] = [] @@ -58,6 +47,7 @@ final class MobileAppModel: ObservableObject { @Published var localSessionSelected = false @Published var pairingSheetOpen = false @Published var pairingScanRequested = false + var pendingDeviceLink: String? @Published var pairingBusy = false @Published var pairingError: String? @Published var coreErrorMessage: String? @@ -65,16 +55,15 @@ final class MobileAppModel: ObservableObject { @Published var accountUserID: String? @Published var localDeviceID = "" @Published var accountBusy = false + @Published var accountAuthorizationURL: URL? @Published var accountFailureStage: String? @Published var accountFailureCanRetry = false @Published var accountDeviceName: String? - @Published var directPairingDeviceName: String? @Published var accountDeviceCount = 0 @Published var accountDevices: [MobileAccountDevice] = [] @Published var accountSelectedDeviceID: String? @Published var accountRefreshing = false @Published var deviceDirectory: [MobileDeviceDirectoryEntry] = [] - @Published var directPairingDirectoryEntry: MobileDeviceDirectoryEntry? @Published var remoteWorkspaces: [MobileWorkspaceGroup] = [] @Published var workspaceLoading = false @Published var workspaceLoadFailed = false @@ -89,7 +78,6 @@ final class MobileAppModel: ObservableObject { @Published var downloadStatusText: String? @Published var downloadPhase: MobileDownloadPhase = .idle var activeTurnID: String? - var directPairingConnected = false var accountLoginPreview = false var localActionPreview = false var composerModelPickerPreview = false @@ -98,8 +86,6 @@ final class MobileAppModel: ObservableObject { var directoryFixturePreview = false var pairingGeneration: UInt64 = 0 var accountGeneration: UInt64 = 0 - var pendingAccountOperationPreservesPairing: (generation: UInt64, preserve: Bool)? - var pairingIntentInFlight = false var remoteTargetEpoch: UInt64 = 0 var remoteExpectedDeviceKey: String? var remoteBoundTargetKey: String? @@ -130,10 +116,6 @@ final class MobileAppModel: ObservableObject { self.timelineRows = messages.map(Self.simpleTimelineRow) self.coreAdapter = nil let adapter = MobileCoreAdapter( - onState: { [weak self] state in self?.apply(coreState: state) }, - onPairingState: { [weak self] state, generation in - self?.apply(pairingState: state, generation: generation) - }, onAccountState: { [weak self] state, generation in self?.apply(accountState: state, generation: generation) }, @@ -161,14 +143,14 @@ final class MobileAppModel: ObservableObject { } var selectedSession: ChatSession? { - guard (surface == .local && localSessionSelected) || (surface == .remote && remoteSessionSelected) else { + guard remoteSessionSelected else { return nil } return visibleSessions.first { $0.id == selectedSessionID } } var visibleSessions: [ChatSession] { - surface == .local ? sessions : remoteSessions + remoteSessions } var remoteCreateInteraction: RemoteCreateInteractionState { @@ -233,30 +215,16 @@ final class MobileAppModel: ObservableObject { } } - var usesDirectPairing: Bool { directPairingConnected } - - var directPairingSidebarDeviceID: String { "qr:\(directPairingDeviceName ?? "desktop")" } - func dismissPairing() { pairingError = nil - coreAdapter?.dismissPairingFailure() } func handleScenePhase(_ phase: ScenePhase) { - switch phase { - case .active: coreAdapter?.pairingForeground() - case .background: coreAdapter?.pairingBackground() - default: break - } + if phase == .active, accountUser != nil { refreshRemoteDevices() } } func verifyRemoteConnection() { - guard accountUser == nil else { - refreshRemoteDevices() - return - } - connectionPhase = .reconnecting - coreAdapter?.verifyPairing() + refreshRemoteDevices() } func disconnectRemote() { @@ -264,9 +232,6 @@ final class MobileAppModel: ObservableObject { committedRemoteCreate = nil remoteLastAppliedAuthority = nil coreAdapter?.disconnect() - directPairingConnected = false - directPairingDeviceName = nil - pendingAccountOperationPreservesPairing = nil remoteConnected = false remoteSessionSelected = false remoteSessions = [] @@ -285,7 +250,7 @@ final class MobileAppModel: ObservableObject { selectedSessionID = "" timelineRows = [] messages = [] - surface = .local + surface = .remote connectionPhase = .connected } @@ -303,21 +268,24 @@ final class MobileAppModel: ObservableObject { } func submitPairing(url: String) { - prepareProjectionForPairingSubmission() - pairingIntentInFlight = true - pairingGeneration &+= 1 - pairingError = nil - pairingBusy = true - coreAdapter?.submitPairing(url: url) - } - - func submitPairing(url: String, userID: String, password: String) { - prepareProjectionForPairingSubmission() - pairingIntentInFlight = true - pairingGeneration &+= 1 + guard let result = coreAdapter?.resolveDeviceLink(url: url) else { return } + pairingBusy = false pairingError = nil - pairingBusy = true - coreAdapter?.submitPairing(url: url, userID: userID, password: password) + if result.status == .signInRequired { + pendingDeviceLink = url + openAccountFromPairing() + } else if result.status == .ready, + let id = result.deviceId, + let device = accountDevices.first(where: { $0.id == id }) { + pendingDeviceLink = nil + pairingSheetOpen = false + selectRemoteDevice(device) + } else { + pendingDeviceLink = nil + pairingError = result.status == .invalid + ? localized("请使用当前版本的 OpenBitFun 设备二维码。") + : localized("该设备已离线,或不属于当前 GitHub 账户。") + } } private func prepareProjectionForPairingSubmission() { @@ -349,14 +317,10 @@ final class MobileAppModel: ObservableObject { guard transition.clearBoundRemoteProjection else { return } invalidateTargetScopedFileTransfers() - directPairingConnected = false - directPairingDeviceName = nil - directPairingDirectoryEntry = nil remoteConnected = transition.remoteConnected remoteExpectedDeviceKey = nil remoteLastAppliedAuthority = nil committedRemoteCreate = nil - pendingAccountOperationPreservesPairing = nil remoteInitialSessionReady = false remoteInitialWorkspaceReady = false remoteSessionSelected = false @@ -394,12 +358,8 @@ final class MobileAppModel: ObservableObject { } func stopSending() { - if surface == .remote { - guard remoteSessionSelected else { return } - coreAdapter?.cancelRemoteTurn(sessionID: selectedSessionID, turnID: activeTurnID) - } else { - coreAdapter?.cancelGeneralChat() - } + guard remoteSessionSelected else { return } + coreAdapter?.cancelRemoteTurn(sessionID: selectedSessionID, turnID: activeTurnID) } func retryMessage(_ text: String) { @@ -421,30 +381,9 @@ final class MobileAppModel: ObservableObject { guard !normalized.isEmpty, selectedSession != nil else { return } if surface == .remote { coreAdapter?.renameRemoteSession(sessionID: selectedSessionID, title: normalized) - } else { - coreAdapter?.renameGeneralSession(sessionID: selectedSessionID, title: normalized) } } - func togglePinSelectedSession() { - guard surface == .local, let session = selectedSession else { return } - coreAdapter?.pinGeneralSession(sessionID: session.id, pinned: !session.pinned) - } - - func archiveSelectedSession() { - guard surface == .local, let session = selectedSession else { return } - coreAdapter?.archiveGeneralSession( - sessionID: session.id, - archived: session.status.lowercased() != "archived" - ) - } - - func deleteSelectedSession() { - guard surface == .local, let session = selectedSession else { return } - coreAdapter?.deleteGeneralSession(sessionID: session.id) - localSessionSelected = false - } - func showUploadedFiles() { let count = composerImages.count showToast( @@ -463,85 +402,4 @@ final class MobileAppModel: ObservableObject { } } - private func apply(pairingState state: PairingUiState, generation: UInt64) { - guard !localActionPreview, generation == pairingGeneration, - remoteExpectedDeviceKey == nil || remoteExpectedDeviceKey == "pairing" || pairingIntentInFlight else { return } - pairingBusy = state is PairingUiStateConnecting - if let failed = state as? PairingUiStateFailed { - pairingBusy = false - pairingIntentInFlight = false - pairingError = PairingFailureCopy.message(failed.failure, localized: localized) - let healthyConnected: Bool - switch connectionPhase { - case .connected: healthyConnected = remoteConnected - case .reconnecting, .disconnected: healthyConnected = false - } - let retainAccount = RemoteAuthorityGate.shouldRetainAccountAfterPairingFailure( - captured: pairingRetainedAccountAuthority, - adapterTargetKey: coreAdapter?.currentRemoteTargetKey, - adapterEpoch: coreAdapter?.currentRemoteTargetEpoch ?? 0, - modelTargetKey: remoteExpectedDeviceKey, - modelEpoch: remoteTargetEpoch, - healthyConnected: healthyConnected - ) - let invalidatedAccountAuthority = !retainAccount && - (remoteExpectedDeviceKey?.hasPrefix("account:") == true) - if invalidatedAccountAuthority, let targetKey = remoteExpectedDeviceKey { - invalidateTargetScopedFileTransfers() - _ = coreAdapter?.invalidateRemoteAuthority( - ifTargetKey: targetKey, - epoch: remoteTargetEpoch - ) - clearInvalidatedRemoteAuthorityProjection( - adapterEpoch: coreAdapter?.currentRemoteTargetEpoch ?? remoteTargetEpoch - ) - } else { - pairingRetainedAccountAuthority = nil - } - remoteConnected = retainAccount - if !retainAccount { - let clearingVisibleRemoteConversation = surface == .remote || remoteSessionSelected - remoteSessionSelected = false - if clearingVisibleRemoteConversation { - selectedSessionID = "" - activeTurnID = nil - isSending = false - busy = false - timelineRows = [] - messages = [] - } - connectionPhase = .disconnected - } - } else if let paired = state as? PairingUiStatePaired { - pairingBusy = false - pairingError = nil - directPairingConnected = true - pairingIntentInFlight = false - pairingRetainedAccountAuthority = nil - remoteConnected = true - directPairingDeviceName = paired.workspace.roomLabel - if pendingDirectoryRemoteDraft?.targetKey == "pairing", - pendingDirectoryRemoteDraft?.rawDeviceKey != directPairingSidebarDeviceID { - pendingDirectoryRemoteDraft = nil - showToast(localized("远程会话连接已失效,请重新选择设备后重试")) - } - directPairingDirectoryEntry = MobileDeviceDirectoryEntry( - id: directPairingSidebarDeviceID, - name: paired.workspace.roomLabel, - online: true, - expanded: true, - status: "READY", - error: nil, - workspaces: remoteWorkspaces, - sessions: remoteSessions - ) - surface = .remote - switch paired.liveness { - case .checking: connectionPhase = .reconnecting - case .lost: connectionPhase = .disconnected - default: connectionPhase = .connected - } - pairingSheetOpen = false - } - } } diff --git a/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileCoreAdapter.swift b/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileCoreAdapter.swift index 54a6fd771b..78d6708319 100644 --- a/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileCoreAdapter.swift +++ b/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileCoreAdapter.swift @@ -9,8 +9,6 @@ final class MobileCoreAdapter { private let accountLoginLog = Logger(subsystem: "com.openbitfun.mobile.ios", category: "account-login") let deviceID: String private let scope: any CoroutineScope - private let generalChat: GeneralChatStore - private let pairing: PairingStore private let account: AccountStore private let deviceDirectory: DeviceDirectoryStore private var remoteSession: RemoteSessionStore? @@ -21,11 +19,9 @@ final class MobileCoreAdapter { private var initialRemoteTargetSelectionOpen = true private var directoryGeneration: UInt64 = 0 private var accountGeneration: UInt64 = 0 - private var pairingGeneration: UInt64 = 0 private var observations: [Task] = [] private var accountObservation: Task? private var directoryObservation: Task? - private var pairingObservation: Task? private var remoteObservations: [Task] = [] private var pendingDirectoryReconciles: [String: PendingDirectoryReconcile] = [:] @@ -36,13 +32,10 @@ final class MobileCoreAdapter { } private enum DesiredRemoteTarget: Equatable { - case pairing case account(deviceID: String) case accountRestore } - var onState: ((GeneralChatUiState) -> Void)? - var onPairingState: ((PairingUiState, UInt64) -> Void)? var onAccountState: ((AccountUiState, UInt64) -> Void)? var onRemoteTargetBound: ((String, UInt64, UInt64) -> Void)? var onRemoteState: ((RemoteSessionUiState, String, UInt64) -> Void)? @@ -52,18 +45,15 @@ final class MobileCoreAdapter { var onCreateUnavailable: ((String, String?) -> Void)? init( - onState: ((GeneralChatUiState) -> Void)? = nil, - onPairingState: ((PairingUiState, UInt64) -> Void)? = nil, onAccountState: ((AccountUiState, UInt64) -> Void)? = nil, onRemoteTargetBound: ((String, UInt64, UInt64) -> Void)? = nil, onRemoteState: ((RemoteSessionUiState, String, UInt64) -> Void)? = nil, onWorkspaceState: ((RemoteWorkspaceUiState, String, UInt64) -> Void)? = nil, onDirectoryState: ((DeviceDirectoryUiState, UInt64) -> Void)? = nil, onCreateOperation: ((CreateSessionOperationState, String) -> Void)? = nil, - onCreateUnavailable: ((String, String?) -> Void)? = nil, + onCreateUnavailable: ((String, String?) -> Void)? = nil ) { self.scope = MainScope() - self.generalChat = GeneralChatStore.companion.create(scope: scope) let defaults = UserDefaults.standard let installID: String if let stored = defaults.string(forKey: "openbitfun.mobile.install_id") { @@ -73,21 +63,14 @@ final class MobileCoreAdapter { defaults.set(installID, forKey: "openbitfun.mobile.install_id") } self.deviceID = installID - self.pairing = PairingStore.companion.create( - scope: scope, - device: DeviceIdentity(installId: installID, displayName: "OpenBitFun iPhone"), - log: CoreLogNone.shared, - ) self.account = AccountStore.companion.create( scope: scope, service: "com.openbitfun.mobile.account", deviceId: installID, deviceName: "OpenBitFun iPhone", - log: CoreLogNone.shared, + log: CoreLogNone.shared ) self.deviceDirectory = DeviceDirectoryStore.companion.create(scope: scope, accountStore: account) - self.onState = onState - self.onPairingState = onPairingState self.onAccountState = onAccountState self.onRemoteTargetBound = onRemoteTargetBound self.onRemoteState = onRemoteState @@ -96,98 +79,12 @@ final class MobileCoreAdapter { self.onCreateOperation = onCreateOperation self.onCreateUnavailable = onCreateUnavailable - let flow = SkieSwiftStateFlow(generalChat.state) - onState?(flow.value) - observations.append(Task { [weak self] in - for await state in flow { - guard !Task.isCancelled else { return } - self?.onState?(state) - } - }) - rebindDirectoryObservation(generation: directoryGeneration) rebindAccountObservation() - rebindPairingObserver(capturedGeneration: pairingGeneration) account.dispatch(intent: AccountIntentRestore.shared) - pairing.dispatch(intent: PairingIntentForeground.shared) - } - - func updateDraft(_ text: String) { - generalChat.dispatch(intent: GeneralChatIntentUpdateDraft(text: text)) - } - - func send() { - generalChat.dispatch(intent: GeneralChatIntentSend.shared) - } - - func cancelGeneralChat() { - generalChat.dispatch(intent: GeneralChatIntentCancel.shared) - } - - func setGeneralChatImages(_ images: [ComposerAttachment]) { - generalChat.dispatch(intent: GeneralChatIntentSetImages(images: images.map(\.coreImage))) - } - - func renameGeneralSession(sessionID: String, title: String) { - generalChat.dispatch(intent: GeneralChatIntentRenameSession(sessionId: sessionID, title: title)) - } - - func pinGeneralSession(sessionID: String, pinned: Bool) { - generalChat.dispatch(intent: GeneralChatIntentPinSession(sessionId: sessionID, pinned: pinned)) - } - - func archiveGeneralSession(sessionID: String, archived: Bool) { - generalChat.dispatch(intent: GeneralChatIntentArchiveSession(sessionId: sessionID, archived: archived)) - } - - func deleteGeneralSession(sessionID: String) { - generalChat.dispatch(intent: GeneralChatIntentDeleteSession(sessionId: sessionID)) - } - - func selectGeneralModel(modelID: String) { - generalChat.dispatch(intent: GeneralChatIntentSelectModel(modelId: modelID)) - } - - func selectGeneralSession(sessionID: String) { - generalChat.dispatch(intent: GeneralChatIntentSelectSession(sessionId: sessionID)) - } - - func saveGeneralConfig(baseURL: String, model: String, apiKey: String, clearAPIKey: Bool) { - generalChat.dispatch( - intent: GeneralChatIntentSaveConfig( - baseUrl: baseURL, model: model, apiKey: apiKey, clearApiKey: clearAPIKey - ) - ) - } - - func testGeneralConnection(baseURL: String, model: String, apiKey: String, clearAPIKey: Bool) { - generalChat.dispatch( - intent: GeneralChatIntentTestConnection( - baseUrl: baseURL, model: model, apiKey: apiKey, clearApiKey: clearAPIKey - ) - ) - } - - func exportGeneralSession(sessionID: String) { - generalChat.dispatch( - intent: GeneralChatIntentExportSession( - sessionId: sessionID, - untitledLabel: "未命名会话", - userLabel: "用户", - assistantLabel: "OpenBitFun" - ) - ) - } - - func clearGeneralExport() { - generalChat.dispatch(intent: GeneralChatIntentClearExport.shared) - } - - func newGeneralSession() { - generalChat.dispatch(intent: GeneralChatIntentNewSession.shared) } private func rebindAccountObservation(emitCurrent: Bool = true) { @@ -220,92 +117,30 @@ final class MobileCoreAdapter { ) } - private func rebindPairingObserver(capturedGeneration: UInt64) { - pairingObservation?.cancel() - let flow = SkieSwiftStateFlow(pairing.state) - let generation = capturedGeneration - onPairingState?(flow.value, generation) - if let paired = flow.value as? PairingUiStatePaired { - startRemoteSessionStoreIfNeeded(paired: paired, generation: generation) - } - pairingObservation = Task { [weak self] in - for await state in flow { - guard !Task.isCancelled else { return } - self?.onPairingState?(state, generation) - if let paired = state as? PairingUiStatePaired { - self?.startRemoteSessionStoreIfNeeded(paired: paired, generation: generation) - } - } - } - } - - func submitPairing(url: String) { - preparePairingSubmission() - pairing.dispatch(intent: PairingIntentSubmit(pairingUrl: url)) - rebindPairingObserver(capturedGeneration: pairingGeneration) - } - - func submitPairing(url: String, userID: String, password: String) { - preparePairingSubmission() - pairing.dispatch( - intent: PairingIntentSubmit( - pairingUrl: url, - userId: userID, - password: password - ) + func resolveDeviceLink(url: String) -> AccountDeviceLinkResult { + let result = AccountDeviceLinkKt.resolveAccountDeviceLink( + url: url, state: SkieSwiftStateFlow(account.state).value ) - rebindPairingObserver(capturedGeneration: pairingGeneration) - } - - private func preparePairingSubmission() { - desiredRemoteTarget = .pairing - initialRemoteTargetSelectionOpen = false - pairingGeneration &+= 1 - pairingObservation?.cancel() - pairingObservation = nil - if remoteTargetKey == "pairing" { - resetRemoteStores() + if result.status == .signInRequired, let relayUrl = result.relayUrl { + account.dispatch(intent: AccountIntentSelectRelay(relayUrl: relayUrl)) } - pairing.dispatch(intent: PairingIntentDisconnect.shared) - } - - func dismissPairingFailure() { - pairing.dispatch(intent: PairingIntentDismiss.shared) - } - - func pairingForeground() { - pairing.dispatch(intent: PairingIntentForeground.shared) - } - - func pairingBackground() { - pairing.dispatch(intent: PairingIntentBackground.shared) - } - - func verifyPairing() { - pairing.dispatch(intent: PairingIntentVerify.shared) + return result } - func beginAccountOperation() -> (accountGeneration: UInt64, remoteTargetEpoch: UInt64, preservePairing: Bool) { + func beginAccountOperation() -> (accountGeneration: UInt64, remoteTargetEpoch: UInt64) { accountGeneration &+= 1 - let preservePairing = remoteTargetKey == "pairing" - if preservePairing { - desiredRemoteTarget = .pairing - } else { - desiredRemoteTarget = nil - initialRemoteTargetSelectionOpen = false - resetRemoteStores() - remoteTargetEpoch &+= 1 - } + desiredRemoteTarget = nil + initialRemoteTargetSelectionOpen = false + resetRemoteStores() + remoteTargetEpoch &+= 1 rebindAccountObservation(emitCurrent: false) - return (accountGeneration, remoteTargetEpoch, preservePairing) + return (accountGeneration, remoteTargetEpoch) } - func loginAccount(relayURL: String, username: String, password: String) { - if remoteTargetKey != "pairing" { - desiredRemoteTarget = .accountRestore - } + func loginAccount() { + desiredRemoteTarget = .accountRestore initialRemoteTargetSelectionOpen = false - account.dispatch(intent: AccountIntentLogin(relayUrl: relayURL, username: username, password: password)) + account.dispatch(intent: AccountIntentLogin.shared) } func selectAccountDevice(id: String) { @@ -361,16 +196,11 @@ final class MobileCoreAdapter { account.dispatch(intent: AccountIntentRetry.shared) } - func logoutAccount(preservePairing: Bool) { + func logoutAccount() { pendingDirectoryReconciles.removeAll() - let keepPairing = preservePairing && remoteTargetKey == "pairing" - if keepPairing { - desiredRemoteTarget = .pairing - } else { - desiredRemoteTarget = nil - initialRemoteTargetSelectionOpen = false - resetRemoteStores() - } + desiredRemoteTarget = nil + initialRemoteTargetSelectionOpen = false + resetRemoteStores() deviceDirectory.dispatch(intent: DeviceDirectoryIntentStop.shared) account.dispatch(intent: AccountIntentLogout.shared) } @@ -378,7 +208,6 @@ final class MobileCoreAdapter { func disconnect() { desiredRemoteTarget = nil initialRemoteTargetSelectionOpen = false - pairing.dispatch(intent: PairingIntentDisconnect.shared) resetRemoteStores() } @@ -387,7 +216,7 @@ final class MobileCoreAdapter { intent: RemoteSessionIntentSendMessage( sessionId: sessionID, content: content, - images: images.isEmpty ? nil : images.map(\.coreImage), + images: images.isEmpty ? nil : images.map(\.coreImage) ) ) } @@ -609,20 +438,6 @@ final class MobileCoreAdapter { remoteWorkspace?.dispatch(intent: RemoteWorkspaceIntentDismissPreview.shared) } - private func startRemoteSessionStoreIfNeeded(paired: PairingUiStatePaired, generation: UInt64) { - let targetKey = "pairing" - guard generation == pairingGeneration, - remoteTargetIsDesired(.pairing), - remoteTargetKey != targetKey, - let sessionStore = pairing.createSessionStore(scope: scope) else { return } - commitInitialRemoteTargetIfNeeded(.pairing) - bindRemoteStores( - targetKey: targetKey, - sessionStore: sessionStore, - workspaceStore: pairing.createWorkspaceStore(scope: scope) - ) - } - private func startAccountRemoteSessionIfNeeded(ready: AccountUiStateReady, generation: UInt64) { guard generation == accountGeneration, let deviceID = ready.selectedDeviceId else { return } @@ -641,8 +456,6 @@ final class MobileCoreAdapter { private func remoteTargetIsDesired(_ candidate: DesiredRemoteTarget) -> Bool { switch desiredRemoteTarget { - case .pairing: - return candidate == .pairing case let .account(deviceID): return candidate == .account(deviceID: deviceID) case .accountRestore: @@ -664,9 +477,6 @@ final class MobileCoreAdapter { pendingDirectoryReconciles.removeValue(forKey: requestID) guard let targetKey = remoteTargetKey else { return } - if targetKey == "pairing" { - return - } let prefix = "account:" guard targetKey.hasPrefix(prefix) else { return } let deviceID = String(targetKey.dropFirst(prefix.count)) @@ -681,7 +491,6 @@ final class MobileCoreAdapter { private func remoteTargetKind(_ targetKey: String?) -> String { guard let targetKey else { return "none" } - if targetKey == "pairing" { return "pairing" } if targetKey.hasPrefix("account:") { return "account" } return "other" } @@ -780,14 +589,10 @@ final class MobileCoreAdapter { observations.forEach { $0.cancel() } observations.removeAll() directoryObservation?.cancel() - pairingObservation?.cancel() - pairingObservation = nil directoryObservation = nil resetRemoteStores() deviceDirectory.dispatch(intent: DeviceDirectoryIntentStop.shared) - pairing.dispatch(intent: PairingIntentDisconnect.shared) account.stop() - generalChat.stop() } } diff --git a/src/apps/mobile/ios/OpenBitFun/Infrastructure/PairingFailureCopy.swift b/src/apps/mobile/ios/OpenBitFun/Infrastructure/PairingFailureCopy.swift deleted file mode 100644 index 5f40ad7c8c..0000000000 --- a/src/apps/mobile/ios/OpenBitFun/Infrastructure/PairingFailureCopy.swift +++ /dev/null @@ -1,31 +0,0 @@ -import OpenBitFunMobileCore - -enum PairingFailureCopy { - static func message(_ failure: PairingFailure, localized: (String) -> String) -> String { - if let remote = failure.remoteMessage?.trimmingCharacters(in: .whitespacesAndNewlines), !remote.isEmpty { - return remote - } - switch failure.reason.name { - case "PAIRING_LINK_EMPTY", "PAIRING_LINK_INCOMPLETE", "PAIRING_LINK_UNDECODABLE", "PAIRING_LINK_KEY_UNUSABLE": - return localized("连接链接无效,请重新扫描或粘贴桌面端链接") - case "ACCOUNT_USERNAME_REQUIRED": - return localized("请输入桌面端账号") - case "ACCOUNT_PASSWORD_REQUIRED": - return localized("请输入桌面端密码") - case "REJECTED", "DESKTOP_REJECTED": - return localized("桌面端拒绝了这次连接") - case "ROOM_NOT_FOUND": - return localized("找不到桌面端房间,请确认桌面端仍在等待连接") - case "RATE_LIMITED", "TOO_MANY_ATTEMPTS": - return localized("尝试次数过多,请稍后再试") - case "RELAY_UNAVAILABLE", "NETWORK_UNREACHABLE": - return localized("网络不可用,请检查手机与桌面端的网络") - case "TIMEOUT": - return localized("连接超时,请重新尝试") - case "PROTOCOL_MISMATCH": - return localized("桌面端版本不兼容,请升级后重试") - default: - return localized("连接失败,请检查桌面端链接") - } - } -} diff --git a/src/apps/mobile/ios/OpenBitFun/Infrastructure/Platform/QRCodeScannerView.swift b/src/apps/mobile/ios/OpenBitFun/Infrastructure/Platform/QRCodeScannerView.swift index ea35926829..4f02efdbd3 100644 --- a/src/apps/mobile/ios/OpenBitFun/Infrastructure/Platform/QRCodeScannerView.swift +++ b/src/apps/mobile/ios/OpenBitFun/Infrastructure/Platform/QRCodeScannerView.swift @@ -154,7 +154,7 @@ final class QRScannerController: UIViewController, AVCaptureMetadataOutputObject func metadataOutput( _ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], - from connection: AVCaptureConnection, + from connection: AVCaptureConnection ) { guard let value = (metadataObjects.first as? AVMetadataMachineReadableCodeObject)?.stringValue, !value.isEmpty, diff --git a/src/apps/mobile/ios/OpenBitFun/Resources/Localizable.xcstrings b/src/apps/mobile/ios/OpenBitFun/Resources/Localizable.xcstrings index 8b82596190..4313cb46b1 100644 --- a/src/apps/mobile/ios/OpenBitFun/Resources/Localizable.xcstrings +++ b/src/apps/mobile/ios/OpenBitFun/Resources/Localizable.xcstrings @@ -2631,12 +2631,12 @@ } } }, - "登录 OpenBitFun 账号": { + "使用 GitHub 登录": { "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Sign in to OpenBitFun" + "value": "Sign in with GitHub" } } } @@ -2931,12 +2931,12 @@ } } }, - "此桌面要求使用 OpenBitFun 账号验证身份。": { + "此桌面要求使用 GitHub 账号验证身份。": { "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "This desktop requires your OpenBitFun account to verify your identity." + "value": "This desktop requires your GitHub account to verify your identity." } } } @@ -2981,12 +2981,12 @@ } } }, - "OpenBitFun 账号": { + "GitHub 账号": { "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "OpenBitFun account" + "value": "GitHub account" } } } @@ -4631,16 +4631,6 @@ } } }, - "登录 OpenBitFun": { - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Sign in to OpenBitFun" - } - } - } - }, "登录后即可同步设备、会话和远程控制状态。": { "localizations": { "en": { @@ -5071,12 +5061,12 @@ } } }, - "桌面端已登录 OpenBitFun 账号,需要验证同一账号后继续连接。": { + "桌面端已使用 GitHub 登录,需要验证同一账号后继续连接。": { "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "The desktop is signed in to a OpenBitFun account. Verify the same account to continue." + "value": "The desktop is signed in to a GitHub account. Verify the same account to continue." } } } @@ -5091,12 +5081,12 @@ } } }, - "请输入 OpenBitFun 账号密码完成配对": { + "请使用 GitHub 登录后连接设备": { "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Enter the OpenBitFun account password to finish pairing" + "value": "Sign in with GitHub to connect this device" } } } @@ -7121,12 +7111,12 @@ } } }, - "请输入 OpenBitFun 账号密码。": { + "请使用 GitHub 登录以继续。": { "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Enter the OpenBitFun account password." + "value": "Sign in with GitHub to continue." } } } @@ -7341,12 +7331,12 @@ } } }, - "把这块手表加入你的 OpenBitFun 账号?": { + "把这块手表加入你的 GitHub 账号?": { "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Add this watch to your OpenBitFun account?" + "value": "Add this watch to your GitHub account?" } } } @@ -7858,6 +7848,58 @@ } } } + }, + "打开 GitHub 授权": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open GitHub authorization" + } + } + } + }, + "通过 GitHub 登录": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Sign in with GitHub" + } + } + } + }, + "请使用当前版本的 OpenBitFun 设备二维码。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Use a current OpenBitFun device QR code." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "请使用当前版本的 OpenBitFun 设备二维码。" + } + } + } + }, + "该设备已离线,或不属于当前 GitHub 账户。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This device is offline or does not belong to your GitHub account." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "该设备已离线,或不属于当前 GitHub 账户。" + } + } + } } }, "version": "1.0" diff --git a/src/apps/mobile/ios/Testing/RemoteAuthorityGateTests.swift b/src/apps/mobile/ios/Testing/RemoteAuthorityGateTests.swift index c2700181d9..6353994f25 100644 --- a/src/apps/mobile/ios/Testing/RemoteAuthorityGateTests.swift +++ b/src/apps/mobile/ios/Testing/RemoteAuthorityGateTests.swift @@ -494,7 +494,7 @@ struct RemoteAuthorityGateTests { ) expectInvalidationBeforeMutation( in: accountSource, - function: "func loginAccount(relayURL: String, username: String, password: String)", + function: "func loginAccount()", mutation: "coreAdapter?.beginAccountOperation()", message: "non-retained login invalidates transfers before adapter authority reset" ) @@ -536,9 +536,9 @@ struct RemoteAuthorityGateTests { expectCallBeforeMutation( in: modelSource, function: "func submitPairing(url: String)", - call: "prepareProjectionForPairingSubmission()", - mutation: "coreAdapter?.submitPairing(url: url)", - message: "pairing replacement preparation runs before adapter pairing mutation" + call: "coreAdapter?.resolveDeviceLink(url: url)", + mutation: "selectRemoteDevice(device)", + message: "QR membership validation precedes the device selection path that invalidates transfers" ) expectInvalidationBeforeMutation( in: modelSource, diff --git a/src/apps/mobile/shared/core-crypto/build.gradle.kts b/src/apps/mobile/shared/core-crypto/build.gradle.kts index 3a52355128..22aa36f45f 100644 --- a/src/apps/mobile/shared/core-crypto/build.gradle.kts +++ b/src/apps/mobile/shared/core-crypto/build.gradle.kts @@ -73,14 +73,14 @@ kotlin { } iosArm64 { - compilations.getByName("main").cinterops.create("openbitfunArgon2") { + compilations.getByName("main").cinterops.create("openBitFunArgon2") { defFile(argon2InteropDir.file("openbitfun_argon2.def")) includeDirs(sharedArgon2Dir, argon2InteropDir) extraOpts("-libraryPath", layout.buildDirectory.dir("native/argon2/iosArm64").get().asFile.absolutePath) } } iosSimulatorArm64 { - compilations.getByName("main").cinterops.create("openbitfunArgon2") { + compilations.getByName("main").cinterops.create("openBitFunArgon2") { defFile(argon2InteropDir.file("openbitfun_argon2_simulator.def")) includeDirs(sharedArgon2Dir, argon2InteropDir) extraOpts("-libraryPath", layout.buildDirectory.dir("native/argon2/iosSimulatorArm64").get().asFile.absolutePath) diff --git a/src/apps/mobile/shared/core-crypto/src/commonMain/kotlin/com/openbitfun/mobile/core/crypto/DeviceIdentity.kt b/src/apps/mobile/shared/core-crypto/src/commonMain/kotlin/com/openbitfun/mobile/core/crypto/DeviceIdentity.kt new file mode 100644 index 0000000000..6e84f8a362 --- /dev/null +++ b/src/apps/mobile/shared/core-crypto/src/commonMain/kotlin/com/openbitfun/mobile/core/crypto/DeviceIdentity.kt @@ -0,0 +1,31 @@ +package com.openbitfun.mobile.core.crypto + +import dev.whyoleg.cryptography.algorithms.HKDF +import dev.whyoleg.cryptography.algorithms.SHA256 +import dev.whyoleg.cryptography.BinarySize.Companion.bytes +import dev.whyoleg.cryptography.random.CryptographyRandom + +/** Private device identity; only its public key is registered with the relay. */ +public object DeviceIdentity { + public fun randomBytes(size: Int): ByteArray = CryptographyRandom.Default.nextBytes(size) + + public fun generateSecret(): ByteArray = CryptographyRandom.Default.nextBytes(32) + + public suspend fun publicKey(secret: ByteArray): ByteArray = + X25519.fromPrivateKeyBytes(secret).publicKeyBytes + + public suspend fun messageKey(secret: ByteArray, peerPublic: ByteArray): ByteArray { + val pair = X25519.fromPrivateKeyBytes(secret) + val shared = pair.sharedSecretWith(peerPublic) + require(shared.any { it != 0.toByte() }) { "Invalid peer public key." } + val local = pair.publicKeyBytes + val order = local.indices.firstOrNull { local[it] != peerPublic[it] } + val localFirst = order == null || (local[order].toInt() and 255) < (peerPublic[order].toInt() and 255) + val info = if (localFirst) local + peerPublic else peerPublic + local + return try { + relayCryptographyProvider.get(HKDF).secretDerivation( + SHA256, 32.bytes, "OpenBitFun Relay v1.0.0 device key".encodeToByteArray(), info, + ).deriveSecretToByteArray(shared) + } finally { shared.fill(0) } + } +} diff --git a/src/apps/mobile/shared/core-crypto/src/commonTest/kotlin/com/openbitfun/mobile/core/crypto/DeviceIdentityTest.kt b/src/apps/mobile/shared/core-crypto/src/commonTest/kotlin/com/openbitfun/mobile/core/crypto/DeviceIdentityTest.kt new file mode 100644 index 0000000000..f79439df01 --- /dev/null +++ b/src/apps/mobile/shared/core-crypto/src/commonTest/kotlin/com/openbitfun/mobile/core/crypto/DeviceIdentityTest.kt @@ -0,0 +1,19 @@ +package com.openbitfun.mobile.core.crypto + +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertContentEquals +import kotlin.test.assertFailsWith + +class DeviceIdentityTest { + @Test + fun deviceKeysMatchRustAndBrowserVector() = runTest { + val a = ByteArray(32) { 7 } + val b = ByteArray(32) { 11 } + val key = DeviceIdentity.messageKey(a, DeviceIdentity.publicKey(b)) + assertEquals("6e8f5da837e91e9ddb09c5aa7dee229e731fc94499d29d10dcf5f1437193f56c", key.joinToString("") { (it.toInt() and 255).toString(16).padStart(2, '0') }) + assertContentEquals(key, DeviceIdentity.messageKey(b, DeviceIdentity.publicKey(a))) + assertFailsWith { DeviceIdentity.messageKey(a, ByteArray(32)) } + } +} diff --git a/src/apps/mobile/shared/core-feature/src/androidMain/kotlin/com/openbitfun/mobile/core/feature/pairing/PairingStore.android.kt b/src/apps/mobile/shared/core-feature/src/androidMain/kotlin/com/openbitfun/mobile/core/feature/pairing/PairingStore.android.kt deleted file mode 100644 index 83c239899d..0000000000 --- a/src/apps/mobile/shared/core-feature/src/androidMain/kotlin/com/openbitfun/mobile/core/feature/pairing/PairingStore.android.kt +++ /dev/null @@ -1,30 +0,0 @@ -package com.openbitfun.mobile.core.feature.pairing - -import android.content.Context -import com.openbitfun.mobile.core.feature.CoreLog -import com.openbitfun.mobile.core.persistence.androidPersistenceStores -import com.openbitfun.mobile.core.persistence.androidSecureStore -import kotlinx.coroutines.CoroutineScope - -/** - * The store with its cooldown kept in the platform's keystore-backed store. - * - * The app hands over a [Context] rather than a `SecureStore` because everything - * below `:core-feature` is off-limits to it — see the architecture guardrail in - * `scripts/check-mobile-architecture.mjs`. - */ -public fun PairingStore.Companion.create( - scope: CoroutineScope, - context: Context, - device: DeviceIdentity, - log: CoreLog, -): PairingStore { - val persistence = androidPersistenceStores(context.applicationContext, "openbitfun-mobile.db") - return PairingStore.create( - scope = scope, - device = device, - protection = androidSecureStore(context.applicationContext, "pairing_protection"), - log = log, - persistence = persistence, - ) -} diff --git a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/account/AccountDeviceLink.kt b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/account/AccountDeviceLink.kt new file mode 100644 index 0000000000..e396429afb --- /dev/null +++ b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/account/AccountDeviceLink.kt @@ -0,0 +1,19 @@ +package com.openbitfun.mobile.core.feature.account + +import com.openbitfun.mobile.core.transport.accountDeviceLink + +public enum class AccountDeviceLinkStatus { INVALID, SIGN_IN_REQUIRED, UNAVAILABLE, READY } + +public data class AccountDeviceLinkResult(public val status: AccountDeviceLinkStatus, public val deviceId: String?, public val relayUrl: String? = null) + +/** Membership must come from the account signed in at this exact Relay endpoint. */ +public fun resolveAccountDeviceLink(url: String, state: AccountUiState): AccountDeviceLinkResult { + val link = accountDeviceLink(url) + ?: return AccountDeviceLinkResult(AccountDeviceLinkStatus.INVALID, null) + val id = link.deviceId + val ready = (state as? AccountUiState.Ready)?.takeIf { it.relayUrl == link.relayUrl } + ?: return AccountDeviceLinkResult(AccountDeviceLinkStatus.SIGN_IN_REQUIRED, id, link.relayUrl) + return if (ready.devices.any { it.id == id && it.online }) { + AccountDeviceLinkResult(AccountDeviceLinkStatus.READY, id, link.relayUrl) + } else AccountDeviceLinkResult(AccountDeviceLinkStatus.UNAVAILABLE, null) +} diff --git a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/account/AccountStore.kt b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/account/AccountStore.kt index 8674a13f25..c1c8eecccf 100644 --- a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/account/AccountStore.kt +++ b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/account/AccountStore.kt @@ -1,8 +1,6 @@ package com.openbitfun.mobile.core.feature.account -import com.openbitfun.mobile.core.feature.CloudSettingsSource import com.openbitfun.mobile.core.feature.CoreLog -import com.openbitfun.mobile.core.feature.pairing.asTransportLog import com.openbitfun.mobile.core.feature.session.RemoteSessionStore import com.openbitfun.mobile.core.feature.workspace.RemoteWorkspaceStore import com.openbitfun.mobile.core.persistence.MobilePersistenceStores @@ -40,18 +38,15 @@ internal data class AccountSessionData( internal interface AccountBackend { suspend fun login( relayUrl: String, - username: String, - password: String, deviceId: String, deviceName: String, + deviceSecret: ByteArray, + onAuthorization: (String) -> Unit, ): AccountSessionData /** [selfDeviceId] lets the transport drop this device's own row. */ suspend fun listDevices(session: AccountSessionData, selfDeviceId: String): List - /** The account's settings document, or null when it has never synced one. */ - suspend fun fetchSettings(session: AccountSessionData): String? - fun transport(session: AccountSessionData, targetDeviceId: String): RemoteCommandTransport } @@ -66,6 +61,7 @@ public class AccountStore internal constructor( private val _state = MutableStateFlow(AccountUiState.Idle) public val state: StateFlow = _state.asStateFlow() private var session: AccountSessionData? = null + private var selectedRelayUrl: String = com.openbitfun.mobile.core.transport.DEFAULT_CLOUD_RELAY_URL private var work: Job? = null /** Latest account membership snapshot, used to authorize explicit device stores. */ private var controllableDevices: List = emptyList() @@ -73,7 +69,18 @@ public class AccountStore internal constructor( public fun dispatch(intent: AccountIntent) { when (intent) { AccountIntent.Restore -> restore() - is AccountIntent.Login -> login(intent) + AccountIntent.Login -> login() + is AccountIntent.SelectRelay -> { + val endpoint = com.openbitfun.mobile.core.transport.normalizeAccountRelayUrl(intent.relayUrl) ?: return + if (session?.relayUrl != endpoint) { + work?.cancel() + session?.masterKey?.fill(0) + session = null + controllableDevices = emptyList() + selectedRelayUrl = endpoint + _state.value = AccountUiState.SignedOut + } + } is AccountIntent.SelectDevice -> selectDevice(intent.deviceId) AccountIntent.RefreshDevices -> refreshDevices() AccountIntent.Retry -> retryFailedStage() @@ -144,18 +151,6 @@ public class AccountStore internal constructor( return controllableDevices.firstOrNull { it.id == target }?.id } - /** - * A handle another feature can use to read the account's settings document. - * - * Bound to the session that was current when it was asked for, so a handle - * taken before a logout reads that session and not the next one — the caller - * asks again after every sign-in change, and gets null while signed out. - */ - public fun cloudSettingsSource(): CloudSettingsSource? { - val current = session ?: return null - return CloudSettingsSource { backend.fetchSettings(current) } - } - public fun stop() { work?.cancel() work = null @@ -186,6 +181,7 @@ public class AccountStore internal constructor( return@launch } session = restored + selectedRelayUrl = restored.relayUrl try { publishReady(restored, backend.listDevices(restored, deviceId)) } catch (cancelled: CancellationException) { @@ -210,17 +206,26 @@ public class AccountStore internal constructor( } } - private fun login(intent: AccountIntent.Login) { + private fun login() { work?.cancel() _state.value = AccountUiState.SigningIn work = scope.launch { + val deviceSecret = try { + secureStore.read(DEVICE_KEY)?.also { require(it.size == 32) } + ?: (session?.masterKey?.copyOf() ?: CloudAccountClient.generateDeviceSecret()).also { + secureStore.write(DEVICE_KEY, it) + } + } catch (_: Throwable) { + failLogin(AccountFailureReason.SECURE_STORAGE, AccountFailureStage.SECURE_STORAGE) + return@launch + } val loggedIn = try { backend.login( - intent.relayUrl, - intent.username, - intent.password, + selectedRelayUrl, deviceId, deviceName, + deviceSecret, + { url -> _state.value = AccountUiState.Authorizing(url) }, ) } catch (cancelled: CancellationException) { throw cancelled @@ -233,6 +238,8 @@ public class AccountStore internal constructor( // never evidence that secure storage was involved. failLogin(AccountFailureReason.MALFORMED_RESPONSE, AccountFailureStage.AUTHENTICATION) return@launch + } finally { + deviceSecret.fill(0) } controllableDevices = emptyList() @@ -394,17 +401,10 @@ public class AccountStore internal constructor( } } - /** Clears every observable and persisted fact owned by an expired token. */ private fun expireSession(reason: AccountFailureReason, stage: AccountFailureStage) { session = null controllableDevices = emptyList() _state.value = AccountUiState.Failed(reason, false, stage) - try { - secureStore.delete(SESSION_KEY) - } catch (_: Throwable) { - // The in-memory projection is already safe. A storage failure must - // not put stale account devices back on screen. - } } /** @@ -416,6 +416,7 @@ public class AccountStore internal constructor( controllableDevices = AccountDevicePolicy.controlTargets(devices, deviceId) _state.value = AccountUiState.Ready( userId = current.userId, + relayUrl = current.relayUrl, username = current.username, devices = controllableDevices, selectedDeviceId = current.targetDeviceId, @@ -441,7 +442,8 @@ public class AccountStore internal constructor( ) } - private const val SESSION_KEY = "cloud_account_session" + private const val DEVICE_KEY = "relay_device_private_key_v1" + private const val SESSION_KEY = "github_device_session_v1" private val JSON = Json { ignoreUnknownKeys = true } private fun encodeRecord(session: AccountSessionData): String = JSON.encodeToString( @@ -477,15 +479,27 @@ private class CloudBackend( ) : AccountBackend { override suspend fun login( relayUrl: String, - username: String, - password: String, deviceId: String, deviceName: String, + deviceSecret: ByteArray, + onAuthorization: (String) -> Unit, ): AccountSessionData { - val session = client.login(relayUrl, username, password, deviceId, deviceName) + val start = client.startAuthorization(relayUrl) + onAuthorization(start.authorizationUrl) + var accessToken: String? = null + while (kotlin.time.Clock.System.now().epochSeconds < start.expiresAt) { + kotlinx.coroutines.delay(start.pollIntervalSeconds.coerceIn(1, 30) * 1000L) + val poll = client.pollAuthorization(relayUrl, start) + if (poll.status == "authorized") { + accessToken = poll.tokens?.accessToken + break + } + if (poll.status == "expired" || poll.status == "denied") break + } + val session = client.login(relayUrl, accessToken ?: throw CloudAccountException(CloudAccountFailure.AUTHENTICATION), deviceId, deviceName, deviceSecret) return AccountSessionData( - relayUrl = relayUrl.trim().ifEmpty { com.openbitfun.mobile.core.transport.DEFAULT_CLOUD_RELAY_URL }, - username = username.trim(), + relayUrl = relayUrl, + username = session.userId, token = session.token, userId = session.userId, masterKey = session.masterKey, @@ -497,9 +511,6 @@ private class CloudBackend( override suspend fun listDevices(session: AccountSessionData, selfDeviceId: String): List = client.listDevices(session.relayUrl, session.toTransportSession(), selfDeviceId).map { it.toUi() } - override suspend fun fetchSettings(session: AccountSessionData): String? = - client.fetchSettings(session.relayUrl, session.toTransportSession())?.plaintext - override fun transport(session: AccountSessionData, targetDeviceId: String): RemoteCommandTransport = AccountDeviceCommandTransport(client, session.relayUrl, session.toTransportSession(), targetDeviceId, log) diff --git a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/account/AccountUiState.kt b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/account/AccountUiState.kt index 6728b5b279..267e7c3b9d 100644 --- a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/account/AccountUiState.kt +++ b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/account/AccountUiState.kt @@ -30,8 +30,10 @@ public sealed interface AccountUiState { public data object Restoring : AccountUiState public data object SignedOut : AccountUiState public data object SigningIn : AccountUiState + public data class Authorizing(public val authorizationUrl: String) : AccountUiState public data class Ready public constructor( public val userId: String, + public val relayUrl: String, /** * The name this session signed in under. * @@ -59,10 +61,11 @@ public sealed interface AccountUiState { public constructor( userId: String, username: String, + relayUrl: String = com.openbitfun.mobile.core.transport.DEFAULT_CLOUD_RELAY_URL, devices: List, selectedDeviceId: String?, selectedDeviceName: String?, - ) : this(userId, username, devices, selectedDeviceId, selectedDeviceName, false, null) + ) : this(userId, relayUrl, username, devices, selectedDeviceId, selectedDeviceName, false, null) } public data class Failed public constructor( public val reason: AccountFailureReason, @@ -73,13 +76,8 @@ public sealed interface AccountUiState { public sealed interface AccountIntent { public data object Restore : AccountIntent - public data class Login public constructor( - public val relayUrl: String, - public val username: String, - public val password: String, - ) : AccountIntent { - override fun toString(): String = "Login(relayUrl=, username=$username, password=)" - } + public data object Login : AccountIntent + public data class SelectRelay(public val relayUrl: String) : AccountIntent public data class SelectDevice public constructor(public val deviceId: String) : AccountIntent /** diff --git a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/account/MobileDeviceIdentity.kt b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/account/MobileDeviceIdentity.kt new file mode 100644 index 0000000000..b6af367e32 --- /dev/null +++ b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/account/MobileDeviceIdentity.kt @@ -0,0 +1,4 @@ +package com.openbitfun.mobile.core.feature.account + +/** Random installation identity; never a hardware identifier. */ +public data class MobileDeviceIdentity(val installId: String, val displayName: String) diff --git a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/account/TransportLogAdapter.kt b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/account/TransportLogAdapter.kt new file mode 100644 index 0000000000..833b5f9a13 --- /dev/null +++ b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/account/TransportLogAdapter.kt @@ -0,0 +1,10 @@ +package com.openbitfun.mobile.core.feature.account + +import com.openbitfun.mobile.core.feature.CoreLog +import com.openbitfun.mobile.core.transport.TransportLog + +internal fun CoreLog.asTransportLog(): TransportLog = object : TransportLog { + override fun info(message: String) = this@asTransportLog.info(message) + override fun warn(message: String) = this@asTransportLog.warn(message) + override fun error(message: String) = this@asTransportLog.error(message) +} diff --git a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/connection/ConnectionStatus.kt b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/connection/ConnectionStatus.kt index 5cfab1d525..fc0b1c3206 100644 --- a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/connection/ConnectionStatus.kt +++ b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/connection/ConnectionStatus.kt @@ -1,7 +1,5 @@ package com.openbitfun.mobile.core.feature.connection -import com.openbitfun.mobile.core.feature.pairing.ConnectionLiveness -import com.openbitfun.mobile.core.feature.pairing.PairingUiState /** * How far the link to a desktop has got. @@ -86,26 +84,3 @@ public object ConnectionStatusPresenter { public fun canReachSessions(phase: ConnectionPhase): Boolean = phase == ConnectionPhase.CONNECTED || phase == ConnectionPhase.RECONNECTING } - -/** - * The phase a pairing state implies. - * - * A paired room reports its own liveness, so all three of [ConnectionPhase]'s - * live values come from here: an announced health check is [ConnectionPhase.RECONNECTING] - * and a failed one is [ConnectionPhase.FAILED]. The latter is still a *paired* - * state — the room and its key are intact, which is why the shell keeps showing - * the session list under an error heading rather than dropping back to the form. - * What is still absent is an automatic re-pair (§11.11): an account room's - * password is never persisted, so the user has to do that one by hand. - */ -public fun PairingUiState.connectionPhase(): ConnectionPhase = when (this) { - PairingUiState.Idle -> ConnectionPhase.IDLE - PairingUiState.Connecting -> ConnectionPhase.CONNECTING - is PairingUiState.Paired -> when (liveness) { - ConnectionLiveness.LIVE -> ConnectionPhase.CONNECTED - ConnectionLiveness.CHECKING -> ConnectionPhase.RECONNECTING - ConnectionLiveness.LOST -> ConnectionPhase.FAILED - } - - is PairingUiState.Failed -> ConnectionPhase.FAILED -} diff --git a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/connection/RemoteControlPresentation.kt b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/connection/RemoteControlPresentation.kt index 9d244d42a9..6226cc99a1 100644 --- a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/connection/RemoteControlPresentation.kt +++ b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/connection/RemoteControlPresentation.kt @@ -1,20 +1,10 @@ package com.openbitfun.mobile.core.feature.connection -/** - * Where the desktop currently being driven came from. - * - * Ports `controlTargetType` in `RemoteControlSettingsSheet.ets`, which is - * `'none' | 'room' | 'account_device'`. A shell shows it as a badge under the - * card, so the user can tell a one-off pairing from a device the account owns - * without opening either screen. - */ +/** The account directory owns remote device selection. */ public enum class RemoteControlSource { /** Nothing is paired and no account device is selected. */ NONE, - /** A room reached through a pairing link, which the desktop showed as a QR code. */ - QR_PAIRING, - /** A desktop registered to the signed-in account. */ ACCOUNT_DEVICE, } @@ -48,46 +38,11 @@ public data class RemoteControlSummary public constructor( /** Ported from the `connectionTitle` / `connectionSource` / `ConnectionAction` trio. */ public object RemoteControlPresenter { - /** - * Reduces the two ways this app can be driving a desktop into the one card - * that describes it. - * - * A paired room wins over an account device when both exist. They are - * separate stores here — unlike the source, which keeps a single control - * target — and a room is the more deliberate of the two: it was pasted or - * scanned for this session, while a selected device outlives every sign-in. - * - * @param pairedRoomLabel the already-truncated room label, or `""`. The full - * room id never crosses this seam. - * @param accountDeviceName may be blank for a device the relay only knows by - * id, in which case the id is what the card can name. - * @param accountPhase the selected device store's latest real command/poll - * phase; selection alone is not evidence that the device is reachable. - */ public fun summarize( - pairingPhase: ConnectionPhase, - pairedRoomLabel: String, accountDeviceId: String, accountDeviceName: String, accountPhase: ConnectionPhase, ): RemoteControlSummary = when { - pairedRoomLabel.isNotBlank() -> RemoteControlSummary( - source = RemoteControlSource.QR_PAIRING, - desktopName = pairedRoomLabel, - phase = pairingPhase, - action = when (pairingPhase) { - ConnectionPhase.CONNECTED, - ConnectionPhase.CONNECTING, - ConnectionPhase.RECONNECTING, - -> RemoteControlAction.DISCONNECT - - ConnectionPhase.FAILED, - ConnectionPhase.DISCONNECTED, - ConnectionPhase.IDLE, - -> RemoteControlAction.RECONNECT - }, - ) - accountDeviceId.isNotBlank() -> RemoteControlSummary( source = RemoteControlSource.ACCOUNT_DEVICE, desktopName = accountDeviceName.ifBlank { accountDeviceId }, diff --git a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/pairing/PairingLinkHints.kt b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/pairing/PairingLinkHints.kt deleted file mode 100644 index 16cf09ab4a..0000000000 --- a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/pairing/PairingLinkHints.kt +++ /dev/null @@ -1,31 +0,0 @@ -package com.openbitfun.mobile.core.feature.pairing - -import com.openbitfun.mobile.core.transport.RelayDescriptorParser - -/** - * What a pairing link says about the form around it. - * - * Ports `RemotePairingPolicy.projection`. A half-typed link is an ordinary - * state, not an error, so this never throws — the screen calls it on every - * keystroke to decide whether to show the password field. - */ -public data class PairingLinkHints( - val requiresAccount: Boolean, - val suggestedUserId: String, -) { - public companion object { - public val None: PairingLinkHints = PairingLinkHints( - requiresAccount = false, - suggestedUserId = "", - ) - } -} - -/** Never throws; an unparseable link yields [PairingLinkHints.None]. */ -public fun inspectPairingLink(url: String): PairingLinkHints { - if (url.isBlank()) return PairingLinkHints.None - return PairingLinkHints( - requiresAccount = RelayDescriptorParser.accountAuthRequired(url), - suggestedUserId = RelayDescriptorParser.accountUsername(url), - ) -} diff --git a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/pairing/PairingStore.kt b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/pairing/PairingStore.kt deleted file mode 100644 index c4be82c452..0000000000 --- a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/pairing/PairingStore.kt +++ /dev/null @@ -1,379 +0,0 @@ -package com.openbitfun.mobile.core.feature.pairing - -import com.openbitfun.mobile.core.feature.CoreLog -import com.openbitfun.mobile.core.feature.session.RemoteSessionStore -import com.openbitfun.mobile.core.feature.session.RemoteSessionUiState -import com.openbitfun.mobile.core.feature.workspace.RemoteWorkspaceStore -import com.openbitfun.mobile.core.persistence.MobilePersistenceStores -import com.openbitfun.mobile.core.persistence.SecureStore -import com.openbitfun.mobile.core.protocol.CommandStatusResponse -import com.openbitfun.mobile.core.protocol.RemoteCommand -import com.openbitfun.mobile.core.transport.PairIdentity -import com.openbitfun.mobile.core.transport.PairedRoom -import com.openbitfun.mobile.core.transport.RelayDescriptor -import com.openbitfun.mobile.core.transport.RelayDescriptorException -import com.openbitfun.mobile.core.transport.RelayDescriptorParser -import com.openbitfun.mobile.core.transport.RelayDescriptorProblem -import com.openbitfun.mobile.core.transport.RelayFailure -import com.openbitfun.mobile.core.transport.RelayPairing -import com.openbitfun.mobile.core.transport.RelayTransportException -import com.openbitfun.mobile.core.transport.TransportLog -import com.openbitfun.mobile.core.transport.relayPairing -import com.openbitfun.mobile.core.transport.send -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch - -/** - * The pairing screen, as state. - * - * Holding [PairedRoom] here rather than handing it out is the point of the - * module boundary: the transport, the descriptor and the shared key stay behind - * the seam, and the screen sees a [PairedWorkspace]. When the session feature - * lands it takes the room from here directly, still without the app touching it. - */ -public class PairingStore internal constructor( - private val scope: CoroutineScope, - private val device: DeviceIdentity, - private val pairing: RelayPairing, - private val log: CoreLog, - private val protection: UserIdProtection, - private val persistence: MobilePersistenceStores? = null, -) { - private val _state = MutableStateFlow(PairingUiState.Idle) - public val state: StateFlow = _state.asStateFlow() - - private var inFlight: Job? = null - private var heartbeat: Job? = null - private var healthCheck: Job? = null - - /** - * Whether a surface is currently on screen. - * - * Kept rather than inferred so the order of `Foreground` and a successful - * pair does not matter: pairing while foregrounded starts the heartbeat, and - * foregrounding while paired does too. - */ - private var foregrounded: Boolean = false - - /** The paired room, once there is one. Consumed by the session feature. */ - internal var room: PairedRoom? = null - private set - - /** - * The session store built over [room], kept only to read its busy flag. - * - * Ports the `isBusy()` guard in `RemoteActivityViewModel.checkConnectionHealth`: - * a silent tick that lands behind a long-running command would time out on - * the queue rather than on the link, and report a busy desktop as a lost one. - */ - private var sessionStore: RemoteSessionStore? = null - - /** Builds the session feature without exposing the paired transport to UI. */ - public fun createSessionStore(scope: CoroutineScope): RemoteSessionStore? = - room?.let { - RemoteSessionStore.create( - scope, - it, - deviceKey = it.descriptor.roomId, - persistence = persistence, - ).also { store -> sessionStore = store } - } - - /** Builds workspace and file-preview features over the same paired transport. */ - public fun createWorkspaceStore(scope: CoroutineScope): RemoteWorkspaceStore? = - room?.let { RemoteWorkspaceStore.create(scope, it.transport) } - - public fun dispatch(intent: PairingIntent) { - when (intent) { - is PairingIntent.Submit -> submit(intent) - PairingIntent.Dismiss -> if (_state.value is PairingUiState.Failed) { - _state.value = PairingUiState.Idle - } - - PairingIntent.Disconnect -> { - inFlight?.cancel() - inFlight = null - stopHeartbeat() - sessionStore = null - room = null - _state.value = PairingUiState.Idle - } - - PairingIntent.Foreground -> { - foregrounded = true - startHeartbeat() - // The user just came back to a screen that may have been away for - // hours, so this one is announced: a blank pause reads as a hang. - checkHealth(announce = true) - } - - PairingIntent.Background -> { - foregrounded = false - stopHeartbeat() - } - - PairingIntent.Verify -> checkHealth(announce = true) - } - } - - private fun submit(intent: PairingIntent.Submit) { - if (!_state.value.acceptsSubmit) return - - // Before the link is even parsed, as in `RemoteConnectionController.connect`: - // a cooldown is about how often credentials may be tried, so it must not - // be something a differently-malformed link can step around. - val locked = protection.lockedSeconds() - if (locked > 0) { - fail(PairingFailure(PairingFailureReason.TooManyAttempts, null, null, locked)) - return - } - - // Everything that can be decided without the network is decided before - // the spinner appears, so a malformed link fails instantly instead of - // after a connect timeout. - val descriptor = try { - RelayDescriptorParser.parse(intent.pairingUrl) - } catch (cause: RelayDescriptorException) { - fail(PairingFailure(cause.problem.asReason())) - return - } - - val identity = identityFor(descriptor, intent) ?: return - - _state.value = PairingUiState.Connecting - inFlight = scope.launch { - try { - val paired = pairing.pair( - descriptor = descriptor, - deviceId = device.installId, - deviceName = device.displayName, - identity = identity, - ) - room = paired - protection.recordSuccess() - _state.value = PairingUiState.Paired(paired.asWorkspace()) - startHeartbeat() - } catch (cause: CancellationException) { - throw cause - } catch (cause: RelayTransportException) { - fail(cause.failure.asFailure()) - } - } - } - - /** - * Ports `RemotePairingPolicy.identityForConnect`. Auto-reconnect is absent - * because nothing reconnects yet; when it lands it must still refuse an - * account room, since the password is never persisted. - */ - private fun identityFor( - descriptor: RelayDescriptor, - intent: PairingIntent.Submit, - ): PairIdentity? { - if (!descriptor.accountAuth) { - // An empty user id means "this device", which is what the desktop - // shows in its device list. - return PairIdentity(userId = intent.userId.trim().ifEmpty { device.installId }) - } - - val username = intent.userId.trim().ifEmpty { descriptor.accountUsername.trim() } - if (username.isEmpty()) { - fail(PairingFailure(PairingFailureReason.AccountUsernameRequired)) - return null - } - if (intent.password.isEmpty()) { - fail(PairingFailure(PairingFailureReason.AccountPasswordRequired)) - return null - } - return PairIdentity(userId = username, password = intent.password) - } - - /** - * Reports a refusal, and counts it if it was a peer refusing credentials. - * - * The failure that trips the cooldown is *replaced* rather than annotated: - * at that point the reason the user has to act on is the wait, and the - * refusal that caused it is the same one they have already been shown twice. - */ - private fun fail(failure: PairingFailure) { - val lockedFor = if (failure.reason.countsTowardLockout()) protection.recordFailure() else 0 - val reported = if (lockedFor > 0) { - PairingFailure(PairingFailureReason.TooManyAttempts, null, null, lockedFor) - } else { - failure - } - log.warn("pair failed reason=${reported.reason}") - _state.value = PairingUiState.Failed(reported) - } - - /** - * Ports `RemoteHeartbeatController`: one timer, started when a paired surface - * is on screen and stopped when it leaves. - * - * The interval is the source's default and the ping's timeout is deliberately - * shorter, so a tick can never still be in flight when the next one is due. - */ - private fun startHeartbeat() { - if (!foregrounded || _state.value !is PairingUiState.Paired) return - if (heartbeat?.isActive == true) return - heartbeat = scope.launch { - while (isActive) { - delay(HEARTBEAT_INTERVAL_MS) - if (sessionIsBusy()) continue - // Silent: a tick the user did not ask for should not flicker the - // status dot on its way to the same answer as last time. - runHealthCheck(announce = false) - } - } - } - - private fun stopHeartbeat() { - heartbeat?.cancel() - heartbeat = null - healthCheck?.cancel() - healthCheck = null - } - - private fun checkHealth(announce: Boolean) { - if (_state.value !is PairingUiState.Paired) return - if (healthCheck?.isActive == true) return - healthCheck = scope.launch { runHealthCheck(announce) } - } - - /** - * One `ping`, and what its outcome means for [PairingUiState.Paired.liveness]. - * - * A failure is not a pairing failure: the room stays, so the state stays - * [PairingUiState.Paired] and only its liveness moves. Dropping to the - * connect form here would throw away a link that a retry ten seconds later - * may well find alive. - */ - private suspend fun runHealthCheck(announce: Boolean) { - val transport = room?.transport ?: return - if (announce) setLiveness(ConnectionLiveness.CHECKING) - try { - transport.send( - command = RemoteCommand(cmd = "ping"), - timeoutMs = HEARTBEAT_TIMEOUT_MS, - ) - setLiveness(ConnectionLiveness.LIVE) - } catch (cause: CancellationException) { - throw cause - } catch (cause: RelayTransportException) { - // The failure's own class, not its message: a `RemoteRejected` carries - // text the desktop wrote, which belongs on screen and not in a log. - log.warn("heartbeat failed reason=${cause.failure::class.simpleName}") - setLiveness(ConnectionLiveness.LOST) - } - } - - private fun setLiveness(liveness: ConnectionLiveness) { - val paired = _state.value as? PairingUiState.Paired ?: return - if (paired.liveness != liveness) _state.value = paired.copy(liveness = liveness) - } - - /** See [sessionStore]. `Loading` is the first list arriving, which is a command too. */ - private fun sessionIsBusy(): Boolean = when (val session = sessionStore?.state?.value) { - RemoteSessionUiState.Loading -> true - is RemoteSessionUiState.Ready -> session.busy - else -> false - } - - private fun PairedRoom.asWorkspace() = PairedWorkspace( - roomLabel = descriptor.roomId.take(ROOM_LABEL_LENGTH), - projectName = initialSync.projectName, - hasWorkspace = initialSync.hasWorkspace == true, - authenticatedUserId = initialSync.authenticatedUserId, - ) - - public companion object { - /** Matches the transport's own log truncation, so the two agree on screen. */ - private const val ROOM_LABEL_LENGTH = 8 - - /** `RemoteHeartbeatController`'s own default. */ - private const val HEARTBEAT_INTERVAL_MS = 15_000L - - /** Under the interval on purpose — see [startHeartbeat]. */ - private const val HEARTBEAT_TIMEOUT_MS = 10_000L - - /** - * Builds a store over the platform's default HTTP engine — OkHttp on - * Android, Darwin on iOS, both pulled in by `:core-transport`. - * - * @param protection where the credential cooldown is kept across - * launches. See [UserIdProtection]; a store that forgets on restart is - * not a cooldown. - */ - public fun create( - scope: CoroutineScope, - device: DeviceIdentity, - protection: SecureStore, - log: CoreLog, - persistence: MobilePersistenceStores?, - ): PairingStore = PairingStore( - scope = scope, - device = device, - pairing = relayPairing(log.asTransportLog()), - log = log, - protection = UserIdProtection(protection), - persistence = persistence, - ) - - /** - * The same store with logging off. An overload rather than a default - * argument: Kotlin defaults do not survive into Swift, so an iOS caller - * would be forced to name a logger it does not want — see the design - * doc §4.1. - */ - public fun create( - scope: CoroutineScope, - device: DeviceIdentity, - protection: SecureStore, - ): PairingStore = create(scope, device, protection, CoreLog.None, null) - } -} - -private fun RelayDescriptorProblem.asReason(): PairingFailureReason = when (this) { - RelayDescriptorProblem.Empty -> PairingFailureReason.PairingLinkEmpty - RelayDescriptorProblem.MissingParameters -> PairingFailureReason.PairingLinkIncomplete - RelayDescriptorProblem.UndecodableQuery -> PairingFailureReason.PairingLinkUndecodable -} - -/** - * `MalformedResponse` covers two situations the transport cannot tell apart but - * the user can act on differently, so it stays one reason here and the screen - * says so: either the link's key is unusable, or the peer answered with - * something that is not the envelope. The former is caught before any request, - * so by the time a [RelayTransportException] carries it, only the latter is left. - */ -private fun RelayFailure.asFailure(): PairingFailure = when (this) { - RelayFailure.PairRejected -> PairingFailure(PairingFailureReason.Rejected) - RelayFailure.RoomNotFound -> PairingFailure(PairingFailureReason.RoomNotFound) - RelayFailure.Timeout -> PairingFailure(PairingFailureReason.Timeout) - RelayFailure.RateLimited -> PairingFailure(PairingFailureReason.RateLimited) - RelayFailure.NetworkUnreachable -> PairingFailure(PairingFailureReason.NetworkUnreachable) - RelayFailure.MalformedResponse -> PairingFailure(PairingFailureReason.ProtocolMismatch) - is RelayFailure.RelayUnavailable -> - PairingFailure(PairingFailureReason.RelayUnavailable, remoteMessage = null, statusCode = statusCode) - - is RelayFailure.UnexpectedStatus -> - PairingFailure(PairingFailureReason.RelayUnavailable, remoteMessage = null, statusCode = statusCode) - - is RelayFailure.RemoteRejected -> - PairingFailure(PairingFailureReason.DesktopRejected, remoteMessage = message, statusCode = null) -} - -internal fun CoreLog.asTransportLog(): TransportLog = object : TransportLog { - override fun info(message: String) = this@asTransportLog.info(message) - - override fun warn(message: String) = this@asTransportLog.warn(message) - - override fun error(message: String) = this@asTransportLog.error(message) -} diff --git a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/pairing/PairingUiState.kt b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/pairing/PairingUiState.kt deleted file mode 100644 index 69f8d04f68..0000000000 --- a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/pairing/PairingUiState.kt +++ /dev/null @@ -1,257 +0,0 @@ -package com.openbitfun.mobile.core.feature.pairing - -/** - * How this install identifies itself to the desktop. - * - * [installId] must be stable across launches — the desktop de-duplicates - * devices by it — and must not be a hardware identifier. The app generates one - * on first run and keeps it. - */ -public data class DeviceIdentity( - val installId: String, - val displayName: String, -) - -/** - * What the desktop reported once the handshake completed. - * - * This is deliberately not `InitialSyncResponse`: wire DTOs stay behind the - * seam, so a defensive field the protocol layer added for a lenient server - * cannot leak into a screen. - */ -public data class PairedWorkspace( - /** Already truncated. The full room id never crosses this seam. */ - val roomLabel: String, - val projectName: String?, - val hasWorkspace: Boolean, - val authenticatedUserId: String?, -) - -/** - * Why pairing did not complete, as a cause rather than a sentence. - * - * The app owns every user-facing string; this enum is what it switches on. The - * split is by what the user can *do* next, which is why 401 and 403 collapse - * into [Rejected] while 404 stays separate: one means "check your credentials", - * the other means "the desktop is not listening on that room any more". - */ -public enum class PairingFailureReason { - /** No pairing link was entered. */ - PairingLinkEmpty, - - /** The link parsed but carried no room id or no public key. */ - PairingLinkIncomplete, - - /** The link's query string could not be decoded. */ - PairingLinkUndecodable, - - /** The link's public key is not a usable X25519 point. */ - PairingLinkKeyUnusable, - - /** The room advertises `auth=account` and no username could be resolved. */ - AccountUsernameRequired, - - /** The room advertises `auth=account` and no password was supplied. */ - AccountPasswordRequired, - - /** The relay refused the pair request (401 / 403). */ - Rejected, - - /** No such room on the relay (404). The desktop is probably not sharing. */ - RoomNotFound, - - /** The relay is throttling this client (429). */ - RateLimited, - - /** The relay itself failed (5xx). */ - RelayUnavailable, - - /** The request never reached the relay. */ - NetworkUnreachable, - - /** The relay or the desktop did not answer in time. */ - Timeout, - - /** - * A 200 that was not the encrypted envelope, or an envelope that would not - * decrypt — a captive portal, or a desktop on an incompatible version. - */ - ProtocolMismatch, - - /** The handshake reached the desktop and the desktop said no. */ - DesktopRejected, - - /** - * Too many credentials were refused in a row, so pairing is on a cooldown. - * - * Ports the `MAX_FAILED_USER_ID_ATTEMPTS` / `USER_ID_LOCKOUT_MS` pair in - * `ConnectionErrorPolicy`. The wait is in - * [PairingFailure.retryAfterSeconds] — this is the one reason whose sentence - * needs a number in it. - */ - TooManyAttempts, -} - -/** - * @param remoteMessage verbatim text from the desktop, present only for - * [PairingFailureReason.DesktopRejected]. It is not localized here and cannot - * be — it was written by the peer. Apps show it as supporting detail under - * their own heading for the reason, never as the whole message. - * @param retryAfterSeconds how long the cooldown still has to run, for - * [PairingFailureReason.TooManyAttempts] and `0` for everything else. Seconds - * rather than a deadline: a screen shows a duration, and an instant would make - * every caller do the same subtraction against the same clock. - */ -public data class PairingFailure( - val reason: PairingFailureReason, - val remoteMessage: String?, - val statusCode: Int?, - val retryAfterSeconds: Int, -) { - /** - * Most reasons carry no detail. A secondary constructor rather than a - * default argument, because Swift does not see Kotlin defaults — see the - * design doc §4.1. - */ - public constructor(reason: PairingFailureReason) : this(reason, null, null, 0) - - /** Everything the relay can say, none of which is a cooldown. */ - public constructor( - reason: PairingFailureReason, - remoteMessage: String?, - statusCode: Int?, - ) : this(reason, remoteMessage, statusCode, 0) - - /** - * Whether the pairing link itself is what needs fixing. - * - * Ports `ConnectionErrorResult.shouldShowRemoteUrlInput`, which is the - * `expired_room` / `invalid_link` half of `failureKindFor`. A screen that - * hides the link behind a scan button — as both clients do — has to put it - * back on screen for exactly these, and only these: nothing the user can - * type will help with a relay outage, and re-opening the field would only - * suggest the link were at fault. - * - * [PairingFailureReason.Rejected] is deliberately not here. The ArkTS - * source folds its `pairRejected` into `expired_room`, but only after - * `protectedUserIdError` has already claimed the credential cases; here a - * 401/403 *is* the credential case, so it counts toward the cooldown - * instead — see [PairingFailureReason.TooManyAttempts]. - */ - public val reopensLinkInput: Boolean - get() = when (reason) { - PairingFailureReason.PairingLinkEmpty, - PairingFailureReason.PairingLinkIncomplete, - PairingFailureReason.PairingLinkUndecodable, - PairingFailureReason.PairingLinkKeyUnusable, - PairingFailureReason.RoomNotFound, - PairingFailureReason.ProtocolMismatch, - -> true - - else -> false - } -} - -/** - * Whether the paired desktop is still answering. - * - * Ported from what `RemoteActivityViewModel.checkConnectionHealth` does with its - * `connectionState`: a room that stops replying is not the same as no room at - * all, so the pairing survives and only its liveness changes. [LOST] is - * therefore recoverable — the room, its key and its transport are all still - * here, and one successful ping puts it back to [LIVE]. - */ -public enum class ConnectionLiveness { - /** The last check succeeded. */ - LIVE, - - /** A check the user was told about is in flight. */ - CHECKING, - - /** The last check failed. Nothing was torn down; a retry may still succeed. */ - LOST, -} - -/** The pairing screen's entire state. */ -public sealed interface PairingUiState { - /** Whether a submit would be accepted right now. */ - public val acceptsSubmit: Boolean - - public data object Idle : PairingUiState { - override val acceptsSubmit: Boolean get() = true - } - - public data object Connecting : PairingUiState { - override val acceptsSubmit: Boolean get() = false - } - - public data class Paired( - val workspace: PairedWorkspace, - val liveness: ConnectionLiveness, - ) : PairingUiState { - /** - * A pairing that has just completed, which is live by definition — the - * handshake was the round trip. A secondary constructor rather than a - * default argument, because Swift does not see Kotlin defaults. - */ - public constructor(workspace: PairedWorkspace) : this(workspace, ConnectionLiveness.LIVE) - - override val acceptsSubmit: Boolean get() = false - } - - public data class Failed(val failure: PairingFailure) : PairingUiState { - override val acceptsSubmit: Boolean get() = true - } -} - -/** Everything the pairing screen can ask for. */ -public sealed interface PairingIntent { - /** - * @param password only read for rooms that advertise `auth=account`; it is - * forwarded into one encrypted command and never retained or logged. - */ - public data class Submit( - val pairingUrl: String, - val userId: String, - val password: String, - ) : PairingIntent { - /** - * A link with no credentials, which is every room that does not - * advertise `auth=account`. Spelled as a constructor rather than as - * default arguments so Swift sees both forms — see the design doc §4.1. - */ - public constructor(pairingUrl: String) : this(pairingUrl, "", "") - - override fun toString(): String = - "Submit(pairingUrl=, userId=$userId, " + - "password=${if (password.isEmpty()) "absent" else "redacted"})" - } - - /** Clears a failure so the form is editable again. */ - public data object Dismiss : PairingIntent - - /** Abandons the paired room and returns to the form. */ - public data object Disconnect : PairingIntent - - /** - * The surface came to the front: start the heartbeat and check the link once, - * visibly. Ports `RemoteActivityViewModel.resume`. - */ - public data object Foreground : PairingIntent - - /** - * The surface went away: stop the heartbeat. Nothing else is torn down — the - * room outlives a trip to the home screen, and the next [Foreground] says - * whether it is still answering. - */ - public data object Background : PairingIntent - - /** - * Check the link now and show that it is being checked. - * - * This is a health check, not a re-pair: an account room's password is never - * persisted, so a room that is genuinely gone has to be paired again by hand - * rather than silently behind the user's back. - */ - public data object Verify : PairingIntent -} diff --git a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/pairing/UserIdProtection.kt b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/pairing/UserIdProtection.kt deleted file mode 100644 index 9214f8ff9e..0000000000 --- a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/pairing/UserIdProtection.kt +++ /dev/null @@ -1,117 +0,0 @@ -package com.openbitfun.mobile.core.feature.pairing - -import com.openbitfun.mobile.core.persistence.SecureStore -import kotlin.time.Clock - -/** - * The consecutive-failure cooldown on pairing credentials. - * - * Ports the `MAX_FAILED_USER_ID_ATTEMPTS` / `USER_ID_LOCKOUT_MS` half of - * `ConnectionErrorPolicy` together with `MobileIdentityStore.saveUserIdProtection`. - * A desktop that protects its user id is the thing being guarded here: without a - * cooldown, a device that can reach the relay can try user ids as fast as it can - * send requests. - * - * **It is persisted, and that is the whole point.** A counter that lives only in - * memory is defeated by force-quitting the app between attempts, which is not a - * defence at all. [SecureStore] is the store because it is the one that is not a - * plaintext preference a rooted shell can reset with a single `am` command — - * this is state about a defence, not a secret, but it deserves the same tamper - * resistance. - * - * An expired lock clears the count as well, so this is a cooldown rather than a - * ratchet: waiting it out gives the *user* their attempts back too, and a - * permanent lock on a device that has no other way in would be worse than the - * attack it prevents. - */ -internal class UserIdProtection( - private val store: SecureStore?, - private val now: () -> Long = { Clock.System.now().toEpochMilliseconds() }, -) { - private var failureCount: Int = 0 - private var lockedUntil: Long = 0 - private var restored: Boolean = false - - /** How much of the cooldown is left, or `0` when pairing may be attempted. */ - fun lockedSeconds(): Int { - restore() - // A run in progress is not an expired lock. Reading `lockedUntil == 0` - // as "the cooldown is over" would clear the count on the way into every - // attempt, and the third failure would never be the third. - if (lockedUntil == 0L) return 0 - val remaining = lockedUntil - now() - if (remaining <= 0) { - // Expired: forget the attempts that produced it, so the next three - // are the user's again. - clear() - return 0 - } - // Rounded up and never zero: "try again in 0 seconds" next to a button - // that refuses is worse than saying nothing. - return ((remaining + MILLIS_PER_SECOND - 1) / MILLIS_PER_SECOND).toInt() - } - - /** - * Counts one refused credential. - * - * @return the cooldown this failure started, in seconds, or `0` if there are - * attempts left. - */ - fun recordFailure(): Int { - restore() - failureCount += 1 - lockedUntil = if (failureCount >= MAX_FAILED_ATTEMPTS) now() + LOCKOUT_MS else 0 - persist() - return if (lockedUntil > 0) (LOCKOUT_MS / MILLIS_PER_SECOND).toInt() else 0 - } - - /** A pairing succeeded, so the run of failures is over. */ - fun recordSuccess() { - restore() - if (failureCount != 0 || lockedUntil != 0L) clear() - } - - private fun restore() { - if (restored) return - restored = true - val stored = store?.read(KEY)?.decodeToString()?.split(SEPARATOR) ?: return - if (stored.size != 2) return - failureCount = stored[0].toIntOrNull() ?: 0 - lockedUntil = stored[1].toLongOrNull() ?: 0 - } - - private fun persist() { - store?.write(KEY, "$failureCount$SEPARATOR$lockedUntil".encodeToByteArray()) - } - - private fun clear() { - failureCount = 0 - lockedUntil = 0 - store?.delete(KEY) - } - - private companion object { - /** `ConnectionErrorPolicy.MAX_FAILED_USER_ID_ATTEMPTS`. */ - const val MAX_FAILED_ATTEMPTS = 3 - - /** `ConnectionErrorPolicy.USER_ID_LOCKOUT_MS`. */ - const val LOCKOUT_MS = 60_000L - - const val MILLIS_PER_SECOND = 1_000L - - const val KEY = "user_id_protection" - const val SEPARATOR = ":" - } -} - -/** - * Whether a refusal is the desktop or relay saying "not with those credentials". - * - * Only a peer's refusal counts. The ArkTS `protectedUserIdError` also matches - * its own `Missing password` / `Missing username` messages, which are raised - * before anything is sent — locking someone out of their own app for tapping - * Connect on an empty field guards nothing, because no attempt ever left the - * device. - */ -internal fun PairingFailureReason.countsTowardLockout(): Boolean = - this == PairingFailureReason.Rejected || this == PairingFailureReason.DesktopRejected diff --git a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/session/RemoteSessionFailureMapping.kt b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/session/RemoteSessionFailureMapping.kt index f7289f9aa4..a30713664b 100644 --- a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/session/RemoteSessionFailureMapping.kt +++ b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/session/RemoteSessionFailureMapping.kt @@ -26,8 +26,8 @@ internal fun remoteSessionFailure(error: Throwable): RemoteSessionUiState.Failed RelayFailure.NetworkUnreachable -> RemoteSessionUiState.Failed(RemoteSessionFailureReason.NETWORK) RelayFailure.RateLimited -> RemoteSessionUiState.Failed(RemoteSessionFailureReason.RATE_LIMITED) RelayFailure.MalformedResponse -> RemoteSessionUiState.Failed(RemoteSessionFailureReason.PROTOCOL_MISMATCH) - RelayFailure.PairRejected, - RelayFailure.RoomNotFound, + RelayFailure.AuthenticationRequired, + RelayFailure.DeviceNotFound, is RelayFailure.RelayUnavailable, is RelayFailure.UnexpectedStatus, -> RemoteSessionUiState.Failed(RemoteSessionFailureReason.TRANSPORT) diff --git a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/session/RemoteSessionStore.kt b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/session/RemoteSessionStore.kt index d9eb8ef2c9..4472c25d1c 100644 --- a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/session/RemoteSessionStore.kt +++ b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/session/RemoteSessionStore.kt @@ -35,7 +35,6 @@ import com.openbitfun.mobile.core.protocol.SendMessageResponse import com.openbitfun.mobile.core.protocol.SessionItemResponse import com.openbitfun.mobile.core.protocol.SessionListResponse import com.openbitfun.mobile.core.protocol.WorkspaceInfoResponse -import com.openbitfun.mobile.core.transport.PairedRoom import com.openbitfun.mobile.core.transport.RemoteCommandTransport import com.openbitfun.mobile.core.transport.send import com.openbitfun.mobile.core.feature.workspace.RemoteWorkspaceIntent @@ -1467,9 +1466,6 @@ public class RemoteSessionStore internal constructor( */ private const val FILTER_PAGE_SIZE: Int = 100 - internal fun create(scope: CoroutineScope, room: PairedRoom): RemoteSessionStore = - RemoteSessionStore(scope, room.transport) - internal fun create(scope: CoroutineScope, transport: RemoteCommandTransport): RemoteSessionStore = RemoteSessionStore(scope, transport) @@ -1480,12 +1476,7 @@ public class RemoteSessionStore internal constructor( persistence: MobilePersistenceStores?, ): RemoteSessionStore = RemoteSessionStore(scope, transport, deviceKey, persistence) - internal fun create( - scope: CoroutineScope, - room: PairedRoom, - deviceKey: String?, - persistence: MobilePersistenceStores?, - ): RemoteSessionStore = RemoteSessionStore(scope, room.transport, deviceKey, persistence) + } } diff --git a/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/account/AccountDeviceLinkTest.kt b/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/account/AccountDeviceLinkTest.kt new file mode 100644 index 0000000000..6a967ec5d1 --- /dev/null +++ b/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/account/AccountDeviceLinkTest.kt @@ -0,0 +1,41 @@ +package com.openbitfun.mobile.core.feature.account + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class AccountDeviceLinkTest { + private val link = "https://remote.openbitfun.com/v/1.0.0/#/pair?did=desktop-1" + private val ready = AccountUiState.Ready(userId = "user", username = "name", relayUrl = "https://remote.openbitfun.com/v/1.0.0", devices = listOf(AccountDeviceUi("desktop-1", "Desktop", true, null)), selectedDeviceId = null, selectedDeviceName = null) + + @Test fun onlyAuthenticatedOnlineMembershipCanResolveTheTarget() { + assertEquals(AccountDeviceLinkStatus.SIGN_IN_REQUIRED, resolveAccountDeviceLink(link, AccountUiState.SignedOut).status) + assertEquals("desktop-1", resolveAccountDeviceLink(link, ready).deviceId) + assertEquals(AccountDeviceLinkStatus.UNAVAILABLE, resolveAccountDeviceLink(link.replace("desktop-1", "foreign"), ready).status) + assertEquals(AccountDeviceLinkStatus.UNAVAILABLE, resolveAccountDeviceLink(link, ready.copy(devices = ready.devices.map { it.copy(online = false) })).status) + } + + @Test fun lanRequiresLoginOnTheScannedEndpointAndUsesTheSameDirectory() { + val endpoint = "http://192.168.1.10:9700" + val localLink = "$endpoint/#/pair?did=desktop-1" + val signedOut = resolveAccountDeviceLink(localLink, ready) + assertEquals(AccountDeviceLinkStatus.SIGN_IN_REQUIRED, signedOut.status) + assertEquals(endpoint, signedOut.relayUrl) + assertEquals(AccountDeviceLinkStatus.READY, resolveAccountDeviceLink(localLink, ready.copy(relayUrl = endpoint)).status) + assertEquals(AccountDeviceLinkStatus.UNAVAILABLE, + resolveAccountDeviceLink(localLink.replace("desktop-1", "foreign"), ready.copy(relayUrl = endpoint)).status) + } + + @Test fun rejectsLegacyAndLookalikeLinksWithoutUsingTheirRoutingFields() { + for (invalid in listOf( + link.replace("https:", "http:"), link.replace("remote.openbitfun.com", "evil.example"), + link.replace("remote.openbitfun.com", "user@remote.openbitfun.com"), + link.replace("/v/1.0.0/", "/relay/"), link + "&did=foreign", link + "&pk=untrusted", link + "&relay=evil", link.replace("did=desktop-1", "room=old"), + link.replace("did=desktop-1", "did=../bad"), + )) { + val result = resolveAccountDeviceLink(invalid, ready) + assertEquals(AccountDeviceLinkStatus.INVALID, result.status, invalid) + assertNull(result.deviceId) + } + } +} diff --git a/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/account/AccountStoreTest.kt b/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/account/AccountStoreTest.kt index ad6815e67c..c26cb017c4 100644 --- a/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/account/AccountStoreTest.kt +++ b/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/account/AccountStoreTest.kt @@ -25,15 +25,15 @@ class AccountStoreTest { val backend = FakeAccountBackend() val store = AccountStore.create(this, backend, secure, "phone-1", "Android") - store.dispatch(AccountIntent.Login("https://relay.test", "user", "top-secret-value")) + store.dispatch(AccountIntent.Login) advanceUntilIdle() val ready = assertIs(store.state.value) assertEquals("user-id", ready.userId) assertEquals("desktop-1", ready.selectedDeviceId) - assertTrue(secure.read("cloud_account_session")?.isNotEmpty() == true) + assertTrue(secure.read("github_device_session_v1")?.isNotEmpty() == true) assertFalse( - AccountIntent.Login("https://relay.test", "user", "top-secret-value") + AccountIntent.Login .toString() .contains("top-secret-value"), ) @@ -47,7 +47,7 @@ class AccountStoreTest { @Test fun onlyControllableDevicesReachTheList() = runTest { val store = AccountStore.create(this, FakeAccountBackend(), MemorySecureStore(), "phone-1", "Android") - store.dispatch(AccountIntent.Login("https://relay.test", "user", "password")) + store.dispatch(AccountIntent.Login) advanceUntilIdle() val ready = assertIs(store.state.value) @@ -60,7 +60,7 @@ class AccountStoreTest { fun deviceSelectionAndLogoutUpdateSecureState() = runTest { val secure = MemorySecureStore() val store = AccountStore.create(this, FakeAccountBackend(), secure, "phone-1", "Android") - store.dispatch(AccountIntent.Login("https://relay.test", "user", "password")) + store.dispatch(AccountIntent.Login) advanceUntilIdle() // Neither this device nor an offline one can become the control target. @@ -71,7 +71,7 @@ class AccountStoreTest { assertIs(store.state.value) assertEquals(1, secure.deleteCount) - assertNull(secure.read("cloud_account_session")) + assertNull(secure.read("github_device_session_v1")) } @Test @@ -79,10 +79,10 @@ class AccountStoreTest { val secure = MemorySecureStore() val backend = FakeAccountBackend().also { it.desktop2Online = true } val store = AccountStore.create(this, backend, secure, "phone-1", "Android") - store.dispatch(AccountIntent.Login("https://relay.test", "user", "password")) + store.dispatch(AccountIntent.Login) advanceUntilIdle() assertEquals("desktop-1", assertIs(store.state.value).selectedDeviceId) - val stored = secure.read("cloud_account_session")!!.toList() + val stored = secure.read("github_device_session_v1")!!.toList() secure.failWrites = true secure.mutateBeforeWriteFailure = true @@ -91,12 +91,11 @@ class AccountStoreTest { val failed = assertIs(store.state.value) assertEquals(AccountFailureReason.SECURE_STORAGE, failed.reason) assertEquals(AccountFailureStage.SECURE_STORAGE, failed.stage) - assertEquals(stored, secure.read("cloud_account_session")?.toList()) + assertEquals(stored, secure.read("github_device_session_v1")?.toList()) assertNull(store.createSessionStore(this)) assertNull(store.createSessionStore(this, "desktop-1")) assertNull(store.createWorkspaceStore(this)) assertNull(store.createWorkspaceStore(this, "desktop-1")) - assertNull(store.cloudSettingsSource()) assertTrue(backend.transportTargets.isEmpty()) assertEquals(0, secure.deleteCount) } @@ -105,7 +104,7 @@ class AccountStoreTest { fun refreshPicksUpADesktopThatCameOnlineAndSurvivesFailing() = runTest { val backend = FakeAccountBackend() val store = AccountStore.create(this, backend, MemorySecureStore(), "phone-1", "Android") - store.dispatch(AccountIntent.Login("https://relay.test", "user", "password")) + store.dispatch(AccountIntent.Login) advanceUntilIdle() assertFalse(assertIs(store.state.value).devices.single { it.id == "desktop-2" }.online) @@ -132,7 +131,7 @@ class AccountStoreTest { fun explicitDeviceStoresCoexistWithSelectedTargetStore() = runTest { val backend = FakeAccountBackend() val store = AccountStore.create(this, backend, MemorySecureStore(), "phone-1", "Android") - store.dispatch(AccountIntent.Login("https://relay.test", "user", "password")) + store.dispatch(AccountIntent.Login) advanceUntilIdle() // The old single-target entry points still resolve the selected device. @@ -157,7 +156,7 @@ class AccountStoreTest { assertNull(signedOut.createWorkspaceStore(this, "desktop-1")) assertTrue(backend.transportTargets.isEmpty()) - signedOut.dispatch(AccountIntent.Login("https://relay.test", "user", "password")) + signedOut.dispatch(AccountIntent.Login) advanceUntilIdle() assertNull(signedOut.createSessionStore(this, "")) assertNull(signedOut.createSessionStore(this, "phone-1")) @@ -173,7 +172,7 @@ class AccountStoreTest { fun invalidRestoreKeepsOpaqueRecordAndCanBeRetried() = runTest { val secure = MemorySecureStore() val raw = "not-a-session-record".encodeToByteArray() - secure.write("cloud_account_session", raw) + secure.write("github_device_session_v1", raw) val store = AccountStore.create(this, FakeAccountBackend(), secure, "phone-1", "Android") store.dispatch(AccountIntent.Restore) @@ -181,20 +180,20 @@ class AccountStoreTest { assertEquals(AccountFailureReason.SECURE_STORAGE, assertIs(store.state.value).reason) assertTrue(assertIs(store.state.value).canRetry) assertEquals(0, secure.deleteCount) - assertEquals(raw.toList(), secure.read("cloud_account_session")?.toList()) + assertEquals(raw.toList(), secure.read("github_device_session_v1")?.toList()) store.dispatch(AccountIntent.Restore) advanceUntilIdle() assertEquals(AccountFailureReason.SECURE_STORAGE, assertIs(store.state.value).reason) assertEquals(0, secure.deleteCount) - assertEquals(raw.toList(), secure.read("cloud_account_session")?.toList()) + assertEquals(raw.toList(), secure.read("github_device_session_v1")?.toList()) } @Test fun secureReadFailureFailsClosedWithoutDeletingStoredBytes() = runTest { val secure = MemorySecureStore() val raw = "opaque-existing-session".encodeToByteArray() - secure.write("cloud_account_session", raw) + secure.write("github_device_session_v1", raw) secure.failReads = true val store = AccountStore.create(this, FakeAccountBackend(), secure, "phone-1", "Android") @@ -204,17 +203,16 @@ class AccountStoreTest { val failed = assertIs(store.state.value) assertEquals(AccountFailureReason.SECURE_STORAGE, failed.reason) assertEquals(AccountFailureStage.RESTORE, failed.stage) - assertNull(store.cloudSettingsSource()) assertEquals(0, secure.deleteCount) secure.failReads = false - assertEquals(raw.toList(), secure.read("cloud_account_session")?.toList()) + assertEquals(raw.toList(), secure.read("github_device_session_v1")?.toList()) } @Test fun legacyRestoreKeepsOpaqueRecord() = runTest { val secure = MemorySecureStore() val raw = "{\"token\":\"legacy-token\"}".encodeToByteArray() - secure.write("cloud_account_session", raw) + secure.write("github_device_session_v1", raw) val store = AccountStore.create(this, FakeAccountBackend(), secure, "phone-1", "Android") store.dispatch(AccountIntent.Restore) @@ -222,7 +220,7 @@ class AccountStoreTest { assertEquals(AccountFailureReason.SECURE_STORAGE, assertIs(store.state.value).reason) assertEquals(0, secure.deleteCount) - assertEquals(raw.toList(), secure.read("cloud_account_session")?.toList()) + assertEquals(raw.toList(), secure.read("github_device_session_v1")?.toList()) } @Test @@ -230,9 +228,9 @@ class AccountStoreTest { val secure = MemorySecureStore() val backend = FakeAccountBackend() val first = AccountStore.create(this, backend, secure, "phone-1", "Android") - first.dispatch(AccountIntent.Login("https://relay.test", "user", "password")) + first.dispatch(AccountIntent.Login) advanceUntilIdle() - val raw = secure.read("cloud_account_session") + val raw = secure.read("github_device_session_v1") val restored = AccountStore.create(this, backend, secure, "phone-1", "Android") restored.dispatch(AccountIntent.Restore) @@ -240,25 +238,27 @@ class AccountStoreTest { assertIs(restored.state.value) assertEquals(0, secure.deleteCount) - assertEquals(raw?.toList(), secure.read("cloud_account_session")?.toList()) + assertEquals(raw?.toList(), secure.read("github_device_session_v1")?.toList()) } @Test - fun expiredRefreshClearsDevicesAndThePersistedSession() = runTest { + fun expiredRefreshRevokesCapabilitiesAndPreservesTheStoredRecord() = runTest { val secure = MemorySecureStore() val backend = FakeAccountBackend() val store = AccountStore.create(this, backend, secure, "phone-1", "Android") - store.dispatch(AccountIntent.Login("https://relay.test", "user", "password")) + store.dispatch(AccountIntent.Login) advanceUntilIdle() assertTrue(assertIs(store.state.value).devices.isNotEmpty()) + val stored = secure.read("github_device_session_v1")!!.toList() backend.listFailure = CloudAccountFailure.AUTHENTICATION store.dispatch(AccountIntent.RefreshDevices) advanceUntilIdle() val failed = assertIs(store.state.value) assertEquals(AccountFailureReason.AUTHENTICATION, failed.reason) - assertNull(secure.read("cloud_account_session")) + assertEquals(stored, secure.read("github_device_session_v1")?.toList()) + assertEquals(0, secure.deleteCount) assertNull(store.createSessionStore(this)) } @@ -267,7 +267,7 @@ class AccountStoreTest { val backend = FakeAccountBackend().also { it.desktop1Online = false } val store = AccountStore.create(this, backend, MemorySecureStore(), "phone-1", "Android") - store.dispatch(AccountIntent.Login("https://relay.test", "user", "password")) + store.dispatch(AccountIntent.Login) advanceUntilIdle() // Not this device as a consolation prize: the live account has ten @@ -280,7 +280,7 @@ class AccountStoreTest { val backend = FakeAccountBackend().also { it.failure = CloudAccountFailure.AUTHENTICATION } val store = AccountStore.create(this, backend, MemorySecureStore(), "phone-1", "Android") - store.dispatch(AccountIntent.Login("https://relay.test", "user", "wrong")) + store.dispatch(AccountIntent.Login) advanceUntilIdle() val failed = assertIs(store.state.value) @@ -293,7 +293,7 @@ class AccountStoreTest { val backend = FakeAccountBackend().also { it.loginThrowable = IllegalStateException("crypto failed") } val store = AccountStore.create(this, backend, MemorySecureStore(), "phone-1", "Android") - store.dispatch(AccountIntent.Login("https://relay.test", "user", "password")) + store.dispatch(AccountIntent.Login) advanceUntilIdle() val failed = assertIs(store.state.value) @@ -306,7 +306,7 @@ class AccountStoreTest { val secure = MemorySecureStore(failWrites = true) val store = AccountStore.create(this, FakeAccountBackend(), secure, "phone-1", "Android") - store.dispatch(AccountIntent.Login("https://relay.test", "user", "password")) + store.dispatch(AccountIntent.Login) advanceUntilIdle() val failed = assertIs(store.state.value) @@ -320,14 +320,13 @@ class AccountStoreTest { val backend = FakeAccountBackend().also { it.listThrowable = IllegalStateException("transport failed") } val store = AccountStore.create(this, backend, secure, "phone-1", "Android") - store.dispatch(AccountIntent.Login("https://relay.test", "user", "password")) + store.dispatch(AccountIntent.Login) advanceUntilIdle() val failed = assertIs(store.state.value) assertEquals(AccountFailureReason.NETWORK, failed.reason) assertEquals(AccountFailureStage.DEVICE_LIST, failed.stage) - assertTrue(store.cloudSettingsSource() != null) - assertTrue(secure.read("cloud_account_session")?.isNotEmpty() == true) + assertTrue(secure.read("github_device_session_v1")?.isNotEmpty() == true) assertEquals(0, secure.deleteCount) } @@ -337,7 +336,7 @@ class AccountStoreTest { val backend = FakeAccountBackend().also { it.listFailure = transportReason } val store = AccountStore.create(this, backend, MemorySecureStore(), "phone-1", "Android") - store.dispatch(AccountIntent.Login("https://relay.test", "user", "password")) + store.dispatch(AccountIntent.Login) advanceUntilIdle() val failed = assertIs(store.state.value) @@ -352,11 +351,11 @@ class AccountStoreTest { val backend = FakeAccountBackend().also { it.listFailure = CloudAccountFailure.TIMEOUT } val store = AccountStore.create(this, backend, secure, "phone-1", "Android") - store.dispatch(AccountIntent.Login("https://relay.test", "user", "password")) + store.dispatch(AccountIntent.Login) advanceUntilIdle() val failed = assertIs(store.state.value) assertEquals(AccountFailureStage.DEVICE_LIST, failed.stage) - val stored = secure.read("cloud_account_session")!!.toList() + val stored = secure.read("github_device_session_v1")!!.toList() val writes = secure.writeCount val deletes = secure.deleteCount @@ -366,7 +365,7 @@ class AccountStoreTest { val ready = assertIs(store.state.value) assertEquals("desktop-1", ready.selectedDeviceId) - assertEquals(stored, secure.read("cloud_account_session")?.toList()) + assertEquals(stored, secure.read("github_device_session_v1")?.toList()) assertEquals(writes, secure.writeCount) assertEquals(deletes, secure.deleteCount) } @@ -375,19 +374,18 @@ class AccountStoreTest { fun writeFailureFailsClosedAndRestoresExistingBytes() = runTest { val secure = MemorySecureStore() val existing = "existing-session-bytes".encodeToByteArray() - secure.write("cloud_account_session", existing) + secure.write("github_device_session_v1", existing) secure.failWrites = true secure.mutateBeforeWriteFailure = true val store = AccountStore.create(this, FakeAccountBackend(), secure, "phone-1", "Android") - store.dispatch(AccountIntent.Login("https://relay.test", "user", "password")) + store.dispatch(AccountIntent.Login) advanceUntilIdle() val failed = assertIs(store.state.value) assertEquals(AccountFailureReason.SECURE_STORAGE, failed.reason) - assertNull(store.cloudSettingsSource()) assertNull(store.createSessionStore(this)) - assertEquals(existing.toList(), secure.read("cloud_account_session")?.toList()) + assertEquals(existing.toList(), secure.read("github_device_session_v1")?.toList()) assertEquals(0, secure.deleteCount) } @@ -395,18 +393,17 @@ class AccountStoreTest { fun deleteFailureLogsOutInMemoryWithoutDestroyingStoredBytes() = runTest { val secure = MemorySecureStore() val store = AccountStore.create(this, FakeAccountBackend(), secure, "phone-1", "Android") - store.dispatch(AccountIntent.Login("https://relay.test", "user", "password")) + store.dispatch(AccountIntent.Login) advanceUntilIdle() - val stored = secure.read("cloud_account_session")!!.toList() + val stored = secure.read("github_device_session_v1")!!.toList() secure.failDeletes = true store.dispatch(AccountIntent.Logout) val failed = assertIs(store.state.value) assertEquals(AccountFailureReason.SECURE_STORAGE, failed.reason) - assertNull(store.cloudSettingsSource()) assertNull(store.createSessionStore(this)) - assertEquals(stored, secure.read("cloud_account_session")?.toList()) + assertEquals(stored, secure.read("github_device_session_v1")?.toList()) } @Test @@ -414,7 +411,7 @@ class AccountStoreTest { val secure = MemorySecureStore() val backend = FakeAccountBackend() val first = AccountStore.create(this, backend, secure, "phone-1", "Android") - first.dispatch(AccountIntent.Login("https://relay.test", "user", "top-secret-value")) + first.dispatch(AccountIntent.Login) advanceUntilIdle() backend.listFailure = CloudAccountFailure.NETWORK @@ -423,7 +420,7 @@ class AccountStoreTest { advanceUntilIdle() assertEquals(AccountFailureReason.NETWORK, assertIs(restored.state.value).reason) - assertTrue(secure.read("cloud_account_session")?.isNotEmpty() == true) + assertTrue(secure.read("github_device_session_v1")?.isNotEmpty() == true) } } @@ -485,16 +482,16 @@ private class FakeAccountBackend : AccountBackend { val transportTargets = mutableListOf() override suspend fun login( relayUrl: String, - username: String, - password: String, deviceId: String, deviceName: String, + deviceSecret: ByteArray, + onAuthorization: (String) -> Unit, ): AccountSessionData { loginThrowable?.let { throw it } failure?.let { throw CloudAccountException(it) } return AccountSessionData( - relayUrl, - username, + "https://remote.openbitfun.com/v/1.0.0", + "user", "token", "user-id", ByteArray(32) { it.toByte() }, @@ -516,7 +513,6 @@ private class FakeAccountBackend : AccountBackend { ) } - override suspend fun fetchSettings(session: AccountSessionData): String? = settings override fun transport(session: AccountSessionData, targetDeviceId: String): RemoteCommandTransport { transportTargets += targetDeviceId diff --git a/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/connection/ConnectionStatusPresenterTest.kt b/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/connection/ConnectionStatusPresenterTest.kt index 5ead178e98..9f35cfaf7b 100644 --- a/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/connection/ConnectionStatusPresenterTest.kt +++ b/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/connection/ConnectionStatusPresenterTest.kt @@ -1,10 +1,5 @@ package com.openbitfun.mobile.core.feature.connection -import com.openbitfun.mobile.core.feature.pairing.ConnectionLiveness -import com.openbitfun.mobile.core.feature.pairing.PairedWorkspace -import com.openbitfun.mobile.core.feature.pairing.PairingFailure -import com.openbitfun.mobile.core.feature.pairing.PairingFailureReason -import com.openbitfun.mobile.core.feature.pairing.PairingUiState import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -31,36 +26,4 @@ class ConnectionStatusPresenterTest { assertFalse(ConnectionStatusPresenter.canReachSessions(ConnectionPhase.DISCONNECTED)) } - @Test - fun pairingStatesMapOntoPhases() { - assertEquals(ConnectionPhase.IDLE, PairingUiState.Idle.connectionPhase()) - assertEquals(ConnectionPhase.CONNECTING, PairingUiState.Connecting.connectionPhase()) - assertEquals( - ConnectionPhase.CONNECTED, - PairingUiState.Paired(PairedWorkspace("room-ab", null, true, null)).connectionPhase(), - ) - assertEquals( - ConnectionPhase.FAILED, - PairingUiState.Failed(PairingFailure(PairingFailureReason.RoomNotFound)).connectionPhase(), - ) - } - - /** - * A paired room reports its own liveness, and that is where RECONNECTING now - * comes from: an announced health check, not a second handshake. A lost one - * reads as an error even though the pairing itself is untouched — from the - * shell's side there is nothing to reach either way. - */ - @Test - fun aPairedRoomsLivenessDecidesItsPhase() { - val workspace = PairedWorkspace("room-ab", null, true, null) - assertEquals( - ConnectionPhase.RECONNECTING, - PairingUiState.Paired(workspace, ConnectionLiveness.CHECKING).connectionPhase(), - ) - assertEquals( - ConnectionPhase.FAILED, - PairingUiState.Paired(workspace, ConnectionLiveness.LOST).connectionPhase(), - ) - } } diff --git a/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/connection/RemoteControlPresenterTest.kt b/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/connection/RemoteControlPresenterTest.kt index 72763f6615..e2ae402f44 100644 --- a/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/connection/RemoteControlPresenterTest.kt +++ b/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/connection/RemoteControlPresenterTest.kt @@ -7,8 +7,6 @@ class RemoteControlPresenterTest { @Test fun withNothingPairedTheCardHasNoDesktopAndNoAction() { val summary = RemoteControlPresenter.summarize( - pairingPhase = ConnectionPhase.IDLE, - pairedRoomLabel = "", accountDeviceId = "", accountDeviceName = "", accountPhase = ConnectionPhase.IDLE, @@ -19,37 +17,9 @@ class RemoteControlPresenterTest { assertEquals(RemoteControlAction.NONE, summary.action) } - @Test - fun aLiveRoomOffersLeavingItAndAStalledOneOffersTryingAgain() { - val live = RemoteControlPresenter.summarize( - pairingPhase = ConnectionPhase.CONNECTED, - pairedRoomLabel = "ab12", - accountDeviceId = "", - accountDeviceName = "", - accountPhase = ConnectionPhase.IDLE, - ) - assertEquals(RemoteControlSource.QR_PAIRING, live.source) - assertEquals("ab12", live.desktopName) - assertEquals(RemoteControlAction.DISCONNECT, live.action) - - // A room that stopped answering is still paired, so the card keeps - // naming it and swaps the action rather than emptying itself. - val lost = RemoteControlPresenter.summarize( - pairingPhase = ConnectionPhase.FAILED, - pairedRoomLabel = "ab12", - accountDeviceId = "", - accountDeviceName = "", - accountPhase = ConnectionPhase.IDLE, - ) - assertEquals(RemoteControlSource.QR_PAIRING, lost.source) - assertEquals(RemoteControlAction.RECONNECT, lost.action) - } - @Test fun aSelectedAccountDeviceIsTheDesktopWhenNoRoomIsPaired() { val summary = RemoteControlPresenter.summarize( - pairingPhase = ConnectionPhase.IDLE, - pairedRoomLabel = "", accountDeviceId = "device-1", accountDeviceName = "Studio", accountPhase = ConnectionPhase.CONNECTED, @@ -64,8 +34,6 @@ class RemoteControlPresenterTest { @Test fun aDeviceTheRelayOnlyKnowsByIdIsNamedByThatId() { val summary = RemoteControlPresenter.summarize( - pairingPhase = ConnectionPhase.IDLE, - pairedRoomLabel = "", accountDeviceId = "device-1", accountDeviceName = " ", accountPhase = ConnectionPhase.RECONNECTING, @@ -74,25 +42,9 @@ class RemoteControlPresenterTest { assertEquals("device-1", summary.desktopName) } - @Test - fun aPairedRoomWinsOverASelectedDevice() { - val summary = RemoteControlPresenter.summarize( - pairingPhase = ConnectionPhase.CONNECTED, - pairedRoomLabel = "ab12", - accountDeviceId = "device-1", - accountDeviceName = "Studio", - accountPhase = ConnectionPhase.FAILED, - ) - - assertEquals(RemoteControlSource.QR_PAIRING, summary.source) - assertEquals("ab12", summary.desktopName) - } - @Test fun aSelectedAccountDevicePublishesItsTransportPhase() { val summary = RemoteControlPresenter.summarize( - pairingPhase = ConnectionPhase.IDLE, - pairedRoomLabel = "", accountDeviceId = "device-1", accountDeviceName = "Studio", accountPhase = ConnectionPhase.RECONNECTING, diff --git a/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/pairing/FakeDesktop.kt b/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/pairing/FakeDesktop.kt deleted file mode 100644 index 1fe4ad478e..0000000000 --- a/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/pairing/FakeDesktop.kt +++ /dev/null @@ -1,103 +0,0 @@ -package com.openbitfun.mobile.core.feature.pairing - -import com.openbitfun.mobile.core.crypto.RemoteCryptoSession -import com.openbitfun.mobile.core.crypto.RemoteHandshake -import com.openbitfun.mobile.core.protocol.EncryptedPayload -import com.openbitfun.mobile.core.protocol.InitialSyncResponse -import com.openbitfun.mobile.core.protocol.PairChallengeResponse -import com.openbitfun.mobile.core.protocol.PairRequest -import com.openbitfun.mobile.core.protocol.RelayJson -import io.ktor.client.engine.mock.MockEngine -import io.ktor.client.engine.mock.respond -import io.ktor.client.engine.mock.toByteArray -import io.ktor.http.HttpHeaders -import io.ktor.http.HttpStatusCode -import io.ktor.http.headersOf -import kotlinx.serialization.SerializationStrategy - -internal const val ROOM_ID: String = "0123456789abcdef0123456789abcdef" -internal const val RELAY_URL: String = "https://relay.example.com" - -/** - * The desktop half of the handshake, running the real cipher. - * - * A trimmed sibling of `:core-transport`'s `DesktopPeer` — that one lives in - * another module's test source set, which is not on this module's test - * classpath. It is kept small on purpose: what is under test here is the state - * machine, and the envelope itself is already covered next door. - */ -internal class FakeDesktop private constructor(private val handshake: RemoteHandshake) { - private var session: RemoteCryptoSession? = null - - /** - * Flip to make every later `/command` fail at the relay. - * - * Which command it was does not matter to what is under test here: a - * heartbeat's only question is whether the room still answers, and the - * transport already turns a 503 into the failure the store reads. - */ - var offline: Boolean = false - - val publicKeyBase64: String get() = handshake.publicKeyBase64 - - private suspend fun encrypt(serializer: SerializationStrategy, value: T): String = - RelayJson.encodeToString( - EncryptedPayload.serializer(), - requireNotNull(session).encryptJson(serializer, value), - ) - - /** A relay that completes the handshake and answers `/command` with [sync]. */ - fun engine(sync: InitialSyncResponse): MockEngine = MockEngine { request -> - val body = request.body.toByteArray().decodeToString() - when (request.url.encodedPath) { - "/api/rooms/$ROOM_ID/pair" -> { - session = handshake.accept( - RelayJson.decodeFromString(PairRequest.serializer(), body).publicKey, - ) - json( - encrypt( - PairChallengeResponse.serializer(), - PairChallengeResponse(CHALLENGE, timestamp = 1_770_000_000), - ), - ) - } - - "/api/rooms/$ROOM_ID/command" -> - if (offline) { - respond("", HttpStatusCode.ServiceUnavailable) - } else { - // A ping only reads `resp`, so the sync doubles as its reply. - json(encrypt(InitialSyncResponse.serializer(), sync)) - } - - else -> respond("", HttpStatusCode.NotFound) - } - } - - private fun io.ktor.client.engine.mock.MockRequestHandleScope.json(body: String) = - respond(body, HttpStatusCode.OK, headersOf(HttpHeaders.ContentType, "application/json")) - - companion object { - /** 32 lowercase hex characters, the shape `pairing.rs` validates the echo against. */ - const val CHALLENGE: String = "9f2c0a17be4d5386a10c7f43de99b025" - - suspend fun create(): FakeDesktop = FakeDesktop(RemoteHandshake.create()) - } -} - -/** Collects log lines so tests can assert what does *not* appear in them. */ -internal class RecordingCoreLog : com.openbitfun.mobile.core.feature.CoreLog { - val lines: MutableList = mutableListOf() - - override fun info(message: String) { - lines += message - } - - override fun warn(message: String) { - lines += message - } - - override fun error(message: String) { - lines += message - } -} diff --git a/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/pairing/PairingStoreTest.kt b/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/pairing/PairingStoreTest.kt deleted file mode 100644 index e8912c7e8f..0000000000 --- a/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/pairing/PairingStoreTest.kt +++ /dev/null @@ -1,472 +0,0 @@ -package com.openbitfun.mobile.core.feature.pairing - -import com.openbitfun.mobile.core.feature.CoreLog -import com.openbitfun.mobile.core.feature.connection.ConnectionPhase -import com.openbitfun.mobile.core.feature.connection.connectionPhase -import com.openbitfun.mobile.core.persistence.SecureStore -import com.openbitfun.mobile.core.protocol.InitialSyncResponse -import com.openbitfun.mobile.core.transport.RelayPairing -import com.openbitfun.mobile.core.transport.relayHttpClient -import io.ktor.client.engine.mock.MockEngine -import io.ktor.client.engine.mock.respond -import io.ktor.http.HttpStatusCode -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.cancel -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.runTest -import kotlin.test.AfterTest -import kotlin.test.Test -import kotlin.test.assertContains -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertIs -import kotlin.test.assertTrue - -private val DEVICE = DeviceIdentity(installId = "android-install-42", displayName = "Pixel") - -/** Mirrors `PairingStore`'s own interval, which is private and stays that way. */ -private const val HEARTBEAT_INTERVAL_MS = 15_000L - -/** The same, for `UserIdProtection`'s two constants. */ -private const val MAX_FAILED_ATTEMPTS = 3 -private const val LOCKOUT_MS = 60_000L - -@OptIn(ExperimentalCoroutinesApi::class) -class PairingStoreTest { - @Test - fun reachesPairedAndExposesOnlyATruncatedRoomId() = runTest { - val desktop = FakeDesktop.create() - val store = storeOver( - desktop.engine( - InitialSyncResponse( - resp = "ok", - hasWorkspace = true, - projectName = "OpenBitFun", - authenticatedUserId = "alice", - ), - ), - ) - - store.dispatch(PairingIntent.Submit(pairingUrl = pairingUrl(desktop))) - - val paired = assertIs(store.settle()) - assertEquals("OpenBitFun", paired.workspace.projectName) - assertEquals("alice", paired.workspace.authenticatedUserId) - assertTrue(paired.workspace.hasWorkspace) - assertEquals("01234567", paired.workspace.roomLabel) - assertFalse(paired.acceptsSubmit) - } - - @Test - fun aMalformedLinkFailsWithoutTouchingTheNetwork() = runTest { - val engine = MockEngine { error("the store must not reach the relay") } - val store = storeOver(engine) - - store.dispatch(PairingIntent.Submit(pairingUrl = " ")) - - val failed = assertIs(store.state.value) - assertEquals(PairingFailureReason.PairingLinkEmpty, failed.failure.reason) - assertEquals(0, engine.requestHistory.size) - } - - @Test - fun aLinkWithoutAKeyIsIncomplete() = runTest { - val store = storeOver(MockEngine { error("unreachable") }) - - store.dispatch(PairingIntent.Submit(pairingUrl = "$RELAY_URL/#/pair?room=$ROOM_ID")) - - val failed = assertIs(store.state.value) - assertEquals(PairingFailureReason.PairingLinkIncomplete, failed.failure.reason) - } - - @Test - fun anAccountRoomWithoutAPasswordStopsBeforePairing() = runTest { - val desktop = FakeDesktop.create() - val engine = desktop.engine(InitialSyncResponse(resp = "ok")) - val store = storeOver(engine) - - store.dispatch( - PairingIntent.Submit(pairingUrl = "${pairingUrl(desktop)}&auth=account&user=alice"), - ) - - val failed = assertIs(store.state.value) - assertEquals(PairingFailureReason.AccountPasswordRequired, failed.failure.reason) - assertEquals(0, engine.requestHistory.size) - } - - @Test - fun anAccountRoomFallsBackToTheUsernameInTheLink() = runTest { - val desktop = FakeDesktop.create() - val store = storeOver(desktop.engine(InitialSyncResponse(resp = "ok"))) - - store.dispatch( - PairingIntent.Submit( - pairingUrl = "${pairingUrl(desktop)}&auth=account&user=alice", - userId = "", - password = "s3cret", - ), - ) - - assertIs(store.settle()) - } - - @Test - fun aMissingRoomIsDistinctFromARejection() = runTest { - for ((status, reason) in listOf( - 404 to PairingFailureReason.RoomNotFound, - 401 to PairingFailureReason.Rejected, - 429 to PairingFailureReason.RateLimited, - 503 to PairingFailureReason.RelayUnavailable, - )) { - val desktop = FakeDesktop.create() - val store = storeOver(MockEngine { respond("", HttpStatusCode.fromValue(status)) }) - - store.dispatch(PairingIntent.Submit(pairingUrl = pairingUrl(desktop))) - - val failed = assertIs(store.settle(), "status $status") - assertEquals(reason, failed.failure.reason, "status $status") - } - } - - @Test - fun aDesktopRefusalKeepsItsOwnWording() = runTest { - val desktop = FakeDesktop.create() - val store = storeOver( - desktop.engine( - InitialSyncResponse( - resp = "error", - message = "This remote URL is already protected by a different user ID.", - ), - ), - ) - - store.dispatch(PairingIntent.Submit(pairingUrl = pairingUrl(desktop))) - - val failed = assertIs(store.settle()) - assertEquals(PairingFailureReason.DesktopRejected, failed.failure.reason) - assertEquals( - "This remote URL is already protected by a different user ID.", - failed.failure.remoteMessage, - ) - } - - /** - * The cooldown is only a defence if the attempt it refuses never reaches the - * relay — a request that is sent has already been a guess, whatever the - * screen then says about it. - */ - @Test - fun aRunOfRefusedCredentialsStopsTheNextAttemptBeforeTheNetwork() = runTest { - val desktop = FakeDesktop.create() - val engine = MockEngine { respond("", HttpStatusCode.Unauthorized) } - val store = storeOver(engine) - - repeat(MAX_FAILED_ATTEMPTS - 1) { attempt -> - store.dispatch(PairingIntent.Submit(pairingUrl = pairingUrl(desktop))) - val failed = assertIs(store.settle(), "attempt $attempt") - assertEquals(PairingFailureReason.Rejected, failed.failure.reason, "attempt $attempt") - store.dispatch(PairingIntent.Dismiss) - } - - store.dispatch(PairingIntent.Submit(pairingUrl = pairingUrl(desktop))) - val locked = assertIs(store.settle()).failure - assertEquals(PairingFailureReason.TooManyAttempts, locked.reason) - assertEquals((LOCKOUT_MS / 1_000).toInt(), locked.retryAfterSeconds) - - val sent = engine.requestHistory.size - store.dispatch(PairingIntent.Dismiss) - store.dispatch(PairingIntent.Submit(pairingUrl = pairingUrl(desktop))) - - val refused = assertIs(store.state.value).failure - assertEquals(PairingFailureReason.TooManyAttempts, refused.reason) - assertEquals(sent, engine.requestHistory.size, "a locked submit reached the relay") - } - - /** - * A counter that only lives in memory is defeated by force-quitting the app - * between attempts, so the one that matters is the one a fresh store reads - * back out of the keystore. - */ - @Test - fun theCooldownOutlivesTheProcessThatEarnedIt() = runTest { - val desktop = FakeDesktop.create() - val secure = MemorySecureStore() - lockOut(desktop, secure) - - val engine = MockEngine { error("a relaunched store must not reach the relay either") } - val relaunched = storeOver(engine, protection = secure) - relaunched.dispatch(PairingIntent.Submit(pairingUrl = pairingUrl(desktop))) - - val failure = assertIs(relaunched.state.value).failure - assertEquals(PairingFailureReason.TooManyAttempts, failure.reason) - assertTrue(failure.retryAfterSeconds in 1..(LOCKOUT_MS / 1_000).toInt()) - } - - /** - * Waiting it out gives the attempts back as well: this is a cooldown, and a - * ratchet on the only way into the app would be worse than what it guards. - */ - @Test - fun anExpiredCooldownTakesTheFailureCountWithIt() = runTest { - val desktop = FakeDesktop.create() - val secure = MemorySecureStore() - lockOut(desktop, secure) - - testScheduler.advanceTimeBy(LOCKOUT_MS + 1) - - val store = storeOver(MockEngine { respond("", HttpStatusCode.Unauthorized) }, protection = secure) - store.dispatch(PairingIntent.Submit(pairingUrl = pairingUrl(desktop))) - - // Rejected rather than TooManyAttempts: one refusal after the wait is the - // first of a new run, not the fourth of the old one. - val failed = assertIs(store.settle()) - assertEquals(PairingFailureReason.Rejected, failed.failure.reason) - } - - @Test - fun aPairThatSucceedsEndsTheRun() = runTest { - val desktop = FakeDesktop.create() - val secure = MemorySecureStore() - val rejecting = storeOver(MockEngine { respond("", HttpStatusCode.Unauthorized) }, protection = secure) - repeat(MAX_FAILED_ATTEMPTS - 1) { - rejecting.dispatch(PairingIntent.Submit(pairingUrl = pairingUrl(desktop))) - assertIs(rejecting.settle()) - rejecting.dispatch(PairingIntent.Dismiss) - } - - val paired = storeOver(desktop.engine(InitialSyncResponse(resp = "ok")), protection = secure) - paired.dispatch(PairingIntent.Submit(pairingUrl = pairingUrl(desktop))) - assertIs(paired.settle()) - - // Two typos and then the right password is an ordinary evening, so the - // typos must not still be waiting for a third. - val later = storeOver(MockEngine { respond("", HttpStatusCode.Unauthorized) }, protection = secure) - later.dispatch(PairingIntent.Submit(pairingUrl = pairingUrl(desktop))) - assertEquals( - PairingFailureReason.Rejected, - assertIs(later.settle()).failure.reason, - ) - } - - /** - * Only a peer's refusal counts. Tapping Connect on a field left empty is - * caught before anything is sent, so locking the user out for it would guard - * nothing at all. - */ - @Test - fun anEmptyFieldIsNotAnAttempt() = runTest { - val desktop = FakeDesktop.create() - val store = storeOver(desktop.engine(InitialSyncResponse(resp = "ok"))) - val accountUrl = "${pairingUrl(desktop)}&auth=account&user=alice" - - repeat(MAX_FAILED_ATTEMPTS + 1) { attempt -> - store.dispatch(PairingIntent.Submit(pairingUrl = accountUrl)) - val failed = assertIs(store.state.value, "attempt $attempt") - assertEquals( - PairingFailureReason.AccountPasswordRequired, - failed.failure.reason, - "attempt $attempt", - ) - store.dispatch(PairingIntent.Dismiss) - } - - store.dispatch( - PairingIntent.Submit(pairingUrl = accountUrl, userId = "", password = "s3cret"), - ) - assertIs(store.settle()) - } - - @Test - fun dismissClearsAFailureAndDisconnectDropsThePairedRoom() = runTest { - val desktop = FakeDesktop.create() - val store = storeOver(desktop.engine(InitialSyncResponse(resp = "ok"))) - - store.dispatch(PairingIntent.Submit(pairingUrl = " ")) - store.dispatch(PairingIntent.Dismiss) - assertEquals(PairingUiState.Idle, store.state.value) - - store.dispatch(PairingIntent.Submit(pairingUrl = pairingUrl(desktop))) - assertIs(store.settle()) - - store.dispatch(PairingIntent.Disconnect) - assertEquals(PairingUiState.Idle, store.state.value) - } - - /** - * The heartbeat belongs to a surface that is on screen. A store nobody has - * foregrounded — the account sheet's, before a device is picked — must never - * wake the radio on its own. - */ - @Test - fun aStoreThatWasNeverForegroundedNeverPings() = runTest { - val desktop = FakeDesktop.create() - val engine = desktop.engine(InitialSyncResponse(resp = "ok")) - val store = storeOver(engine) - - store.dispatch(PairingIntent.Submit(pairingUrl = pairingUrl(desktop))) - assertIs(store.settle()) - - val afterPairing = engine.requestHistory.size - testScheduler.advanceTimeBy(HEARTBEAT_INTERVAL_MS * 4) - testScheduler.runCurrent() - - assertEquals(afterPairing, engine.requestHistory.size) - } - - /** - * A desktop that stops answering has not un-paired: the room, its key and its - * transport are all still here, so the state stays [PairingUiState.Paired] - * and one later ping is enough to put it back. - */ - @Test - fun aFailedCheckLosesTheLinkWithoutLosingTheRoom() = runTest { - val desktop = FakeDesktop.create() - val store = storeOver(desktop.engine(InitialSyncResponse(resp = "ok", projectName = "OpenBitFun"))) - - store.dispatch(PairingIntent.Submit(pairingUrl = pairingUrl(desktop))) - assertIs(store.settle()) - - desktop.offline = true - store.dispatch(PairingIntent.Foreground) - - val lost = store.awaitLiveness(ConnectionLiveness.LOST) - assertEquals("OpenBitFun", lost.workspace.projectName) - assertEquals(ConnectionPhase.FAILED, lost.connectionPhase()) - assertFalse(lost.acceptsSubmit, "a lost link is still a pairing, not a form") - - desktop.offline = false - store.dispatch(PairingIntent.Verify) - - assertEquals(ConnectionLiveness.LIVE, store.awaitLiveness(ConnectionLiveness.LIVE).liveness) - } - - /** The timer recovers a lost link on its own, and only while foregrounded. */ - @Test - fun theHeartbeatTicksUntilTheSurfaceGoesAway() = runTest { - val desktop = FakeDesktop.create() - val store = storeOver(desktop.engine(InitialSyncResponse(resp = "ok"))) - - store.dispatch(PairingIntent.Submit(pairingUrl = pairingUrl(desktop))) - assertIs(store.settle()) - - desktop.offline = true - store.dispatch(PairingIntent.Foreground) - store.awaitLiveness(ConnectionLiveness.LOST) - - // Nothing dispatched between here and the assertion: the desktop coming - // back is silent, so only a tick of the store's own timer can notice. - desktop.offline = false - testScheduler.advanceTimeBy(HEARTBEAT_INTERVAL_MS + 1_000) - assertEquals(ConnectionLiveness.LIVE, store.awaitLiveness(ConnectionLiveness.LIVE).liveness) - - desktop.offline = true - store.dispatch(PairingIntent.Background) - testScheduler.advanceTimeBy(HEARTBEAT_INTERVAL_MS * 4) - testScheduler.runCurrent() - - val paired = assertIs(store.state.value) - assertEquals(ConnectionLiveness.LIVE, paired.liveness, "a stopped timer must not ping") - } - - @Test - fun neitherTheRoomIdNorThePasswordReachesTheLog() = runTest { - val desktop = FakeDesktop.create() - val log = RecordingCoreLog() - val store = storeOver(desktop.engine(InitialSyncResponse(resp = "ok")), log) - - store.dispatch( - PairingIntent.Submit( - pairingUrl = "${pairingUrl(desktop)}&auth=account&user=alice", - userId = "", - password = "s3cret", - ), - ) - assertIs(store.settle()) - - val joined = log.lines.joinToString("\n") - assertTrue(log.lines.isNotEmpty()) - assertFalse(ROOM_ID in joined, "the full room id reached the log") - assertFalse("s3cret" in joined, "the password reached the log") - assertContains(joined, "room=01234567") - } - - // --- helpers ----------------------------------------------------------- - - // The shape the desktop actually advertises: a hash route whose query - // carries the room and the key. '+' is escaped because a raw one would be - // read as a literal '+' either way, and escaping it is what the desktop does. - private fun pairingUrl(desktop: FakeDesktop) = - "$RELAY_URL/#/pair?room=$ROOM_ID&pk=${desktop.publicKeyBase64.replace("+", "%2B")}" - - /** - * Spends the whole run of attempts against [secure], leaving it locked. - * - * [PairingIntent.Dismiss] between attempts is not decoration: two identical - * `Failed` values in a row are conflated by the flow, so without a trip - * through `Idle` the second wait would return the first attempt's state. - */ - private suspend fun TestScope.lockOut(desktop: FakeDesktop, secure: SecureStore) { - val store = storeOver(MockEngine { respond("", HttpStatusCode.Unauthorized) }, protection = secure) - repeat(MAX_FAILED_ATTEMPTS) { - store.dispatch(PairingIntent.Submit(pairingUrl = pairingUrl(desktop))) - assertIs(store.settle()) - store.dispatch(PairingIntent.Dismiss) - } - } - - private val scopes = mutableListOf() - - @AfterTest - fun cancelStoreScopes() { - scopes.forEach(CoroutineScope::cancel) - } - - private fun TestScope.storeOver( - engine: MockEngine, - log: CoreLog = CoreLog.None, - protection: SecureStore = MemorySecureStore(), - ): PairingStore { - val scope = CoroutineScope(UnconfinedTestDispatcher(testScheduler)) - scopes += scope - return PairingStore( - scope = scope, - device = DEVICE, - pairing = RelayPairing(relayHttpClient(engine), log.asTransportLog()), - log = log, - // Put on virtual time so a sixty-second cooldown can be waited out - // in a test; in the app it reads the wall clock, which is the only - // one still running after the process is killed and restarted. - protection = UserIdProtection(protection) { testScheduler.currentTime }, - ) - } - - /** - * Waits for the request to land. The mock engine hops off the test - * dispatcher, so the terminal state has to be awaited rather than reached by - * advancing virtual time. - */ - private suspend fun PairingStore.settle(): PairingUiState = - state.first { it !is PairingUiState.Connecting } - - /** The same wait, for the health check: liveness moves without leaving [PairingUiState.Paired]. */ - private suspend fun PairingStore.awaitLiveness(liveness: ConnectionLiveness): PairingUiState.Paired = - state.first { it is PairingUiState.Paired && it.liveness == liveness } as PairingUiState.Paired -} - -/** The keystore, minus the keystore: what survives here is a store, not a process. */ -private class MemorySecureStore : SecureStore { - private val values = mutableMapOf() - - override fun read(key: String): ByteArray? = values[key]?.copyOf() - - override fun write(key: String, value: ByteArray) { - values[key] = value.copyOf() - } - - override fun delete(key: String) { - values.remove(key) - } -} diff --git a/src/apps/mobile/shared/core-feature/src/iosMain/kotlin/com/openbitfun/mobile/core/feature/pairing/PairingStore.ios.kt b/src/apps/mobile/shared/core-feature/src/iosMain/kotlin/com/openbitfun/mobile/core/feature/pairing/PairingStore.ios.kt deleted file mode 100644 index 988cb31c9e..0000000000 --- a/src/apps/mobile/shared/core-feature/src/iosMain/kotlin/com/openbitfun/mobile/core/feature/pairing/PairingStore.ios.kt +++ /dev/null @@ -1,22 +0,0 @@ -package com.openbitfun.mobile.core.feature.pairing - -import com.openbitfun.mobile.core.feature.CoreLog -import com.openbitfun.mobile.core.persistence.iosPersistenceStores -import com.openbitfun.mobile.core.persistence.iosSecureStore -import kotlinx.coroutines.CoroutineScope - -/** iOS pairing wiring; the credential cooldown lives in the Keychain. */ -public fun PairingStore.Companion.create( - scope: CoroutineScope, - device: DeviceIdentity, - log: CoreLog, -): PairingStore { - val persistence = iosPersistenceStores("openbitfun-mobile.db") - return PairingStore.create( - scope = scope, - device = device, - protection = iosSecureStore("com.openbitfun.mobile.pairing"), - log = log, - persistence = persistence, - ) -} diff --git a/src/apps/mobile/shared/core-transport/src/commonMain/kotlin/com/openbitfun/mobile/core/transport/AccountDeviceLink.kt b/src/apps/mobile/shared/core-transport/src/commonMain/kotlin/com/openbitfun/mobile/core/transport/AccountDeviceLink.kt new file mode 100644 index 0000000000..93119adb4c --- /dev/null +++ b/src/apps/mobile/shared/core-transport/src/commonMain/kotlin/com/openbitfun/mobile/core/transport/AccountDeviceLink.kt @@ -0,0 +1,36 @@ +package com.openbitfun.mobile.core.transport + +import io.ktor.http.Url + +public data class AccountDeviceLink(public val relayUrl: String, public val deviceId: String) + +public fun normalizeAccountRelayUrl(value: String): String? = runCatching { + val url = Url(value.trim()) + if (url.protocol.name !in setOf("http", "https") || url.user != null || url.password != null || + url.parameters.names().isNotEmpty() || url.fragment.isNotEmpty()) return null + val normalized = url.toString().trimEnd('/') + if (normalized == DEFAULT_CLOUD_RELAY_URL) return normalized + val host = url.host.removeSurrounding("[", "]").lowercase() + val octets = host.split('.').mapNotNull(String::toIntOrNull) + val local = host == "localhost" || host == "::1" || + Regex("^(f[cd][0-9a-f]{2}|fe[89ab][0-9a-f]):").containsMatchIn(host) || + (octets.size == 4 && octets.all { it in 0..255 } && (octets[0] == 10 || octets[0] == 127 || + (octets[0] == 192 && octets[1] == 168) || (octets[0] == 172 && octets[1] in 16..31) || + (octets[0] == 169 && octets[1] == 254))) + normalized.takeIf { local && url.encodedPath in setOf("", "/") } +}.getOrNull() + +/** The link chooses an endpoint and target; authenticated membership grants access. */ +public fun accountDeviceLink(value: String): AccountDeviceLink? = runCatching { + if (value.length > 8192) return null + val url = Url(value.trim()) + if (url.parameters.names().isNotEmpty() || !url.fragment.startsWith("/pair?")) return null + val endpoint = normalizeAccountRelayUrl(value.trim().substringBefore('#')) ?: return null + val query = Url("https://localhost/?" + url.fragment.removePrefix("/pair?")) + if (query.parameters.names() != setOf("did")) return null + val id = query.parameters.getAll("did")?.singleOrNull()?.takeIf { id -> + id.isNotEmpty() && id.length <= 128 && id !in setOf(".", "..") && + id.all { (it.isLetterOrDigit() && it.code < 128) || it in "-_." } + } ?: return null + AccountDeviceLink(endpoint, id) +}.getOrNull() diff --git a/src/apps/mobile/shared/core-transport/src/commonMain/kotlin/com/openbitfun/mobile/core/transport/CloudAccountClient.kt b/src/apps/mobile/shared/core-transport/src/commonMain/kotlin/com/openbitfun/mobile/core/transport/CloudAccountClient.kt index 86ff95bea9..b4f5046cce 100644 --- a/src/apps/mobile/shared/core-transport/src/commonMain/kotlin/com/openbitfun/mobile/core/transport/CloudAccountClient.kt +++ b/src/apps/mobile/shared/core-transport/src/commonMain/kotlin/com/openbitfun/mobile/core/transport/CloudAccountClient.kt @@ -1,8 +1,7 @@ package com.openbitfun.mobile.core.transport import com.openbitfun.mobile.core.crypto.CloudAccountCipher -import com.openbitfun.mobile.core.crypto.CloudAccountKdfParams -import com.openbitfun.mobile.core.crypto.PlatformArgon2id +import com.openbitfun.mobile.core.crypto.DeviceIdentity import com.openbitfun.mobile.core.protocol.CommandStatus import com.openbitfun.mobile.core.protocol.EncryptedPayload import com.openbitfun.mobile.core.protocol.RelayJson @@ -29,9 +28,10 @@ import kotlinx.serialization.builtins.ListSerializer import kotlinx.serialization.json.JsonObject import kotlinx.coroutines.CancellationException import kotlin.io.encoding.Base64 -import kotlin.random.Random +import kotlin.uuid.Uuid +import kotlin.uuid.ExperimentalUuidApi -public const val DEFAULT_CLOUD_RELAY_URL: String = "https://remote.openbitfun.com/relay" +public const val DEFAULT_CLOUD_RELAY_URL: String = "https://remote.openbitfun.com/v/1.0.0" /** Device kinds the relay accepts; mirrors `relay-service/src/db.rs::DEVICE_KINDS`. */ private const val DEVICE_KIND_DESKTOP = "desktop" @@ -120,23 +120,6 @@ public data class CloudAccountDevice public constructor( public val deviceKind: String? = null, ) -/** - * The account's settings, decrypted, still as the desktop wrote them. - * - * Kept as text rather than parsed here: the blob is the desktop's whole - * configuration document and this layer has no business knowing its schema — - * only that the relay stored it sealed and that the master key opens it. - * - * [plaintext] is redacted from [toString] because that document carries the - * user's provider API keys. - */ -public data class CloudSettingsBlob public constructor( - public val plaintext: String, - public val version: Long, -) { - override fun toString(): String = "CloudSettingsBlob(plaintext=, version=$version)" -} - public class CloudAccountClient internal constructor( private val client: HttpClient, private val log: TransportLog = TransportLog.None, @@ -147,53 +130,35 @@ public class CloudAccountClient internal constructor( it.trim().lowercase() } - public suspend fun login( - relayUrl: String, - username: String, - password: String, - deviceId: String, - deviceName: String, - ): CloudAccountSession { - val relay = relayUrl.trim().ifEmpty { DEFAULT_CLOUD_RELAY_URL }.trimEnd('/') - val user = username.trim() - if (user.isEmpty() || user.length > 128 || password.isEmpty() || password.length > 1024) { - throw CloudAccountException(CloudAccountFailure.INVALID_CREDENTIALS) - } - val challenge = request( - relay, - "/api/auth/login/challenge", - HttpMethod.Post, - LoginChallengeRequest.serializer(), - LoginChallengeRequest(user), - AccountChallenge.serializer(), - "", - RELAY_DEFAULT_TIMEOUT_MS, - ) - val paramsWire = try { - RelayJson.decodeFromString(Argon2ParamsWire.serializer(), challenge.argon2Params) - } catch (_: Throwable) { - throw CloudAccountException(CloudAccountFailure.MALFORMED_RESPONSE) - } - val params = CloudAccountKdfParams(paramsWire.m, paramsWire.t, paramsWire.p) - val salt = decode(challenge.salt) - val kdfSalt = decode(challenge.kdfSalt) - val kek = PlatformArgon2id.derive(password, salt, params) - val masterKey = unwrapMasterKey(kek, challenge.wrappedMasterKey) - val proof = PlatformArgon2id.derive(password, kdfSalt, params) - val auth = request( - relay, - "/api/auth/login", - HttpMethod.Post, - LoginRequest.serializer(), - LoginRequest(user, Base64.Default.encode(proof), deviceId, deviceName, DEVICE_KIND_MOBILE), - AccountAuthResponse.serializer(), - "", - RELAY_DEFAULT_TIMEOUT_MS, - ) - if (auth.token.isEmpty() || auth.userId.isEmpty()) { - throw CloudAccountException(CloudAccountFailure.MALFORMED_RESPONSE) - } - return CloudAccountSession(auth.token, auth.userId, masterKey) + public suspend fun startAuthorization(relayUrl: String): GitHubAuthorization = request( + relayUrl, "/api/auth/github/start", HttpMethod.Post, + JsonObject.serializer(), JsonObject(emptyMap()), GitHubAuthorization.serializer(), "", RELAY_DEFAULT_TIMEOUT_MS, + ).also { + val url = io.ktor.http.Url(it.authorizationUrl) + require(url.protocol.name == "https" && url.host == "github.com" && url.encodedPath == "/login/oauth/authorize" && url.port == 443 && url.user == null && url.password == null) + } + + public suspend fun pollAuthorization(relayUrl: String, start: GitHubAuthorization): GitHubAuthorizationPoll = request( + relayUrl, "/api/auth/github/poll", HttpMethod.Post, + GitHubPollRequest.serializer(), GitHubPollRequest(start.transactionId, start.transactionSecret), + GitHubAuthorizationPoll.serializer(), "", RELAY_DEFAULT_TIMEOUT_MS, + ) + + @OptIn(ExperimentalUuidApi::class) + public suspend fun login(relayUrl: String, accessToken: String, deviceId: String, deviceName: String, deviceSecret: ByteArray): CloudAccountSession { + require(accessToken.isNotBlank()) + require(deviceSecret.size == 32) { "Invalid device key." } + val secret = deviceSecret.copyOf() + try { + val auth = request( + relayUrl, "/api/auth/login", HttpMethod.Post, + LoginRequest.serializer(), LoginRequest(accessToken, deviceId, deviceName, DEVICE_KIND_MOBILE, + Base64.Default.encode(DeviceIdentity.publicKey(secret)), Uuid.random().toString()), + AccountAuthResponse.serializer(), "", RELAY_DEFAULT_TIMEOUT_MS, + ) + if (auth.token.isBlank() || auth.userId.isBlank()) throw CloudAccountException(CloudAccountFailure.MALFORMED_RESPONSE) + return CloudAccountSession(auth.token, auth.userId, secret) + } catch (cause: Throwable) { secret.fill(0); throw cause } } /** @@ -209,7 +174,7 @@ public class CloudAccountClient internal constructor( selfDeviceId: String = "", ): List = requestWithoutBody( - relayUrl.trim().ifEmpty { DEFAULT_CLOUD_RELAY_URL }.trimEnd('/'), + relayUrl, "/api/devices", HttpMethod.Get, ListSerializer(AccountDeviceWire.serializer()), @@ -229,52 +194,6 @@ public class CloudAccountClient internal constructor( ) } - /** - * The account's encrypted settings, or null when the account has none. - * - * "None" has two spellings on this endpoint and both mean the same thing to - * a caller: a relay that has never been given a settings document answers - * 404, and one that holds an empty row answers 200 with nothing sealed in - * it. `CloudAccountClient.ets` folds both into `undefined` rather than an - * error, because an account that has simply not synced yet is an ordinary - * state and not a failure anyone can act on. - * - * Anything else — an expired token, a relay that is down, a body that will - * not decrypt — still throws, so a caller can tell "you have no models" from - * "I could not find out". - */ - public suspend fun fetchSettings(relayUrl: String, session: CloudAccountSession): CloudSettingsBlob? { - val entry = try { - requestWithoutBody( - relayUrl.trim().ifEmpty { DEFAULT_CLOUD_RELAY_URL }.trimEnd('/'), - "/api/sync/settings", - HttpMethod.Get, - SyncSettingsWire.serializer(), - session.token, - RELAY_DEFAULT_TIMEOUT_MS, - ) - } catch (error: CloudAccountException) { - if (error.statusCode == HTTP_NOT_FOUND) return null - throw error - } - if (entry.encryptedData.isEmpty() || entry.nonce.isEmpty()) return null - val plaintext = try { - CloudAccountCipher.decrypt( - decode(entry.encryptedData), - session.masterKey, - decode(entry.nonce), - ).decodeToString() - } catch (error: CloudAccountException) { - throw error - } catch (cause: Throwable) { - // Neither the body nor the reason it would not open: this blob is - // the user's provider credentials. - log.error("account settings undecryptable version=${entry.version}") - throw CloudAccountException(CloudAccountFailure.MALFORMED_RESPONSE, null, cause) - } - return CloudSettingsBlob(plaintext, entry.version) - } - public suspend fun deviceRpc( relayUrl: String, session: CloudAccountSession, @@ -285,11 +204,15 @@ public class CloudAccountClient internal constructor( ): T { val target = targetDeviceId.trim() if (target.isEmpty()) throw CloudAccountException(CloudAccountFailure.MALFORMED_RESPONSE) - val nonce = Random.Default.nextBytes(12) + val peer = requestWithoutBody(relayUrl, + "/api/devices/" + encodePathSegment(target) + "/key", HttpMethod.Get, + DeviceKeyWire.serializer(), session.token, RELAY_DEFAULT_TIMEOUT_MS) + val messageKey = DeviceIdentity.messageKey(session.masterKey, decode(peer.publicKey)) + val nonce = DeviceIdentity.randomBytes(12) val plain = RelayJson.encodeToString(RemoteCommand.serializer(), command).encodeToByteArray() - val encrypted = CloudAccountCipher.encrypt(plain, session.masterKey, nonce) + val encrypted = CloudAccountCipher.encrypt(plain, messageKey, nonce) val response = request( - relayUrl.trim().ifEmpty { DEFAULT_CLOUD_RELAY_URL }.trimEnd('/'), + relayUrl, "/api/devices/" + encodePathSegment(target) + "/rpc", HttpMethod.Post, EncryptedPayload.serializer(), @@ -301,7 +224,7 @@ public class CloudAccountClient internal constructor( val decoded = try { CloudAccountCipher.decrypt( decode(response.encryptedData), - session.masterKey, + messageKey, decode(response.nonce), ).decodeToString() } catch (error: CloudAccountException) { @@ -318,22 +241,6 @@ public class CloudAccountClient internal constructor( } } - private suspend fun unwrapMasterKey(kek: ByteArray, wrapped: String): ByteArray { - val parts = wrapped.split('.') - if (parts.size != 2 || kek.size != 32) { - throw CloudAccountException(CloudAccountFailure.MALFORMED_RESPONSE) - } - return try { - CloudAccountCipher.decrypt(decode(parts[0]), kek, decode(parts[1])).also { masterKey -> - if (masterKey.size != 32) throw CloudAccountException(CloudAccountFailure.MALFORMED_RESPONSE) - } - } catch (error: CloudAccountException) { - throw error - } catch (_: Throwable) { - throw CloudAccountException(CloudAccountFailure.AUTHENTICATION) - } - } - private suspend fun request( relayUrl: String, path: String, @@ -364,7 +271,7 @@ public class CloudAccountClient internal constructor( timeoutMs: Long, ): Response { val response = try { - client.request(relayUrl + path) { + client.request(requireNotNull(normalizeAccountRelayUrl(relayUrl)) + path) { this.method = method contentType(ContentType.Application.Json) accept(ContentType.Application.Json) @@ -397,6 +304,8 @@ public class CloudAccountClient internal constructor( } public companion object { + public fun generateDeviceSecret(): ByteArray = DeviceIdentity.generateSecret() + public fun create(): CloudAccountClient = CloudAccountClient(relayHttpClient(), TransportLog.None) public fun create(log: TransportLog): CloudAccountClient = CloudAccountClient(relayHttpClient(), log) @@ -408,17 +317,7 @@ public class CloudAccountClient internal constructor( } } -/** - * Command transport over a signed-in account, i.e. `POST /api/devices/{id}/rpc`. - * - * The envelope differs from [RoomRemoteCommandTransport]'s — the key is the - * account's master key rather than a pairing handshake's — but everything above - * a transport is written against one contract, so the two failure vocabularies - * are reconciled here rather than left for each caller to learn: a - * [CloudAccountException] becomes the [RelayFailure] that says the same thing, - * and a desktop that answered `{"resp":"error"}` is a rejection rather than a - * reply, exactly as it is on the paired path. - */ +/** Device-to-device commands encrypted using authenticated X25519 public keys. */ public class AccountDeviceCommandTransport public constructor( private val client: CloudAccountClient, private val relayUrl: String, @@ -438,8 +337,8 @@ public class AccountDeviceCommandTransport public constructor( command: RemoteCommand, timeoutMs: Long, ): T { - val label = "cmd=${command.cmd} request=${shortRequestId(command.requestId.orEmpty())} " + - "device=${shortRoomId(targetDeviceId)}" + val label = "cmd=${command.cmd} request=${command.requestId.orEmpty().take(12)} " + + "device=${targetDeviceId.take(12)}" log.info("command start $label") val response = try { @@ -461,7 +360,7 @@ public class AccountDeviceCommandTransport public constructor( } private fun CloudAccountFailure.asRelayFailure(): RelayFailure = when (this) { - CloudAccountFailure.INVALID_CREDENTIALS, CloudAccountFailure.AUTHENTICATION -> RelayFailure.PairRejected + CloudAccountFailure.INVALID_CREDENTIALS, CloudAccountFailure.AUTHENTICATION -> RelayFailure.AuthenticationRequired CloudAccountFailure.RATE_LIMITED -> RelayFailure.RateLimited CloudAccountFailure.RELAY_UNAVAILABLE -> RelayFailure.RelayUnavailable(HTTP_SERVER_ERROR) CloudAccountFailure.NETWORK -> RelayFailure.NetworkUnreachable @@ -477,45 +376,36 @@ private fun CloudAccountFailure.asRelayFailure(): RelayFailure = when (this) { private const val HTTP_SERVER_ERROR = 500 @Serializable -private data class LoginChallengeRequest(val username: String) - +public data class GitHubAuthorization( + public val transactionId: String, + public val transactionSecret: String, + public val authorizationUrl: String, + public val expiresAt: Long, + public val pollIntervalSeconds: Int, +) { + override fun toString(): String = "GitHubAuthorization()" +} +@Serializable +private data class GitHubPollRequest(val transactionId: String, val transactionSecret: String) +@Serializable +public data class GitHubAuthorizationPoll(public val status: String, public val tokens: GitHubTokens? = null) +@Serializable +public data class GitHubTokens(public val accessToken: String) { + override fun toString(): String = "GitHubTokens()" +} @Serializable private data class LoginRequest( - val username: String, - @SerialName("password_hash") val passwordHash: String, + @SerialName("access_token") val accessToken: String, @SerialName("device_id") val deviceId: String, @SerialName("device_name") val deviceName: String, @SerialName("device_kind") val deviceKind: String, + @SerialName("public_key") val publicKey: String, + @SerialName("request_id") val requestId: String, ) - -@Serializable -private data class AccountChallenge( - val salt: String, - @SerialName("kdf_salt") val kdfSalt: String, - @SerialName("argon2_params") val argon2Params: String, - @SerialName("wrapped_master_key") val wrappedMasterKey: String, -) - -@Serializable -private data class Argon2ParamsWire(val m: Int, val t: Int, val p: Int) - @Serializable private data class AccountAuthResponse(val token: String, @SerialName("user_id") val userId: String) - -/** - * Defaults on every field: the relay answers this endpoint with an empty object - * for an account that has never synced, and that has to read as "nothing here" - * rather than as a malformed response. - */ @Serializable -private data class SyncSettingsWire( - @SerialName("encrypted_data") val encryptedData: String = "", - val nonce: String = "", - val version: Long = 0, -) - -/** The relay's "this account has no settings document", not an error. */ -private const val HTTP_NOT_FOUND = 404 +private data class DeviceKeyWire(@SerialName("public_key") val publicKey: String) @Serializable private data class AccountDeviceWire( diff --git a/src/apps/mobile/shared/core-transport/src/commonMain/kotlin/com/openbitfun/mobile/core/transport/RelayDescriptor.kt b/src/apps/mobile/shared/core-transport/src/commonMain/kotlin/com/openbitfun/mobile/core/transport/RelayDescriptor.kt deleted file mode 100644 index 978c8ed365..0000000000 --- a/src/apps/mobile/shared/core-transport/src/commonMain/kotlin/com/openbitfun/mobile/core/transport/RelayDescriptor.kt +++ /dev/null @@ -1,174 +0,0 @@ -package com.openbitfun.mobile.core.transport - -import io.ktor.http.decodeURLQueryComponent - -/** - * Everything a pairing URL (or QR code) carries, ported from `RemoteDescriptor` - * in `model/RemoteModels.ets`. - * - * [publicKey] is the desktop's X25519 public key — public by construction — but - * [roomId] is a bearer capability for the pairing exchange, which is why - * [toString] truncates it. See [RelayDescriptorParser] for the accepted shapes. - */ -public data class RelayDescriptor( - val relayUrl: String, - val roomId: String, - val publicKey: String, - val accountAuth: Boolean, - val accountUsername: String, -) { - override fun toString(): String = - "RelayDescriptor(relayUrl=$relayUrl, roomId=${shortRoomId(roomId)}, " + - "accountAuth=$accountAuth, accountUsername=$accountUsername)" -} - -/** Why a pairing URL could not be read. */ -public enum class RelayDescriptorProblem { - /** Blank input. */ - Empty, - - /** `room` or `pk` is absent or empty. */ - MissingParameters, - - /** A percent-escape in the query is not decodable. */ - UndecodableQuery, -} - -public class RelayDescriptorException( - public val problem: RelayDescriptorProblem, - cause: Throwable? = null, -) : Exception(problem.name, cause) - -/** - * Reads the pairing descriptor out of whatever the desktop put on screen. - * - * Three shapes are accepted, in the order the HarmonyOS parser tries them: - * a hash route (`https://host/r/x#/pair?room=…`), any URL with a query string, - * and a bare query string with no URL around it. The last one exists because - * the desktop's own pairing text has been pasted by hand. - */ -public object RelayDescriptorParser { - private const val PAIR_ROUTE = "#/pair?" - - private val WSS_SCHEME = Regex("^wss://") - private val WS_SCHEME = Regex("^ws://") - private val WS_SUFFIX = Regex("/ws/?$") - private val TRAILING_SLASH = Regex("/$") - private val LAST_PATH_SEGMENT = Regex("/[^/]*$") - - /** - * @throws RelayDescriptorException when [input] is blank, is missing `room` - * or `pk`, or contains an invalid percent-escape. - */ - public fun parse(input: String): RelayDescriptor { - val value = input.trim() - if (value.isEmpty()) throw RelayDescriptorException(RelayDescriptorProblem.Empty) - - val params = parseQuery(extractQuery(value)) - val roomId = params["room"].orEmpty() - val publicKey = params["pk"].orEmpty() - if (roomId.isEmpty() || publicKey.isEmpty()) { - throw RelayDescriptorException(RelayDescriptorProblem.MissingParameters) - } - - return RelayDescriptor( - relayUrl = resolveRelayBaseUrl(value, params["relay"].orEmpty()), - roomId = roomId, - publicKey = publicKey, - accountAuth = params["auth"] == "account", - accountUsername = params["user"].orEmpty().trim(), - ) - } - - /** - * Whether [input] describes a room that requires account credentials. - * - * Returns `false` for anything unparseable, matching the HarmonyOS helper: - * the pairing screen calls this while the user is still typing, so a partial - * URL is an ordinary state rather than an error. - */ - public fun accountAuthRequired(input: String): Boolean = parseOrNull(input)?.accountAuth == true - - /** The `user` hint from [input], or `""` when there is none. */ - public fun accountUsername(input: String): String = - parseOrNull(input)?.accountUsername.orEmpty() - - private fun parseOrNull(input: String): RelayDescriptor? = - try { - parse(input) - } catch (_: RelayDescriptorException) { - null - } - - private fun extractQuery(value: String): String { - val pairIndex = value.indexOf(PAIR_ROUTE) - if (pairIndex >= 0) return value.substring(pairIndex + PAIR_ROUTE.length) - val questionIndex = value.indexOf('?') - if (questionIndex >= 0) return value.substring(questionIndex + 1) - return value - } - - private fun parseQuery(query: String): Map { - val params = mutableMapOf() - for (pair in query.split('&')) { - val eq = pair.indexOf('=') - val rawKey = if (eq >= 0) pair.substring(0, eq) else pair - val rawValue = if (eq >= 0) pair.substring(eq + 1) else "" - if (rawKey.isEmpty()) continue - params[decodeComponent(rawKey)] = decodeComponent(rawValue) - } - return params - } - - // decodeURLQueryComponent defaults to plusIsSpace = false, which is what - // matches the JS decodeURIComponent the desktop and HarmonyOS both use. A - // literal '+' in a base64 public key must survive as '+'. - private fun decodeComponent(raw: String): String = - try { - raw.decodeURLQueryComponent() - } catch (cause: Throwable) { - throw RelayDescriptorException(RelayDescriptorProblem.UndecodableQuery, cause) - } - - private fun resolveRelayBaseUrl(fullUrl: String, relayParam: String): String { - // An explicit relay= wins, after being normalised from the websocket URL - // the desktop advertises to the HTTP origin this client posts to. - val relay = relayParam - .let { WSS_SCHEME.replaceFirst(it, "https://") } - .let { WS_SCHEME.replaceFirst(it, "http://") } - .let { WS_SUFFIX.replaceFirst(it, "") } - .let { TRAILING_SLASH.replaceFirst(it, "") } - if (relay.isNotEmpty()) return relay - - val withoutHash = fullUrl.substringBefore('#') - val withoutQuery = withoutHash.substringBefore('?') - - // A relay-hosted pairing page lives under /r/, so the origin is - // everything before that segment — which may include a path prefix when - // the relay is mounted behind a reverse proxy. - val relayRouteIndex = withoutQuery.indexOf("/r/") - if (relayRouteIndex >= 0) { - return TRAILING_SLASH.replaceFirst(withoutQuery.substring(0, relayRouteIndex), "") - } - - val schemeIndex = withoutQuery.indexOf("://") - if (schemeIndex >= 0) { - val pathIndex = withoutQuery.indexOf('/', schemeIndex + 3) - val origin = if (pathIndex >= 0) withoutQuery.substring(0, pathIndex) else withoutQuery - return TRAILING_SLASH.replaceFirst(origin, "") - } - - return TRAILING_SLASH.replaceFirst(LAST_PATH_SEGMENT.replaceFirst(withoutQuery, ""), "") - } -} - -/** First 8 characters — enough to correlate log lines, not enough to replay. */ -internal fun shortRoomId(roomId: String): String = - if (roomId.length <= 8) roomId else roomId.substring(0, 8) - -/** Last 12 characters; request ids are generated with the entropy at the end. */ -internal fun shortRequestId(requestId: String): String = when { - requestId.isEmpty() -> "none" - requestId.length <= 12 -> requestId - else -> requestId.substring(requestId.length - 12) -} diff --git a/src/apps/mobile/shared/core-transport/src/commonMain/kotlin/com/openbitfun/mobile/core/transport/RelayFailure.kt b/src/apps/mobile/shared/core-transport/src/commonMain/kotlin/com/openbitfun/mobile/core/transport/RelayFailure.kt index 7be8a0f512..6fc8bcf2f4 100644 --- a/src/apps/mobile/shared/core-transport/src/commonMain/kotlin/com/openbitfun/mobile/core/transport/RelayFailure.kt +++ b/src/apps/mobile/shared/core-transport/src/commonMain/kotlin/com/openbitfun/mobile/core/transport/RelayFailure.kt @@ -10,11 +10,11 @@ package com.openbitfun.mobile.core.transport * [RelayFailure] into text. */ public sealed interface RelayFailure { - /** The relay accepted the request but the desktop refused to pair. 401 / 403. */ - public data object PairRejected : RelayFailure + /** The account session is missing, expired or no longer authorized. */ + public data object AuthenticationRequired : RelayFailure - /** No such room, usually a stale or already-consumed pairing URL. 404. */ - public data object RoomNotFound : RelayFailure + /** The selected device is absent from the authenticated directory. */ + public data object DeviceNotFound : RelayFailure /** The desktop did not answer in time. 408 / 504, or a client-side timeout. */ public data object Timeout : RelayFailure @@ -49,12 +49,3 @@ public class RelayTransportException( public val failure: RelayFailure, cause: Throwable? = null, ) : Exception(failure.toString(), cause) - -internal fun httpFailureFor(statusCode: Int): RelayFailure = when { - statusCode == 401 || statusCode == 403 -> RelayFailure.PairRejected - statusCode == 404 -> RelayFailure.RoomNotFound - statusCode == 408 || statusCode == 504 -> RelayFailure.Timeout - statusCode == 429 -> RelayFailure.RateLimited - statusCode >= 500 -> RelayFailure.RelayUnavailable(statusCode) - else -> RelayFailure.UnexpectedStatus(statusCode) -} diff --git a/src/apps/mobile/shared/core-transport/src/commonMain/kotlin/com/openbitfun/mobile/core/transport/RelayHttpClient.kt b/src/apps/mobile/shared/core-transport/src/commonMain/kotlin/com/openbitfun/mobile/core/transport/RelayHttpClient.kt index f646caf9bd..e1c381c60f 100644 --- a/src/apps/mobile/shared/core-transport/src/commonMain/kotlin/com/openbitfun/mobile/core/transport/RelayHttpClient.kt +++ b/src/apps/mobile/shared/core-transport/src/commonMain/kotlin/com/openbitfun/mobile/core/transport/RelayHttpClient.kt @@ -50,61 +50,3 @@ private fun HttpClientConfig<*>.configureForRelay() { requestTimeoutMillis = RELAY_DEFAULT_TIMEOUT_MS } } - -/** - * The relay's two POST endpoints, with every outcome expressed as a - * [RelayFailure]. - * - * Bodies are serialised with [RelayJson] by hand rather than through - * ContentNegotiation, so the wire format does not depend on which converters - * the surrounding app happens to have installed. - */ -internal class RelayEndpoints( - private val httpClient: HttpClient, - private val relayUrl: String, - private val json: Json = RelayJson, -) { - suspend fun postForEncryptedPayload( - path: String, - body: String, - timeoutMs: Long, - ): EncryptedPayload { - val text = post(path, body, timeoutMs) - return try { - json.decodeFromString(EncryptedPayload.serializer(), text) - } catch (cause: SerializationException) { - // The body is attacker-influenced and may contain anything; only the - // fact that it did not parse is reportable. - throw RelayTransportException(RelayFailure.MalformedResponse, cause) - } - } - - private suspend fun post(path: String, body: String, timeoutMs: Long): String { - val response = try { - httpClient.post("$relayUrl$path") { - contentType(ContentType.Application.Json) - accept(ContentType.Application.Json) - setBody(body) - timeout { requestTimeoutMillis = timeoutMs } - } - } catch (cancellation: CancellationException) { - // Caller-side cancellation is not a transport failure and must keep - // propagating as cancellation, or structured concurrency breaks. - throw cancellation - } catch (timeout: HttpRequestTimeoutException) { - throw RelayTransportException(RelayFailure.Timeout, timeout) - } catch (timeout: ConnectTimeoutException) { - throw RelayTransportException(RelayFailure.Timeout, timeout) - } catch (timeout: SocketTimeoutException) { - throw RelayTransportException(RelayFailure.Timeout, timeout) - } catch (cause: Throwable) { - // DNS, TLS, refused connections and engine-specific I/O errors have - // no common supertype across ktor's four engines. - throw RelayTransportException(RelayFailure.NetworkUnreachable, cause) - } - - val status = response.status.value - if (status !in 200..299) throw RelayTransportException(httpFailureFor(status)) - return response.bodyAsText() - } -} diff --git a/src/apps/mobile/shared/core-transport/src/commonMain/kotlin/com/openbitfun/mobile/core/transport/RelayPairing.kt b/src/apps/mobile/shared/core-transport/src/commonMain/kotlin/com/openbitfun/mobile/core/transport/RelayPairing.kt deleted file mode 100644 index df47a17c86..0000000000 --- a/src/apps/mobile/shared/core-transport/src/commonMain/kotlin/com/openbitfun/mobile/core/transport/RelayPairing.kt +++ /dev/null @@ -1,153 +0,0 @@ -package com.openbitfun.mobile.core.transport - -import com.openbitfun.mobile.core.crypto.RemoteCryptoException -import com.openbitfun.mobile.core.crypto.RemoteHandshake -import com.openbitfun.mobile.core.protocol.ChallengeCommand -import com.openbitfun.mobile.core.protocol.EncryptedPayload -import com.openbitfun.mobile.core.protocol.InitialSyncResponse -import com.openbitfun.mobile.core.protocol.PairChallengeResponse -import com.openbitfun.mobile.core.protocol.PairRequest -import com.openbitfun.mobile.core.protocol.RelayJson -import com.openbitfun.mobile.core.protocol.isError -import io.ktor.client.HttpClient - -/** - * Who this device claims to be when pairing. - * - * [password] is only supplied for rooms advertised with `auth=account`. It is a - * credential: it goes into one encrypted command and is not retained here. - */ -public data class PairIdentity( - val userId: String, - val password: String? = null, -) { - override fun toString(): String = - "PairIdentity(userId=$userId, password=${if (password.isNullOrEmpty()) "absent" else "redacted"})" -} - -/** - * A room that has completed the handshake. - * - * The only way to obtain one is [RelayPairing.pair], so a [transport] always has - * a live shared key behind it — the mobile client never holds a half-paired - * room the way `RelayHttpClient`'s optional `crypto` field allows. - */ -public class PairedRoom internal constructor( - public val descriptor: RelayDescriptor, - public val initialSync: InitialSyncResponse, - public val transport: RemoteCommandTransport, -) - -/** - * A pairing client over the platform's default engine — OkHttp on Android, - * Darwin on iOS, both supplied by this module. - * - * This exists so callers never have to name [HttpClient]: Ktor is an - * implementation detail of the transport, and a module that only wants to pair - * should not have to depend on it to say so. - */ -public fun relayPairing(log: TransportLog = TransportLog.None): RelayPairing = - RelayPairing(relayHttpClient(), log) - -/** - * The room pairing handshake, ported from `RelayHttpClient.pair`. - * - * Four steps, all under one shared key derived from the descriptor's public key: - * publish our public key to `/pair`, decrypt the challenge, echo it back through - * `/command`, and decrypt the initial sync that answers it. - */ -public class RelayPairing( - private val httpClient: HttpClient, - private val log: TransportLog = TransportLog.None, -) { - /** - * @param deviceId stable per install; also sent as `mobile_install_id`, - * matching the HarmonyOS client, which passes the same value for both. - * @param handshake defaults to a fresh ephemeral key pair; tests pass one - * built over a seeded nonce source. It cannot be a default argument because - * creating it suspends. - * @throws RelayTransportException on any transport, crypto or peer-side - * failure. - */ - public suspend fun pair( - descriptor: RelayDescriptor, - deviceId: String, - deviceName: String, - identity: PairIdentity, - handshake: RemoteHandshake? = null, - ): PairedRoom { - val keys = handshake ?: RemoteHandshake.create() - val room = shortRoomId(descriptor.roomId) - log.info("pair start room=$room") - - val endpoints = RelayEndpoints(httpClient, descriptor.relayUrl) - val session = try { - keys.accept(descriptor.publicKey) - } catch (cause: RemoteCryptoException) { - // A descriptor whose pk is not a usable X25519 key is a bad pairing - // URL, not a relay problem — but the relay was never contacted, so - // there is no status code to report. - log.error("pair rejected room=$room reason=descriptor-key") - throw RelayTransportException(RelayFailure.MalformedResponse, cause) - } - - val challengePayload = endpoints.postForEncryptedPayload( - path = pairPath(descriptor.roomId), - body = RelayJson.encodeToString( - PairRequest.serializer(), - PairRequest( - publicKey = keys.publicKeyBase64, - deviceId = deviceId, - deviceName = deviceName, - ), - ), - timeoutMs = RELAY_DEFAULT_TIMEOUT_MS, - ) - val challenge = try { - session.decryptJson(PairChallengeResponse.serializer(), challengePayload) - } catch (cause: RemoteCryptoException) { - log.error("pair challenge undecryptable room=$room") - throw RelayTransportException(RelayFailure.MalformedResponse, cause) - } - log.info("pair challenge received room=$room") - - val transport = RoomRemoteCommandTransport( - endpoints = endpoints, - roomId = descriptor.roomId, - session = session, - log = log, - ) - - // The echo goes out as a raw encrypted body rather than through the - // command transport: at this point the peer has not yet accepted us, so - // the reply is an initial sync rather than a command result. - val challengeCommand = ChallengeCommand( - challengeEcho = challenge.challenge, - deviceId = deviceId, - deviceName = deviceName, - mobileInstallId = deviceId, - userId = identity.userId.trim(), - password = identity.password?.takeIf { it.isNotEmpty() }, - ) - val encrypted = session.encryptJson(ChallengeCommand.serializer(), challengeCommand) - val syncPayload = endpoints.postForEncryptedPayload( - path = commandPath(descriptor.roomId), - body = RelayJson.encodeToString(EncryptedPayload.serializer(), encrypted), - timeoutMs = RELAY_DEFAULT_TIMEOUT_MS, - ) - val initialSync = try { - session.decryptJson(InitialSyncResponse.serializer(), syncPayload) - } catch (cause: RemoteCryptoException) { - log.error("pair sync undecryptable room=$room") - throw RelayTransportException(RelayFailure.MalformedResponse, cause) - } - - if (initialSync.isError) { - log.warn("pair rejected room=$room") - throw RelayTransportException(RelayFailure.RemoteRejected(initialSync.message)) - } - log.info("pair complete room=$room") - - return PairedRoom(descriptor, initialSync, transport) - } -} diff --git a/src/apps/mobile/shared/core-transport/src/commonMain/kotlin/com/openbitfun/mobile/core/transport/RemoteCommandTransport.kt b/src/apps/mobile/shared/core-transport/src/commonMain/kotlin/com/openbitfun/mobile/core/transport/RemoteCommandTransport.kt index a74d08cf81..ac0ad92472 100644 --- a/src/apps/mobile/shared/core-transport/src/commonMain/kotlin/com/openbitfun/mobile/core/transport/RemoteCommandTransport.kt +++ b/src/apps/mobile/shared/core-transport/src/commonMain/kotlin/com/openbitfun/mobile/core/transport/RemoteCommandTransport.kt @@ -1,31 +1,12 @@ package com.openbitfun.mobile.core.transport -import com.openbitfun.mobile.core.crypto.RemoteCryptoException -import com.openbitfun.mobile.core.crypto.RemoteCryptoSession import com.openbitfun.mobile.core.protocol.CommandStatus -import com.openbitfun.mobile.core.protocol.EncryptedPayload -import com.openbitfun.mobile.core.protocol.RelayJson import com.openbitfun.mobile.core.protocol.RemoteCommand -import com.openbitfun.mobile.core.protocol.isError import kotlinx.serialization.DeserializationStrategy import kotlinx.serialization.serializer -/** - * Sends one command to the paired desktop and returns its reply. - * - * Ported from `RemoteCommandTransport` in `services/RemoteCommandTransport.ets`, - * minus its `reset()`. That method exists there because `RelayHttpClient` is a - * long-lived singleton rebound on every pairing, so it has to be emptied when a - * session ends. Here a transport is created by a successful pairing and owns an - * immutable session, so ending a session means dropping the object — there is - * no state left to clear, and no window in which a transport exists without a - * key. - */ +/** A command addressed to a device in the authenticated account directory. */ public interface RemoteCommandTransport { - /** - * @throws RelayTransportException on any transport, decoding or peer-side - * failure; [RelayFailure.RemoteRejected] carries the desktop's own message. - */ public suspend fun send( deserializer: DeserializationStrategy, command: RemoteCommand, @@ -33,62 +14,7 @@ public interface RemoteCommandTransport { ): T } -/** Reified convenience over [RemoteCommandTransport.send]. */ public suspend inline fun RemoteCommandTransport.send( command: RemoteCommand, timeoutMs: Long = RELAY_DEFAULT_TIMEOUT_MS, ): T = send(serializer(), command, timeoutMs) - -/** - * Command transport over a paired room, i.e. `POST /api/rooms/{roomId}/command` - * with an AES-GCM envelope in both directions. - */ -public class RoomRemoteCommandTransport internal constructor( - private val endpoints: RelayEndpoints, - private val roomId: String, - private val session: RemoteCryptoSession, - private val log: TransportLog, -) : RemoteCommandTransport { - override suspend fun send( - deserializer: DeserializationStrategy, - command: RemoteCommand, - timeoutMs: Long, - ): T { - val label = "cmd=${command.cmd} request=${shortRequestId(command.requestId.orEmpty())} " + - "room=${shortRoomId(roomId)}" - log.info("command start $label") - - val encrypted = try { - session.encryptJson(RemoteCommand.serializer(), command) - } catch (cause: RemoteCryptoException) { - log.error("command encrypt failed $label") - throw RelayTransportException(RelayFailure.MalformedResponse, cause) - } - - val responsePayload = endpoints.postForEncryptedPayload( - path = commandPath(roomId), - body = RelayJson.encodeToString(EncryptedPayload.serializer(), encrypted), - timeoutMs = timeoutMs, - ) - - val response = try { - session.decryptJson(deserializer, responsePayload) - } catch (cause: RemoteCryptoException) { - log.error("command decrypt failed $label") - throw RelayTransportException(RelayFailure.MalformedResponse, cause) - } - - if (response.isError) { - // The message is the desktop's, already localized by the desktop. - // It is echoed to the user but never used for control flow. - log.warn("command rejected $label") - throw RelayTransportException(RelayFailure.RemoteRejected(response.message)) - } - log.info("command done $label resp=${response.resp ?: "unknown"}") - return response - } -} - -internal fun commandPath(roomId: String): String = "/api/rooms/$roomId/command" - -internal fun pairPath(roomId: String): String = "/api/rooms/$roomId/pair" diff --git a/src/apps/mobile/shared/core-transport/src/commonTest/kotlin/com/openbitfun/mobile/core/transport/CloudAccountClientTest.kt b/src/apps/mobile/shared/core-transport/src/commonTest/kotlin/com/openbitfun/mobile/core/transport/CloudAccountClientTest.kt index ef00895fea..ddf43a72b7 100644 --- a/src/apps/mobile/shared/core-transport/src/commonTest/kotlin/com/openbitfun/mobile/core/transport/CloudAccountClientTest.kt +++ b/src/apps/mobile/shared/core-transport/src/commonTest/kotlin/com/openbitfun/mobile/core/transport/CloudAccountClientTest.kt @@ -1,8 +1,7 @@ package com.openbitfun.mobile.core.transport import com.openbitfun.mobile.core.crypto.CloudAccountCipher -import com.openbitfun.mobile.core.crypto.CloudAccountKdfParams -import com.openbitfun.mobile.core.crypto.PlatformArgon2id +import com.openbitfun.mobile.core.crypto.DeviceIdentity import com.openbitfun.mobile.core.protocol.CommandStatusResponse import com.openbitfun.mobile.core.protocol.EncryptedPayload import com.openbitfun.mobile.core.protocol.RelayJson @@ -28,40 +27,32 @@ import kotlin.test.assertTrue class CloudAccountClientTest { @Test - fun completesChallengeLoginWithoutSendingPlaintextPassword() = runTest { - val password = "correct horse battery staple" - val salt = ByteArray(16) { it.toByte() } - val kdfSalt = ByteArray(16) { (it + 16).toByte() } - val params = CloudAccountKdfParams(8 * 1024, 1, 1) - val masterKey = ByteArray(32) { (it + 32).toByte() } - val nonce = ByteArray(12) { (it + 64).toByte() } - val kek = PlatformArgon2id.derive(password, salt, params) - val wrapped = CloudAccountCipher.encrypt(masterKey, kek, nonce) - val requests = mutableListOf() + fun authorizationAcceptsTheIdentityAuthorityGithubUrlAndRejectsOtherDestinations() = runTest { + for (url in listOf("https://github.com/login/oauth/authorize?state=test", "https://github.com.evil.example/login/oauth/authorize", "https://github.com/login", "https://user@github.com/login/oauth/authorize", "http://github.com/login/oauth/authorize")) { + val engine = MockEngine { json("""{"transactionId":"txn","transactionSecret":"secret","authorizationUrl":"$url","expiresAt":9999999999,"pollIntervalSeconds":3}""") } + val client = CloudAccountClient(relayHttpClient(engine)) + if (url == "https://github.com/login/oauth/authorize?state=test") assertEquals(url, client.startAuthorization(DEFAULT_CLOUD_RELAY_URL).authorizationUrl) + else assertFailsWith { client.startAuthorization(DEFAULT_CLOUD_RELAY_URL) } + } + } + + @Test + fun githubLoginRegistersOnlyThePublicDeviceKey() = runTest { + val bodies = mutableListOf() val engine = MockEngine { request -> - val body = request.text() - requests += body - when (request.url.encodedPath) { - "/relay/api/auth/login/challenge" -> json( - """{"salt":"${Base64.Default.encode(salt)}","kdf_salt":"${Base64.Default.encode(kdfSalt)}","argon2_params":"{\"m\":8192,\"t\":1,\"p\":1}","wrapped_master_key":"${Base64.Default.encode(wrapped)}.${Base64.Default.encode(nonce)}"}""", - ) - "/relay/api/auth/login" -> json("""{"token":"token-1","user_id":"user-1"}""") - else -> respond("", HttpStatusCode.NotFound) - } + assertEquals("https://remote.openbitfun.com/v/1.0.0/api/auth/login", request.url.toString()) + bodies += RelayJson.parseToJsonElement(request.text()).jsonObject + json("""{"token":"token-1","user_id":"123"}""") } val client = CloudAccountClient(relayHttpClient(engine)) - - val session = client.login("https://relay.test/relay", " user-1 ", password, "device-1", "Android") - - assertEquals("user-1", session.userId) - assertContentEquals(masterKey, session.masterKey) - assertFalse(requests.any { it.contains(password) }) - val expectedProof = Base64.Default.encode(PlatformArgon2id.derive(password, kdfSalt, params)) - assertEquals(expectedProof, RelayJson.parseToJsonElement(requests[1]).jsonObject["password_hash"]?.jsonPrimitive?.content) - // Registering as a phone is what keeps this device out of every other - // device's list of things it can drive. - assertEquals("mobile", RelayJson.parseToJsonElement(requests[1]).jsonObject["device_kind"]?.jsonPrimitive?.content) - assertFalse(session.toString().contains("token-1")) + val first = client.login(DEFAULT_CLOUD_RELAY_URL, "verified-identity", "device-1", "Android", ByteArray(32) { 7 }) + val second = client.login(DEFAULT_CLOUD_RELAY_URL, "verified-identity", "device-2", "iOS", ByteArray(32) { 11 }) + assertEquals("verified-identity", bodies[0]["access_token"]?.jsonPrimitive?.content) + assertEquals(Base64.Default.encode(DeviceIdentity.publicKey(first.masterKey)), bodies[0]["public_key"]?.jsonPrimitive?.content) + assertFalse(first.masterKey.contentEquals(second.masterKey)) + assertFalse(bodies[0].containsKey("password")) + assertFalse(bodies[0].containsKey("master_key")) + assertFalse(first.toString().contains("token-1")) } /** @@ -93,7 +84,7 @@ class CloudAccountClientTest { ) val devices = client.listDevices( - "https://relay.test/relay", + "http://192.168.1.2:9700", CloudAccountSession("token-1", "user-1", ByteArray(32)), "phone-1", ) @@ -103,77 +94,26 @@ class CloudAccountClientTest { assertEquals(null, devices[1].deviceKind) } - @Test - fun fetchSettingsDecryptsTheAccountsDocument() = runTest { - val masterKey = ByteArray(32) { it.toByte() } - val nonce = ByteArray(12) { (it + 90).toByte() } - val document = """{"config":{"ai":{"models":[]}}}""" - val engine = MockEngine { request -> - assertEquals("/relay/api/sync/settings", request.url.encodedPath) - assertEquals("Bearer token-1", request.headers[HttpHeaders.Authorization]) - val sealed = CloudAccountCipher.encrypt(document.encodeToByteArray(), masterKey, nonce) - json( - """{"encrypted_data":"${Base64.Default.encode(sealed)}","nonce":"${Base64.Default.encode(nonce)}","version":7}""", - ) - } - val client = CloudAccountClient(relayHttpClient(engine)) - - val blob = client.fetchSettings("https://relay.test/relay", CloudAccountSession("token-1", "user-1", masterKey)) - - assertEquals(document, blob?.plaintext) - assertEquals(7, blob?.version) - // The document carries the user's provider keys, so it must not be one - // careless log line away from a bug report. - assertFalse(blob.toString().contains("models")) - } - - /** - * An account that has never synced has no settings, which is not a failure: - * the relay says 404 and the phone simply has no account models to list. - * An empty entry is the same answer written differently. - */ - @Test - fun fetchSettingsTreatsAMissingDocumentAsNoDocument() = runTest { - val session = CloudAccountSession("token-1", "user-1", ByteArray(32)) - val absent = CloudAccountClient(relayHttpClient(MockEngine { respond("", HttpStatusCode.NotFound) })) - val blank = CloudAccountClient( - relayHttpClient(MockEngine { json("""{"encrypted_data":"","nonce":"","version":1}""") }), - ) - - assertEquals(null, absent.fetchSettings("https://relay.test/relay", session)) - assertEquals(null, blank.fetchSettings("https://relay.test/relay", session)) - } - - @Test - fun fetchSettingsReportsADocumentItCannotOpen() = runTest { - val engine = MockEngine { - json("""{"encrypted_data":"${Base64.Default.encode(ByteArray(48))}","nonce":"${Base64.Default.encode(ByteArray(12))}","version":2}""") - } - val client = CloudAccountClient(relayHttpClient(engine)) - - val error = assertFailsWith { - client.fetchSettings("https://relay.test/relay", CloudAccountSession("token-1", "user-1", ByteArray(32))) - } - - assertEquals(CloudAccountFailure.MALFORMED_RESPONSE, error.failure) - } - @Test fun accountDeviceTransportEncryptsCommandAndDecryptsResponse() = runTest { val masterKey = ByteArray(32) { it.toByte() } val session = CloudAccountSession("token-1", "user-1", masterKey) + val peerSecret = ByteArray(32) { 11 } + val peerPublic = DeviceIdentity.publicKey(peerSecret) + val messageKey = DeviceIdentity.messageKey(peerSecret, DeviceIdentity.publicKey(masterKey)) val engine = MockEngine { request -> assertEquals("Bearer token-1", request.headers[HttpHeaders.Authorization]) + if (request.url.encodedPath.endsWith("/key")) return@MockEngine json("""{"public_key":"${Base64.Default.encode(peerPublic)}"}""") val envelope = RelayJson.decodeFromString(EncryptedPayload.serializer(), request.text()) val commandText = CloudAccountCipher.decrypt( Base64.Default.decode(envelope.encryptedData), - masterKey, + messageKey, Base64.Default.decode(envelope.nonce), ).decodeToString() assertEquals("ping", RelayJson.decodeFromString(RemoteCommand.serializer(), commandText).cmd) val nonce = ByteArray(12) { (it + 20).toByte() } val plain = RelayJson.encodeToString(CommandStatusResponse.serializer(), CommandStatusResponse("ok", null)) - val encrypted = CloudAccountCipher.encrypt(plain.encodeToByteArray(), masterKey, nonce) + val encrypted = CloudAccountCipher.encrypt(plain.encodeToByteArray(), messageKey, nonce) json( RelayJson.encodeToString( EncryptedPayload.serializer(), @@ -182,7 +122,7 @@ class CloudAccountClientTest { ) } val client = CloudAccountClient(relayHttpClient(engine)) - val transport = AccountDeviceCommandTransport(client, "https://relay.test/relay", session, "desktop 1") + val transport = AccountDeviceCommandTransport(client, "http://192.168.1.2:9700", session, "desktop 1") val response = transport.send(RemoteCommand(cmd = "ping")) @@ -199,13 +139,17 @@ class CloudAccountClientTest { fun accountDeviceTransportReportsARefusalRatherThanReturningIt() = runTest { val masterKey = ByteArray(32) { it.toByte() } val session = CloudAccountSession("token-1", "user-1", masterKey) - val engine = MockEngine { + val peerSecret = ByteArray(32) { 11 } + val peerPublic = DeviceIdentity.publicKey(peerSecret) + val messageKey = DeviceIdentity.messageKey(peerSecret, DeviceIdentity.publicKey(masterKey)) + val engine = MockEngine { request -> + if (request.url.encodedPath.endsWith("/key")) return@MockEngine json("""{"public_key":"${Base64.Default.encode(peerPublic)}"}""") val nonce = ByteArray(12) { (it + 20).toByte() } val plain = RelayJson.encodeToString( CommandStatusResponse.serializer(), CommandStatusResponse("error", "No workspace is open"), ) - val encrypted = CloudAccountCipher.encrypt(plain.encodeToByteArray(), masterKey, nonce) + val encrypted = CloudAccountCipher.encrypt(plain.encodeToByteArray(), messageKey, nonce) json( RelayJson.encodeToString( EncryptedPayload.serializer(), @@ -215,7 +159,7 @@ class CloudAccountClientTest { } val transport = AccountDeviceCommandTransport( CloudAccountClient(relayHttpClient(engine)), - "https://relay.test/relay", + "http://192.168.1.2:9700", session, "desktop-1", ) @@ -238,7 +182,7 @@ class CloudAccountClientTest { val engine = MockEngine { respond("upstream is down", HttpStatusCode.ServiceUnavailable) } val transport = AccountDeviceCommandTransport( CloudAccountClient(relayHttpClient(engine)), - "https://relay.test/relay", + "http://192.168.1.2:9700", session, "desktop-1", ) diff --git a/src/apps/mobile/shared/core-transport/src/commonTest/kotlin/com/openbitfun/mobile/core/transport/DesktopPeer.kt b/src/apps/mobile/shared/core-transport/src/commonTest/kotlin/com/openbitfun/mobile/core/transport/DesktopPeer.kt deleted file mode 100644 index f85627841b..0000000000 --- a/src/apps/mobile/shared/core-transport/src/commonTest/kotlin/com/openbitfun/mobile/core/transport/DesktopPeer.kt +++ /dev/null @@ -1,75 +0,0 @@ -package com.openbitfun.mobile.core.transport - -import com.openbitfun.mobile.core.crypto.RemoteCryptoSession -import com.openbitfun.mobile.core.crypto.RemoteHandshake -import com.openbitfun.mobile.core.protocol.EncryptedPayload -import com.openbitfun.mobile.core.protocol.PairRequest -import com.openbitfun.mobile.core.protocol.RelayJson -import kotlinx.serialization.DeserializationStrategy -import kotlinx.serialization.SerializationStrategy -import kotlinx.serialization.json.JsonObject - -/** - * The desktop half of the handshake, standing in for the peer that - * `harmonyos/tools/fake-relay.mjs` emulates. - * - * X25519 is symmetric, so the peer runs the same [RemoteHandshake] the client - * does: whichever side calls `accept` with the other's public key derives the - * same shared secret. That means these tests exercise the real cipher rather - * than a stub — a change that broke the envelope layout would fail here, not - * only against a live desktop. - */ -internal class DesktopPeer private constructor(private val handshake: RemoteHandshake) { - val publicKeyBase64: String get() = handshake.publicKeyBase64 - - private var session: RemoteCryptoSession? = null - - /** Derives the shared key from the client's `/pair` body, as the desktop does. */ - suspend fun acceptPairRequest(body: String): PairRequest { - val request = RelayJson.decodeFromString(PairRequest.serializer(), body) - session = handshake.accept(request.publicKey) - return request - } - - suspend fun encrypt(serializer: SerializationStrategy, value: T): String = - RelayJson.encodeToString( - EncryptedPayload.serializer(), - requireSession().encryptJson(serializer, value), - ) - - suspend fun decrypt(deserializer: DeserializationStrategy, body: String): T = - requireSession().decryptJson( - deserializer, - RelayJson.decodeFromString(EncryptedPayload.serializer(), body), - ) - - /** Decrypts without imposing a shape, so tests can assert which keys are on the wire. */ - suspend fun decryptRaw(body: String): JsonObject = decrypt(JsonObject.serializer(), body) - - companion object { - /** 32 lowercase hex characters, the shape `pairing.rs` validates the echo against. */ - const val CHALLENGE: String = "9f2c0a17be4d5386a10c7f43de99b025" - - suspend fun create(): DesktopPeer = DesktopPeer(RemoteHandshake.create()) - } - - private fun requireSession(): RemoteCryptoSession = - session ?: error("DesktopPeer was used before /pair derived a shared key") -} - -/** Collects transport log lines so tests can assert what does *not* appear in them. */ -internal class RecordingLog : TransportLog { - val lines: MutableList = mutableListOf() - - override fun info(message: String) { - lines += message - } - - override fun warn(message: String) { - lines += message - } - - override fun error(message: String) { - lines += message - } -} diff --git a/src/apps/mobile/shared/core-transport/src/commonTest/kotlin/com/openbitfun/mobile/core/transport/RelayDescriptorParserTest.kt b/src/apps/mobile/shared/core-transport/src/commonTest/kotlin/com/openbitfun/mobile/core/transport/RelayDescriptorParserTest.kt deleted file mode 100644 index e1b5e9562a..0000000000 --- a/src/apps/mobile/shared/core-transport/src/commonTest/kotlin/com/openbitfun/mobile/core/transport/RelayDescriptorParserTest.kt +++ /dev/null @@ -1,143 +0,0 @@ -package com.openbitfun.mobile.core.transport - -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFailsWith -import kotlin.test.assertFalse -import kotlin.test.assertTrue - -/** - * The first six cases are the ones already asserted for `RemoteDescriptorParser` - * in `entry/src/test/TransportAndGeneralChatUnit.test.ets`, translated verbatim. - * They are the evidence that this port is behaviour-equivalent, so their inputs - * and expectations are copied rather than rewritten. - */ -class RelayDescriptorParserTest { - @Test - fun parsesHashRouteUrls() { - val descriptor = RelayDescriptorParser.parse( - "https://relay.example.com/r/mobile#/pair?room=room-a&pk=public-key", - ) - assertEquals("room-a", descriptor.roomId) - assertEquals("public-key", descriptor.publicKey) - assertEquals("https://relay.example.com", descriptor.relayUrl) - } - - @Test - fun normalizesRelayWebsocketUrls() { - val descriptor = RelayDescriptorParser.parse( - "https://app.example.com/#/pair?room=room-b&pk=key-b&relay=wss%3A%2F%2Frelay.example.com%2Fws", - ) - assertEquals("https://relay.example.com", descriptor.relayUrl) - } - - @Test - fun parsesAccountPairingMetadata() { - val descriptor = RelayDescriptorParser.parse( - "https://relay.example.com/#/pair?room=room-account&pk=key-account&auth=account&user=alice", - ) - assertTrue(descriptor.accountAuth) - assertEquals("alice", descriptor.accountUsername) - } - - @Test - fun parsesRawQueryStrings() { - val descriptor = RelayDescriptorParser.parse( - "room=room-c&pk=key%2Bc%3D&relay=http%3A%2F%2F127.0.0.1%3A30333", - ) - assertEquals("room-c", descriptor.roomId) - assertEquals("key+c=", descriptor.publicKey) - assertEquals("http://127.0.0.1:30333", descriptor.relayUrl) - } - - @Test - fun rejectsUrlsMissingRoom() { - val failure = assertFailsWith { - RelayDescriptorParser.parse("https://relay.example.com/#/pair?pk=key-only") - } - assertEquals(RelayDescriptorProblem.MissingParameters, failure.problem) - } - - @Test - fun rejectsUrlsMissingPublicKey() { - val failure = assertFailsWith { - RelayDescriptorParser.parse("https://relay.example.com/#/pair?room=room-only") - } - assertEquals(RelayDescriptorProblem.MissingParameters, failure.problem) - } - - @Test - fun rejectsBlankInput() { - val failure = assertFailsWith { - RelayDescriptorParser.parse(" \n ") - } - assertEquals(RelayDescriptorProblem.Empty, failure.problem) - } - - /** - * A base64 public key contains `+`, which `application/x-www-form-urlencoded` - * decoding would turn into a space and silently corrupt the key. The desktop - * and HarmonyOS both use `decodeURIComponent`, which does not. - */ - @Test - fun keepsPlusSignsInBase64Values() { - val descriptor = RelayDescriptorParser.parse("room=room-d&pk=abc+def=") - assertEquals("abc+def=", descriptor.publicKey) - } - - @Test - fun rejectsUndecodableEscapes() { - val failure = assertFailsWith { - RelayDescriptorParser.parse("room=room-e&pk=%zz") - } - assertEquals(RelayDescriptorProblem.UndecodableQuery, failure.problem) - } - - @Test - fun trimsSurroundingWhitespaceFromPastedUrls() { - val descriptor = RelayDescriptorParser.parse( - " https://relay.example.com/#/pair?room=room-f&pk=key-f\n", - ) - assertEquals("room-f", descriptor.roomId) - } - - @Test - fun readsAccountHintsWithoutThrowingOnPartialInput() { - assertFalse(RelayDescriptorParser.accountAuthRequired("https://relay.example.com/#/pair?ro")) - assertEquals("", RelayDescriptorParser.accountUsername("not a url at all")) - assertTrue( - RelayDescriptorParser.accountAuthRequired( - "https://relay.example.com/#/pair?room=r&pk=k&auth=account", - ), - ) - } - - /** A relay behind a path prefix keeps that prefix; only `/r/` is cut. */ - @Test - fun keepsAPathPrefixBeforeTheRelayRoute() { - val descriptor = RelayDescriptorParser.parse( - "https://example.com/openbitfun/r/mobile#/pair?room=room-g&pk=key-g", - ) - assertEquals("https://example.com/openbitfun", descriptor.relayUrl) - } - - @Test - fun fallsBackToTheOriginWhenThereIsNoRelayRoute() { - val descriptor = RelayDescriptorParser.parse( - "https://relay.example.com/some/page?room=room-h&pk=key-h", - ) - assertEquals("https://relay.example.com", descriptor.relayUrl) - } - - /** The room id is a bearer capability for pairing; it must not print in full. */ - @Test - fun toStringTruncatesTheRoomIdAndOmitsTheKey() { - val descriptor = RelayDescriptorParser.parse( - "room=0123456789abcdef&pk=secret-looking-key&relay=https%3A%2F%2Fr.example.com", - ) - val rendered = descriptor.toString() - assertFalse("0123456789abcdef" in rendered) - assertFalse("secret-looking-key" in rendered) - assertTrue("01234567" in rendered) - } -} diff --git a/src/apps/mobile/shared/core-transport/src/commonTest/kotlin/com/openbitfun/mobile/core/transport/RelayPairingTest.kt b/src/apps/mobile/shared/core-transport/src/commonTest/kotlin/com/openbitfun/mobile/core/transport/RelayPairingTest.kt deleted file mode 100644 index 22c6fb4a3e..0000000000 --- a/src/apps/mobile/shared/core-transport/src/commonTest/kotlin/com/openbitfun/mobile/core/transport/RelayPairingTest.kt +++ /dev/null @@ -1,380 +0,0 @@ -package com.openbitfun.mobile.core.transport - -import com.openbitfun.mobile.core.protocol.ChallengeCommand -import com.openbitfun.mobile.core.protocol.InitialSyncResponse -import com.openbitfun.mobile.core.protocol.PairChallengeResponse -import com.openbitfun.mobile.core.protocol.SessionListResponse -import com.openbitfun.mobile.core.protocol.RemoteCommand -import io.ktor.client.engine.mock.MockEngine -import io.ktor.client.engine.mock.respond -import io.ktor.client.engine.mock.toByteArray -import io.ktor.client.request.HttpRequestData -import io.ktor.http.HttpHeaders -import io.ktor.http.HttpStatusCode -import io.ktor.http.headersOf -import kotlinx.coroutines.test.runTest -import kotlin.test.Test -import kotlin.test.assertContains -import kotlin.test.assertEquals -import kotlin.test.assertFailsWith -import kotlin.test.assertFalse -import kotlin.test.assertNull -import kotlin.test.assertTrue - -private const val ROOM_ID = "0123456789abcdef0123456789abcdef" -private const val RELAY_URL = "https://relay.example.com" -private const val DEVICE_ID = "android-install-42" -private const val DEVICE_NAME = "Pixel" - -/** - * Exercises the full four-step handshake against a [DesktopPeer] speaking the - * real cipher, which is the JVM-side equivalent of pointing the client at - * `harmonyos/tools/fake-relay.mjs`. - */ -class RelayPairingTest { - @Test - fun completesTheHandshakeAndReturnsInitialSync() = runTest { - val peer = DesktopPeer.create() - var echoed: ChallengeCommand? = null - - val engine = MockEngine { request -> - when (request.path()) { - "/api/rooms/$ROOM_ID/pair" -> { - peer.acceptPairRequest(request.text()) - jsonResponse( - peer.encrypt( - PairChallengeResponse.serializer(), - PairChallengeResponse(DesktopPeer.CHALLENGE, timestamp = 1_770_000_000), - ), - ) - } - - "/api/rooms/$ROOM_ID/command" -> { - echoed = peer.decrypt(ChallengeCommand.serializer(), request.text()) - jsonResponse( - peer.encrypt( - InitialSyncResponse.serializer(), - InitialSyncResponse( - resp = "ok", - hasWorkspace = true, - projectName = "OpenBitFun", - authenticatedUserId = "alice", - ), - ), - ) - } - - else -> respond("", HttpStatusCode.NotFound) - } - } - - val paired = pairWith(peer, engine, PairIdentity(userId = "alice")) - - assertEquals(DesktopPeer.CHALLENGE, echoed?.challengeEcho) - assertEquals(DEVICE_ID, echoed?.deviceId) - // The HarmonyOS client sends the install id under both names; the desktop - // reads mobile_install_id when de-duplicating devices. - assertEquals(DEVICE_ID, echoed?.mobileInstallId) - assertEquals("alice", echoed?.userId) - assertEquals("OpenBitFun", paired.initialSync.projectName) - assertEquals("alice", paired.initialSync.authenticatedUserId) - assertEquals( - listOf("/api/rooms/$ROOM_ID/pair", "/api/rooms/$ROOM_ID/command"), - engine.requestHistory.map { it.url.encodedPath }, - ) - } - - @Test - fun sendsNoPasswordFieldForAPasswordlessRoom() = runTest { - val peer = DesktopPeer.create() - var commandKeys: Set = emptySet() - - val engine = handshakeEngine(peer) { body -> - commandKeys = peer.decryptRaw(body).keys - peer.encrypt(InitialSyncResponse.serializer(), InitialSyncResponse(resp = "ok")) - } - - pairWith(peer, engine, PairIdentity(userId = "alice")) - - // explicitNulls = false must hold end to end: the desktop distinguishes - // an absent password from a null one when deciding whether to check it. - assertFalse("password" in commandKeys) - assertContains(commandKeys, "challenge_echo") - } - - @Test - fun sendsThePasswordWhenTheRoomRequiresAccountAuth() = runTest { - val peer = DesktopPeer.create() - var command: ChallengeCommand? = null - - val engine = handshakeEngine(peer) { body -> - command = peer.decrypt(ChallengeCommand.serializer(), body) - peer.encrypt(InitialSyncResponse.serializer(), InitialSyncResponse(resp = "ok")) - } - - pairWith(peer, engine, PairIdentity(userId = "alice", password = "s3cret")) - - assertEquals("s3cret", command?.password) - // An empty password means "no password", not "the empty password". - assertFalse("s3cret" in command.toString()) - } - - @Test - fun anEmptyPasswordIsTreatedAsAbsent() = runTest { - val peer = DesktopPeer.create() - var commandKeys: Set = emptySet() - - val engine = handshakeEngine(peer) { body -> - commandKeys = peer.decryptRaw(body).keys - peer.encrypt(InitialSyncResponse.serializer(), InitialSyncResponse(resp = "ok")) - } - - pairWith(peer, engine, PairIdentity(userId = "alice", password = "")) - - assertFalse("password" in commandKeys) - } - - @Test - fun trimsTheUserIdBeforeSendingIt() = runTest { - val peer = DesktopPeer.create() - var command: ChallengeCommand? = null - - val engine = handshakeEngine(peer) { body -> - command = peer.decrypt(ChallengeCommand.serializer(), body) - peer.encrypt(InitialSyncResponse.serializer(), InitialSyncResponse(resp = "ok")) - } - - pairWith(peer, engine, PairIdentity(userId = " alice ")) - - assertEquals("alice", command?.userId) - } - - @Test - fun aDesktopRejectionBecomesRemoteRejected() = runTest { - val peer = DesktopPeer.create() - val engine = handshakeEngine(peer) { - peer.encrypt( - InitialSyncResponse.serializer(), - InitialSyncResponse( - resp = "error", - message = "This remote URL is already protected by a different user ID.", - ), - ) - } - - val failure = assertFailsWith { - pairWith(peer, engine, PairIdentity(userId = "mallory")) - } - - assertEquals( - RelayFailure.RemoteRejected( - "This remote URL is already protected by a different user ID.", - ), - failure.failure, - ) - } - - @Test - fun aDescriptorKeyThatIsNotAValidPointFailsBeforeAnyRequest() = runTest { - val engine = MockEngine { error("the transport must not reach the relay") } - val pairing = RelayPairing(relayHttpClient(engine)) - - val failure = assertFailsWith { - pairing.pair( - descriptor = descriptorFor("not base64 at all!!"), - deviceId = DEVICE_ID, - deviceName = DEVICE_NAME, - identity = PairIdentity(userId = "alice"), - ) - } - - assertEquals(RelayFailure.MalformedResponse, failure.failure) - assertEquals(0, engine.requestHistory.size) - } - - /** - * A relay that answers 200 with something other than the envelope — a login - * page from a captive portal, say — must not surface as a crypto failure. - */ - @Test - fun aNonEnvelopeBodyIsAMalformedResponse() = runTest { - val peer = DesktopPeer.create() - val engine = MockEngine { respond("captive portal", HttpStatusCode.OK) } - - val failure = assertFailsWith { - pairWith(peer, engine, PairIdentity(userId = "alice")) - } - - assertEquals(RelayFailure.MalformedResponse, failure.failure) - } - - @Test - fun logsCarryTruncatedRoomIdsOnly() = runTest { - val peer = DesktopPeer.create() - val log = RecordingLog() - val engine = handshakeEngine(peer) { - peer.encrypt(InitialSyncResponse.serializer(), InitialSyncResponse(resp = "ok")) - } - - pairWith(peer, engine, PairIdentity(userId = "alice"), log = log) - - assertTrue(log.lines.isNotEmpty()) - val joined = log.lines.joinToString("\n") - assertFalse(ROOM_ID in joined, "the full room id reached the log") - assertContains(joined, "room=01234567") - } - - @Test - fun mapsHttpStatusCodesToTypedFailures() = runTest { - val expected = mapOf( - 401 to RelayFailure.PairRejected, - 403 to RelayFailure.PairRejected, - 404 to RelayFailure.RoomNotFound, - 408 to RelayFailure.Timeout, - 418 to RelayFailure.UnexpectedStatus(418), - 429 to RelayFailure.RateLimited, - 500 to RelayFailure.RelayUnavailable(500), - 502 to RelayFailure.RelayUnavailable(502), - 504 to RelayFailure.Timeout, - ) - - for ((status, failure) in expected) { - val peer = DesktopPeer.create() - val engine = MockEngine { respond("", HttpStatusCode.fromValue(status)) } - - val thrown = assertFailsWith { - pairWith(peer, engine, PairIdentity(userId = "alice")) - } - assertEquals(failure, thrown.failure, "status $status") - } - } - - @Test - fun commandsRoundTripThroughThePairedTransport() = runTest { - val peer = DesktopPeer.create() - var seen: RemoteCommand? = null - - val engine = handshakeEngine(peer) { body -> - val decoded = peer.decryptRaw(body) - if ("challenge_echo" in decoded.keys) { - peer.encrypt(InitialSyncResponse.serializer(), InitialSyncResponse(resp = "ok")) - } else { - seen = peer.decrypt(RemoteCommand.serializer(), body) - peer.encrypt( - SessionListResponse.serializer(), - SessionListResponse(resp = "ok", hasMore = true), - ) - } - } - - val paired = pairWith(peer, engine, PairIdentity(userId = "alice")) - val response: SessionListResponse = paired.transport.send( - RemoteCommand(cmd = "list_sessions", requestId = "req-000000000042", limit = 20), - ) - - assertEquals("list_sessions", seen?.cmd) - assertEquals(20, seen?.limit) - assertTrue(response.hasMore) - assertNull(response.message) - } - - @Test - fun aCommandErrorReplyBecomesRemoteRejected() = runTest { - val peer = DesktopPeer.create() - val engine = handshakeEngine(peer) { body -> - if ("challenge_echo" in peer.decryptRaw(body).keys) { - peer.encrypt(InitialSyncResponse.serializer(), InitialSyncResponse(resp = "ok")) - } else { - peer.encrypt( - SessionListResponse.serializer(), - SessionListResponse(resp = "error", message = "Workspace is closed."), - ) - } - } - - val paired = pairWith(peer, engine, PairIdentity(userId = "alice")) - val failure = assertFailsWith { - paired.transport.send(RemoteCommand(cmd = "list_sessions")) - } - - assertEquals(RelayFailure.RemoteRejected("Workspace is closed."), failure.failure) - } - - @Test - fun commandLogsKeepRequestIdsShortAndRoomIdsTruncated() = runTest { - val peer = DesktopPeer.create() - val log = RecordingLog() - val engine = handshakeEngine(peer) { body -> - if ("challenge_echo" in peer.decryptRaw(body).keys) { - peer.encrypt(InitialSyncResponse.serializer(), InitialSyncResponse(resp = "ok")) - } else { - peer.encrypt(SessionListResponse.serializer(), SessionListResponse(resp = "ok")) - } - } - - val paired = pairWith(peer, engine, PairIdentity(userId = "alice"), log = log) - log.lines.clear() - paired.transport.send( - RemoteCommand(cmd = "list_sessions", requestId = "session-abcdef-000000000042"), - ) - - val joined = log.lines.joinToString("\n") - assertContains(joined, "request=000000000042") - assertFalse("session-abcdef" in joined) - assertFalse(ROOM_ID in joined) - } - - // --- helpers ----------------------------------------------------------- - - private fun descriptorFor(publicKey: String) = RelayDescriptor( - relayUrl = RELAY_URL, - roomId = ROOM_ID, - publicKey = publicKey, - accountAuth = false, - accountUsername = "", - ) - - private suspend fun pairWith( - peer: DesktopPeer, - engine: MockEngine, - identity: PairIdentity, - log: TransportLog = TransportLog.None, - ): PairedRoom = RelayPairing(relayHttpClient(engine), log).pair( - descriptor = descriptorFor(peer.publicKeyBase64), - deviceId = DEVICE_ID, - deviceName = DEVICE_NAME, - identity = identity, - ) - - /** - * A relay that always answers the challenge and delegates `/command` to - * [onCommand], which receives the raw encrypted body. - */ - private fun handshakeEngine( - peer: DesktopPeer, - onCommand: suspend (String) -> String, - ) = MockEngine { request -> - val body = request.text() - when (request.path()) { - "/api/rooms/$ROOM_ID/pair" -> { - peer.acceptPairRequest(body) - jsonResponse( - peer.encrypt( - PairChallengeResponse.serializer(), - PairChallengeResponse(DesktopPeer.CHALLENGE), - ), - ) - } - - "/api/rooms/$ROOM_ID/command" -> jsonResponse(onCommand(body)) - else -> respond("", HttpStatusCode.NotFound) - } - } -} - -private fun HttpRequestData.path(): String = url.encodedPath - -private suspend fun HttpRequestData.text(): String = body.toByteArray().decodeToString() - -private fun io.ktor.client.engine.mock.MockRequestHandleScope.jsonResponse(body: String) = - respond(body, HttpStatusCode.OK, headersOf(HttpHeaders.ContentType, "application/json")) diff --git a/src/apps/mobile/shared/core-transport/src/jvmTest/kotlin/com/openbitfun/mobile/core/transport/FakeRelayIntegrationTest.kt b/src/apps/mobile/shared/core-transport/src/jvmTest/kotlin/com/openbitfun/mobile/core/transport/FakeRelayIntegrationTest.kt deleted file mode 100644 index 57ced47989..0000000000 --- a/src/apps/mobile/shared/core-transport/src/jvmTest/kotlin/com/openbitfun/mobile/core/transport/FakeRelayIntegrationTest.kt +++ /dev/null @@ -1,98 +0,0 @@ -package com.openbitfun.mobile.core.transport - -import com.openbitfun.mobile.core.protocol.CreateSessionResponse -import com.openbitfun.mobile.core.protocol.ModelCatalogResponse -import com.openbitfun.mobile.core.protocol.PollSessionResponse -import com.openbitfun.mobile.core.protocol.RemoteCommand -import com.openbitfun.mobile.core.protocol.SendMessageResponse -import com.openbitfun.mobile.core.protocol.SessionListResponse -import com.openbitfun.mobile.core.protocol.SessionMessagesResponse -import com.openbitfun.mobile.core.protocol.WorkspaceInfoResponse -import io.ktor.client.engine.java.Java -import kotlinx.coroutines.test.runTest -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNotNull -import kotlin.test.assertTrue - -/** - * End-to-end handshake and command round trip against a live - * `harmonyos/tools/fake-relay.mjs`, over the real HTTP engine. - * - * Opt-in, because it needs a running node process: start the stub, then pass its - * printed pairing URL through - * `./gradlew :core-transport:jvmTest -Popenbitfun.pairingUrl=''`. - * - * Everything this covers is also covered by [RelayPairingTest] against an - * in-process peer, so CI stays green without node. What only this test can show - * is that the two implementations agree — that the Kotlin client and the stub - * derive the same key, and that the DTOs match a body neither of them wrote. - */ -class FakeRelayIntegrationTest { - private val pairingUrl: String = System.getProperty("openbitfun.pairingUrl").orEmpty() - - @Test - fun pairsAndRoundTripsCommands() = runTest { - if (pairingUrl.isEmpty()) { - println("skipping: pass -Popenbitfun.pairingUrl= to run") - return@runTest - } - - val descriptor = RelayDescriptorParser.parse(pairingUrl) - val client = relayHttpClient(Java.create()) - val paired = RelayPairing(client).pair( - descriptor = descriptor, - deviceId = "jvm-integration-test", - deviceName = "JVM Integration Test", - identity = PairIdentity(userId = "jvm-integration-test"), - ) - - assertEquals("ok", paired.initialSync.resp) - assertTrue(paired.initialSync.hasWorkspace == true) - - val transport = paired.transport - - val workspace: WorkspaceInfoResponse = - transport.send(RemoteCommand(cmd = "get_workspace_info")) - assertEquals("ok", workspace.resp) - - val sessions: SessionListResponse = - transport.send(RemoteCommand(cmd = "list_sessions", limit = 8)) - assertEquals("ok", sessions.resp) - assertTrue(sessions.sessions.isNotEmpty()) - - val catalog: ModelCatalogResponse = - transport.send(RemoteCommand(cmd = "get_model_catalog")) - assertNotNull(catalog.catalog) - - val created: CreateSessionResponse = transport.send( - RemoteCommand(cmd = "create_session", agentType = "code", sessionName = "integration"), - ) - val sessionId = assertNotNull(created.resolvedSessionId) - - val messages: SessionMessagesResponse = transport.send( - RemoteCommand(cmd = "get_session_messages", sessionId = sessionId, limit = 50), - ) - assertEquals("ok", messages.resp) - - val sent: SendMessageResponse = transport.send( - RemoteCommand(cmd = "send_message", sessionId = sessionId, content = "hello"), - ) - assertNotNull(sent.turnId) - - // The cursor contract: send back the version the peer last reported and - // it answers with what changed after it, never re-sending the same turn. - var cursor = 0 - repeat(3) { - val poll: PollSessionResponse = transport.send( - RemoteCommand(cmd = "poll_session", sessionId = sessionId, sinceVersion = cursor), - ) - assertEquals("ok", poll.resp) - assertTrue(poll.version >= cursor, "cursor went backwards: ${poll.version} < $cursor") - cursor = poll.version - } - assertTrue(cursor > 0, "poll_session never advanced its cursor") - - client.close() - } -} diff --git a/src/apps/relay-server/README.md b/src/apps/relay-server/README.md index 74f778ec54..4633e48a3e 100644 --- a/src/apps/relay-server/README.md +++ b/src/apps/relay-server/README.md @@ -1,655 +1,228 @@ # OpenBitFun Relay Server -WebSocket / HTTP relay for OpenBitFun **Remote Connect** and **account login**. - -Open-source OpenBitFun does **not** ship a public hosted login service. If you want -Desktop / CLI **account login**, cross-device session & settings sync, or -**Peer Device Mode** (control another online device on the same account), you -must: - -1. Deploy this relay yourself -2. Enable the account database (`RELAY_DB_PATH`) -3. Create user accounts out-of-band with `relay-admin` (no public sign-up) -4. Point OpenBitFun Desktop or CLI at your relay URL and log in - -The relay stays **zero-knowledge**: clients encrypt with a master key derived -locally; the server stores Argon2id password hashes and AES-GCM-wrapped keys, -never plaintext passwords or decryptable sync payloads. - -## Supported deploy hosts - -One-click Docker deploy (`bash deploy.sh`) targets: - -| OS | CPU | -|----|-----| -| Linux | **amd64** (`x86_64`) | -| Linux | **arm64** (`aarch64`) | - -The default path requires Docker Engine plus permission to talk to its daemon. -OpenBitFun Desktop installs Docker automatically when the SSH user has root/sudo. -Docker Compose, Cargo, git, tar, and build toolchains are not required on the -customer server. Compose is used only by the explicit -`deploy.sh --build-from-source` maintenance path. - -### Mainland China hosts - -`deploy.sh` (and Desktop one-click deploy) auto-detects mainland China. Image -pulls try the verified Nanjing University GHCR accelerator, then DaoCloud, then -official GHCR, always using the same image digest. Global -mode goes directly to official GHCR. Docker Engine installation also uses a -mainland route. The Desktop wizard offers **Auto / Mainland China / Global** so -an operator can override inaccurate cloud-IP geolocation: - -```bash -OPENBITFUN_MIRROR=cn bash deploy.sh # force China mirrors - OPENBITFUN_MIRROR=global bash deploy.sh # restore OpenBitFun-managed upstream sources -bash deploy.sh --cn-mirror -bash deploy.sh --global-mirror -``` - -Engine installation defaults to Aliyun docker-ce and mirrored get.docker.com. -The daemon's Docker Hub mirrors remain useful for explicit source builds, but -they do not accelerate GHCR; `release-download.sh` therefore uses GHCR-specific -repository prefixes. Switching to global restores only OpenBitFun-managed host -mirror entries. See `mirror.sh` for the installer/source-build knobs. - -## Two operating modes - -| Mode | When | What you get | -|------|------|----------------| -| **Pure relay** | `RELAY_DB_PATH` unset | Room pairing + mobile HTTP ↔ Desktop WebSocket bridge only. **No** account login, sync, or Peer Device Mode. | -| **Account-enabled** | `RELAY_DB_PATH` set to a persistent SQLite path | Everything above **plus** login, device presence, device RPC (Peer HostInvoke), encrypted session/settings sync. | - -Docker Compose in this directory **already enables account mode** -(`RELAY_DB_PATH=/app/data/openbitfun_relay.db`). Manual / cargo runs must set the -variable yourself or accounts stay disabled. - -## Features - -- Desktop and CLI connect via WebSocket; mobile uses HTTP -- End-to-end encrypted passthrough (the server does not decrypt payloads) -- Correlation-based HTTP-to-WebSocket request-response matching -- Per-room mobile-web static file upload and serving -- Heartbeat-based connection management with configurable room TTL -- Optional zero-knowledge account storage + device routing + sync -- Docker deployment support with optional Caddy reverse proxy - -## Open-source: enable account login (recommended path) - -Use this checklist on a machine you control (VPS, LAN server, or localhost). - -### Desktop one-click deploy (preferred for end users) - -OpenBitFun Desktop can SSH to your host without a manual clone. One click installs -Docker when necessary, verifies the signed release image descriptor locally, -pulls the latest amd64/arm64 image through the selected network route, and -starts it by immutable digest. If no usable published image exists, it automatically -builds the current OpenBitFun source in Docker and shows that fallback in the -terminal. Invalid release signatures remain an error. Pull or build completes before an existing -Relay is stopped; startup or health failure restores the previous container. -Entry points: Account Login → “一键部署到自己的服务器”, or -Remote Connect → Network Relay → Self-Hosted → the same action. - -- Orchestration: `src/crates/services/services-integrations/src/remote_ssh/relay_deploy.rs` -- Wizard + invariants: `src/web-ui/src/features/relay-deploy/README.md` - -Task state lives under `~/.openbitfun/relay-deploy`; temporary source checkouts -live under `~/.openbitfun/relay-src` and are cleaned after the build. The wizard -prepares Git and Docker Buildx if needed; host Rust and Compose are not required. -Closing the wizard cancels the remote task and restores a staged -previous container. Account passwords are provisioned locally and imported via -`relay-admin import-user`. - -### Release artifact verification - -Every release publishes `relay-image.json` plus `relay-image.json.sig` -(minisign, in the same base64-wrapped format as the Desktop updater). The -descriptor fixes the canonical GHCR repository, release tag, amd64/arm64 -platform set, and multi-platform manifest digest. Desktop verifies it on the -user's machine and sends the digest to the server; Docker then verifies every -manifest and layer while pulling, even through a third-party accelerator. -GitHub/GHCR stays first when a 10-second GitHub byte probe reaches 512 KiB/s; -below that floor, automatic mode tries the NJU and DaoCloud GHCR accelerators -first while retaining official GHCR as the final fallback. - -The raw Relay archives still carry `.sha256` and `.sig` files for direct binary -use and for constructing the release image in CI. - -Verifying an archive by hand: - -```bash -BASE=https://github.com/GCWing/OpenBitFun/releases/latest/download -ASSET=openbitfun-relay-server-x86_64-unknown-linux-gnu.tar.gz -curl -fsSLO "$BASE/$ASSET" -O "$BASE/$ASSET.sig" -O "$BASE/minisign.pub" -base64 -d <"$ASSET.sig" >"$ASSET.minisig" -minisign -Vm "$ASSET" -p minisign.pub -x "$ASSET.minisig" -``` - -`minisign.pub` is published with every release and is the same key the Desktop -updater trusts, so it can also be pinned out-of-band once and reused. - -Note this is not OS-level code signing: macOS Gatekeeper and Windows SmartScreen -need Apple/Authenticode certificates, which the project does not currently hold. - -### 1. Deploy the relay (manual / server shell) - -```bash -git clone https://github.com/GCWing/OpenBitFun -cd OpenBitFun/src/apps/relay-server -bash deploy.sh -``` - -`deploy.sh` must run **on the target server** (it does not SSH elsewhere). -Its default path requires Docker on **linux/amd64** or **linux/arm64** and pulls -`ghcr.io/gcwing/openbitfun-relay-server:latest`; it does not compile locally. -Use `--build-from-source` only when deliberately exercising the source path. - -Clone on the server, as above, rather than uploading a Windows checkout. Git for -Windows rewrites these scripts to CRLF by default, and bash then fails on the -first blank line: - -``` -deploy.sh: line 37: $'\r': command not found -``` - -If that happens, strip the CR and re-run: - -```bash -sed -i 's/\r$//' *.sh && bash deploy.sh -``` - -After a successful start, the script runs `relay-admin list-users`. If the -database has **no accounts**, it prints the exact `add-user` command to run -next (account login will not work until you create at least one user). - -Verify: - -```bash -curl -fsS http://127.0.0.1:9700/health -docker ps --filter name=openbitfun-relay -``` - -### 2. Confirm account database is on - -The published image deploy and the Compose source path both set: - -```yaml -RELAY_DB_PATH=/app/data/openbitfun_relay.db -``` - -Data lives in the `relay-server_relay-db` Docker volume. If you run the binary -without Docker, export a persistent path first: - -```bash -export RELAY_DB_PATH=/var/lib/openbitfun/openbitfun_relay.db -mkdir -p "$(dirname "$RELAY_DB_PATH")" -RELAY_PORT=9700 ./target/release/openbitfun-relay-server -``` - -If the process logs `RELAY_DB_PATH not set — account features disabled`, login -will fail with “account features disabled” until you fix the env and restart. - -### 3. Create accounts (`relay-admin`) - -There is **no** public registration API. Operators create users with -`relay-admin` (bundled in the Docker image). `--db` must be the **same path** -as `RELAY_DB_PATH`. - -```bash -# Interactive password prompt (recommended) -docker exec -it openbitfun-relay \ - /app/relay-admin --db /app/data/openbitfun_relay.db add-user --username alice - -# Non-interactive (scripts / CI) -docker exec openbitfun-relay \ - /app/relay-admin --db /app/data/openbitfun_relay.db add-user \ - --username alice --password 'choose-a-strong-password' - -# List accounts -docker exec openbitfun-relay \ - /app/relay-admin --db /app/data/openbitfun_relay.db list-users -``` - -Other commands: - -```bash -# Reset password (also rotates the master key — old synced blobs become unreadable) -docker exec -it openbitfun-relay \ - /app/relay-admin --db /app/data/openbitfun_relay.db reset-password --username alice - -# Rename (credentials / user_id unchanged) -docker exec openbitfun-relay \ - /app/relay-admin --db /app/data/openbitfun_relay.db rename-user \ - --username alice --new-username alice2 - -# Delete account and all of its relay-side data -docker exec openbitfun-relay \ - /app/relay-admin --db /app/data/openbitfun_relay.db delete-user --username alice -``` - -Without Docker, build and run the same tool from this crate: +The official Relay connects devices signed in to the same GitHub identity. +GitHub identity is shared with the marketplaces. Users sign in +from OpenBitFun; they do not create a Relay account or deploy a server. + +The official endpoint is `https://remote.openbitfun.com/v/1.0.0`. This release is deployed with +its own process, database, assets, and reverse-proxy location. An existing +`/relay` deployment remains on its existing binary and data directory. + +The Relay forwards opaque encrypted messages. Each device generates an X25519 +private key locally and registers its public key after GitHub identity +verification. Peers obtain public keys through the authenticated same-account +directory and derive an AES-GCM key with X25519 and HKDF-SHA256. The Relay does +not receive device private keys or upload copies of settings and sessions. +Sessions and files are read from the owning online device on demand. + +Selecting **Same network** starts the same Relay implementation inside the +Desktop host at `http://:9700`. Its SQLite database is local to that +host (`/relay-v1.0.0/local-server/relay.db`), separate from the +official server database. Login, device registration, device discovery, +public-key lookup, RPC and presence all use the selected Relay endpoint. +The two modes differ only in endpoint and host startup; no device traffic is +forwarded from the local Relay to the official Relay. + +Both modes verify GitHub identity through `auth.openbitfun.com`, so signing in +requires internet access. An invitation contains only the selected endpoint +and device id (`/#/pair?did=`); scanning it grants no authority. +The controller must sign in and resolve that id in its same-account directory. +Anonymous room pairing and tunnel-provider startup have been removed. + +SSH and Docker workspace connections remain independent of Relay login. + +## For community developers and forks + +Self-hosting is supported through source and deployment scripts. The public mode uses a fixed official endpoint; it has no deployment wizard or editable +Relay URL. A private Relay therefore needs a matching client build. + +1. Fork this workspace and read `CONTRIBUTING.md`. Build the Relay and mobile + controller from the same revision. Keep your fork's changes in source control. +2. Select your HTTPS endpoint in `product-domains/src/account.rs`, then align + the frontend constants in `src/web-ui/src/infrastructure/remote-connect/remoteConnectionState.ts` + and `src/mobile-web/src/services/pairingLink.ts`. Native clients have + matching constants in KMP `core-transport/AccountDeviceLink.kt` and HarmonyOS + `services/AccountDeviceLink.ets`; update the HarmonyOS account-link parser too. + Search for `https://remote.openbitfun.com/v/1.0.0` to verify every runtime + reference and corresponding test before building your distribution. +3. Decide who owns identity. You can retain the official GitHub identity + authority, or run the [shared identity service](../../../deploy/miniapp-market/README.md) + with your own GitHub OAuth application. For an independent authority, change + `IDENTITY_ME_URL` in `relay-service/src/identity.rs` and + `DEFAULT_ACCOUNT_API_URL` in `services-integrations/src/account_identity/mod.rs` + together, and adapt the market sign-in links and callback/completion host. + `OPENBITFUN_ACCOUNT_API_URL` overrides the desktop/CLI identity API for + development; the previous `OPENBITFUN_MINIAPP_MARKET_API_URL` alias remains + readable. Relay never accepts an identity authority from a client request. +4. Use separate persistent data and asset directories, configure exact browser + CORS origins, then put the service behind your own TLS reverse proxy. Build + and exercise two devices using the same GitHub identity before distributing + your fork. The public web controller must come from that matching build. + +The scripts remain in this directory: `deploy.sh` deploys on the machine where +it runs, `common.sh` contains Docker/health helpers, and `mirror.sh` and +`release-download.sh` support mirrors and published images. Inspect +`bash deploy.sh --help` first. For fork code use +`bash deploy.sh --build-from-source --global-mirror`; the default image path +pulls a published upstream release, so it will not include your modifications. +An empty account database is normal: successful GitHub verification creates an +identity. Do not run retired `add-user` or password-reset commands. + +The legacy script uses its own Compose project and defaults. For a fresh +versioned deployment, prefer the isolated [v1 Compose project](../../../deploy/relay-v1/README.md) +and adapt its host paths, bind port, proxy host, and trusted upstream ranges to +your infrastructure. Never reuse production data directories or an existing +container name for a development deployment. There is no need to restore the +removed deployment wizard to operate these scripts. + +## Operator startup + +This directory owns the official service binary and maintenance tools. The +shared HTTP/WebSocket implementation lives in `src/crates/services/relay-service`. + +Set `RELAY_DB_PATH` to a persistent SQLite database before starting the service. +Startup fails if it is missing; anonymous public relay mode is unsupported. +The service validates OpenBitFun access tokens against the fixed GitHub identity +authority at `https://auth.openbitfun.com/api/v1/me`. ```bash cargo build --release -p openbitfun-relay-server -./target/release/relay-admin --db "$RELAY_DB_PATH" add-user --username alice -``` - -### 4. Point OpenBitFun clients at your relay - -Relay URL examples: - -- Direct: `http://:9700` -- Localhost: `http://127.0.0.1:9700` -- Behind a reverse proxy: `https://relay.example.com/relay` - -The client appends paths (`/ws`, `/api/*`, `/r/*`) to the URL you enter. Use -the `/relay` suffix to match the official server format -(`https://remote.openbitfun.com/relay`). See **Reverse Proxy** for nginx config. - -**Desktop** - -1. Open account / login UI (or Remote Connect self-hosted settings, depending - on your build). -2. Set **Auth Server / Relay URL** to the URL above. -3. Sign in with the username and password you created with `relay-admin`. - -**CLI** - -1. Run `openbitfun`, open `/login`. -2. Fill **Auth Server**, **Username**, **Password**, then Login. -3. After login, the CLI can act as a **Peer Host** for same-account Desktops. - -Clients remember a non-secret hint (`~/.openbitfun/account_hint.json`: username + -relay URL) and an encrypted session file for restart without retyping the -password. - -### 5. What works after login - -- Encrypted **settings / session sync** across devices on the same account -- **Device list** and online presence for that account -- **Peer Device Mode**: one Desktop controls another online Desktop **or** CLI - host over device RPC (`HostInvoke` / `DeviceEvent`) -- Same machine Desktop + CLI share one `device_id`; the **last successful** - `AuthConnect` wins as the live Peer Host for that id - -## Upgrade notes - -The supported Docker build context is now the repository root because the app -uses the shared relay service: - -```bash -docker build -f src/apps/relay-server/Dockerfile . -docker compose -f src/apps/relay-server/docker-compose.yml build -``` - -Copying only `src/apps/relay-server` is no longer sufficient; deployments must -also include `src/crates/services/relay-service`. The repository keeps one -Docker build layout rather than duplicating the shared service. - -The standalone library facade is `openbitfun_relay_server`; reusable relay -runtime ownership remains in the internal `openbitfun-relay-service` crate. - -Source builds are tagged as `openbitfun-relay:` by `deploy.sh` and -carry the same commit in the `org.opencontainers.image.revision` label. The -resolved commit is persisted in the local root-only `.env`, so `start.sh` and -`restart.sh` keep selecting the deployed image instead of a floating tag. - -```bash -docker inspect --format '{{.Config.Image}} {{index .Config.Labels "org.opencontainers.image.revision"}}' \ - openbitfun-relay -``` - -The image must report `/app/openbitfun-relay-server` as its command and account -mode must use `/app/data/openbitfun_relay.db`. A pre-1.0 deployment that still -has `bitfun_relay.db` must be stopped and copied with SQLite's `.backup` command -to the new filename before the OpenBitFun image is started. The runtime has no -fallback to the retired filename. - -## Quick Start (service ops) - -### Recommended: Run on the target server - -```bash -git clone https://github.com/GCWing/OpenBitFun -cd OpenBitFun/src/apps/relay-server -bash deploy.sh -``` - -### Service Operations - -Run these on the target server inside this directory: - -```bash -bash start.sh -bash stop.sh -bash restart.sh -docker compose ps -docker compose logs -f relay-server -``` - -Notes: - -- `start.sh` is idempotent and exits if the service is already running. -- `stop.sh` exits cleanly when the service is already stopped. -- `restart.sh` restarts the service when running, or starts it when stopped. -- The container uses `restart: unless-stopped`. - -### Network Binding - -By default the relay listens on `0.0.0.0:9700` and Compose publishes that port -on the host. - -Restrict to localhost: - -```bash -export RELAY_HOST_BIND_IP=127.0.0.1 -bash deploy.sh -``` - -### Manual Run (without Docker) - -```bash -# From repository root -cargo build --release -p openbitfun-relay-server - -# Account-enabled (persistent DB path required for login) -export RELAY_DB_PATH="$HOME/.openbitfun-relay/openbitfun_relay.db" -mkdir -p "$(dirname "$RELAY_DB_PATH")" -RELAY_PORT=9700 ./target/release/openbitfun-relay-server -``` - -## Deployment Checklist - -1. Open ports: `9700` (direct), and `80/443` if using a reverse proxy. -2. Hit `http://:9700/health` (or `https://relay.example.com/relay/health` behind a proxy). -3. Confirm `RELAY_DB_PATH` if you need accounts (Compose does this for you). -4. Create at least one user with `relay-admin`. -5. Fill the same relay URL into Desktop / CLI and log in. -6. If you terminate TLS on a reverse proxy, raise body size and read timeouts - (see sync + device RPC notes below). -7. Use the `/relay` suffix in the relay URL (e.g. `https://relay.example.com/relay`) - to match the official server format. See **Reverse Proxy** for nginx config. - -## Reverse Proxy - -When deploying behind a reverse proxy (Caddy, nginx, etc.), configure: - -- **Body size limit**: at least 100 MB (sync POSTs carry large encrypted bundles) -- **Read/response timeout**: at least 130s (device RPC waits up to 120s) -- **WebSocket upgrade**: the /ws endpoint requires Connection upgrade headers -- **Path prefix**: serve the relay at `/relay/*` (strip prefix before proxying - to port 9700); serve static homepage files at `/` via exact-match locations - -### Nginx example (/relay prefix + homepage at /) +RELAY_PORT=9700 RELAY_DB_PATH=/var/lib/openbitfun-relay-v1/relay.db \ + RELAY_ASSET_DIR=/var/lib/openbitfun-relay-v1/assets \ + ./target/release/openbitfun-relay-server +``` + +Use the isolated [v1 Compose project](../../../deploy/relay-v1/README.md). +Set `RELAY_LISTEN_ADDR=127.0.0.1:19700` with host networking so the service can +verify the immediate loopback proxy peer. Invalid listener values fail startup. +Expose only the TLS reverse proxy. Keep the database and asset paths distinct from older deployments. +`relay-admin` supports listing and explicitly deleting accounts; GitHub login +creates identities. Password provisioning, password reset, and user-entered +Relay server URLs are retired. + +## Public-service resource controls + +These limits protect the service independently of reverse-proxy configuration. +They are implemented in the shared Relay service, not in the agent loop. + +| Resource | Limit and overload behavior | +|---|---| +| Authentication request body | 16 KiB; oversized bodies return 413 | +| Buffered HTTP request bodies | 512 MiB total reserved before buffering; overload returns 503 | +| Concurrent HTTP API requests | 2,048; overload returns 503 | +| Body read / device RPC handler | 15 seconds / 130 seconds | +| HTTP request rate | 6,000/minute per source IP; device APIs also per account; overload returns 429 | +| GitHub authorization start / poll | 10 / 120 per minute per IP | +| Identity exchanges | 10/minute per IP; 64 concurrent outbound identity requests | +| WebSocket upgrades | 120/minute per IP; 4,096 active sockets globally | +| WebSocket authentication | Must complete within 10 seconds | +| WebSocket ingress | 16 KiB per message/frame; 4 KiB read buffer per connection | +| WebSocket messages | 12,000/minute per connection | +| WebSocket outgoing queue | 128 messages per socket, 256 MiB total queued/writing bytes | +| Slow WebSocket writes | Close after a 15-second write timeout | +| RPC response memory | 256 MiB covering queued payloads and serialized replies, retained until read or disconnect | +| Pending device RPCs | 2,048 globally; 64 per account; cancellation releases capacity | +| Registered devices / active credentials | 64 / 256 per account; database-atomic admission | +| Device RPC ciphertext | 48 MiB, with JSON envelope allowance | + +Existing devices can reconnect at the registration limit. Idempotent token +replays remain valid at the credential limit. Limits never delete a user's +session, device, workspace, or other product data. + +Bearer authentication precedes body buffering on device APIs. Device discovery, +public-key lookup, message routing, and RPC correlation all enforce account +ownership. Delegated controller credentials cannot register sockets, mint more +credentials, or delete devices; revoking their parent device revokes them. +Account-enabled services reject the retired anonymous pairing-room endpoints. + +The identity HTTP client rejects redirects, bounds response size and duration, +and never accepts a caller-provided identity authority. Browser CORS uses an +explicit origin list; wildcard CORS is rejected by the standalone host when +account APIs are enabled. Published Pages must use an origin separate from the +account sign-in surface. Without both isolated origins, the standalone host +returns 503 for `/api/pages`, `/api/page-auth`, and `/p` routes. + +Application limits do not replace network-layer protection. Public deployment +also requires bounded proxy connections and request bodies, TLS, upstream DDoS +protection, and alerts for saturation, rejected requests, and disk growth. +Do not log Authorization headers, OAuth transaction secrets, tokens, request +bodies, or URL query strings containing sign-in state. + +## Versioned reverse proxy + +Strip only the new version prefix when forwarding. Do not replace the existing +`/relay` location. The proxy must overwrite forwarded IP headers with its own +observed source address, and the upstream port must be unreachable externally. +The Relay trusts forwarded client IPs only from an immediate loopback peer. ```nginx -server { - listen 80; - server_name relay.example.com; - - # Homepage static files (exact match) - location = / { - root /path/to/relay-server/static/homepage; - try_files /index.html =404; - } - location = /i18n.json { - root /path/to/relay-server/static/homepage; - } - location = /i18n.shared.json { - root /path/to/relay-server/static/homepage; - } - - # With /relay prefix: strip prefix, proxy to relay server - # For clients configured with https://relay.example.com/relay - location = /relay { - return 301 /relay/; - } - location /relay/ { - proxy_pass http://127.0.0.1:9700/; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection "upgrade"; - proxy_buffering off; - proxy_read_timeout 130s; - proxy_send_timeout 130s; - client_max_body_size 100m; - } - +location ^~ /v/1.0.0/ { + proxy_pass http://127.0.0.1:19700/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + client_max_body_size 49m; + client_body_timeout 15s; + proxy_read_timeout 140s; + proxy_send_timeout 30s; } ``` -See `Caddyfile` for the Caddy equivalent. - -## Environment Variables - -| Variable | Default | Description | -|----------|---------|-------------| -| `RELAY_PORT` | `9700` | Server listen port | -| `RELAY_STATIC_DIR` | _(none)_ | Path to mobile web static files fallback SPA. When unset, no fallback static files are served. Docker Compose sets this to `/app/static`. | -| `RELAY_ROOM_WEB_DIR` | `/tmp/openbitfun-room-web` | Directory for per-room uploaded mobile-web files. Docker Compose uses a named volume mounted at `/app/room-web`. | -| `RELAY_ASSET_STORE_MAX_BYTES` | `1073741824` | Global content-addressed asset capacity (1 GiB by default). New uploads return HTTP 507 after the limit is reached; existing content remains readable. | -| `RELAY_ROOM_TTL` | `300` | Idle room TTL in seconds (0 = no expiry). Active heartbeats and commands refresh activity. | -| `RELAY_DB_PATH` | _(none)_ | SQLite path for account storage. **Unset = pure relay (no login).** Set a persistent path (Compose: `/app/data/openbitfun_relay.db`) to enable login, device routing, and sync. Accounts are provisioned only via `relay-admin`. | -| `RELAY_CORS_ALLOW_ORIGINS` | _(none)_ | Comma-separated browser origin allowlist, for example `https://remote.example.com`. Empty means same-origin only. `*` is rejected when account APIs are enabled. | -| `RELAY_PAGE_PUBLIC_BASE_URL` | _(none)_ | Browser-visible base URL for untrusted published Page content, for example `https://pages.example.com`. Configure together with `RELAY_PAGE_AUTH_BASE_URL`. | -| `RELAY_PAGE_AUTH_BASE_URL` | _(none)_ | Browser-visible base URL for the trusted Relay Page login UI, for example `https://relay.example.com/relay`. It must use a different browser origin from `RELAY_PAGE_PUBLIC_BASE_URL`. | - -Production deployments that use non-public Pages should configure both Page -base URLs. The reverse proxy must route both hosts to this Relay and preserve -the original `Host`; it may strip the configured path prefix before proxying. -Relay then serves Page content only on the public origin and the account login -UI only on the authentication origin. Login completes through a 60-second, -single-use callback code; the callback writes an HttpOnly, Page-path-scoped -cookie on the public origin. If the variables are omitted, same-origin login is -kept only for local/backward-compatible deployments and the server logs a -warning. - -When `RELAY_DB_PATH` is set, database open or migration failure is fatal: the -process exits instead of silently starting without account protection. The -`/health` response reports account capability, room/device connection counts, -pending bridge requests, and asset-store used/capacity bytes for operational -checks and capacity alerts. - -## API Endpoints - -### Health & Info - -| Endpoint | Method | Description | -|----------|--------|-------------| -| `/health` | GET | Health check (status, version, uptime, room and connection counts) | -| `/api/info` | GET | Server info (name, version, protocol version) | - -### Account (requires `RELAY_DB_PATH`) - -Zero-knowledge authentication. Clients derive an Argon2id KEK locally and send -only password hashes. Brute-force protection: per-account lockout + per-IP rate -limit. **No public registration endpoint.** - -| Endpoint | Method | Description | -|----------|--------|-------------| -| `/api/auth/login/challenge` | POST | Fetch KDF params + wrapped master key for local derivation | -| `/api/auth/login` | POST | Verify password hash and issue a token; returns `{ token, user_id }` | -| `/api/auth/logout` | POST | Revoke the caller's token | -| `/api/auth/delegate` | POST | Issue a delegated token for a paired client (authenticated caller) | - -### Published Page browser authentication +Define the standard `$connection_upgrade` map and deployment-specific +`limit_req`/`limit_conn` zones in the owning Nginx configuration. Tune worker and +file-descriptor limits against measured concurrent sockets; daily active users +alone are not a capacity measurement. Preserve ordinary streaming and attachment +traffic in the load test when tuning rate limits. + +Before opening the new location, exercise invalid/expired credentials, +cross-account access, concurrent quota exhaustion, oversized/slow bodies, +unauthenticated and slow-reader sockets, cancellation, reconnect, and normal +streaming. Verify that overload returns promptly and releases memory. Confirm +that the old service remains healthy and that rollback only removes the new +location and process. + +## API + +| Endpoint | Purpose | +|---|---| +| `GET /health`, `GET /api/info` | Health and service version | +| `POST /api/auth/github/start`, `/api/auth/github/poll` | Browser GitHub authorization | +| `POST /api/auth/login` | Exchange verified identity for a keyed device credential | +| `POST /api/auth/logout` | Revoke a credential | +| `POST /api/auth/delegate` | Issue a separately keyed, restricted controller credential | +| `POST /api/auth/provision-device` | Authorized SSH host bootstrap | +| `GET /api/devices` | Same-account device directory | +| `GET /api/devices/{id}/key` | Same-account device public key | +| `POST /api/devices/{id}/rpc` | Encrypted request/response forwarding | +| `POST /api/devices/{id}/messages` | Authenticated device responses and same-account messages | +| `DELETE /api/devices/{id}` | Explicit device removal and revocation | +| `GET /ws` | Authenticated device presence and encrypted messages | + +`auth_connect` verifies a device token before WebSocket routing is enabled. +Devices receive requests over WebSocket and submit payloads through the HTTP +`messages` endpoint, which reserves memory before buffering. Correlation replies +must come from the expected account and device. Small legacy `device_message` +envelopes remain recognized; attachment-sized WebSocket ingress is rejected. +The versioned client and server must be deployed together for this transport. + +## Configuration + +`RELAY_PORT`, `RELAY_DB_PATH`, `RELAY_STATIC_DIR`, `RELAY_ROOM_WEB_DIR`, +`RELAY_ASSET_STORE_MAX_BYTES`, and `RELAY_CORS_ALLOW_ORIGINS` are operator +settings. `RELAY_PAGE_PUBLIC_BASE_URL` and `RELAY_PAGE_AUTH_BASE_URL` must be set +together and use distinct origins when protected Pages are deployed. + +## Verification -Public Pages need no session. `relay` Pages accept any valid account on this -Relay; `private` Pages accept only the owner account. The browser derives the -same Argon2id password hash as native clients, so plaintext passwords are never -sent to Relay. - -| Endpoint | Method | Description | -|----------|--------|-------------| -| `/api/page-auth/sign-in?state=…` | GET | Trusted Relay-hosted username/password form | -| `/api/page-auth/login` | POST | Verify the derived password hash and issue a single-use callback code | -| `/api/page-auth/callback?code=…` | GET | Consume the code on the Page origin and set the scoped browser session cookie | -| `/api/page-auth/client.js` | GET | Browser Argon2id login client | - -### Devices (requires `RELAY_DB_PATH` + Bearer token) - -Used by Desktop / CLI / mobile-web for presence and Peer Device Mode RPC. - -| Endpoint | Method | Description | -|----------|--------|-------------| -| `/api/devices` | GET | List devices for the account (online + offline) | -| `/api/devices/:target_device_id/rpc` | POST | Route an opaque encrypted RPC to an **online** device (waits up to **120s**) | -| `/api/devices/:target_device_id` | DELETE | Remove a device registration (and drop any live WS session) | - -#### Device RPC timeouts (Peer HostInvoke) - -`POST /api/devices/:target_device_id/rpc` waits up to **120 seconds** for the -target device (`RPC_TIMEOUT` in -`../../crates/services/relay-service/src/routes/devices.rs`). Peer Device Mode -uses this for product `invoke` calls. - -Reverse proxies in front of the relay must use a read / response timeout -**≥ 120s** (recommend 130s), or clients see **HTTP 504** before Axum finishes. -See `Caddyfile` for `transport http` timeout settings. - -### Room Operations (Mobile HTTP → Desktop WS bridge) - -| Endpoint | Method | Description | -|----------|--------|-------------| -| `/api/rooms/:room_id/pair` | POST | Mobile initiates pairing; relay forwards to desktop via WebSocket and waits for a response | -| `/api/rooms/:room_id/command` | POST | Mobile sends an encrypted command; relay forwards it to desktop and returns the response | - -### Per-Room Mobile-Web File Management - -| Endpoint | Method | Description | -|----------|--------|-------------| -| `/api/rooms/:room_id/upload-web` | POST | Full upload of base64-encoded files keyed by path (10 MB body limit) | -| `/api/rooms/:room_id/check-web-files` | POST | Incremental check for already uploaded files by hash | -| `/api/rooms/:room_id/upload-web-files` | POST | Incremental upload of only missing files (10 MB body limit) | -| `/r/:room_id/*path` | GET | Serve uploaded mobile-web static files for a room | - -### WebSocket - -| Endpoint | Method | Description | -|----------|--------|-------------| -| `/ws` | WebSocket | Desktop **and CLI** account / room clients | - -### Cross-Device Sync (requires `RELAY_DB_PATH` + Bearer token) - -Encrypted session and settings blobs. All payloads are AES-256-GCM encrypted -client-side with the account master key; the relay cannot read them. - -| Endpoint | Method | Description | -|----------|--------|-------------| -| `/api/sync/sessions` | POST | Upload/replace an encrypted session blob (**64 MiB** Axum body limit) | -| `/api/sync/sessions` | GET | List encrypted session blobs (`?since=`) | -| `/api/sync/sessions/:session_id` | GET | Fetch one encrypted session blob by id | -| `/api/sync/sessions/:session_id` | DELETE | Soft-delete a session blob (tombstone) | -| `/api/sync/settings` | POST | Upload/replace the encrypted settings blob (**64 MiB** Axum body limit) | -| `/api/sync/settings` | GET | Fetch the encrypted settings blob | - -#### Request body size limits (Axum vs reverse proxy) - -Session sync posts a **full** encrypted session bundle. Large conversations can -exceed Axum’s default ~2 MiB limit and fail with **HTTP 413**. - -This server raises the limit on sync POSTs to **64 MiB** (`SYNC_BODY_LIMIT` in -`../../crates/services/relay-service/src/routes/sync.rs`). Proxies must raise -their body limit too, or they reject uploads before Axum sees them: - -```nginx -# nginx — must be >= Axum SYNC_BODY_LIMIT (64M) -client_max_body_size 100M; -``` - -```caddy -# Caddy: request_body { max_size 100MB } -``` - -When diagnosing 413s, check **both** the proxy and Axum. Direct host-port access -only hits the Axum limit. - -## WebSocket Protocol - -Desktop and CLI use WebSocket for rooms and/or account device routing. Mobile -clients use the HTTP endpoints above. - -### Client → Server (Inbound) - -```json -// Create a room (Remote Connect room bridge) -{ "type": "create_room", "room_id": "optional-id", "device_id": "...", "device_type": "desktop", "public_key": "base64..." } - -// Respond to a bridged HTTP request (pair or command) -{ "type": "relay_response", "correlation_id": "...", "encrypted_data": "base64...", "nonce": "base64..." } - -// Heartbeat -{ "type": "heartbeat" } - -// Account-authenticated device routing (requires RELAY_DB_PATH) -{ "type": "auth_connect", "token": "...", "device_name": "..." } -{ "type": "device_message", "target_device_id": "...", "correlation_id": "...", "encrypted_data": "base64...", "nonce": "base64..." } -``` - -A second `auth_connect` with the same `(user_id, device_id)` **replaces** the -previous live connection (last connect wins). - -### Server → Client (Outbound) - -```json -{ "type": "room_created", "room_id": "..." } -{ "type": "pair_request", "correlation_id": "...", "public_key": "base64...", "device_id": "...", "device_name": "..." } -{ "type": "command", "correlation_id": "...", "encrypted_data": "base64...", "nonce": "base64..." } -{ "type": "heartbeat_ack" } -{ "type": "auth_ok", "user_id": "...", "device_id": "..." } -{ "type": "auth_error", "message": "..." } -{ "type": "incoming_device_message", "source_device_id": "...", "correlation_id": "...", "encrypted_data": "base64...", "nonce": "base64..." } -{ "type": "device_presence", "devices": [{ "device_id": "...", "device_name": "..." }] } -{ "type": "error", "message": "..." } -``` - -## Architecture - -``` -Mobile ──HTTP──► Relay ◄──WebSocket── Desktop / CLI - │ - opaque E2E payloads - (optional SQLite for - accounts / sync / devices) -``` - -- **Room bridge**: Desktop creates a room; mobile posts `/pair` and `/command`; - the relay correlates HTTP ↔ WebSocket without reading ciphertext. -- **Account plane** (when `RELAY_DB_PATH` is set): clients log in over HTTP, - then `auth_connect` on WebSocket; device RPC and sync store opaque blobs. -- Per-room mobile-web files can be served at `/r/:room_id/`. - -## Directory structure - -``` -relay-server/ -├── src/ -│ ├── main.rs # Relay server binary entry point -│ ├── config.rs # Environment-based configuration -│ └── bin/ -│ └── relay_admin.rs # relay-admin CLI binary -├── static/ # Mobile-web static files -├── Cargo.toml -├── Dockerfile -├── docker-compose.yml # Sets RELAY_DB_PATH for account mode -├── Caddyfile # Optional reverse proxy (body + RPC timeouts) -├── deploy.sh -├── start.sh / stop.sh / restart.sh -├── common.sh # Shared helpers for the scripts above -└── README.md +```bash +cargo test -p openbitfun-relay-server --bin openbitfun-relay-server +cargo test -p openbitfun-relay-service +cargo check -p openbitfun-relay-server +node scripts/check-core-boundaries.mjs ``` -Reusable relay state, storage, asset stores, and HTTP/WebSocket routes live in -`src/crates/services/relay-service`. This directory owns only the standalone -process configuration, static-file fallback, and operator CLI. - -## About `src/apps/server` vs `src/apps/relay-server` - -- Self-hosted Remote Connect **and** open-source account login use **this** - `relay-server` directory. -- `src/apps/server` is a different application and is not the relay used by - Desktop / CLI / mobile Remote Connect. +Unit and integration tests are local evidence. Record live remote-control, +peer-device, remote-workspace, and detached-dispatch validation separately. diff --git a/src/apps/relay-server/common.sh b/src/apps/relay-server/common.sh index 7c9aaa18da..0858ed11f0 100755 --- a/src/apps/relay-server/common.sh +++ b/src/apps/relay-server/common.sh @@ -249,40 +249,12 @@ wait_for_relay_health() { return 1 } -print_add_user_command() { - echo " docker exec -it ${CONTAINER_NAME} /app/relay-admin --db ${RELAY_ADMIN_DB} add-user --username " -} - +# Identities are created only after GitHub verification; an empty database is valid. check_relay_accounts_or_remind() { if ! container_running; then - echo "Warning: container '${CONTAINER_NAME}' is not running; skipped account check." - echo "After it is up, create an account with:" - print_add_user_command + echo "Warning: container '${CONTAINER_NAME}' is not running." return 0 fi - - local user_list - user_list="$( - docker_cmd exec "$CONTAINER_NAME" /app/relay-admin --db "$RELAY_ADMIN_DB" list-users 2>/dev/null || true - )" - - local empty=0 - if echo "$user_list" | grep -q '^No accounts found\.'; then - empty=1 - elif ! echo "$user_list" | grep -q '^USERNAME'; then - empty=1 - fi - - if [ "$empty" -eq 1 ]; then - echo "No relay accounts yet. Account login will not work until you create one." - echo "Run:" - print_add_user_command - echo "(omit --password to enter the password interactively)" - else - local user_count - user_count="$( - echo "$user_list" | awk 'NR>2 && NF { count++ } END { print count+0 }' - )" - echo "Relay accounts found: ${user_count}" - fi + echo "Sign in with GitHub from a client built for this Relay endpoint." + echo "No password accounts need to be provisioned." } diff --git a/src/apps/relay-server/src/bin/relay_admin.rs b/src/apps/relay-server/src/bin/relay_admin.rs index 9d5d10d9e9..a2f8921d26 100644 --- a/src/apps/relay-server/src/bin/relay_admin.rs +++ b/src/apps/relay-server/src/bin/relay_admin.rs @@ -1,164 +1,43 @@ -//! relay-admin — CLI tool for managing relay server accounts. -//! -//! Run inside the relay-server Docker container or on the server directly: -//! -//! relay-admin add-user --db --username [--password ] -//! relay-admin list-users --db -//! relay-admin delete-user --db --username -//! relay-admin reset-password --db --username [--password ] -//! relay-admin import-user --db [--file ] -//! -//! If `--password` is omitted the tool prompts interactively (hidden input). -//! The plaintext password is never stored — only Argon2id-derived hashes and -//! AES-256-GCM wrapped master keys are written to the database. -//! -//! `import-user` inserts an account provisioned elsewhere (JSON from --file or -//! stdin). The plaintext password never transits the server: the producer -//! (e.g. an OpenBitFun client self-deploying its relay) only sends derived -//! artifacts — salts, the Argon2id password hash, and the wrapped master key. - -use anyhow::{anyhow, Result}; +//! Inspect or explicitly delete relay records. GitHub owns account identity. +use anyhow::Result; use clap::{Parser, Subcommand}; #[derive(Parser)] -#[command(name = "relay-admin")] -#[command(about = "Manage relay server accounts (provisioning tool)")] +#[command(name = "relay-admin", about = "Inspect GitHub-linked relay accounts")] struct Cli { - /// Path to the SQLite database file. #[arg(long, env = "RELAY_DB_PATH")] db: String, - #[command(subcommand)] command: Command, } #[derive(Subcommand)] enum Command { - /// Create a new account. - AddUser { - #[arg(long)] - username: String, - /// Omit to prompt interactively (recommended). - #[arg(long)] - password: Option, - }, - /// List all accounts. + /// List GitHub-linked accounts. ListUsers, - /// Delete an account and all its data. + /// Explicitly delete an account and its relay data. DeleteUser { #[arg(long)] username: String, }, - /// Reset an account's password (generates new salts + new master key). - ResetPassword { - #[arg(long)] - username: String, - #[arg(long)] - password: Option, - }, - /// Rename an existing account. Credentials and user_id stay the same. - RenameUser { - #[arg(long)] - username: String, - #[arg(long)] - new_username: String, - }, - /// Import an account provisioned elsewhere (JSON from --file or stdin). - /// The JSON carries only derived artifacts, never the plaintext password. - ImportUser { - /// Path to the provisioned account JSON; omit to read from stdin. - #[arg(long)] - file: Option, - }, } #[tokio::main] async fn main() -> Result<()> { let cli = Cli::parse(); - let pool = openbitfun_relay_service::db::connect_for_admin(&cli.db).await?; - match cli.command { - Command::AddUser { username, password } => { - let password = resolve_password(password)?; - let user_id = - openbitfun_relay_service::admin::add_user(&pool, &username, &password).await?; - println!("Created account: username='{username}' user_id={user_id}"); - } Command::ListUsers => { - let users = openbitfun_relay_service::admin::list_users(&pool).await?; - if users.is_empty() { - println!("No accounts found."); - } else { - println!("{:<24} {:<38} CREATED", "USERNAME", "USER_ID"); - println!("{}", "-".repeat(80)); - for (username, user_id, created) in users { - let dt = chrono::DateTime::from_timestamp(created, 0) - .map(|dt| dt.format("%Y-%m-%d %H:%M").to_string()) - .unwrap_or_else(|| created.to_string()); - println!("{:<24} {:<38} {dt}", username, user_id); - } + for (login, github_id, created) in + openbitfun_relay_service::admin::list_users(&pool).await? + { + println!("{login}\t{github_id}\t{created}"); } } Command::DeleteUser { username } => { openbitfun_relay_service::admin::delete_user(&pool, &username).await?; - println!("Deleted account: {username}"); - } - Command::ResetPassword { username, password } => { - let password = resolve_password(password)?; - openbitfun_relay_service::admin::reset_password(&pool, &username, &password).await?; - println!("Password reset for: {username}"); - println!("NOTE: All previously synced sessions/settings are now unreadable"); - println!(" (they were encrypted with the old master key)."); - } - Command::RenameUser { - username, - new_username, - } => { - openbitfun_relay_service::admin::rename_user(&pool, &username, &new_username).await?; - println!("Renamed: {username} → {new_username}"); - } - Command::ImportUser { file } => { - let json = match file { - Some(path) => std::fs::read_to_string(&path) - .map_err(|e| anyhow!("read import file '{path}': {e}"))?, - None => { - use std::io::Read; - let mut buf = String::new(); - std::io::stdin() - .read_to_string(&mut buf) - .map_err(|e| anyhow!("read import JSON from stdin: {e}"))?; - buf - } - }; - let import: openbitfun_relay_service::admin::ImportableAccount = - serde_json::from_str(&json).map_err(|e| anyhow!("parse import JSON: {e}"))?; - let user_id = openbitfun_relay_service::admin::import_user(&pool, &import).await?; - println!( - "Imported account: username='{}' user_id={user_id}", - import.username - ); + println!("Deleted relay account: {username}"); } } - Ok(()) } - -/// Use the provided password, or prompt interactively with hidden input. -fn resolve_password(provided: Option) -> Result { - match provided { - Some(p) if p.len() >= 8 => Ok(p), - Some(_) => Err(anyhow!("password must be at least 8 characters")), - None => { - let p1 = rpassword::prompt_password("Enter password: ")?; - if p1.len() < 8 { - return Err(anyhow!("password must be at least 8 characters")); - } - let p2 = rpassword::prompt_password("Confirm password: ")?; - if p1 != p2 { - return Err(anyhow!("passwords do not match")); - } - Ok(p1) - } - } -} diff --git a/src/apps/relay-server/src/config.rs b/src/apps/relay-server/src/config.rs index b5972a65ad..ad92c3eb9b 100644 --- a/src/apps/relay-server/src/config.rs +++ b/src/apps/relay-server/src/config.rs @@ -1,18 +1,18 @@ //! Relay server configuration. +use anyhow::Context; use std::net::SocketAddr; #[derive(Debug, Clone)] #[allow(dead_code)] pub(super) struct RelayConfig { pub listen_addr: SocketAddr, - pub room_ttl_secs: u64, pub heartbeat_interval_secs: u64, pub heartbeat_timeout_secs: u64, pub static_dir: Option, - /// Directory where per-room uploaded mobile-web files are stored. - pub room_web_dir: String, - /// Global capacity for content-addressed room and Page assets. + /// Directory for published Page assets. + pub asset_dir: String, + /// Global capacity for content-addressed Page assets. pub asset_store_max_bytes: u64, pub cors_allow_origins: Vec, /// Browser-visible base URL used for published Page content. @@ -20,7 +20,7 @@ pub(super) struct RelayConfig { /// Browser-visible base URL used for trusted Relay Page login. pub page_auth_base_url: Option, /// Path to the SQLite database file used for account storage. - /// When None, account features are disabled (relay acts as pure relay only). + /// Required at startup; every device connection is account-authenticated. pub db_path: Option, } @@ -28,12 +28,11 @@ impl Default for RelayConfig { fn default() -> Self { Self { listen_addr: ([0, 0, 0, 0], 9700).into(), - room_ttl_secs: 300, heartbeat_interval_secs: 30, heartbeat_timeout_secs: 90, static_dir: None, - // Also stores published OpenBitFun Pages under `{room_web_dir}/pages/{user_id}/{slug}/`. - room_web_dir: "/tmp/openbitfun-room-web".to_string(), + // Also stores published OpenBitFun Pages under `{asset_dir}/pages/{user_id}/{slug}/`. + asset_dir: "/tmp/openbitfun-room-web".to_string(), asset_store_max_bytes: openbitfun_relay_service::DEFAULT_DISK_ASSET_STORE_MAX_BYTES, cors_allow_origins: Vec::new(), page_public_base_url: None, @@ -44,29 +43,32 @@ impl Default for RelayConfig { } impl RelayConfig { - pub(super) fn from_env() -> Self { + pub(super) fn from_env() -> anyhow::Result { let mut cfg = Self::default(); if let Ok(port) = std::env::var("RELAY_PORT") { if let Ok(p) = port.parse::() { cfg.listen_addr = ([0, 0, 0, 0], p).into(); } } + if let Ok(addr) = std::env::var("RELAY_LISTEN_ADDR") { + cfg.listen_addr = addr + .parse() + .context("RELAY_LISTEN_ADDR must be an IP socket address")?; + } if let Ok(dir) = std::env::var("RELAY_STATIC_DIR") { cfg.static_dir = Some(dir); } - if let Ok(dir) = std::env::var("RELAY_ROOM_WEB_DIR") { - cfg.room_web_dir = dir; + // Keep the previous storage location readable during upgrades. + if let Ok(dir) = + std::env::var("RELAY_ASSET_DIR").or_else(|_| std::env::var("RELAY_ROOM_WEB_DIR")) + { + cfg.asset_dir = dir; } if let Ok(limit) = std::env::var("RELAY_ASSET_STORE_MAX_BYTES") { if let Ok(bytes) = limit.parse::() { cfg.asset_store_max_bytes = bytes; } } - if let Ok(ttl) = std::env::var("RELAY_ROOM_TTL") { - if let Ok(t) = ttl.parse() { - cfg.room_ttl_secs = t; - } - } if let Ok(path) = std::env::var("RELAY_DB_PATH") { if path.is_empty() { cfg.db_path = None; @@ -88,6 +90,6 @@ impl RelayConfig { cfg.page_auth_base_url = std::env::var("RELAY_PAGE_AUTH_BASE_URL") .ok() .filter(|value| !value.trim().is_empty()); - cfg + Ok(cfg) } } diff --git a/src/apps/relay-server/src/lib.rs b/src/apps/relay-server/src/lib.rs index 31ce796e23..5a6f89640f 100644 --- a/src/apps/relay-server/src/lib.rs +++ b/src/apps/relay-server/src/lib.rs @@ -5,18 +5,16 @@ pub use openbitfun_relay_service::{ admin, db, page_execution, relay, routes, AppState, DiskAssetStore, MemoryAssetStore, - ResponsePayload, RoomManager, WebAssetStore, + WebAssetStore, }; /// Builds the shared relay router using this host's version. pub fn build_relay_router( - room_manager: std::sync::Arc, asset_store: std::sync::Arc, start_time: std::time::Instant, - db: Option>, + db: std::sync::Arc, ) -> axum::Router { openbitfun_relay_service::build_relay_router_with_page_data( - room_manager, asset_store, start_time, db, diff --git a/src/apps/relay-server/src/main.rs b/src/apps/relay-server/src/main.rs index 2b65836554..9d69463b6b 100644 --- a/src/apps/relay-server/src/main.rs +++ b/src/apps/relay-server/src/main.rs @@ -1,7 +1,7 @@ //! OpenBitFun Relay Server //! //! Standalone binary that runs the relay as a network service. -//! Uses `DiskAssetStore` for filesystem-backed mobile-web file storage. +//! Uses `DiskAssetStore` for filesystem-backed published Page assets. use anyhow::Context; use std::sync::Arc; @@ -10,7 +10,7 @@ use tracing::info; mod config; use config::RelayConfig; -use openbitfun_relay_service::{DiskAssetStore, RoomManager, WebAssetStore}; +use openbitfun_relay_service::DiskAssetStore; #[tokio::main] async fn main() -> anyhow::Result<()> { @@ -21,28 +21,14 @@ async fn main() -> anyhow::Result<()> { ) .init(); - let cfg = RelayConfig::from_env(); + let cfg = RelayConfig::from_env()?; info!("OpenBitFun Relay Server v{}", env!("CARGO_PKG_VERSION")); - let room_manager = RoomManager::new(); let asset_store = Arc::new(DiskAssetStore::new_with_max_bytes( - &cfg.room_web_dir, + &cfg.asset_dir, cfg.asset_store_max_bytes, )); - let cleanup_rm = room_manager.clone(); - let cleanup_ttl = cfg.room_ttl_secs; - let cleanup_store = asset_store.clone(); - tokio::spawn(async move { - loop { - tokio::time::sleep(std::time::Duration::from_secs(60)).await; - let stale_ids = cleanup_rm.cleanup_stale_rooms(cleanup_ttl); - for room_id in &stale_ids { - cleanup_store.cleanup_room(room_id); - } - } - }); - let start_time = std::time::Instant::now(); let db = if let Some(path) = &cfg.db_path { @@ -51,12 +37,11 @@ async fn main() -> anyhow::Result<()> { .with_context(|| { format!("failed to initialize configured account database at {path}") })?; - Some(Arc::new(pool)) + Arc::new(pool) } else { - info!("RELAY_DB_PATH not set — account features disabled (pure relay mode)"); - None + anyhow::bail!("RELAY_DB_PATH is required; anonymous relay mode is no longer supported") }; - if db.is_some() && cfg.cors_allow_origins.iter().any(|origin| origin == "*") { + if cfg.cors_allow_origins.iter().any(|origin| origin == "*") { anyhow::bail!( "RELAY_CORS_ALLOW_ORIGINS=* is not allowed when RELAY_DB_PATH enables account APIs" ); @@ -70,10 +55,10 @@ async fn main() -> anyhow::Result<()> { .map_err(anyhow::Error::msg)?, ), (None, None) => { - if db.is_some() { + { tracing::warn!( "RELAY_PAGE_PUBLIC_BASE_URL and RELAY_PAGE_AUTH_BASE_URL are not set; \ - protected Page login uses same-origin compatibility mode" + published Pages are disabled until isolated origins are configured" ); } None @@ -83,14 +68,14 @@ async fn main() -> anyhow::Result<()> { ), }; - let page_data_dir = std::path::PathBuf::from(&cfg.room_web_dir).join("page-data"); + let pages_enabled = page_browser_auth.is_some(); + let page_data_dir = std::path::PathBuf::from(&cfg.asset_dir).join("page-data"); let mut app = openbitfun_relay_service::build_relay_router_with_page_data_origins_and_page_auth( - room_manager, asset_store, start_time, db, env!("CARGO_PKG_VERSION"), - Some(page_data_dir), + pages_enabled.then_some(page_data_dir), cfg.cors_allow_origins.clone(), page_browser_auth, ); @@ -101,18 +86,19 @@ async fn main() -> anyhow::Result<()> { tower_http::services::ServeDir::new(static_dir).append_index_html_on_directories(true), ); } + if !pages_enabled { + app = app.layer(axum::middleware::from_fn(require_isolated_page_origins)); + } // Re-apply after installing the optional fallback so static files receive // the same browser hardening as relay API responses. - app = app.layer(axum::middleware::from_fn( - openbitfun_relay_service::relay_security_headers, - )); + app = app.layer(axum::middleware::from_fn(host_security_headers)); - info!("Room web upload dir: {}", cfg.room_web_dir); + info!("Page asset directory: {}", cfg.asset_dir); info!("Asset store capacity: {} bytes", cfg.asset_store_max_bytes); let listener = tokio::net::TcpListener::bind(cfg.listen_addr).await?; info!("Relay server listening on {}", cfg.listen_addr); - info!("WebSocket endpoint: ws://{}/ws", cfg.listen_addr); + info!("Device WebSocket endpoint: ws://{}/ws", cfg.listen_addr); axum::serve( listener, @@ -121,3 +107,71 @@ async fn main() -> anyhow::Result<()> { .await?; Ok(()) } + +// The trusted mobile document needs camera access for its QR scanner. Keep +// uploaded content and API responses under the shared restrictive policy. +async fn host_security_headers( + request: axum::extract::Request, + next: axum::middleware::Next, +) -> axum::response::Response { + let controller_document = matches!(request.uri().path(), "/" | "/index.html"); + let mut response = openbitfun_relay_service::relay_security_headers(request, next).await; + if controller_document { + response.headers_mut().insert( + "permissions-policy", + axum::http::HeaderValue::from_static("camera=(self), microphone=(), geolocation=()"), + ); + } + response +} + +async fn require_isolated_page_origins( + request: axum::extract::Request, + next: axum::middleware::Next, +) -> axum::response::Response { + use axum::response::IntoResponse; + if is_published_page_path(request.uri().path()) { + return ( + axum::http::StatusCode::SERVICE_UNAVAILABLE, + axum::Json(serde_json::json!({"error": "Published Pages require isolated public and sign-in origins"})), + ).into_response(); + } + next.run(request).await +} + +fn is_published_page_path(path: &str) -> bool { + ["/api/pages", "/api/page-auth", "/p"].iter().any(|prefix| { + path == *prefix + || path + .strip_prefix(prefix) + .is_some_and(|tail| tail.starts_with('/')) + }) +} + +#[cfg(test)] +mod tests { + use super::is_published_page_path; + + #[test] + fn gate_all_published_page_routes_without_blocking_account_or_device_routes() { + for path in [ + "/api/pages", + "/api/pages/foo", + "/api/page-auth/login", + "/p", + "/p/owner/page", + ] { + assert!(is_published_page_path(path), "{path}"); + } + for path in [ + "/health", + "/ws", + "/api/devices", + "/api/auth/login", + "/privacy", + "/api/pages-other", + ] { + assert!(!is_published_page_path(path), "{path}"); + } + } +} diff --git a/src/apps/relay-server/tests/library_compat.rs b/src/apps/relay-server/tests/library_compat.rs index a715e1ea99..f605d46091 100644 --- a/src/apps/relay-server/tests/library_compat.rs +++ b/src/apps/relay-server/tests/library_compat.rs @@ -1,35 +1,27 @@ use openbitfun_relay_server::{ admin, build_relay_router, db, relay, routes, AppState, DiskAssetStore, MemoryAssetStore, - ResponsePayload, RoomManager, WebAssetStore, + WebAssetStore, }; use std::sync::Arc; use std::time::Instant; -#[test] -fn openbitfun_library_path_exposes_supported_relay_api() { - let _: fn( - Arc, - Arc, - Instant, - Option>, - ) -> axum::Router = build_relay_router; +#[tokio::test] +async fn openbitfun_library_path_exposes_supported_relay_api() { + let _: fn(Arc, Instant, Arc) -> axum::Router = + build_relay_router; let _ = admin::list_users; let _ = db::connect; let _ = DiskAssetStore::new; let _ = MemoryAssetStore::new; - let _ = RoomManager::new; - let _ = relay::room::RoomManager::new; let _ = routes::api::health_check; // Pin the symbol, not a call: `server_info` is async, and `let _ =` on the // returned future would drop it unpolled. let _ = routes::api::server_info; - let _: Option = None; let _ = std::mem::size_of::(); let _ = AppState { - room_manager: RoomManager::new(), start_time: Instant::now(), asset_store: Arc::new(MemoryAssetStore::new()), - db: None, + db: Arc::new(db::connect(":memory:").await.unwrap()), page_data: None, page_access_manager: Arc::new(routes::pages::PageAccessManager::new()), page_upload_manager: Arc::new(routes::pages::PageUploadManager::new()), diff --git a/src/crates/assembly/core/src/agentic/tools/account_login_capability.rs b/src/crates/assembly/core/src/agentic/tools/account_login_capability.rs index 23d9d1489a..923f2abaf9 100644 --- a/src/crates/assembly/core/src/agentic/tools/account_login_capability.rs +++ b/src/crates/assembly/core/src/agentic/tools/account_login_capability.rs @@ -1,4 +1,4 @@ -//! Account login gate for tools that require a OpenBitFun account session. +//! Account login gate for tools that require a GitHub account session. use std::sync::atomic::{AtomicBool, Ordering}; @@ -7,7 +7,7 @@ use std::sync::{Mutex, MutexGuard}; static ACCOUNT_LOGIN_AVAILABLE: AtomicBool = AtomicBool::new(false); -/// Mark whether the current process has a fully logged-in OpenBitFun account session. +/// Mark whether the current process has a fully logged-in GitHub account session. pub fn set_account_login_available(available: bool) { ACCOUNT_LOGIN_AVAILABLE.store(available, Ordering::SeqCst); } diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/page_deploy_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/page_deploy_tool.rs index 9d2142f5a8..148475e9c6 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/page_deploy_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/page_deploy_tool.rs @@ -31,7 +31,7 @@ impl Tool for PageDeployTool { Ok( r#"Switch the production pointer of an existing OpenBitFun Page to a previously saved version_id (rollback or promote a prior version). -Requires a logged-in OpenBitFun account. This tool is only available after account login. To create or update page content and publish, use PagePublish instead. Existing versions can also be reviewed from the Pages scene. +Requires a logged-in GitHub account. This tool is only available after account login. To create or update page content and publish, use PagePublish instead. Existing versions can also be reviewed from the Pages scene. Input: slug (page path id), version_id (immutable saved version from a prior PagePublish). Returns absolute `url` plus url_path / deployed_version_id. Public links can be shared directly. Private and relay links must be opened or copied through the Pages scene/tool card so the browser receives a scoped one-time access handoff. @@ -120,7 +120,7 @@ Preview a version at /p/{username}/{slug}/@v/{version_id}."# ) -> OpenBitFunResult> { if !account_login_available() { return Err(OpenBitFunError::tool( - "PageDeploy requires a logged-in OpenBitFun account".to_string(), + "PageDeploy requires a logged-in GitHub account".to_string(), )); } diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/page_publish_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/page_publish_tool.rs index 38cb7e5d2d..72fc65b2fa 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/page_publish_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/page_publish_tool.rs @@ -34,7 +34,7 @@ impl Tool for PagePublishTool { Ok( r#"Publish a OpenBitFun Page to the account relay: upload content, freeze an immutable version, and optionally deploy it to production. -Requires a logged-in OpenBitFun account. This tool is only available after account login. Published Pages can be reviewed and managed later from the Pages scene. +Requires a logged-in GitHub account. This tool is only available after account login. Published Pages can be reviewed and managed later from the Pages scene. When you produce self-contained publishable web content (landing page, docs site, or a Page with server/worker.js) and the user is logged in, proactively ask whether they want it published to OpenBitFun Page (suggest a slug and visibility). If they already said publish/deploy/上线, proceed with permission confirmation. @@ -173,7 +173,7 @@ Use PageDeploy only to switch an already-saved version_id (rollback / promote a ) -> OpenBitFunResult> { if !account_login_available() { return Err(OpenBitFunError::tool( - "PagePublish requires a logged-in OpenBitFun account".to_string(), + "PagePublish requires a logged-in GitHub account".to_string(), )); } diff --git a/src/crates/assembly/core/src/agentic/tools/pipeline/tool_pipeline.rs b/src/crates/assembly/core/src/agentic/tools/pipeline/tool_pipeline.rs index 0aedc5a18a..a1d7596dcb 100644 --- a/src/crates/assembly/core/src/agentic/tools/pipeline/tool_pipeline.rs +++ b/src/crates/assembly/core/src/agentic/tools/pipeline/tool_pipeline.rs @@ -579,7 +579,7 @@ fn permission_project_path(context: &ToolUseContext) -> OpenBitFunResult const ACCOUNT_PERMISSION_SCOPE: &str = "account"; const ACCOUNT_PERMISSION_PROJECT_ID: &str = "__openbitfun_account_actions__"; -const ACCOUNT_PERMISSION_PROJECT_PATH: &str = "OpenBitFun account"; +const ACCOUNT_PERMISSION_PROJECT_PATH: &str = "GitHub account"; fn permission_scope( context: &ToolUseContext, diff --git a/src/crates/assembly/core/src/service/config/service.rs b/src/crates/assembly/core/src/service/config/service.rs index 16e54edc10..f3b64df301 100644 --- a/src/crates/assembly/core/src/service/config/service.rs +++ b/src/crates/assembly/core/src/service/config/service.rs @@ -64,12 +64,6 @@ impl<'de> Deserialize<'de> for ConfigExport { } } -#[derive(Clone, Copy, PartialEq, Eq)] -enum ConfigImportSource { - Explicit, - AccountSync, -} - fn validate_config_export(export: &ConfigExport) -> OpenBitFunResult<()> { validate_openbitfun_product_identity(&export.product_id, "Configuration export")?; if export.format_version != CURRENT_CONFIG_EXPORT_FORMAT_VERSION { @@ -370,41 +364,6 @@ impl ConfigService { pub async fn import_config( &self, export: ConfigExport, - ) -> OpenBitFunResult { - self.import_config_from_source(export, ConfigImportSource::Explicit, None) - .await - } - - /// Applies a complete current-format OpenBitFun account settings export. - pub async fn import_account_settings( - &self, - export: ConfigExport, - ) -> OpenBitFunResult { - self.import_config_from_source(export, ConfigImportSource::AccountSync, None) - .await - } - - /// A periodic pull may spend seconds on the network. Apply its response - /// only if the local document still matches the pre-fetch snapshot, with - /// the comparison and import protected by the same manager write lock. - pub async fn import_account_settings_if_unchanged( - &self, - export: ConfigExport, - expected_local_config: serde_json::Value, - ) -> OpenBitFunResult { - self.import_config_from_source( - export, - ConfigImportSource::AccountSync, - Some(expected_local_config), - ) - .await - } - - async fn import_config_from_source( - &self, - export: ConfigExport, - source: ConfigImportSource, - expected_local_config: Option, ) -> OpenBitFunResult { if let Err(error) = validate_config_export(&export) { return Ok(ConfigImportResult { @@ -416,15 +375,6 @@ impl ConfigService { let config_data = serde_json::to_value(export.config)?; let import_result = { let mut manager = self.manager.write().await; - if let Some(expected) = expected_local_config { - if manager.export_config()? != expected { - return Ok(ConfigImportResult { - success: false, - errors: vec!["Local settings changed while cloud settings were being fetched; skipped the stale response".to_string()], - warnings: Vec::new(), - }); - } - } manager.import_config(config_data).await }; @@ -436,9 +386,7 @@ impl ConfigService { .await; #[cfg(feature = "web-tools")] self.refresh_web_search_runtime().await; - if source == ConfigImportSource::Explicit { - self.local_changes.send_replace(()); - } + self.local_changes.send_replace(()); Ok(ConfigImportResult { success: true, errors: Vec::new(), @@ -1041,7 +989,7 @@ mod tests { #[tokio::test] async fn imports_still_honor_explicit_deletions_and_default_elision_in_backups() { - for account_sync in [false, true] { + for typed_export in [false, true] { let (service, _dir) = test_service("import-explicit-deletions").await; // A raw backup intentionally omits these default values. Restoring // it must still reset them to the declared defaults. @@ -1069,7 +1017,7 @@ mod tests { local.app.notifications.enabled = false; service.set_config("", &local).await.unwrap(); - let mut incoming = if account_sync { + let mut incoming = if typed_export { serde_json::to_value(GlobalConfig::default()).unwrap() } else { raw_backup @@ -1078,11 +1026,7 @@ mod tests { incoming["ai"]["agent_model_defaults"]["subagents"]["builtin"] = serde_json::json!({}); incoming["workspace"]["exclude_patterns"] = serde_json::json!([]); let export = current_export(serde_json::from_value(incoming).unwrap()); - let result = if account_sync { - service.import_account_settings(export).await.unwrap() - } else { - service.import_config(export).await.unwrap() - }; + let result = service.import_config(export).await.unwrap(); assert!(result.success, "{:?}", result.errors); let saved: GlobalConfig = service.get_config(None).await.unwrap(); assert!(saved.mcp_servers.is_none()); @@ -1112,81 +1056,7 @@ mod tests { } #[tokio::test] - async fn local_change_notifications_cover_mutations_without_echoing_cloud_restores() { - let (service, _dir) = test_service("config-local-notifications").await; - let mut changes = service.subscribe_local_changes(); - assert!(!changes.has_changed().unwrap()); - - service - .set_config("app.notifications.enabled", false) - .await - .unwrap(); - assert!(changes.has_changed().unwrap()); - changes.borrow_and_update(); - - service - .update_config("ai.skill_settings", |settings: &mut SkillSettingsConfig| { - settings - .globally_disabled_user_skills - .push("user::fixture".to_string()); - Ok(()) - }) - .await - .unwrap(); - assert!(changes.has_changed().unwrap()); - changes.borrow_and_update(); - - let snapshot: serde_json::Value = service.get_config(None).await.unwrap(); - assert!( - service - .import_account_settings(current_export( - serde_json::from_value(snapshot.clone()).unwrap(), - )) - .await - .unwrap() - .success - ); - service.reload().await.unwrap(); - assert!( - !changes.has_changed().unwrap(), - "Cloud imports must not start an upload feedback loop" - ); - - assert!( - service - .import_config(current_export(serde_json::from_value(snapshot).unwrap())) - .await - .unwrap() - .success - ); - assert!(changes.has_changed().unwrap()); - changes.borrow_and_update(); - - service - .reset_config(Some("app.notifications")) - .await - .unwrap(); - assert!(changes.has_changed().unwrap()); - changes.borrow_and_update(); - - service - .install_runtime_ai_model(runtime_model("ephemeral", "runtime-fixture-key")) - .await - .unwrap(); - assert!( - !changes.has_changed().unwrap(), - "Runtime-only credentials must not be synced" - ); - - service.save_cloud_speech_config(serde_json::from_value(serde_json::json!({ - "preset": "custom", "name": "Speech fixture", "baseUrl": "https://example.com/v1", - "modelName": "speech-fixture", "apiKey": "speech-fixture-key" - })).unwrap()).await.unwrap(); - assert!(changes.has_changed().unwrap()); - } - - #[tokio::test] - async fn account_settings_round_trip_covers_persisted_preference_groups() { + async fn config_export_round_trip_covers_persisted_preference_groups() { use serde_json::json; let (source, _source_dir) = test_service("sync-coverage-source").await; @@ -1268,7 +1138,7 @@ mod tests { source.set_config(path, value).await.unwrap(); assert!( changes.has_changed().unwrap(), - "Missing upload signal: {path}" + "Missing configuration change signal: {path}" ); changes.borrow_and_update(); } @@ -1276,13 +1146,13 @@ mod tests { let payload = serde_json::to_string(&source.export_config().await.unwrap()).unwrap(); let target_changes = target.subscribe_local_changes(); let result = target - .import_account_settings(serde_json::from_str(&payload).unwrap()) + .import_config(serde_json::from_str(&payload).unwrap()) .await .unwrap(); assert!(result.success, "{:?}", result.errors); assert!( - !target_changes.has_changed().unwrap(), - "Cloud apply echoed an upload" + target_changes.has_changed().unwrap(), + "Explicit import must notify local changes" ); drop(target); let restarted = restart_test_service(&target_dir, "sync-coverage-target").await; @@ -1293,7 +1163,7 @@ mod tests { let source_value: serde_json::Value = source.get_config(Some(path)).await.unwrap(); assert_eq!( actual, source_value, - "Settings lost in sync/restart: {path}" + "Settings lost in export/import/restart: {path}" ); if path != "ai.agent_profiles" && path != "ai.models" { assert_eq!( @@ -1320,57 +1190,6 @@ mod tests { ); } - #[tokio::test] - async fn stale_cloud_pull_cannot_overwrite_a_save_made_during_the_fetch() { - let name = "config-stale-cloud-pull"; - let (service, dir) = test_service(name).await; - let before_fetch: serde_json::Value = service.get_config(None).await.unwrap(); - let mut cloud = before_fetch.clone(); - cloud["app"]["voice_call"]["api_key"] = serde_json::json!("old-cloud-fixture-key"); - - let (saved, imported) = race_config_operations( - &service, - service.set_config("app.voice_call.api_key", "new-local-fixture-key"), - service.import_account_settings_if_unchanged( - current_export(serde_json::from_value(cloud.clone()).unwrap()), - before_fetch, - ), - ) - .await; - saved.unwrap(); - let imported = imported.unwrap(); - assert!(!imported.success); - assert!(imported.errors[0].contains("Local settings changed")); - let restarted = restart_test_service(&dir, name).await; - assert_eq!( - restarted - .get_config::(Some("app.voice_call.api_key")) - .await - .unwrap(), - "new-local-fixture-key" - ); - - // A later pull with a current snapshot is still authoritative. - let current = service.get_config(None).await.unwrap(); - assert!( - service - .import_account_settings_if_unchanged( - current_export(serde_json::from_value(cloud).unwrap()), - current, - ) - .await - .unwrap() - .success - ); - assert_eq!( - service - .get_config::(Some("app.voice_call.api_key")) - .await - .unwrap(), - "old-cloud-fixture-key" - ); - } - #[tokio::test] async fn wrong_product_export_is_rejected_without_changing_config() { let (service, _dir) = test_service("wrong-product-export").await; @@ -1433,7 +1252,7 @@ mod tests { } #[tokio::test] - async fn current_account_export_is_an_authoritative_replacement() { + async fn current_export_is_an_authoritative_replacement() { let name = "current-account-export"; let (service, dir) = test_service(name).await; service @@ -1444,7 +1263,7 @@ mod tests { let mut incoming = GlobalConfig::default(); incoming.ai.models = vec![runtime_model("cloud-model", "fixture-cloud-model-key")]; let imported = service - .import_account_settings(current_export(incoming)) + .import_config(current_export(incoming)) .await .unwrap(); assert!(imported.success, "{:?}", imported.errors); @@ -1462,7 +1281,7 @@ mod tests { } #[tokio::test] - async fn realtime_voice_account_sync_updates_keys_and_backs_up_the_previous_file() { + async fn realtime_voice_import_updates_keys_and_backs_up_the_previous_file() { let name = "realtime-voice-account-update"; let (service, dir) = test_service(name).await; service @@ -1481,7 +1300,7 @@ mod tests { ..Default::default() }; let imported = service - .import_account_settings(current_export(incoming)) + .import_config(current_export(incoming)) .await .unwrap(); assert!(imported.success, "{:?}", imported.errors); diff --git a/src/crates/assembly/core/src/service/i18n/generated_locale_contract.rs b/src/crates/assembly/core/src/service/i18n/generated_locale_contract.rs index 7f7d3f1f2c..718619dbd9 100644 --- a/src/crates/assembly/core/src/service/i18n/generated_locale_contract.rs +++ b/src/crates/assembly/core/src/service/i18n/generated_locale_contract.rs @@ -112,11 +112,6 @@ pub const GENERATED_SHARED_TERMS: &[GeneratedSharedTermEntry] = &[ key: "connectionMethods.lan", value: "局域网", }, - GeneratedSharedTermEntry { - locale: LocaleId::ZhCN, - key: "connectionMethods.ngrok", - value: "Ngrok", - }, GeneratedSharedTermEntry { locale: LocaleId::ZhCN, key: "connectionMethods.openbitfunServer", @@ -282,11 +277,6 @@ pub const GENERATED_SHARED_TERMS: &[GeneratedSharedTermEntry] = &[ key: "connectionMethods.lan", value: "區域網路", }, - GeneratedSharedTermEntry { - locale: LocaleId::ZhTW, - key: "connectionMethods.ngrok", - value: "Ngrok", - }, GeneratedSharedTermEntry { locale: LocaleId::ZhTW, key: "connectionMethods.openbitfunServer", @@ -452,11 +442,6 @@ pub const GENERATED_SHARED_TERMS: &[GeneratedSharedTermEntry] = &[ key: "connectionMethods.lan", value: "LAN", }, - GeneratedSharedTermEntry { - locale: LocaleId::EnUS, - key: "connectionMethods.ngrok", - value: "Ngrok", - }, GeneratedSharedTermEntry { locale: LocaleId::EnUS, key: "connectionMethods.openbitfunServer", diff --git a/src/crates/assembly/core/src/service/remote_connect/account_runtime.rs b/src/crates/assembly/core/src/service/remote_connect/account_runtime.rs index ea34ed4744..b42551fe15 100644 --- a/src/crates/assembly/core/src/service/remote_connect/account_runtime.rs +++ b/src/crates/assembly/core/src/service/remote_connect/account_runtime.rs @@ -1,30 +1,22 @@ //! Shared account runtime owner for product Hosts. //! //! The runtime owns account identity transitions, persisted credentials, -//! settings synchronization, and account-backed Session backup. Product Hosts +//! and authenticated device connections. Product Hosts //! inject device-routing and background-owner lifecycle effects without //! exposing App Server wire DTOs to this owner. -use std::future::Future; -use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}; use std::sync::Arc; use std::time::Duration; use anyhow::{anyhow, Result}; use async_trait::async_trait; -use serde::{Deserialize, Serialize}; -use tokio::sync::{Mutex, MutexGuard, Notify, RwLock}; +use tokio::sync::{Mutex, MutexGuard, RwLock}; -use openbitfun_services_integrations::remote_connect::account::{ - ensure_relay_session_history_exportable, relay_session_export_metadata, AccountClient, - AccountSession, -}; -use openbitfun_services_integrations::remote_connect::{session_store, sync_state, DeviceIdentity}; +use openbitfun_services_integrations::remote_connect::account::{AccountClient, AccountSession}; +use openbitfun_services_integrations::remote_connect::{session_store, DeviceIdentity}; -use super::{settings_sync, validate_relay_base_url}; - -const UPLOAD_CONCURRENCY_CHUNK: usize = 5; +use super::validate_relay_base_url; #[derive(Debug, Clone)] struct AccountContextState { @@ -59,46 +51,18 @@ pub trait AccountRuntimeHost: Send + Sync { async fn start_device_routing(&self, request: AccountRoutingStartRequest) -> Result<()>; async fn stop_device_routing(&self); - - fn notify_controllers_settings_changed(&self); } -#[derive(Debug, Clone)] -pub struct AccountSessionBackup { - pub session_id: String, - pub metadata: serde_json::Value, - pub turns: Vec, -} - -#[async_trait] -pub trait AccountSessionBackupPort: Send + Sync { - async fn list_session_backups( - &self, - workspace_path: &Path, - ) -> Result>; -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct AutomaticAccountSyncPolicy { - pub background_engine: bool, - pub management_push: bool, -} - -fn automatic_account_sync_policy_for_pending( - pending_sync_choice: bool, -) -> AutomaticAccountSyncPolicy { - let allowed = !pending_sync_choice; - AutomaticAccountSyncPolicy { - background_engine: allowed, - management_push: allowed, - } +pub enum AccountLoginProgress { + Authorization(openbitfun_product_domains::account::GitHubAuthStart), + Waiting, + Complete(AccountLoginResult), } #[derive(Debug, Clone)] pub struct AccountLoginResult { pub user_id: String, pub relay_url: String, - pub has_cloud_settings: bool, pub routing_owner_replaced: bool, pub routing_connected: bool, pub routing_error: Option, @@ -119,108 +83,35 @@ pub struct AccountDevice { pub online: bool, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum AccountSyncStatus { - #[default] - Idle, - Syncing, - Done, - Failed, - Cancelled, -} - -#[derive(Debug, Clone)] -pub struct AccountSyncProgress { - pub operation_id: Option, - pub status: AccountSyncStatus, - pub phase: String, - pub percent: u8, - pub current: Option, - pub total: Option, - pub detail: Option, - pub error: Option, - pub settings_synced: bool, - pub sessions_exported: usize, -} - -impl Default for AccountSyncProgress { - fn default() -> Self { - Self { - operation_id: None, - status: AccountSyncStatus::Idle, - phase: String::new(), - percent: 0, - current: None, - total: None, - detail: None, - error: None, - settings_synced: false, - sessions_exported: 0, - } - } -} - #[derive(Debug, Clone)] pub struct AccountSnapshot { pub logged_in: bool, - pub pending_sync_choice: bool, pub info: Option, pub devices: Vec, - pub sync: AccountSyncProgress, -} - -#[derive(Debug, Clone)] -struct AutoSyncResult { - settings_synced: bool, - sessions_exported: usize, -} - -#[derive(Serialize, Deserialize)] -struct SessionBundle { - session_id: String, - metadata: serde_json::Value, - turns: Vec, - source_device_id: Option, - source_device_name: Option, } pub struct AccountRuntime { host: Arc, - session_backup: Arc, account_context: RwLock>, account_context_generation: AtomicU64, account_context_transitions: AtomicUsize, - account_sync_lock: Mutex<()>, account_login_lock: Mutex<()>, account_context_transition_lock: Mutex<()>, - account_sync_cancel: Notify, routing_recovery_generation: AtomicU64, token_expired: AtomicBool, - pending_sync_choice: AtomicBool, - sync_progress: RwLock, - auto_sync_in_flight: AtomicBool, } impl AccountRuntime { - pub fn new( - host: Arc, - session_backup: Arc, - ) -> Arc { + pub fn new(host: Arc) -> Arc { Arc::new(Self { host, - session_backup, account_context: RwLock::new(None), account_context_generation: AtomicU64::new(1), account_context_transitions: AtomicUsize::new(0), - account_sync_lock: Mutex::new(()), account_login_lock: Mutex::new(()), account_context_transition_lock: Mutex::new(()), - account_sync_cancel: Notify::new(), routing_recovery_generation: AtomicU64::new(0), token_expired: AtomicBool::new(false), - pending_sync_choice: AtomicBool::new(false), - sync_progress: RwLock::new(AccountSyncProgress::default()), - auto_sync_in_flight: AtomicBool::new(false), }) } @@ -233,14 +124,6 @@ impl AccountRuntime { && self.account_context_generation() == generation } - pub fn automatic_account_sync_policy(&self) -> AutomaticAccountSyncPolicy { - automatic_account_sync_policy_for_pending(self.pending_sync_choice.load(Ordering::Acquire)) - } - - pub fn pending_sync_choice(&self) -> bool { - self.pending_sync_choice.load(Ordering::Acquire) - } - pub fn is_token_expired(&self) -> bool { self.token_expired.load(Ordering::Relaxed) } @@ -249,47 +132,14 @@ impl AccountRuntime { self.token_expired.store(true, Ordering::Relaxed); } - async fn lock_account_sync(&self, generation: u64) -> Result> { - let guard = self.account_sync_lock.lock().await; - if !self.account_context_is_current(generation) { - return Err(anyhow!("account sync cancelled")); - } - Ok(guard) - } - - async fn await_account_sync_current(&self, generation: u64, future: F) -> Result - where - F: Future, - { - let mut cancelled = Box::pin(self.account_sync_cancel.notified()); - cancelled.as_mut().enable(); - if !self.account_context_is_current(generation) { - return Err(anyhow!("account sync cancelled")); - } - tokio::select! { - _ = &mut cancelled => Err(anyhow!("account sync cancelled")), - result = future => { - if !self.account_context_is_current(generation) { - Err(anyhow!("account sync cancelled")) - } else { - Ok(result) - } - } - } - } - async fn begin_account_transition(&self) -> AccountContextTransitionGuard<'_> { let transition_guard = self.account_context_transition_lock.lock().await; self.account_context_transitions .fetch_add(1, Ordering::AcqRel); self.account_context_generation .fetch_add(1, Ordering::AcqRel); - self.account_sync_cancel.notify_waiters(); - let sync_guard = self.account_sync_lock.lock().await; - settings_sync::wait_for_sync_operations_idle().await; AccountContextTransitionGuard { runtime: self, - sync_guard: Some(sync_guard), transition_guard: Some(transition_guard), active: true, } @@ -307,12 +157,8 @@ impl AccountRuntime { .fetch_add(1, Ordering::AcqRel); self.account_context_generation .fetch_add(1, Ordering::AcqRel); - self.account_sync_cancel.notify_waiters(); - let sync_guard = self.account_sync_lock.lock().await; - settings_sync::wait_for_sync_operations_idle().await; Some(AccountContextTransitionGuard { runtime: self, - sync_guard: Some(sync_guard), transition_guard: Some(transition_guard), active: true, }) @@ -347,9 +193,6 @@ impl AccountRuntime { } pub async fn is_logged_in(&self) -> bool { - if self.pending_sync_choice.load(Ordering::Acquire) { - return false; - } self.read_account_context().await.is_ok() } @@ -373,11 +216,7 @@ impl AccountRuntime { log::warn!("Failed to adopt restored session device_id: {error}"); } } - let session = AccountSession { - token: loaded.token, - user_id: user_id.clone(), - master_key: loaded.master_key, - }; + let session = AccountSession::new(loaded.token, user_id.clone(), loaded.master_key); *self.account_context.write().await = Some(AccountContextState { session, relay_url }); log::info!("Restored account session for user {user_id}"); @@ -393,25 +232,55 @@ impl AccountRuntime { restored } - pub async fn login_with_credentials( + pub async fn advance_github_login( self: &Arc, - relay_url: &str, - username: &str, - password: &str, - ) -> Result { - let _login_guard = self.account_login_lock.lock().await; - let relay_url_input = relay_url.trim(); - let username = username.trim(); - if relay_url_input.is_empty() { - return Err(anyhow!("Auth Server is required")); - } - if username.is_empty() { - return Err(anyhow!("Username is required")); - } - if password.is_empty() { - return Err(anyhow!("Password is required")); + transaction_id: Option, + ) -> Result { + if let Some(transaction_id) = transaction_id { + let response = self.poll_github_auth(transaction_id).await?; + match response.status.as_str() { + "pending" => return Ok(AccountLoginProgress::Waiting), + "authorized" => {} + _ => { + return Err(anyhow!( + "GitHub authorization expired or was not completed; restart sign-in" + )) + } + } + } else { + let mut identity = openbitfun_services_integrations::account_identity::AccountIdentityClient::from_environment().await?; + if identity.me().await?.is_none() { + return Ok(AccountLoginProgress::Authorization( + self.start_github_auth().await?, + )); + } } - let relay_url = normalize_relay_url(relay_url_input)?; + Ok(AccountLoginProgress::Complete( + self.login_with_identity().await?, + )) + } + + pub async fn start_github_auth( + &self, + ) -> Result { + Ok(openbitfun_services_integrations::account_identity::start_auth_flow().await?) + } + + pub async fn poll_github_auth( + &self, + transaction_id: String, + ) -> Result { + Ok( + openbitfun_services_integrations::account_identity::poll_auth_flow( + openbitfun_product_domains::account::GitHubAuthPollRequest { transaction_id }, + ) + .await?, + ) + } + + pub async fn login_with_identity(self: &Arc) -> Result { + let _login_guard = self.account_login_lock.lock().await; + let relay_url = openbitfun_product_domains::account::DEFAULT_RELAY_URL.to_string(); let expected_generation = self.account_context_generation(); if !self.account_context_is_current(expected_generation) { return Err(anyhow!("account context changed")); @@ -419,19 +288,10 @@ impl AccountRuntime { let device = current_device_identity()?; let client = AccountClient::new(); - let session = client - .login(&relay_url, username, password, &device) + let (session, profile) = client + .login_with_identity(&relay_url, &device) .await .map_err(|error| anyhow!("login failed: {error}"))?; - let has_cloud_settings = - match resolve_cloud_settings_probe(client.fetch_settings(&relay_url, &session).await) { - Ok(value) => value, - Err(error) => { - revoke_rejected_login_candidate(&client, &relay_url, &session).await; - return Err(error); - } - }; - let previous_account_context = self.account_context.read().await.clone(); let retired_background_owner = match self.host.retire_background_routing_owner().await { Ok(retired) => retired, @@ -463,25 +323,9 @@ impl AccountRuntime { session: session.clone(), relay_url: relay_url.clone(), }); - session_store::save_credential_hint(username, &relay_url); + session_store::save_credential_hint(&profile.login, &relay_url); self.token_expired.store(false, Ordering::Relaxed); - if has_cloud_settings { - self.pending_sync_choice.store(true, Ordering::Release); - transition.finish(); - revoke_replaced_account_context(&client, previous_account_context, &relay_url, &token) - .await; - return Ok(AccountLoginResult { - user_id, - relay_url, - has_cloud_settings, - routing_owner_replaced: retired_background_owner, - routing_connected: false, - routing_error: None, - }); - } - - self.pending_sync_choice.store(false, Ordering::Release); if let Err(error) = session_store::save_session_with_device( &token, &user_id, @@ -507,47 +351,12 @@ impl AccountRuntime { Ok(AccountLoginResult { user_id, relay_url, - has_cloud_settings, routing_owner_replaced: retired_background_owner, routing_connected: routing.is_ok(), routing_error: routing.err().map(|error| error.to_string()), }) } - pub async fn finalize_login_after_sync_choice(self: &Arc) -> Result<()> { - let generation = self.account_context_generation(); - let sync_guard = self.lock_account_sync(generation).await?; - let device = current_device_identity()?; - let (session, relay_url) = self.read_account_context().await?; - let retired_background_owner = self - .host - .retire_background_routing_owner() - .await - .map_err(|failure| failure.error)?; - session_store::save_session_with_device( - &session.token, - &session.user_id, - &session.master_key, - &relay_url, - Some(device.device_id.as_str()), - ) - .map_err(|error| anyhow!("persist session: {error}"))?; - self.pending_sync_choice.store(false, Ordering::Release); - if retired_background_owner { - log::info!("Stopped the previous background account routing owner"); - } - drop(sync_guard); - self.host - .start_device_routing(AccountRoutingStartRequest { - session, - relay_url, - device_name: device.device_name, - account_generation: generation, - }) - .await - .map_err(|error| anyhow!("device routing failed: {error}")) - } - pub async fn restore_device_routing(self: &Arc, device_name: &str) -> Result<()> { let generation = self.account_context_generation(); let (session, relay_url) = self.read_account_context_for_generation(generation).await?; @@ -562,6 +371,8 @@ impl AccountRuntime { } pub async fn logout(&self) -> Result<()> { + let mut identity = openbitfun_services_integrations::account_identity::AccountIdentityClient::from_environment().await?; + identity.logout().await?; let transition = self.begin_account_transition().await; self.host.stop_device_routing().await; if self.host.request_background_routing_owner_shutdown() { @@ -573,7 +384,6 @@ impl AccountRuntime { .await; } *self.account_context.write().await = None; - self.pending_sync_choice.store(false, Ordering::Release); session_store::clear_session(); session_store::clear_credential_hint(); self.token_expired.store(false, Ordering::Relaxed); @@ -604,7 +414,6 @@ impl AccountRuntime { *context = None; drop(context); self.token_expired.store(true, Ordering::Relaxed); - self.pending_sync_choice.store(false, Ordering::Release); session_store::clear_session(); transition.finish(); true @@ -650,380 +459,11 @@ impl AccountRuntime { }; AccountSnapshot { logged_in, - pending_sync_choice: self.pending_sync_choice(), info, devices, - sync: self.current_sync_progress().await, } } - pub fn start_settings_sync_loop(self: &Arc) { - let weak_runtime = Arc::downgrade(self); - let context_runtime = weak_runtime.clone(); - let current_runtime = weak_runtime.clone(); - let settings_runtime = weak_runtime.clone(); - let pushed_runtime = weak_runtime.clone(); - let expired_runtime = weak_runtime; - settings_sync::start_settings_sync_engine(settings_sync::SettingsSyncHooks { - account_context: Some(Arc::new(move || { - let runtime = context_runtime.clone(); - Box::pin(async move { - let runtime = runtime - .upgrade() - .ok_or_else(|| anyhow!("account runtime stopped"))?; - if !runtime.automatic_account_sync_policy().background_engine { - return Err(anyhow!("account login is awaiting a sync choice")); - } - let generation = runtime.account_context_generation(); - let (account, relay_url) = runtime - .read_account_context_for_generation(generation) - .await?; - if !runtime.automatic_account_sync_policy().background_engine { - return Err(anyhow!("account login is awaiting a sync choice")); - } - Ok((account, relay_url, generation)) - }) - })), - is_account_context_current: Some(Arc::new(move |generation| { - current_runtime - .upgrade() - .is_some_and(|runtime| runtime.account_context_is_current(generation)) - })), - on_settings_applied: Some(Arc::new(move || { - if let Some(runtime) = settings_runtime.upgrade() { - runtime.host.notify_controllers_settings_changed(); - } - })), - on_settings_pushed: Some(Arc::new(move || { - if let Some(runtime) = pushed_runtime.upgrade() { - runtime.host.notify_controllers_settings_changed(); - } - })), - on_token_expired: Some(Arc::new(move || { - if let Some(runtime) = expired_runtime.upgrade() { - runtime.mark_token_expired(); - } - })), - ..Default::default() - }); - } - - pub fn notify_local_settings_changed(&self) { - settings_sync::notify_settings_changed(); - } - - pub async fn push_settings_after_local_change(&self) { - if !self.automatic_account_sync_policy().management_push { - return; - } - if self.read_account_context().await.is_err() { - self.try_restore_session().await; - } - let generation = self.account_context_generation(); - let Ok(_sync_guard) = self.lock_account_sync(generation).await else { - return; - }; - if !self.automatic_account_sync_policy().management_push { - return; - } - let Ok((account, relay_url)) = self.read_account_context().await else { - return; - }; - match settings_sync::push_settings_now(&account, &relay_url).await { - Ok(true) => log::info!("Settings pushed to account cloud"), - Ok(false) => {} - Err(error) => log::warn!("Settings push failed: {error}"), - } - } - - pub async fn current_sync_progress(&self) -> AccountSyncProgress { - self.sync_progress.read().await.clone() - } - - async fn set_progress(&self, mut update: impl FnMut(&mut AccountSyncProgress)) { - let mut progress = self.sync_progress.write().await; - update(&mut progress); - } - - async fn emit_progress( - &self, - phase: &str, - percent: u8, - current: Option, - total: Option, - detail: Option<&str>, - ) { - self.set_progress(|progress| { - progress.status = AccountSyncStatus::Syncing; - progress.phase = phase.to_string(); - progress.percent = percent; - progress.current = current; - progress.total = total; - progress.detail = detail.map(str::to_string); - progress.error = None; - }) - .await; - } - - pub async fn start_auto_sync_background( - self: &Arc, - operation_id: String, - is_first_login: bool, - workspace_path: PathBuf, - ) -> bool { - if self.auto_sync_in_flight.swap(true, Ordering::SeqCst) { - log::warn!("Account auto-sync already in flight; skipping duplicate start"); - return false; - } - self.set_progress(|progress| { - progress.operation_id = Some(operation_id.clone()); - }) - .await; - let runtime = Arc::clone(self); - tokio::spawn(async move { - let result = runtime.run_auto_sync(is_first_login, &workspace_path).await; - runtime.auto_sync_in_flight.store(false, Ordering::SeqCst); - match result { - Ok(result) => { - runtime - .set_progress(|progress| { - if progress.operation_id.as_deref() != Some(operation_id.as_str()) { - return; - } - if progress.status == AccountSyncStatus::Cancelled { - return; - } - progress.status = AccountSyncStatus::Done; - progress.phase = "done".to_string(); - progress.percent = 100; - progress.settings_synced = result.settings_synced; - progress.sessions_exported = result.sessions_exported; - progress.error = None; - }) - .await; - } - Err(error) => { - runtime - .set_progress(|progress| { - if progress.operation_id.as_deref() != Some(operation_id.as_str()) { - return; - } - if progress.status == AccountSyncStatus::Cancelled { - return; - } - progress.status = AccountSyncStatus::Failed; - progress.error = Some(error.to_string()); - }) - .await; - log::warn!("Account auto-sync failed: {error}"); - } - } - }); - true - } - - pub async fn mark_sync_cancelled(&self, operation_id: String) { - self.set_progress(|progress| { - progress.operation_id = Some(operation_id.clone()); - progress.status = AccountSyncStatus::Cancelled; - progress.phase = "cancelled".to_string(); - progress.error = None; - }) - .await; - } - - pub async fn cancel_sync(&self, operation_id: String) -> Result { - self.logout().await?; - self.mark_sync_cancelled(operation_id).await; - Ok(self.current_sync_progress().await) - } - - async fn run_auto_sync( - self: &Arc, - is_first_login: bool, - workspace_path: &Path, - ) -> Result { - let generation = self.account_context_generation(); - let _sync_guard = self.lock_account_sync(generation).await?; - self.set_progress(|progress| { - *progress = AccountSyncProgress { - operation_id: progress.operation_id.clone(), - status: AccountSyncStatus::Syncing, - phase: "starting".to_string(), - percent: 1, - ..AccountSyncProgress::default() - }; - }) - .await; - - let (account, relay_url) = self.read_account_context().await?; - let client = AccountClient::new(); - let settings_synced = if is_first_login { - self.emit_progress("uploading_settings", 5, None, None, None) - .await; - let config_service = crate::service::config::get_global_config_service() - .await - .map_err(|error| anyhow!("config service: {error}"))?; - let exported = config_service - .export_config() - .await - .map_err(|error| anyhow!("export config: {error}"))?; - let config_json = serde_json::to_string(&exported) - .map_err(|error| anyhow!("serialize config: {error}"))?; - self.await_account_sync_current( - generation, - settings_sync::upload_settings_payload(&account, &relay_url, &config_json), - ) - .await??; - self.emit_progress("settings_done", 15, None, None, None) - .await; - true - } else { - self.emit_progress("downloading_settings", 5, None, None, None) - .await; - let cloud = self - .await_account_sync_current( - generation, - client.fetch_settings_with_version(&relay_url, &account), - ) - .await??; - if let Some(blob) = cloud { - self.emit_progress("applying_settings", 10, None, None, None) - .await; - self.await_account_sync_current( - generation, - settings_sync::apply_settings_blob(&account, &blob, true), - ) - .await??; - self.emit_progress("settings_done", 15, None, None, None) - .await; - true - } else { - self.emit_progress("settings_done", 15, None, None, None) - .await; - false - } - }; - - self.emit_progress("listing_sessions", 18, None, None, None) - .await; - let local_sessions = self - .await_account_sync_current( - generation, - self.session_backup.list_session_backups(workspace_path), - ) - .await??; - self.emit_progress( - "exporting_sessions", - 20, - Some(0), - Some(local_sessions.len()), - None, - ) - .await; - - let mut local_sync_state = sync_state::load(&account.user_id); - let mut pending_uploads = Vec::new(); - for backup in local_sessions { - if !self.account_context_is_current(generation) { - return Err(anyhow!("account sync cancelled")); - } - let bundle = SessionBundle { - session_id: backup.session_id.clone(), - metadata: backup.metadata, - turns: backup.turns, - source_device_id: None, - source_device_name: None, - }; - let bundle_json = serde_json::to_string(&bundle) - .map_err(|error| anyhow!("serialize bundle: {error}"))?; - let hash = sync_state::content_hash(&bundle_json); - if local_sync_state.uploaded_hash(&backup.session_id) == Some(hash.as_str()) { - continue; - } - pending_uploads.push((backup.session_id, bundle_json, hash)); - } - - let upload_total = pending_uploads.len(); - self.emit_progress("exporting_sessions", 20, Some(0), Some(upload_total), None) - .await; - let mut uploaded = Vec::new(); - let mut upload_errors = Vec::new(); - for chunk in pending_uploads.chunks(UPLOAD_CONCURRENCY_CHUNK) { - let mut handles = Vec::new(); - for (session_id, bundle_json, hash) in chunk { - let runtime = Arc::clone(self); - let client = AccountClient::new(); - let relay_url = relay_url.clone(); - let account = account.clone(); - let session_id = session_id.clone(); - let bundle_json = bundle_json.clone(); - let hash = hash.clone(); - handles.push(tokio::spawn(async move { - let result = runtime - .await_account_sync_current( - generation, - client.upload_session(&relay_url, &account, &session_id, &bundle_json), - ) - .await; - (session_id, hash, result) - })); - } - for handle in handles { - match handle.await { - Ok((session_id, hash, Ok(Ok(version)))) => { - uploaded.push((session_id.clone(), hash, version)); - let done = uploaded.len(); - let percent = if upload_total == 0 { - 95 - } else { - 20 + ((75 * done) / upload_total) as u8 - }; - self.emit_progress( - "exporting_sessions", - percent.min(95), - Some(done), - Some(upload_total), - Some(&session_id), - ) - .await; - } - Ok((session_id, _, Ok(Err(error)))) => { - log::warn!("Auto-sync upload {session_id} failed: {error}"); - upload_errors.push(format!("{session_id}: {error}")); - } - Ok((_, _, Err(error))) => return Err(error), - Err(error) => { - log::warn!("Auto-sync upload task join failed: {error}"); - upload_errors.push(format!("upload task join failed: {error}")); - } - } - } - if !self.account_context_is_current(generation) { - return Err(anyhow!("account sync cancelled")); - } - } - - let exported = uploaded.len(); - let mut max_uploaded_version = local_sync_state.last_session_since; - for (session_id, hash, version) in uploaded { - local_sync_state.set_uploaded_hash(&session_id, hash); - max_uploaded_version = max_uploaded_version.max(version); - } - if max_uploaded_version > local_sync_state.last_session_since { - local_sync_state.last_session_since = max_uploaded_version; - } - let _ = sync_state::save(&account.user_id, &local_sync_state); - ensure_session_backup_complete(upload_total, exported, &upload_errors)?; - log::info!("Auto-sync: settings={settings_synced} exported={exported} imported=0"); - self.emit_progress("done", 100, Some(exported), Some(0), None) - .await; - Ok(AutoSyncResult { - settings_synced, - sessions_exported: exported, - }) - } - fn schedule_routing_recovery_after_background_owner_exit( self: &Arc, expected_generation: u64, @@ -1067,7 +507,6 @@ impl AccountRuntime { struct AccountContextTransitionGuard<'a> { runtime: &'a AccountRuntime, - sync_guard: Option>, transition_guard: Option>, active: bool, } @@ -1078,7 +517,6 @@ impl AccountContextTransitionGuard<'_> { } fn release(&mut self) -> u64 { - drop(self.sync_guard.take()); if self.active { self.runtime .account_context_generation @@ -1148,46 +586,6 @@ async fn revoke_replaced_account_context( } } -fn resolve_cloud_settings_probe(result: Result>) -> Result { - result.map(|settings| settings.is_some()).map_err(|error| { - anyhow!("could not check cloud settings: {error}; the current account remains active") - }) -} - -fn ensure_session_backup_complete( - total: usize, - uploaded: usize, - upload_errors: &[String], -) -> Result<()> { - if uploaded == total { - return Ok(()); - } - let detail = upload_errors - .first() - .map(String::as_str) - .unwrap_or("retry will resume remaining sessions"); - Err(anyhow!( - "session backup incomplete: uploaded {uploaded} of {total}; {detail}" - )) -} - -pub fn build_session_backup( - metadata: &openbitfun_services_core::session::SessionMetadata, - turns: &[openbitfun_services_core::session::DialogTurnData], -) -> Result { - ensure_relay_session_history_exportable(metadata).map_err(anyhow::Error::msg)?; - let metadata = relay_session_export_metadata(metadata, turns.len()); - Ok(AccountSessionBackup { - session_id: metadata.session_id.clone(), - metadata: serde_json::to_value(metadata) - .map_err(|error| anyhow!("serialize metadata: {error}"))?, - turns: turns - .iter() - .map(|turn| serde_json::to_value(turn).unwrap_or(serde_json::Value::Null)) - .collect(), - }) -} - #[cfg(test)] mod tests { use super::*; @@ -1215,64 +613,21 @@ mod tests { } async fn stop_device_routing(&self) {} - - fn notify_controllers_settings_changed(&self) {} - } - - struct EmptySessionBackup; - - #[async_trait] - impl AccountSessionBackupPort for EmptySessionBackup { - async fn list_session_backups( - &self, - _workspace_path: &Path, - ) -> Result> { - Ok(Vec::new()) - } } fn test_runtime() -> Arc { - AccountRuntime::new( - Arc::new(TestAccountRuntimeHost), - Arc::new(EmptySessionBackup), - ) - } - - #[test] - fn pending_sync_choice_blocks_automatic_sync() { - let pending = automatic_account_sync_policy_for_pending(true); - assert!(!pending.background_engine); - assert!(!pending.management_push); - - let finalized = automatic_account_sync_policy_for_pending(false); - assert!(finalized.background_engine); - assert!(finalized.management_push); - } - - #[test] - fn cloud_settings_probe_errors_are_not_treated_as_missing_settings() { - assert!(!resolve_cloud_settings_probe(Ok(None)).expect("missing settings")); - assert!(resolve_cloud_settings_probe(Ok(Some("settings".to_string()))).unwrap()); - assert!(resolve_cloud_settings_probe(Err(anyhow!("relay unavailable"))).is_err()); - } - - #[test] - fn partial_session_backup_is_not_reported_as_success() { - assert!(ensure_session_backup_complete(4, 4, &[]).is_ok()); - assert!(ensure_session_backup_complete(4, 1, &["quota full".to_string()]).is_err()); + AccountRuntime::new(Arc::new(TestAccountRuntimeHost)) } #[tokio::test] - async fn invalid_login_does_not_advance_the_account_generation() { + async fn rejected_account_transition_does_not_advance_the_generation() { let runtime = test_runtime(); let generation = runtime.account_context_generation(); - let error = runtime - .login_with_credentials("", "user", "password") + assert!(runtime + .begin_account_transition_if_current(generation + 1) .await - .expect_err("empty relay URL must be rejected"); - - assert!(error.to_string().contains("Auth Server is required")); + .is_none()); assert_eq!(runtime.account_context_generation(), generation); } } diff --git a/src/crates/assembly/core/src/service/remote_connect/bot/command_router.rs b/src/crates/assembly/core/src/service/remote_connect/bot/command_router.rs index 74f2987f4d..395ea035d2 100644 --- a/src/crates/assembly/core/src/service/remote_connect/bot/command_router.rs +++ b/src/crates/assembly/core/src/service/remote_connect/bot/command_router.rs @@ -724,11 +724,11 @@ fn delegated_session( } let mut master_key = [0u8; 32]; master_key.copy_from_slice(&key_vec); - Some(crate::service::remote_connect::AccountSession { + Some(crate::service::remote_connect::AccountSession::new( token, - user_id: String::new(), + String::new(), master_key, - }) + )) } async fn list_devices(state: &mut BotChatState, s: &'static BotStrings) -> HandleResult { diff --git a/src/crates/assembly/core/src/service/remote_connect/bot/feishu.rs b/src/crates/assembly/core/src/service/remote_connect/bot/feishu.rs index 9f1ae9fc8d..eff104dddc 100644 --- a/src/crates/assembly/core/src/service/remote_connect/bot/feishu.rs +++ b/src/crates/assembly/core/src/service/remote_connect/bot/feishu.rs @@ -87,6 +87,7 @@ impl FeishuBot { } } + #[cfg(test)] pub fn new(config: FeishuConfig) -> Self { Self::new_fenced(config, BotRuntimeFence::standalone()) } @@ -660,6 +661,7 @@ impl FeishuBot { async fn persist_chat_state(&self, chat_id: &str, state: &BotChatState) { let snapshot = self.runtime_fence.persistence_snapshot(state); let connection = SavedBotConnection { + account_user_id: self.runtime_fence.account_user_id(), bot_type: "feishu".to_string(), chat_id: chat_id.to_string(), config: BotConfig::Feishu { diff --git a/src/crates/assembly/core/src/service/remote_connect/bot/mod.rs b/src/crates/assembly/core/src/service/remote_connect/bot/mod.rs index 9bfb593d19..9350f13868 100644 --- a/src/crates/assembly/core/src/service/remote_connect/bot/mod.rs +++ b/src/crates/assembly/core/src/service/remote_connect/bot/mod.rs @@ -80,6 +80,7 @@ impl BotSlotFence { /// work is not awaited during account replacement; instead every state commit /// is fenced and sanitized if it started under an older account epoch. pub(crate) struct BotRuntimeFence { + account_user_id: String, account_identity_epoch: Arc, observed_identity_epoch: AtomicU64, slot: Arc, @@ -96,11 +97,22 @@ impl BotRuntimeFence { Self { account_identity_epoch, observed_identity_epoch: AtomicU64::new(observed_identity_epoch), + account_user_id: String::new(), slot, lifecycle_generation, } } + pub(crate) fn with_account(mut self, user_id: String) -> Self { + self.account_user_id = user_id; + self + } + + pub(crate) fn account_user_id(&self) -> String { + self.account_user_id.clone() + } + + #[cfg(test)] pub(crate) fn standalone() -> Self { let account_identity_epoch = Arc::new(AtomicU64::new(0)); let slot = Arc::new(BotSlotFence::default()); diff --git a/src/crates/assembly/core/src/service/remote_connect/bot/telegram.rs b/src/crates/assembly/core/src/service/remote_connect/bot/telegram.rs index 258d260717..95e0475d11 100644 --- a/src/crates/assembly/core/src/service/remote_connect/bot/telegram.rs +++ b/src/crates/assembly/core/src/service/remote_connect/bot/telegram.rs @@ -54,6 +54,7 @@ impl TelegramBot { } } + #[cfg(test)] pub fn new(config: TelegramConfig) -> Self { Self::new_fenced(config, BotRuntimeFence::standalone()) } @@ -491,6 +492,7 @@ impl TelegramBot { async fn persist_chat_state(&self, chat_id: i64, state: &BotChatState) { let snapshot = self.runtime_fence.persistence_snapshot(state); let connection = SavedBotConnection { + account_user_id: self.runtime_fence.account_user_id(), bot_type: "telegram".to_string(), chat_id: chat_id.to_string(), config: BotConfig::Telegram { diff --git a/src/crates/assembly/core/src/service/remote_connect/bot/weixin.rs b/src/crates/assembly/core/src/service/remote_connect/bot/weixin.rs index 80ded71f63..f0e1f4dd71 100644 --- a/src/crates/assembly/core/src/service/remote_connect/bot/weixin.rs +++ b/src/crates/assembly/core/src/service/remote_connect/bot/weixin.rs @@ -70,6 +70,7 @@ pub async fn weixin_qr_poll( } impl WeixinBot { + #[cfg(test)] pub fn new(config: WeixinConfig) -> Self { Self::new_fenced(config, BotRuntimeFence::standalone()) } @@ -321,6 +322,7 @@ impl WeixinBot { let config = self.api.config().clone(); let snapshot = self.runtime_fence.persistence_snapshot(state); let connection = SavedBotConnection { + account_user_id: self.runtime_fence.account_user_id(), bot_type: "weixin".to_string(), chat_id: peer_id.to_string(), config: BotConfig::Weixin { diff --git a/src/crates/assembly/core/src/service/remote_connect/embedded_relay_host.rs b/src/crates/assembly/core/src/service/remote_connect/embedded_relay_host.rs index 0db5fea828..8b50fc1cd8 100644 --- a/src/crates/assembly/core/src/service/remote_connect/embedded_relay_host.rs +++ b/src/crates/assembly/core/src/service/remote_connect/embedded_relay_host.rs @@ -1,4 +1,4 @@ -//! Host capability required by Remote Connect LAN and Ngrok orchestration. +//! Host capability required by Remote Connect LAN orchestration. //! //! Product assembly decides when the embedded relay is needed. Concrete //! listener, router, static-file, and task lifecycle details belong to the app diff --git a/src/crates/assembly/core/src/service/remote_connect/host_lifecycle_tests.rs b/src/crates/assembly/core/src/service/remote_connect/host_lifecycle_tests.rs index 9eb951998f..2f78f7bab4 100644 --- a/src/crates/assembly/core/src/service/remote_connect/host_lifecycle_tests.rs +++ b/src/crates/assembly/core/src/service/remote_connect/host_lifecycle_tests.rs @@ -105,21 +105,24 @@ async fn remote_connect_stop_delegates_concrete_cleanup_to_host() { } #[tokio::test] -async fn remote_connect_start_failure_rolls_back_started_host() { +async fn preparation_starts_a_local_host_but_never_grants_unauthenticated_control() { let port = unused_port().await; - let host = Arc::new(RecordingEmbeddedRelayHost::default()); - let service = RemoteConnectService::new(lan_config(port), host.clone()) - .expect("remote connect service should initialize"); - - service + let service = RemoteConnectService::new(lan_config(port), host.clone()).unwrap(); + assert_eq!( + service.prepare_relay(&lan_method()).await.unwrap(), + format!("http://127.0.0.1:{port}") + ); + assert!(service .start(lan_method()) .await - .expect_err("downstream relay connection should fail without a real host listener"); - + .unwrap_err() + .to_string() + .contains("Sign in with GitHub")); assert_eq!(host.start_calls.load(Ordering::SeqCst), 1); + assert!(host.active.load(Ordering::SeqCst)); + service.stop_relay().await; assert_eq!(host.cleanup_stops.load(Ordering::SeqCst), 1); - assert!(!host.active.load(Ordering::SeqCst)); } #[tokio::test] @@ -132,7 +135,7 @@ async fn concurrent_relay_starts_do_not_cleanup_or_enter_the_host_concurrently() let first = tokio::spawn({ let service = service.clone(); - async move { service.start(lan_method()).await } + async move { service.prepare_relay(&lan_method()).await } }); tokio::time::timeout( std::time::Duration::from_secs(1), @@ -143,7 +146,7 @@ async fn concurrent_relay_starts_do_not_cleanup_or_enter_the_host_concurrently() let second = tokio::spawn({ let service = service.clone(); - async move { service.start(lan_method()).await } + async move { service.prepare_relay(&lan_method()).await } }); assert!( tokio::time::timeout( @@ -164,15 +167,15 @@ async fn concurrent_relay_starts_do_not_cleanup_or_enter_the_host_concurrently() .expect("serialized starts should complete"); first_result .expect("first start task should join") - .expect_err("fake host does not create a relay listener"); + .expect("endpoint preparation should succeed"); second_result .expect("second start task should join") - .expect_err("fake host does not create a relay listener"); + .expect("endpoint preparation should succeed"); - assert_eq!(host.start_calls.load(Ordering::SeqCst), 2); + assert_eq!(host.start_calls.load(Ordering::SeqCst), 1); assert_eq!(host.overlapping_starts.load(Ordering::SeqCst), 0); assert_eq!(host.stop_while_starting.load(Ordering::SeqCst), 0); - assert!(!host.active.load(Ordering::SeqCst)); + assert!(host.active.load(Ordering::SeqCst)); } #[tokio::test] @@ -185,7 +188,7 @@ async fn relay_stop_waits_for_an_in_progress_start_to_settle() { let start = tokio::spawn({ let service = service.clone(); - async move { service.start(lan_method()).await } + async move { service.prepare_relay(&lan_method()).await } }); tokio::time::timeout( std::time::Duration::from_secs(1), @@ -218,9 +221,103 @@ async fn relay_stop_waits_for_an_in_progress_start_to_settle() { .expect("start and stop should complete after release"); start_result .expect("start task should join") - .expect_err("fake host does not create a relay listener"); + .expect("endpoint preparation should succeed"); stop_result.expect("stop task should join"); assert_eq!(host.stop_while_starting.load(Ordering::SeqCst), 0); assert!(!host.active.load(Ordering::SeqCst)); } + +#[tokio::test] +async fn official_invitation_requires_device_auth_without_starting_an_anonymous_room() { + let host = Arc::new(RecordingEmbeddedRelayHost::default()); + let service = RemoteConnectService::new(RemoteConnectConfig::default(), host.clone()).unwrap(); + let error = tokio::time::timeout( + std::time::Duration::from_secs(1), + service.start(ConnectionMethod::OpenBitFunServer), + ) + .await + .expect("must fail immediately without waiting for RoomCreated") + .unwrap_err(); + assert!(error.to_string().contains("Sign in with GitHub")); + assert_eq!(host.start_calls.load(Ordering::SeqCst), 0); + assert_eq!(host.stop_calls.load(Ordering::SeqCst), 0); +} + +#[tokio::test] +async fn official_and_lan_invitations_use_the_same_authenticated_device_protocol() { + for method in [ConnectionMethod::OpenBitFunServer, lan_method()] { + let host = Arc::new(RecordingEmbeddedRelayHost::default()); + let service = RemoteConnectService::new(lan_config(9700), host.clone()).unwrap(); + let url = service.prepare_relay(&method).await.unwrap(); + *service.authenticated_device_id.write().await = Some("authenticated-host-1".into()); + *service.device_relay_url.write().await = Some(url.clone()); + let result = service.start(method.clone()).await.unwrap(); + assert_eq!( + result.qr_url, + Some(format!("{url}/#/pair?did=authenticated-host-1")) + ); + assert!(result + .qr_data + .as_ref() + .is_some_and(|value| !value.is_empty())); + assert_eq!( + host.start_calls.load(Ordering::SeqCst), + usize::from(matches!(method, ConnectionMethod::Lan { .. })) + ); + service.stop_device_connection().await; + assert!(service.start(method).await.is_err()); + } +} + +#[tokio::test] +async fn switching_endpoint_invalidates_the_previous_invitation() { + let host = Arc::new(RecordingEmbeddedRelayHost::default()); + let service = RemoteConnectService::new(lan_config(9700), host.clone()).unwrap(); + let local = service.prepare_relay(&lan_method()).await.unwrap(); + *service.authenticated_device_id.write().await = Some("device-1".into()); + *service.device_relay_url.write().await = Some(local); + assert!(service.start(lan_method()).await.is_ok()); + service + .prepare_relay(&ConnectionMethod::OpenBitFunServer) + .await + .unwrap(); + assert!(service.start(lan_method()).await.is_err()); + assert!(service + .start(ConnectionMethod::OpenBitFunServer) + .await + .is_err()); + assert!(!host.active.load(Ordering::SeqCst)); +} + +#[tokio::test] +async fn every_bot_provider_requires_an_active_github_account() { + let service = RemoteConnectService::new( + RemoteConnectConfig::default(), + Arc::new(RecordingEmbeddedRelayHost::default()), + ) + .unwrap(); + for method in [ + ConnectionMethod::BotFeishu, + ConnectionMethod::BotTelegram, + ConnectionMethod::BotWeixin, + ] { + let error = service.start(method).await.unwrap_err(); + assert!(error.to_string().contains("Sign in with GitHub")); + } + service.set_bot_account(Some("account-a".into())).await; + let epoch = service.bot_account_identity_epoch.load(Ordering::SeqCst); + service.set_bot_account(Some("account-a".into())).await; + assert_eq!( + service.bot_account_identity_epoch.load(Ordering::SeqCst), + epoch + ); + service.set_bot_account(None).await; + assert!(service.bot_account_identity_epoch.load(Ordering::SeqCst) > epoch); + assert!(service + .start(ConnectionMethod::BotTelegram) + .await + .unwrap_err() + .to_string() + .contains("Sign in with GitHub")); +} diff --git a/src/crates/assembly/core/src/service/remote_connect/mod.rs b/src/crates/assembly/core/src/service/remote_connect/mod.rs index efdf03b5b7..f06364887c 100644 --- a/src/crates/assembly/core/src/service/remote_connect/mod.rs +++ b/src/crates/assembly/core/src/service/remote_connect/mod.rs @@ -1,10 +1,10 @@ //! Remote Connect service module. //! //! Provides phone-to-desktop remote connection capabilities with E2E encryption. -//! Supports multiple connection methods: LAN, ngrok, relay server, and bots. +//! Account-authenticated Relay connections over official or LAN URLs, plus IM bots. //! //! Bot connections (Telegram / Feishu / Weixin) run independently of relay connections -//! (LAN / ngrok / OpenBitFun Server / Custom Server). Calling `stop()` only +//! (LAN / OpenBitFun Server). Calling `stop()` only //! tears down the relay side; bots keep running. Use `stop_bot()` or //! `stop_all()` to shut everything down. @@ -12,9 +12,7 @@ pub mod account_runtime; pub mod bot; pub mod embedded_relay_host; pub mod lan; -pub mod ngrok; pub mod remote_server; -pub mod settings_sync; pub mod device { pub use openbitfun_services_integrations::remote_connect::device::*; @@ -44,17 +42,13 @@ pub mod session_store { pub use openbitfun_services_integrations::remote_connect::session_store::*; } -pub mod sync_state { - pub use openbitfun_services_integrations::remote_connect::sync_state::*; -} - pub use account::{ build_relay_websocket_url, validate_relay_base_url, AccountClient, AccountSession, - DelegateToken, DelegatedIdentity, FetchedSession, KdfParams, ListedSessionEntry, SettingsBlob, + DelegateToken, }; pub use device::DeviceIdentity; pub use encryption::{decrypt_from_base64, encrypt_to_base64, KeyPair}; -pub use pairing::{PairingProtocol, PairingState}; +pub use pairing::PairingState; pub use qr_generator::QrGenerator; pub use relay_client::ensure_rustls_crypto_provider; pub use relay_client::RelayClient; @@ -62,8 +56,7 @@ pub use remote_server::RemoteServer; use anyhow::Result; use embedded_relay_host::EmbeddedRelayHost; -use log::{debug, error, info, warn}; -use openbitfun_services_integrations::remote_connect::upload_mobile_web_to_relay; +use log::{info, warn}; use serde::{Deserialize, Serialize}; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; @@ -73,10 +66,11 @@ use tokio::sync::{Mutex, RwLock}; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum ConnectionMethod { - Lan { ip: Option }, - Ngrok, + Lan { + ip: Option, + }, + #[serde(rename = "openbitfun_server")] OpenBitFunServer, - CustomServer { url: String }, BotFeishu, BotTelegram, BotWeixin, @@ -87,8 +81,6 @@ pub enum ConnectionMethod { pub struct RemoteConnectConfig { pub lan_port: u16, pub openbitfun_server_url: String, - pub web_app_url: String, - pub custom_server_url: Option, pub bot_feishu: Option, pub bot_telegram: Option, pub bot_weixin: Option, @@ -99,9 +91,8 @@ impl Default for RemoteConnectConfig { fn default() -> Self { Self { lan_port: 9700, - openbitfun_server_url: "https://remote.openbitfun.com/relay".to_string(), - web_app_url: "https://remote.openbitfun.com/relay".to_string(), - custom_server_url: None, + openbitfun_server_url: openbitfun_product_domains::account::DEFAULT_RELAY_URL + .to_string(), bot_feishu: None, bot_telegram: None, bot_weixin: None, @@ -133,240 +124,17 @@ impl BotHandle { } } -#[derive(Debug, Clone, Serialize, Deserialize)] -struct TrustedMobileIdentity { - mobile_install_id: String, - user_id: String, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -struct RoomConnectionOwner { - generation: u64, - room_id: String, -} - -fn room_owner_is_current( - active: &Option, - expected: &RoomConnectionOwner, -) -> bool { - active.as_ref() == Some(expected) -} - -fn clear_room_owner_if_current( - active: &mut Option, - expected: &RoomConnectionOwner, -) -> bool { - if !room_owner_is_current(active, expected) { - return false; - } - *active = None; - true -} - -/// Successful account pairing verification together with an optional host -/// lease. The service keeps the lease alive until the trusted identity and -/// paired server are committed, so an account transition cannot clear state -/// and then be overwritten by a retiring verifier. -pub struct AccountPairingVerification { - user_id: String, - _host_lease: Option>, -} - -impl std::fmt::Debug for AccountPairingVerification { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - formatter - .debug_struct("AccountPairingVerification") - .field("user_id", &self.user_id) - .finish_non_exhaustive() - } -} - -impl AccountPairingVerification { - pub fn new(user_id: String) -> Self { - Self { - user_id, - _host_lease: None, - } - } - - pub fn with_host_lease(user_id: String, lease: L) -> Self - where - L: Send + 'static, - { - Self { - user_id, - _host_lease: Some(Box::new(lease)), - } - } - - pub fn user_id(&self) -> &str { - &self.user_id - } -} - -/// Delegated account credentials together with the host account lease that -/// authorized them. The lease is retained after the provider returns and is -/// released only after the encrypted room response has been sent. -pub struct DelegatedIdentityAuthorization { - token: String, - user_id: String, - master_key: [u8; 32], - host_lease: Option>, -} - -impl DelegatedIdentityAuthorization { - pub fn new(token: String, user_id: String, master_key: [u8; 32]) -> Self { - Self { - token, - user_id, - master_key, - host_lease: None, - } - } - - pub fn with_host_lease( - token: String, - user_id: String, - master_key: [u8; 32], - lease: L, - ) -> Self - where - L: Send + 'static, - { - Self { - token, - user_id, - master_key, - host_lease: Some(Box::new(lease)), - } - } - - fn into_response(self, local_device_id: &str) -> AuthorizedCredentialResolution { - use base64::{engine::general_purpose::STANDARD as B64, Engine}; - - let Self { - token, - user_id, - master_key, - host_lease, - } = self; - AuthorizedCredentialResolution { - response: remote_server::RemoteResponse::DelegateIdentity { - token, - user_id, - master_key: B64.encode(master_key), - device_id: local_device_id.to_string(), - }, - _host_lease: host_lease, - } - } -} - -/// A full account device credential minted for a peer device that cannot -/// authenticate on its own, together with the host account lease that -/// authorized it. Deliberately a distinct type from -/// [`DelegatedIdentityAuthorization`]: that one carries a 24-hour delegated -/// token limited to device discovery and RPC, this one carries a 30-day full -/// device credential. They must never be routed into each other's response. -pub struct ProvisionedDeviceAuthorization { - token: String, - user_id: String, - master_key: [u8; 32], - /// The device the credential was minted *for*, echoed back so the caller - /// can verify the relay registered the id it asked for. - device_id: String, - host_lease: Option>, -} - -impl ProvisionedDeviceAuthorization { - pub fn new(token: String, user_id: String, master_key: [u8; 32], device_id: String) -> Self { - Self { - token, - user_id, - master_key, - device_id, - host_lease: None, - } - } - - pub fn with_host_lease( - token: String, - user_id: String, - master_key: [u8; 32], - device_id: String, - lease: L, - ) -> Self - where - L: Send + 'static, - { - Self { - token, - user_id, - master_key, - device_id, - host_lease: Some(Box::new(lease)), - } - } - - fn into_response(self) -> AuthorizedCredentialResolution { - use base64::{engine::general_purpose::STANDARD as B64, Engine}; - - let Self { - token, - user_id, - master_key, - device_id, - host_lease, - } = self; - AuthorizedCredentialResolution { - response: remote_server::RemoteResponse::PeerDeviceProvisioned { - token, - user_id, - master_key: B64.encode(master_key), - device_id, - }, - _host_lease: host_lease, - } - } -} - -/// An authorized credential response together with the host account lease that -/// authorized it. The lease is retained after the provider returns and released -/// only after the encrypted room response has been sent, so an account -/// transition cannot clear state and then be overwritten by a retiring -/// verifier. Carries either credential kind. -struct AuthorizedCredentialResolution { - response: remote_server::RemoteResponse, - _host_lease: Option>, -} - -impl AuthorizedCredentialResolution { - fn error(message: impl Into) -> Self { - Self { - response: remote_server::RemoteResponse::Error { - message: message.into(), - }, - _host_lease: None, - } - } -} - /// Unified Remote Connect service that orchestrates all connection methods. pub struct RemoteConnectService { config: RemoteConnectConfig, device_identity: DeviceIdentity, - pairing: Arc>, - relay_client: Arc>>, - remote_server: Arc>>, active_method: Arc>>, - ngrok_tunnel: Arc>>, embedded_relay_host: Arc, relay_lifecycle: Arc>, - room_connection_generation: AtomicU64, - active_room_owner: Arc>>, // Bot handles live independently of relay connections bot_lifecycle: Arc>, bot_account_identity_epoch: Arc, + bot_account_user_id: RwLock>, bot_telegram_slot: Arc, bot_feishu_slot: Arc, bot_weixin_slot: Arc, @@ -377,104 +145,37 @@ pub struct RemoteConnectService { telegram_bot: Arc>>>, feishu_bot: Arc>>>, weixin_bot: Arc>>>, - /// Independent bot connection state — not tied to PairingProtocol. + /// Independent bot connection state. /// Stores the peer description (e.g. "Telegram(7096812005)") when a bot is active. bot_connected_info: Arc>>, - /// Trusted mobile identity for the current relay lifecycle only. - trusted_mobile_identity: Arc>>, - /// Account-authenticated device-routing relay client (P2). Independent from - /// the room-pairing relay_client above; connects after account login. + /// The single account-authenticated transport for every Relay endpoint. device_relay_client: Arc>>, device_relay_lifecycle: Arc>, device_connection_generation: AtomicU64, active_device_connection_id: Arc>>, + authenticated_device_id: Arc>>, + device_relay_url: Arc>>, + prepared_relay_url: Arc>>, /// Latest online-device presence for the account (P2). online_devices: Arc>>, - /// Callback that provides a delegated identity and its host account lease - /// for paired mobile/IM clients. Set by the desktop layer after account - /// login. Resolved on demand when a paired client sends - /// `get_delegated_identity` over the room channel. - delegated_identity_fn: Arc>>, - /// Callback that mints a full account device credential for a peer device - /// on behalf of a paired client. Set by the desktop layer after account - /// login. Resolved on demand when a paired client sends - /// `provision_peer_device` over the room channel. - peer_device_provision_fn: Arc>>, - /// Non-secret username embedded in the QR when the desktop is logged in. - account_pairing_username: Arc>>, - /// When set, pairing requires OpenBitFun account username+password and the - /// verifier returns the canonical account `user_id` on success. - account_pairing_verifier: Arc>>, } -/// Provider returning authorized delegated credentials for the paired client. -type DelegatedIdentityFn = Arc< - dyn Fn() -> std::pin::Pin< - Box< - dyn std::future::Future> - + Send - + Sync, - >, - > + Send - + Sync, ->; - -/// Provider minting a full account device credential for a peer device. -/// Takes `(device_id, device_name, request_id)`; `request_id` comes from the -/// device being provisioned so retries stay idempotent at the relay. -type PeerDeviceProvisionFn = Arc< - dyn Fn( - String, - String, - String, - ) -> std::pin::Pin< - Box< - dyn std::future::Future> - + Send - + Sync, - >, - > + Send - + Sync, ->; - -/// Verifies mobile-submitted account credentials. Returns the canonical -/// account `user_id` when the credentials match the logged-in desktop account. -type AccountPairingVerifierFn = Arc< - dyn Fn( - String, - String, - ) -> std::pin::Pin< - Box< - dyn std::future::Future> - + Send - + Sync, - >, - > + Send - + Sync, ->; - impl RemoteConnectService { pub fn new( config: RemoteConnectConfig, embedded_relay_host: Arc, ) -> Result { let device_identity = DeviceIdentity::from_current_machine()?; - let pairing = PairingProtocol::new(device_identity.clone()); Ok(Self { config, device_identity, - pairing: Arc::new(RwLock::new(pairing)), - relay_client: Arc::new(RwLock::new(None)), - remote_server: Arc::new(RwLock::new(None)), active_method: Arc::new(RwLock::new(None)), - ngrok_tunnel: Arc::new(RwLock::new(None)), embedded_relay_host, relay_lifecycle: Arc::new(Mutex::new(())), - room_connection_generation: AtomicU64::new(0), - active_room_owner: Arc::new(RwLock::new(None)), bot_lifecycle: Arc::new(Mutex::new(())), bot_account_identity_epoch: Arc::new(AtomicU64::new(0)), + bot_account_user_id: RwLock::new(None), bot_telegram_slot: Arc::new(bot::BotSlotFence::default()), bot_feishu_slot: Arc::new(bot::BotSlotFence::default()), bot_weixin_slot: Arc::new(bot::BotSlotFence::default()), @@ -485,113 +186,32 @@ impl RemoteConnectService { feishu_bot: Arc::new(RwLock::new(None)), weixin_bot: Arc::new(RwLock::new(None)), bot_connected_info: Arc::new(RwLock::new(None)), - trusted_mobile_identity: Arc::new(RwLock::new(None)), device_relay_client: Arc::new(RwLock::new(None)), device_relay_lifecycle: Arc::new(Mutex::new(())), device_connection_generation: AtomicU64::new(0), active_device_connection_id: Arc::new(RwLock::new(None)), + authenticated_device_id: Arc::new(RwLock::new(None)), + device_relay_url: Arc::new(RwLock::new(None)), + prepared_relay_url: Arc::new(RwLock::new(None)), online_devices: Arc::new(RwLock::new(Vec::new())), - delegated_identity_fn: Arc::new(RwLock::new(None)), - peer_device_provision_fn: Arc::new(RwLock::new(None)), - account_pairing_username: Arc::new(RwLock::new(None)), - account_pairing_verifier: Arc::new(RwLock::new(None)), }) } - /// Set the delegated identity provider (called by desktop after login). - /// Returns delegated credentials with a host account lease for the paired client. - pub async fn set_delegated_identity_provider(&self, f: F) - where - F: Fn() -> Fut + Send + Sync + 'static, - Fut: std::future::Future> - + Send - + Sync - + 'static, - { - *self.delegated_identity_fn.write().await = Some(Arc::new(move || Box::pin(f()))); - } - - /// Set the peer-device provisioning provider (called by desktop after - /// login). Mints a full account device credential with a host account lease - /// for a device the paired client vouches for. - pub async fn set_peer_device_provisioner(&self, f: F) - where - F: Fn(String, String, String) -> Fut + Send + Sync + 'static, - Fut: std::future::Future> - + Send - + Sync - + 'static, - { - *self.peer_device_provision_fn.write().await = - Some(Arc::new(move |device_id, device_name, request_id| { - Box::pin(f(device_id, device_name, request_id)) - })); - } - - /// Enable account-password pairing in the QR. - /// `None` disables account mode; `Some(username)` enables it (username may - /// be empty when only `auth=account` should be advertised without prefill). - pub async fn set_account_pairing_username(&self, username: Option) { - *self.account_pairing_username.write().await = username.map(|u| u.trim().to_string()); - } - - /// Register account password verification for mobile pairing. - pub async fn set_account_pairing_verifier(&self, f: F) - where - F: Fn(String, String) -> Fut + Send + Sync + 'static, - Fut: std::future::Future> - + Send - + Sync - + 'static, - { - *self.account_pairing_verifier.write().await = Some(Arc::new(move |username, password| { - Box::pin(f(username, password)) - })); - } - - /// Clear account pairing context (username + verifier) on logout. - pub async fn clear_account_pairing_context(&self) { - *self.account_pairing_username.write().await = None; - *self.account_pairing_verifier.write().await = None; - } - - /// Drop the URL-bound mobile identity. Call when the desktop account - /// changes so a later pair can bind to the new account user id. - pub async fn clear_trusted_mobile_identity(&self) { - *self.trusted_mobile_identity.write().await = None; + /// Account identity is supplied only by the authenticated host adapter. + /// Replacing or removing it retires every IM channel before new work starts. + pub async fn set_bot_account(&self, user_id: Option) { + let _lifecycle = self.bot_lifecycle.lock().await; + if *self.bot_account_user_id.read().await == user_id { + return; + } + self.bot_account_identity_epoch + .fetch_add(1, Ordering::AcqRel); + self.stop_bots_inner().await; + *self.bot_account_user_id.write().await = user_id; } - /// Clear credentials and remote-device selections cached by long-lived IM - /// bot chats. Bot connections intentionally survive account logout, but - /// their delegated authority must not. pub async fn clear_bot_delegated_identities(&self) { - // Increment before waiting for lifecycle ownership. In-flight bot work - // can observe the epoch immediately and is forbidden from committing - // account-bound state even if a provider request does not return. - self.bot_account_identity_epoch - .fetch_add(1, Ordering::AcqRel); - let _lifecycle = self.bot_lifecycle.lock().await; - let telegram = self.telegram_bot.read().await.clone(); - let feishu = self.feishu_bot.read().await.clone(); - let weixin = self.weixin_bot.read().await.clone(); - - tokio::join!( - async move { - if let Some(bot) = telegram { - bot.clear_delegated_identities().await; - } - }, - async move { - if let Some(bot) = feishu { - bot.clear_delegated_identities().await; - } - }, - async move { - if let Some(bot) = weixin { - bot.clear_delegated_identities().await; - } - }, - ); + self.set_bot_account(None).await; openbitfun_services_integrations::remote_connect::bot::clear_persisted_bot_account_contexts( ); } @@ -600,214 +220,6 @@ impl RemoteConnectService { &self.device_identity } - async fn validate_mobile_identity( - trusted_mobile_identity: &Arc>>, - mobile_install_id: &str, - user_id: &str, - ) -> std::result::Result { - let mobile_install_id = mobile_install_id.trim(); - let user_id = user_id.trim(); - if mobile_install_id.is_empty() { - return Err("Missing mobile installation ID".to_string()); - } - if user_id.is_empty() { - return Err("Missing user ID".to_string()); - } - - let submitted = TrustedMobileIdentity { - mobile_install_id: mobile_install_id.to_string(), - user_id: user_id.to_string(), - }; - - let trusted = trusted_mobile_identity.read().await.clone(); - match trusted { - Some(existing) if existing.mobile_install_id == submitted.mobile_install_id => { - if existing.user_id != submitted.user_id { - Err("This mobile device must continue using the previously confirmed user ID".to_string()) - } else { - Ok(submitted) - } - } - Some(existing) if existing.user_id != submitted.user_id => Err( - "This remote URL is already protected. Enter the previously confirmed user ID to continue.".to_string(), - ), - _ => Ok(submitted), - } - } - - /// When the desktop is logged in, require and verify account credentials. - /// Returns the canonical account `user_id` to bind as the trusted identity. - async fn resolve_pairing_user_id( - account_pairing_verifier: &Arc>>, - response: &pairing::PairingResponse, - ) -> std::result::Result { - let verifier = account_pairing_verifier.read().await.clone(); - let Some(verify) = verifier else { - // The mobile submitted account credentials (QR advertised - // auth=account) but the desktop logged out after generating the - // code. Never downgrade to password-less pairing. - if response - .password - .as_deref() - .is_some_and(|value| !value.is_empty()) - { - return Err( - "Desktop signed out of the OpenBitFun account; sign in again and refresh the QR code" - .to_string(), - ); - } - let user_id = response - .user_id - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .ok_or_else(|| "Missing user ID".to_string())?; - return Ok(AccountPairingVerification::new(user_id.to_string())); - }; - - let username = response - .user_id - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .ok_or_else(|| "Missing username".to_string())?; - let password = response - .password - .as_deref() - .filter(|value| !value.is_empty()) - .ok_or_else(|| "Missing password".to_string())?; - - verify(username.to_string(), password.to_string()).await - } - - async fn persist_mobile_identity( - trusted_mobile_identity: &Arc>>, - identity: TrustedMobileIdentity, - ) { - *trusted_mobile_identity.write().await = Some(identity); - } - - /// Answer a paired client's `get_delegated_identity` request using the - /// provider registered by the desktop layer after account login. - async fn resolve_delegated_identity_response( - delegated_identity_fn: &Arc>>, - trusted_mobile_identity: &Arc>>, - local_device_id: &str, - ) -> AuthorizedCredentialResolution { - let trusted_identity = trusted_mobile_identity.read().await.clone(); - let Some(trusted_identity) = trusted_identity else { - return AuthorizedCredentialResolution::error( - "Pairing authorization expired; scan a new QR code", - ); - }; - let provider = delegated_identity_fn.read().await.clone(); - let Some(get_identity) = provider else { - return AuthorizedCredentialResolution::error( - "Desktop is not logged into a OpenBitFun account", - ); - }; - let Some(authorization) = get_identity().await else { - return AuthorizedCredentialResolution::error( - "Desktop is not logged into a OpenBitFun account", - ); - }; - if authorization.user_id != trusted_identity.user_id { - return AuthorizedCredentialResolution::error( - "Paired mobile identity no longer matches the desktop account", - ); - } - info!("Delegated identity resolved for paired client"); - authorization.into_response(local_device_id) - } - - /// Answer a paired client's `provision_peer_device` request using the - /// provider registered by the desktop layer after account login. - /// - /// Gated exactly like `resolve_delegated_identity_response`: only a client - /// that completed pairing (which requires the account password whenever the - /// desktop is logged in) may ask the desktop to add a device to its account. - async fn resolve_provisioned_device_response( - peer_device_provision_fn: &Arc>>, - trusted_mobile_identity: &Arc>>, - device_id: &str, - device_name: &str, - request_id: &str, - ) -> AuthorizedCredentialResolution { - let trusted_identity = trusted_mobile_identity.read().await.clone(); - let Some(trusted_identity) = trusted_identity else { - return AuthorizedCredentialResolution::error( - "Pairing authorization expired; scan a new QR code", - ); - }; - - // Checked here as well as at the relay so a malformed id fails with a - // usable message instead of an opaque HTTP 400 one hop away. - if device_id.len() != 32 || !device_id.bytes().all(|b| b.is_ascii_hexdigit()) { - return AuthorizedCredentialResolution::error( - "Device id must be 32 hexadecimal characters", - ); - } - if device_id.bytes().any(|b| b.is_ascii_uppercase()) { - return AuthorizedCredentialResolution::error("Device id must be lowercase"); - } - if device_name.trim().is_empty() { - return AuthorizedCredentialResolution::error("Device name is required"); - } - if request_id.trim().is_empty() { - return AuthorizedCredentialResolution::error("Request id is required"); - } - - let provider = peer_device_provision_fn.read().await.clone(); - let Some(provision) = provider else { - return AuthorizedCredentialResolution::error( - "Desktop is not logged into a OpenBitFun account", - ); - }; - let authorization = match provision( - device_id.to_string(), - device_name.to_string(), - request_id.to_string(), - ) - .await - { - Ok(authorization) => authorization, - Err(message) => return AuthorizedCredentialResolution::error(message), - }; - if authorization.user_id != trusted_identity.user_id { - return AuthorizedCredentialResolution::error( - "Paired mobile identity no longer matches the desktop account", - ); - } - // The credential is only useful for the device that asked for it; a - // mismatch means the account switched mid-flight or the relay answered - // for someone else. - if authorization.device_id != device_id { - return AuthorizedCredentialResolution::error( - "Provisioned credential does not match the requested device", - ); - } - info!("Provisioned account device credential for paired client"); - authorization.into_response() - } - - async fn send_pairing_error_response( - relay_arc: &Arc>>, - correlation_id: &str, - shared_secret: &[u8; 32], - message: String, - ) { - let server = RemoteServer::new(*shared_secret); - if let Ok((enc, nonce)) = - server.encrypt_response(&remote_server::RemoteResponse::Error { message }, None) - { - if let Some(ref client) = *relay_arc.read().await { - let _ = client - .send_relay_response(correlation_id, &enc, &nonce) - .await; - } - } - } - pub fn update_bot_config(&mut self, bot_config: bot::BotConfig) { match bot_config { bot::BotConfig::Feishu { app_id, app_secret } => { @@ -833,556 +245,100 @@ impl RemoteConnectService { pub async fn available_methods(&self) -> Vec { vec![ ConnectionMethod::Lan { ip: None }, - ConnectionMethod::Ngrok, ConnectionMethod::OpenBitFunServer, - ConnectionMethod::CustomServer { - url: self.config.custom_server_url.clone().unwrap_or_default(), - }, ConnectionMethod::BotFeishu, ConnectionMethod::BotTelegram, ConnectionMethod::BotWeixin, ] } - /// Start a remote connection with the given method. - /// - /// For relay methods (LAN / ngrok / OpenBitFun Server / Custom Server) this - /// tears down any existing relay and starts a new one. - /// For bot methods, this starts the bot pairing flow without affecting - /// any running relay connection. - pub async fn start(&self, method: ConnectionMethod) -> Result { - info!("Starting remote connect: {method:?}"); - - match &method { - ConnectionMethod::BotFeishu - | ConnectionMethod::BotTelegram - | ConnectionMethod::BotWeixin => { - return self.start_bot_connection(&method).await; - } - _ => {} - } - + /// Resolve the endpoint and start the local host only for LAN. Every + /// endpoint subsequently uses account login and the same device transport. + pub async fn prepare_relay(&self, method: &ConnectionMethod) -> Result { let _lifecycle = self.relay_lifecycle.lock().await; - - // Relay methods: clean up previous relay (but leave bots alone) - self.stop_relay_inner().await; - - let result: Result = async { - let static_dir = self.config.mobile_web_dir.as_deref(); - - let relay_url = match &method { - ConnectionMethod::Lan { ip } => { - self.embedded_relay_host - .start(self.config.lan_port, self.config.mobile_web_dir.clone()) - .await?; - let url_result = match ip { - Some(ip) => lan::build_lan_relay_url_with_ip(self.config.lan_port, ip), - None => lan::build_lan_relay_url(self.config.lan_port), - }; - match url_result { - Ok(url) => url, - Err(e) => { - return Err(e); - } - } + if self.active_method.read().await.as_ref() == Some(method) { + if let Some(url) = self.prepared_relay_url.read().await.clone() { + return Ok(url); } - ConnectionMethod::Ngrok => { - self.embedded_relay_host - .start(self.config.lan_port, self.config.mobile_web_dir.clone()) - .await?; - - let tunnel = match ngrok::start_ngrok_tunnel(self.config.lan_port).await { - Ok(tunnel) => tunnel, - Err(e) => { - return Err(e); - } - }; - let url = tunnel.public_url.clone(); - *self.ngrok_tunnel.write().await = Some(tunnel); - url - } - ConnectionMethod::OpenBitFunServer => validate_relay_base_url(&self.config.openbitfun_server_url)? - .as_str() - .trim_end_matches('/') - .to_string(), - ConnectionMethod::CustomServer { url } => validate_relay_base_url(url)? - .as_str() - .trim_end_matches('/') - .to_string(), - _ => unreachable!(), - }; - - let mut pairing = self.pairing.write().await; - pairing.reset().await; - let qr_payload = pairing.initiate(&relay_url).await?; - - let ws_url = match &method { - ConnectionMethod::Lan { .. } | ConnectionMethod::Ngrok => { - format!("ws://127.0.0.1:{}/ws", self.config.lan_port) - } - _ => build_relay_websocket_url(&relay_url)?, + } + let url = match method { + ConnectionMethod::Lan { ip } => match ip { + Some(ip) => lan::build_lan_relay_url_with_ip(self.config.lan_port, ip)?, + None => lan::build_lan_relay_url(self.config.lan_port)?, + }, + ConnectionMethod::OpenBitFunServer => self.config.openbitfun_server_url.clone(), + _ => anyhow::bail!("connection method does not use a relay"), }; - - let (client, mut event_rx) = RelayClient::new(); - client.connect(&ws_url).await?; - client - .create_room( - &self.device_identity.device_id, - &qr_payload.public_key, - Some(&qr_payload.room_id), - ) - .await?; - - // Wait for RoomCreated before HTTP upload / QR generation so the relay - // has registered the room (avoids upload 404 races on OpenBitFun/Custom). - // Mirror start_device_connection's AuthOk wait pattern. - { - let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(10); - let expected_room = qr_payload.room_id.clone(); - let mut got_room = false; - while !got_room { - match tokio::time::timeout_at(deadline, event_rx.recv()).await { - Ok(Some(relay_client::RelayEvent::RoomCreated { room_id })) => { - if room_id == expected_room { - info!("Room created on relay: {room_id}"); - got_room = true; - } else { - log::warn!( - "Unexpected RoomCreated room_id={room_id}, expected={expected_room}" - ); - } - } - Ok(Some(other)) => { - log::debug!( - "Skipping relay event while waiting for RoomCreated: {other:?}" - ); - } - Ok(None) => { - anyhow::bail!("relay connection closed before RoomCreated"); - } - Err(_) => { - anyhow::bail!("timeout waiting for RoomCreated"); - } - } + self.stop_relay_inner().await; + if matches!(method, ConnectionMethod::Lan { .. }) { + if let Err(error) = self + .embedded_relay_host + .start(self.config.lan_port, self.config.mobile_web_dir.clone()) + .await + { + self.embedded_relay_host.stop().await; + return Err(error); } } - - let web_app_url: String = match &method { - ConnectionMethod::Lan { .. } | ConnectionMethod::Ngrok => relay_url.clone(), - ConnectionMethod::OpenBitFunServer => { - if let Some(web_dir) = static_dir { - match upload_mobile_web_to_relay(&relay_url, &qr_payload.room_id, web_dir).await - { - Ok(()) => { - let url = format!( - "{}/r/{}", - relay_url.trim_end_matches('/'), - qr_payload.room_id - ); - info!("Uploaded mobile-web to relay: {url}"); - url - } - Err(e) => { - error!("Failed to upload mobile-web to relay: {e}; falling back to server-hosted version"); - self.config.web_app_url.clone() - } - } - } else { - info!("No mobile_web_dir configured; using server-hosted mobile web"); - self.config.web_app_url.clone() - } - } - ConnectionMethod::CustomServer { .. } => { - if let Some(web_dir) = static_dir { - match upload_mobile_web_to_relay(&relay_url, &qr_payload.room_id, web_dir).await - { - Ok(()) => { - let url = format!( - "{}/r/{}", - relay_url.trim_end_matches('/'), - qr_payload.room_id - ); - info!("Uploaded mobile-web to relay: {url}"); - url - } - Err(e) => { - error!("Failed to upload mobile-web to custom relay: {e}; using custom server URL directly"); - relay_url.clone() - } - } - } else { - info!("No mobile_web_dir configured; using custom server URL directly"); - relay_url.clone() - } - } - _ => self.config.web_app_url.clone(), - }; - - let client_language = crate::service::config::get_app_language_code().await; - let account_username = self.account_pairing_username.read().await.clone(); - let qr_url = QrGenerator::build_url( - &qr_payload, - &web_app_url, - &client_language, - account_username.as_deref(), - ); - let qr_svg = QrGenerator::generate_svg_from_url(&qr_url)?; - let qr_data = QrGenerator::generate_png_base64_from_url(&qr_url)?; - + *self.prepared_relay_url.write().await = Some(url.clone()); *self.active_method.write().await = Some(method.clone()); - *self.relay_client.write().await = Some(client); - let room_owner = RoomConnectionOwner { - generation: self - .room_connection_generation - .fetch_add(1, Ordering::AcqRel) - + 1, - room_id: qr_payload.room_id.clone(), - }; - *self.active_room_owner.write().await = Some(room_owner.clone()); - - let pairing_arc = self.pairing.clone(); - let relay_arc = self.relay_client.clone(); - let server_arc = self.remote_server.clone(); - let active_method_arc = self.active_method.clone(); - let room_lifecycle = self.relay_lifecycle.clone(); - let active_room_owner = self.active_room_owner.clone(); - let trusted_mobile_identity_arc = self.trusted_mobile_identity.clone(); - let delegated_identity_fn_arc = self.delegated_identity_fn.clone(); - let peer_device_provision_fn_arc = self.peer_device_provision_fn.clone(); - let account_pairing_verifier_arc = self.account_pairing_verifier.clone(); - let local_device_id = self.device_identity.device_id.clone(); - tokio::spawn(async move { - while let Some(event) = event_rx.recv().await { - // Lease only this event's effects. `start`/`stop_relay` can - // acquire the lifecycle mutex between events, and a retiring - // loop observes the owner mismatch before touching shared - // pairing/client/server state. - let _room_effect = room_lifecycle.lock().await; - if !room_owner_is_current(&*active_room_owner.read().await, &room_owner) { - break; - } - match event { - relay_client::RelayEvent::PairRequest { - correlation_id, - public_key, - device_id, - device_name: _, - } => { - info!("PairRequest from {device_id}"); - let mut p = pairing_arc.write().await; - match p.on_peer_joined(&public_key).await { - Ok(challenge) => { - if let Some(secret) = p.shared_secret() { - let challenge_json = - serde_json::to_string(&challenge).unwrap_or_default(); - if let Ok((enc, nonce)) = - encryption::encrypt_to_base64(secret, &challenge_json) - { - if let Some(ref client) = *relay_arc.read().await { - let _ = client - .send_relay_response(&correlation_id, &enc, &nonce) - .await; - } - } - } - } - Err(e) => { - error!("Pairing error on pair_request: {e}"); - } - } - } - relay_client::RelayEvent::CommandReceived { - correlation_id, - encrypted_data, - nonce, - } => { - let mut handled_as_active_command = false; - { - let server_guard = server_arc.read().await; - if let Some(ref server) = *server_guard { - match server.decrypt_command(&encrypted_data, &nonce) { - Ok((cmd, request_id)) => { - handled_as_active_command = true; - debug!("Remote command decrypted"); - // Account-credential commands are answered - // here, before dispatch: this loop owns the - // trusted pairing identity that authorizes - // them. Everything else routes normally. - let response_resolution = match &cmd { - remote_server::RemoteCommand::GetDelegatedIdentity => { - RemoteConnectService::resolve_delegated_identity_response( - &delegated_identity_fn_arc, - &trusted_mobile_identity_arc, - &local_device_id, - ) - .await - } - remote_server::RemoteCommand::ProvisionPeerDevice { - device_id, - device_name, - request_id, - } => { - RemoteConnectService::resolve_provisioned_device_response( - &peer_device_provision_fn_arc, - &trusted_mobile_identity_arc, - device_id, - device_name, - request_id, - ) - .await - } - _ => AuthorizedCredentialResolution { - response: server.dispatch(&cmd).await, - _host_lease: None, - }, - }; - match server - .encrypt_response( - &response_resolution.response, - request_id.as_deref(), - ) - { - Ok((enc, resp_nonce)) => { - if let Some(ref client) = *relay_arc.read().await { - let _ = client - .send_relay_response( - &correlation_id, - &enc, - &resp_nonce, - ) - .await; - } - } - Err(e) => { - error!("Failed to encrypt response: {e}"); - } - } - // `response_resolution` owns the account lease for - // delegated credentials. Keep it alive through both - // encryption and the awaited room send above. - drop(response_resolution); - } - Err(e) => { - debug!( - "Active session could not decrypt command, falling back to pairing verification: {e}" - ); - } - } - } - } - if handled_as_active_command { - continue; - } - - let p = pairing_arc.read().await; - if let Some(secret) = p.shared_secret() { - let shared_secret = *secret; - if let Ok(json) = encryption::decrypt_from_base64( - &shared_secret, - &encrypted_data, - &nonce, - ) { - if let Ok(response) = - serde_json::from_str::(&json) - { - if let Err(error) = response.validate_untrusted() { - drop(p); - RemoteConnectService::send_pairing_error_response( - &relay_arc, - &correlation_id, - &shared_secret, - format!("Invalid pairing response: {error}"), - ) - .await; - continue; - } - let account_verification = match RemoteConnectService::resolve_pairing_user_id( - &account_pairing_verifier_arc, - &response, - ) - .await - { - Ok(user_id) => user_id, - Err(message) => { - drop(p); - pairing_arc - .write() - .await - .retry_after_identity_rejection() - .await; - RemoteConnectService::send_pairing_error_response( - &relay_arc, - &correlation_id, - &shared_secret, - message, - ) - .await; - continue; - } - }; - let canonical_user_id = - account_verification.user_id().to_string(); - let mobile_install_id = response - .mobile_install_id - .clone() - .unwrap_or_default(); - let submitted_identity = - match RemoteConnectService::validate_mobile_identity( - &trusted_mobile_identity_arc, - &mobile_install_id, - &canonical_user_id, - ) - .await - { - Ok(identity) => identity, - Err(message) => { - drop(p); - pairing_arc - .write() - .await - .retry_after_identity_rejection() - .await; - RemoteConnectService::send_pairing_error_response( - &relay_arc, - &correlation_id, - &shared_secret, - message, - ) - .await; - continue; - } - }; - drop(p); - let mut pw = pairing_arc.write().await; - match pw.verify_response(&response).await { - Ok(true) => { - info!("Pairing verified successfully"); - RemoteConnectService::persist_mobile_identity( - &trusted_mobile_identity_arc, - submitted_identity.clone(), - ) - .await; - if let Some(s) = pw.shared_secret() { - let server = RemoteServer::new(*s); - - let initial_sync = server - .generate_initial_sync(Some( - submitted_identity.user_id.clone(), - )) - .await; - if let Ok((enc, resp_nonce)) = - server.encrypt_response(&initial_sync, None) - { - if let Some(ref client) = - *relay_arc.read().await - { - info!( - "Sending initial sync to mobile after pairing" - ); - let _ = client - .send_relay_response( - &correlation_id, - &enc, - &resp_nonce, - ) - .await; - } - } - - *server_arc.write().await = Some(server); - - // The delegated account identity is NOT - // pushed here: the relay pending request - // for this correlation_id is consumed by - // the initial_sync response, so a second - // frame would be dropped. Paired clients - // pull it via `get_delegated_identity`. - } - // Keep the host's account lease through every - // successful pairing commit and response-side effect. - drop(account_verification); - } - Ok(false) => { - error!("Pairing verification failed"); - RemoteConnectService::send_pairing_error_response( - &relay_arc, - &correlation_id, - &shared_secret, - "Pairing verification failed".to_string(), - ) - .await; - } - Err(e) => { - error!("Pairing verification error: {e}"); - RemoteConnectService::send_pairing_error_response( - &relay_arc, - &correlation_id, - &shared_secret, - format!("Pairing verification error: {e}"), - ) - .await; - } - } - } - } - } - } - relay_client::RelayEvent::Reconnected => { - info!("Relay reconnected — pairing + server preserved for mobile polling"); - } - relay_client::RelayEvent::Disconnected => { - info!("Relay disconnected"); - pairing_arc.write().await.disconnect().await; - *server_arc.write().await = None; - } - relay_client::RelayEvent::Error { message } => { - error!("Relay error: {message}"); - if message.contains("Room not found") { - info!("Room expired, disconnecting"); - pairing_arc.write().await.disconnect().await; - *server_arc.write().await = None; - } - } - _ => {} - } - } - - // Stream exit is itself generation-fenced. In particular, an old - // loop closing after reconnect must not disconnect the new room. - let _room_effect = room_lifecycle.lock().await; - let mut owner = active_room_owner.write().await; - if clear_room_owner_if_current(&mut owner, &room_owner) { - drop(owner); - *relay_arc.write().await = None; - pairing_arc.write().await.disconnect().await; - *server_arc.write().await = None; - *active_method_arc.write().await = None; - *trusted_mobile_identity_arc.write().await = None; - } - }); + Ok(url) + } - let state = pairing.state().await; + /// Build an invitation for the authenticated route. URL selection is the + /// only difference between official and locally hosted Relay connections. + pub async fn start(&self, method: ConnectionMethod) -> Result { + if matches!( + method, + ConnectionMethod::BotFeishu + | ConnectionMethod::BotTelegram + | ConnectionMethod::BotWeixin + ) { + return self.start_bot_connection(&method).await; + } + let _lifecycle = self.device_relay_lifecycle.lock().await; + let device_id = self + .authenticated_device_id + .read() + .await + .clone() + .ok_or_else(|| { + anyhow::anyhow!( + "Sign in with GitHub and connect this device before creating an invitation" + ) + })?; + let relay_url = self + .device_relay_url + .read() + .await + .clone() + .ok_or_else(|| anyhow::anyhow!("No authenticated relay connection"))?; + if self.prepared_relay_url.read().await.as_deref() != Some(relay_url.as_str()) + || self.active_method.read().await.as_ref() != Some(&method) + { + anyhow::bail!("Relay endpoint changed; start the connection again"); + } + let qr_url = QrGenerator::build_device_url(&relay_url, &device_id)?; Ok(ConnectionResult { method, - qr_data: Some(qr_data), - qr_svg: Some(qr_svg), + qr_data: Some(QrGenerator::generate_png_base64_from_url(&qr_url)?), + qr_svg: Some(QrGenerator::generate_svg_from_url(&qr_url)?), qr_url: Some(qr_url), bot_pairing_code: None, bot_link: None, - pairing_state: state, + pairing_state: PairingState::WaitingForScan, }) - } - .await; - - if result.is_err() { - self.stop_relay_inner().await; - } - result } async fn start_bot_connection(&self, method: &ConnectionMethod) -> Result { let _lifecycle = self.bot_lifecycle.lock().await; - let pairing_code = PairingProtocol::generate_bot_pairing_code(); + let account_user_id = self + .bot_account_user_id + .read() + .await + .clone() + .ok_or_else(|| anyhow::anyhow!("Sign in with GitHub before connecting a bot"))?; + let pairing_code = pairing::generate_bot_pairing_code(); let bot_link = match method { ConnectionMethod::BotTelegram => { @@ -1402,7 +358,8 @@ impl RemoteConnectService { self.bot_account_identity_epoch.clone(), self.bot_telegram_slot.clone(), generation, - ), + ) + .with_account(account_user_id.clone()), )); tg_bot.register_pairing(&pairing_code).await?; @@ -1471,7 +428,8 @@ impl RemoteConnectService { self.bot_account_identity_epoch.clone(), self.bot_feishu_slot.clone(), generation, - ), + ) + .with_account(account_user_id.clone()), )); fs_bot.register_pairing(&pairing_code).await?; @@ -1555,7 +513,8 @@ impl RemoteConnectService { self.bot_account_identity_epoch.clone(), self.bot_weixin_slot.clone(), generation, - ), + ) + .with_account(account_user_id.clone()), )); wx_bot.register_pairing(&pairing_code).await?; @@ -1632,6 +591,15 @@ impl RemoteConnectService { /// Skips the pairing step and directly starts the message loop. pub async fn restore_bot(&self, saved: &bot::SavedBotConnection) -> Result<()> { let _lifecycle = self.bot_lifecycle.lock().await; + let account_user_id = self + .bot_account_user_id + .read() + .await + .clone() + .ok_or_else(|| anyhow::anyhow!("Sign in with GitHub before restoring a bot"))?; + if saved.account_user_id.is_empty() || saved.account_user_id != account_user_id { + anyhow::bail!("Saved bot belongs to a different account or requires pairing again"); + } match saved.config { bot::BotConfig::Telegram { ref bot_token } => { let generation = self.bot_telegram_slot.advance(); @@ -1647,7 +615,8 @@ impl RemoteConnectService { self.bot_account_identity_epoch.clone(), self.bot_telegram_slot.clone(), generation, - ), + ) + .with_account(account_user_id.clone()), )); let chat_id: i64 = saved.chat_id.parse().map_err(|_| { @@ -1688,7 +657,8 @@ impl RemoteConnectService { self.bot_account_identity_epoch.clone(), self.bot_feishu_slot.clone(), generation, - ), + ) + .with_account(account_user_id.clone()), )); fs_bot @@ -1741,7 +711,8 @@ impl RemoteConnectService { self.bot_account_identity_epoch.clone(), self.bot_weixin_slot.clone(), generation, - ), + ) + .with_account(account_user_id.clone()), )); wx_bot .restore_chat_state(&saved.chat_id, saved.chat_state.clone()) @@ -1777,42 +748,33 @@ impl RemoteConnectService { } pub async fn pairing_state(&self) -> PairingState { - self.pairing.read().await.state().await + if self.is_device_connected().await { + PairingState::Connected + } else { + PairingState::Idle + } } - /// Stop relay connections (LAN / ngrok / OpenBitFun Server / Custom Server). - /// Bot connections are left running. + /// Stop Relay routing and its local host; bots retain their own lifecycle. pub async fn stop_relay(&self) { let _lifecycle = self.relay_lifecycle.lock().await; self.stop_relay_inner().await; } async fn stop_relay_inner(&self) { - // Fence the retiring event loop before disconnecting its client. A - // late event or stream-exit cleanup must not mutate the next room. - *self.active_room_owner.write().await = None; - if let Some(ref client) = *self.relay_client.read().await { - client.disconnect().await; - } - *self.relay_client.write().await = None; - *self.remote_server.write().await = None; + self.stop_device_connection().await; *self.active_method.write().await = None; - - if let Some(ref mut tunnel) = *self.ngrok_tunnel.write().await { - tunnel.stop().await; - } - *self.ngrok_tunnel.write().await = None; - + *self.prepared_relay_url.write().await = None; self.embedded_relay_host.stop().await; - - self.pairing.write().await.reset().await; - *self.trusted_mobile_identity.write().await = None; - info!("Relay connections stopped (bots unaffected)"); } /// Stop all bot connections. pub async fn stop_bots(&self) { let _lifecycle = self.bot_lifecycle.lock().await; + self.stop_bots_inner().await; + } + + async fn stop_bots_inner(&self) { self.bot_telegram_slot.advance(); if let Some(handle) = self.bot_telegram_handle.write().await.take() { handle.stop(); @@ -1852,21 +814,13 @@ impl RemoteConnectService { } pub async fn is_connected(&self) -> bool { - self.pairing.read().await.state().await == PairingState::Connected + self.is_device_connected().await } pub async fn active_method(&self) -> Option { self.active_method.read().await.clone() } - pub async fn peer_device_name(&self) -> Option { - self.pairing - .read() - .await - .peer_device_name() - .map(String::from) - } - /// Check whether a specific bot type is currently running. pub async fn is_bot_running(&self, bot_type: &str) -> bool { match bot_type { @@ -1881,19 +835,10 @@ impl RemoteConnectService { self.bot_connected_info.read().await.clone() } - pub async fn trusted_mobile_user_id(&self) -> Option { - self.trusted_mobile_identity - .read() - .await - .as_ref() - .map(|identity| identity.user_id.clone()) - } - // ── P2: Account-authenticated device routing ─────────────────────────── /// Connect to the relay's WS endpoint and authenticate with an account - /// token. This establishes a parallel device-routing pathway that does not - /// interfere with the room-pairing flow. Incoming device messages are + /// token. Incoming device messages are /// forwarded via the returned event receiver. /// /// The caller (desktop Tauri layer) owns the AccountSession containing the @@ -1912,6 +857,10 @@ impl RemoteConnectService { ) } + pub async fn device_relay_url(&self) -> Option { + self.device_relay_url.read().await.clone() + } + /// Start account device routing. Returns /// `(event_rx, authenticated_device_id, connection_id)`. /// @@ -1992,6 +941,9 @@ impl RemoteConnectService { + 1; *device_client_arc.write().await = Some(client); *active_connection_id.write().await = Some(connection_id); + *self.authenticated_device_id.write().await = Some(authenticated_device_id.clone()); + *self.device_relay_url.write().await = Some(relay_url.to_string()); + let authenticated_id = self.authenticated_device_id.clone(); // Spawn event forwarder that updates presence state; the raw event stream // is also forwarded to a new channel for the caller to consume. let (forward_tx, forward_rx) = tokio::sync::mpsc::unbounded_channel(); @@ -2018,6 +970,7 @@ impl RemoteConnectService { *active = None; drop(active); *device_client_arc.write().await = None; + *authenticated_id.write().await = None; online_arc.write().await.clear(); } }); @@ -2032,6 +985,8 @@ impl RemoteConnectService { } async fn stop_device_connection_inner(&self) { + *self.authenticated_device_id.write().await = None; + *self.device_relay_url.write().await = None; *self.active_device_connection_id.write().await = None; if let Some(client) = self.device_relay_client.write().await.take() { client.disconnect().await; @@ -2085,673 +1040,7 @@ impl RemoteConnectService { pub async fn online_devices(&self) -> Vec { self.online_devices.read().await.clone() } - - /// Send an encrypted response to the paired mobile/IM client via the room - /// channel. Used to delegate account identity after pairing. - pub async fn send_room_response( - &self, - correlation_id: &str, - encrypted_data: &str, - nonce: &str, - ) -> Result<()> { - let guard = self.relay_client.read().await; - let client = guard - .as_ref() - .ok_or_else(|| anyhow::anyhow!("relay client not connected"))?; - client - .send_relay_response(correlation_id, encrypted_data, nonce) - .await - } - - /// Send only if the relay connection still belongs to the pairing secret - /// captured by the caller. Holding the relay lifecycle and pairing read - /// leases across the send prevents a concurrently replaced room from - /// receiving a response prepared for the previous room. - pub async fn send_room_response_if_pairing_secret( - &self, - expected_secret: &[u8; 32], - correlation_id: &str, - encrypted_data: &str, - nonce: &str, - ) -> Result { - let _lifecycle = self.relay_lifecycle.lock().await; - let pairing = self.pairing.read().await; - if pairing.shared_secret() != Some(expected_secret) { - return Ok(false); - } - let guard = self.relay_client.read().await; - let client = guard - .as_ref() - .ok_or_else(|| anyhow::anyhow!("relay client not connected"))?; - client - .send_relay_response(correlation_id, encrypted_data, nonce) - .await?; - Ok(true) - } - - /// Room-first variant for host-authorized secret-bearing responses. The - /// returned authorization lease stays alive through the transport write; - /// hosts can use it to keep account replacement from completing without - /// introducing an account-lock -> room-lock inversion. - pub async fn send_room_response_if_pairing_secret_authorized( - &self, - expected_secret: &[u8; 32], - correlation_id: &str, - encrypted_data: &str, - nonce: &str, - authorize: F, - ) -> Result - where - F: FnOnce() -> Fut, - Fut: std::future::Future>, - L: Send, - { - let _lifecycle = self.relay_lifecycle.lock().await; - let pairing = self.pairing.read().await; - if pairing.shared_secret() != Some(expected_secret) { - return Ok(false); - } - let _authorization = authorize().await.map_err(anyhow::Error::msg)?; - if pairing.shared_secret() != Some(expected_secret) { - return Ok(false); - } - let guard = self.relay_client.read().await; - let client = guard - .as_ref() - .ok_or_else(|| anyhow::anyhow!("relay client not connected"))?; - client - .send_relay_response(correlation_id, encrypted_data, nonce) - .await?; - Ok(true) - } - - /// Get the pairing shared secret (for encrypting delegate identity). - pub async fn pairing_shared_secret(&self) -> Option<[u8; 32]> { - self.pairing.read().await.shared_secret().copied() - } } #[cfg(test)] mod host_lifecycle_tests; - -#[cfg(test)] -mod tests { - use super::*; - use std::sync::atomic::{AtomicBool, Ordering}; - - fn pairing_response(user_id: Option<&str>, password: Option<&str>) -> pairing::PairingResponse { - pairing::PairingResponse { - challenge_echo: "echo".to_string(), - device_id: "mobile-1".to_string(), - device_name: "Phone".to_string(), - mobile_install_id: Some("install-1".to_string()), - user_id: user_id.map(str::to_string), - password: password.map(str::to_string), - } - } - - fn no_verifier() -> Arc>> { - Arc::new(RwLock::new(None)) - } - - #[test] - fn retiring_room_owner_cannot_clear_replacement() { - let owner_a = RoomConnectionOwner { - generation: 1, - room_id: "room-a".to_string(), - }; - let owner_b = RoomConnectionOwner { - generation: 2, - room_id: "room-b".to_string(), - }; - let mut active = Some(owner_b.clone()); - - assert!(!clear_room_owner_if_current(&mut active, &owner_a)); - assert_eq!(active, Some(owner_b.clone())); - assert!(clear_room_owner_if_current(&mut active, &owner_b)); - assert_eq!(active, None); - } - - fn verifier_returning( - canonical_user_id: &'static str, - ) -> Arc>> { - Arc::new(RwLock::new(Some( - Arc::new(move |_username: String, _password: String| { - Box::pin(async move { - Ok(AccountPairingVerification::new( - canonical_user_id.to_string(), - )) - }) - as std::pin::Pin< - Box< - dyn std::future::Future< - Output = Result, - > + Send - + Sync, - >, - > - }) as AccountPairingVerifierFn, - ))) - } - - #[tokio::test] - async fn account_mode_requires_password() { - let result = RemoteConnectService::resolve_pairing_user_id( - &verifier_returning("user-123"), - &pairing_response(Some("alice"), None), - ) - .await; - assert_eq!(result.unwrap_err(), "Missing password"); - } - - #[tokio::test] - async fn account_mode_requires_username() { - let result = RemoteConnectService::resolve_pairing_user_id( - &verifier_returning("user-123"), - &pairing_response(None, Some("secret")), - ) - .await; - assert_eq!(result.unwrap_err(), "Missing username"); - } - - #[tokio::test] - async fn account_credentials_are_never_downgraded_when_verifier_is_gone() { - // QR advertised auth=account, but the desktop signed out before the - // scan finished: reject instead of falling back to password-less pairing. - let result = RemoteConnectService::resolve_pairing_user_id( - &no_verifier(), - &pairing_response(Some("alice"), Some("secret")), - ) - .await; - assert!(result.unwrap_err().contains("signed out")); - } - - #[tokio::test] - async fn legacy_mode_without_verifier_uses_plain_user_id() { - let result = RemoteConnectService::resolve_pairing_user_id( - &no_verifier(), - &pairing_response(Some("local-user"), None), - ) - .await; - assert_eq!(result.unwrap().user_id(), "local-user"); - } - - #[tokio::test] - async fn verifier_result_binds_canonical_user_id() { - let canonical = RemoteConnectService::resolve_pairing_user_id( - &verifier_returning("canonical-user-123"), - &pairing_response(Some("alice"), Some("secret")), - ) - .await - .expect("verification should succeed"); - assert_eq!(canonical.user_id(), "canonical-user-123"); - let canonical_user_id = canonical.user_id().to_string(); - - let trusted = Arc::new(RwLock::new(None)); - let identity = RemoteConnectService::validate_mobile_identity( - &trusted, - "install-1", - &canonical_user_id, - ) - .await - .expect("first pairing binds the identity"); - assert_eq!(identity.user_id, "canonical-user-123"); - RemoteConnectService::persist_mobile_identity(&trusted, identity).await; - - // Reconnect with the same canonical id still matches. - assert!(RemoteConnectService::validate_mobile_identity( - &trusted, - "install-1", - &canonical_user_id, - ) - .await - .is_ok()); - // A different account user id is rejected against the bound identity. - assert!(RemoteConnectService::validate_mobile_identity( - &trusted, - "install-1", - "other-user" - ) - .await - .is_err()); - } - - #[tokio::test] - async fn account_pairing_preserves_password_whitespace_for_verification() { - let verifier = Arc::new(RwLock::new(Some( - Arc::new(|_username: String, password: String| { - Box::pin(async move { - assert_eq!(password, " secret with spaces "); - Ok(AccountPairingVerification::new( - "canonical-user-123".to_string(), - )) - }) - as std::pin::Pin< - Box< - dyn std::future::Future< - Output = Result, - > + Send - + Sync, - >, - > - }) as AccountPairingVerifierFn, - ))); - let verified = RemoteConnectService::resolve_pairing_user_id( - &verifier, - &pairing_response(Some("alice"), Some(" secret with spaces ")), - ) - .await - .expect("pairing should pass the exact password to the verifier"); - assert_eq!(verified.user_id(), "canonical-user-123"); - } - - #[tokio::test] - async fn delegated_identity_requires_a_trusted_pairing_before_minting_credentials() { - let provider_called = Arc::new(AtomicBool::new(false)); - let called = provider_called.clone(); - let provider: DelegatedIdentityFn = Arc::new(move || { - let called = called.clone(); - Box::pin(async move { - called.store(true, Ordering::SeqCst); - Some(DelegatedIdentityAuthorization::new( - "account-token".to_string(), - "account-user".to_string(), - [7_u8; 32], - )) - }) - }); - let provider = Arc::new(RwLock::new(Some(provider))); - let trusted = Arc::new(RwLock::new(None)); - - let response = RemoteConnectService::resolve_delegated_identity_response( - &provider, - &trusted, - "desktop-1", - ) - .await; - - assert!(matches!( - response.response, - remote_server::RemoteResponse::Error { .. } - )); - assert!(!provider_called.load(Ordering::SeqCst)); - } - - #[tokio::test] - async fn delegated_identity_uses_the_account_bound_during_pairing() { - let provider: DelegatedIdentityFn = Arc::new(|| { - Box::pin(async { - Some(DelegatedIdentityAuthorization::new( - "account-token".to_string(), - "paired-user".to_string(), - [7_u8; 32], - )) - }) - }); - let provider = Arc::new(RwLock::new(Some(provider))); - let trusted = Arc::new(RwLock::new(Some(TrustedMobileIdentity { - mobile_install_id: "install-1".to_string(), - user_id: "paired-user".to_string(), - }))); - - let response = RemoteConnectService::resolve_delegated_identity_response( - &provider, - &trusted, - "desktop-1", - ) - .await; - - assert!(matches!( - response.response, - remote_server::RemoteResponse::DelegateIdentity { - user_id, - device_id, - .. - } if user_id == "paired-user" && device_id == "desktop-1" - )); - } - - #[tokio::test] - async fn delegated_identity_rejects_a_provider_for_another_account() { - let provider: DelegatedIdentityFn = Arc::new(|| { - Box::pin(async { - Some(DelegatedIdentityAuthorization::new( - "account-token".to_string(), - "replacement-user".to_string(), - [7_u8; 32], - )) - }) - }); - let provider = Arc::new(RwLock::new(Some(provider))); - let trusted = Arc::new(RwLock::new(Some(TrustedMobileIdentity { - mobile_install_id: "install-1".to_string(), - user_id: "paired-user".to_string(), - }))); - - let response = RemoteConnectService::resolve_delegated_identity_response( - &provider, - &trusted, - "desktop-1", - ) - .await; - assert!(matches!( - response.response, - remote_server::RemoteResponse::Error { message } - if message.contains("no longer matches") - )); - } - - #[tokio::test] - async fn delegated_identity_keeps_account_lease_until_response_is_released() { - let account_lifecycle = Arc::new(Mutex::new(())); - let provider_lifecycle = account_lifecycle.clone(); - let provider: DelegatedIdentityFn = Arc::new(move || { - let provider_lifecycle = provider_lifecycle.clone(); - Box::pin(async move { - let lease = provider_lifecycle.lock_owned().await; - Some(DelegatedIdentityAuthorization::with_host_lease( - "account-token".to_string(), - "paired-user".to_string(), - [7_u8; 32], - lease, - )) - }) - }); - let provider = Arc::new(RwLock::new(Some(provider))); - let trusted = Arc::new(RwLock::new(Some(TrustedMobileIdentity { - mobile_install_id: "install-1".to_string(), - user_id: "paired-user".to_string(), - }))); - - let response = RemoteConnectService::resolve_delegated_identity_response( - &provider, - &trusted, - "desktop-1", - ) - .await; - assert!(matches!( - &response.response, - remote_server::RemoteResponse::DelegateIdentity { .. } - )); - assert!( - tokio::time::timeout( - std::time::Duration::from_millis(20), - account_lifecycle.clone().lock_owned(), - ) - .await - .is_err(), - "account replacement must remain blocked while the response is in flight" - ); - - drop(response); - tokio::time::timeout( - std::time::Duration::from_secs(1), - account_lifecycle.lock_owned(), - ) - .await - .expect("account replacement should proceed after the response is released"); - } - - const WATCH_DEVICE_ID: &str = "0123456789abcdef0123456789abcdef"; - - fn peer_provisioner(user_id: &'static str, device_id: &'static str) -> PeerDeviceProvisionFn { - Arc::new(move |_device_id, _device_name, _request_id| { - Box::pin(async move { - Ok(ProvisionedDeviceAuthorization::new( - "watch-device-token".to_string(), - user_id.to_string(), - [9_u8; 32], - device_id.to_string(), - )) - }) - }) - } - - fn trusted_as(user_id: &str) -> Arc>> { - Arc::new(RwLock::new(Some(TrustedMobileIdentity { - mobile_install_id: "install-1".to_string(), - user_id: user_id.to_string(), - }))) - } - - #[tokio::test] - async fn provisioning_requires_a_trusted_pairing_before_minting_credentials() { - let provider_called = Arc::new(AtomicBool::new(false)); - let called = provider_called.clone(); - let provider: PeerDeviceProvisionFn = - Arc::new(move |_device_id, _device_name, _request_id| { - let called = called.clone(); - Box::pin(async move { - called.store(true, Ordering::SeqCst); - Ok(ProvisionedDeviceAuthorization::new( - "watch-device-token".to_string(), - "account-user".to_string(), - [9_u8; 32], - WATCH_DEVICE_ID.to_string(), - )) - }) - }); - let provider = Arc::new(RwLock::new(Some(provider))); - let trusted = Arc::new(RwLock::new(None)); - - let response = RemoteConnectService::resolve_provisioned_device_response( - &provider, - &trusted, - WATCH_DEVICE_ID, - "HarmonyOS Watch", - "5f0d1c1a-0000-4000-8000-000000000001", - ) - .await; - - assert!(matches!( - response.response, - remote_server::RemoteResponse::Error { .. } - )); - assert!( - !provider_called.load(Ordering::SeqCst), - "an unpaired caller must never reach the relay" - ); - } - - #[tokio::test] - async fn provisioning_uses_the_account_bound_during_pairing() { - let provider = Arc::new(RwLock::new(Some(peer_provisioner( - "paired-user", - WATCH_DEVICE_ID, - )))); - - let response = RemoteConnectService::resolve_provisioned_device_response( - &provider, - &trusted_as("paired-user"), - WATCH_DEVICE_ID, - "HarmonyOS Watch", - "5f0d1c1a-0000-4000-8000-000000000001", - ) - .await; - - match response.response { - remote_server::RemoteResponse::PeerDeviceProvisioned { - token, - user_id, - device_id, - .. - } => { - assert_eq!(token, "watch-device-token"); - assert_eq!(user_id, "paired-user"); - // The provisioned device, not the delegating desktop. - assert_eq!(device_id, WATCH_DEVICE_ID); - } - other => panic!("expected a provisioned credential, got {other:?}"), - } - } - - #[tokio::test] - async fn provisioning_rejects_a_provider_for_another_account() { - let provider = Arc::new(RwLock::new(Some(peer_provisioner( - "other-user", - WATCH_DEVICE_ID, - )))); - - let response = RemoteConnectService::resolve_provisioned_device_response( - &provider, - &trusted_as("paired-user"), - WATCH_DEVICE_ID, - "HarmonyOS Watch", - "5f0d1c1a-0000-4000-8000-000000000001", - ) - .await; - - assert!(matches!( - response.response, - remote_server::RemoteResponse::Error { .. } - )); - } - - #[tokio::test] - async fn provisioning_rejects_a_credential_minted_for_a_different_device() { - let provider = Arc::new(RwLock::new(Some(peer_provisioner( - "paired-user", - "ffffffffffffffffffffffffffffffff", - )))); - - let response = RemoteConnectService::resolve_provisioned_device_response( - &provider, - &trusted_as("paired-user"), - WATCH_DEVICE_ID, - "HarmonyOS Watch", - "5f0d1c1a-0000-4000-8000-000000000001", - ) - .await; - - assert!(matches!( - response.response, - remote_server::RemoteResponse::Error { .. } - )); - } - - #[tokio::test] - async fn provisioning_rejects_device_ids_the_relay_would_refuse() { - let provider_called = Arc::new(AtomicBool::new(false)); - let called = provider_called.clone(); - let provider: PeerDeviceProvisionFn = - Arc::new(move |_device_id, _device_name, _request_id| { - let called = called.clone(); - Box::pin(async move { - called.store(true, Ordering::SeqCst); - Ok(ProvisionedDeviceAuthorization::new( - "watch-device-token".to_string(), - "paired-user".to_string(), - [9_u8; 32], - WATCH_DEVICE_ID.to_string(), - )) - }) - }); - let provider = Arc::new(RwLock::new(Some(provider))); - - // Too short, non-hex, and uppercase: the three shapes the relay's - // `provision_device` validator rejects. - for bad_id in [ - "watch-0123456789abcdef", - "0123456789abcdef0123456789abcdeg", - "0123456789ABCDEF0123456789ABCDEF", - ] { - let response = RemoteConnectService::resolve_provisioned_device_response( - &provider, - &trusted_as("paired-user"), - bad_id, - "HarmonyOS Watch", - "5f0d1c1a-0000-4000-8000-000000000001", - ) - .await; - assert!( - matches!( - response.response, - remote_server::RemoteResponse::Error { .. } - ), - "{bad_id} should be rejected before the relay sees it" - ); - } - assert!( - !provider_called.load(Ordering::SeqCst), - "a malformed id must fail locally rather than at the relay" - ); - } - - #[tokio::test] - async fn provisioning_surfaces_the_relay_failure_reason() { - let provider: PeerDeviceProvisionFn = - Arc::new(move |_device_id, _device_name, _request_id| { - Box::pin(async move { Err("relay rejected the request".to_string()) }) - }); - let provider = Arc::new(RwLock::new(Some(provider))); - - let response = RemoteConnectService::resolve_provisioned_device_response( - &provider, - &trusted_as("paired-user"), - WATCH_DEVICE_ID, - "HarmonyOS Watch", - "5f0d1c1a-0000-4000-8000-000000000001", - ) - .await; - - match response.response { - // The person is standing there watching a watch spin; a generic - // failure would send them to the wrong fix. - remote_server::RemoteResponse::Error { message } => { - assert!(message.contains("relay rejected the request"), "{message}"); - } - other => panic!("expected the relay reason to survive, got {other:?}"), - } - } - - #[tokio::test] - async fn provisioning_keeps_account_lease_until_response_is_released() { - let account_lifecycle = Arc::new(Mutex::new(())); - let provider_lifecycle = account_lifecycle.clone(); - let provider: PeerDeviceProvisionFn = - Arc::new(move |_device_id, _device_name, _request_id| { - let provider_lifecycle = provider_lifecycle.clone(); - Box::pin(async move { - let lease = provider_lifecycle.lock_owned().await; - Ok(ProvisionedDeviceAuthorization::with_host_lease( - "watch-device-token".to_string(), - "paired-user".to_string(), - [9_u8; 32], - WATCH_DEVICE_ID.to_string(), - lease, - )) - }) - }); - let provider = Arc::new(RwLock::new(Some(provider))); - - let response = RemoteConnectService::resolve_provisioned_device_response( - &provider, - &trusted_as("paired-user"), - WATCH_DEVICE_ID, - "HarmonyOS Watch", - "5f0d1c1a-0000-4000-8000-000000000001", - ) - .await; - assert!(matches!( - &response.response, - remote_server::RemoteResponse::PeerDeviceProvisioned { .. } - )); - assert!( - tokio::time::timeout( - std::time::Duration::from_millis(20), - account_lifecycle.clone().lock_owned(), - ) - .await - .is_err(), - "account replacement must remain blocked while the response is in flight" - ); - - drop(response); - tokio::time::timeout( - std::time::Duration::from_secs(1), - account_lifecycle.lock_owned(), - ) - .await - .expect("account replacement should proceed after the response is released"); - } -} diff --git a/src/crates/assembly/core/src/service/remote_connect/ngrok.rs b/src/crates/assembly/core/src/service/remote_connect/ngrok.rs deleted file mode 100644 index 411a6c2a36..0000000000 --- a/src/crates/assembly/core/src/service/remote_connect/ngrok.rs +++ /dev/null @@ -1,5 +0,0 @@ -//! Compatibility facade for Remote Connect ngrok tunnel lifecycle. - -pub use openbitfun_services_integrations::remote_connect::{ - cleanup_all_ngrok, detect_running_ngrok, is_ngrok_available, start_ngrok_tunnel, NgrokTunnel, -}; diff --git a/src/crates/assembly/core/src/service/remote_connect/settings_sync.rs b/src/crates/assembly/core/src/service/remote_connect/settings_sync.rs deleted file mode 100644 index d9a783af9f..0000000000 --- a/src/crates/assembly/core/src/service/remote_connect/settings_sync.rs +++ /dev/null @@ -1,692 +0,0 @@ -//! Account cloud settings sync engine, shared by Desktop and CLI. -//! -//! Owns the full settings sync lifecycle for one process: -//! - **Push**: persisted ConfigService changes or `notify_settings_changed()` -//! mark local settings dirty; a 5s debounce -//! later the engine exports the config and uploads it to the relay. Uploads -//! are content-hash deduped so identical content is never re-uploaded. -//! - **Pull**: an immediate pull on start, then every 30s, fetches the cloud -//! settings blob and applies it when the relay version differs from the -//! last version this device uploaded or applied. -//! - **Apply**: import into the global config service, reload, invalidate the -//! AI client cache, then fire `on_settings_applied` so the host app can -//! refresh UI / notify peer controllers. -//! -//! The cursor (`version` + content `hash` of the last uploaded/applied blob) -//! is persisted in `~/.openbitfun/account_sync/.settings.json`, separate -//! from the session sync state, so restarts do not re-apply unchanged blobs -//! and co-located processes (e.g. CLI daemon + interactive CLI) share one -//! cursor without racing the session backup writer. -//! -//! Apps wire platform behavior through [`SettingsSyncHooks`]; the engine -//! itself is platform-agnostic. - -use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; -use std::sync::{Arc, OnceLock}; -use std::time::Duration; - -use anyhow::{anyhow, Result}; -use log::{debug, warn}; -use tokio::sync::{mpsc, Notify}; - -use openbitfun_services_integrations::remote_connect::account::{ - error_indicates_expired_token, AccountClient, AccountSession, SettingsBlob, -}; -use openbitfun_services_integrations::remote_connect::sync_state; - -/// How often the engine pulls cloud settings. -pub const SETTINGS_PULL_INTERVAL: Duration = Duration::from_secs(30); -/// Debounce window between the last local change and the settings upload. -pub const SETTINGS_PUSH_DEBOUNCE: Duration = Duration::from_secs(5); - -/// Account context needed for every relay call: the session (token + -/// master_key) and the relay base URL. -pub type AccountContext = (AccountSession, String, u64); - -type AccountContextFn = dyn Fn() -> std::pin::Pin> + Send>> - + Send - + Sync; - -/// Platform wiring for the settings sync engine. All hooks have no-op -/// defaults so apps only register what they need. -#[derive(Default)] -pub struct SettingsSyncHooks { - /// Returns the current account session + relay URL, or an error when - /// logged out. Required for the background loop; one-shot helpers take - /// the context explicitly. - pub account_context: Option>, - /// Confirms that a context generation captured before an async relay call - /// still belongs to the active account. Hosts bump the generation before - /// logout or replacement login. - pub is_account_context_current: Option bool + Send + Sync>>, - /// When true, push and pull are paused (Desktop: Peer controller mode). - pub should_pause: Option bool + Send + Sync>>, - /// Fired after cloud settings were applied to the local config. - pub on_settings_applied: Option>, - /// Fired after local settings were uploaded to the cloud. - pub on_settings_pushed: Option>, - /// Fired when the relay rejected the account token. - pub on_token_expired: Option>, -} - -static HOOKS: OnceLock = OnceLock::new(); -static STARTED: AtomicBool = AtomicBool::new(false); -static PUSH_TX: OnceLock> = OnceLock::new(); -/// Number of uploads/applies currently running (one-shot login sync, loop -/// push, or loop pull apply). The periodic pull skips while non-zero so it -/// never re-applies a blob that is mid-upload or fights an explicit -/// user-chosen direction. A counter (not a flag) so concurrent ops do not -/// clear each other's in-flight state on completion. -static SYNC_OPS_IN_FLIGHT: AtomicUsize = AtomicUsize::new(0); -static SYNC_OPS_IDLE: Notify = Notify::const_new(); - -/// RAII guard that marks a sync upload/apply as in flight. -struct SyncOpGuard; -impl SyncOpGuard { - fn begin() -> Self { - SYNC_OPS_IN_FLIGHT.fetch_add(1, Ordering::SeqCst); - Self - } -} -impl Drop for SyncOpGuard { - fn drop(&mut self) { - if SYNC_OPS_IN_FLIGHT.fetch_sub(1, Ordering::SeqCst) == 1 { - SYNC_OPS_IDLE.notify_waiters(); - } - } -} - -/// Wait until every settings upload/apply critical section has completed. -/// Hosts call this after invalidating their account generation and before -/// completing logout or installing a replacement account. -pub async fn wait_for_sync_operations_idle() { - loop { - let notified = SYNC_OPS_IDLE.notified(); - if SYNC_OPS_IN_FLIGHT.load(Ordering::SeqCst) == 0 { - return; - } - notified.await; - } -} - -fn hooks() -> &'static SettingsSyncHooks { - static DEFAULT: SettingsSyncHooks = SettingsSyncHooks { - account_context: None, - is_account_context_current: None, - should_pause: None, - on_settings_applied: None, - on_settings_pushed: None, - on_token_expired: None, - }; - HOOKS.get().unwrap_or(&DEFAULT) -} - -fn is_account_context_current(generation: u64) -> bool { - hooks() - .is_account_context_current - .as_ref() - .map(|check| check(generation)) - .unwrap_or(true) -} - -fn should_pause() -> bool { - hooks().should_pause.as_ref().map(|f| f()).unwrap_or(false) -} - -fn fire_settings_applied() { - if let Some(f) = hooks().on_settings_applied.as_ref() { - f(); - } -} - -fn fire_settings_pushed() { - if let Some(f) = hooks().on_settings_pushed.as_ref() { - f(); - } -} - -fn fire_token_expired() { - if let Some(f) = hooks().on_token_expired.as_ref() { - f(); - } -} - -/// Start the background settings sync loop. Idempotent: later calls are -/// ignored. Safe to call before login — every cycle silently skips while the -/// account context is unavailable, and picks up once the user logs in. -pub fn start_settings_sync_engine(hooks: SettingsSyncHooks) { - if STARTED.swap(true, Ordering::SeqCst) { - debug!("Settings sync engine already started; ignoring duplicate start"); - return; - } - let _ = HOOKS.set(hooks); - let (tx, rx) = mpsc::unbounded_channel::<()>(); - let _ = PUSH_TX.set(tx); - tokio::spawn(settings_sync_loop(rx)); -} - -/// Notify the engine that local settings changed (config set / import / -/// reset). Cheap and non-blocking; the upload is debounced and deduped. -pub fn notify_settings_changed() { - if let Some(tx) = PUSH_TX.get() { - let _ = tx.send(()); - } -} - -/// Parses the only supported account-settings payload: a complete current -/// OpenBitFun `ConfigExport` wrapper. -fn config_export_value(payload: &str) -> Result { - serde_json::from_str(payload).map_err(|e| anyhow!("parse OpenBitFun settings export: {e}")) -} - -fn canonicalize_json(value: serde_json::Value) -> serde_json::Value { - match value { - serde_json::Value::Object(fields) => { - let mut entries = fields.into_iter().collect::>(); - entries.sort_by(|left, right| left.0.cmp(&right.0)); - serde_json::Value::Object( - entries - .into_iter() - .map(|(key, value)| (key, canonicalize_json(value))) - .collect(), - ) - } - serde_json::Value::Array(values) => { - serde_json::Value::Array(values.into_iter().map(canonicalize_json).collect()) - } - value => value, - } -} - -/// Hash settings content, excluding export and document write metadata. A -/// cloud import updates the local document timestamp/build; those changes -/// must not turn the next unchanged save into another upload. -fn settings_content_hash(payload: &str) -> Result { - let export = config_export_value(payload)?; - let mut config = serde_json::to_value(export.config)?; - if let Some(root) = config.as_object_mut() { - root.remove("last_modified"); - root.remove("version"); - } - let canonical = serde_json::to_string(&canonicalize_json(config)) - .map_err(|e| anyhow!("serialize settings for hashing: {e}"))?; - Ok(sync_state::content_hash(&canonical)) -} - -/// Record the settings cursor after a successful upload or apply. -fn record_settings_cursor(user_id: &str, version: i64, hash: String) { - let cursor = sync_state::SettingsCursor { version, hash }; - if let Err(e) = sync_state::save_settings_cursor(user_id, &cursor) { - warn!("Settings sync: failed to persist settings cursor: {e}"); - } -} - -fn note_relay_error(error: &anyhow::Error, context: &str) { - if error_indicates_expired_token(&error.to_string()) { - fire_token_expired(); - } - warn!("Settings sync: {context} failed: {error}"); -} - -/// Upload a settings payload to the relay and record the cursor. -/// Returns the version assigned to the upload. -pub async fn upload_settings_payload( - account: &AccountSession, - relay_url: &str, - payload: &str, -) -> Result { - upload_settings_payload_for_generation(account, relay_url, payload, None).await -} - -async fn upload_settings_payload_for_generation( - account: &AccountSession, - relay_url: &str, - payload: &str, - generation: Option, -) -> Result { - if generation.is_some_and(|value| !is_account_context_current(value)) { - return Err(anyhow!("account context changed before settings upload")); - } - let _op = SyncOpGuard::begin(); - if generation.is_some_and(|value| !is_account_context_current(value)) { - return Err(anyhow!("account context changed before settings upload")); - } - let client = AccountClient::new(); - // The version returned here is the exact one stored on the relay — - // recording it keeps the next pull from re-applying our own upload. - let version = client.upload_settings(relay_url, account, payload).await?; - if generation.is_some_and(|value| !is_account_context_current(value)) { - return Err(anyhow!("account context changed during settings upload")); - } - let hash = settings_content_hash(payload).unwrap_or_default(); - record_settings_cursor(&account.user_id, version, hash); - debug!("Settings sync: uploaded settings (version={version})"); - fire_settings_pushed(); - Ok(version) -} - -/// Export the current config and upload it when the content differs from the -/// last uploaded/applied blob. Returns `true` when an upload happened. -pub async fn push_settings_now(account: &AccountSession, relay_url: &str) -> Result { - push_settings_now_for_generation(account, relay_url, None).await -} - -async fn push_settings_now_for_generation( - account: &AccountSession, - relay_url: &str, - generation: Option, -) -> Result { - let config_service = crate::service::config::get_global_config_service() - .await - .map_err(|e| anyhow!("config service: {e}"))?; - let exported = config_service - .export_config() - .await - .map_err(|e| anyhow!("export config: {e}"))?; - let payload = serde_json::to_string(&exported).map_err(|e| anyhow!("serialize config: {e}"))?; - - if generation.is_some_and(|value| !is_account_context_current(value)) { - return Err(anyhow!("account context changed while exporting settings")); - } - - let hash = settings_content_hash(&payload)?; - let known = sync_state::load_settings_cursor(&account.user_id); - if known.hash == hash && known.version != 0 { - debug!("Settings sync: push skipped, content unchanged"); - return Ok(false); - } - - upload_settings_payload_for_generation(account, relay_url, &payload, generation).await?; - Ok(true) -} - -/// Apply a fetched cloud settings blob when its version is newer than the -/// recorded cursor. Pass `force = true` for explicit user choices (login -/// "use cloud") so the blob applies even when the version matches the cursor. -/// Returns `true` when the blob was applied. -pub async fn apply_settings_blob( - account: &AccountSession, - blob: &SettingsBlob, - force: bool, -) -> Result { - apply_settings_blob_for_generation(account, blob, force, None, None).await -} - -async fn apply_settings_blob_for_generation( - account: &AccountSession, - blob: &SettingsBlob, - force: bool, - generation: Option, - expected_local_config: Option, -) -> Result { - if !force { - let known = sync_state::load_settings_cursor(&account.user_id); - if blob.version == known.version { - return Ok(false); - } - } - if generation.is_some_and(|value| !is_account_context_current(value)) { - return Err(anyhow!("account context changed before settings apply")); - } - let _op = SyncOpGuard::begin(); - if generation.is_some_and(|value| !is_account_context_current(value)) { - return Err(anyhow!("account context changed before settings apply")); - } - - let export = config_export_value(&blob.plaintext)?; - let config_service = crate::service::config::get_global_config_service() - .await - .map_err(|e| anyhow!("config service: {e}"))?; - let import_result = match expected_local_config { - Some(expected) if !force => { - config_service - .import_account_settings_if_unchanged(export, expected) - .await - } - _ => config_service.import_account_settings(export).await, - } - .map_err(|e| anyhow!("import cloud config: {e}"))?; - if !import_result.success { - return Err(anyhow!( - "import cloud config failed: {}", - import_result.errors.join("; ") - )); - } - if let Ok(factory) = crate::infrastructure::ai::AIClientFactory::get_global().await { - factory.invalidate_cache(); - } - // A failed reload leaves in-memory config stale; bail without recording - // the cursor so the next pull retries the apply. - config_service - .reload() - .await - .map_err(|e| anyhow!("reload after config import: {e}"))?; - - let hash = settings_content_hash(&blob.plaintext).unwrap_or_default(); - record_settings_cursor(&account.user_id, blob.version, hash); - debug!( - "Settings sync: applied cloud settings (version={})", - blob.version - ); - fire_settings_applied(); - Ok(true) -} - -/// Fetch the cloud settings blob and apply it when changed. Returns `true` -/// when new settings were applied; `Ok(false)` also when no cloud settings -/// exist yet. -pub async fn pull_and_apply_settings(account: &AccountSession, relay_url: &str) -> Result { - pull_and_apply_settings_for_generation(account, relay_url, None).await -} - -async fn pull_and_apply_settings_for_generation( - account: &AccountSession, - relay_url: &str, - generation: Option, -) -> Result { - let config_service = crate::service::config::get_global_config_service() - .await - .map_err(|e| anyhow!("config service: {e}"))?; - let expected_local_config = serde_json::to_value( - config_service - .export_config() - .await - .map_err(|e| anyhow!("export config: {e}"))? - .config, - ) - .map_err(|e| anyhow!("snapshot config: {e}"))?; - let client = AccountClient::new(); - let Some(blob) = client - .fetch_settings_with_version(relay_url, account) - .await? - else { - return Ok(false); - }; - if generation.is_some_and(|value| !is_account_context_current(value)) { - return Err(anyhow!("account context changed during settings pull")); - } - apply_settings_blob_for_generation( - account, - &blob, - false, - generation, - Some(expected_local_config), - ) - .await -} - -async fn account_context() -> Result { - let provider = hooks() - .account_context - .as_ref() - .ok_or_else(|| anyhow!("account context provider not registered"))?; - provider().await -} - -async fn push_from_loop() { - if should_pause() { - debug!("Settings sync: push paused by host app"); - return; - } - let (account, relay_url, generation) = match account_context().await { - Ok(ctx) => ctx, - Err(_) => return, // logged out — silently skip - }; - if !is_account_context_current(generation) { - return; - } - if let Err(e) = push_settings_now_for_generation(&account, &relay_url, Some(generation)).await { - if !is_account_context_current(generation) { - return; - } - note_relay_error(&e, "push"); - } -} - -async fn pull_from_loop() { - if should_pause() { - debug!("Settings sync: pull paused by host app"); - return; - } - if SYNC_OPS_IN_FLIGHT.load(Ordering::SeqCst) > 0 { - debug!("Settings sync: pull skipped while an upload/apply is in flight"); - return; - } - let (account, relay_url, generation) = match account_context().await { - Ok(ctx) => ctx, - Err(_) => return, // logged out — silently skip - }; - if !is_account_context_current(generation) { - return; - } - if let Err(e) = - pull_and_apply_settings_for_generation(&account, &relay_url, Some(generation)).await - { - if !is_account_context_current(generation) { - return; - } - note_relay_error(&e, "pull"); - } -} - -/// Background loop: debounced push on local change + periodic pull. -/// The first pull runs immediately so a long-running process (CLI daemon) -/// converges right after start instead of one interval later. -async fn settings_sync_loop(mut rx: mpsc::UnboundedReceiver<()>) { - let mut next_pull = tokio::time::Instant::now(); - let mut local_changes = None; - loop { - // Subscribe at the persistence owner as well as accepting legacy host - // notifications. Skills, Agent profiles, CLI mutations, and future - // settings must not depend on each adapter remembering an upload hook. - if local_changes.is_none() { - match crate::service::config::get_global_config_service().await { - Ok(service) => local_changes = Some(service.subscribe_local_changes()), - Err(error) => { - warn!("Settings sync: config subscription unavailable; will retry: {error}") - } - } - } - let pull_deadline = tokio::time::sleep_until(next_pull); - tokio::pin!(pull_deadline); - - let push_requested = tokio::select! { - // A queued local save takes priority over a periodic pull. - biased; - Some(()) = rx.recv() => true, - available = wait_for_local_config_change(&mut local_changes) => { - if !available { - local_changes = None; - continue; - } - true - }, - _ = &mut pull_deadline => { - next_pull = tokio::time::Instant::now() + SETTINGS_PULL_INTERVAL; - pull_from_loop().await; - false - } - }; - if !push_requested { - continue; - } - - // Drain both notification sources during the same debounce window. - let deadline = tokio::time::sleep(SETTINGS_PUSH_DEBOUNCE); - tokio::pin!(deadline); - loop { - tokio::select! { - _ = &mut deadline => break, - Some(()) = rx.recv() => {}, - available = wait_for_local_config_change(&mut local_changes) => { - if !available { - local_changes = None; - } - } - } - } - push_from_loop().await; - } -} - -async fn wait_for_local_config_change( - receiver: &mut Option>, -) -> bool { - match receiver { - Some(receiver) => receiver.changed().await.is_ok(), - None => std::future::pending().await, - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn settings_payload( - config: crate::service::config::GlobalConfig, - export_timestamp: &str, - version: &str, - ) -> String { - serde_json::to_string(&crate::service::config::ConfigExport { - product_id: openbitfun_core_types::product_identity::product_id().to_string(), - format_version: crate::service::config::CURRENT_CONFIG_EXPORT_FORMAT_VERSION, - config, - export_timestamp: export_timestamp.to_string(), - version: version.to_string(), - }) - .unwrap() - } - - #[test] - fn content_hash_ignores_export_wrapper_fields() { - let config = crate::service::config::GlobalConfig::default(); - let a = settings_payload(config.clone(), "2026-01-01T00:00:00Z", "1.0.0"); - let b = settings_payload(config, "2026-02-02T00:00:00Z", "1.1.0"); - assert_eq!( - settings_content_hash(&a).unwrap(), - settings_content_hash(&b).unwrap() - ); - } - - #[test] - fn content_hash_ignores_host_write_metadata_but_keeps_settings() { - let mut first = crate::service::config::GlobalConfig::default(); - first.last_modified = chrono::DateTime::from_timestamp_millis(1_000).unwrap(); - first.version = "older-build".to_string(); - let mut second = first.clone(); - second.last_modified = chrono::DateTime::from_timestamp_millis(2_000).unwrap(); - second.version = "newer-build".to_string(); - let hash = |config| { - settings_content_hash(&settings_payload(config, "fixture", "fixture")).unwrap() - }; - assert_eq!(hash(first.clone()), hash(second.clone())); - second.app.notifications.enabled = !first.app.notifications.enabled; - assert_ne!(hash(first), hash(second)); - } - - #[test] - fn content_hash_changes_with_config_content() { - let a = crate::service::config::GlobalConfig::default(); - let mut b = a.clone(); - b.app.language = "en-US".to_string(); - let a = settings_payload(a, "2026-01-01T00:00:00Z", "1.0.0"); - let b = settings_payload(b, "2026-01-01T00:00:00Z", "1.0.0"); - assert_ne!( - settings_content_hash(&a).unwrap(), - settings_content_hash(&b).unwrap() - ); - } - - #[test] - fn account_settings_payload_preserves_chat_input_default_preference() { - let mut config = crate::service::config::GlobalConfig::default(); - config.app.flow_chat.default_mode_strategy = - Some(crate::service::config::types::ChatInputDefaultModeStrategy::FollowLast); - config.app.flow_chat.default_mode_id = Some("Ultra".to_string()); - config.app.flow_chat.last_mode_id = Some("Creative".to_string()); - - let payload = settings_payload(config, "2026-01-01T00:00:00Z", "1.0.0"); - let export = config_export_value(&payload).unwrap(); - - assert_eq!( - export.config.app.flow_chat.default_mode_strategy, - Some(crate::service::config::types::ChatInputDefaultModeStrategy::FollowLast) - ); - assert_eq!( - export.config.app.flow_chat.default_mode_id.as_deref(), - Some("Ultra") - ); - assert_eq!( - export.config.app.flow_chat.last_mode_id.as_deref(), - Some("Creative") - ); - assert_eq!( - serde_json::to_value(export).unwrap()["config"]["app"]["flow_chat"], - serde_json::json!({ - "default_mode_strategy": "follow_last", - "default_mode_id": "Ultra", - "last_mode_id": "Creative" - }) - ); - } - - #[test] - fn content_hash_rejects_bare_config_payload() { - let bare = serde_json::to_string(&crate::service::config::GlobalConfig::default()).unwrap(); - assert!(settings_content_hash(&bare).is_err()); - } - - #[test] - fn config_export_parser_requires_current_wrapper_shape() { - let config = crate::service::config::GlobalConfig::default(); - let payload = settings_payload(config.clone(), "2026-01-01T00:00:00Z", "1.0.0"); - let export = config_export_value(&payload).unwrap(); - assert_eq!(export.config.product_id, config.product_id); - - let mut invalid: serde_json::Value = serde_json::from_str(&payload).unwrap(); - invalid.as_object_mut().unwrap().remove("format_version"); - assert!(config_export_value(&invalid.to_string()).is_err()); - } - - #[test] - fn older_supported_payload_defaults_missing_preferences_and_round_trips() { - let config = crate::service::config::GlobalConfig::default(); - let mut payload: serde_json::Value = serde_json::from_str(&settings_payload( - config, - "2026-01-01T00:00:00Z", - "older-build", - )) - .unwrap(); - let app = payload["config"]["app"].as_object_mut().unwrap(); - for field in [ - "voice_call", - "user_tool_groups", - "user_skill_groups", - "prevent_sleep", - ] { - app.remove(field); - } - payload["config"]["app"]["ai_experience"]["quick_actions"] = serde_json::json!([]); - payload["config"].as_object_mut().unwrap().remove("font"); - let export = config_export_value(&payload.to_string()).unwrap(); - assert!(export.config.app.voice_call.api_key.is_empty()); - assert!(export.config.app.user_tool_groups.groups.is_empty()); - assert!(export.config.app.user_skill_groups.groups.is_empty()); - assert!(!export.config.app.prevent_sleep); - assert_eq!(export.config.app.flow_chat.default_mode_strategy, None); - assert_eq!(export.config.app.flow_chat.default_mode_id, None); - assert_eq!(export.config.app.flow_chat.last_mode_id, None); - assert!(export.config.font.is_none()); - assert!(export.config.app.ai_experience.quick_actions.is_empty()); - let reexported = serde_json::to_string(&export).unwrap(); - let reparsed = config_export_value(&reexported).unwrap(); - assert_eq!( - serde_json::to_value(export.config).unwrap(), - serde_json::to_value(reparsed.config).unwrap() - ); - assert_eq!( - settings_content_hash(&payload.to_string()).unwrap(), - settings_content_hash(&reexported).unwrap() - ); - } -} diff --git a/src/crates/assembly/core/src/service/remote_ssh/mod.rs b/src/crates/assembly/core/src/service/remote_ssh/mod.rs index 5d0289e238..b316aec3ee 100644 --- a/src/crates/assembly/core/src/service/remote_ssh/mod.rs +++ b/src/crates/assembly/core/src/service/remote_ssh/mod.rs @@ -13,11 +13,11 @@ pub mod remote_terminal; pub mod types; pub mod workspace_state; +#[cfg(feature = "ssh-remote")] +pub use openbitfun_services_integrations::remote_ssh::dispatch_ssh; pub use openbitfun_services_integrations::remote_ssh::{ build_remote_git_command, search_remote_file_names, shell_quote_posix, RemoteFileNameSearch, }; -#[cfg(feature = "ssh-remote")] -pub use openbitfun_services_integrations::remote_ssh::{dispatch_ssh, relay_deploy}; #[cfg(not(feature = "ssh-remote"))] pub use openbitfun_services_integrations::remote_ssh::{ get_global_remote_exec_process_manager, global_port_forward_manager, diff --git a/src/crates/contracts/product-domains/src/account.rs b/src/crates/contracts/product-domains/src/account.rs index 4a7197a2b9..00108ebd0c 100644 --- a/src/crates/contracts/product-domains/src/account.rs +++ b/src/crates/contracts/product-domains/src/account.rs @@ -1,7 +1,10 @@ -//! Account identity and settings-sync projections shared by product surfaces. +//! Account identity projections shared by product surfaces. use serde::{Deserialize, Serialize}; +/// Versioned hosted Relay deployment for the GitHub account/device-key protocol. +pub const DEFAULT_RELAY_URL: &str = "https://remote.openbitfun.com/v/1.0.0"; + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AccountInfo { @@ -19,47 +22,14 @@ pub struct AccountDevice { pub online: bool, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] -#[serde(rename_all = "snake_case")] -pub enum SettingsSyncStatus { - #[default] - Idle, - Syncing, - Done, - Failed, - Cancelled, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] -#[serde(rename_all = "camelCase")] -pub struct SettingsSyncProgress { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub operation_id: Option, - pub status: SettingsSyncStatus, - pub phase: String, - pub percent: u8, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub current: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub total: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub detail: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub error: Option, - pub settings_synced: bool, - pub sessions_exported: usize, -} - #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AccountSnapshotProjection { pub logged_in: bool, - pub pending_sync_choice: bool, #[serde(default, skip_serializing_if = "Option::is_none")] pub info: Option, #[serde(default)] pub devices: Vec, - pub sync: SettingsSyncProgress, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -67,6 +37,35 @@ pub struct AccountSnapshotProjection { pub struct AccountLoginProjection { pub user_id: String, pub relay_url: String, - pub has_cloud_settings: bool, pub status_message: String, } + +/// Verified GitHub profile for the global GitHub account. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GitHubUser { + pub github_id: i64, + pub login: String, + pub avatar_url: String, +} + +/// Public authorization progress. The transaction secret stays in its host. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GitHubAuthStart { + pub transaction_id: String, + pub authorization_url: String, + pub expires_at: i64, + pub poll_interval_seconds: u32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GitHubAuthPollRequest { + pub transaction_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GitHubAuthPollResponse { + pub status: String, +} diff --git a/src/crates/contracts/product-domains/src/generated/product-control-catalog.json b/src/crates/contracts/product-domains/src/generated/product-control-catalog.json index aaea11d048..50211aea4e 100644 --- a/src/crates/contracts/product-domains/src/generated/product-control-catalog.json +++ b/src/crates/contracts/product-domains/src/generated/product-control-catalog.json @@ -4,7 +4,7 @@ "title": "OpenBitFun Playbook", "origin": "https://playbook.openbitfun.com", "source": "src/shared/interactive-capabilities/catalog.json", - "digest": "409e6dbef7ebceafccc11606e2855227b9a33fa1ed71f5b4608da04e8a23de7d", + "digest": "896281a3cd5cac2b50ec607988e04624224444911ddb2d58ba59a5d7d06493c5", "ownerDigest": "c0e5c187cf62bc6ed06196ce8520b3eb427bf268cf24659b72d2552fb1d99c54", "searchAcceptance": [ { @@ -138,11 +138,11 @@ "features": 22, "settings": 21, "userFacing": 43, - "documentedItems": 322, + "documentedItems": 319, "controlCoverage": { "direct": 48, "delegated": 61, - "interactive": 213, + "interactive": 210, "unsupported": 0 } }, @@ -8666,11 +8666,8 @@ "start-stop-status", "network", "bots", - "relay-wizard", "account", "devices", - "session-sync", - "settings-sync", "peer-device" ], "kind": "query", @@ -8896,52 +8893,6 @@ "eventName": "openbitfun:open-remote-connect" } }, - { - "id": "feature.remote-connect:open:relay-wizard", - "capabilityId": "feature.remote-connect", - "itemIds": [ - "relay-wizard" - ], - "kind": "open", - "risk": "ui", - "executionHost": "presentationSurface", - "availability": { - "desktop": { - "available": true - }, - "cli": { - "available": false, - "reason": "This delivery profile has no live presentation surface" - }, - "peer": { - "available": true, - "requiredCapabilities": [ - "product_control_v1", - "product_control_presentation_v1" - ] - }, - "remoteControl": { - "available": true - }, - "detachedDispatch": { - "available": false, - "reason": "This delivery profile has no live presentation surface" - } - }, - "inputSchema": { - "type": "object", - "additionalProperties": false - }, - "outputSchema": { - "type": "object", - "additionalProperties": true - }, - "openReason": "unstructuredInteraction", - "presentationTarget": { - "kind": "event", - "eventName": "openbitfun:open-remote-connect" - } - }, { "id": "feature.remote-connect:open:account", "capabilityId": "feature.remote-connect", @@ -9034,98 +8985,6 @@ "eventName": "openbitfun:open-remote-connect" } }, - { - "id": "feature.remote-connect:open:session-sync", - "capabilityId": "feature.remote-connect", - "itemIds": [ - "session-sync" - ], - "kind": "open", - "risk": "ui", - "executionHost": "presentationSurface", - "availability": { - "desktop": { - "available": true - }, - "cli": { - "available": false, - "reason": "This delivery profile has no live presentation surface" - }, - "peer": { - "available": true, - "requiredCapabilities": [ - "product_control_v1", - "product_control_presentation_v1" - ] - }, - "remoteControl": { - "available": true - }, - "detachedDispatch": { - "available": false, - "reason": "This delivery profile has no live presentation surface" - } - }, - "inputSchema": { - "type": "object", - "additionalProperties": false - }, - "outputSchema": { - "type": "object", - "additionalProperties": true - }, - "openReason": "unstructuredInteraction", - "presentationTarget": { - "kind": "event", - "eventName": "openbitfun:open-remote-connect" - } - }, - { - "id": "feature.remote-connect:open:settings-sync", - "capabilityId": "feature.remote-connect", - "itemIds": [ - "settings-sync" - ], - "kind": "open", - "risk": "ui", - "executionHost": "presentationSurface", - "availability": { - "desktop": { - "available": true - }, - "cli": { - "available": false, - "reason": "This delivery profile has no live presentation surface" - }, - "peer": { - "available": true, - "requiredCapabilities": [ - "product_control_v1", - "product_control_presentation_v1" - ] - }, - "remoteControl": { - "available": true - }, - "detachedDispatch": { - "available": false, - "reason": "This delivery profile has no live presentation surface" - } - }, - "inputSchema": { - "type": "object", - "additionalProperties": false - }, - "outputSchema": { - "type": "object", - "additionalProperties": true - }, - "openReason": "unstructuredInteraction", - "presentationTarget": { - "kind": "event", - "eventName": "openbitfun:open-remote-connect" - } - }, { "id": "feature.remote-connect:open:peer-device", "capabilityId": "feature.remote-connect", @@ -23867,7 +23726,7 @@ "微信", "多设备", "Peer Device", - "账户同步" + "GitHub" ], "keywordsEn": [ "remote connect", @@ -23878,28 +23737,28 @@ "WeChat", "multi-device", "peer device", - "account sync" + "GitHub" ], "highlightsZh": [ - "通过局域网、Ngrok 或自建 Relay 连接", + "通过局域网或官方 Relay 连接", "接入飞书、Telegram 或微信 Bot", - "登录账户、同步会话并进入 Peer Device Mode" + "使用 GitHub 身份管理设备并进入 Peer Device Mode" ], "highlightsEn": [ - "Connect through LAN, Ngrok, or a self-hosted relay", + "Connect through LAN or the official Relay", "Use Feishu, Telegram, or WeChat bots", - "Sign in, sync sessions, and enter Peer Device Mode" + "Use GitHub identity to manage devices and enter Peer Device Mode" ], "items": [ { "id": "connection-methods", - "titleZh": "选择局域网、Ngrok、官方 Relay、自建 Relay 或自定义服务器", - "titleEn": "Choose LAN, Ngrok, the hosted relay, a self-hosted relay, or a custom server", + "titleZh": "选择局域网或官方 Relay", + "titleEn": "Choose LAN or the official Relay", "control": { "kind": "open", "reasonCode": "unstructuredInteraction", - "reasonZh": "“选择局域网、Ngrok、官方 Relay、自建 Relay 或自定义服务器”由多个实时状态相关步骤组成,目前没有一个能确定完成整个流程的单一结构化 Command;Agent 会打开精确入口,并把后续交互保留在用户可见界面。", - "reasonEn": "“Choose LAN, Ngrok, the hosted relay, a self-hosted relay, or a custom server” spans multiple live-state-dependent steps and currently has no single structured Command that can deterministically complete the whole workflow; the Agent opens the exact entry and keeps the remaining interaction visible to the user." + "reasonZh": "“选择局域网或官方 Relay”需要结合当前网络与设备状态,确认目标主机后才能连接;Agent 打开连接入口,由用户完成选择。", + "reasonEn": "“Choose LAN or the official Relay” depends on the current network and device state and requires the user to confirm the target host; the Agent opens the connection entry for that choice." } }, { @@ -23935,26 +23794,15 @@ "reasonEn": "“Configure Feishu, Telegram, WeChat, and other bots, and stop a bot independently” spans multiple live-state-dependent steps and currently has no single structured Command that can deterministically complete the whole workflow; the Agent opens the exact entry and keeps the remaining interaction visible to the user." } }, - { - "id": "relay-wizard", - "titleZh": "通过向导预检、安装 Docker、部署、注册并验证自建 Relay", - "titleEn": "Preflight, install Docker, deploy, register, and verify a self-hosted relay through the wizard", - "control": { - "kind": "open", - "reasonCode": "unstructuredInteraction", - "reasonZh": "“通过向导预检、安装 Docker、部署、注册并验证自建 Relay”由多个实时状态相关步骤组成,目前没有一个能确定完成整个流程的单一结构化 Command;Agent 会打开精确入口,并把后续交互保留在用户可见界面。", - "reasonEn": "“Preflight, install Docker, deploy, register, and verify a self-hosted relay through the wizard” spans multiple live-state-dependent steps and currently has no single structured Command that can deterministically complete the whole workflow; the Agent opens the exact entry and keeps the remaining interaction visible to the user." - } - }, { "id": "account", - "titleZh": "登录、退出并查看账户状态和凭据提示", - "titleEn": "Sign in, sign out, and inspect account status and credential hints", + "titleZh": "使用 GitHub 登录、退出并查看身份状态", + "titleEn": "Sign in with GitHub, sign out, and inspect identity status", "control": { "kind": "open", "reasonCode": "externalAuth", - "reasonZh": "OpenBitFun 账户登录必须由用户在外部认证页面确认;Agent 可以打开入口并读取非秘密状态,但不能冒充账户持有人。", - "reasonEn": "OpenBitFun account sign-in requires confirmation on an external authentication page; the Agent may open the entry and read non-secret status but cannot impersonate the account holder." + "reasonZh": "GitHub 账户登录必须由用户在外部认证页面确认;Agent 可以打开入口并读取非秘密状态,但不能冒充账户持有人。", + "reasonEn": "GitHub account sign-in requires confirmation on an external authentication page; the Agent may open the entry and read non-secret status but cannot impersonate the account holder." } }, { @@ -23968,28 +23816,6 @@ "reasonEn": "“List, connect, inspect online status, and remove same-account devices” spans multiple live-state-dependent steps and currently has no single structured Command that can deterministically complete the whole workflow; the Agent opens the exact entry and keeps the remaining interaction visible to the user." } }, - { - "id": "session-sync", - "titleZh": "同步、导出、导入、删除或发送会话到另一台设备", - "titleEn": "Sync, export, import, delete, or send sessions to another device", - "control": { - "kind": "open", - "reasonCode": "unstructuredInteraction", - "reasonZh": "“同步、导出、导入、删除或发送会话到另一台设备”由多个实时状态相关步骤组成,目前没有一个能确定完成整个流程的单一结构化 Command;Agent 会打开精确入口,并把后续交互保留在用户可见界面。", - "reasonEn": "“Sync, export, import, delete, or send sessions to another device” spans multiple live-state-dependent steps and currently has no single structured Command that can deterministically complete the whole workflow; the Agent opens the exact entry and keeps the remaining interaction visible to the user." - } - }, - { - "id": "settings-sync", - "titleZh": "在设备间自动或手动同步 OpenBitFun 设置", - "titleEn": "Synchronize OpenBitFun settings across devices automatically or on demand", - "control": { - "kind": "open", - "reasonCode": "unstructuredInteraction", - "reasonZh": "“在设备间自动或手动同步 OpenBitFun 设置”由多个实时状态相关步骤组成,目前没有一个能确定完成整个流程的单一结构化 Command;Agent 会打开精确入口,并把后续交互保留在用户可见界面。", - "reasonEn": "“Synchronize OpenBitFun settings across devices automatically or on demand” spans multiple live-state-dependent steps and currently has no single structured Command that can deterministically complete the whole workflow; the Agent opens the exact entry and keeps the remaining interaction visible to the user." - } - }, { "id": "peer-device", "titleZh": "进入 Peer Device Mode,把另一台 OpenBitFun 设备作为命令与事件数据面", @@ -24042,7 +23868,7 @@ "微信", "多设备", "Peer Device", - "账户同步", + "GitHub", "remote connect", "remote control", "mobile", @@ -24050,31 +23876,24 @@ "WeChat", "multi-device", "peer device", - "account sync", - "通过局域网、Ngrok 或自建 Relay 连接", + "通过局域网或官方 Relay 连接", "接入飞书、Telegram 或微信 Bot", - "登录账户、同步会话并进入 Peer Device Mode", - "Connect through LAN, Ngrok, or a self-hosted relay", + "使用 GitHub 身份管理设备并进入 Peer Device Mode", + "Connect through LAN or the official Relay", "Use Feishu, Telegram, or WeChat bots", - "Sign in, sync sessions, and enter Peer Device Mode", - "选择局域网、Ngrok、官方 Relay、自建 Relay 或自定义服务器", - "Choose LAN, Ngrok, the hosted relay, a self-hosted relay, or a custom server", + "Use GitHub identity to manage devices and enter Peer Device Mode", + "选择局域网或官方 Relay", + "Choose LAN or the official Relay", "启动、停止 Remote Connect 并查看实时连接状态和设备信息", "Start or stop Remote Connect and inspect live status and device information", "查看局域网 IP、网络信息与可分享的连接配置", "Inspect LAN IP, network details, and shareable connection configuration", "配置飞书、Telegram、微信等 Bot 并单独停止 Bot", "Configure Feishu, Telegram, WeChat, and other bots, and stop a bot independently", - "通过向导预检、安装 Docker、部署、注册并验证自建 Relay", - "Preflight, install Docker, deploy, register, and verify a self-hosted relay through the wizard", - "登录、退出并查看账户状态和凭据提示", - "Sign in, sign out, and inspect account status and credential hints", + "使用 GitHub 登录、退出并查看身份状态", + "Sign in with GitHub, sign out, and inspect identity status", "列出、连接、查看在线状态和删除同账户设备", "List, connect, inspect online status, and remove same-account devices", - "同步、导出、导入、删除或发送会话到另一台设备", - "Sync, export, import, delete, or send sessions to another device", - "在设备间自动或手动同步 OpenBitFun 设置", - "Synchronize OpenBitFun settings across devices automatically or on demand", "进入 Peer Device Mode,把另一台 OpenBitFun 设备作为命令与事件数据面", "Enter Peer Device Mode and use another OpenBitFun device as the command and event data plane", "打开 Remote Connect", @@ -24402,12 +24221,12 @@ } ], "stepsZh": [ - "登录 OpenBitFun 账户", + "使用 GitHub 登录", "打开 Pages", "选择页面并确认发布与可见性" ], "stepsEn": [ - "Sign in to a OpenBitFun account", + "Sign in to a GitHub account", "Open Pages", "Choose a page and confirm publishing and visibility" ], diff --git a/src/crates/contracts/product-domains/src/generated/remote-surface-registry.json b/src/crates/contracts/product-domains/src/generated/remote-surface-registry.json index 51e7efbb6a..864000a584 100644 --- a/src/crates/contracts/product-domains/src/generated/remote-surface-registry.json +++ b/src/crates/contracts/product-domains/src/generated/remote-surface-registry.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "digest": "fnv1a64:255dceea3d2c1591", + "digest": "fnv1a64:4347c91266c12da9", "retiredCommandPrefixes": [ { "prefix": "lsp_", @@ -81,30 +81,6 @@ "reason": "the CLI peer host has no handler for this command" } }, - { - "id": "account_auto_sync", - "surface": "tauri_command", - "remoteWorkspace": "WorkspaceAgnostic", - "peer": { - "kind": "controller_local" - }, - "cliPeer": { - "kind": "unsupported", - "reason": "the controller keeps this command; peer hosts refuse it before dispatch" - } - }, - { - "id": "account_cancel_pending_login", - "surface": "tauri_command", - "remoteWorkspace": "WorkspaceAgnostic", - "peer": { - "kind": "controller_local" - }, - "cliPeer": { - "kind": "unsupported", - "reason": "the controller keeps this command; peer hosts refuse it before dispatch" - } - }, { "id": "account_connect_devices", "surface": "tauri_command", @@ -117,18 +93,6 @@ "reason": "the controller keeps this command; peer hosts refuse it before dispatch" } }, - { - "id": "account_delegate_to_paired", - "surface": "tauri_command", - "remoteWorkspace": "WorkspaceAgnostic", - "peer": { - "kind": "controller_local" - }, - "cliPeer": { - "kind": "unsupported", - "reason": "the controller keeps this command; peer hosts refuse it before dispatch" - } - }, { "id": "account_delete_device", "surface": "tauri_command", @@ -141,18 +105,6 @@ "reason": "the controller keeps this command; peer hosts refuse it before dispatch" } }, - { - "id": "account_delete_synced_session", - "surface": "tauri_command", - "remoteWorkspace": "WorkspaceAgnostic", - "peer": { - "kind": "controller_local" - }, - "cliPeer": { - "kind": "unsupported", - "reason": "the controller keeps this command; peer hosts refuse it before dispatch" - } - }, { "id": "account_device_rpc", "surface": "tauri_command", @@ -178,55 +130,7 @@ } }, { - "id": "account_export_all_sessions", - "surface": "tauri_command", - "remoteWorkspace": "WorkspaceAgnostic", - "peer": { - "kind": "controller_local" - }, - "cliPeer": { - "kind": "unsupported", - "reason": "the controller keeps this command; peer hosts refuse it before dispatch" - } - }, - { - "id": "account_export_local_session", - "surface": "tauri_command", - "remoteWorkspace": "WorkspaceAgnostic", - "peer": { - "kind": "controller_local" - }, - "cliPeer": { - "kind": "unsupported", - "reason": "the controller keeps this command; peer hosts refuse it before dispatch" - } - }, - { - "id": "account_fetch_session_turns", - "surface": "tauri_command", - "remoteWorkspace": "WorkspaceAgnostic", - "peer": { - "kind": "controller_local" - }, - "cliPeer": { - "kind": "unsupported", - "reason": "the controller keeps this command; peer hosts refuse it before dispatch" - } - }, - { - "id": "account_fetch_settings", - "surface": "tauri_command", - "remoteWorkspace": "WorkspaceAgnostic", - "peer": { - "kind": "controller_local" - }, - "cliPeer": { - "kind": "unsupported", - "reason": "the controller keeps this command; peer hosts refuse it before dispatch" - } - }, - { - "id": "account_fetch_synced_sessions", + "id": "account_get_credential_hint", "surface": "tauri_command", "remoteWorkspace": "WorkspaceAgnostic", "peer": { @@ -238,7 +142,7 @@ } }, { - "id": "account_finalize_login", + "id": "account_github_info", "surface": "tauri_command", "remoteWorkspace": "WorkspaceAgnostic", "peer": { @@ -250,7 +154,7 @@ } }, { - "id": "account_get_credential_hint", + "id": "account_github_poll", "surface": "tauri_command", "remoteWorkspace": "WorkspaceAgnostic", "peer": { @@ -262,7 +166,7 @@ } }, { - "id": "account_import_remote_sessions", + "id": "account_github_start", "surface": "tauri_command", "remoteWorkspace": "WorkspaceAgnostic", "peer": { @@ -321,18 +225,6 @@ "reason": "the controller keeps this command; peer hosts refuse it before dispatch" } }, - { - "id": "account_send_session_to_device", - "surface": "tauri_command", - "remoteWorkspace": "WorkspaceAgnostic", - "peer": { - "kind": "controller_local" - }, - "cliPeer": { - "kind": "unsupported", - "reason": "the controller keeps this command; peer hosts refuse it before dispatch" - } - }, { "id": "account_status", "surface": "tauri_command", @@ -345,30 +237,6 @@ "reason": "the controller keeps this command; peer hosts refuse it before dispatch" } }, - { - "id": "account_sync_session", - "surface": "tauri_command", - "remoteWorkspace": "WorkspaceAgnostic", - "peer": { - "kind": "controller_local" - }, - "cliPeer": { - "kind": "unsupported", - "reason": "the controller keeps this command; peer hosts refuse it before dispatch" - } - }, - { - "id": "account_sync_settings", - "surface": "tauri_command", - "remoteWorkspace": "WorkspaceAgnostic", - "peer": { - "kind": "controller_local" - }, - "cliPeer": { - "kind": "unsupported", - "reason": "the controller keeps this command; peer hosts refuse it before dispatch" - } - }, { "id": "account_token_expired", "surface": "tauri_command", @@ -4507,30 +4375,6 @@ "reason": "the CLI peer host has no handler for this command" } }, - { - "id": "miniapp_market_auth_poll", - "surface": "tauri_command", - "remoteWorkspace": "WorkspaceAgnostic", - "peer": { - "kind": "proxied" - }, - "cliPeer": { - "kind": "unsupported", - "reason": "the CLI peer host has no handler for this command" - } - }, - { - "id": "miniapp_market_auth_start", - "surface": "tauri_command", - "remoteWorkspace": "WorkspaceAgnostic", - "peer": { - "kind": "proxied" - }, - "cliPeer": { - "kind": "unsupported", - "reason": "the CLI peer host has no handler for this command" - } - }, { "id": "miniapp_market_browse", "surface": "tauri_command", @@ -4639,30 +4483,6 @@ "reason": "the CLI peer host has no handler for this command" } }, - { - "id": "miniapp_market_logout", - "surface": "tauri_command", - "remoteWorkspace": "WorkspaceAgnostic", - "peer": { - "kind": "proxied" - }, - "cliPeer": { - "kind": "unsupported", - "reason": "the CLI peer host has no handler for this command" - } - }, - { - "id": "miniapp_market_me", - "surface": "tauri_command", - "remoteWorkspace": "WorkspaceAgnostic", - "peer": { - "kind": "proxied" - }, - "cliPeer": { - "kind": "unsupported", - "reason": "the CLI peer host has no handler for this command" - } - }, { "id": "miniapp_market_set_favorite", "surface": "tauri_command", @@ -5318,90 +5138,6 @@ "reason": "the CLI peer host has no handler for this command" } }, - { - "id": "relay_deploy_cancel", - "surface": "tauri_command", - "remoteWorkspace": "WorkspaceAgnostic", - "peer": { - "kind": "controller_local" - }, - "cliPeer": { - "kind": "unsupported", - "reason": "the controller keeps this command; peer hosts refuse it before dispatch" - } - }, - { - "id": "relay_deploy_install_docker", - "surface": "tauri_command", - "remoteWorkspace": "WorkspaceAgnostic", - "peer": { - "kind": "controller_local" - }, - "cliPeer": { - "kind": "unsupported", - "reason": "the controller keeps this command; peer hosts refuse it before dispatch" - } - }, - { - "id": "relay_deploy_poll", - "surface": "tauri_command", - "remoteWorkspace": "WorkspaceAgnostic", - "peer": { - "kind": "controller_local" - }, - "cliPeer": { - "kind": "unsupported", - "reason": "the controller keeps this command; peer hosts refuse it before dispatch" - } - }, - { - "id": "relay_deploy_preflight", - "surface": "tauri_command", - "remoteWorkspace": "WorkspaceAgnostic", - "peer": { - "kind": "controller_local" - }, - "cliPeer": { - "kind": "unsupported", - "reason": "the controller keeps this command; peer hosts refuse it before dispatch" - } - }, - { - "id": "relay_deploy_register", - "surface": "tauri_command", - "remoteWorkspace": "WorkspaceAgnostic", - "peer": { - "kind": "controller_local" - }, - "cliPeer": { - "kind": "unsupported", - "reason": "the controller keeps this command; peer hosts refuse it before dispatch" - } - }, - { - "id": "relay_deploy_start", - "surface": "tauri_command", - "remoteWorkspace": "WorkspaceAgnostic", - "peer": { - "kind": "controller_local" - }, - "cliPeer": { - "kind": "unsupported", - "reason": "the controller keeps this command; peer hosts refuse it before dispatch" - } - }, - { - "id": "relay_deploy_verify", - "surface": "tauri_command", - "remoteWorkspace": "WorkspaceAgnostic", - "peer": { - "kind": "controller_local" - }, - "cliPeer": { - "kind": "unsupported", - "reason": "the controller keeps this command; peer hosts refuse it before dispatch" - } - }, { "id": "reload_config", "surface": "tauri_command", @@ -5473,18 +5209,6 @@ "reason": "the controller keeps this command; peer hosts refuse it before dispatch" } }, - { - "id": "remote_connect_configure_custom_server", - "surface": "tauri_command", - "remoteWorkspace": "WorkspaceAgnostic", - "peer": { - "kind": "controller_local" - }, - "cliPeer": { - "kind": "unsupported", - "reason": "the controller keeps this command; peer hosts refuse it before dispatch" - } - }, { "id": "remote_connect_get_bot_verbose_mode", "surface": "tauri_command", diff --git a/src/crates/contracts/product-domains/src/miniapp/market.rs b/src/crates/contracts/product-domains/src/miniapp/market.rs index 750037f1fc..5a9cc05ad2 100644 --- a/src/crates/contracts/product-domains/src/miniapp/market.rs +++ b/src/crates/contracts/product-domains/src/miniapp/market.rs @@ -57,13 +57,7 @@ pub enum MarketSort { Rating, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct MarketUserSummary { - pub github_id: i64, - pub login: String, - pub avatar_url: String, -} +pub use crate::account::GitHubUser as MarketUserSummary; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] diff --git a/src/crates/contracts/product-domains/src/remote_surface/mod.rs b/src/crates/contracts/product-domains/src/remote_surface/mod.rs index ec41af7967..3234e9789b 100644 --- a/src/crates/contracts/product-domains/src/remote_surface/mod.rs +++ b/src/crates/contracts/product-domains/src/remote_surface/mod.rs @@ -642,10 +642,12 @@ mod tests { for anchor in [ "show_main_window", "account_login", - "account_cancel_pending_login", + "account_github_start", + "account_github_poll", + "account_github_info", + "account_logout", "peer_mode_ping", "dispatch_submit", - "relay_deploy_start", "mark_openbitfun_control_surface_ready", ] { assert!( diff --git a/src/crates/contracts/product-domains/src/remote_surface/table.rs b/src/crates/contracts/product-domains/src/remote_surface/table.rs index 6725f75a70..571114d43c 100644 --- a/src/crates/contracts/product-domains/src/remote_surface/table.rs +++ b/src/crates/contracts/product-domains/src/remote_surface/table.rs @@ -87,30 +87,19 @@ pub(super) const OPERATIONS: &[OperationDefinition] = &[ op("accept_file", Unaudited, Proxied, CLI_NOT_IMPLEMENTED), op("accept_operation", Unaudited, Proxied, CLI_NOT_IMPLEMENTED), op("accept_session", Unaudited, Proxied, CLI_NOT_IMPLEMENTED), - op("account_auto_sync", Agnostic, ControllerLocal, REFUSED), - op("account_cancel_pending_login", Agnostic, ControllerLocal, REFUSED), op("account_connect_devices", Agnostic, ControllerLocal, REFUSED), - op("account_delegate_to_paired", Agnostic, ControllerLocal, REFUSED), op("account_delete_device", Agnostic, ControllerLocal, REFUSED), - op("account_delete_synced_session", Agnostic, ControllerLocal, REFUSED), op("account_device_rpc", Agnostic, ControllerLocal, REFUSED), op("account_execute_on_device", Agnostic, ControllerLocal, REFUSED), - op("account_export_all_sessions", Agnostic, ControllerLocal, REFUSED), - op("account_export_local_session", Agnostic, ControllerLocal, REFUSED), - op("account_fetch_session_turns", Agnostic, ControllerLocal, REFUSED), - op("account_fetch_settings", Agnostic, ControllerLocal, REFUSED), - op("account_fetch_synced_sessions", Agnostic, ControllerLocal, REFUSED), - op("account_finalize_login", Agnostic, ControllerLocal, REFUSED), op("account_get_credential_hint", Agnostic, ControllerLocal, REFUSED), - op("account_import_remote_sessions", Agnostic, ControllerLocal, REFUSED), + op("account_github_info", Agnostic, ControllerLocal, REFUSED), + op("account_github_poll", Agnostic, ControllerLocal, REFUSED), + op("account_github_start", Agnostic, ControllerLocal, REFUSED), op("account_list_devices", Agnostic, ControllerLocal, REFUSED), op("account_login", Agnostic, ControllerLocal, REFUSED), op("account_logout", Agnostic, ControllerLocal, REFUSED), op("account_online_devices", Agnostic, ControllerLocal, REFUSED), - op("account_send_session_to_device", Agnostic, ControllerLocal, REFUSED), op("account_status", Agnostic, ControllerLocal, REFUSED), - op("account_sync_session", Agnostic, ControllerLocal, REFUSED), - op("account_sync_settings", Agnostic, ControllerLocal, REFUSED), op("account_token_expired", Agnostic, ControllerLocal, REFUSED), op("acknowledge_external_ecosystems_command", Unsupported, Proxied, CLI_NOT_IMPLEMENTED), op("activate_session_goal", Unaudited, Proxied, CLI_NOT_IMPLEMENTED), @@ -461,8 +450,6 @@ pub(super) const OPERATIONS: &[OperationDefinition] = &[ op("miniapp_host_call", Unaudited, Proxied, CLI_NOT_IMPLEMENTED), op("miniapp_import_from_path", Unaudited, Proxied, CLI_NOT_IMPLEMENTED), op("miniapp_install_deps", Unaudited, Proxied, CLI_NOT_IMPLEMENTED), - op("miniapp_market_auth_poll", Agnostic, Proxied, CLI_NOT_IMPLEMENTED), - op("miniapp_market_auth_start", Agnostic, Proxied, CLI_NOT_IMPLEMENTED), op("miniapp_market_browse", Agnostic, Proxied, CLI_NOT_IMPLEMENTED), op("miniapp_market_capture_window", LocalOnly, Proxied, CLI_NOT_IMPLEMENTED), op("miniapp_market_get_listing", Agnostic, Proxied, CLI_NOT_IMPLEMENTED), @@ -472,8 +459,6 @@ pub(super) const OPERATIONS: &[OperationDefinition] = &[ op("miniapp_market_installed_origins", LocalOnly, Proxied, CLI_NOT_IMPLEMENTED), op("miniapp_market_installed_status", LocalOnly, Proxied, CLI_NOT_IMPLEMENTED), op("miniapp_market_list_submissions", Agnostic, Proxied, CLI_NOT_IMPLEMENTED), - op("miniapp_market_logout", Agnostic, Proxied, CLI_NOT_IMPLEMENTED), - op("miniapp_market_me", Agnostic, Proxied, CLI_NOT_IMPLEMENTED), op("miniapp_market_set_favorite", Agnostic, Proxied, CLI_NOT_IMPLEMENTED), op("miniapp_market_set_rating", Agnostic, Proxied, CLI_NOT_IMPLEMENTED), op("miniapp_market_submit_installed", LocalOnly, Proxied, CLI_NOT_IMPLEMENTED), @@ -529,20 +514,12 @@ pub(super) const OPERATIONS: &[OperationDefinition] = &[ op("refresh_subscription_account", LocalOnly, Proxied, CLI_NOT_IMPLEMENTED), op("reject_file", Unaudited, Proxied, CLI_NOT_IMPLEMENTED), op("reject_operation", Unaudited, Proxied, CLI_NOT_IMPLEMENTED), - op("relay_deploy_cancel", Agnostic, ControllerLocal, REFUSED), - op("relay_deploy_install_docker", Agnostic, ControllerLocal, REFUSED), - op("relay_deploy_poll", Agnostic, ControllerLocal, REFUSED), - op("relay_deploy_preflight", Agnostic, ControllerLocal, REFUSED), - op("relay_deploy_register", Agnostic, ControllerLocal, REFUSED), - op("relay_deploy_start", Agnostic, ControllerLocal, REFUSED), - op("relay_deploy_verify", Agnostic, ControllerLocal, REFUSED), op("reload_config", Unaudited, Proxied, HANDLED), op("reload_custom_agents", Unaudited, Proxied, CLI_NOT_IMPLEMENTED), op("reload_session_context", Routed, Proxied, CLI_NOT_IMPLEMENTED), op("reload_subagents", Unaudited, Proxied, CLI_NOT_IMPLEMENTED), op("remote_close_workspace", Routed, Proxied, CLI_NOT_IMPLEMENTED), op("remote_connect_configure_bot", Agnostic, ControllerLocal, REFUSED), - op("remote_connect_configure_custom_server", Agnostic, ControllerLocal, REFUSED), op("remote_connect_get_bot_verbose_mode", Agnostic, ControllerLocal, REFUSED), op("remote_connect_get_device_info", Agnostic, ControllerLocal, REFUSED), op("remote_connect_get_form_state", Agnostic, ControllerLocal, REFUSED), diff --git a/src/crates/interfaces/app-server-client/src/lib.rs b/src/crates/interfaces/app-server-client/src/lib.rs index 45bf106b5a..66a599dc6a 100644 --- a/src/crates/interfaces/app-server-client/src/lib.rs +++ b/src/crates/interfaces/app-server-client/src/lib.rs @@ -89,57 +89,34 @@ impl AppServerClient { self.rpc(|cx| Ok(cx.send_request(request))).await } - pub async fn account_login( - &self, - request: AccountLoginRequest, - ) -> Result { - self.request_with_timeout(|cx| Ok(cx.send_request(request)), SIDE_EFFECT_TIMEOUT) - .await - } - - pub async fn account_finalize_login( + pub async fn account_github_start( &self, - request: AccountFinalizeLoginRequest, - ) -> Result { + request: AccountGitHubStartRequest, + ) -> Result { self.request_with_timeout(|cx| Ok(cx.send_request(request)), SIDE_EFFECT_TIMEOUT) .await } - pub async fn account_logout( + pub async fn account_github_poll( &self, - request: AccountLogoutRequest, - ) -> Result { + request: AccountGitHubPollRequest, + ) -> Result { self.request_with_timeout(|cx| Ok(cx.send_request(request)), SIDE_EFFECT_TIMEOUT) .await } - pub async fn settings_sync_start( - &self, - request: SettingsSyncStartRequest, - ) -> Result { - self.request_with_timeout(|cx| Ok(cx.send_request(request)), SIDE_EFFECT_TIMEOUT) - .await - } - - pub async fn settings_sync_snapshot( - &self, - request: SettingsSyncSnapshotRequest, - ) -> agent_client_protocol::Result { - self.rpc(|cx| Ok(cx.send_request(request))).await - } - - pub async fn settings_sync_cancel( + pub async fn account_login( &self, - request: SettingsSyncCancelRequest, - ) -> Result { + request: AccountLoginRequest, + ) -> Result { self.request_with_timeout(|cx| Ok(cx.send_request(request)), SIDE_EFFECT_TIMEOUT) .await } - pub async fn settings_sync_local_changed( + pub async fn account_logout( &self, - request: SettingsSyncLocalChangedRequest, - ) -> Result { + request: AccountLogoutRequest, + ) -> Result { self.request_with_timeout(|cx| Ok(cx.send_request(request)), SIDE_EFFECT_TIMEOUT) .await } diff --git a/src/crates/interfaces/app-server-protocol/src/schemas/account.rs b/src/crates/interfaces/app-server-protocol/src/schemas/account.rs index a834027890..74f9f76ede 100644 --- a/src/crates/interfaces/app-server-protocol/src/schemas/account.rs +++ b/src/crates/interfaces/app-server-protocol/src/schemas/account.rs @@ -1,12 +1,10 @@ -//! Account and settings-sync App Server wire schemas. +//! Account App Server wire schemas. #[cfg(feature = "rpc")] use agent_client_protocol::{JsonRpcRequest, JsonRpcResponse}; use serde::{Deserialize, Serialize}; -pub use openbitfun_product_domains::account::{ - AccountDevice, AccountInfo, SettingsSyncProgress, SettingsSyncStatus, -}; +pub use openbitfun_product_domains::account::{AccountDevice, AccountInfo}; #[derive(Clone, Serialize, Deserialize)] #[cfg_attr(feature = "rpc", derive(JsonRpcRequest))] @@ -30,73 +28,54 @@ impl std::fmt::Debug for AccountSnapshotRequest { #[serde(rename_all = "camelCase")] pub struct AccountSnapshotResponse { pub logged_in: bool, - pub pending_sync_choice: bool, #[serde(default, skip_serializing_if = "Option::is_none")] pub info: Option, #[serde(default)] pub devices: Vec, - pub sync: SettingsSyncProgress, } -#[derive(Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[cfg_attr(feature = "rpc", derive(JsonRpcRequest))] -#[cfg_attr(feature = "rpc", request(method = "account/login", response = AccountLoginResponse))] +#[cfg_attr(feature = "rpc", request(method = "account/githubStart", response = AccountGitHubStartResponse))] #[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct AccountLoginRequest { - pub operation_id: String, - pub relay_url: String, - pub username: String, - pub password: String, +pub struct AccountGitHubStartRequest {} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcResponse))] +pub struct AccountGitHubStartResponse { + #[serde(flatten)] + pub authorization: openbitfun_product_domains::account::GitHubAuthStart, } -impl std::fmt::Debug for AccountLoginRequest { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - formatter - .debug_struct("AccountLoginRequest") - .field("operation_id", &self.operation_id) - .field("relay_url", &"") - .field("username", &"") - .field("password", &"") - .finish() - } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcRequest))] +#[cfg_attr(feature = "rpc", request(method = "account/githubPoll", response = AccountGitHubPollResponse))] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct AccountGitHubPollRequest { + pub transaction_id: String, } #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "rpc", derive(JsonRpcResponse))] -#[serde(rename_all = "camelCase")] -pub struct AccountLoginResponse { - pub user_id: String, - pub relay_url: String, - pub has_cloud_settings: bool, - pub status_message: String, +pub struct AccountGitHubPollResponse { + pub status: String, } -#[derive(Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "rpc", derive(JsonRpcRequest))] -#[cfg_attr(feature = "rpc", request(method = "account/finalizeLogin", response = AccountSnapshotResponse))] +#[cfg_attr(feature = "rpc", request(method = "account/login", response = AccountLoginResponse))] #[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct AccountFinalizeLoginRequest { +pub struct AccountLoginRequest { pub operation_id: String, - pub choice: AccountSyncChoice, - pub workspace_path: String, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "rpc", derive(JsonRpcResponse))] #[serde(rename_all = "camelCase")] -pub enum AccountSyncChoice { - Local, - Cloud, -} - -impl std::fmt::Debug for AccountFinalizeLoginRequest { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - formatter - .debug_struct("AccountFinalizeLoginRequest") - .field("operation_id", &self.operation_id) - .field("choice", &self.choice) - .field("workspace_path", &"") - .finish() - } +pub struct AccountLoginResponse { + pub user_id: String, + pub relay_url: String, + pub status_message: String, } #[derive(Clone, Serialize, Deserialize)] @@ -118,115 +97,20 @@ impl std::fmt::Debug for AccountLogoutRequest { } } -#[derive(Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "rpc", derive(JsonRpcRequest))] -#[cfg_attr(feature = "rpc", request(method = "settingsSync/start", response = SettingsSyncResponse))] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct SettingsSyncStartRequest { - pub operation_id: String, - pub workspace_path: String, - pub is_first_login: bool, -} - -impl std::fmt::Debug for SettingsSyncStartRequest { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - formatter - .debug_struct("SettingsSyncStartRequest") - .field("operation_id", &self.operation_id) - .field("workspace_path", &"") - .field("is_first_login", &self.is_first_login) - .finish() - } -} - -#[derive(Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "rpc", derive(JsonRpcRequest))] -#[cfg_attr(feature = "rpc", request(method = "settingsSync/snapshot", response = SettingsSyncResponse))] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct SettingsSyncSnapshotRequest { - pub workspace_path: String, -} - -impl std::fmt::Debug for SettingsSyncSnapshotRequest { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - formatter - .debug_struct("SettingsSyncSnapshotRequest") - .field("workspace_path", &"") - .finish() - } -} - -#[derive(Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "rpc", derive(JsonRpcRequest))] -#[cfg_attr(feature = "rpc", request(method = "settingsSync/cancel", response = SettingsSyncResponse))] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct SettingsSyncCancelRequest { - pub operation_id: String, - pub workspace_path: String, -} - -impl std::fmt::Debug for SettingsSyncCancelRequest { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - formatter - .debug_struct("SettingsSyncCancelRequest") - .field("operation_id", &self.operation_id) - .field("workspace_path", &"") - .finish() - } -} - -#[derive(Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "rpc", derive(JsonRpcRequest))] -#[cfg_attr(feature = "rpc", request(method = "settingsSync/localChanged", response = SettingsSyncResponse))] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct SettingsSyncLocalChangedRequest { - pub operation_id: String, - pub workspace_path: String, -} - -impl std::fmt::Debug for SettingsSyncLocalChangedRequest { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - formatter - .debug_struct("SettingsSyncLocalChangedRequest") - .field("operation_id", &self.operation_id) - .field("workspace_path", &"") - .finish() - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "rpc", derive(JsonRpcResponse))] -#[serde(rename_all = "camelCase")] -pub struct SettingsSyncResponse { - pub progress: SettingsSyncProgress, -} - #[cfg(test)] mod tests { use super::*; #[test] - fn credential_debug_does_not_expose_secrets_or_paths() { - let request = AccountLoginRequest { - operation_id: "account-op-1".to_string(), - relay_url: "https://secret.example".to_string(), - username: "alice".to_string(), - password: "password-value".to_string(), - }; - let debug = format!("{request:?}"); - assert!(!debug.contains("secret.example")); - assert!(!debug.contains("alice")); - assert!(!debug.contains("password-value")); - assert!(debug.contains("account-op-1")); - - let finalize = AccountFinalizeLoginRequest { - operation_id: "account-op-2".to_string(), - choice: AccountSyncChoice::Cloud, - workspace_path: "C:/private/workspace".to_string(), - }; - let debug = format!("{finalize:?}"); - assert!(!debug.contains("private/workspace")); - assert!(debug.contains("account-op-2")); + fn login_accepts_no_password_or_relay_configuration() { + assert!(serde_json::from_value::( + serde_json::json!({"operationId":"op"}) + ) + .is_ok()); + assert!(serde_json::from_value::( + serde_json::json!({"operationId":"op","password":"secret"}) + ) + .is_err()); } #[test] @@ -234,26 +118,11 @@ mod tests { for method in [ "account/snapshot", "account/login", - "account/finalizeLogin", "account/logout", - "settingsSync/start", - "settingsSync/snapshot", - "settingsSync/cancel", - "settingsSync/localChanged", + "account/githubStart", + "account/githubPoll", ] { assert!(crate::method::is_valid_method_name(method)); } } - - #[test] - fn settings_sync_progress_carries_operation_identity_and_cancel_state() { - let progress = SettingsSyncProgress { - operation_id: Some("account-op-3".to_string()), - status: SettingsSyncStatus::Cancelled, - ..Default::default() - }; - let value = serde_json::to_value(progress).expect("serialize progress"); - assert_eq!(value["operationId"], "account-op-3"); - assert_eq!(value["status"], "cancelled"); - } } diff --git a/src/crates/interfaces/app-server/src/management.rs b/src/crates/interfaces/app-server/src/management.rs index e1607fbbfd..36ae2eef5e 100644 --- a/src/crates/interfaces/app-server/src/management.rs +++ b/src/crates/interfaces/app-server/src/management.rs @@ -18,7 +18,6 @@ pub const EXTERNAL_SOURCES_CAPABILITY: &str = "tui.externalSources"; pub const NATIVE_HOOKS_CAPABILITY: &str = "tui.nativeHooks"; pub const EXTERNAL_HOOKS_CAPABILITY: &str = "tui.externalHooks"; pub const ACCOUNT_CAPABILITY: &str = "tui.account"; -pub const SETTINGS_SYNC_CAPABILITY: &str = "tui.settingsSync"; pub const WORKTREES_CAPABILITY: &str = "tui.worktrees"; #[derive(Debug, Clone, PartialEq, Eq)] @@ -32,7 +31,6 @@ pub struct AppManagementCapabilities { pub native_hooks: CapabilityAvailability, pub external_hooks: CapabilityAvailability, pub account: CapabilityAvailability, - pub settings_sync: CapabilityAvailability, pub worktrees: CapabilityAvailability, } @@ -48,7 +46,6 @@ impl AppManagementCapabilities { native_hooks: CapabilityAvailability::Available, external_hooks: CapabilityAvailability::Available, account: CapabilityAvailability::Available, - settings_sync: CapabilityAvailability::Available, worktrees: CapabilityAvailability::Available, } } @@ -65,7 +62,6 @@ impl AppManagementCapabilities { native_hooks: unavailable(&reason), external_hooks: unavailable(&reason), account: unavailable(&reason), - settings_sync: unavailable(&reason), worktrees: unavailable(&reason), } } @@ -81,7 +77,6 @@ impl AppManagementCapabilities { NATIVE_HOOKS_CAPABILITY => Some(&self.native_hooks), EXTERNAL_HOOKS_CAPABILITY => Some(&self.external_hooks), ACCOUNT_CAPABILITY => Some(&self.account), - SETTINGS_SYNC_CAPABILITY => Some(&self.settings_sync), WORKTREES_CAPABILITY => Some(&self.worktrees), _ => None, } @@ -156,22 +151,7 @@ impl AppManagementCapabilities { descriptor( ACCOUNT_CAPABILITY, self.account.clone(), - &[ - "account/snapshot", - "account/login", - "account/finalizeLogin", - "account/logout", - ], - ), - descriptor( - SETTINGS_SYNC_CAPABILITY, - self.settings_sync.clone(), - &[ - "settingsSync/start", - "settingsSync/snapshot", - "settingsSync/cancel", - "settingsSync/localChanged", - ], + &["account/snapshot", "account/login", "account/logout"], ), descriptor( WORKTREES_CAPABILITY, diff --git a/src/crates/interfaces/app-server/src/management/owner.rs b/src/crates/interfaces/app-server/src/management/owner.rs index 65fb3f9dd3..48a69e4058 100644 --- a/src/crates/interfaces/app-server/src/management/owner.rs +++ b/src/crates/interfaces/app-server/src/management/owner.rs @@ -19,13 +19,11 @@ use openbitfun_app_server_protocol::worktree::*; use openbitfun_core::service::config::model_projection::{ model_catalog_projection, model_edit_projection, model_list_projection, selector_is_unset, }; -use openbitfun_core::service::remote_connect::account_runtime::{ - AccountRuntime, AccountSyncProgress, AccountSyncStatus, -}; +use openbitfun_core::service::remote_connect::account_runtime::AccountRuntime; use super::{ AppManagementCapabilities, AppManagementError, AppManagementResult, ACCOUNT_CAPABILITY, - SETTINGS_SYNC_CAPABILITY, WORKTREES_CAPABILITY, + WORKTREES_CAPABILITY, }; /// Management adapter shared by App Server, Embedded, and local Shared Hosts. @@ -751,8 +749,6 @@ impl AppManagementService { openbitfun_app_server_protocol::app::CapabilityAvailability::Unavailable { reason: reason.clone(), }; - capabilities.settings_sync = - openbitfun_app_server_protocol::app::CapabilityAvailability::Unavailable { reason }; } if !self.local_worktrees_enabled { capabilities.worktrees = @@ -797,6 +793,32 @@ impl AppManagementService { )) } + pub async fn account_github_start( + &self, + _request: AccountGitHubStartRequest, + ) -> AppManagementResult { + let authorization = self + .account_runtime(ACCOUNT_CAPABILITY)? + .start_github_auth() + .await + .map_err(internal_account_error)?; + Ok(AccountGitHubStartResponse { authorization }) + } + + pub async fn account_github_poll( + &self, + request: AccountGitHubPollRequest, + ) -> AppManagementResult { + let result = self + .account_runtime(ACCOUNT_CAPABILITY)? + .poll_github_auth(request.transaction_id) + .await + .map_err(internal_account_error)?; + Ok(AccountGitHubPollResponse { + status: result.status, + }) + } + pub async fn account_login( &self, request: AccountLoginRequest, @@ -804,43 +826,17 @@ impl AppManagementService { validate_account_operation_id(&request.operation_id)?; let result = self .account_runtime(ACCOUNT_CAPABILITY)? - .login_with_credentials(&request.relay_url, &request.username, &request.password) + .login_with_identity() .await - .map_err(|error| account_error(error, &request))?; + .map_err(|error| internal_account_error(error))?; let status_message = account_login_status_message(&result); Ok(AccountLoginResponse { user_id: result.user_id, relay_url: result.relay_url, - has_cloud_settings: result.has_cloud_settings, status_message, }) } - pub async fn account_finalize_login( - &self, - request: AccountFinalizeLoginRequest, - ) -> AppManagementResult { - validate_account_operation_id(&request.operation_id)?; - let account = self.account_runtime(ACCOUNT_CAPABILITY)?; - account - .finalize_login_after_sync_choice() - .await - .map_err(internal_account_error)?; - if !account - .start_auto_sync_background( - request.operation_id, - request.choice == AccountSyncChoice::Local, - PathBuf::from(request.workspace_path), - ) - .await - { - return Err(AppManagementError::invalid_request( - "Account settings sync is already in progress", - )); - } - Ok(project_account_snapshot(account.snapshot().await)) - } - pub async fn account_logout( &self, request: AccountLogoutRequest, @@ -848,79 +844,9 @@ impl AppManagementService { validate_account_operation_id(&request.operation_id)?; let account = self.account_runtime(ACCOUNT_CAPABILITY)?; account.logout().await.map_err(internal_account_error)?; - account.mark_sync_cancelled(request.operation_id).await; Ok(project_account_snapshot(account.snapshot().await)) } - pub async fn settings_sync_start( - &self, - request: SettingsSyncStartRequest, - ) -> AppManagementResult { - validate_account_operation_id(&request.operation_id)?; - let account = self.account_runtime(SETTINGS_SYNC_CAPABILITY)?; - if !account.is_logged_in().await { - return Err(AppManagementError::invalid_request( - "Account login must be finalized before settings sync starts", - )); - } - if !account - .start_auto_sync_background( - request.operation_id, - request.is_first_login, - PathBuf::from(request.workspace_path), - ) - .await - { - return Err(AppManagementError::invalid_request( - "Account settings sync is already in progress", - )); - } - Ok(SettingsSyncResponse { - progress: project_sync_progress(account.current_sync_progress().await), - }) - } - - pub async fn settings_sync_snapshot( - &self, - request: SettingsSyncSnapshotRequest, - ) -> AppManagementResult { - let _ = request; - let progress = self - .account_runtime(SETTINGS_SYNC_CAPABILITY)? - .current_sync_progress() - .await; - Ok(SettingsSyncResponse { - progress: project_sync_progress(progress), - }) - } - - pub async fn settings_sync_cancel( - &self, - request: SettingsSyncCancelRequest, - ) -> AppManagementResult { - validate_account_operation_id(&request.operation_id)?; - let progress = self - .account_runtime(SETTINGS_SYNC_CAPABILITY)? - .cancel_sync(request.operation_id) - .await - .map_err(internal_account_error)?; - Ok(SettingsSyncResponse { - progress: project_sync_progress(progress), - }) - } - - pub async fn settings_sync_local_changed( - &self, - request: SettingsSyncLocalChangedRequest, - ) -> AppManagementResult { - validate_account_operation_id(&request.operation_id)?; - let account = self.account_runtime(SETTINGS_SYNC_CAPABILITY)?; - account.notify_local_settings_changed(); - Ok(SettingsSyncResponse { - progress: project_sync_progress(account.current_sync_progress().await), - }) - } - pub async fn native_hook_overview( &self, request: NativeHookOverviewRequest, @@ -1667,7 +1593,6 @@ fn project_account_snapshot( ) -> AccountSnapshotResponse { AccountSnapshotResponse { logged_in: snapshot.logged_in, - pending_sync_choice: snapshot.pending_sync_choice, info: snapshot.info.map(|info| AccountInfo { user_id: info.user_id, relay_url: info.relay_url, @@ -1683,40 +1608,12 @@ fn project_account_snapshot( online: device.online, }) .collect(), - sync: project_sync_progress(snapshot.sync), - } -} - -fn project_sync_progress(progress: AccountSyncProgress) -> SettingsSyncProgress { - SettingsSyncProgress { - operation_id: progress.operation_id, - status: match progress.status { - AccountSyncStatus::Idle => SettingsSyncStatus::Idle, - AccountSyncStatus::Syncing => SettingsSyncStatus::Syncing, - AccountSyncStatus::Done => SettingsSyncStatus::Done, - AccountSyncStatus::Failed => SettingsSyncStatus::Failed, - AccountSyncStatus::Cancelled => SettingsSyncStatus::Cancelled, - }, - phase: progress.phase, - percent: progress.percent, - current: progress.current, - total: progress.total, - detail: progress.detail, - error: progress.error, - settings_synced: progress.settings_synced, - sessions_exported: progress.sessions_exported, } } fn account_login_status_message( result: &openbitfun_core::service::remote_connect::account_runtime::AccountLoginResult, ) -> String { - if result.has_cloud_settings { - return format!( - "Authenticated as user {} on {}. Choose cloud or local settings to finish login.", - result.user_id, result.relay_url - ); - } if result.routing_connected { format!( "Logged in as user {} on {}. Device routing connected.", @@ -1748,16 +1645,6 @@ fn validate_account_operation_id(operation_id: &str) -> AppManagementResult<()> .ok_or_else(|| AppManagementError::invalid_request("Account operation ID is invalid")) } -fn account_error(error: anyhow::Error, request: &AccountLoginRequest) -> AppManagementError { - let mut message = error.to_string(); - for secret in [&request.relay_url, &request.username, &request.password] { - if !secret.is_empty() { - message = message.replace(secret, ""); - } - } - AppManagementError::internal(bounded_error(message)) -} - fn internal_account_error(error: anyhow::Error) -> AppManagementError { AppManagementError::internal(bounded_error(error.to_string())) } diff --git a/src/crates/interfaces/app-server/src/server/handlers/account.rs b/src/crates/interfaces/app-server/src/server/handlers/account.rs index 6d25facfe2..bfad248376 100644 --- a/src/crates/interfaces/app-server/src/server/handlers/account.rs +++ b/src/crates/interfaces/app-server/src/server/handlers/account.rs @@ -4,7 +4,7 @@ use agent_client_protocol::{Builder, HandleDispatchFrom}; use openbitfun_app_server_protocol::account::*; use super::capability::management_handler; -use crate::management::{AppManagementService, ACCOUNT_CAPABILITY, SETTINGS_SYNC_CAPABILITY}; +use crate::management::{AppManagementService, ACCOUNT_CAPABILITY}; use crate::role::{AppClient, AppServer}; pub(in crate::server) fn builder( @@ -12,13 +12,13 @@ pub(in crate::server) fn builder( ) -> Builder> { AppServer .builder() - .name("account and settings sync handlers") + .name("account handlers") .on_receive_request( management_handler!( management, ACCOUNT_CAPABILITY, - AccountSnapshotRequest, - account_snapshot + AccountGitHubStartRequest, + account_github_start ), agent_client_protocol::on_receive_request!(), ) @@ -26,8 +26,8 @@ pub(in crate::server) fn builder( management_handler!( management, ACCOUNT_CAPABILITY, - AccountLoginRequest, - account_login + AccountGitHubPollRequest, + account_github_poll ), agent_client_protocol::on_receive_request!(), ) @@ -35,8 +35,8 @@ pub(in crate::server) fn builder( management_handler!( management, ACCOUNT_CAPABILITY, - AccountFinalizeLoginRequest, - account_finalize_login + AccountSnapshotRequest, + account_snapshot ), agent_client_protocol::on_receive_request!(), ) @@ -44,44 +44,17 @@ pub(in crate::server) fn builder( management_handler!( management, ACCOUNT_CAPABILITY, - AccountLogoutRequest, - account_logout - ), - agent_client_protocol::on_receive_request!(), - ) - .on_receive_request( - management_handler!( - management, - SETTINGS_SYNC_CAPABILITY, - SettingsSyncStartRequest, - settings_sync_start - ), - agent_client_protocol::on_receive_request!(), - ) - .on_receive_request( - management_handler!( - management, - SETTINGS_SYNC_CAPABILITY, - SettingsSyncSnapshotRequest, - settings_sync_snapshot - ), - agent_client_protocol::on_receive_request!(), - ) - .on_receive_request( - management_handler!( - management, - SETTINGS_SYNC_CAPABILITY, - SettingsSyncCancelRequest, - settings_sync_cancel + AccountLoginRequest, + account_login ), agent_client_protocol::on_receive_request!(), ) .on_receive_request( management_handler!( management, - SETTINGS_SYNC_CAPABILITY, - SettingsSyncLocalChangedRequest, - settings_sync_local_changed + ACCOUNT_CAPABILITY, + AccountLogoutRequest, + account_logout ), agent_client_protocol::on_receive_request!(), ) diff --git a/src/crates/services/legacy-migration-adapters/src/remote_connect.rs b/src/crates/services/legacy-migration-adapters/src/remote_connect.rs index 29201ee64b..77dd02069b 100644 --- a/src/crates/services/legacy-migration-adapters/src/remote_connect.rs +++ b/src/crates/services/legacy-migration-adapters/src/remote_connect.rs @@ -13,9 +13,9 @@ use openbitfun_product_domains::legacy_migration::{ }; use openbitfun_services_integrations::remote_persistence as owner; use owner::{ - AccountHintRecord, AccountSessionRecord, AccountSyncStateRecord, BotChatStateRecord, - BotConfigRecord, BotPersistenceRecord, LegacyAccountSessionKeyDomains, MachineBinding, - RemoteConnectFormStateRecord, SavedBotConnectionRecord, SettingsCursorRecord, + AccountHintRecord, AccountSessionRecord, BotChatStateRecord, BotConfigRecord, + BotPersistenceRecord, LegacyAccountSessionKeyDomains, MachineBinding, + RemoteConnectFormStateRecord, SavedBotConnectionRecord, }; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; @@ -84,8 +84,6 @@ struct RemoteConnectState { device: Option, account_session_present: bool, account_hint: Option, - sync_states: BTreeMap, - settings_cursors: BTreeMap, bot: Option, bot_source_kind: BotSourceKind, bot_unresolved: bool, @@ -127,7 +125,7 @@ impl LegacyDomainAdapter for RemoteConnectAdapter { logical_bytes: total_bytes(&roots.legacy_home_root, source.files.keys())?, source_schema: Some(SOURCE_SCHEMA.to_string()), migratable: !source.bot_unresolved || source.files.len() > 1, - detail: "Legacy Remote Connect identity, account, sync, and bot stores were inspected without exposing credentials.".to_string(), + detail: "Legacy Remote Connect identity, account, and bot stores were inspected without exposing credentials.".to_string(), }, conflicts: preview.conflicts, target_schema: Some(TARGET_SCHEMA.to_string()), @@ -506,28 +504,10 @@ fn apply_merge( } else { None }; - let effective_session = if target.account_session_present { - target_session.as_ref() - } else if let Some(session) = &source_session { - owner::write_current_account_session(target_root, &binding, session) - .map_err(owner_error)?; - Some(session) - } else { - None - }; - if let (Some(source_session), Some(effective_session)) = - (source_session.as_ref(), effective_session) - { - if same_account(source_session, effective_session) { - merge_account_sync(context, source_session, target, outcome)?; - } else { - outcome.skipped = outcome - .skipped - .saturating_add(source.sync_states.len() as u64); - outcome.warnings.push(warning( - "account_sync_different_account_skipped", - "Legacy account sync cursors were not applied to a different current account.", - )); + if !target.account_session_present { + if let Some(session) = &source_session { + owner::write_current_account_session(target_root, &binding, session) + .map_err(owner_error)?; } } @@ -558,74 +538,6 @@ fn apply_merge( Ok(()) } -fn merge_account_sync( - context: &DomainContext<'_>, - session: &AccountSessionRecord, - target: &RemoteConnectState, - outcome: &mut RemoteConnectOutcome, -) -> LegacyMigrationResult<()> { - let Some(component) = owner::safe_account_file_component(&session.user_id) else { - outcome.warnings.push(warning( - "account_sync_invalid_user_id", - "Account sync cursors were skipped because their safe owner filename could not be resolved.", - )); - return Ok(()); - }; - let state_name = format!("{component}.json"); - let settings_name = format!("{component}.settings.json"); - let source_root = context.roots.legacy_home_root.join("account_sync"); - let target_root = context.roots.target_home_root.join("account_sync"); - if let Some(source_state) = strict_sync_state( - &source_root.join(&state_name), - &context.roots.legacy_home_root, - )? { - let merged = merge_sync_state( - source_state, - target - .sync_states - .get(&state_name) - .cloned() - .unwrap_or_default(), - ); - owner::write_account_sync_state(&target_root.join(&state_name), &merged) - .map_err(owner_error)?; - outcome.imported = outcome.imported.saturating_add(1); - } - if let Some(source_cursor) = strict_settings_cursor( - &source_root.join(&settings_name), - &context.roots.legacy_home_root, - )? { - let merged = choose_settings_cursor( - source_cursor, - target.settings_cursors.get(&settings_name).cloned(), - ); - owner::write_settings_cursor(&target_root.join(&settings_name), &merged) - .map_err(owner_error)?; - outcome.imported = outcome.imported.saturating_add(1); - } - Ok(()) -} - -fn merge_sync_state( - source: AccountSyncStateRecord, - mut target: AccountSyncStateRecord, -) -> AccountSyncStateRecord { - target.last_session_since = target.last_session_since.max(source.last_session_since); - for (session_id, hash) in source.uploaded_hashes { - target.uploaded_hashes.entry(session_id).or_insert(hash); - } - target -} - -fn choose_settings_cursor( - source: SettingsCursorRecord, - target: Option, -) -> SettingsCursorRecord { - target - .filter(|cursor| cursor.version >= source.version) - .unwrap_or(source) -} - fn same_account(left: &AccountSessionRecord, right: &AccountSessionRecord) -> bool { left.user_id == right.user_id && normalized_url(&left.relay_url) == normalized_url(&right.relay_url) @@ -733,7 +645,6 @@ fn merge_form_state( source: &RemoteConnectFormStateRecord, target: &mut RemoteConnectFormStateRecord, ) { - fill_empty(&mut target.custom_server_url, &source.custom_server_url); fill_empty(&mut target.telegram_bot_token, &source.telegram_bot_token); fill_empty(&mut target.feishu_app_id, &source.feishu_app_id); fill_empty(&mut target.feishu_app_secret, &source.feishu_app_secret); @@ -823,49 +734,6 @@ fn read_state(root: &Path, legacy: bool) -> LegacyMigrationResult MAX_REMOTE_FILES { - return Err(LegacyMigrationError::ResourceLimit( - "account sync file count exceeds the migration limit".to_string(), - )); - } - for path in paths { - if !source_file_exists(root, &path, MAX_JSON_BYTES, legacy, &mut state.omitted)? { - continue; - } - let name = path - .file_name() - .and_then(|name| name.to_str()) - .ok_or_else(|| { - LegacyMigrationError::InvalidRequest( - "account sync filename is not UTF-8".to_string(), - ) - })? - .to_string(); - record_file(root, &path, &mut state.files)?; - if name.ends_with(".settings.json") { - if let Some(value) = source_optional( - owner::read_settings_cursor(&path).map_err(owner_error), - legacy, - &format!("account_sync/{name}"), - &mut state.omitted, - )? { - state.settings_cursors.insert(name, value); - } - } else if let Some(value) = source_optional( - owner::read_account_sync_state(&path).map_err(owner_error), - legacy, - &format!("account_sync/{name}"), - &mut state.omitted, - )? { - state.sync_states.insert(name, value); - } - record_file(root, &path, &mut state.files)?; - } - } - let canonical = root.join("remote_connect_persistence.json"); let backup = root.join("remote_connect_persistence.json.bak"); let fallback = root.join("bot_connections.json"); @@ -961,26 +829,6 @@ fn active_weixin_ids(bot: &BotPersistenceRecord) -> LegacyMigrationResult LegacyMigrationResult> { - if !existing_regular(root, path, MAX_JSON_BYTES)? { - return Ok(None); - } - owner::read_account_sync_state(path).map_err(owner_error) -} - -fn strict_settings_cursor( - path: &Path, - root: &Path, -) -> LegacyMigrationResult> { - if !existing_regular(root, path, MAX_JSON_BYTES)? { - return Ok(None); - } - owner::read_settings_cursor(path).map_err(owner_error) -} - fn target_candidates(source: &RemoteConnectState, target: &RemoteConnectState) -> BTreeSet { let mut paths = target.files.keys().cloned().collect::>(); paths.extend([ @@ -990,13 +838,6 @@ fn target_candidates(source: &RemoteConnectState, target: &RemoteConnectState) - "account_session.key".to_string(), "remote_connect_persistence.json".to_string(), ]); - for name in source - .sync_states - .keys() - .chain(source.settings_cursors.keys()) - { - paths.insert(format!("account_sync/{name}")); - } for account_id in source.weixin_sync.keys().chain(source.weixin_tokens.keys()) { paths.insert(format!("weixin/{account_id}_get_updates_buf.txt")); paths.insert(format!("weixin/{account_id}_context_tokens.json")); @@ -1144,8 +985,6 @@ fn source_entity_count(state: &RemoteConnectState) -> u64 { u64::from(state.device.is_some()) + u64::from(state.account_session_present) + u64::from(state.account_hint.is_some()) - + state.sync_states.len() as u64 - + state.settings_cursors.len() as u64 + state .bot .as_ref() @@ -1537,41 +1376,6 @@ mod tests { assert_eq!(bot.connections.len(), 1); } - #[test] - fn account_sync_merge_keeps_target_hashes_and_whole_version_pairs() { - let source = AccountSyncStateRecord { - last_session_since: 9, - uploaded_hashes: std::collections::HashMap::from([ - ("shared".to_string(), "source-hash".to_string()), - ("source-only".to_string(), "source-only-hash".to_string()), - ]), - }; - let target = AccountSyncStateRecord { - last_session_since: 7, - uploaded_hashes: std::collections::HashMap::from([( - "shared".to_string(), - "target-hash".to_string(), - )]), - }; - let merged = merge_sync_state(source, target); - assert_eq!(merged.last_session_since, 9); - assert_eq!(merged.uploaded_hashes["shared"], "target-hash"); - assert_eq!(merged.uploaded_hashes["source-only"], "source-only-hash"); - - let selected = choose_settings_cursor( - SettingsCursorRecord { - version: 4, - hash: "source-pair".to_string(), - }, - Some(SettingsCursorRecord { - version: 6, - hash: "target-pair".to_string(), - }), - ); - assert_eq!(selected.version, 6); - assert_eq!(selected.hash, "target-pair"); - } - struct CrashOnce { point: CrashPoint, fired: AtomicBool, @@ -1590,6 +1394,7 @@ mod tests { ) -> BotPersistenceRecord { BotPersistenceRecord { connections: vec![SavedBotConnectionRecord { + account_user_id: String::new(), bot_type: "telegram".to_string(), chat_id: "chat-1".to_string(), config: BotConfigRecord::Telegram { diff --git a/src/crates/services/miniapp-market-service/README.md b/src/crates/services/miniapp-market-service/README.md index dc7a030a6b..33d775a728 100644 --- a/src/crates/services/miniapp-market-service/README.md +++ b/src/crates/services/miniapp-market-service/README.md @@ -55,7 +55,8 @@ link、重复/大小写冲突路径和超限解压。 - GitHub token 只用于读取公开 `{id,login,avatar_url}`,随后丢弃,不能下发给 Web 或桌面客户端。 -- MiniApp 服务是 MiniApp 与 Skin 两个市场唯一的 GitHub 身份权威。Web 登录会为 +- 本服务是 MiniApp、Skin 和远控共用的 GitHub 身份权威,通过 `auth.openbitfun.com` + 提供统一入口。Web 和桌面 OAuth 完成都为 `/miniapp` 与 `/skin` 签发同一服务端 session 的独立 Path-scoped Cookie;Skin 不保存 OAuth secret,退出登录必须撤销 session 并清除两组 Cookie。 - 管理员身份每次请求按 GitHub 数字 ID 计算,不能依赖客户端声明。 @@ -63,6 +64,19 @@ 拒绝 Web Cookie 会话;Desktop Bearer 投稿、投稿历史读取和 Web 管理员审核 保持可用。UI 隐藏不是这一边界的替代品。 +## 全局身份入口资源限制 + +GitHub 授权启动与回调分别限制为每分钟 300 次,避免启动请求挤占完成授权的容量; +认证请求最多并发 128 个,POST body 上限 16 KiB,读取期限 10 秒、处理期限 45 秒。 +GitHub HTTP 连接 / 总期限为 10 / 20 秒,单个 JSON 响应不超过 64 KiB。 +公开部署还需按真实来源配置反向代理限流和上游防护,不能把所有 Relay 代理用户 +误识别成同一个终端 IP。 + +待完成 OAuth flow 和未过期桌面授权事务各有 8,192 条数据库原子上限;桌面事务 +与对应 OAuth flow 一起提交,拒绝新授权时不会留下半条记录。每五分钟清理过期 +认证状态,桌面授权事务在过期后一小时删除。用户、投稿和其他产品数据不在此清理 +范围内;尚未过期的已撤销 refresh token 继续保留,用于发现重放并撤销令牌族。 + ## 当前投稿入口与鉴权矩阵 生产默认 `MARKET_WEB_SUBMISSIONS_ENABLED=false`。该开关只控制普通用户的投稿 diff --git a/src/crates/services/miniapp-market-service/src/auth.rs b/src/crates/services/miniapp-market-service/src/auth.rs index 06a6fc95e5..b305b87d8f 100644 --- a/src/crates/services/miniapp-market-service/src/auth.rs +++ b/src/crates/services/miniapp-market-service/src/auth.rs @@ -20,6 +20,7 @@ const SKIN_CSRF_COOKIE: &str = "openbitfun_skin_csrf"; const MINIAPP_COOKIE_PATH: &str = "/miniapp"; const SKIN_COOKIE_PATH: &str = "/skin"; const OAUTH_FLOW_MINUTES: i64 = 10; +const MAX_ACTIVE_OAUTH_FLOWS: i64 = 8192; const WEB_SESSION_DAYS: i64 = 7; const ACCESS_TOKEN_MINUTES: i64 = 15; const REFRESH_TOKEN_DAYS: i64 = 30; @@ -80,7 +81,11 @@ pub(crate) enum CompletedOAuth { csrf_token: String, expires_at: i64, }, - Desktop, + Desktop { + session_token: String, + csrf_token: String, + expires_at: i64, + }, } #[derive(Debug, Clone, Serialize)] @@ -151,6 +156,8 @@ impl AuthService { let client = reqwest::Client::builder() .user_agent("OpenBitFun-MiniApp-Market/1") .redirect(reqwest::redirect::Policy::none()) + .connect_timeout(std::time::Duration::from_secs(10)) + .timeout(std::time::Duration::from_secs(20)) .build() .map_err(MarketError::internal)?; Ok(Self { config, db, client }) @@ -238,26 +245,43 @@ impl AuthService { let transaction_secret = random_token(32); let now = Utc::now().timestamp(); let expires_at = (Utc::now() + Duration::minutes(OAUTH_FLOW_MINUTES)).timestamp(); - sqlx::query( + let mut transaction = self + .db + .pool() + .begin() + .await + .map_err(MarketError::internal)?; + let inserted = sqlx::query( "INSERT INTO desktop_auth_transactions( id, secret_hash, status, expires_at, created_at, updated_at - ) VALUES(?, ?, 'pending', ?, ?, ?)", + ) SELECT ?, ?, 'pending', ?, ?, ? + WHERE (SELECT COUNT(*) FROM desktop_auth_transactions WHERE expires_at > ?) < ?", ) .bind(&transaction_id) .bind(token_hash(&transaction_secret)) .bind(expires_at) .bind(now) .bind(now) - .execute(self.db.pool()) + .bind(now) + .bind(MAX_ACTIVE_OAUTH_FLOWS) + .execute(&mut *transaction) .await .map_err(MarketError::internal)?; + if inserted.rows_affected() == 0 { + return Err(MarketError::service_unavailable( + "auth_capacity", + "Sign-in is busy. Please try again shortly.", + )); + } let authorization_url = self - .create_oauth_flow( + .create_oauth_flow_in_transaction( + &mut transaction, "desktop", Some(&transaction_id), - "/miniapp/auth/desktop-complete", + "https://auth.openbitfun.com/complete", ) .await?; + transaction.commit().await.map_err(MarketError::internal)?; Ok(DesktopAuthStart { transaction_id, transaction_secret, @@ -272,6 +296,26 @@ impl AuthService { kind: &str, transaction_id: Option<&str>, return_to: &str, + ) -> MarketResult { + let mut transaction = self + .db + .pool() + .begin() + .await + .map_err(MarketError::internal)?; + let url = self + .create_oauth_flow_in_transaction(&mut transaction, kind, transaction_id, return_to) + .await?; + transaction.commit().await.map_err(MarketError::internal)?; + Ok(url) + } + + async fn create_oauth_flow_in_transaction( + &self, + transaction: &mut sqlx::Transaction<'_, sqlx::Sqlite>, + kind: &str, + transaction_id: Option<&str>, + return_to: &str, ) -> MarketResult { self.ensure_github_configured()?; let state = random_token(32); @@ -279,10 +323,11 @@ impl AuthService { let challenge = URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes())); let now = Utc::now().timestamp(); let expires_at = (Utc::now() + Duration::minutes(OAUTH_FLOW_MINUTES)).timestamp(); - sqlx::query( + let inserted = sqlx::query( "INSERT INTO oauth_flows( state_hash, flow_kind, transaction_id, code_verifier, return_to, expires_at, created_at - ) VALUES(?, ?, ?, ?, ?, ?, ?)", + ) SELECT ?, ?, ?, ?, ?, ?, ? + WHERE (SELECT COUNT(*) FROM oauth_flows WHERE expires_at > ?) < ?", ) .bind(token_hash(&state)) .bind(kind) @@ -291,10 +336,18 @@ impl AuthService { .bind(return_to) .bind(expires_at) .bind(now) - .execute(self.db.pool()) + .bind(now) + .bind(MAX_ACTIVE_OAUTH_FLOWS) + .execute(&mut **transaction) .await .map_err(MarketError::internal)?; + if inserted.rows_affected() == 0 { + return Err(MarketError::service_unavailable( + "auth_capacity", + "Sign-in is busy. Please try again shortly.", + )); + } let mut url = Url::parse("https://github.com/login/oauth/authorize") .map_err(MarketError::internal)?; url.query_pairs_mut() @@ -323,6 +376,14 @@ impl AuthService { .upsert_github_user(github_user.id, &github_user.login, &github_user.avatar_url) .await?; + self.finish_verified_oauth(flow, user.internal_id).await + } + + async fn finish_verified_oauth( + &self, + flow: OAuthFlowRecord, + user_id: i64, + ) -> MarketResult { if flow.flow_kind == "desktop" { let transaction_id = flow.transaction_id.ok_or_else(|| { MarketError::internal("Desktop OAuth flow is missing its transaction") @@ -332,7 +393,7 @@ impl AuthService { SET status = 'authorized', user_id = ?, updated_at = ? WHERE id = ? AND status = 'pending' AND expires_at > ?", ) - .bind(user.internal_id) + .bind(user_id) .bind(Utc::now().timestamp()) .bind(&transaction_id) .bind(Utc::now().timestamp()) @@ -345,15 +406,23 @@ impl AuthService { "The desktop authorization request has expired.", )); } - return Ok(CompletedOAuth::Desktop); } + // Every GitHub authorization establishes the same browser identity. + // Device token delivery remains bound to its one-use polling secret. let session_token = random_token(32); let csrf_token = random_token(24); let expires_at = (Utc::now() + Duration::days(WEB_SESSION_DAYS)).timestamp(); self.db - .create_web_session(user.internal_id, &session_token, &csrf_token, expires_at) + .create_web_session(user_id, &session_token, &csrf_token, expires_at) .await?; + if flow.flow_kind == "desktop" { + return Ok(CompletedOAuth::Desktop { + session_token, + csrf_token, + expires_at, + }); + } Ok(CompletedOAuth::Web { return_to: flow.return_to, session_token, @@ -595,7 +664,7 @@ impl AuthService { } async fn exchange_github_code(&self, code: &str, verifier: &str) -> MarketResult { - let token_response = self + let response = self .client .post("https://github.com/login/oauth/access_token") .header(header::ACCEPT, "application/json") @@ -617,10 +686,8 @@ impl AuthService { ]) .send() .await - .map_err(MarketError::internal)? - .json::() - .await .map_err(MarketError::internal)?; + let token_response: GitHubTokenResponse = bounded_github_json(response).await?; let access_token = token_response.access_token.ok_or_else(|| { MarketError::bad_request( "github_oauth_failed", @@ -630,17 +697,16 @@ impl AuthService { .unwrap_or_else(|| "GitHub did not return an access token.".to_string()), ) })?; - self.client + let response = self + .client .get("https://api.github.com/user") .bearer_auth(access_token) .send() .await .map_err(MarketError::internal)? .error_for_status() - .map_err(MarketError::internal)? - .json::() - .await - .map_err(MarketError::internal) + .map_err(MarketError::internal)?; + bounded_github_json(response).await } fn ensure_github_configured(&self) -> MarketResult<()> { @@ -655,6 +721,32 @@ impl AuthService { } } +async fn bounded_github_json( + mut response: reqwest::Response, +) -> MarketResult { + const MAX_BYTES: usize = 64 * 1024; + let oversized = || { + MarketError::service_unavailable( + "github_response_size", + "The identity provider response exceeds its size limit.", + ) + }; + if response + .content_length() + .is_some_and(|length| length > MAX_BYTES as u64) + { + return Err(oversized()); + } + let mut bytes = Vec::new(); + while let Some(chunk) = response.chunk().await.map_err(MarketError::internal)? { + if chunk.len() > MAX_BYTES.saturating_sub(bytes.len()) { + return Err(oversized()); + } + bytes.extend_from_slice(&chunk); + } + serde_json::from_slice(&bytes).map_err(MarketError::internal) +} + fn random_token(bytes: usize) -> String { let mut value = vec![0_u8; bytes]; OsRng.fill_bytes(&mut value); @@ -750,6 +842,159 @@ mod tests { } } + #[tokio::test] + async fn oauth_capacity_refusal_rolls_back_the_desktop_transaction() { + let temporary = tempfile::tempdir().unwrap(); + let database = Database::open(&temporary.path().join("market.sqlite")) + .await + .unwrap(); + let service = AuthService::new(test_config(temporary.path()), database.clone()).unwrap(); + let now = Utc::now().timestamp(); + sqlx::query("WITH RECURSIVE ids(n) AS (SELECT 1 UNION ALL SELECT n+1 FROM ids WHERE n < ?) + INSERT INTO oauth_flows(state_hash, flow_kind, code_verifier, return_to, expires_at, created_at) + SELECT CAST(n AS TEXT), 'web', 'verifier', '/miniapp/', ?, ? FROM ids") + .bind(MAX_ACTIVE_OAUTH_FLOWS).bind(now + 600).bind(now) + .execute(database.pool()).await.unwrap(); + let error = service.start_desktop_oauth().await.unwrap_err(); + assert_eq!(error.code, "auth_capacity"); + let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM desktop_auth_transactions") + .fetch_one(database.pool()) + .await + .unwrap(); + assert_eq!(count, 0); + sqlx::query("DELETE FROM oauth_flows WHERE state_hash = '1'") + .execute(database.pool()) + .await + .unwrap(); + assert!(service.start_desktop_oauth().await.is_ok()); + } + + #[tokio::test] + async fn auth_cleanup_keeps_live_identity_and_unexpired_revocation_evidence() { + let temporary = tempfile::tempdir().unwrap(); + let database = Database::open(&temporary.path().join("market.sqlite")) + .await + .unwrap(); + let service = AuthService::new(test_config(temporary.path()), database.clone()).unwrap(); + let user = database.upsert_github_user(42, "alice", "").await.unwrap(); + let now = Utc::now().timestamp(); + let live = service.start_desktop_oauth().await.unwrap(); + let expired = service.start_desktop_oauth().await.unwrap(); + sqlx::query("UPDATE desktop_auth_transactions SET expires_at = ? WHERE id = ?") + .bind(now - 3601) + .bind(&expired.transaction_id) + .execute(database.pool()) + .await + .unwrap(); + database + .create_api_token( + user.internal_id, + "revoked-token", + "refresh", + "family", + now + 3600, + ) + .await + .unwrap(); + database.revoke_token_family("family").await.unwrap(); + database.cleanup_expired_auth().await.unwrap(); + let ids: Vec = sqlx::query_scalar("SELECT id FROM desktop_auth_transactions") + .fetch_all(database.pool()) + .await + .unwrap(); + assert_eq!(ids, vec![live.transaction_id]); + let tokens: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM api_tokens WHERE family_id = 'family' AND revoked_at IS NOT NULL", + ) + .fetch_one(database.pool()) + .await + .unwrap(); + assert_eq!(tokens, 1); + assert!(database.user_by_github_id(42).await.unwrap().is_some()); + } + + #[tokio::test] + async fn identity_json_reader_rejects_oversized_provider_responses() { + let response = + reqwest::Response::from(axum::http::Response::new(reqwest::Body::from(vec![ + b'x'; + 65537 + ]))); + let error = bounded_github_json::(response) + .await + .unwrap_err(); + assert_eq!(error.code, "github_response_size"); + } + + #[tokio::test] + async fn desktop_authorization_creates_shared_browser_session_and_one_use_device_tokens() { + let temporary = tempfile::tempdir().unwrap(); + let database = Database::open(&temporary.path().join("market.sqlite")) + .await + .unwrap(); + let service = AuthService::new(test_config(temporary.path()), database.clone()).unwrap(); + let user = database.upsert_github_user(42, "alice", "").await.unwrap(); + let started = service.start_desktop_oauth().await.unwrap(); + let url = Url::parse(&started.authorization_url).unwrap(); + let state = url + .query_pairs() + .find(|(key, _)| key == "state") + .unwrap() + .1 + .into_owned(); + let flow = service.consume_oauth_flow(&state).await.unwrap(); + let completed = service + .finish_verified_oauth(flow, user.internal_id) + .await + .unwrap(); + let CompletedOAuth::Desktop { + session_token, + csrf_token, + expires_at, + } = completed + else { + panic!("desktop completion expected") + }; + let mut headers = HeaderMap::new(); + service + .append_web_session_cookies(&mut headers, &session_token, &csrf_token, expires_at) + .unwrap(); + let cookies: Vec<_> = headers + .get_all(header::SET_COOKIE) + .iter() + .map(|v| v.to_str().unwrap()) + .collect(); + assert_eq!(cookies.len(), 4); + assert!(cookies.iter().any(|v| v.contains("Path=/miniapp;"))); + assert!(cookies.iter().any(|v| v.contains("Path=/skin;"))); + assert!(cookies.iter().all(|v| !v.contains("Domain="))); + let mut request_headers = HeaderMap::new(); + request_headers.insert( + header::COOKIE, + format!("openbitfun_market_session={session_token}") + .parse() + .unwrap(), + ); + assert!(service.require_auth(&request_headers).await.is_ok()); + assert!(service.consume_oauth_flow(&state).await.is_err()); + let first = service + .poll_desktop(DesktopAuthPollRequest { + transaction_id: started.transaction_id.clone(), + transaction_secret: started.transaction_secret.clone(), + }) + .await + .unwrap(); + assert!(first.tokens.is_some()); + let replay = service + .poll_desktop(DesktopAuthPollRequest { + transaction_id: started.transaction_id, + transaction_secret: started.transaction_secret, + }) + .await + .unwrap(); + assert!(replay.tokens.is_none()); + } + #[tokio::test] async fn oauth_flow_uses_pkce_empty_scope_and_one_time_state() { let temporary = tempfile::tempdir().unwrap(); diff --git a/src/crates/services/miniapp-market-service/src/auth_admission.rs b/src/crates/services/miniapp-market-service/src/auth_admission.rs new file mode 100644 index 0000000000..74b9036069 --- /dev/null +++ b/src/crates/services/miniapp-market-service/src/auth_admission.rs @@ -0,0 +1,184 @@ +//! Resource admission for the shared GitHub identity authority. +use crate::error::MarketError; +use axum::{ + extract::Request, + http::{header, HeaderValue, Method, StatusCode}, + middleware::Next, + response::{IntoResponse, Response}, +}; +use std::{ + sync::{Arc, Mutex, OnceLock}, + time::{Duration, Instant}, +}; +use tokio::sync::Semaphore; + +const MAX_AUTH_BODY: usize = 16 * 1024; +const MAX_AUTH_REQUESTS: usize = 128; +const STARTS_PER_MINUTE: u32 = 300; + +struct AuthorizationRate { + window: Instant, + starts: u32, + callbacks: u32, +} + +impl AuthorizationRate { + fn new() -> Self { + Self { + window: Instant::now(), + starts: 0, + callbacks: 0, + } + } + + fn allow(&mut self, callback: bool) -> bool { + if self.window.elapsed() >= Duration::from_secs(60) { + *self = Self::new(); + } + let count = if callback { + &mut self.callbacks + } else { + &mut self.starts + }; + if *count >= STARTS_PER_MINUTE { + return false; + } + *count += 1; + true + } +} + +pub(crate) async fn admit(mut request: Request, next: Next) -> Response { + let path = request + .uri() + .path() + .strip_prefix("/miniapp/api/v1") + .unwrap_or(request.uri().path()); + if !path.starts_with("/auth/") { + return next.run(request).await; + } + if matches!( + path, + "/auth/github/start" | "/auth/desktop/start" | "/auth/github/callback" + ) { + static RATE: OnceLock> = OnceLock::new(); + let allowed = RATE + .get_or_init(|| Mutex::new(AuthorizationRate::new())) + .lock() + .unwrap_or_else(|error| error.into_inner()) + .allow(path.ends_with("/callback")); + if !allowed { + let mut response = MarketError::new( + StatusCode::TOO_MANY_REQUESTS, + "auth_rate_limit", + "Sign-in is busy. Please try again shortly.", + ) + .into_response(); + response + .headers_mut() + .insert(header::RETRY_AFTER, HeaderValue::from_static("60")); + return response; + } + } + static SLOTS: OnceLock> = OnceLock::new(); + let Ok(_permit) = Arc::clone(SLOTS.get_or_init(|| Arc::new(Semaphore::new(MAX_AUTH_REQUESTS)))) + .try_acquire_owned() + else { + return MarketError::service_unavailable( + "auth_capacity", + "Sign-in is busy. Please try again shortly.", + ) + .into_response(); + }; + if request.method() == Method::POST { + let body = std::mem::replace(request.body_mut(), axum::body::Body::empty()); + let bytes = match tokio::time::timeout( + Duration::from_secs(10), + axum::body::to_bytes(body, MAX_AUTH_BODY), + ) + .await + { + Ok(Ok(bytes)) => bytes, + Ok(Err(_)) => { + return MarketError::new( + StatusCode::PAYLOAD_TOO_LARGE, + "payload_too_large", + "The authentication request exceeds its size limit.", + ) + .into_response() + } + Err(_) => { + return MarketError::new( + StatusCode::REQUEST_TIMEOUT, + "auth_timeout", + "The authentication request timed out.", + ) + .into_response() + } + }; + *request.body_mut() = axum::body::Body::from(bytes); + } + tokio::time::timeout(Duration::from_secs(45), next.run(request)) + .await + .unwrap_or_else(|_| { + MarketError::new( + StatusCode::GATEWAY_TIMEOUT, + "auth_timeout", + "The identity service timed out.", + ) + .into_response() + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::{body::Body, routing::post, Json, Router}; + use tower::ServiceExt; + + #[test] + fn sign_in_start_flood_does_not_consume_callback_capacity() { + let mut rate = AuthorizationRate::new(); + for _ in 0..STARTS_PER_MINUTE { + assert!(rate.allow(false)); + } + assert!(!rate.allow(false)); + assert!(rate.allow(true)); + rate.window = Instant::now() - Duration::from_secs(61); + assert!(rate.allow(false)); + } + + #[tokio::test] + async fn auth_body_limit_is_enforced_without_changing_other_api_limits() { + let app = Router::new() + .route( + "/auth/desktop/poll", + post(|Json(_body): Json| async { StatusCode::NO_CONTENT }), + ) + .route( + "/submissions", + post(|Json(_body): Json| async { StatusCode::NO_CONTENT }), + ) + .layer(axum::middleware::from_fn(admit)); + for (path, expected) in [ + ("/auth/desktop/poll", StatusCode::PAYLOAD_TOO_LARGE), + ("/submissions", StatusCode::NO_CONTENT), + ] { + let response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri(path) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from( + serde_json::json!({"value": "a".repeat(MAX_AUTH_BODY)}).to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), expected); + } + } +} diff --git a/src/crates/services/miniapp-market-service/src/db.rs b/src/crates/services/miniapp-market-service/src/db.rs index 07fd09cc3c..b5d6b9cf67 100644 --- a/src/crates/services/miniapp-market-service/src/db.rs +++ b/src/crates/services/miniapp-market-service/src/db.rs @@ -91,6 +91,17 @@ impl Database { .bind(now) .execute(&self.pool) .await?; + // Keep a bounded grace period for clients polling an expired sign-in. + // These are transient authorization transactions, never product records. + sqlx::query("DELETE FROM desktop_auth_transactions WHERE expires_at <= ?") + .bind(now - 3600) + .execute(&self.pool) + .await?; + // Retain unexpired revoked refresh tokens for replay-family revocation. + sqlx::query("DELETE FROM api_tokens WHERE expires_at <= ?") + .bind(now) + .execute(&self.pool) + .await?; Ok(()) } diff --git a/src/crates/services/miniapp-market-service/src/lib.rs b/src/crates/services/miniapp-market-service/src/lib.rs index cc5c7619ab..21d706d377 100644 --- a/src/crates/services/miniapp-market-service/src/lib.rs +++ b/src/crates/services/miniapp-market-service/src/lib.rs @@ -2,6 +2,7 @@ mod artifacts; mod auth; +mod auth_admission; pub mod config; mod db; mod error; @@ -46,6 +47,18 @@ pub async fn build_market_router(config: MarketConfig) -> anyhow::Result ); } retention::spawn_cleanup_loop(db.clone(), artifacts.clone()); + let auth_cleanup_db = db.clone(); + tokio::spawn(async move { + let mut interval = tokio::time::interval(std::time::Duration::from_secs(300)); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + interval.tick().await; + loop { + interval.tick().await; + if let Err(error) = auth_cleanup_db.cleanup_expired_auth().await { + tracing::error!(%error, "Global identity authorization cleanup failed"); + } + } + }); let auth = AuthService::new(config.clone(), db.clone()) .map_err(|error| anyhow::anyhow!(error.to_string()))?; let state = Arc::new(MarketState { diff --git a/src/crates/services/miniapp-market-service/src/routes.rs b/src/crates/services/miniapp-market-service/src/routes.rs index 2d6a910c75..81f07b594c 100644 --- a/src/crates/services/miniapp-market-service/src/routes.rs +++ b/src/crates/services/miniapp-market-service/src/routes.rs @@ -233,6 +233,7 @@ pub(crate) fn api_router(state: Arc) -> Router { submission_policy_state, enforce_submission_write_policy, )) + .layer(axum::middleware::from_fn(crate::auth_admission::admit)) .with_state(state) } @@ -555,8 +556,19 @@ async fn github_oauth_callback( )?; Ok(response) } - CompletedOAuth::Desktop => { - Ok(Redirect::to("/miniapp/auth/desktop-complete").into_response()) + CompletedOAuth::Desktop { + session_token, + csrf_token, + expires_at, + } => { + let mut response = Redirect::to("https://auth.openbitfun.com/complete").into_response(); + state.auth.append_web_session_cookies( + response.headers_mut(), + &session_token, + &csrf_token, + expires_at, + )?; + Ok(response) } } } diff --git a/src/crates/services/relay-service/AGENTS.md b/src/crates/services/relay-service/AGENTS.md index a315cd9386..cebd2a8753 100644 --- a/src/crates/services/relay-service/AGENTS.md +++ b/src/crates/services/relay-service/AGENTS.md @@ -5,7 +5,7 @@ and embedded hosts. ## Ownership -- Room and device state, account provisioning and sync storage, HTTP/WebSocket +- Device state, verified GitHub identity exchange, scoped credentials, HTTP/WebSocket routes, and memory/disk web asset stores belong here. - Standalone host binding, environment configuration, static-file fallback, process lifecycle, and administrative CLI parsing/output remain in the app. @@ -13,15 +13,21 @@ and embedded hosts. its task lifecycle in `src/apps/desktop`; assembly controls only product start/stop sequencing through a narrow host port. - Hosts supply the version reported by the shared health and info routes. -- Keep the relay runtime zero-knowledge: it persists encrypted payloads, - derived hashes, and wrapped keys. Operator provisioning may generate a master - key only to wrap it before storage; plaintext keys must not be retained. +- Keep relay messages opaque. Devices retain their own private keys; public-key + lookup and message routing must enforce authenticated account ownership. +- Keep admission before body buffering and preserve resource permits through + cancellation and slow-reader failures. Test quota boundaries and isolation. ## Boundaries - Do not depend on assembly, interface, or application crates. - Standalone and embedded hosts must construct the same router from this crate. - Do not introduce host-specific APIs or duplicate the relay runtime per host. +- The independently built identity verifier supplies a ring `ClientConfig` to + its own Reqwest client. This narrow standalone exception must not install or + replace the process-wide provider; embedded product hosts retain the + `services-core::tls_provider` owner. Boundary checks require the explicit + client binding and forbid `install_default` in this verifier. ## Verification diff --git a/src/crates/services/relay-service/Cargo.toml b/src/crates/services/relay-service/Cargo.toml index 1e92ada35c..d0ff689e8e 100644 --- a/src/crates/services/relay-service/Cargo.toml +++ b/src/crates/services/relay-service/Cargo.toml @@ -28,13 +28,13 @@ dashmap = "6" rand = "0.8" base64 = "0.22" sha2 = "0.10" -subtle = "2" +reqwest = { version = "0.13.4", default-features = false, features = ["json", "rustls-no-provider"] } +rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] } +webpki-roots = "1" -# The relay stores encrypted blobs and password-derived hashes, not master keys. +# The relay stores verified account identity, device public keys, and opaque blobs. sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio", "sqlite", "macros"] } libsqlite3-sys = { version = "0.30", features = ["bundled"] } -argon2 = "0.5" -aes-gcm = "0.10" openbitfun-page-function-runtime = { path = "../page-function-runtime" } # Two SQLite drivers here is deliberate, not drift. They own disjoint databases: # diff --git a/src/crates/services/relay-service/src/account_transport_tests.rs b/src/crates/services/relay-service/src/account_transport_tests.rs new file mode 100644 index 0000000000..e0df7f1ad7 --- /dev/null +++ b/src/crates/services/relay-service/src/account_transport_tests.rs @@ -0,0 +1,246 @@ +//! End-to-end shared-router contract at the local and public path layouts. +use crate::{build_relay_router, db, identity::IdentityVerifier, MemoryAssetStore}; +use axum::{ + body::{to_bytes, Body}, + http::{header, HeaderMap, Request, StatusCode}, + Extension, Json, Router, +}; +use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; +use futures_util::{SinkExt, StreamExt}; +use serde_json::{json, Value}; +use std::{ + sync::Arc, + time::{Duration, Instant}, +}; +use tokio_tungstenite::tungstenite::Message; +use tower::ServiceExt; + +async fn request( + app: &Router, + method: &str, + path: &str, + token: &str, + body: Value, +) -> axum::response::Response { + app.clone() + .oneshot( + Request::builder() + .method(method) + .uri(path) + .header(header::AUTHORIZATION, format!("Bearer {token}")) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(body.to_string())) + .unwrap(), + ) + .await + .unwrap() +} +async fn json_body(response: axum::response::Response) -> Value { + serde_json::from_slice(&to_bytes(response.into_body(), 1_000_000).await.unwrap()).unwrap() +} + +#[tokio::test] +async fn official_and_local_layouts_share_authenticated_directory_and_rpc() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let authority_url = format!("http://{}/me", listener.local_addr().unwrap()); + let authority = Router::new().route( + "/me", + axum::routing::get(|headers: HeaderMap| async move { + let id = match headers + .get(header::AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + { + Some("Bearer account-a") => 123, + Some("Bearer account-b") => 456, + _ => return (StatusCode::UNAUTHORIZED, Json(json!({}))), + }; + ( + StatusCode::OK, + Json(json!({"user":{"githubId":id,"login":format!("user-{id}")}})), + ) + }), + ); + let authority_task = tokio::spawn(async move { + axum::serve(listener, authority).await.unwrap(); + }); + for prefix in ["", "/v/1.0.0"] { + let db = Arc::new(db::connect(":memory:").await.unwrap()); + let shared = build_relay_router( + Arc::new(MemoryAssetStore::new()), + Instant::now(), + db, + "test", + ) + .layer(Extension( + IdentityVerifier::with_url(&authority_url).unwrap(), + )); + let app = if prefix.is_empty() { + shared + } else { + Router::new().nest(prefix, shared) + }; + let route = |path: &str| format!("{prefix}{path}"); + assert_eq!( + request(&app, "GET", &route("/api/devices"), "", json!(null)) + .await + .status(), + StatusCode::UNAUTHORIZED + ); + for retired in ["/api/rooms", "/api/pair", "/api/auth/login/challenge"] { + assert_eq!( + request(&app, "POST", &route(retired), "", json!({})) + .await + .status(), + StatusCode::NOT_FOUND + ); + } + let mut tokens = Vec::new(); + for (device, account) in [ + ("desktop", "account-a"), + ("mobile", "account-a"), + ("outsider", "account-b"), + ] { + let response = request(&app, "POST", &route("/api/auth/login"), "", json!({ + "access_token":account, "user_id":"untrusted", "device_id":device, + "device_name":device, "device_kind":"desktop", "public_key":BASE64.encode([9u8;32]), + "request_id":uuid::Uuid::new_v4().to_string() + })).await; + assert_eq!(response.status(), StatusCode::OK); + let response = json_body(response).await; + assert_eq!( + response["user_id"], + if account == "account-a" { "123" } else { "456" } + ); + tokens.push(response["token"].as_str().unwrap().to_owned()); + } + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server_app = app.clone(); + let server = tokio::spawn(async move { + axum::serve(listener, server_app).await.unwrap(); + }); + let (mut socket, _) = + tokio_tungstenite::connect_async(format!("ws://{address}{prefix}/ws")) + .await + .unwrap(); + socket.send(Message::Text(json!({"type":"auth_connect","token":tokens[0],"device_name":"desktop","device_kind":"desktop"}).to_string().into())).await.unwrap(); + loop { + let msg = tokio::time::timeout(Duration::from_secs(3), socket.next()) + .await + .unwrap() + .unwrap() + .unwrap(); + let value: Value = serde_json::from_str(msg.to_text().unwrap()).unwrap(); + if value["type"] == "auth_ok" { + assert_eq!(value["user_id"], "123"); + break; + } + } + let devices = + json_body(request(&app, "GET", &route("/api/devices"), &tokens[1], json!(null)).await) + .await; + assert!(devices + .as_array() + .unwrap() + .iter() + .any(|device| device["device_id"] == "desktop" && device["online"] == true)); + assert!(!devices + .as_array() + .unwrap() + .iter() + .any(|device| device["device_id"] == "outsider")); + assert_eq!( + request( + &app, + "GET", + &route("/api/devices/desktop/key"), + &tokens[1], + json!(null) + ) + .await + .status(), + StatusCode::OK + ); + assert_eq!( + request( + &app, + "GET", + &route("/api/devices/desktop/key"), + &tokens[2], + json!(null) + ) + .await + .status(), + StatusCode::NOT_FOUND + ); + assert_eq!( + request( + &app, + "POST", + &route("/api/devices/desktop/rpc"), + &tokens[2], + json!({"encrypted_data":"YQ==","nonce":"bg=="}) + ) + .await + .status(), + StatusCode::NOT_FOUND + ); + let rpc_app = app.clone(); + let rpc_token = tokens[1].clone(); + let rpc_path = route("/api/devices/desktop/rpc"); + let rpc = tokio::spawn(async move { + request( + &rpc_app, + "POST", + &rpc_path, + &rpc_token, + json!({"encrypted_data":"YQ==","nonce":"bg=="}), + ) + .await + }); + let incoming = loop { + let msg = tokio::time::timeout(Duration::from_secs(3), socket.next()) + .await + .unwrap() + .unwrap() + .unwrap(); + let value: Value = serde_json::from_str(msg.to_text().unwrap()).unwrap(); + if value["type"] == "incoming_device_message" { + break value; + } + }; + assert_eq!(incoming["source_device_id"], "mobile"); + let payload = BASE64.encode(vec![7u8; 32 * 1024]); + let response = request(&app,"POST",&route("/api/devices/mobile/messages"),&tokens[0],json!({ + "correlation_id":incoming["correlation_id"], "encrypted_data":payload, "nonce":"bg==" + })).await; + assert_eq!(response.status(), StatusCode::NO_CONTENT); + let response = tokio::time::timeout(Duration::from_secs(3), rpc) + .await + .unwrap() + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(json_body(response).await["encrypted_data"], payload); + assert_eq!( + request( + &app, + "POST", + &route("/api/auth/logout"), + &tokens[1], + json!({}) + ) + .await + .status(), + StatusCode::NO_CONTENT + ); + assert_eq!( + request(&app, "GET", &route("/api/devices"), &tokens[1], json!(null)) + .await + .status(), + StatusCode::UNAUTHORIZED + ); + socket.close(None).await.unwrap(); + server.abort(); + } + authority_task.abort(); +} diff --git a/src/crates/services/relay-service/src/admin.rs b/src/crates/services/relay-service/src/admin.rs index 9edba901c9..b13a272ced 100644 --- a/src/crates/services/relay-service/src/admin.rs +++ b/src/crates/services/relay-service/src/admin.rs @@ -1,216 +1,7 @@ -//! Admin account provisioning module. -//! -//! Provides the cryptographic primitives needed to create/update user accounts -//! out-of-band. The relay server itself never uses this code at runtime — it -//! is consumed only by the `relay-admin` CLI binary. -//! -//! The key-derivation logic mirrors `account.rs` in `services-integrations` -//! exactly: same Argon2id parameters, same salt lengths, same AES-256-GCM -//! wrapping format. This ensures accounts provisioned here can log in via the -//! normal client flow. - -use aes_gcm::aead::{Aead, KeyInit, OsRng}; -use aes_gcm::{Aes256Gcm, Nonce}; -use anyhow::{anyhow, Result}; -use argon2::{Algorithm, Argon2, Params, Version}; -use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; -use rand::RngCore; -use serde::{Deserialize, Serialize}; +//! Administrative listing and explicit deletion of GitHub-linked relay records. use crate::db::{DbPool, UserRow}; - -const MASTER_KEY_LEN: usize = 32; -const SALT_LEN: usize = 16; -const NONCE_LEN: usize = 12; - -/// Argon2id parameters. Must match `KdfParams::default()` in `account.rs`. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AdminKdfParams { - pub m: u32, - pub t: u32, - pub p: u32, -} - -impl Default for AdminKdfParams { - fn default() -> Self { - Self { - // Resource-aware baseline: 16 MiB, 3 iterations, 4 lanes. - m: 16 * 1024, - t: 3, - p: 4, - } - } -} - -impl AdminKdfParams { - fn build(&self) -> Result> { - let params = Params::new(self.m, self.t, self.p, Some(MASTER_KEY_LEN)) - .map_err(|e| anyhow!("invalid argon2 params: {e}"))?; - Ok(Argon2::new(Algorithm::Argon2id, Version::V0x13, params)) - } - - pub fn to_json(&self) -> Result { - serde_json::to_string(self).map_err(|e| anyhow!("serialize kdf params: {e}")) - } -} - -/// The complete set of values needed to insert/update a user row. -/// -/// Serializable so a client can provision an account locally (keeping the -/// plaintext password on the client machine) and hand the derived artifacts -/// to `relay-admin import-user` on the server over a trusted channel (e.g. -/// the operator's own SSH session). -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ProvisionedAccount { - pub user_id: String, - pub salt: String, - pub kdf_salt: String, - pub argon2_params: String, - pub password_hash: String, - pub wrapped_master_key: String, -} - -/// A locally-provisioned account plus its username, as exchanged with -/// `relay-admin import-user` (JSON over file/stdin). -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ImportableAccount { - pub username: String, - #[serde(flatten)] - pub account: ProvisionedAccount, -} - -/// Derive all cryptographic artifacts for a username + password. -/// -/// This is the provisioning primitive: it generates two random salts, derives -/// the KEK (to wrap a new random master key) and the server-verifiable password -/// hash, and returns everything the DB needs. The plaintext password is not -/// retained after this function returns. -pub fn provision(_username: &str, password: &str) -> Result { - let params = AdminKdfParams::default(); - let argon2 = params.build()?; - - // Two independent random salts. - let mut salt = [0u8; SALT_LEN]; - let mut kdf_salt = [0u8; SALT_LEN]; - OsRng.fill_bytes(&mut salt); - OsRng.fill_bytes(&mut kdf_salt); - - // 1. Derive KEK from password + salt. - let mut kek = [0u8; MASTER_KEY_LEN]; - argon2 - .hash_password_into(password.as_bytes(), &salt, &mut kek) - .map_err(|e| anyhow!("derive kek: {e}"))?; - - // 2. Generate a random master key and wrap it with the KEK. - let mut master_key = [0u8; MASTER_KEY_LEN]; - OsRng.fill_bytes(&mut master_key); - - let cipher = Aes256Gcm::new_from_slice(&kek).map_err(|e| anyhow!("aes init: {e}"))?; - let mut nonce_bytes = [0u8; NONCE_LEN]; - OsRng.fill_bytes(&mut nonce_bytes); - let nonce = Nonce::from_slice(&nonce_bytes); - - let wrapped_ct = cipher - .encrypt(nonce, master_key.as_slice()) - .map_err(|e| anyhow!("wrap master key: {e}"))?; - - // Packed format: "ct_b64.nonce_b64" — matches account.rs `pack_wrapped`. - let wrapped_master_key = format!( - "{}.{}", - BASE64.encode(&wrapped_ct), - BASE64.encode(nonce_bytes) - ); - - // 3. Derive the server-verifiable password hash (separate salt). - let mut pwd_hash = [0u8; MASTER_KEY_LEN]; - argon2 - .hash_password_into(password.as_bytes(), &kdf_salt, &mut pwd_hash) - .map_err(|e| anyhow!("derive password hash: {e}"))?; - - Ok(ProvisionedAccount { - user_id: uuid::Uuid::new_v4().to_string(), - salt: BASE64.encode(salt), - kdf_salt: BASE64.encode(kdf_salt), - argon2_params: params.to_json()?, - password_hash: BASE64.encode(pwd_hash), - wrapped_master_key, - }) -} - -// ── DB operations ────────────────────────────────────────────────────────── - -/// Create a new account. Fails if the username already exists. -pub async fn add_user(pool: &DbPool, username: &str, password: &str) -> Result { - if UserRow::find_by_username(pool, username).await?.is_some() { - return Err(anyhow!("username '{username}' already exists")); - } - let acct = provision(username, password)?; - insert_user_row(pool, username, &acct).await?; - Ok(acct.user_id.clone()) -} - -/// Insert an account that was provisioned elsewhere (e.g. on the client -/// machine, so the plaintext password never transits the server). Only -/// derived artifacts are stored — identical to what `add_user` persists. -pub async fn import_user(pool: &DbPool, import: &ImportableAccount) -> Result { - if UserRow::find_by_username(pool, &import.username) - .await? - .is_some() - { - return Err(anyhow!("username '{}' already exists", import.username)); - } - insert_user_row(pool, &import.username, &import.account).await?; - Ok(import.account.user_id.clone()) -} - -async fn insert_user_row(pool: &DbPool, username: &str, acct: &ProvisionedAccount) -> Result<()> { - UserRow::create( - pool, - &acct.user_id, - username, - &acct.salt, - &acct.kdf_salt, - &acct.argon2_params, - &acct.password_hash, - &acct.wrapped_master_key, - ) - .await?; - Ok(()) -} - -/// Reset the password for an existing account. Generates new salts and a new -/// master key — **all existing synced data (encrypted with the old master key) -/// becomes unreadable**. The user must re-sync after logging in. -pub async fn reset_password(pool: &DbPool, username: &str, password: &str) -> Result<()> { - let user = UserRow::find_by_username(pool, username) - .await? - .ok_or_else(|| anyhow!("username '{username}' not found"))?; - let acct = provision(username, password)?; - UserRow::update_credentials( - pool, - &user.user_id, - &acct.salt, - &acct.kdf_salt, - &acct.argon2_params, - &acct.password_hash, - &acct.wrapped_master_key, - ) - .await?; - // Wipe old sync data since it was encrypted with the old master key. - let _ = sqlx::query("DELETE FROM sync_sessions WHERE user_id = ?") - .bind(&user.user_id) - .execute(pool) - .await; - let _ = sqlx::query("DELETE FROM sync_settings WHERE user_id = ?") - .bind(&user.user_id) - .execute(pool) - .await; - let _ = sqlx::query("DELETE FROM auth_tokens WHERE user_id = ?") - .bind(&user.user_id) - .execute(pool) - .await; - Ok(()) -} +use anyhow::{anyhow, Result}; /// Delete an account and all associated data. pub async fn delete_user(pool: &DbPool, username: &str) -> Result<()> { @@ -225,18 +16,3 @@ pub async fn delete_user(pool: &DbPool, username: &str) -> Result<()> { pub async fn list_users(pool: &DbPool) -> Result> { UserRow::list_all(pool).await } - -/// Rename an existing account. Fails if the new username is already taken. -/// The user_id and all credentials remain unchanged. -pub async fn rename_user(pool: &DbPool, old_username: &str, new_username: &str) -> Result<()> { - let user = UserRow::find_by_username(pool, old_username) - .await? - .ok_or_else(|| anyhow!("username '{old_username}' not found"))?; - if UserRow::find_by_username(pool, new_username) - .await? - .is_some() - { - return Err(anyhow!("username '{new_username}' already exists")); - } - UserRow::rename(pool, &user.user_id, new_username).await -} diff --git a/src/crates/services/relay-service/src/admission.rs b/src/crates/services/relay-service/src/admission.rs new file mode 100644 index 0000000000..e94c2c3482 --- /dev/null +++ b/src/crates/services/relay-service/src/admission.rs @@ -0,0 +1,177 @@ +//! Admission happens before JSON/body buffering and expensive identity calls. +use crate::routes::api::AppState; +use axum::{ + extract::{ConnectInfo, Request, State}, + http::{header, Method, StatusCode}, + middleware::Next, + response::{IntoResponse, Response}, +}; +use std::{ + net::SocketAddr, + sync::{Arc, OnceLock}, + time::Duration, +}; +use tokio::sync::Semaphore; + +const BODY_MEMORY_BUDGET: usize = 512 * 1024 * 1024; +const MAX_REQUESTS: usize = 2048; +const MAX_RPC_BODY: usize = 48 * 1024 * 1024 + 64 * 1024; + +pub(crate) async fn admit( + State(state): State, + mut request: Request, + next: Next, +) -> Response { + let path = request.uri().path(); + if !path.starts_with("/api/") { + return next.run(request).await; + } + let peer = request + .extensions() + .get::>() + .map(|peer| peer.0); + let ip = crate::routes::auth::client_ip(request.headers(), peer); + if !state + .login_rate_limiter + .check_and_record("http", &ip, 6000, None) + { + return StatusCode::TOO_MANY_REQUESTS.into_response(); + } + if path.starts_with("/api/devices") { + let auth = match crate::routes::devices::validate_user(&state, request.headers()).await { + Ok(auth) => auth, + Err(status) => return status.into_response(), + }; + if !state + .login_rate_limiter + .check_and_record("account-http", &auth.user_id, 6000, None) + { + return StatusCode::TOO_MANY_REQUESTS.into_response(); + } + } + static REQUESTS: OnceLock> = OnceLock::new(); + static MEMORY: OnceLock> = OnceLock::new(); + let Ok(request_slot) = + Arc::clone(REQUESTS.get_or_init(|| Arc::new(Semaphore::new(MAX_REQUESTS)))) + .try_acquire_owned() + else { + return StatusCode::SERVICE_UNAVAILABLE.into_response(); + }; + let mut memory_permit = None; + if request.method() == Method::POST { + let maximum = if path.starts_with("/api/auth/") { + 16 * 1024 + } else { + MAX_RPC_BODY + }; + let declared = request + .headers() + .get(header::CONTENT_LENGTH) + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.parse::().ok()); + if declared.is_some_and(|length| length > maximum) { + return StatusCode::PAYLOAD_TOO_LARGE.into_response(); + } + let reservation = declared.unwrap_or(maximum).max(64 * 1024); + memory_permit = + match Arc::clone(MEMORY.get_or_init(|| Arc::new(Semaphore::new(BODY_MEMORY_BUDGET)))) + .try_acquire_many_owned(reservation as u32) + { + Ok(permit) => Some(permit), + Err(_) => return StatusCode::SERVICE_UNAVAILABLE.into_response(), + }; + let body = std::mem::replace(request.body_mut(), axum::body::Body::empty()); + let bytes = match tokio::time::timeout( + Duration::from_secs(15), + axum::body::to_bytes(body, declared.unwrap_or(maximum).min(maximum)), + ) + .await + { + Ok(Ok(bytes)) => bytes, + Ok(Err(_)) => return StatusCode::PAYLOAD_TOO_LARGE.into_response(), + Err(_) => return StatusCode::REQUEST_TIMEOUT.into_response(), + }; + // Unknown-length ingress reserves its ceiling before reading. Once + // buffered, retain only the actual body reservation through the reply. + if let Some(permit) = memory_permit.as_mut() { + let unused = permit + .num_permits() + .saturating_sub(bytes.len().max(64 * 1024)); + drop(permit.split(unused)); + } + *request.body_mut() = axum::body::Body::from(bytes); + } + let result = tokio::time::timeout(Duration::from_secs(130), next.run(request)).await; + let response = result.unwrap_or_else(|_| StatusCode::REQUEST_TIMEOUT.into_response()); + let (parts, body) = response.into_parts(); + let stream = futures_util::stream::unfold( + (body.into_data_stream(), request_slot, memory_permit), + |(mut body, request_slot, memory_permit)| async move { + use futures_util::StreamExt; + body.next() + .await + .map(|chunk| (chunk, (body, request_slot, memory_permit))) + }, + ); + Response::from_parts(parts, axum::body::Body::from_stream(stream)) +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::body::Body; + use tower::ServiceExt; + + async fn router() -> axum::Router { + let db = crate::db::connect(":memory:").await.unwrap(); + crate::build_relay_router( + Arc::new(crate::MemoryAssetStore::new()), + std::time::Instant::now(), + Arc::new(db), + "test", + ) + } + + #[tokio::test] + async fn unauthenticated_rpc_is_rejected_before_reading_unbounded_body() { + let body = Body::from_stream(futures_util::stream::pending::< + Result, + >()); + let request = Request::builder() + .method("POST") + .uri("/api/devices/desktop/rpc") + .body(body) + .unwrap(); + let response = + tokio::time::timeout(Duration::from_secs(1), router().await.oneshot(request)) + .await + .unwrap() + .unwrap(); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn chunked_oversize_auth_body_is_rejected() { + let request = Request::builder() + .method("POST") + .uri("/api/auth/login") + .header("content-type", "application/json") + .body(Body::from("x".repeat(16385))) + .unwrap(); + let response = router().await.oneshot(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE); + } + + #[tokio::test] + async fn account_service_does_not_expose_anonymous_rooms() { + let request = Request::builder() + .method("POST") + .uri("/api/rooms/test/pair") + .body(Body::empty()) + .unwrap(); + assert_eq!( + router().await.oneshot(request).await.unwrap().status(), + StatusCode::NOT_FOUND + ); + } +} diff --git a/src/crates/services/relay-service/src/db.rs b/src/crates/services/relay-service/src/db.rs index 9de84502a6..dc38fa2ee6 100644 --- a/src/crates/services/relay-service/src/db.rs +++ b/src/crates/services/relay-service/src/db.rs @@ -1,8 +1,7 @@ //! SQLite-backed account storage for the relay server. //! -//! The relay remains zero-knowledge: it stores only password-derived hashes -//! and AES-GCM-wrapped master keys (encrypted client-side). It never holds a -//! plaintext master key and cannot decrypt synced session/settings blobs. +//! The versioned relay stores verified GitHub identity and device public keys. +//! Device private keys never leave their owning clients. use anyhow::{anyhow, Result}; use chrono::Utc; @@ -24,13 +23,6 @@ const SCHEMA: &str = r#" CREATE TABLE IF NOT EXISTS users ( user_id TEXT PRIMARY KEY, username TEXT UNIQUE NOT NULL, - salt TEXT NOT NULL, - kdf_salt TEXT NOT NULL, - argon2_params TEXT NOT NULL, - password_hash TEXT NOT NULL, - wrapped_master_key TEXT NOT NULL, - failed_attempts INTEGER NOT NULL DEFAULT 0, - locked_until INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL ); @@ -54,26 +46,13 @@ CREATE TABLE IF NOT EXISTS auth_tokens ( expires_at INTEGER NOT NULL, FOREIGN KEY (user_id, device_id) REFERENCES devices(user_id, device_id) ON DELETE CASCADE ); +CREATE TABLE IF NOT EXISTS delegated_device_keys ( + token TEXT PRIMARY KEY REFERENCES auth_tokens(token) ON DELETE CASCADE, + controller_id TEXT NOT NULL UNIQUE, + public_key TEXT NOT NULL +); CREATE INDEX IF NOT EXISTS idx_auth_tokens_user ON auth_tokens(user_id); CREATE INDEX IF NOT EXISTS idx_devices_user ON devices(user_id); -CREATE TABLE IF NOT EXISTS sync_sessions ( - user_id TEXT NOT NULL REFERENCES users(user_id), - session_id TEXT NOT NULL, - encrypted_data TEXT NOT NULL, - nonce TEXT NOT NULL, - version INTEGER NOT NULL, - updated_at INTEGER NOT NULL, - deleted INTEGER NOT NULL DEFAULT 0, - PRIMARY KEY (user_id, session_id) -); -CREATE INDEX IF NOT EXISTS idx_sync_sessions_user ON sync_sessions(user_id); -CREATE TABLE IF NOT EXISTS sync_settings ( - user_id TEXT PRIMARY KEY REFERENCES users(user_id), - encrypted_data TEXT NOT NULL, - nonce TEXT NOT NULL, - version INTEGER NOT NULL, - updated_at INTEGER NOT NULL -); CREATE TABLE IF NOT EXISTS pages ( user_id TEXT NOT NULL REFERENCES users(user_id), slug TEXT NOT NULL, @@ -122,6 +101,19 @@ CREATE TABLE IF NOT EXISTS page_blobs ( ); "#; +// SQLite triggers serialize admission with the insert itself, including concurrent clients. +// Existing devices remain usable above a new quota; no user records are deleted. +const REGISTRATION_QUOTAS: &str = r#" +CREATE TRIGGER IF NOT EXISTS limit_account_devices BEFORE INSERT ON devices +WHEN NOT EXISTS (SELECT 1 FROM devices WHERE user_id = NEW.user_id AND device_id = NEW.device_id) + AND (SELECT count(*) FROM devices WHERE user_id = NEW.user_id) >= 64 +BEGIN SELECT RAISE(ABORT, 'account device quota exceeded'); END; +CREATE TRIGGER IF NOT EXISTS limit_account_tokens BEFORE INSERT ON auth_tokens +WHEN NOT EXISTS (SELECT 1 FROM auth_tokens WHERE request_id = NEW.request_id) + AND (SELECT count(*) FROM auth_tokens WHERE user_id = NEW.user_id AND expires_at > unixepoch()) >= 256 +BEGIN SELECT RAISE(ABORT, 'account token quota exceeded'); END; +"#; + const MIGRATE_PAGES_DEPLOYED_VERSION: &str = r#" ALTER TABLE pages ADD COLUMN deployed_version_id TEXT; "#; @@ -237,6 +229,7 @@ async fn connect_with_presence_reset(db_path: &str, reset_presence: bool) -> Res .execute(&pool) .await .map_err(|e| anyhow!("index auth token request ids: {e}"))?; + sqlx::raw_sql(REGISTRATION_QUOTAS).execute(&pool).await?; let now = Utc::now().timestamp(); sqlx::query("DELETE FROM auth_tokens WHERE expires_at <= ?") .bind(now) @@ -371,78 +364,45 @@ async fn migrate_account_scoped_devices(pool: &DbPool) -> Result<()> { pub struct UserRow { pub user_id: String, pub username: String, - pub salt: String, - pub kdf_salt: String, - pub argon2_params: String, - pub password_hash: String, - pub wrapped_master_key: String, - pub failed_attempts: i64, - pub locked_until: i64, pub created_at: i64, pub updated_at: i64, } impl UserRow { - /// Insert a new user row. Not exposed via HTTP — accounts are provisioned - /// out-of-band (e.g. an admin import tool) so the relay never sees a - /// password. Kept as a DB primitive for that future tooling. - #[allow(dead_code)] - pub async fn create( - pool: &DbPool, - user_id: &str, - username: &str, - salt: &str, - kdf_salt: &str, - argon2_params: &str, - password_hash: &str, - wrapped_master_key: &str, - ) -> Result<()> { + /// Persist a profile authenticated by the shared GitHub authority. The + /// immutable numeric GitHub id owns devices; login is display/URL metadata. + pub async fn upsert_verified(pool: &DbPool, user_id: &str, username: &str) -> Result { let now = Utc::now().timestamp(); - sqlx::query( - "INSERT INTO users \ - (user_id, username, salt, kdf_salt, argon2_params, password_hash, \ - wrapped_master_key, failed_attempts, locked_until, created_at, updated_at) \ - VALUES (?, ?, ?, ?, ?, ?, ?, 0, 0, ?, ?)", - ) - .bind(user_id) - .bind(username) - .bind(salt) - .bind(kdf_salt) - .bind(argon2_params) - .bind(password_hash) - .bind(wrapped_master_key) - .bind(now) - .bind(now) - .execute(pool) - .await - .map_err(|e| anyhow!("create user: {e}"))?; + let user = sqlx::query_as::<_, UserRow>( + "INSERT INTO users (user_id, username, created_at, updated_at) VALUES (?, ?, ?, ?) \ + ON CONFLICT(user_id) DO UPDATE SET username = excluded.username, updated_at = excluded.updated_at \ + RETURNING user_id, username, created_at, updated_at", + ).bind(user_id).bind(username).bind(now).bind(now).fetch_one(pool).await?; + Ok(user) + } + + #[cfg(test)] + pub async fn create(pool: &DbPool, user_id: &str, username: &str) -> Result<()> { + Self::upsert_verified(pool, user_id, username).await?; Ok(()) } pub async fn find_by_username(pool: &DbPool, username: &str) -> Result> { - let row = sqlx::query_as::<_, UserRow>( - "SELECT user_id, username, salt, kdf_salt, argon2_params, password_hash, \ - wrapped_master_key, failed_attempts, locked_until, created_at, updated_at \ - FROM users WHERE username = ?", + Ok(sqlx::query_as::<_, UserRow>( + "SELECT user_id, username, created_at, updated_at FROM users WHERE username = ?", ) .bind(username) .fetch_optional(pool) - .await - .map_err(|e| anyhow!("find user: {e}"))?; - Ok(row) + .await?) } pub async fn find_by_user_id(pool: &DbPool, user_id: &str) -> Result> { - let row = sqlx::query_as::<_, UserRow>( - "SELECT user_id, username, salt, kdf_salt, argon2_params, password_hash, \ - wrapped_master_key, failed_attempts, locked_until, created_at, updated_at \ - FROM users WHERE user_id = ?", + Ok(sqlx::query_as::<_, UserRow>( + "SELECT user_id, username, created_at, updated_at FROM users WHERE user_id = ?", ) .bind(user_id) .fetch_optional(pool) - .await - .map_err(|e| anyhow!("find user by id: {e}"))?; - Ok(row) + .await?) } /// Resolve username for a user id (convenience for page URL construction). @@ -455,19 +415,6 @@ impl UserRow { .map(|u| u.username)) } - /// Rename a user. Fails if the new username already exists. - pub async fn rename(pool: &DbPool, user_id: &str, new_username: &str) -> Result<()> { - let now = Utc::now().timestamp(); - sqlx::query("UPDATE users SET username = ?, updated_at = ? WHERE user_id = ?") - .bind(new_username) - .bind(now) - .bind(user_id) - .execute(pool) - .await - .map_err(|e| anyhow!("rename user: {e}"))?; - Ok(()) - } - /// List all usernames (admin tooling). Returns `(username, created_at)`. pub async fn list_all(pool: &DbPool) -> Result> { let rows = sqlx::query_as::<_, (String, String, i64)>( @@ -479,50 +426,9 @@ impl UserRow { Ok(rows) } - /// Update credentials for an existing user (admin password reset). - /// Replaces salt, kdf_salt, password_hash, and wrapped_master_key. - pub async fn update_credentials( - pool: &DbPool, - user_id: &str, - salt: &str, - kdf_salt: &str, - argon2_params: &str, - password_hash: &str, - wrapped_master_key: &str, - ) -> Result<()> { - let now = Utc::now().timestamp(); - sqlx::query( - "UPDATE users SET salt = ?, kdf_salt = ?, argon2_params = ?, \ - password_hash = ?, wrapped_master_key = ?, failed_attempts = 0, \ - locked_until = 0, updated_at = ? WHERE user_id = ?", - ) - .bind(salt) - .bind(kdf_salt) - .bind(argon2_params) - .bind(password_hash) - .bind(wrapped_master_key) - .bind(now) - .bind(user_id) - .execute(pool) - .await - .map_err(|e| anyhow!("update credentials: {e}"))?; - Ok(()) - } - /// Permanently delete a user and all associated data (devices, tokens, - /// sync blobs, pages). Cascading deletes handle FK-linked rows. + /// pages). Cascading deletes handle FK-linked rows. pub async fn delete(pool: &DbPool, user_id: &str) -> Result<()> { - // Clean up sync tables first (no FK cascade configured on them). - sqlx::query("DELETE FROM sync_sessions WHERE user_id = ?") - .bind(user_id) - .execute(pool) - .await - .map_err(|e| anyhow!("delete sync_sessions: {e}"))?; - sqlx::query("DELETE FROM sync_settings WHERE user_id = ?") - .bind(user_id) - .execute(pool) - .await - .map_err(|e| anyhow!("delete sync_settings: {e}"))?; sqlx::query("DELETE FROM page_kv WHERE user_id = ?") .bind(user_id) .execute(pool) @@ -562,69 +468,6 @@ impl UserRow { .map_err(|e| anyhow!("delete user: {e}"))?; Ok(()) } - - /// Increment the failed-attempt counter and apply an exponential-backoff - /// lockout once the threshold is reached. Returns the new `locked_until` - /// timestamp (0 when not locked). - pub async fn record_failed_attempt(pool: &DbPool, user_id: &str) -> Result { - let now = Utc::now().timestamp(); - let row = sqlx::query("SELECT failed_attempts FROM users WHERE user_id = ?") - .bind(user_id) - .fetch_one(pool) - .await - .map_err(|e| anyhow!("fetch attempts: {e}"))?; - let current: i64 = sqlx::Row::get(&row, "failed_attempts"); - let new_count = current + 1; - let locked_until = lockout_until(new_count, now); - sqlx::query( - "UPDATE users SET failed_attempts = ?, locked_until = ?, updated_at = ? \ - WHERE user_id = ?", - ) - .bind(new_count) - .bind(locked_until) - .bind(now) - .bind(user_id) - .execute(pool) - .await - .map_err(|e| anyhow!("update attempts: {e}"))?; - Ok(locked_until) - } - - pub async fn reset_failed_attempts(pool: &DbPool, user_id: &str) -> Result<()> { - let now = Utc::now().timestamp(); - sqlx::query( - "UPDATE users SET failed_attempts = 0, locked_until = 0, updated_at = ? \ - WHERE user_id = ?", - ) - .bind(now) - .bind(user_id) - .execute(pool) - .await - .map_err(|e| anyhow!("reset attempts: {e}"))?; - Ok(()) - } - - pub fn is_locked(&self) -> bool { - self.locked_until > Utc::now().timestamp() - } -} - -/// Exponential backoff lockout schedule. -/// -/// `attempts` is the count *after* the latest failure. Locking kicks in at 5 -/// failures and grows: 1m → 5m → 15m → 60m (capped). -fn lockout_until(attempts: i64, now: i64) -> i64 { - if attempts < 5 { - return 0; - } - let level = (attempts - 4).min(4); - let secs = match level { - 1 => 60, - 2 => 300, - 3 => 900, - _ => 3600, - }; - now + secs } // ── Devices ───────────────────────────────────────────────────────────── @@ -680,7 +523,7 @@ impl DeviceRow { ON CONFLICT(user_id, device_id) DO UPDATE SET \ device_name = excluded.device_name, \ device_kind = COALESCE(excluded.device_kind, devices.device_kind), \ - public_key = excluded.public_key, \ + public_key = COALESCE(excluded.public_key, devices.public_key), \ last_seen_at = excluded.last_seen_at", ) .bind(device_id) @@ -800,6 +643,7 @@ impl AuthToken { device_name: &str, device_kind: Option<&str>, request_id: &str, + public_key: &str, ) -> Result> { let mut tx = pool .begin() @@ -839,18 +683,32 @@ impl AuthToken { "device provisioning request id conflicts with another device name" )); } + let stored_key = sqlx::query_scalar::<_, Option>( + "SELECT public_key FROM devices WHERE user_id = ? AND device_id = ?", + ) + .bind(user_id) + .bind(device_id) + .fetch_optional(&mut *tx) + .await? + .flatten(); + if stored_key.as_deref() != Some(public_key) { + return Err(anyhow!( + "device provisioning request id conflicts with another public key" + )); + } return Ok(Some(existing)); } let inserted = sqlx::query( "INSERT OR IGNORE INTO devices \ (device_id, user_id, device_name, device_kind, public_key, last_seen_at, online) \ - VALUES (?, ?, ?, ?, NULL, ?, 0)", + VALUES (?, ?, ?, ?, ?, ?, 0)", ) .bind(device_id) .bind(user_id) .bind(device_name) .bind(device_kind) + .bind(public_key) .bind(now) .execute(&mut *tx) .await @@ -890,6 +748,57 @@ impl AuthToken { })) } + pub async fn create_keyed_delegated( + pool: &DbPool, + user_id: &str, + parent_device_id: &str, + public_key: &str, + ) -> Result { + let token = generate_token(); + let now = Utc::now().timestamp(); + let expires_at = now + DELEGATED_TOKEN_TTL_SECS; + let mut transaction = pool.begin().await?; + sqlx::query( + "INSERT INTO auth_tokens (token, user_id, device_id, token_kind, created_at, expires_at) \ + VALUES (?, ?, ?, 'delegated_control', ?, ?)", + ) + .bind(&token).bind(user_id).bind(parent_device_id).bind(now).bind(expires_at) + .execute(&mut *transaction).await?; + let controller_id = format!("controller-{}", uuid::Uuid::new_v4()); + sqlx::query( + "INSERT INTO delegated_device_keys (token, controller_id, public_key) VALUES (?, ?, ?)", + ) + .bind(&token) + .bind(&controller_id) + .bind(public_key) + .execute(&mut *transaction) + .await?; + transaction.commit().await?; + Ok(AuthToken { + token, + user_id: user_id.to_string(), + device_id: parent_device_id.to_string(), + token_kind: "delegated_control".to_string(), + request_id: None, + created_at: now, + expires_at, + }) + } + + /// The owner device remains the revocation parent; routing uses a distinct controller key. + pub async fn routing_device_id(&self, pool: &DbPool) -> Result { + if self.is_device_token() { + return Ok(self.device_id.clone()); + } + sqlx::query_scalar::<_, String>( + "SELECT controller_id FROM delegated_device_keys WHERE token = ?", + ) + .bind(&self.token) + .fetch_optional(pool) + .await? + .ok_or_else(|| anyhow!("controller has no registered device key")) + } + pub async fn create_delegated( pool: &DbPool, user_id: &str, @@ -1076,224 +985,6 @@ pub fn is_valid_auth_token(token: &str) -> bool { .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) } -// ── Sync sessions (encrypted blobs, server never decrypts) ───────────── - -#[derive(Debug, Clone, sqlx::FromRow)] -pub struct SyncSessionRow { - pub session_id: String, - pub encrypted_data: String, - pub nonce: String, - pub version: i64, - pub updated_at: i64, - pub deleted: i64, -} - -impl SyncSessionRow { - /// Upsert an encrypted session blob. Last-writer-wins via version. - /// - /// Enforces optional per-user active session count and total encrypted-byte - /// quotas. Product defaults are effectively unlimited (`i32::MAX`); pass - /// lower ceilings when an operator needs to bound account storage. - pub async fn upsert_with_quota( - pool: &DbPool, - user_id: &str, - session_id: &str, - encrypted_data: &str, - nonce: &str, - version: i64, - max_sessions: i64, - max_total_bytes: i64, - ) -> Result { - let now = Utc::now().timestamp(); - let result = sqlx::query( - "INSERT INTO sync_sessions (user_id, session_id, encrypted_data, nonce, version, updated_at, deleted) \ - SELECT ?, ?, ?, ?, ?, ?, 0 \ - WHERE (SELECT COUNT(*) FROM sync_sessions \ - WHERE user_id = ? AND deleted = 0 AND session_id <> ?) < ? \ - AND (SELECT COALESCE(SUM(LENGTH(encrypted_data)), 0) FROM sync_sessions \ - WHERE user_id = ? AND deleted = 0 AND session_id <> ?) + ? <= ? \ - ON CONFLICT(user_id, session_id) DO UPDATE SET \ - encrypted_data = excluded.encrypted_data, \ - nonce = excluded.nonce, \ - version = excluded.version, \ - updated_at = excluded.updated_at, \ - deleted = 0", - ) - .bind(user_id) - .bind(session_id) - .bind(encrypted_data) - .bind(nonce) - .bind(version) - .bind(now) - .bind(user_id) - .bind(session_id) - .bind(max_sessions) - .bind(user_id) - .bind(session_id) - .bind(encrypted_data.len() as i64) - .bind(max_total_bytes) - .execute(pool) - .await - .map_err(|e| anyhow!("upsert sync session: {e}"))?; - Ok(result.rows_affected() > 0) - } - - /// Fetch all non-deleted sessions for a user updated after `since_version`. - pub async fn list_since( - pool: &DbPool, - user_id: &str, - since_version: i64, - ) -> Result> { - let rows = sqlx::query_as::<_, SyncSessionRow>( - "SELECT session_id, encrypted_data, nonce, version, updated_at, deleted \ - FROM sync_sessions WHERE user_id = ? AND version > ? AND deleted = 0 \ - ORDER BY version ASC, session_id ASC", - ) - .bind(user_id) - .bind(since_version) - .fetch_all(pool) - .await - .map_err(|e| anyhow!("list sync sessions: {e}"))?; - Ok(rows) - } - - /// Soft-delete a session (tombstone for syncing deletions across devices). - /// Bumps `version` so incremental-sync consumers pick up the deletion. - pub async fn delete(pool: &DbPool, user_id: &str, session_id: &str) -> Result<()> { - let now = Utc::now().timestamp(); - sqlx::query( - "UPDATE sync_sessions SET deleted = 1, version = ?, updated_at = ? \ - WHERE user_id = ? AND session_id = ?", - ) - .bind(now) - .bind(now) - .bind(user_id) - .bind(session_id) - .execute(pool) - .await - .map_err(|e| anyhow!("delete sync session: {e}"))?; - Ok(()) - } - - /// Soft-delete oldest active sessions (excluding `keep_session_id`) until - /// inserting/replacing a blob of `needed_bytes` would satisfy count/byte quotas. - /// - /// Used when a fresh upsert is rejected so recent backups can displace LRU - /// cloud sessions instead of failing the whole sync with HTTP 507. - pub async fn make_room_for_upsert( - pool: &DbPool, - user_id: &str, - keep_session_id: &str, - needed_bytes: i64, - max_sessions: i64, - max_total_bytes: i64, - ) -> Result { - if needed_bytes > max_total_bytes { - return Ok(0); - } - let candidates = sqlx::query_as::<_, SyncSessionRow>( - "SELECT session_id, encrypted_data, nonce, version, updated_at, deleted \ - FROM sync_sessions \ - WHERE user_id = ? AND deleted = 0 AND session_id <> ? \ - ORDER BY updated_at ASC, version ASC, session_id ASC", - ) - .bind(user_id) - .bind(keep_session_id) - .fetch_all(pool) - .await - .map_err(|e| anyhow!("list sync sessions for quota relief: {e}"))?; - - let mut bytes: i64 = candidates - .iter() - .map(|row| row.encrypted_data.len() as i64) - .sum(); - let mut count = candidates.len() as i64; - let mut evicted = 0usize; - - for row in candidates { - if count < max_sessions && bytes.saturating_add(needed_bytes) <= max_total_bytes { - break; - } - Self::delete(pool, user_id, &row.session_id).await?; - bytes = bytes.saturating_sub(row.encrypted_data.len() as i64); - count = count.saturating_sub(1); - evicted += 1; - } - - Ok(evicted) - } - - /// Fetch one non-deleted session blob by id. - pub async fn get( - pool: &DbPool, - user_id: &str, - session_id: &str, - ) -> Result> { - let row = sqlx::query_as::<_, SyncSessionRow>( - "SELECT session_id, encrypted_data, nonce, version, updated_at, deleted \ - FROM sync_sessions \ - WHERE user_id = ? AND session_id = ? AND deleted = 0", - ) - .bind(user_id) - .bind(session_id) - .fetch_optional(pool) - .await - .map_err(|e| anyhow!("get sync session: {e}"))?; - Ok(row) - } -} - -// ── Sync settings (single encrypted blob per user) ────────────────────── - -#[derive(Debug, Clone, sqlx::FromRow)] -pub struct SyncSettingsRow { - pub encrypted_data: String, - pub nonce: String, - pub version: i64, - pub updated_at: i64, -} - -impl SyncSettingsRow { - pub async fn upsert( - pool: &DbPool, - user_id: &str, - encrypted_data: &str, - nonce: &str, - version: i64, - ) -> Result<()> { - let now = Utc::now().timestamp(); - sqlx::query( - "INSERT INTO sync_settings (user_id, encrypted_data, nonce, version, updated_at) \ - VALUES (?, ?, ?, ?, ?) \ - ON CONFLICT(user_id) DO UPDATE SET \ - encrypted_data = excluded.encrypted_data, \ - nonce = excluded.nonce, \ - version = excluded.version, \ - updated_at = excluded.updated_at", - ) - .bind(user_id) - .bind(encrypted_data) - .bind(nonce) - .bind(version) - .bind(now) - .execute(pool) - .await - .map_err(|e| anyhow!("upsert sync settings: {e}"))?; - Ok(()) - } - - pub async fn get(pool: &DbPool, user_id: &str) -> Result> { - let row = sqlx::query_as::<_, SyncSettingsRow>( - "SELECT encrypted_data, nonce, version, updated_at FROM sync_settings WHERE user_id = ?", - ) - .bind(user_id) - .fetch_optional(pool) - .await - .map_err(|e| anyhow!("get sync settings: {e}"))?; - Ok(row) - } -} - // ── Pages (published static sites) ────────────────────────────────────── /// Visibility levels for a published OpenBitFun Page. @@ -2388,6 +2079,27 @@ pub fn new_page_version_id() -> String { #[cfg(test)] mod tests { + #[tokio::test] + async fn delegated_key_failure_rolls_back_token() { + let pool = super::connect(":memory:").await.unwrap(); + super::UserRow::create(&pool, "u1", "alice").await.unwrap(); + super::DeviceRow::upsert(&pool, "d1", "u1", "desktop", None, None) + .await + .unwrap(); + sqlx::query("CREATE TRIGGER reject_controller_key BEFORE INSERT ON delegated_device_keys BEGIN SELECT RAISE(ABORT, 'test rejection'); END") + .execute(&pool).await.unwrap(); + assert!( + super::AuthToken::create_keyed_delegated(&pool, "u1", "d1", "key") + .await + .is_err() + ); + let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM auth_tokens") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(count, 0); + } + use super::*; async fn setup() -> DbPool { @@ -2397,71 +2109,93 @@ mod tests { } #[tokio::test] - async fn admin_connection_preserves_live_server_presence_projection() { - let temp = tempfile::tempdir().unwrap(); - let db_path = temp.path().join("relay.db"); - let db_path = db_path.to_str().unwrap(); - let runtime_pool = connect(db_path).await.unwrap(); - UserRow::create(&runtime_pool, "u1", "alice", "s", "ks", "{}", "hash", "wmk") + async fn device_quota_is_atomic_and_account_scoped() { + let pool = connect(":memory:").await.unwrap(); + UserRow::create(&pool, "quota-a", "a").await.unwrap(); + UserRow::create(&pool, "quota-b", "b").await.unwrap(); + for index in 0..63 { + DeviceRow::upsert( + &pool, + &format!("device-{index}"), + "quota-a", + "Device", + None, + None, + ) .await .unwrap(); - DeviceRow::upsert(&runtime_pool, "d1", "u1", "Laptop", None, None) + } + let (first, second) = tokio::join!( + DeviceRow::upsert(&pool, "last-a", "quota-a", "Device", None, None), + DeviceRow::upsert(&pool, "last-b", "quota-a", "Device", None, None), + ); + assert_eq!(usize::from(first.is_ok()) + usize::from(second.is_ok()), 1); + DeviceRow::upsert(&pool, "device-0", "quota-a", "Renamed", None, None) .await .unwrap(); - DeviceRow::set_online(&runtime_pool, "u1", "d1", true) + DeviceRow::upsert(&pool, "new", "quota-b", "Device", None, None) .await .unwrap(); - - let admin_pool = connect_for_admin(db_path).await.unwrap(); - let rows = DeviceRow::list_by_user(&admin_pool, "u1").await.unwrap(); - assert_eq!(rows.len(), 1); - assert_eq!(rows[0].online, 1); - } - - #[test] - fn lockout_schedule() { - let now = 1000; - assert_eq!(lockout_until(4, now), 0); - assert_eq!(lockout_until(5, now), now + 60); - assert_eq!(lockout_until(6, now), now + 300); - assert_eq!(lockout_until(7, now), now + 900); - assert_eq!(lockout_until(8, now), now + 3600); - assert_eq!(lockout_until(100, now), now + 3600); } #[tokio::test] - async fn failed_attempts_lock_account() { - let pool = setup().await; - UserRow::create(&pool, "u1", "alice", "s", "ks", "{}", "hash", "wmk") + async fn token_quota_preserves_idempotent_replays_and_other_accounts() { + let pool = connect(":memory:").await.unwrap(); + UserRow::create(&pool, "a", "a").await.unwrap(); + UserRow::create(&pool, "b", "b").await.unwrap(); + DeviceRow::upsert(&pool, "device", "a", "Device", None, None) + .await + .unwrap(); + DeviceRow::upsert(&pool, "device", "b", "Device", None, None) + .await + .unwrap(); + let original = AuthToken::create_idempotent(&pool, "a", "device", "replay") .await .unwrap(); - for _ in 0..4 { - let lock = UserRow::record_failed_attempt(&pool, "u1").await.unwrap(); - assert_eq!(lock, 0, "not locked before 5 failures"); + for _ in 1..256 { + AuthToken::create(&pool, "a", "device").await.unwrap(); } - let lock = UserRow::record_failed_attempt(&pool, "u1").await.unwrap(); - assert!(lock > 0, "locked at 5 failures"); - - let user = UserRow::find_by_username(&pool, "alice") + assert!(AuthToken::create(&pool, "a", "device").await.is_err()); + assert_eq!( + AuthToken::create_idempotent(&pool, "a", "device", "replay") + .await + .unwrap() + .token, + original.token + ); + AuthToken::create(&pool, "b", "device").await.unwrap(); + sqlx::query("DELETE FROM auth_tokens WHERE token = ?") + .bind(&original.token) + .execute(&pool) .await - .unwrap() .unwrap(); - assert!(user.is_locked()); + AuthToken::create(&pool, "a", "device").await.unwrap(); + } - UserRow::reset_failed_attempts(&pool, "u1").await.unwrap(); - let user = UserRow::find_by_username(&pool, "alice") + #[tokio::test] + async fn admin_connection_preserves_live_server_presence_projection() { + let temp = tempfile::tempdir().unwrap(); + let db_path = temp.path().join("relay.db"); + let db_path = db_path.to_str().unwrap(); + let runtime_pool = connect(db_path).await.unwrap(); + UserRow::create(&runtime_pool, "u1", "alice").await.unwrap(); + DeviceRow::upsert(&runtime_pool, "d1", "u1", "Laptop", None, None) .await - .unwrap() .unwrap(); - assert!(!user.is_locked()); + DeviceRow::set_online(&runtime_pool, "u1", "d1", true) + .await + .unwrap(); + + let admin_pool = connect_for_admin(db_path).await.unwrap(); + let rows = DeviceRow::list_by_user(&admin_pool, "u1").await.unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].online, 1); } #[tokio::test] async fn token_create_and_find() { let pool = setup().await; - UserRow::create(&pool, "u1", "alice", "s", "ks", "{}", "hash", "wmk") - .await - .unwrap(); + UserRow::create(&pool, "u1", "alice").await.unwrap(); DeviceRow::upsert(&pool, "d1", "u1", "Laptop", None, None) .await .unwrap(); @@ -2489,12 +2223,8 @@ mod tests { #[tokio::test] async fn the_same_install_device_id_is_isolated_between_accounts() { let pool = setup().await; - UserRow::create(&pool, "u1", "alice", "s", "ks", "{}", "hash", "wmk") - .await - .unwrap(); - UserRow::create(&pool, "u2", "bob", "s", "ks", "{}", "hash", "wmk") - .await - .unwrap(); + UserRow::create(&pool, "u1", "alice").await.unwrap(); + UserRow::create(&pool, "u2", "bob").await.unwrap(); DeviceRow::upsert(&pool, "shared-install", "u1", "Alice laptop", None, None) .await @@ -2525,83 +2255,6 @@ mod tests { assert_eq!(DeviceRow::list_by_user(&pool, "u2").await.unwrap().len(), 1); } - #[tokio::test] - async fn sync_session_upsert_enforces_count_and_byte_quotas_atomically() { - let pool = setup().await; - UserRow::create(&pool, "u1", "alice", "s", "ks", "{}", "hash", "wmk") - .await - .unwrap(); - - assert!( - SyncSessionRow::upsert_with_quota(&pool, "u1", "s1", "1234", "n", 1, 1, 5) - .await - .unwrap() - ); - assert!( - SyncSessionRow::upsert_with_quota(&pool, "u1", "s1", "12345", "n", 2, 1, 5) - .await - .unwrap() - ); - assert!( - !SyncSessionRow::upsert_with_quota(&pool, "u1", "s2", "1", "n", 1, 1, 5) - .await - .unwrap() - ); - assert!( - !SyncSessionRow::upsert_with_quota(&pool, "u1", "s1", "123456", "n", 3, 1, 5) - .await - .unwrap() - ); - - let stored = SyncSessionRow::get(&pool, "u1", "s1") - .await - .unwrap() - .unwrap(); - assert_eq!(stored.encrypted_data, "12345"); - } - - #[tokio::test] - async fn sync_session_make_room_evicts_oldest_until_upsert_fits() { - let pool = setup().await; - UserRow::create(&pool, "u1", "alice", "s", "ks", "{}", "hash", "wmk") - .await - .unwrap(); - - assert!( - SyncSessionRow::upsert_with_quota(&pool, "u1", "old", "1234", "n", 1, 2, 8) - .await - .unwrap() - ); - assert!( - SyncSessionRow::upsert_with_quota(&pool, "u1", "mid", "1234", "n", 2, 2, 8) - .await - .unwrap() - ); - assert!( - !SyncSessionRow::upsert_with_quota(&pool, "u1", "new", "1234", "n", 3, 2, 8) - .await - .unwrap() - ); - - let evicted = SyncSessionRow::make_room_for_upsert(&pool, "u1", "new", 4, 2, 8) - .await - .unwrap(); - assert!(evicted >= 1); - assert!( - SyncSessionRow::upsert_with_quota(&pool, "u1", "new", "1234", "n", 3, 2, 8) - .await - .unwrap() - ); - assert!(SyncSessionRow::get(&pool, "u1", "old") - .await - .unwrap() - .is_none()); - assert!(SyncSessionRow::get(&pool, "u1", "new") - .await - .unwrap() - .is_some()); - } - #[tokio::test] async fn legacy_global_device_schema_is_migrated_without_ambiguous_tokens() { let db_path = std::env::temp_dir().join(format!( @@ -2734,9 +2387,7 @@ mod tests { let db_path_text = db_path.to_string_lossy().to_string(); let first = connect(&db_path_text).await.unwrap(); - UserRow::create(&first, "u1", "alice", "s", "ks", "{}", "hash", "wmk") - .await - .unwrap(); + UserRow::create(&first, "u1", "alice").await.unwrap(); DeviceRow::upsert( &first, "phone", @@ -2833,9 +2484,7 @@ mod tests { #[tokio::test] async fn page_ensure_version_deploy_and_resolve() { let pool = setup().await; - UserRow::create(&pool, "u1", "alice", "s", "ks", "{}", "hash", "wmk") - .await - .unwrap(); + UserRow::create(&pool, "u1", "alice").await.unwrap(); PageRow::ensure(&pool, "u1", "my-site", PageVisibility::Public, "My Site") .await .unwrap(); @@ -2890,9 +2539,7 @@ mod tests { #[tokio::test] async fn page_version_metadata_and_lifecycle_mutations_are_atomic() { let pool = setup().await; - UserRow::create(&pool, "u1", "alice", "s", "ks", "{}", "hash", "wmk") - .await - .unwrap(); + UserRow::create(&pool, "u1", "alice").await.unwrap(); PageRow::ensure( &pool, "u1", diff --git a/src/crates/services/relay-service/src/identity.rs b/src/crates/services/relay-service/src/identity.rs new file mode 100644 index 0000000000..cfe0c42fef --- /dev/null +++ b/src/crates/services/relay-service/src/identity.rs @@ -0,0 +1,258 @@ +//! Verification against the shared OpenBitFun GitHub identity authority. +//! Relay receives an OpenBitFun access token, never a GitHub OAuth secret. + +use axum::http::StatusCode; +use serde::Deserialize; +use std::{sync::Arc, time::Duration}; + +pub(crate) const IDENTITY_ME_URL: &str = "https://auth.openbitfun.com/api/v1/me"; + +#[derive(Clone)] +pub(crate) struct IdentityVerifier { + client: reqwest::Client, + me_url: reqwest::Url, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct VerifiedIdentity { + pub github_id: i64, + pub login: String, +} + +#[derive(Deserialize)] +struct IdentityResponse { + user: VerifiedIdentity, +} + +impl IdentityVerifier { + pub(crate) async fn start_auth(&self) -> Result { + self.auth_request("auth/desktop/start", serde_json::json!({})) + .await + } + + pub(crate) async fn poll_auth( + &self, + transaction_id: &str, + transaction_secret: &str, + ) -> Result { + if transaction_id.is_empty() + || transaction_id.len() > 256 + || transaction_secret.is_empty() + || transaction_secret.len() > 1024 + { + return Err(StatusCode::BAD_REQUEST); + } + self.auth_request( + "auth/desktop/poll", + serde_json::json!({ + "transactionId": transaction_id, + "transactionSecret": transaction_secret, + }), + ) + .await + } + + async fn auth_request( + &self, + path: &str, + body: serde_json::Value, + ) -> Result { + let url = self + .me_url + .join(path) + .map_err(|_| StatusCode::SERVICE_UNAVAILABLE)?; + let _permit = identity_request_permit()?; + let mut response = self + .client + .post(url) + .json(&body) + .send() + .await + .map_err(|_| StatusCode::SERVICE_UNAVAILABLE)?; + if !response.status().is_success() { + return Err(match response.status() { + StatusCode::BAD_REQUEST + | StatusCode::UNAUTHORIZED + | StatusCode::GONE + | StatusCode::TOO_MANY_REQUESTS => response.status(), + _ => StatusCode::SERVICE_UNAVAILABLE, + }); + } + let mut bytes = Vec::new(); + while let Some(chunk) = response + .chunk() + .await + .map_err(|_| StatusCode::SERVICE_UNAVAILABLE)? + { + if bytes.len() + chunk.len() > 65536 { + return Err(StatusCode::SERVICE_UNAVAILABLE); + } + bytes.extend_from_slice(&chunk); + } + serde_json::from_slice(&bytes).map_err(|_| StatusCode::SERVICE_UNAVAILABLE) + } + + pub(crate) fn new() -> anyhow::Result { + Self::with_url(IDENTITY_ME_URL) + } + + pub(crate) fn with_url(url: &str) -> anyhow::Result { + let me_url = reqwest::Url::parse(url)?; + anyhow::ensure!( + me_url.scheme() == "https" + || (cfg!(test) + && me_url.scheme() == "http" + && me_url.host_str() == Some("127.0.0.1")), + "The identity authority must use HTTPS" + ); + // Standalone relay does not link the workspace service facade. Select + // ring for this client without installing a second process provider. + let roots = + rustls::RootCertStore::from_iter(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()); + let tls = rustls::ClientConfig::builder_with_provider(Arc::new( + rustls::crypto::ring::default_provider(), + )) + .with_safe_default_protocol_versions()? + .with_root_certificates(roots) + .with_no_client_auth(); + Ok(Self { + client: reqwest::Client::builder() + .tls_backend_preconfigured(tls) + .connect_timeout(Duration::from_secs(3)) + .timeout(Duration::from_secs(5)) + .redirect(reqwest::redirect::Policy::none()) + .build()?, + me_url, + }) + } + + pub(crate) async fn verify(&self, token: &str) -> Result { + if token.is_empty() + || token.len() > 8192 + || token + .bytes() + .any(|c| c.is_ascii_whitespace() || c.is_ascii_control()) + { + return Err(StatusCode::UNAUTHORIZED); + } + let _permit = identity_request_permit()?; + let mut response = self + .client + .get(self.me_url.clone()) + .bearer_auth(token) + .send() + .await + .map_err(|_| StatusCode::SERVICE_UNAVAILABLE)?; + if matches!( + response.status(), + StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN + ) { + return Err(StatusCode::UNAUTHORIZED); + } + if !response.status().is_success() { + return Err(StatusCode::SERVICE_UNAVAILABLE); + } + let mut bytes = Vec::new(); + while let Some(chunk) = response + .chunk() + .await + .map_err(|_| StatusCode::SERVICE_UNAVAILABLE)? + { + if bytes.len() + chunk.len() > 65536 { + return Err(StatusCode::SERVICE_UNAVAILABLE); + } + bytes.extend_from_slice(&chunk); + } + let identity: IdentityResponse = + serde_json::from_slice(&bytes).map_err(|_| StatusCode::SERVICE_UNAVAILABLE)?; + let user = identity.user; + if user.github_id <= 0 + || user.login.is_empty() + || user.login.len() > 100 + || !user + .login + .bytes() + .all(|c| c.is_ascii_alphanumeric() || c == b'-') + { + return Err(StatusCode::SERVICE_UNAVAILABLE); + } + Ok(user) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::{routing::get, Router}; + + async fn verifier( + status: StatusCode, + body: &'static str, + ) -> (IdentityVerifier, tokio::task::JoinHandle<()>) { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("http://{}/me", listener.local_addr().unwrap()); + let app = Router::new().route( + "/me", + get(move |headers: axum::http::HeaderMap| async move { + assert_eq!( + headers.get("authorization").unwrap(), + "Bearer account-token" + ); + (status, body) + }), + ); + let task = tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + (IdentityVerifier::with_url(&url).unwrap(), task) + } + + #[tokio::test] + async fn accepts_only_authority_verified_numeric_identity() { + let (client, task) = verifier( + StatusCode::OK, + r#"{"user":{"githubId":123,"login":"alice"}}"#, + ) + .await; + let identity = client.verify("account-token").await.unwrap(); + assert_eq!(identity.github_id, 123); + assert_eq!(identity.login, "alice"); + task.abort(); + } + + #[tokio::test] + async fn fails_closed_on_expired_tokens_unavailable_authority_and_invalid_profiles() { + for (status, body, expected) in [ + (StatusCode::UNAUTHORIZED, "", StatusCode::UNAUTHORIZED), + (StatusCode::FOUND, "", StatusCode::SERVICE_UNAVAILABLE), + ( + StatusCode::INTERNAL_SERVER_ERROR, + "", + StatusCode::SERVICE_UNAVAILABLE, + ), + ( + StatusCode::OK, + r#"{"user":{"githubId":0,"login":"alice"}}"#, + StatusCode::SERVICE_UNAVAILABLE, + ), + ( + StatusCode::OK, + r#"{"user":{"githubId":123,"login":"../bob"}}"#, + StatusCode::SERVICE_UNAVAILABLE, + ), + (StatusCode::OK, "invalid", StatusCode::SERVICE_UNAVAILABLE), + ] { + let (client, task) = verifier(status, body).await; + assert_eq!(client.verify("account-token").await.unwrap_err(), expected); + task.abort(); + } + } +} + +fn identity_request_permit() -> Result { + static REQUESTS: std::sync::OnceLock> = std::sync::OnceLock::new(); + Arc::clone(REQUESTS.get_or_init(|| Arc::new(tokio::sync::Semaphore::new(64)))) + .try_acquire_owned() + .map_err(|_| StatusCode::SERVICE_UNAVAILABLE) +} diff --git a/src/crates/services/relay-service/src/lib.rs b/src/crates/services/relay-service/src/lib.rs index f77c1d892b..0d8558cadd 100644 --- a/src/crates/services/relay-service/src/lib.rs +++ b/src/crates/services/relay-service/src/lib.rs @@ -3,23 +3,28 @@ //! Shared relay logic used by both the standalone relay-server binary and //! the embedded relay running inside the desktop process. //! -//! The relay is a stateless HTTP-to-WebSocket bridge: +//! The relay bridges authenticated same-account devices: //! - Desktop clients connect via WebSocket //! - Mobile clients interact via HTTP POST //! - The relay forwards encrypted payloads without inspection -//! - Per-room mobile-web static files are managed via `WebAssetStore` +//! - GitHub identity owns device membership and public-key lookup +//! - Published Page assets are managed via `WebAssetStore` + +mod identity; + +#[cfg(test)] +mod account_transport_tests; pub mod admin; +mod admission; pub mod db; pub mod page_data; pub mod page_execution; pub mod relay; pub mod routes; -pub use relay::room::{ResponsePayload, RoomManager}; pub use routes::api::AppState; -use axum::extract::DefaultBodyLimit; use axum::http::{header, HeaderValue, Method}; use axum::routing::{get, post}; use axum::Router; @@ -1074,33 +1079,23 @@ fn create_link(original: &std::path::Path, link: &std::path::Path) -> std::io::R /// Both the standalone binary and the embedded relay call this function, /// passing their own `WebAssetStore` implementation. pub fn build_relay_router( - room_manager: Arc, asset_store: Arc, start_time: std::time::Instant, - db: Option>, + db: std::sync::Arc, host_version: &'static str, ) -> Router { - build_relay_router_with_page_data( - room_manager, - asset_store, - start_time, - db, - host_version, - None, - ) + build_relay_router_with_page_data(asset_store, start_time, db, host_version, None) } /// Like [`build_relay_router`], with an explicit page-data directory for Page Functions. pub fn build_relay_router_with_page_data( - room_manager: Arc, asset_store: Arc, start_time: std::time::Instant, - db: Option>, + db: std::sync::Arc, host_version: &'static str, page_data_dir: Option, ) -> Router { build_relay_router_with_page_data_and_origins( - room_manager, asset_store, start_time, db, @@ -1114,16 +1109,14 @@ pub fn build_relay_router_with_page_data( /// same-origin only. `*` remains available for intentionally public relays but /// should not be combined with account APIs. pub fn build_relay_router_with_page_data_and_origins( - room_manager: Arc, asset_store: Arc, start_time: std::time::Instant, - db: Option>, + db: std::sync::Arc, host_version: &'static str, page_data_dir: Option, cors_allow_origins: Vec, ) -> Router { build_relay_router_with_page_data_origins_and_page_auth( - room_manager, asset_store, start_time, db, @@ -1137,10 +1130,9 @@ pub fn build_relay_router_with_page_data_and_origins( /// Build a relay with browser CORS policy and an isolated Page login origin. #[allow(clippy::too_many_arguments)] pub fn build_relay_router_with_page_data_origins_and_page_auth( - room_manager: Arc, asset_store: Arc, start_time: std::time::Instant, - db: Option>, + db: std::sync::Arc, host_version: &'static str, page_data_dir: Option, cors_allow_origins: Vec, @@ -1158,7 +1150,6 @@ pub fn build_relay_router_with_page_data_origins_and_page_auth( }) .collect::>(); let state = AppState { - room_manager, start_time, asset_store, db, @@ -1181,10 +1172,8 @@ pub fn build_relay_router_with_page_data_origins_and_page_auth( "/api/info", get(move || routes::api::server_info_for_host(host_version)), ) - .route( - "/api/auth/login/challenge", - post(routes::auth::login_challenge), - ) + .route("/api/auth/github/start", post(routes::auth::github_start)) + .route("/api/auth/github/poll", post(routes::auth::github_poll)) .route("/api/auth/login", post(routes::auth::login)) .route("/api/auth/logout", post(routes::auth::logout)) .route("/api/auth/delegate", post(routes::auth::delegate)) @@ -1192,28 +1181,13 @@ pub fn build_relay_router_with_page_data_origins_and_page_auth( "/api/auth/provision-device", post(routes::auth::provision_device), ) - .route("/api/rooms/{room_id}/pair", post(routes::api::pair)) - .route( - "/api/rooms/{room_id}/command", - post(routes::api::command).layer(DefaultBodyLimit::max(10 * 1024 * 1024)), - ) - .route( - "/api/rooms/{room_id}/upload-web", - post(routes::api::upload_web).layer(DefaultBodyLimit::max(10 * 1024 * 1024)), - ) - .route( - "/api/rooms/{room_id}/check-web-files", - post(routes::api::check_web_files), - ) - .route( - "/api/rooms/{room_id}/upload-web-files", - post(routes::api::upload_web_files).layer(DefaultBodyLimit::max(10 * 1024 * 1024)), - ) - .route("/r/{*rest}", get(routes::api::serve_room_web_catchall)) .route("/ws", get(routes::websocket::websocket_handler)) - .merge(routes::sync::sync_router()) .merge(routes::devices::device_router()) .merge(routes::pages::pages_router()) + .layer(axum::middleware::from_fn_with_state( + state.clone(), + admission::admit, + )) .with_state(state) .layer(axum::middleware::from_fn(relay_security_headers)); @@ -1273,17 +1247,17 @@ mod tests { #[tokio::test] async fn router_exposes_health_and_server_info() { let app = build_relay_router( - RoomManager::new(), Arc::new(MemoryAssetStore::new()), std::time::Instant::now(), - None, + Arc::new(db::connect(":memory:").await.unwrap()), "test-host-version", ); let health = get_json(app.clone(), "/health").await; assert_eq!(health["status"], "healthy"); - assert_eq!(health["rooms"], 0); - assert_eq!(health["connections"], 0); + assert!(health.get("rooms").is_none()); + assert_eq!(health["device_connections"], 0); + assert_eq!(health["account_features"], true); assert_eq!(health["version"], "test-host-version"); assert_eq!(health["asset_store_bytes"], 0); assert_eq!( @@ -1310,7 +1284,7 @@ mod tests { let info = get_json(app, "/api/info").await; assert_eq!(info["name"], "OpenBitFun Relay Server"); assert_eq!(info["version"], "test-host-version"); - assert_eq!(info["protocol_version"], 2); + assert_eq!(info["protocol_version"], 3); } #[test] diff --git a/src/crates/services/relay-service/src/relay/device_manager.rs b/src/crates/services/relay-service/src/relay/device_manager.rs index 0bba46e1a4..9910cff21b 100644 --- a/src/crates/services/relay-service/src/relay/device_manager.rs +++ b/src/crates/services/relay-service/src/relay/device_manager.rs @@ -1,10 +1,7 @@ //! Per-user online device registry for account-based device routing. //! -//! This is a **parallel** pathway to `RoomManager`: the existing QR-pairing -//! flow keeps using rooms (1 desktop per room, unchanged). Account-logged-in -//! devices register here, scoped by `user_id`, and can route -//! `device_to_device` messages to each other. The relay never decrypts the -//! payloads — it only routes by `(user_id, target_device_id)`. +//! Authenticated devices register by account and device id. The Relay never +//! decrypts payloads; it routes within the authenticated account directory. //! //! The manager also supports HTTP RPC: a request can register a pending //! response keyed by `correlation_id`, and when a `DeviceMessage` response @@ -18,9 +15,10 @@ use std::sync::{ use tokio::sync::{mpsc, oneshot, watch, OwnedSemaphorePermit, Semaphore}; use tracing::{debug, info}; -use crate::relay::room::{ConnId, OutboundMessage}; +use crate::relay::transport::{ConnId, OutboundMessage}; -pub const MAX_PENDING_DEVICE_RPCS: usize = i32::MAX as usize; +pub const MAX_PENDING_DEVICE_RPCS: usize = 2048; +pub const MAX_PENDING_DEVICE_RPCS_PER_ACCOUNT: usize = 64; /// An online device connection belonging to a user. struct DeviceConn { @@ -57,15 +55,76 @@ struct PendingRpc { } /// The response payload from a device RPC call. -#[derive(Debug, Clone)] +#[derive(Debug)] pub struct RpcResponse { pub encrypted_data: String, pub nonce: String, + memory: OwnedSemaphorePermit, +} + +impl RpcResponse { + pub fn try_new(encrypted_data: String, nonce: String) -> Option { + static MEMORY: std::sync::OnceLock> = std::sync::OnceLock::new(); + Self::with_budget( + encrypted_data, + nonce, + MEMORY.get_or_init(|| Arc::new(Semaphore::new(256 * 1024 * 1024))), + ) + } + + fn with_budget(encrypted_data: String, nonce: String, budget: &Arc) -> Option { + // Reserve both the parsed payload and its serialized HTTP response. + // The permit follows a response through the mailbox and slow readers. + let size = encrypted_data + .len() + .checked_add(nonce.len())? + .checked_mul(2)? + .checked_add(1024)?; + let memory = Arc::clone(budget) + .try_acquire_many_owned(u32::try_from(size).ok()?) + .ok()?; + Some(Self { + encrypted_data, + nonce, + memory, + }) + } + + pub fn into_http_response(self) -> Result { + #[derive(serde::Serialize)] + struct Payload<'a> { + encrypted_data: &'a str, + nonce: &'a str, + } + let bytes = serde_json::to_vec(&Payload { + encrypted_data: &self.encrypted_data, + nonce: &self.nonce, + }) + .map_err(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR)?; + let length = bytes.len(); + let state = (axum::body::Bytes::from(bytes), self.memory); + let stream = futures_util::stream::unfold(state, |(mut bytes, permit)| async move { + if bytes.is_empty() { + return None; + } + // Do not hand a whole large body to Hyper and release its budget + // while it is still blocked on a slow socket. + let piece = bytes.split_to(bytes.len().min(64 * 1024)); + let chunk = axum::body::Bytes::copy_from_slice(&piece); + Some((Ok::<_, std::convert::Infallible>(chunk), (bytes, permit))) + }); + axum::response::Response::builder() + .header(axum::http::header::CONTENT_TYPE, "application/json") + .header(axum::http::header::CONTENT_LENGTH, length) + .body(axum::body::Body::from_stream(stream)) + .map_err(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR) + } } /// Tracks online devices grouped by `user_id` so that `device_to_device` /// messages can be routed within an account without exposing other accounts. pub struct DeviceManager { + next_connection_id: std::sync::atomic::AtomicU64, /// Serializes presence mutations with authoritative snapshot broadcasts. /// /// DashMap keeps individual registry operations safe, but a presence @@ -88,14 +147,20 @@ pub struct DeviceManager { /// correlation_id → pending RPC response sender (for HTTP→WS→HTTP bridge). pending_rpcs: DashMap, pending_rpc_permits: Arc, + pending_registration_gate: Mutex<()>, /// Starts the database-backed token revalidator exactly once, lazily from /// the first WebSocket handled inside a Tokio runtime. token_revalidator_started: AtomicBool, } impl DeviceManager { + pub fn next_connection_id(&self) -> ConnId { + self.next_connection_id.fetch_add(1, Ordering::Relaxed) + } + pub fn new() -> Arc { Arc::new(Self { + next_connection_id: std::sync::atomic::AtomicU64::new(1), presence_gate: Mutex::new(()), presence_projection_gate: tokio::sync::Mutex::new(()), users: DashMap::new(), @@ -103,6 +168,7 @@ impl DeviceManager { pending_connections: DashMap::new(), pending_rpcs: DashMap::new(), pending_rpc_permits: Arc::new(Semaphore::new(MAX_PENDING_DEVICE_RPCS)), + pending_registration_gate: Mutex::new(()), token_revalidator_started: AtomicBool::new(false), }) } @@ -232,10 +298,8 @@ impl DeviceManager { // same presence gate excludes snapshot broadcasts. Once membership is // published below, every later DevicePresence is necessarily behind // AuthOk in this socket's FIFO queue. - if pending - .tx - .try_send(OutboundMessage::text(initial_text)) - .is_err() + if !OutboundMessage::try_text(initial_text) + .is_some_and(|message| pending.tx.try_send(message).is_ok()) { let _ = pending.force_close_tx.send(true); return false; @@ -399,7 +463,10 @@ impl DeviceManager { let Some(dev) = user_devices.get(target_device_id) else { return false; }; - match dev.tx.try_send(OutboundMessage::text(text)) { + let Some(message) = OutboundMessage::try_text(text) else { + return false; + }; + match dev.tx.try_send(message) { Ok(()) => true, Err(tokio::sync::mpsc::error::TrySendError::Full(_)) => { debug!("route_message: target {target_device_id} queue full, dropping"); @@ -511,9 +578,14 @@ impl DeviceManager { }; for entry in user_devices.iter() { let tx = entry.tx.clone(); - let msg = OutboundMessage::text(&text); + let Some(msg) = OutboundMessage::try_text(&text) else { + let _ = entry.force_close_tx.send(true); + continue; + }; // best-effort; don't block the caller on a slow peer - let _ = tx.try_send(msg); + if tx.try_send(msg).is_err() { + let _ = entry.force_close_tx.send(true); + } } } @@ -533,6 +605,20 @@ impl DeviceManager { user_id: &str, target_device_id: &str, ) -> Option> { + let _registration = self + .pending_registration_gate + .lock() + .unwrap_or_else(|e| e.into_inner()); + if self.pending_rpcs.contains_key(correlation_id) + || self + .pending_rpcs + .iter() + .filter(|entry| entry.user_id == user_id) + .count() + >= MAX_PENDING_DEVICE_RPCS_PER_ACCOUNT + { + return None; + } let permit = Arc::clone(&self.pending_rpc_permits) .try_acquire_owned() .ok()?; @@ -741,21 +827,44 @@ mod tests { ); } + #[tokio::test] + async fn rpc_response_budget_survives_mailbox_and_slow_reader_and_releases_on_drop() { + use futures_util::StreamExt; + let size = 128 * 1024; + let capacity = (size + 5) * 2 + 1024; + let budget = Arc::new(Semaphore::new(capacity)); + let response = RpcResponse::with_budget("a".repeat(size), "nonce".into(), &budget).unwrap(); + assert_eq!(budget.available_permits(), 0); + assert!(RpcResponse::with_budget("x".into(), "nonce".into(), &budget).is_none()); + let manager = DeviceManager::new(); + let rx = manager.register_rpc("budget", "account", "device").unwrap(); + assert!(manager.resolve_rpc("budget", "account", "device", response)); + assert_eq!(budget.available_permits(), 0); + let mut body = rx + .await + .unwrap() + .into_http_response() + .unwrap() + .into_body() + .into_data_stream(); + assert_eq!(body.next().await.unwrap().unwrap().len(), 64 * 1024); + assert_eq!(budget.available_permits(), 0); + drop(body); + assert_eq!(budget.available_permits(), capacity); + } + #[tokio::test] async fn rpc_response_must_come_from_the_expected_account_and_device() { let mgr = DeviceManager::new(); let mut response_rx = mgr .register_rpc("corr-1", "user-1", "desktop-1") .expect("RPC registration"); - let response = RpcResponse { - encrypted_data: "ciphertext".to_string(), - nonce: "nonce".to_string(), - }; + let response = || RpcResponse::try_new("ciphertext".into(), "nonce".into()).unwrap(); - assert!(!mgr.resolve_rpc("corr-1", "user-2", "desktop-1", response.clone())); - assert!(!mgr.resolve_rpc("corr-1", "user-1", "desktop-2", response.clone())); + assert!(!mgr.resolve_rpc("corr-1", "user-2", "desktop-1", response())); + assert!(!mgr.resolve_rpc("corr-1", "user-1", "desktop-2", response())); assert!(response_rx.try_recv().is_err()); - assert!(mgr.resolve_rpc("corr-1", "user-1", "desktop-1", response)); + assert!(mgr.resolve_rpc("corr-1", "user-1", "desktop-1", response())); assert_eq!( response_rx .await @@ -766,8 +875,7 @@ mod tests { } #[test] - fn pending_device_rpcs_are_effectively_unbounded_and_permits_are_reclaimed() { - assert_eq!(MAX_PENDING_DEVICE_RPCS, i32::MAX as usize); + fn pending_device_rpcs_are_isolated_bounded_and_permits_are_reclaimed() { let mgr = DeviceManager::new(); let mut receivers = Vec::new(); for index in 0..64 { @@ -777,6 +885,12 @@ mod tests { ); } + assert!(mgr + .register_rpc("over-budget", "user-1", "desktop-1") + .is_none()); + assert!(mgr + .register_rpc("other-account", "user-2", "desktop-2") + .is_some()); mgr.cancel_rpc("corr-0"); assert!(mgr .register_rpc("after-cancel", "user-1", "desktop-1") diff --git a/src/crates/services/relay-service/src/relay/mod.rs b/src/crates/services/relay-service/src/relay/mod.rs index c08c9d0e7e..6f9c824496 100644 --- a/src/crates/services/relay-service/src/relay/mod.rs +++ b/src/crates/services/relay-service/src/relay/mod.rs @@ -1,7 +1,4 @@ -//! Core relay logic: room management and message routing. - +//! Account-scoped device presence and opaque message routing. pub mod device_manager; -pub mod room; - +pub mod transport; pub use device_manager::DeviceManager; -pub use room::RoomManager; diff --git a/src/crates/services/relay-service/src/relay/room.rs b/src/crates/services/relay-service/src/relay/room.rs deleted file mode 100644 index cbc412e7f2..0000000000 --- a/src/crates/services/relay-service/src/relay/room.rs +++ /dev/null @@ -1,578 +0,0 @@ -//! Room management for the relay server. -//! -//! Each room holds a single desktop participant connected via WebSocket. -//! Mobile clients interact through HTTP requests that the relay bridges -//! to the desktop via the WebSocket connection. The relay stores no -//! business data — it only routes messages. - -use chrono::Utc; -use dashmap::mapref::entry::Entry; -use dashmap::DashMap; -use std::sync::Arc; -use tokio::sync::{mpsc, oneshot, OwnedSemaphorePermit, Semaphore}; -use tracing::{debug, info, warn}; - -pub type ConnId = u64; -pub const MAX_PENDING_REQUESTS: usize = i32::MAX as usize; -pub const MAX_PENDING_REQUESTS_PER_ROOM: usize = i32::MAX as usize; -pub const MAX_ACTIVE_ROOMS: usize = i32::MAX as usize; - -/// Room IDs cross an untrusted WebSocket boundary and later become asset -/// namespace names. Keep them to one portable path segment so they can never -/// influence filesystem traversal in a disk-backed relay host. -pub fn is_valid_room_id(room_id: &str) -> bool { - !room_id.is_empty() - && room_id.len() <= 128 - && !matches!(room_id, "_store" | "page-data" | "pages") - && room_id - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) -} - -struct PendingRequest { - tx: oneshot::Sender, - room_id: String, - _permit: OwnedSemaphorePermit, -} - -pub struct PendingRequestGuard { - room_manager: Arc, - correlation_id: String, -} - -impl Drop for PendingRequestGuard { - fn drop(&mut self) { - self.room_manager.cancel_pending(&self.correlation_id); - } -} - -#[derive(Debug, Clone)] -pub struct OutboundMessage { - pub text: String, -} - -impl OutboundMessage { - pub fn text(text: impl Into) -> Self { - Self { text: text.into() } - } -} - -/// Payload returned by the desktop in response to a bridged HTTP request. -#[derive(Debug, Clone)] -pub struct ResponsePayload { - pub encrypted_data: String, - pub nonce: String, -} - -#[derive(Debug)] -pub struct DesktopConnection { - pub conn_id: ConnId, - #[allow(dead_code)] - pub device_id: String, - #[allow(dead_code)] - pub public_key: String, - pub tx: mpsc::Sender, - #[allow(dead_code)] - pub joined_at: i64, - pub last_heartbeat: i64, -} - -#[derive(Debug)] -pub struct RelayRoom { - pub room_id: String, - #[allow(dead_code)] - pub created_at: i64, - pub last_activity: i64, - pub desktop: Option, -} - -impl RelayRoom { - pub fn new(room_id: String) -> Self { - let now = Utc::now().timestamp(); - Self { - room_id, - created_at: now, - last_activity: now, - desktop: None, - } - } - - pub fn is_empty(&self) -> bool { - self.desktop.is_none() - } - - pub fn touch(&mut self) { - self.last_activity = Utc::now().timestamp(); - } -} - -pub async fn send_outbound_message( - tx: &mpsc::Sender, - message: OutboundMessage, -) -> bool { - match tx.send(message).await { - Ok(()) => true, - Err(_) => { - debug!("Outbound websocket channel closed before message could be sent"); - false - } - } -} - -pub struct RoomManager { - rooms: DashMap, - conn_to_room: DashMap, - next_conn_id: std::sync::atomic::AtomicU64, - pending_requests: DashMap, - pending_permits: Arc, - pending_room_counts: DashMap, -} - -impl RoomManager { - pub fn new() -> Arc { - Arc::new(Self { - rooms: DashMap::new(), - conn_to_room: DashMap::new(), - next_conn_id: std::sync::atomic::AtomicU64::new(1), - pending_requests: DashMap::new(), - pending_permits: Arc::new(Semaphore::new(MAX_PENDING_REQUESTS)), - pending_room_counts: DashMap::new(), - }) - } - - pub fn next_conn_id(&self) -> ConnId { - self.next_conn_id - .fetch_add(1, std::sync::atomic::Ordering::Relaxed) - } - - pub fn create_room( - &self, - room_id: &str, - conn_id: ConnId, - device_id: &str, - public_key: &str, - tx: mpsc::Sender, - ) -> bool { - if !is_valid_room_id(room_id) { - warn!("Rejected invalid room id"); - return false; - } - if self.rooms.len() >= MAX_ACTIVE_ROOMS && !self.rooms.contains_key(room_id) { - warn!("Rejected room creation because the active-room limit was reached"); - return false; - } - let now = Utc::now().timestamp(); - let mut room = RelayRoom::new(room_id.to_string()); - room.desktop = Some(DesktopConnection { - conn_id, - device_id: device_id.to_string(), - public_key: public_key.to_string(), - tx, - joined_at: now, - last_heartbeat: now, - }); - - // Room ids are bearer secrets embedded in pairing QR codes. A second - // socket must never be able to evict the desktop that currently owns - // one by guessing or observing the id. Re-sending create_room from the - // same socket is harmless and remains supported. - match self.rooms.entry(room_id.to_string()) { - Entry::Occupied(mut existing) => { - let owned_by_other_connection = existing - .get() - .desktop - .as_ref() - .is_some_and(|desktop| desktop.conn_id != conn_id); - if owned_by_other_connection { - warn!("Rejected attempt to replace an active relay room"); - return false; - } - existing.insert(room); - } - Entry::Vacant(vacant) => { - vacant.insert(room); - } - } - - if let Some(previous_room_id) = self - .conn_to_room - .insert(conn_id, room_id.to_string()) - .filter(|previous| previous != room_id) - { - let should_remove = - if let Some(mut previous_room) = self.rooms.get_mut(&previous_room_id) { - if previous_room - .desktop - .as_ref() - .is_some_and(|desktop| desktop.conn_id == conn_id) - { - previous_room.desktop = None; - } - previous_room.is_empty() - } else { - false - }; - if should_remove { - self.rooms.remove(&previous_room_id); - } - } - - info!("Room {room_id} created by desktop {device_id}"); - true - } - - pub async fn send_to_desktop(&self, room_id: &str, message: &str) -> bool { - let tx = if let Some(mut room) = self.rooms.get_mut(room_id) { - room.touch(); - room.desktop.as_ref().map(|desktop| desktop.tx.clone()) - } else { - None - }; - - if let Some(tx) = tx { - send_outbound_message(&tx, OutboundMessage::text(message)).await - } else { - false - } - } - - #[allow(dead_code)] - pub fn get_desktop_public_key(&self, room_id: &str) -> Option { - self.rooms - .get(room_id) - .and_then(|r| r.desktop.as_ref().map(|d| d.public_key.clone())) - } - - pub fn try_register_pending( - self: &Arc, - room_id: &str, - correlation_id: String, - ) -> Option<(PendingRequestGuard, oneshot::Receiver)> { - let permit = Arc::clone(&self.pending_permits).try_acquire_owned().ok()?; - if !self.try_acquire_room_pending(room_id) { - drop(permit); - return None; - } - - let (tx, rx) = oneshot::channel(); - let guard = PendingRequestGuard { - room_manager: Arc::clone(self), - correlation_id: correlation_id.clone(), - }; - if let Some(previous) = self.pending_requests.insert( - correlation_id, - PendingRequest { - tx, - room_id: room_id.to_string(), - _permit: permit, - }, - ) { - self.release_room_pending(&previous.room_id); - } - Some((guard, rx)) - } - - /// Resolve a pending response only when it originates from the desktop - /// socket that currently owns the associated room. Correlation ids are not - /// authorization secrets and must not be sufficient on their own. - pub fn resolve_pending_from_conn( - &self, - conn_id: ConnId, - correlation_id: &str, - payload: ResponsePayload, - ) -> bool { - let expected_room_id = self - .pending_requests - .get(correlation_id) - .map(|pending| pending.room_id.clone()); - let owns_expected_room = expected_room_id.as_ref().is_some_and(|expected| { - self.conn_to_room - .get(&conn_id) - .is_some_and(|actual| actual.value() == expected) - && self.rooms.get(expected).is_some_and(|room| { - room.desktop - .as_ref() - .is_some_and(|desktop| desktop.conn_id == conn_id) - }) - }); - if !owns_expected_room { - warn!("Rejected relay response from a socket that does not own the pending room"); - return false; - } - - if let Some((_, pending)) = self.pending_requests.remove(correlation_id) { - self.release_room_pending(&pending.room_id); - pending.tx.send(payload).is_ok() - } else { - warn!("No pending request for correlation_id={correlation_id}"); - false - } - } - - pub fn cancel_pending(&self, correlation_id: &str) { - if let Some((_, pending)) = self.pending_requests.remove(correlation_id) { - self.release_room_pending(&pending.room_id); - } - } - - fn try_acquire_room_pending(&self, room_id: &str) -> bool { - let mut count = self - .pending_room_counts - .entry(room_id.to_string()) - .or_insert(0); - if *count >= MAX_PENDING_REQUESTS_PER_ROOM { - return false; - } - *count += 1; - true - } - - fn release_room_pending(&self, room_id: &str) { - if let Entry::Occupied(mut entry) = self.pending_room_counts.entry(room_id.to_string()) { - let should_remove = { - let count = entry.get_mut(); - *count = count.saturating_sub(1); - *count == 0 - }; - if should_remove { - entry.remove(); - } - } - } - - pub fn on_disconnect(&self, conn_id: ConnId) { - if let Some((_, room_id)) = self.conn_to_room.remove(&conn_id) { - let should_remove = if let Some(mut room) = self.rooms.get_mut(&room_id) { - if room.desktop.as_ref().is_some_and(|d| d.conn_id == conn_id) { - info!("Desktop disconnected from room {room_id}"); - room.desktop = None; - } - room.is_empty() - } else { - false - }; - if should_remove { - self.rooms.remove(&room_id); - debug!("Empty room {room_id} removed"); - } - } - } - - pub fn heartbeat(&self, conn_id: ConnId) -> bool { - if let Some(room_id) = self.conn_to_room.get(&conn_id) { - if let Some(mut room) = self.rooms.get_mut(room_id.value()) { - let is_match = room.desktop.as_ref().is_some_and(|d| d.conn_id == conn_id); - if is_match { - let now = Utc::now().timestamp(); - room.last_activity = now; - if let Some(ref mut desktop) = room.desktop { - desktop.last_heartbeat = now; - } - return true; - } - } - } - false - } - - pub fn cleanup_stale_rooms(&self, ttl_secs: u64) -> Vec { - if ttl_secs == 0 { - return Vec::new(); - } - let now = Utc::now().timestamp(); - let stale_ids: Vec = self - .rooms - .iter() - .filter(|r| now.saturating_sub(r.last_activity) as u64 > ttl_secs) - .map(|r| r.room_id.clone()) - .collect(); - - for room_id in &stale_ids { - let pending_ids: Vec = self - .pending_requests - .iter() - .filter(|pending| pending.room_id == *room_id) - .map(|pending| pending.key().clone()) - .collect(); - for correlation_id in pending_ids { - self.cancel_pending(&correlation_id); - } - if let Some((_, room)) = self.rooms.remove(room_id) { - if let Some(ref desktop) = room.desktop { - self.conn_to_room.remove(&desktop.conn_id); - } - info!("Stale room {room_id} cleaned up"); - } - } - - stale_ids - } - - pub fn room_exists(&self, room_id: &str) -> bool { - self.rooms.contains_key(room_id) - } - - pub fn has_desktop(&self, room_id: &str) -> bool { - self.rooms.get(room_id).is_some_and(|r| r.desktop.is_some()) - } - - pub fn room_count(&self) -> usize { - self.rooms.len() - } - - pub fn connection_count(&self) -> usize { - self.conn_to_room.len() - } - - pub fn pending_request_count(&self) -> usize { - self.pending_requests.len() - } - - pub fn has_connection(&self, conn_id: ConnId) -> bool { - self.conn_to_room.contains_key(&conn_id) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use tokio::time::{timeout, Duration}; - - #[tokio::test] - async fn outbound_send_waits_for_bounded_queue_capacity() { - let (tx, mut rx) = mpsc::channel(1); - - assert!(send_outbound_message(&tx, OutboundMessage::text("first"),).await); - - let blocked_send = tokio::spawn({ - let tx = tx.clone(); - async move { send_outbound_message(&tx, OutboundMessage::text("second")).await } - }); - - tokio::task::yield_now().await; - assert!( - !blocked_send.is_finished(), - "bounded outbound send should apply backpressure instead of dropping" - ); - - assert_eq!(rx.recv().await.expect("first message").text, "first"); - assert!(timeout(Duration::from_secs(1), blocked_send) - .await - .expect("send should complete after capacity is released") - .expect("send task should not panic")); - assert_eq!(rx.recv().await.expect("second message").text, "second"); - } - - #[test] - fn pending_registration_capacity_is_effectively_unbounded() { - assert_eq!(MAX_PENDING_REQUESTS, i32::MAX as usize); - assert_eq!(MAX_PENDING_REQUESTS_PER_ROOM, i32::MAX as usize); - assert_eq!(MAX_ACTIVE_ROOMS, i32::MAX as usize); - - let manager = RoomManager::new(); - let mut guards = Vec::new(); - for index in 0..64 { - let room_id = format!("room-{index}"); - let (guard, _rx) = manager - .try_register_pending(&room_id, format!("pending-{index}")) - .expect("pending registration within limit"); - guards.push(guard); - } - drop(guards.pop()); - assert!(manager - .try_register_pending("after-cancel-room", "after-cancel".to_string()) - .is_some()); - - for index in 0..64 { - let (guard, _rx) = manager - .try_register_pending("room-a", format!("room-a-{index}")) - .expect("room-a pending registration within per-room limit"); - guards.push(guard); - } - assert!(manager - .try_register_pending("room-b", "room-b-still-healthy".to_string()) - .is_some()); - } - - #[test] - fn pending_room_counts_are_reclaimed_after_cancel_and_resolve() { - let manager = RoomManager::new(); - - let (_guard, _rx) = manager - .try_register_pending("room-a", "pending-a".to_string()) - .expect("pending registration"); - assert!(manager.pending_room_counts.contains_key("room-a")); - - manager.cancel_pending("pending-a"); - assert!(!manager.pending_room_counts.contains_key("room-a")); - - let (_guard, _rx) = manager - .try_register_pending("room-b", "pending-b".to_string()) - .expect("pending registration"); - let (tx, _rx) = mpsc::channel(1); - assert!(manager.create_room("room-b", 2, "desktop-b", "public-key", tx)); - assert!(manager.resolve_pending_from_conn( - 2, - "pending-b", - ResponsePayload { - encrypted_data: "encrypted".to_string(), - nonce: "nonce".to_string(), - }, - )); - assert!(!manager.pending_room_counts.contains_key("room-b")); - } - - #[test] - fn active_room_cannot_be_replaced_by_another_connection() { - let manager = RoomManager::new(); - let (tx_a, _rx_a) = mpsc::channel(1); - let (tx_b, _rx_b) = mpsc::channel(1); - - assert!(manager.create_room("room-a", 1, "desktop-a", "key-a", tx_a)); - assert!(!manager.create_room("room-a", 2, "desktop-b", "key-b", tx_b)); - assert!(manager.heartbeat(1)); - assert!(!manager.heartbeat(2)); - } - - #[test] - fn pending_response_must_come_from_owning_room_connection() { - let manager = RoomManager::new(); - let (tx_a, _rx_a) = mpsc::channel(1); - let (tx_b, _rx_b) = mpsc::channel(1); - assert!(manager.create_room("room-a", 1, "desktop-a", "key-a", tx_a)); - assert!(manager.create_room("room-b", 2, "desktop-b", "key-b", tx_b)); - let (_guard, mut rx) = manager - .try_register_pending("room-a", "pending-a".to_string()) - .expect("pending registration"); - let response = ResponsePayload { - encrypted_data: "encrypted".to_string(), - nonce: "nonce".to_string(), - }; - - assert!(!manager.resolve_pending_from_conn(2, "pending-a", response.clone())); - assert!(rx.try_recv().is_err()); - assert!(manager.resolve_pending_from_conn(1, "pending-a", response)); - } - - #[test] - fn room_ids_are_single_portable_path_segments() { - for valid in ["room-a", "ROOM_1", "0123456789abcdef"] { - assert!(is_valid_room_id(valid), "room id should be valid: {valid}"); - } - for invalid in [ - "", - "../room", - "/tmp/room", - "room/child", - "room\\child", - "_store", - "page-data", - "pages", - ] { - assert!( - !is_valid_room_id(invalid), - "room id should be rejected: {invalid}" - ); - } - } -} diff --git a/src/crates/services/relay-service/src/relay/transport.rs b/src/crates/services/relay-service/src/relay/transport.rs new file mode 100644 index 0000000000..a3c5a6deb3 --- /dev/null +++ b/src/crates/services/relay-service/src/relay/transport.rs @@ -0,0 +1,94 @@ +//! Bounded outbound transport shared by account device routing and RPC. +use std::sync::Arc; +use tokio::sync::{mpsc, OwnedSemaphorePermit, Semaphore}; +use tracing::debug; +pub type ConnId = u64; + +const OUTBOUND_MEMORY_BYTES: usize = 256 * 1024 * 1024; + +#[derive(Debug)] +pub struct OutboundMessage { + pub text: String, + _memory: Option, +} + +impl OutboundMessage { + /// Keep queued and actively written payloads inside one process-wide budget. + pub fn try_text(text: impl AsRef) -> Option { + static MEMORY: std::sync::OnceLock> = std::sync::OnceLock::new(); + Self::with_budget( + text.as_ref(), + MEMORY.get_or_init(|| Arc::new(Semaphore::new(OUTBOUND_MEMORY_BYTES))), + ) + } + + fn with_budget(text: &str, budget: &Arc) -> Option { + let size = u32::try_from(text.len().max(1)).ok()?; + let permit = Arc::clone(budget).try_acquire_many_owned(size).ok()?; + Some(Self { + text: text.to_owned(), + _memory: Some(permit), + }) + } + + #[cfg(test)] + pub fn text(text: impl Into) -> Self { + Self { + text: text.into(), + _memory: None, + } + } +} + +pub async fn send_outbound_message( + tx: &mpsc::Sender, + message: OutboundMessage, +) -> bool { + match tokio::time::timeout(std::time::Duration::from_secs(2), tx.send(message)).await { + Ok(Ok(())) => true, + _ => { + debug!("Outbound websocket channel closed before message could be sent"); + false + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tokio::time::{timeout, Duration}; + #[tokio::test] + async fn outbound_send_waits_for_bounded_queue_capacity() { + let (tx, mut rx) = mpsc::channel(1); + + assert!(send_outbound_message(&tx, OutboundMessage::text("first"),).await); + + let blocked_send = tokio::spawn({ + let tx = tx.clone(); + async move { send_outbound_message(&tx, OutboundMessage::text("second")).await } + }); + + tokio::task::yield_now().await; + assert!( + !blocked_send.is_finished(), + "bounded outbound send should apply backpressure instead of dropping" + ); + + assert_eq!(rx.recv().await.expect("first message").text, "first"); + assert!(timeout(Duration::from_secs(1), blocked_send) + .await + .expect("send should complete after capacity is released") + .expect("send task should not panic")); + assert_eq!(rx.recv().await.expect("second message").text, "second"); + } + + #[test] + fn outbound_memory_is_bounded_and_reclaimed() { + let budget = std::sync::Arc::new(tokio::sync::Semaphore::new(8)); + let first = OutboundMessage::with_budget("12345678", &budget).unwrap(); + assert!(OutboundMessage::with_budget("x", &budget).is_none()); + drop(first); + assert!(OutboundMessage::with_budget("12345678", &budget).is_some()); + assert!(OutboundMessage::with_budget("123456789", &budget).is_none()); + } +} diff --git a/src/crates/services/relay-service/src/routes/api.rs b/src/crates/services/relay-service/src/routes/api.rs index 17770bc077..62d0a8fabf 100644 --- a/src/crates/services/relay-service/src/routes/api.rs +++ b/src/crates/services/relay-service/src/routes/api.rs @@ -1,68 +1,16 @@ -//! REST API routes for the relay server. -//! -//! Provides two HTTP endpoints for mobile clients: -//! - POST /api/rooms/:room_id/pair — initiate pairing -//! - POST /api/rooms/:room_id/command — send encrypted commands -//! -//! Both endpoints bridge the HTTP request to the desktop via WebSocket -//! using correlation-based request-response matching. -//! -//! File-serving and upload endpoints use the `WebAssetStore` trait, -//! so the same handlers work for both disk-backed and memory-backed stores. - -use axum::extract::{Path, State}; -use axum::http::StatusCode; +//! Shared Relay state and public health/capability metadata. +use crate::WebAssetStore; +use axum::extract::State; use axum::Json; -use base64::{engine::general_purpose::STANDARD as B64, Engine}; -use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; -use std::collections::HashMap; +use serde::Serialize; use std::sync::Arc; -use std::time::Duration; - -use crate::relay::RoomManager; -use crate::routes::websocket::OutboundProtocol; -use crate::WebAssetStore; - -#[cfg(not(test))] -const DESKTOP_ENQUEUE_TIMEOUT: Duration = Duration::from_secs(5); -#[cfg(test)] -const DESKTOP_ENQUEUE_TIMEOUT: Duration = Duration::from_millis(25); -const MAX_IDENTIFIER_BYTES: usize = 128; -const MAX_DEVICE_NAME_BYTES: usize = 256; -const MAX_PUBLIC_KEY_BYTES: usize = 512; -const MAX_ENCRYPTED_PAYLOAD_BYTES: usize = 10 * 1024 * 1024; -const MAX_NONCE_BYTES: usize = 256; -const MAX_ROOM_WEB_FILES: usize = 4096; - -fn is_valid_identifier(value: &str) -> bool { - !value.is_empty() - && value.len() <= MAX_IDENTIFIER_BYTES - && value - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')) -} - -fn is_valid_display_text(value: &str, max_bytes: usize) -> bool { - !value.trim().is_empty() && value.len() <= max_bytes && !value.chars().any(char::is_control) -} - -fn is_valid_encrypted_payload(encrypted_data: &str, nonce: &str) -> bool { - !encrypted_data.is_empty() - && encrypted_data.len() <= MAX_ENCRYPTED_PAYLOAD_BYTES - && !nonce.is_empty() - && nonce.len() <= MAX_NONCE_BYTES - && !nonce.chars().any(char::is_control) -} #[derive(Clone)] pub struct AppState { - pub room_manager: Arc, pub start_time: std::time::Instant, pub asset_store: Arc, - /// Optional account database. When `None`, the relay runs in pure-relay - /// mode (no account features); the embedded relay passes `None`. - pub db: Option>, + /// Every Relay host owns an authenticated account/device directory. + pub db: Arc, /// Optional per-page mutable data root (KV/SQLite/blobs). Required for Page Functions data plane. pub page_data: Option, /// Page-scoped browser sessions issued after Relay account login. These @@ -91,11 +39,8 @@ pub struct HealthResponse { pub status: String, pub version: String, pub uptime_seconds: u64, - pub rooms: usize, - pub connections: usize, pub account_features: bool, pub device_connections: usize, - pub pending_room_requests: usize, pub pending_device_rpcs: usize, pub asset_store_bytes: u64, pub asset_store_max_bytes: u64, @@ -113,11 +58,8 @@ pub(crate) async fn health_check_for_host( status: "healthy".to_string(), version: host_version.to_string(), uptime_seconds: state.start_time.elapsed().as_secs(), - rooms: state.room_manager.room_count(), - connections: state.room_manager.connection_count(), - account_features: state.db.is_some(), + account_features: true, device_connections: state.device_manager.connection_count(), - pending_room_requests: state.room_manager.pending_request_count(), pending_device_rpcs: state.device_manager.pending_rpc_count(), asset_store_bytes: state.asset_store.stored_bytes(), asset_store_max_bytes: state.asset_store.max_store_bytes(), @@ -139,551 +81,6 @@ pub(crate) async fn server_info_for_host(host_version: &'static str) -> Json, - Path(room_id): Path, - Json(body): Json, -) -> Result, StatusCode> { - if !crate::relay::room::is_valid_room_id(&room_id) - || !is_valid_identifier(&body.device_id) - || !is_valid_display_text(&body.device_name, MAX_DEVICE_NAME_BYTES) - || !is_valid_display_text(&body.public_key, MAX_PUBLIC_KEY_BYTES) - { - return Err(StatusCode::BAD_REQUEST); - } - if !state.room_manager.has_desktop(&room_id) { - return Err(StatusCode::NOT_FOUND); - } - - let correlation_id = generate_correlation_id(); - let Some((_pending_guard, rx)) = state - .room_manager - .try_register_pending(&room_id, correlation_id.clone()) - else { - return Err(StatusCode::SERVICE_UNAVAILABLE); - }; - - let ws_msg = serde_json::to_string(&OutboundProtocol::PairRequest { - correlation_id: correlation_id.clone(), - public_key: body.public_key, - device_id: body.device_id, - device_name: body.device_name, - }) - .unwrap_or_default(); - - if let Err(status) = send_to_desktop_with_backpressure_timeout(&state, &room_id, &ws_msg).await - { - state.room_manager.cancel_pending(&correlation_id); - return Err(status); - } - - match tokio::time::timeout(Duration::from_secs(30), rx).await { - Ok(Ok(payload)) => Ok(Json(PairResponse { - encrypted_data: payload.encrypted_data, - nonce: payload.nonce, - })), - Err(_) => { - state.room_manager.cancel_pending(&correlation_id); - Err(StatusCode::GATEWAY_TIMEOUT) - } - Ok(Err(_)) => { - state.room_manager.cancel_pending(&correlation_id); - Err(StatusCode::GATEWAY_TIMEOUT) - } - } -} - -#[derive(Deserialize)] -pub struct CommandRequest { - pub encrypted_data: String, - pub nonce: String, -} - -#[derive(Serialize)] -pub struct CommandResponse { - pub encrypted_data: String, - pub nonce: String, -} - -/// `POST /api/rooms/:room_id/command` -/// -/// Mobile sends an encrypted command. The relay forwards it to the desktop -/// via WebSocket, waits for the encrypted response, and returns it. -pub async fn command( - State(state): State, - Path(room_id): Path, - Json(body): Json, -) -> Result, StatusCode> { - if !crate::relay::room::is_valid_room_id(&room_id) - || !is_valid_encrypted_payload(&body.encrypted_data, &body.nonce) - { - return Err(StatusCode::BAD_REQUEST); - } - if !state.room_manager.has_desktop(&room_id) { - return Err(StatusCode::NOT_FOUND); - } - - let correlation_id = generate_correlation_id(); - let Some((_pending_guard, rx)) = state - .room_manager - .try_register_pending(&room_id, correlation_id.clone()) - else { - return Err(StatusCode::SERVICE_UNAVAILABLE); - }; - - let ws_msg = serde_json::to_string(&OutboundProtocol::Command { - correlation_id: correlation_id.clone(), - encrypted_data: body.encrypted_data, - nonce: body.nonce, - }) - .unwrap_or_default(); - - if let Err(status) = send_to_desktop_with_backpressure_timeout(&state, &room_id, &ws_msg).await - { - state.room_manager.cancel_pending(&correlation_id); - return Err(status); - } - - match tokio::time::timeout(Duration::from_secs(60), rx).await { - Ok(Ok(payload)) => Ok(Json(CommandResponse { - encrypted_data: payload.encrypted_data, - nonce: payload.nonce, - })), - Err(_) => { - state.room_manager.cancel_pending(&correlation_id); - Err(StatusCode::GATEWAY_TIMEOUT) - } - Ok(Err(_)) => { - state.room_manager.cancel_pending(&correlation_id); - Err(StatusCode::GATEWAY_TIMEOUT) - } - } -} - -async fn send_to_desktop_with_backpressure_timeout( - state: &AppState, - room_id: &str, - ws_msg: &str, -) -> Result<(), StatusCode> { - match tokio::time::timeout( - DESKTOP_ENQUEUE_TIMEOUT, - state.room_manager.send_to_desktop(room_id, ws_msg), - ) - .await - { - Ok(true) => Ok(()), - Ok(false) | Err(_) => Err(StatusCode::SERVICE_UNAVAILABLE), - } -} - -fn generate_correlation_id() -> String { - let bytes: [u8; 16] = rand::random(); - bytes.iter().map(|b| format!("{b:02x}")).collect() -} - -// ── Per-room mobile-web upload & serving ─────────────────────────────────── - -fn hex_sha256(data: &[u8]) -> String { - let mut hasher = Sha256::new(); - hasher.update(data); - format!("{:x}", hasher.finalize()) -} - -#[derive(Deserialize)] -pub struct UploadWebRequest { - pub files: HashMap, -} - -/// `POST /api/rooms/:room_id/upload-web` -pub async fn upload_web( - State(state): State, - Path(room_id): Path, - Json(body): Json, -) -> Result, StatusCode> { - if !state.room_manager.room_exists(&room_id) { - return Err(StatusCode::NOT_FOUND); - } - if body.files.len() > MAX_ROOM_WEB_FILES - || body - .files - .keys() - .any(|path| crate::validated_asset_relative_path(path).is_err()) - { - return Err(StatusCode::BAD_REQUEST); - } - - let asset_store = Arc::clone(&state.asset_store); - let room_id_for_io = room_id.clone(); - let (written, reused) = tokio::task::spawn_blocking(move || { - process_upload_web(asset_store, &room_id_for_io, body.files) - }) - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)??; - - tracing::info!("Room {room_id}: upload-web complete (new={written}, reused={reused})"); - Ok(Json(serde_json::json!({ - "status": "ok", - "files_written": written, - "files_reused": reused - }))) -} - -// ── Incremental upload protocol ──────────────────────────────────────────── - -#[derive(Deserialize)] -pub struct FileManifestEntry { - pub path: String, - pub hash: String, - #[allow(dead_code)] - pub size: u64, -} - -#[derive(Deserialize)] -pub struct CheckWebFilesRequest { - pub files: Vec, -} - -#[derive(Serialize)] -pub struct CheckWebFilesResponse { - pub needed: Vec, - pub existing_count: usize, - pub total_count: usize, -} - -/// `POST /api/rooms/:room_id/check-web-files` -pub async fn check_web_files( - State(state): State, - Path(room_id): Path, - Json(body): Json, -) -> Result, StatusCode> { - if !state.room_manager.room_exists(&room_id) { - return Err(StatusCode::NOT_FOUND); - } - if body.files.len() > MAX_ROOM_WEB_FILES - || body.files.iter().any(|entry| { - crate::validated_asset_relative_path(&entry.path).is_err() - || !crate::is_valid_content_hash(&entry.hash) - }) - { - return Err(StatusCode::BAD_REQUEST); - } - - let asset_store = Arc::clone(&state.asset_store); - let room_id_for_io = room_id.clone(); - let response = tokio::task::spawn_blocking(move || { - process_check_web_files(asset_store, &room_id_for_io, body.files) - }) - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - - tracing::info!( - "Room {room_id}: check-web-files total={total_count}, existing={existing_count}, needed={needed_count}", - total_count = response.total_count, - existing_count = response.existing_count, - needed_count = response.needed.len() - ); - - Ok(Json(response)) -} - -#[derive(Deserialize)] -pub struct UploadWebFilesEntry { - pub content: String, - pub hash: String, -} - -#[derive(Deserialize)] -pub struct UploadWebFilesRequest { - pub files: HashMap, -} - -/// `POST /api/rooms/:room_id/upload-web-files` -pub async fn upload_web_files( - State(state): State, - Path(room_id): Path, - Json(body): Json, -) -> Result, StatusCode> { - if !state.room_manager.room_exists(&room_id) { - return Err(StatusCode::NOT_FOUND); - } - if body.files.len() > MAX_ROOM_WEB_FILES - || body.files.iter().any(|(path, entry)| { - crate::validated_asset_relative_path(path).is_err() - || !crate::is_valid_content_hash(&entry.hash) - }) - { - return Err(StatusCode::BAD_REQUEST); - } - - let asset_store = Arc::clone(&state.asset_store); - let room_id_for_io = room_id.clone(); - let stored = tokio::task::spawn_blocking(move || { - process_upload_web_files(asset_store, &room_id_for_io, body.files) - }) - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)??; - - tracing::info!("Room {room_id}: upload-web-files stored {stored} new files"); - Ok(Json( - serde_json::json!({ "status": "ok", "files_stored": stored }), - )) -} - -/// `GET /r/{*rest}` — serve per-room mobile-web static files. -pub async fn serve_room_web_catchall( - State(state): State, - Path(rest): Path, -) -> Result { - use axum::body::Body; - use axum::http::header; - use axum::response::IntoResponse; - - let rest = rest.trim_start_matches('/'); - let (room_id, file_path) = match rest.find('/') { - Some(idx) => (&rest[..idx], &rest[idx + 1..]), - None => (rest, ""), - }; - - if room_id.is_empty() { - return Err(StatusCode::NOT_FOUND); - } - - let lookup_path = if file_path.is_empty() { - "index.html" - } else { - file_path - } - .to_string(); - crate::validated_asset_relative_path(room_id).map_err(|_| StatusCode::BAD_REQUEST)?; - crate::validated_asset_relative_path(&lookup_path).map_err(|_| StatusCode::BAD_REQUEST)?; - - let asset_store = Arc::clone(&state.asset_store); - let room_id_for_io = room_id.to_string(); - let lookup_path_for_io = lookup_path.clone(); - let content = tokio::task::spawn_blocking(move || { - asset_store.get_file(&room_id_for_io, &lookup_path_for_io) + protocol_version: 3, }) - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? - .ok_or(StatusCode::NOT_FOUND)?; - - let mime = mime_from_path(&lookup_path); - Ok(( - [ - (header::CONTENT_TYPE, mime), - (header::X_FRAME_OPTIONS, "DENY"), - ], - Body::from(content), - ) - .into_response()) -} - -fn process_upload_web( - asset_store: Arc, - room_id: &str, - files: HashMap, -) -> Result<(usize, usize), StatusCode> { - let mut written = 0usize; - let mut reused = 0usize; - for (rel_path, b64_content) in files { - crate::validated_asset_relative_path(&rel_path).map_err(|_| StatusCode::BAD_REQUEST)?; - let decoded = B64 - .decode(b64_content) - .map_err(|_| StatusCode::BAD_REQUEST)?; - let hash = hex_sha256(&decoded); - - if !asset_store.has_content(&hash) { - asset_store - .store_content(&hash, decoded) - .map_err(crate::asset_store_error_status)?; - written += 1; - } else { - reused += 1; - } - - asset_store - .map_to_room(room_id, &rel_path, &hash) - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - } - - Ok((written, reused)) -} - -fn process_check_web_files( - asset_store: Arc, - room_id: &str, - files: Vec, -) -> CheckWebFilesResponse { - let mut needed = Vec::new(); - let mut existing_count = 0usize; - let total_count = files.len(); - - for entry in files { - if crate::validated_asset_relative_path(&entry.path).is_err() - || !crate::is_valid_content_hash(&entry.hash) - { - needed.push(entry.path); - continue; - } - if asset_store.has_content(&entry.hash) { - existing_count += 1; - let _ = asset_store.map_to_room(room_id, &entry.path, &entry.hash); - } else { - needed.push(entry.path); - } - } - - CheckWebFilesResponse { - needed, - existing_count, - total_count, - } -} - -fn process_upload_web_files( - asset_store: Arc, - room_id: &str, - files: HashMap, -) -> Result { - let mut stored = 0usize; - for (rel_path, entry) in files { - crate::validated_asset_relative_path(&rel_path).map_err(|_| StatusCode::BAD_REQUEST)?; - let decoded = B64 - .decode(&entry.content) - .map_err(|_| StatusCode::BAD_REQUEST)?; - let actual_hash = hex_sha256(&decoded); - if actual_hash != entry.hash { - tracing::warn!( - "Room {room_id}: hash mismatch for {rel_path} (expected={}, actual={actual_hash})", - entry.hash - ); - return Err(StatusCode::BAD_REQUEST); - } - - if !asset_store.has_content(&actual_hash) { - asset_store - .store_content(&actual_hash, decoded) - .map_err(crate::asset_store_error_status)?; - stored += 1; - } - - asset_store - .map_to_room(room_id, &rel_path, &actual_hash) - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - } - - Ok(stored) -} - -fn mime_from_path(p: &str) -> &'static str { - match p.rsplit('.').next() { - Some("html") => "text/html; charset=utf-8", - Some("js") => "application/javascript; charset=utf-8", - Some("css") => "text/css; charset=utf-8", - Some("json") => "application/json", - Some("png") => "image/png", - Some("svg") => "image/svg+xml", - Some("ico") => "image/x-icon", - Some("woff2") => "font/woff2", - Some("woff") => "font/woff", - Some("ttf") => "font/ttf", - Some("wasm") => "application/wasm", - _ => "application/octet-stream", - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::relay::room::OutboundMessage; - use crate::MemoryAssetStore; - use axum::extract::{Path, State}; - use axum::Json; - use std::collections::HashMap; - use tokio::sync::mpsc; - - fn test_state(room_manager: Arc) -> AppState { - AppState { - room_manager, - start_time: std::time::Instant::now(), - asset_store: Arc::new(MemoryAssetStore::new()), - db: None, - page_data: None, - page_access_manager: Arc::new(crate::routes::pages::PageAccessManager::new()), - page_upload_manager: Arc::new(crate::routes::pages::PageUploadManager::new()), - page_execution_guard: Arc::new(crate::page_execution::PageExecutionGuard::new()), - login_rate_limiter: Arc::new(crate::routes::auth::LoginRateLimiter::new()), - device_manager: crate::relay::DeviceManager::new(), - cors_allow_origins: Arc::new(Vec::new()), - page_browser_auth: None, - } - } - - #[tokio::test] - async fn pair_reports_backpressure_before_response_timeout() { - let room_manager = RoomManager::new(); - let (tx, _rx) = mpsc::channel(1); - tx.send(OutboundMessage::text("queued")) - .await - .expect("queue should accept first message"); - room_manager.create_room("room-a", 1, "desktop-a", "public-key", tx); - - let result = tokio::time::timeout( - Duration::from_millis(100), - pair( - State(test_state(room_manager)), - Path("room-a".to_string()), - Json(PairRequest { - public_key: "mobile-key".to_string(), - device_id: "mobile-a".to_string(), - device_name: "Mobile A".to_string(), - }), - ), - ) - .await - .expect("backpressure should return before the response timeout"); - - assert!(matches!(result, Err(StatusCode::SERVICE_UNAVAILABLE))); - } - - #[tokio::test] - async fn room_web_upload_rejects_absolute_file_paths() { - let room_manager = RoomManager::new(); - let (tx, _rx) = mpsc::channel(1); - assert!(room_manager.create_room("room-a", 1, "desktop-a", "public-key", tx)); - let mut files = HashMap::new(); - files.insert("/tmp/relay-owned".to_string(), B64.encode(b"owned")); - - let result = upload_web( - State(test_state(room_manager)), - Path("room-a".to_string()), - Json(UploadWebRequest { files }), - ) - .await; - - assert!(matches!(result, Err(StatusCode::BAD_REQUEST))); - } } diff --git a/src/crates/services/relay-service/src/routes/auth.rs b/src/crates/services/relay-service/src/routes/auth.rs index c7551b3a03..ee300f90df 100644 --- a/src/crates/services/relay-service/src/routes/auth.rs +++ b/src/crates/services/relay-service/src/routes/auth.rs @@ -1,27 +1,14 @@ -//! Account authentication endpoints for the relay server. -//! -//! The relay stays zero-knowledge: it never sees the plaintext password or -//! the master key. Clients derive a KEK from the password (Argon2id) locally, -//! wrap a random master key, and send only: -//! - `password_hash` (Argon2id over a separate salt, for server-side verify) -//! - `wrapped_master_key` (AES-GCM(KEK, master_key), server stores as-is) -//! -//! Brute-force protection is layered: -//! - per-account exponential-backoff lockout (in the `users` table) -//! - per-IP sliding-window rate limit (in-memory) -//! - Argon2id high parameters slow offline attacks (client-enforced) +//! GitHub identity exchange for versioned OpenBitFun Relay device sessions. use axum::extract::{ConnectInfo, State}; -use axum::http::{HeaderMap, StatusCode}; +use axum::http::{header, HeaderMap, StatusCode}; use axum::{Extension, Json}; use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; use chrono::Utc; use dashmap::DashMap; use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; use std::net::SocketAddr; use std::sync::OnceLock; -use subtle::ConstantTimeEq; use crate::db::{AuthToken, DeviceRow, UserRow}; use crate::routes::api::AppState; @@ -30,10 +17,7 @@ use crate::routes::api::AppState; /// credential-stuffing where one IP tries many usernames). const MAX_LOGIN_ATTEMPTS_PER_MIN: usize = 10; /// Max challenge requests per IP per minute (stops bulk salt harvesting). -const MAX_CHALLENGE_PER_MIN: usize = 20; const MAX_RATE_LIMIT_BUCKETS: usize = 50_000; -const MAX_USERNAME_BYTES: usize = 128; -const MAX_PASSWORD_HASH_BYTES: usize = 128; const MAX_DEVICE_ID_BYTES: usize = 128; const MAX_DEVICE_NAME_BYTES: usize = 256; @@ -49,14 +33,6 @@ fn valid_device_id(value: &str) -> bool { .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')) } -fn valid_password_hash(value: &str) -> bool { - !value.is_empty() - && value.len() <= MAX_PASSWORD_HASH_BYTES - && value.bytes().all(|byte| { - byte.is_ascii_alphanumeric() || matches!(byte, b'+' | b'/' | b'=' | b'-' | b'_') - }) -} - fn valid_login_request_id(value: &str) -> bool { uuid::Uuid::parse_str(value).is_ok() } @@ -67,38 +43,6 @@ fn valid_optional_device_kind(value: Option<&str>) -> bool { value.is_none_or(crate::db::is_valid_device_kind) } -fn decoy_login_challenge(username: &str) -> LoginChallengeResponse { - static SECRET: OnceLock<[u8; 32]> = OnceLock::new(); - let secret = SECRET.get_or_init(rand::random); - let material = |label: &[u8]| { - let mut hasher = Sha256::new(); - hasher.update(secret); - hasher.update(label); - hasher.update(username.as_bytes()); - hasher.finalize() - }; - let salt_material = material(b"salt"); - let kdf_salt_material = material(b"kdf-salt"); - let ciphertext_head = material(b"wrapped-key-1"); - let ciphertext_tail = material(b"wrapped-key-2"); - let nonce_material = material(b"nonce"); - let mut ciphertext = Vec::with_capacity(48); - ciphertext.extend_from_slice(&ciphertext_head); - ciphertext.extend_from_slice(&ciphertext_tail[..16]); - - LoginChallengeResponse { - salt: BASE64.encode(&salt_material[..16]), - kdf_salt: BASE64.encode(&kdf_salt_material[..16]), - argon2_params: r#"{"m":16384,"t":3,"p":4}"#.to_string(), - wrapped_master_key: format!( - "{}.{}", - BASE64.encode(ciphertext), - BASE64.encode(&nonce_material[..12]) - ), - login_idempotency_supported: true, - } -} - // ── IP rate limiter (sliding window, in-memory) ───────────────────────── /// Per-IP sliding-window rate limiter. In-memory only; resets on restart, @@ -123,7 +67,7 @@ impl LoginRateLimiter { /// Record an attempt for one rate-limit scope and return `true` if the IP /// is still under the per-minute limit. An exact replay key is counted once /// so an ambiguous idempotent response cannot consume the full login budget. - fn check_and_record( + pub(crate) fn check_and_record( &self, scope: &str, ip: &str, @@ -172,7 +116,7 @@ impl Default for LoginRateLimiter { /// Extract the client IP from `X-Forwarded-For` (first hop) or fall back to a /// static bucket so all headerless requests share one limiter entry. -fn client_ip(headers: &HeaderMap, peer_addr: Option) -> String { +pub(crate) fn client_ip(headers: &HeaderMap, peer_addr: Option) -> String { let Some(peer_addr) = peer_addr else { return "unknown".to_string(); }; @@ -203,36 +147,19 @@ pub struct AuthResponse { pub user_id: String, } -#[derive(Deserialize)] -pub struct LoginChallengeRequest { - pub username: String, -} - -#[derive(Serialize, Deserialize)] -pub struct LoginChallengeResponse { - pub salt: String, - pub kdf_salt: String, - pub argon2_params: String, - pub wrapped_master_key: String, - pub login_idempotency_supported: bool, -} - #[derive(Deserialize)] pub struct LoginRequest { - pub username: String, - pub password_hash: String, + pub access_token: String, pub device_id: String, pub device_name: String, - /// `desktop` | `mobile` | `watch`. Absent from clients that predate the - /// field; see `device_kind_is_desktop` for how those rows are read. - #[serde(default)] - pub device_kind: Option, - #[serde(default)] - pub request_id: Option, + pub device_kind: String, + pub public_key: String, + pub request_id: String, } #[derive(Deserialize)] pub struct ProvisionDeviceRequest { + pub public_key: String, pub device_id: String, pub device_name: String, #[serde(default)] @@ -264,226 +191,168 @@ fn err(error: &str, status: StatusCode) -> (StatusCode, Json) { ) } -pub(crate) async fn verify_password_hash_credentials( - state: &AppState, - peer_addr: Option, - headers: &HeaderMap, - username: &str, - password_hash: &str, - rate_limit_replay_key: Option<&str>, -) -> Result)> { - let Some(db) = state.db.as_ref() else { - return Err(err( - "account features disabled", - StatusCode::NOT_IMPLEMENTED, - )); - }; - - let ip = client_ip(headers, peer_addr); - if !state.login_rate_limiter.check_and_record( - "credentials", - &ip, - MAX_LOGIN_ATTEMPTS_PER_MIN, - rate_limit_replay_key, - ) { - return Err(err( - "too many login attempts from this IP", - StatusCode::TOO_MANY_REQUESTS, - )); +fn identity_verifier( + injected: Option<&crate::identity::IdentityVerifier>, +) -> Result<&crate::identity::IdentityVerifier, (StatusCode, Json)> { + static VERIFIER: OnceLock> = OnceLock::new(); + if let Some(verifier) = injected { + return Ok(verifier); } + VERIFIER + .get_or_init(|| { + crate::identity::IdentityVerifier::new() + .map_err(|_| "initialization failed".to_string()) + }) + .as_ref() + .map_err(|_| { + err( + "identity service unavailable", + StatusCode::SERVICE_UNAVAILABLE, + ) + }) +} - if !valid_bounded_text(username, MAX_USERNAME_BYTES) || !valid_password_hash(password_hash) { - return Err(err("invalid login parameters", StatusCode::BAD_REQUEST)); - } - - let user = UserRow::find_by_username(db, username.trim()) - .await - .map_err(|error| { - tracing::error!("login: db error: {error}"); - err("internal error", StatusCode::INTERNAL_SERVER_ERROR) - })? - .ok_or_else(|| err("invalid username or password", StatusCode::UNAUTHORIZED))?; - - if user.is_locked() { - let retry = user.locked_until - Utc::now().timestamp(); - return Err(( - StatusCode::TOO_MANY_REQUESTS, - Json(ErrorResponse { - error: "account temporarily locked, try later".to_string(), - retry_after_secs: Some(retry.max(0)), - }), - )); - } +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GithubPollRequest { + transaction_id: String, + transaction_secret: String, +} - // The browser or native client already paid the Argon2id cost. Compare - // the fixed secret without a data-dependent early exit. - let password_matches = user.password_hash.len() == password_hash.len() - && bool::from( - user.password_hash - .as_bytes() - .ct_eq(password_hash.as_bytes()), - ); - if !password_matches { - let locked_until = UserRow::record_failed_attempt(db, &user.user_id) - .await - .map_err(|error| { - tracing::error!("login: failed to record attempt: {error}"); - err("internal error", StatusCode::INTERNAL_SERVER_ERROR) - })?; - let now = Utc::now().timestamp(); - if locked_until > now { - return Err(( - StatusCode::TOO_MANY_REQUESTS, - Json(ErrorResponse { - error: "too many failed attempts, account locked".to_string(), - retry_after_secs: Some(locked_until - now), - }), - )); - } +pub(crate) async fn github_start( + State(state): State, + connect_info: Option>>, + headers: HeaderMap, + verifier: Option>, +) -> Result, (StatusCode, Json)> { + let ip = client_ip( + &headers, + connect_info.map(|Extension(ConnectInfo(addr))| addr), + ); + if !state + .login_rate_limiter + .check_and_record("github-start", &ip, 10, None) + { return Err(err( - "invalid username or password", - StatusCode::UNAUTHORIZED, + "too many sign-in attempts", + StatusCode::TOO_MANY_REQUESTS, )); } - - UserRow::reset_failed_attempts(db, &user.user_id) + identity_verifier(verifier.as_ref().map(|v| &v.0))? + .start_auth() .await - .map_err(|error| { - tracing::error!("login: failed to reset attempts: {error}"); - err("internal error", StatusCode::INTERNAL_SERVER_ERROR) - })?; - Ok(user) + .map(Json) + .map_err(|status| err("GitHub sign-in could not be started", status)) } -// ── Handlers ──────────────────────────────────────────────────────────── - -/// `POST /api/auth/login/challenge` — fetch KDF params + wrapped master key -/// so the client can derive the KEK locally and attempt decryption. -pub async fn login_challenge( +pub(crate) async fn github_poll( State(state): State, connect_info: Option>>, headers: HeaderMap, - Json(body): Json, -) -> Result, (StatusCode, Json)> { - let Some(db) = state.db.as_ref() else { - return Err(err( - "account features disabled", - StatusCode::NOT_IMPLEMENTED, - )); - }; - + verifier: Option>, + Json(body): Json, +) -> Result, (StatusCode, Json)> { let ip = client_ip( &headers, connect_info.map(|Extension(ConnectInfo(addr))| addr), ); if !state .login_rate_limiter - .check_and_record("challenge", &ip, MAX_CHALLENGE_PER_MIN, None) + .check_and_record("github-poll", &ip, 120, None) { + return Err(err("too many sign-in polls", StatusCode::TOO_MANY_REQUESTS)); + } + identity_verifier(verifier.as_ref().map(|v| &v.0))? + .poll_auth(&body.transaction_id, &body.transaction_secret) + .await + .map(Json) + .map_err(|status| err("GitHub sign-in could not be checked", status)) +} + +pub(crate) async fn verify_identity_credentials( + state: &AppState, + peer_addr: Option, + headers: &HeaderMap, + access_token: &str, + injected_verifier: Option<&crate::identity::IdentityVerifier>, +) -> Result)> { + let db = state.db.as_ref(); + if !state.login_rate_limiter.check_and_record( + "identity", + &client_ip(headers, peer_addr), + MAX_LOGIN_ATTEMPTS_PER_MIN, + None, + ) { return Err(err( - "too many requests, try later", + "too many login attempts", StatusCode::TOO_MANY_REQUESTS, )); } - - if !valid_bounded_text(&body.username, MAX_USERNAME_BYTES) { - return Err(err("invalid username", StatusCode::BAD_REQUEST)); - } - - let username = body.username.trim(); - let user = UserRow::find_by_username(db, username).await.map_err(|e| { - tracing::error!("challenge: db error: {e}"); - err("internal error", StatusCode::INTERNAL_SERVER_ERROR) + let verifier = identity_verifier(injected_verifier)?; + let identity = verifier.verify(access_token).await.map_err(|status| { + err( + if status == StatusCode::UNAUTHORIZED { + "Sign in with GitHub to continue" + } else { + "identity service unavailable" + }, + status, + ) })?; - - // Unknown accounts receive a deterministic, process-keyed decoy with the - // same shape and KDF cost as a real challenge. The client then fails with - // the same local "invalid username or password" path, without exposing a - // bulk username-enumeration oracle at this endpoint. - let Some(user) = user else { - return Ok(Json(decoy_login_challenge(username))); - }; - - Ok(Json(LoginChallengeResponse { - salt: user.salt, - kdf_salt: user.kdf_salt, - argon2_params: user.argon2_params, - wrapped_master_key: user.wrapped_master_key, - login_idempotency_supported: true, - })) + UserRow::upsert_verified(db, &identity.github_id.to_string(), &identity.login) + .await + .map_err(|error| { + tracing::error!("Identity persistence failed: {error}"); + err("internal error", StatusCode::INTERNAL_SERVER_ERROR) + }) } -/// `POST /api/auth/login` — verify the password hash and issue a token. -pub async fn login( +/// Exchange a shared OpenBitFun GitHub session for a device-scoped relay token. +pub(crate) async fn login( State(state): State, connect_info: Option>>, headers: HeaderMap, + verifier: Option>, Json(body): Json, ) -> Result, (StatusCode, Json)> { + let public_key = BASE64.decode(&body.public_key).ok(); if !valid_device_id(&body.device_id) || !valid_bounded_text(&body.device_name, MAX_DEVICE_NAME_BYTES) - || !valid_optional_device_kind(body.device_kind.as_deref()) - || body - .request_id - .as_deref() - .is_some_and(|request_id| !valid_login_request_id(request_id)) + || !crate::db::is_valid_device_kind(&body.device_kind) + || !valid_login_request_id(&body.request_id) + || !public_key + .as_ref() + .is_some_and(|key| key.len() == 32 && key.iter().any(|b| *b != 0)) { return Err(err("invalid login parameters", StatusCode::BAD_REQUEST)); } - let db = state - .db - .as_ref() - .ok_or_else(|| err("account features disabled", StatusCode::NOT_IMPLEMENTED))?; - let rate_limit_replay_key = body.request_id.as_ref().map(|request_id| { - let mut hasher = Sha256::new(); - for value in [ - request_id.as_str(), - body.username.as_str(), - body.password_hash.as_str(), - body.device_id.as_str(), - body.device_name.as_str(), - ] { - hasher.update((value.len() as u64).to_be_bytes()); - hasher.update(value.as_bytes()); - } - BASE64.encode(hasher.finalize()) - }); - let user = verify_password_hash_credentials( + let user = verify_identity_credentials( &state, connect_info.map(|Extension(ConnectInfo(addr))| addr), &headers, - &body.username, - &body.password_hash, - rate_limit_replay_key.as_deref(), + &body.access_token, + verifier.as_ref().map(|v| &v.0), ) .await?; - + let db = state.db.as_ref(); DeviceRow::upsert( db, &body.device_id, &user.user_id, &body.device_name, - body.device_kind.as_deref(), - None, + Some(&body.device_kind), + Some(&body.public_key), ) .await - .map_err(|e| { - tracing::error!("login: failed to upsert device: {e}"); - err("internal error", StatusCode::INTERNAL_SERVER_ERROR) - })?; - - let token = match body.request_id.as_deref() { - Some(request_id) => { - AuthToken::create_idempotent(db, &user.user_id, &body.device_id, request_id).await - } - None => AuthToken::create(db, &user.user_id, &body.device_id).await, - } - .map_err(|e| { - tracing::error!("login: failed to create token: {e}"); - err("internal error", StatusCode::INTERNAL_SERVER_ERROR) + .map_err(|error| { + err( + "device registration failed", + registration_error_status(&error), + ) })?; - - tracing::info!("Account login: user_id={}", user.user_id); + let token = AuthToken::create_idempotent(db, &user.user_id, &body.device_id, &body.request_id) + .await + .map_err(|error| err("token creation failed", registration_error_status(&error)))?; Ok(Json(AuthResponse { token: token.token, user_id: user.user_id, @@ -492,10 +361,7 @@ pub async fn login( /// `POST /api/auth/logout` — revoke the caller's token on the relay. pub async fn logout(State(state): State, headers: HeaderMap) -> StatusCode { - let db = match state.db.as_ref() { - Some(db) => db, - None => return StatusCode::NOT_IMPLEMENTED, - }; + let db = state.db.as_ref(); let token = headers .get(axum::http::header::AUTHORIZATION) .and_then(|v| v.to_str().ok()) @@ -525,7 +391,7 @@ pub async fn logout(State(state): State, headers: HeaderMap) -> Status // Delete the token row if let Err(error) = sqlx::query("DELETE FROM auth_tokens WHERE token = ?") .bind(&token) - .execute(&**db) + .execute(db) .await { tracing::error!(%error, "Failed to revoke account token"); @@ -588,11 +454,17 @@ pub async fn logout(State(state): State, headers: HeaderMap) -> Status /// `device_id` for lifetime tracking, but is limited to device discovery and /// RPC. It cannot open a device WebSocket, mint more credentials, delete a /// device, or access account sync/page APIs. +#[derive(Deserialize)] +pub struct DelegateRequest { + pub public_key: String, +} + pub async fn delegate( State(state): State, headers: HeaderMap, + Json(body): Json, ) -> Result, StatusCode> { - let db = state.db.as_ref().ok_or(StatusCode::NOT_IMPLEMENTED)?; + let db = state.db.as_ref(); let token = headers .get(axum::http::header::AUTHORIZATION) .and_then(|v| v.to_str().ok()) @@ -609,11 +481,19 @@ pub async fn delegate( return Err(StatusCode::FORBIDDEN); } + let public_key = BASE64.decode(&body.public_key).ok(); + if !public_key + .as_ref() + .is_some_and(|key| key.len() == 32 && key.iter().any(|byte| *byte != 0)) + { + return Err(StatusCode::BAD_REQUEST); + } // Issue a capability-limited token for the same account and bind its // lifetime to the delegating device row. - let new_token = AuthToken::create_delegated(db, &auth.user_id, &auth.device_id) - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let new_token = + AuthToken::create_keyed_delegated(db, &auth.user_id, &auth.device_id, &body.public_key) + .await + .map_err(|error| registration_error_status(&error))?; tracing::info!( "Delegated token for user_id={} device_id={}", @@ -638,7 +518,11 @@ pub async fn provision_device( headers: HeaderMap, Json(body): Json, ) -> Result, (StatusCode, Json)> { - if body.device_id.len() != 32 + let public_key = BASE64.decode(&body.public_key).ok(); + if !public_key + .as_ref() + .is_some_and(|key| key.len() == 32 && key.iter().any(|b| *b != 0)) + || body.device_id.len() != 32 || !body .device_id .bytes() @@ -653,10 +537,7 @@ pub async fn provision_device( )); } - let db = state - .db - .as_ref() - .ok_or_else(|| err("account features disabled", StatusCode::NOT_IMPLEMENTED))?; + let db = state.db.as_ref(); let token = headers .get(axum::http::header::AUTHORIZATION) .and_then(|value| value.to_str().ok()) @@ -688,11 +569,15 @@ pub async fn provision_device( .unwrap_or(crate::db::DEVICE_KIND_DESKTOP), ), &body.request_id, + &body.public_key, ) .await .map_err(|error| { tracing::error!(%error, "Failed to provision account device"); - err("internal error", StatusCode::INTERNAL_SERVER_ERROR) + err( + "device registration unavailable", + registration_error_status(&error), + ) })? .ok_or_else(|| { err( @@ -713,11 +598,49 @@ pub async fn provision_device( })) } +/// Validated principal extracted from the bearer token. +pub struct AuthUser { + pub user_id: String, + #[allow(dead_code)] + pub device_id: String, +} + +/// Validate the bearer token in `headers`; returns the owning user/device. +pub async fn validate_auth(state: &AppState, headers: &HeaderMap) -> Result { + let token = extract_bearer_token(headers).ok_or(StatusCode::UNAUTHORIZED)?; + validate_token(state, &token).await +} + +/// Extract `Bearer` token from the `Authorization` header. +pub fn extract_bearer_token(headers: &HeaderMap) -> Option { + headers + .get(header::AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + .and_then(|s| s.strip_prefix("Bearer ")) + .map(|t| t.trim().to_string()) + .filter(|t| !t.is_empty()) +} + +/// Validate a raw token string against the account database. +pub async fn validate_token(state: &AppState, token: &str) -> Result { + let db = state.db.as_ref(); + let auth = AuthToken::find(db, token) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .ok_or(StatusCode::UNAUTHORIZED)?; + if !auth.is_device_token() { + return Err(StatusCode::FORBIDDEN); + } + Ok(AuthUser { + user_id: auth.user_id, + device_id: auth.device_id, + }) +} + #[cfg(test)] mod tests { use super::*; use crate::db::{connect, DbPool}; - use crate::relay::RoomManager; use crate::MemoryAssetStore; use axum::body::{to_bytes, Body}; use axum::http::{header, Request}; @@ -741,9 +664,7 @@ mod tests { async fn setup_app() -> (axum::Router, Arc, String) { let db = Arc::new(connect(":memory:").await.unwrap()); - UserRow::create(&db, "owner", "alice", "s", "ks", "{}", "hash", "wmk") - .await - .unwrap(); + UserRow::create(&db, "owner", "alice").await.unwrap(); DeviceRow::upsert(&db, "owner-device", "owner", "Owner", None, None) .await .unwrap(); @@ -752,10 +673,9 @@ mod tests { .unwrap() .token; let app = crate::build_relay_router( - RoomManager::new(), Arc::new(MemoryAssetStore::new()), std::time::Instant::now(), - Some(db.clone()), + db.clone(), "test", ); (app, db, token) @@ -768,7 +688,12 @@ mod tests { .method("POST") .uri(path) .header(header::AUTHORIZATION, format!("Bearer {token}")) - .body(Body::empty()) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(if path == "/api/auth/delegate" { + serde_json::json!({"public_key": BASE64.encode([9u8; 32])}).to_string() + } else { + "{}".to_string() + })) .unwrap(), ) .await @@ -795,6 +720,77 @@ mod tests { .unwrap() } + #[tokio::test] + async fn github_login_uses_verified_id_and_preserves_device_key_on_reconnect() { + let (app, db, _) = setup_app().await; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("http://{}/me", listener.local_addr().unwrap()); + let authority = axum::Router::new().route( + "/me", + axum::routing::get(|headers: HeaderMap| async move { + if headers + .get(header::AUTHORIZATION) + .and_then(|h| h.to_str().ok()) + != Some("Bearer shared-account-token") + { + return (StatusCode::UNAUTHORIZED, Json(serde_json::json!({}))); + } + ( + StatusCode::OK, + Json(serde_json::json!({"user":{"githubId":123,"login":"github-user"}})), + ) + }), + ); + let task = tokio::spawn(async move { + axum::serve(listener, authority).await.unwrap(); + }); + let app = app.layer(Extension( + crate::identity::IdentityVerifier::with_url(&url).unwrap(), + )); + let public_key = BASE64.encode([9u8; 32]); + let request = serde_json::json!({ + "access_token":"shared-account-token", "user_id":"attacker-chosen-id", + "device_id":"new-device", "device_name":"Laptop", "device_kind":"desktop", + "public_key":public_key, "request_id":uuid::Uuid::new_v4().to_string(), + }); + let first = post_json(&app, "/api/auth/login", "", request.clone()).await; + assert_eq!(first.status(), StatusCode::OK); + let first: serde_json::Value = + serde_json::from_slice(&to_bytes(first.into_body(), 16384).await.unwrap()).unwrap(); + assert_eq!(first["user_id"], "123"); + let second = post_json(&app, "/api/auth/login", "", request.clone()).await; + let second: serde_json::Value = + serde_json::from_slice(&to_bytes(second.into_body(), 16384).await.unwrap()).unwrap(); + assert_eq!(second["token"], first["token"]); + DeviceRow::upsert(&db, "new-device", "123", "Laptop", None, None) + .await + .unwrap(); + let devices = DeviceRow::list_by_user(&db, "123").await.unwrap(); + assert_eq!(devices[0].public_key.as_deref(), Some(public_key.as_str())); + let mut invalid = request; + invalid["access_token"] = serde_json::json!("expired"); + invalid["device_id"] = serde_json::json!("must-not-register"); + assert_eq!( + post_json(&app, "/api/auth/login", "", invalid) + .await + .status(), + StatusCode::UNAUTHORIZED + ); + assert_eq!(DeviceRow::list_by_user(&db, "123").await.unwrap().len(), 1); + assert_eq!( + post_json( + &app, + "/api/auth/login/challenge", + "", + serde_json::json!({"username":"alice"}) + ) + .await + .status(), + StatusCode::NOT_FOUND + ); + task.abort(); + } + #[tokio::test] async fn device_provisioning_is_full_scope_idempotent_and_device_only() { let (app, db, device_token) = setup_app().await; @@ -803,6 +799,7 @@ mod tests { let request = serde_json::json!({ "device_id": device_id, "device_name": "SSH Build Host", + "public_key": BASE64.encode([9u8; 32]), "request_id": request_id, }); @@ -835,6 +832,7 @@ mod tests { serde_json::json!({ "device_id": device_id, "device_name": "SSH Build Host", + "public_key": BASE64.encode([9u8; 32]), "request_id": uuid::Uuid::new_v4().to_string(), }), ) @@ -857,6 +855,7 @@ mod tests { serde_json::json!({ "device_id": "cd".repeat(16), "device_name": "Another Host", + "public_key": BASE64.encode([9u8; 32]), "request_id": uuid::Uuid::new_v4().to_string(), }), ) @@ -887,20 +886,6 @@ mod tests { .status(), StatusCode::FORBIDDEN ); - let sync_response = app - .clone() - .oneshot( - Request::builder() - .method("GET") - .uri("/api/sync/settings") - .header(header::AUTHORIZATION, format!("Bearer {delegated_token}")) - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(sync_response.status(), StatusCode::FORBIDDEN); - DeviceRow::set_online(&db, "owner", "owner-device", true) .await .unwrap(); @@ -916,86 +901,6 @@ mod tests { assert_eq!(devices[0].online, 1); } - #[tokio::test] - async fn unknown_login_challenge_has_a_stable_valid_decoy_shape() { - let (app, _db, _token) = setup_app().await; - let request = || { - Request::builder() - .method("POST") - .uri("/api/auth/login/challenge") - .header(header::CONTENT_TYPE, "application/json") - .body(Body::from(r#"{"username":"missing-user"}"#)) - .unwrap() - }; - - let first = app.clone().oneshot(request()).await.unwrap(); - assert_eq!(first.status(), StatusCode::OK); - let first_body = to_bytes(first.into_body(), 16 * 1024).await.unwrap(); - let first_json: LoginChallengeResponse = serde_json::from_slice(&first_body).unwrap(); - assert!(first_json.login_idempotency_supported); - let params: serde_json::Value = serde_json::from_str(&first_json.argon2_params).unwrap(); - assert_eq!(params["m"], 16 * 1024); - assert_eq!(params["t"], 3); - assert_eq!(params["p"], 4); - assert_eq!(BASE64.decode(&first_json.salt).unwrap().len(), 16); - assert_eq!(BASE64.decode(&first_json.kdf_salt).unwrap().len(), 16); - let (ciphertext, nonce) = first_json.wrapped_master_key.split_once('.').unwrap(); - assert_eq!(BASE64.decode(ciphertext).unwrap().len(), 48); - assert_eq!(BASE64.decode(nonce).unwrap().len(), 12); - - let second = app.oneshot(request()).await.unwrap(); - let second_body = to_bytes(second.into_body(), 16 * 1024).await.unwrap(); - assert_eq!(first_body, second_body); - } - - #[tokio::test] - async fn login_request_id_reuses_the_issued_token() { - let (app, db, _token) = setup_app().await; - let request_id = uuid::Uuid::new_v4().to_string(); - let request = || { - Request::builder() - .method("POST") - .uri("/api/auth/login") - .header(header::CONTENT_TYPE, "application/json") - .body(Body::from( - serde_json::json!({ - "username": "alice", - "password_hash": "hash", - "device_id": "retry-device", - "device_name": "Retry Device", - "request_id": request_id, - }) - .to_string(), - )) - .unwrap() - }; - - let first = app.clone().oneshot(request()).await.unwrap(); - assert_eq!(first.status(), StatusCode::OK); - let first_body = to_bytes(first.into_body(), 16 * 1024).await.unwrap(); - let second = app.oneshot(request()).await.unwrap(); - assert_eq!(second.status(), StatusCode::OK); - let second_body = to_bytes(second.into_body(), 16 * 1024).await.unwrap(); - let first_token = serde_json::from_slice::(&first_body).unwrap() - ["token"] - .as_str() - .unwrap() - .to_string(); - let second_token = serde_json::from_slice::(&second_body).unwrap() - ["token"] - .as_str() - .unwrap() - .to_string(); - - assert_eq!(first_token, second_token); - let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM auth_tokens WHERE request_id = ?") - .bind(&request_id) - .fetch_one(&*db) - .await - .unwrap(); - assert_eq!(count.0, 1); - } - #[test] fn exact_idempotent_login_replays_consume_one_rate_limit_slot() { let limiter = LoginRateLimiter::new(); @@ -1010,3 +915,15 @@ mod tests { assert!(limiter.check_and_record("challenge", "127.0.0.1", 1, None)); } } + +fn registration_error_status(error: &anyhow::Error) -> StatusCode { + if error.chain().any(|cause| { + let text = cause.to_string(); + text.contains("account device quota exceeded") + || text.contains("account token quota exceeded") + }) { + StatusCode::TOO_MANY_REQUESTS + } else { + StatusCode::INTERNAL_SERVER_ERROR + } +} diff --git a/src/crates/services/relay-service/src/routes/devices.rs b/src/crates/services/relay-service/src/routes/devices.rs index 8722996d6b..1bad0db37a 100644 --- a/src/crates/services/relay-service/src/routes/devices.rs +++ b/src/crates/services/relay-service/src/routes/devices.rs @@ -48,14 +48,20 @@ fn is_valid_device_id(value: &str) -> bool { fn is_valid_encrypted_payload(encrypted_data: &str, nonce: &str) -> bool { !encrypted_data.is_empty() && encrypted_data.len() <= MAX_ENCRYPTED_PAYLOAD_BYTES + && encrypted_data.bytes().all(|byte| { + byte.is_ascii_alphanumeric() || matches!(byte, b'+' | b'/' | b'=' | b'-' | b'_') + }) && !nonce.is_empty() && nonce.len() <= MAX_NONCE_BYTES && !nonce.chars().any(char::is_control) } /// Validate bearer token and return its account principal and capability kind. -async fn validate_user(state: &AppState, headers: &HeaderMap) -> Result { - let db = state.db.as_ref().ok_or(StatusCode::NOT_IMPLEMENTED)?; +pub(crate) async fn validate_user( + state: &AppState, + headers: &HeaderMap, +) -> Result { + let db = state.db.as_ref(); let token = headers .get(header::AUTHORIZATION) .and_then(|v| v.to_str().ok()) @@ -76,13 +82,53 @@ async fn validate_user(state: &AppState, headers: &HeaderMap) -> Result Router { Router::new() .route("/api/devices", get(list_devices)) + .route("/api/devices/{target_device_id}/key", get(device_key)) .route( "/api/devices/{target_device_id}/rpc", post(device_rpc).layer(DefaultBodyLimit::max(RPC_BODY_LIMIT_BYTES)), ) + .route( + "/api/devices/{target_device_id}/messages", + post(device_message).layer(DefaultBodyLimit::max(RPC_BODY_LIMIT_BYTES)), + ) .route("/api/devices/{target_device_id}", delete(delete_device)) } +#[derive(Serialize)] +pub struct DeviceKeyResponse { + pub device_id: String, + pub public_key: String, +} + +/// Resolve even hidden controller keys, scoped to the authenticated account. +pub async fn device_key( + State(state): State, + Path(target_device_id): Path, + headers: HeaderMap, +) -> Result, StatusCode> { + let auth = validate_user(&state, &headers).await?; + if !is_valid_device_id(&target_device_id) { + return Err(StatusCode::BAD_REQUEST); + } + let db = state.db.as_ref(); + let public_key = sqlx::query_scalar::<_, Option>( + "SELECT public_key FROM devices WHERE user_id = ?1 AND device_id = ?2 + UNION ALL SELECT k.public_key FROM delegated_device_keys k JOIN auth_tokens t ON t.token = k.token + WHERE t.user_id = ?1 AND k.controller_id = ?2 AND t.expires_at > unixepoch() LIMIT 1", + ) + .bind(&auth.user_id) + .bind(&target_device_id) + .fetch_optional(db) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .flatten() + .ok_or(StatusCode::NOT_FOUND)?; + Ok(Json(DeviceKeyResponse { + device_id: target_device_id, + public_key, + })) +} + // ── List devices ──────────────────────────────────────────────────────── #[derive(Serialize)] @@ -113,7 +159,8 @@ async fn list_devices( // Get all registered devices from the DB (online + offline) let mut devices = Vec::new(); let mut hidden_ids: std::collections::HashSet = std::collections::HashSet::new(); - if let Some(db) = &state.db { + { + let db = &state.db; if let Ok(db_devices) = crate::db::DeviceRow::list_by_user(db, &user_id).await { for row in db_devices { if !crate::db::device_kind_is_desktop(row.device_kind.as_deref()) { @@ -157,7 +204,7 @@ async fn list_devices( #[derive(Deserialize)] pub struct DeviceRpcRequest { - /// Opaque ciphertext encrypted client-side with the account master_key. + /// Opaque ciphertext encrypted client-side with the device-pair key. /// The relay never decrypts this — it only routes. pub encrypted_data: String, pub nonce: String, @@ -169,6 +216,60 @@ pub struct DeviceRpcResponse { pub nonce: String, } +#[derive(Deserialize)] +struct DeviceMessageRequest { + correlation_id: String, + encrypted_data: String, + nonce: String, +} + +/// Device responses use the same authenticated, memory-admitted HTTP ingress +/// as requests. WebSocket ingress is reserved for small control messages. +async fn device_message( + State(state): State, + headers: HeaderMap, + Path(target_device_id): Path, + Json(body): Json, +) -> Result { + let auth = validate_user(&state, &headers).await?; + if !auth.is_device_token() { + return Err(StatusCode::FORBIDDEN); + } + if !is_valid_device_id(&target_device_id) + || !is_valid_device_id(&body.correlation_id) + || !is_valid_encrypted_payload(&body.encrypted_data, &body.nonce) + { + return Err(StatusCode::BAD_REQUEST); + } + let response = crate::relay::device_manager::RpcResponse::try_new( + body.encrypted_data.clone(), + body.nonce.clone(), + ) + .ok_or(StatusCode::SERVICE_UNAVAILABLE)?; + if state.device_manager.resolve_rpc( + &body.correlation_id, + &auth.user_id, + &auth.device_id, + response, + ) { + return Ok(StatusCode::NO_CONTENT); + } + let message = OutboundProtocol::IncomingDeviceMessage { + source_device_id: auth.device_id, + correlation_id: body.correlation_id, + encrypted_data: body.encrypted_data, + nonce: body.nonce, + }; + let json = serde_json::to_string(&message).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + if !state + .device_manager + .route_message(&auth.user_id, &target_device_id, &json) + { + return Err(StatusCode::NOT_FOUND); + } + Ok(StatusCode::NO_CONTENT) +} + /// `POST /api/devices/:target_device_id/rpc` /// /// Routes an encrypted command to the target device via WS, waits for the @@ -179,8 +280,13 @@ async fn device_rpc( headers: HeaderMap, Path(target_device_id): Path, Json(body): Json, -) -> Result, StatusCode> { +) -> Result { let auth = validate_user(&state, &headers).await?; + let db = state.db.as_ref(); + let source_device_id = auth + .routing_device_id(db) + .await + .map_err(|_| StatusCode::FORBIDDEN)?; let user_id = auth.user_id; if !is_valid_device_id(&target_device_id) @@ -206,10 +312,10 @@ async fn device_rpc( .ok_or(StatusCode::TOO_MANY_REQUESTS)?; // Build the WS message to send to the target device. - // The relay acts as a "virtual" source — the target device sees this - // as an IncomingDeviceMessage from a special "rpc" source. + // Retain the authenticated controller identity so the receiver can resolve + // its same-account public key. Correlation still resolves the HTTP response. let out_msg = OutboundProtocol::IncomingDeviceMessage { - source_device_id: "rpc".to_string(), // indicates HTTP RPC origin + source_device_id, correlation_id: correlation_id.clone(), encrypted_data: body.encrypted_data, nonce: body.nonce, @@ -227,10 +333,7 @@ async fn device_rpc( // Wait for the response (the target device sends back a DeviceMessage // via WS, which the WS handler resolves via resolve_rpc) match tokio::time::timeout(RPC_TIMEOUT, rx).await { - Ok(Ok(resp)) => Ok(Json(DeviceRpcResponse { - encrypted_data: resp.encrypted_data, - nonce: resp.nonce, - })), + Ok(Ok(resp)) => resp.into_http_response(), _ => { state.device_manager.cancel_rpc(&correlation_id); Err(StatusCode::GATEWAY_TIMEOUT) @@ -259,7 +362,7 @@ async fn delete_device( return Err(StatusCode::BAD_REQUEST); } - let db = state.db.as_ref().ok_or(StatusCode::NOT_IMPLEMENTED)?; + let db = state.db.as_ref(); let _presence_projection_guard = state.device_manager.lock_presence_projection().await; let current_auth = crate::db::AuthToken::find(db, &auth.token) .await @@ -319,7 +422,6 @@ async fn delete_device( mod tests { use super::*; use crate::db::{connect, AuthToken, DbPool, DeviceRow, UserRow}; - use crate::relay::RoomManager; use crate::MemoryAssetStore; use axum::body::Body; use axum::http::Request; @@ -335,14 +437,136 @@ mod tests { other_token: String, } - async fn setup_app() -> TestContext { - let db = Arc::new(connect(":memory:").await.unwrap()); - UserRow::create(&db, "owner", "alice", "s", "ks", "{}", "hash", "wmk") + #[tokio::test] + async fn delegated_device_keys_are_scoped_and_follow_parent_revocation() { + let ctx = setup_app().await; + let controller = AuthToken::create_keyed_delegated( + &ctx.db, + "owner", + "owner-device", + "controller-public-key", + ) + .await + .unwrap(); + let id = controller.routing_device_id(&ctx.db).await.unwrap(); + assert_ne!(id, "owner-device"); + let second = + AuthToken::create_keyed_delegated(&ctx.db, "owner", "owner-device", "other-public-key") + .await + .unwrap(); + assert_ne!(id, second.routing_device_id(&ctx.db).await.unwrap()); + for (token, expected) in [ + (&ctx.owner_token, StatusCode::OK), + (&ctx.other_token, StatusCode::NOT_FOUND), + ] { + let response = ctx + .app + .clone() + .oneshot( + Request::builder() + .uri(format!("/api/devices/{id}/key")) + .header(axum::http::header::AUTHORIZATION, format!("Bearer {token}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), expected); + } + AuthToken::revoke_by_device(&ctx.db, "owner", "owner-device") .await .unwrap(); - UserRow::create(&db, "other", "bob", "s", "ks", "{}", "hash", "wmk") + assert!(controller.routing_device_id(&ctx.db).await.is_err()); + assert!(second.routing_device_id(&ctx.db).await.is_err()); + } + + #[tokio::test] + async fn http_device_response_requires_expected_principal_and_accepts_large_payload() { + let ctx = setup_app().await; + let state = AppState { + start_time: std::time::Instant::now(), + asset_store: Arc::new(MemoryAssetStore::new()), + db: ctx.db.clone(), + page_data: None, + page_access_manager: Arc::new(crate::routes::pages::PageAccessManager::new()), + page_upload_manager: Arc::new(crate::routes::pages::PageUploadManager::new()), + page_execution_guard: Arc::new(crate::page_execution::PageExecutionGuard::new()), + login_rate_limiter: Arc::new(crate::routes::auth::LoginRateLimiter::new()), + device_manager: crate::relay::DeviceManager::new(), + cors_allow_origins: Arc::new(Vec::new()), + page_browser_auth: None, + }; + let app = device_router() + .layer(axum::middleware::from_fn_with_state( + state.clone(), + crate::admission::admit, + )) + .with_state(state.clone()); + let mut pending = state + .device_manager + .register_rpc("correlation", "owner", "target-device") + .unwrap(); + let ciphertext = "a".repeat(256 * 1024); + let body = serde_json::json!({ + "correlation_id": "correlation", "encrypted_data": ciphertext, "nonce": "nonce" + }) + .to_string(); + for (token, expected) in [ + (&ctx.owner_token, StatusCode::NOT_FOUND), + (&ctx.other_token, StatusCode::NOT_FOUND), + (&ctx.delegated_token, StatusCode::FORBIDDEN), + ] { + let response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/devices/owner-device/messages") + .header(header::AUTHORIZATION, format!("Bearer {token}")) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(body.clone())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), expected); + assert!(pending.try_recv().is_err()); + } + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/devices/owner-device/messages") + .header( + header::AUTHORIZATION, + format!("Bearer {}", ctx.target_token), + ) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(body)) + .unwrap(), + ) .await .unwrap(); + assert_eq!(response.status(), StatusCode::NO_CONTENT); + let response = pending.await.unwrap().into_http_response().unwrap(); + let bytes = axum::body::to_bytes(response.into_body(), 1024 * 1024) + .await + .unwrap(); + let value: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(value["encrypted_data"], ciphertext); + assert_eq!(value["nonce"], "nonce"); + } + + #[test] + fn encrypted_payload_cannot_expand_json_with_escape_characters() { + assert!(!is_valid_encrypted_payload("\"\\", "nonce")); + assert!(is_valid_encrypted_payload("ab+/=_-09", "nonce")); + } + + async fn setup_app() -> TestContext { + let db = Arc::new(connect(":memory:").await.unwrap()); + UserRow::create(&db, "owner", "alice").await.unwrap(); + UserRow::create(&db, "other", "bob").await.unwrap(); DeviceRow::upsert(&db, "owner-device", "owner", "Owner", None, None) .await .unwrap(); @@ -370,10 +594,9 @@ mod tests { .unwrap() .token; let app = crate::build_relay_router( - RoomManager::new(), Arc::new(MemoryAssetStore::new()), std::time::Instant::now(), - Some(db.clone()), + db.clone(), "test", ); diff --git a/src/crates/services/relay-service/src/routes/mod.rs b/src/crates/services/relay-service/src/routes/mod.rs index 8b09cb34b3..03467fc831 100644 --- a/src/crates/services/relay-service/src/routes/mod.rs +++ b/src/crates/services/relay-service/src/routes/mod.rs @@ -4,5 +4,4 @@ pub mod api; pub mod auth; pub mod devices; pub mod pages; -pub mod sync; pub mod websocket; diff --git a/src/crates/services/relay-service/src/routes/page_auth_client.bundle.js b/src/crates/services/relay-service/src/routes/page_auth_client.bundle.js index 87fffc395c..474ee7491c 100644 --- a/src/crates/services/relay-service/src/routes/page_auth_client.bundle.js +++ b/src/crates/services/relay-service/src/routes/page_auth_client.bundle.js @@ -1 +1 @@ -(()=>{var Ct=Object.defineProperty;var Mt=(t,e,n)=>e in t?Ct(t,e,{enumerable:!0,configurable:!0,writable:!0,value:n}):t[e]=n;var w=(t,e,n)=>Mt(t,typeof e!="symbol"?e+"":e,n);var tt=BigInt(4294967295),mt=BigInt(32);function xt(t,e=!1){return e?{h:Number(t&tt),l:Number(t>>mt&tt)}:{h:Number(t>>mt&tt)|0,l:Number(t&tt)|0}}var V=(t,e,n)=>t>>>n|e<<32-n,v=(t,e,n)=>t<<32-n|e>>>n,et=(t,e,n)=>t<<64-n|e>>>n-32,nt=(t,e,n)=>t>>>n-32|e<<64-n,rt=(t,e)=>e,ot=(t,e)=>t;function ct(t,e,n,o){let i=(e>>>0)+(o>>>0);return{h:t+n+(i/2**32|0)|0,l:i|0}}var X=(t,e,n)=>(t>>>0)+(e>>>0)+(n>>>0),J=(t,e,n,o)=>e+n+o+(t/2**32|0)|0;/*! noble-hashes - MIT License (c) 2022 Paul Miller (paulmillr.com) */function Nt(t){return t instanceof Uint8Array||ArrayBuffer.isView(t)&&t.constructor.name==="Uint8Array"}function z(t,e=""){if(!Number.isSafeInteger(t)||t<0){let n=e&&`"${e}" `;throw new Error(`${n}expected integer >= 0, got ${t}`)}}function T(t,e,n=""){let o=Nt(t),i=t?.length,r=e!==void 0;if(!o||r&&i!==e){let c=n&&`"${n}" `,s=r?` of length ${e}`:"",a=o?`length=${i}`:`type=${typeof t}`;throw new Error(c+"expected Uint8Array"+s+", got "+a)}return t}function lt(t,e=!0){if(t.destroyed)throw new Error("Hash instance has been destroyed");if(e&&t.finished)throw new Error("Hash#digest() has already been called")}function bt(t,e){T(t,void 0,"digestInto() output");let n=e.outputLen;if(t.length='+n)}function F(t){return new Uint8Array(t.buffer,t.byteOffset,t.byteLength)}function C(t){return new Uint32Array(t.buffer,t.byteOffset,Math.floor(t.byteLength/4))}function E(...t){for(let e=0;e>>8&65280|t>>>24&255}var U=At?t=>t:t=>Bt(t);function $t(t){for(let e=0;et:$t;var Lt=async()=>{};function Rt(t){if(typeof t!="string")throw new Error("string expected");return new Uint8Array(new TextEncoder().encode(t))}function it(t,e=""){return typeof t=="string"?Rt(t):T(t,void 0,e)}function kt(t,e={}){let n=(i,r)=>t(r).update(i).digest(),o=t(void 0);return n.outputLen=o.outputLen,n.blockLen=o.blockLen,n.create=i=>t(i),Object.assign(n,e),Object.freeze(n)}var Et=Uint8Array.from([0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,14,10,4,8,9,15,13,6,1,12,0,2,11,7,5,3,11,8,12,0,5,2,15,13,10,14,3,6,7,1,9,4,7,9,3,1,13,12,11,14,2,6,5,10,4,0,15,8,9,0,5,7,2,4,10,15,14,1,11,12,6,8,3,13,2,12,6,10,0,11,8,3,4,13,7,5,15,14,1,9,12,5,1,15,14,13,4,10,0,7,6,3,9,2,8,11,13,11,7,14,12,1,3,9,5,0,15,4,8,6,2,10,6,15,14,9,11,3,0,8,12,2,13,7,1,4,10,5,10,2,8,4,7,6,1,5,15,11,9,14,3,12,13,0,0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,14,10,4,8,9,15,13,6,1,12,0,2,11,7,5,3,11,8,12,0,5,2,15,13,10,14,3,6,7,1,9,4,7,9,3,1,13,12,11,14,2,6,5,10,4,0,15,8,9,0,5,7,2,4,10,15,14,1,11,12,6,8,3,13,2,12,6,10,0,11,8,3,4,13,7,5,15,14,1,9]);var b=Uint32Array.from([4089235720,1779033703,2227873595,3144134277,4271175723,1013904242,1595750129,2773480762,2917565137,1359893119,725511199,2600822924,4215389547,528734635,327033209,1541459225]),l=new Uint32Array(32);function _(t,e,n,o,i,r){let c=i[r],s=i[r+1],a=l[2*t],f=l[2*t+1],u=l[2*e],h=l[2*e+1],g=l[2*n],y=l[2*n+1],p=l[2*o],d=l[2*o+1],A=X(a,u,c);f=J(A,f,h,s),a=A|0,{Dh:d,Dl:p}={Dh:d^f,Dl:p^a},{Dh:d,Dl:p}={Dh:rt(d,p),Dl:ot(d,p)},{h:y,l:g}=ct(y,g,d,p),{Bh:h,Bl:u}={Bh:h^y,Bl:u^g},{Bh:h,Bl:u}={Bh:V(h,u,24),Bl:v(h,u,24)},l[2*t]=a,l[2*t+1]=f,l[2*e]=u,l[2*e+1]=h,l[2*n]=g,l[2*n+1]=y,l[2*o]=p,l[2*o+1]=d}function D(t,e,n,o,i,r){let c=i[r],s=i[r+1],a=l[2*t],f=l[2*t+1],u=l[2*e],h=l[2*e+1],g=l[2*n],y=l[2*n+1],p=l[2*o],d=l[2*o+1],A=X(a,u,c);f=J(A,f,h,s),a=A|0,{Dh:d,Dl:p}={Dh:d^f,Dl:p^a},{Dh:d,Dl:p}={Dh:V(d,p,16),Dl:v(d,p,16)},{h:y,l:g}=ct(y,g,d,p),{Bh:h,Bl:u}={Bh:h^y,Bl:u^g},{Bh:h,Bl:u}={Bh:et(h,u,63),Bl:nt(h,u,63)},l[2*t]=a,l[2*t+1]=f,l[2*e]=u,l[2*e+1]=h,l[2*n]=g,l[2*n+1]=y,l[2*o]=p,l[2*o+1]=d}function Gt(t,e={},n,o,i){if(z(n),t<0||t>n)throw new Error("outputLen bigger than keyLen");let{key:r,salt:c,personalization:s}=e;if(r!==void 0&&(r.length<1||r.length>n))throw new Error('"key" expected to be undefined or of length=1..'+n);c!==void 0&&T(c,o,"salt"),s!==void 0&&T(s,i,"personalization")}var ft=class{constructor(e,n){w(this,"buffer");w(this,"buffer32");w(this,"finished",!1);w(this,"destroyed",!1);w(this,"length",0);w(this,"pos",0);w(this,"blockLen");w(this,"outputLen");z(e),z(n),this.blockLen=e,this.outputLen=n,this.buffer=new Uint8Array(e),this.buffer32=C(this.buffer)}update(e){lt(this),T(e);let{blockLen:n,buffer:o,buffer32:i}=this,r=e.length,c=e.byteOffset,s=e.buffer;for(let a=0;ai[c]=U(r))}digest(){let{buffer:e,outputLen:n}=this;this.digestInto(e);let o=e.slice(0,n);return this.destroy(),o}_cloneInto(e){let{buffer:n,length:o,finished:i,destroyed:r,outputLen:c,pos:s}=this;return e||(e=new this.constructor({dkLen:c})),e.set(...this.get()),e.buffer.set(n),e.destroyed=r,e.finished=i,e.length=o,e.pos=s,e.outputLen=c,e}clone(){return this._cloneInto()}},ut=class extends ft{constructor(n={}){let o=n.dkLen===void 0?64:n.dkLen;super(128,o);w(this,"v0l",b[0]|0);w(this,"v0h",b[1]|0);w(this,"v1l",b[2]|0);w(this,"v1h",b[3]|0);w(this,"v2l",b[4]|0);w(this,"v2h",b[5]|0);w(this,"v3l",b[6]|0);w(this,"v3h",b[7]|0);w(this,"v4l",b[8]|0);w(this,"v4h",b[9]|0);w(this,"v5l",b[10]|0);w(this,"v5h",b[11]|0);w(this,"v6l",b[12]|0);w(this,"v6h",b[13]|0);w(this,"v7l",b[14]|0);w(this,"v7h",b[15]|0);Gt(o,n,64,16,16);let{key:i,personalization:r,salt:c}=n,s=0;if(i!==void 0&&(T(i,void 0,"key"),s=i.length),this.v0l^=this.outputLen|s<<8|65536|1<<24,c!==void 0){T(c,void 0,"salt");let a=C(c);this.v4l^=U(a[0]),this.v4h^=U(a[1]),this.v5l^=U(a[2]),this.v5h^=U(a[3])}if(r!==void 0){T(r,void 0,"personalization");let a=C(r);this.v6l^=U(a[0]),this.v6h^=U(a[1]),this.v7l^=U(a[2]),this.v7h^=U(a[3])}if(i!==void 0){let a=new Uint8Array(this.blockLen);a.set(i),this.update(a)}}get(){let{v0l:n,v0h:o,v1l:i,v1h:r,v2l:c,v2h:s,v3l:a,v3h:f,v4l:u,v4h:h,v5l:g,v5h:y,v6l:p,v6h:d,v7l:A,v7h:m}=this;return[n,o,i,r,c,s,a,f,u,h,g,y,p,d,A,m]}set(n,o,i,r,c,s,a,f,u,h,g,y,p,d,A,m){this.v0l=n|0,this.v0h=o|0,this.v1l=i|0,this.v1h=r|0,this.v2l=c|0,this.v2h=s|0,this.v3l=a|0,this.v3h=f|0,this.v4l=u|0,this.v4h=h|0,this.v5l=g|0,this.v5h=y|0,this.v6l=p|0,this.v6h=d|0,this.v7l=A|0,this.v7h=m|0}compress(n,o,i){this.get().forEach((f,u)=>l[u]=f),l.set(b,16);let{h:r,l:c}=xt(BigInt(this.length));l[24]=b[8]^c,l[25]=b[9]^r,i&&(l[28]=~l[28],l[29]=~l[29]);let s=0,a=Et;for(let f=0;f<12;f++)_(0,4,8,12,n,o+2*a[s++]),D(0,4,8,12,n,o+2*a[s++]),_(1,5,9,13,n,o+2*a[s++]),D(1,5,9,13,n,o+2*a[s++]),_(2,6,10,14,n,o+2*a[s++]),D(2,6,10,14,n,o+2*a[s++]),_(3,7,11,15,n,o+2*a[s++]),D(3,7,11,15,n,o+2*a[s++]),_(0,5,10,15,n,o+2*a[s++]),D(0,5,10,15,n,o+2*a[s++]),_(1,6,11,12,n,o+2*a[s++]),D(1,6,11,12,n,o+2*a[s++]),_(2,7,8,13,n,o+2*a[s++]),D(2,7,8,13,n,o+2*a[s++]),_(3,4,9,14,n,o+2*a[s++]),D(3,4,9,14,n,o+2*a[s++]);this.v0l^=l[0]^l[16],this.v0h^=l[1]^l[17],this.v1l^=l[2]^l[18],this.v1h^=l[3]^l[19],this.v2l^=l[4]^l[20],this.v2h^=l[5]^l[21],this.v3l^=l[6]^l[22],this.v3h^=l[7]^l[23],this.v4l^=l[8]^l[24],this.v4h^=l[9]^l[25],this.v5l^=l[10]^l[26],this.v5h^=l[11]^l[27],this.v6l^=l[12]^l[28],this.v6h^=l[13]^l[29],this.v7l^=l[14]^l[30],this.v7h^=l[15]^l[31],E(l)}destroy(){this.destroyed=!0,E(this.buffer32),this.set(0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0)}},q=kt(t=>new ut(t));var at={Argond2d:0,Argon2i:1,Argon2id:2},K=4,Ut=(t,e="")=>t===void 0?Uint8Array.of():it(t,e);function ht(t,e){let n=t&65535,o=t>>>16,i=e&65535,r=e>>>16,c=Math.imul(n,i),s=Math.imul(o,i),a=Math.imul(n,r),f=Math.imul(o,r),u=(c>>>16)+(s&65535)+a,h=f+(s>>>16)+(u>>>16)|0,g=u<<16|c&65535;return{h,l:g}}function Vt(t,e){let{h:n,l:o}=ht(t,e);return{h:(n<<1|o>>>31)&4294967295,l:o<<1&4294967295}}function st(t,e,n,o){let{h:i,l:r}=Vt(e,o),c=X(e,o,r);return{h:J(c,t,n,i),l:c|0}}var x=new Uint32Array(256);function P(t,e,n,o){let i=x[2*t],r=x[2*t+1],c=x[2*e],s=x[2*e+1],a=x[2*n],f=x[2*n+1],u=x[2*o],h=x[2*o+1];({h:r,l:i}=st(r,i,s,c)),{Dh:h,Dl:u}={Dh:h^r,Dl:u^i},{Dh:h,Dl:u}={Dh:rt(h,u),Dl:ot(h,u)},{h:f,l:a}=st(f,a,h,u),{Bh:s,Bl:c}={Bh:s^f,Bl:c^a},{Bh:s,Bl:c}={Bh:V(s,c,24),Bl:v(s,c,24)},{h:r,l:i}=st(r,i,s,c),{Dh:h,Dl:u}={Dh:h^r,Dl:u^i},{Dh:h,Dl:u}={Dh:V(h,u,16),Dl:v(h,u,16)},{h:f,l:a}=st(f,a,h,u),{Bh:s,Bl:c}={Bh:s^f,Bl:c^a},{Bh:s,Bl:c}={Bh:et(s,c,63),Bl:nt(s,c,63)},x[2*t]=i,x[2*t+1]=r,x[2*e]=c,x[2*e+1]=s,x[2*n]=a,x[2*n+1]=f,x[2*o]=u,x[2*o+1]=h}function Ht(t,e,n,o,i,r,c,s,a,f,u,h,g,y,p,d){P(t,i,a,g),P(e,r,f,y),P(n,c,u,p),P(o,s,h,d),P(t,r,u,d),P(e,c,h,g),P(n,s,a,y),P(o,i,f,p)}function W(t,e,n,o,i){for(let r=0;r<256;r++)x[r]=t[e+r]^t[n+r];for(let r=0;r<128;r+=16)Ht(r,r+1,r+2,r+3,r+4,r+5,r+6,r+7,r+8,r+9,r+10,r+11,r+12,r+13,r+14,r+15);for(let r=0;r<16;r+=2)Ht(r,r+1,r+16,r+17,r+32,r+33,r+48,r+49,r+64,r+65,r+80,r+81,r+96,r+97,r+112,r+113);if(i)for(let r=0;r<256;r++)t[o+r]^=x[r]^t[e+r]^t[n+r];else for(let r=0;r<256;r++)t[o+r]=x[r]^t[e+r]^t[n+r];E(x)}function dt(t,e){let n=F(t),o=new Uint32Array(1),i=F(o);if(o[0]=e,e<=64)return q.create({dkLen:e}).update(i).update(n).digest();let r=new Uint8Array(e),c=q.create({}).update(i).update(n).digest(),s=0;for(r.set(c.subarray(0,32)),s+=32;e-s>64;s+=32){let a=q.create({}).update(c);a.digestInto(c),a.destroy(),r.set(c.subarray(0,32),s)}return r.set(q(c,{dkLen:e-s}),s),E(c,o),C(r)}function vt(t,e,n,o,i,r,c=!1){let s;t===0?e===0?s=i-1:c?s=e*o+i-1:s=e*o+(i==0?-1:0):c?s=n-o+i-1:s=n-o+(i==0?-1:0);let a=t!==0&&e!==K-1?(e+1)*o:0,f=s-1-ht(s,ht(r,r).h).h;return(a+f)%n}var St=Math.pow(2,32);function O(t){return Number.isSafeInteger(t)&&t>=0&&t=Math.pow(2,24))throw new Error('"p" must be 1..2^24');if(!O(i))throw new Error('"m" must be 0..2^32');if(!O(r)||r<1)throw new Error('"t" (iterations) must be 1..2^32');if(s!==void 0&&typeof s!="function")throw new Error('"progressCb" must be a function');if(z(a,"asyncTick"),!O(i)||i<8*o)throw new Error('"m" (memory) must be at least 8*p bytes');if(c!==16&&c!==19)throw new Error('"version" must be 0x10 or 0x13, got '+c);return e}function Ft(t,e,n,o){if(t=it(t,"password"),e=it(e,"salt"),!O(t.length))throw new Error('"password" must be less of length 1..4Gb');if(!O(e.length)||e.length<8)throw new Error('"salt" must be of length 8..4Gb');if(!Object.values(at).includes(n))throw new Error('"type" was invalid');let{p:i,dkLen:r,m:c,t:s,version:a,key:f,personalization:u,maxmem:h,onProgress:g,asyncTick:y}=zt(o);f=Ut(f,"key"),u=Ut(u,"personalization");let p=q.create(),d=new Uint32Array(1),A=F(d);for(let B of[i,r,c,s,a,n])d[0]=B,p.update(A);for(let B of[t,e,f,u])d[0]=B.length,p.update(A).update(B);let m=new Uint32Array(18),k=F(m);p.digestInto(k);let H=i,N=4*i*Math.floor(c/(K*i)),S=Math.floor(N/i),$=Math.floor(S/K),I=N*256;if(!O(h)||I>h)throw new Error('"maxmem" expected <2**32, got: maxmem='+h+", memused="+I);let R=new Uint32Array(I);for(let B=0;B{};if(g){let B=s*K*i*$,Z=Math.max(Math.floor(B/1e4),1),Q=0;G=()=>{Q++,g&&(!(Q%Z)||Q===B)&&g(Q/B)}}return E(d,m),{type:n,mP:N,p:i,t:s,version:a,B:R,laneLen:S,lanes:H,segmentLen:$,dkLen:r,perBlock:G,asyncTick:y}}function qt(t,e,n,o){let i=new Uint32Array(256);for(let c=0;c=0&&BJt(at.Argon2id,t,e,n);var Ot=document.querySelector("[data-page-login-form]"),Tt=document.querySelector("[data-page-login-username]"),j=document.querySelector("[data-page-login-password]"),Y=document.querySelector("[data-page-login-submit]"),pt=document.querySelector("[data-page-login-error]"),gt=document.querySelector("[data-page-login-toggle-password]"),_t=Ot?.dataset.pageLoginState;function yt(){let t=window.location.pathname.indexOf("/api/page-auth/");if(t>=0)return window.location.pathname.slice(0,t);let e=window.location.pathname.indexOf("/p/");return e>=0?window.location.pathname.slice(0,e):""}function Dt(t){return`${yt()}${t}`}function Kt(){let t=window.location.pathname.indexOf("/p/");return`${t>=0?window.location.pathname.slice(t):window.location.pathname}${window.location.search}`}function Wt(t){return/^https?:\/\//i.test(t)?t:`${yt()}${t}`}function Yt(){return navigator.language.toLowerCase().startsWith("zh")}function L(t,e){return Yt()?t:e}function wt(t){pt&&(pt.textContent=t,pt.hidden=t.length===0)}function Zt(t){let e=atob(t),n=new Uint8Array(e.length);for(let o=0;o256*1024||e.t<1||e.t>10||e.p<1||e.p>16)throw new Error(L("\u767B\u5F55\u53C2\u6570\u65E0\u6548\u3002","The sign-in parameters are invalid."));return e}async function ee(t){let e=L("\u767B\u5F55\u5931\u8D25\uFF0C\u8BF7\u91CD\u8BD5\u3002","Sign-in failed. Try again.");try{let n=await t.json();return n.retry_after_secs&&n.retry_after_secs>0?L(`\u5C1D\u8BD5\u6B21\u6570\u8FC7\u591A\uFF0C\u8BF7\u5728 ${n.retry_after_secs} \u79D2\u540E\u91CD\u8BD5\u3002`,`Too many attempts. Try again in ${n.retry_after_secs} seconds.`):n.error==="invalid username or password"?L("\u7528\u6237\u540D\u6216\u5BC6\u7801\u4E0D\u6B63\u786E\u3002","Incorrect username or password."):n.error==="account does not have access to this Page"?L("\u8BE5\u8D26\u53F7\u6CA1\u6709\u6B64\u9875\u9762\u7684\u8BBF\u95EE\u6743\u9650\u3002","This account cannot access the Page."):n.error||e}catch{return e}}async function Pt(t,e){let n=await fetch(t,{method:"POST",credentials:"same-origin",headers:{"content-type":"application/json"},body:JSON.stringify(e)});if(!n.ok)throw new Error(await ee(n));return n.json()}gt?.addEventListener("click",()=>{if(!j)return;let t=j.type==="password";j.type=t?"text":"password",gt.textContent=t?L("\u9690\u85CF","Hide"):L("\u663E\u793A","Show"),gt.setAttribute("aria-pressed",String(t))});Ot?.addEventListener("submit",async t=>{if(t.preventDefault(),!Tt||!j||!Y)return;let e=Tt.value.trim(),n=j.value;if(!e||e.length>128||!n||n.length>1024){wt(L("\u8BF7\u8F93\u5165\u6709\u6548\u7684\u7528\u6237\u540D\u548C\u5BC6\u7801\u3002","Enter a valid username and password."));return}wt(""),Y.disabled=!0,Y.textContent=L("\u6B63\u5728\u9A8C\u8BC1\u2026","Signing in\u2026");let o,i;try{let r=await Pt(Dt("/api/auth/login/challenge"),{username:e}),c=Zt(r.kdf_salt);if(c.length!==16)throw new Error(L("\u767B\u5F55\u53C2\u6570\u65E0\u6548\u3002","The sign-in parameters are invalid."));let s=te(r.argon2_params);o=new TextEncoder().encode(n),j.value="",i=await It(o,c,{m:s.m,t:s.t,p:s.p,dkLen:32,version:19,asyncTick:16});let a={username:e,password_hash:Qt(i)};_t?a.state=_t:(a.return_to=Kt(),a.path_prefix=yt());let f=await Pt(Dt("/api/page-auth/login"),a);window.location.replace(Wt(f.redirect_to))}catch(r){wt(r instanceof Error?r.message:L("\u767B\u5F55\u5931\u8D25\uFF0C\u8BF7\u91CD\u8BD5\u3002","Sign-in failed. Try again.")),j.focus()}finally{o?.fill(0),i?.fill(0),Y.disabled=!1,Y.textContent=L("\u767B\u5F55\u5E76\u8BBF\u95EE","Sign in and continue")}});})(); +(()=>{var f=document.querySelector("[data-page-login-form]"),o=document.querySelector("[data-page-login-submit]"),u=document.querySelector("[data-page-login-error]"),p=f?.dataset.pageLoginState;function h(){let t=window.location.pathname.indexOf("/api/page-auth/");if(t>=0)return window.location.pathname.slice(0,t);let e=window.location.pathname.indexOf("/p/");return e>=0?window.location.pathname.slice(0,e):""}function l(t){return`${h()}${t}`}function y(){let t=window.location.pathname.indexOf("/p/");return`${t>=0?window.location.pathname.slice(t):window.location.pathname}${window.location.search}`}function b(t){return/^https?:\/\//i.test(t)?t:`${h()}${t}`}function T(){return navigator.language.toLowerCase().startsWith("zh")}function r(t,e){return T()?t:e}function g(t){u&&(u.textContent=t,u.hidden=t.length===0)}async function S(t){let e=r("\u767B\u5F55\u5931\u8D25\uFF0C\u8BF7\u91CD\u8BD5\u3002","Sign-in failed. Try again.");try{let n=await t.json();return n.retry_after_secs&&n.retry_after_secs>0?r(`\u5C1D\u8BD5\u6B21\u6570\u8FC7\u591A\uFF0C\u8BF7\u5728 ${n.retry_after_secs} \u79D2\u540E\u91CD\u8BD5\u3002`,`Too many attempts. Try again in ${n.retry_after_secs} seconds.`):n.error==="account does not have access to this Page"?r("\u8BE5\u8D26\u53F7\u6CA1\u6709\u6B64\u9875\u9762\u7684\u8BBF\u95EE\u6743\u9650\u3002","This account cannot access the Page."):n.error||e}catch{return e}}async function d(t,e){let n=await fetch(t,{method:"POST",credentials:"same-origin",headers:{"content-type":"application/json"},body:JSON.stringify(e)});if(!n.ok)throw new Error(await S(n));return n.json()}f?.addEventListener("submit",async t=>{if(t.preventDefault(),!o||o.disabled)return;let e=window.open("about:blank","_blank");if(!e){g(r("\u8BF7\u5141\u8BB8\u767B\u5F55\u5F39\u7A97\u540E\u91CD\u8BD5\u3002","Allow the sign-in popup and try again."));return}e.opener=null,g(""),o.disabled=!0,o.textContent=r("\u7B49\u5F85 GitHub \u6388\u6743\u2026","Waiting for GitHub\u2026");try{let n=await d(l("/api/auth/github/start"),{}),i=new URL(n.authorizationUrl);if(i.protocol!=="https:"||i.hostname!=="github.com")throw new Error(r("\u767B\u5F55\u5730\u5740\u65E0\u6548\u3002","The sign-in URL is invalid."));e.location.replace(i.href);let s;for(;Date.now()setTimeout(m,Math.max(1,n.pollIntervalSeconds)*1e3));let c=await d(l("/api/auth/github/poll"),{transactionId:n.transactionId,transactionSecret:n.transactionSecret});if(c.tokens?.accessToken){s=c.tokens.accessToken;break}if(c.status!=="pending")throw new Error(r("\u6388\u6743\u672A\u5B8C\u6210\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55\u3002","Authorization did not complete. Sign in again."))}if(!s)throw new Error(r("\u767B\u5F55\u5DF2\u8FC7\u671F\uFF0C\u8BF7\u91CD\u8BD5\u3002","Sign-in expired. Try again."));let a={access_token:s};p?a.state=p:(a.return_to=y(),a.path_prefix=h());let w=await d(l("/api/page-auth/login"),a);window.location.replace(b(w.redirect_to))}catch(n){g(n instanceof Error?n.message:r("\u767B\u5F55\u5931\u8D25\uFF0C\u8BF7\u91CD\u8BD5\u3002","Sign-in failed. Try again."))}finally{e.close(),o.disabled=!1,o.textContent=r("\u4F7F\u7528 GitHub \u767B\u5F55","Sign in with GitHub")}});})(); diff --git a/src/crates/services/relay-service/src/routes/pages.rs b/src/crates/services/relay-service/src/routes/pages.rs index 429a78c841..0aeca1b233 100644 --- a/src/crates/services/relay-service/src/routes/pages.rs +++ b/src/crates/services/relay-service/src/routes/pages.rs @@ -29,7 +29,7 @@ use crate::db::{ }; use crate::page_data::RelayPageHost; use crate::routes::api::AppState; -use crate::routes::sync::{extract_bearer_token, validate_auth, validate_token, AuthUser}; +use crate::routes::auth::{extract_bearer_token, validate_auth, validate_token, AuthUser}; use crate::WebAssetStore; pub const MAX_PAGES_PER_USER: i64 = 50; @@ -512,11 +512,7 @@ fn upload_request_matches_session_intent( } fn require_db(state: &AppState) -> Result<&crate::db::DbPool, StatusCode> { - state - .db - .as_ref() - .map(|db| db.as_ref()) - .ok_or(StatusCode::NOT_IMPLEMENTED) + Ok(state.db.as_ref()) } pub fn pages_router() -> Router { @@ -781,8 +777,7 @@ async fn create_open_ticket( #[derive(Deserialize)] struct PageBrowserLoginRequest { - username: String, - password_hash: String, + access_token: String, #[serde(default)] return_to: Option, #[serde(default)] @@ -1003,6 +998,7 @@ async fn page_browser_login( State(state): State, connect_info: Option>>, headers: HeaderMap, + verifier: Option>, Json(body): Json, ) -> Response { let db = match require_db(&state) { @@ -1032,13 +1028,12 @@ async fn page_browser_login( }) .into_response(); } - let viewer = match crate::routes::auth::verify_password_hash_credentials( + let viewer = match crate::routes::auth::verify_identity_credentials( &state, connect_info.map(|Extension(ConnectInfo(addr))| addr), &headers, - &body.username, - &body.password_hash, - None, + &body.access_token, + verifier.as_ref().map(|v| &v.0), ) .await { @@ -1122,13 +1117,12 @@ async fn page_browser_login( .into_response(); } - let viewer = match crate::routes::auth::verify_password_hash_credentials( + let viewer = match crate::routes::auth::verify_identity_credentials( &state, connect_info.map(|Extension(ConnectInfo(addr))| addr), &headers, - &body.username, - &body.password_hash, - None, + &body.access_token, + verifier.as_ref().map(|v| &v.0), ) .await { @@ -1326,25 +1320,14 @@ fn page_login_form_response(

OPENBITFUN PAGE

登录后访问

-

此页面受访问权限保护,请使用 OpenBitFun 账号登录。

-

This Page is protected. Sign in with your OpenBitFun account.

+

此页面受访问权限保护,请使用 GitHub 账号登录。

+

This Page is protected. Sign in with your GitHub account.

{access_description}

- - - +
-

密码只在此浏览器中用于 Argon2id 派生,不会以明文发送给 Relay。

+

使用 OpenBitFun 统一 GitHub 账号登录。

@@ -2553,7 +2536,7 @@ async fn serve_with_worker( .try_acquire(&page.user_id, &page.slug) .map_err(|_| StatusCode::TOO_MANY_REQUESTS)?; let page_data = state.page_data.clone().ok_or(StatusCode::NOT_IMPLEMENTED)?; - let db = state.db.clone().ok_or(StatusCode::NOT_IMPLEMENTED)?; + let db = state.db.clone(); let asset_store = Arc::clone(&state.asset_store); let asset_store_fallback = Arc::clone(&state.asset_store); let asset_key = asset_key.to_string(); @@ -2772,9 +2755,7 @@ async fn maybe_migrate_legacy_page_locked( slug: &str, expected_generation: &str, ) { - let Some(db) = state.db.as_ref() else { - return; - }; + let db = state.db.as_ref(); let Ok(Some(page)) = PageRow::get(db, user_id, slug).await else { return; }; @@ -3024,7 +3005,6 @@ fn mime_from_path(p: &str) -> &'static str { mod tests { use super::*; use crate::db::{connect, page_kv, AuthToken, DeviceRow, PageRow, UserRow}; - use crate::relay::RoomManager; use crate::MemoryAssetStore; use axum::body::to_bytes; use axum::http::{Request, StatusCode}; @@ -3087,34 +3067,56 @@ mod tests { ) -> (axum::Router, String, String, Arc) { let pool = connect(":memory:").await.unwrap(); let pool = Arc::new(pool); - UserRow::create(&pool, "u1", "alice", "s", "ks", "{}", "hash", "wmk") - .await - .unwrap(); - UserRow::create(&pool, "u2", "bob", "s", "ks", "{}", "hash", "wmk") - .await - .unwrap(); - DeviceRow::upsert(&pool, "d1", "u1", "Laptop", None, None) + UserRow::create(&pool, "101", "alice").await.unwrap(); + UserRow::create(&pool, "102", "bob").await.unwrap(); + DeviceRow::upsert(&pool, "d1", "101", "Laptop", None, None) .await .unwrap(); - DeviceRow::upsert(&pool, "d2", "u2", "Phone", None, None) + DeviceRow::upsert(&pool, "d2", "102", "Phone", None, None) .await .unwrap(); - let tok_alice = AuthToken::create(&pool, "u1", "d1").await.unwrap(); - let tok_bob = AuthToken::create(&pool, "u2", "d2").await.unwrap(); + let tok_alice = AuthToken::create(&pool, "101", "d1").await.unwrap(); + let tok_bob = AuthToken::create(&pool, "102", "d2").await.unwrap(); let tmp = tempfile::tempdir().unwrap(); let page_data_dir = tmp.path().join("page-data"); std::mem::forget(tmp); let app = crate::build_relay_router_with_page_data_origins_and_page_auth( - RoomManager::new(), Arc::new(MemoryAssetStore::new()), std::time::Instant::now(), - Some(Arc::clone(&pool)), + Arc::clone(&pool), "test", Some(page_data_dir), Vec::new(), page_browser_auth, ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let authority_url = format!("http://{}/me", listener.local_addr().unwrap()); + let authority = axum::Router::new().route( + "/me", + axum::routing::get(|headers: HeaderMap| async move { + match headers.get("authorization").and_then(|h| h.to_str().ok()) { + Some("Bearer alice") => ( + StatusCode::OK, + Json(serde_json::json!({"user":{"githubId":101,"login":"alice"}})), + ), + Some("Bearer bob") => ( + StatusCode::OK, + Json(serde_json::json!({"user":{"githubId":102,"login":"bob"}})), + ), + _ => ( + StatusCode::UNAUTHORIZED, + Json(serde_json::json!({"error":"expired"})), + ), + } + }), + ); + tokio::spawn(async move { + axum::serve(listener, authority).await.unwrap(); + }); + let app = app.layer(Extension( + crate::identity::IdentityVerifier::with_url(&authority_url).unwrap(), + )); (app, tok_alice.token, tok_bob.token, pool) } @@ -4014,11 +4016,11 @@ mod tests { #[tokio::test] async fn expired_session_pruning_waits_for_the_target_page_lock() { let manager = Arc::new(PageUploadManager::new()); - let key = page_upload_session_key("u1", "locked"); + let key = page_upload_session_key("101", "locked"); manager.sessions.insert( key.clone(), PageUploadSession { - user_id: "u1".to_string(), + user_id: "101".to_string(), upload_id: Some("a".repeat(32)), draft_key: "pages/u1/locked/draft/id".to_string(), manifest: HashMap::new(), @@ -4452,7 +4454,7 @@ mod tests { }); let mut observed_start = false; for _ in 0..200 { - if page_kv::get(&pool, "u1", "shared-read", "hold-started") + if page_kv::get(&pool, "101", "shared-read", "hold-started") .await .unwrap() .as_deref() @@ -4539,7 +4541,7 @@ mod tests { }); let mut observed_start = false; for _ in 0..200 { - if page_kv::get(&pool, "u1", "delete-running", "started") + if page_kv::get(&pool, "101", "delete-running", "started") .await .unwrap() .as_deref() @@ -4578,11 +4580,11 @@ mod tests { ); assert_eq!(worker_request.await.unwrap().status(), StatusCode::OK); assert_eq!(deletion.await.unwrap().status(), StatusCode::OK); - assert!(PageRow::get(&pool, "u1", "delete-running") + assert!(PageRow::get(&pool, "101", "delete-running") .await .unwrap() .is_none()); - assert!(page_kv::get(&pool, "u1", "delete-running", "late") + assert!(page_kv::get(&pool, "101", "delete-running", "late") .await .unwrap() .is_none()); @@ -4620,8 +4622,7 @@ mod tests { .header("content-type", "application/json") .body(axum::body::Body::from( serde_json::json!({ - "username": username, - "password_hash": "hash", + "access_token": username, "return_to": return_to, }) .to_string(), @@ -4678,7 +4679,7 @@ mod tests { .unwrap(); let login_html = String::from_utf8_lossy(&body); assert!(login_html.contains("登录后访问")); - assert!(login_html.contains("data-page-login-username")); + assert!(login_html.contains("GitHub")); assert_eq!( get_page(&app, "/p/alice/priv", Some(&bob)).await, StatusCode::NOT_FOUND @@ -4859,8 +4860,7 @@ mod tests { assert!(login_page.headers().get(header::SET_COOKIE).is_none()); let body = to_bytes(login_page.into_body(), usize::MAX).await.unwrap(); let html = String::from_utf8_lossy(&body); - assert!(html.contains("data-page-login-username")); - assert!(html.contains("data-page-login-password")); + assert!(html.contains("GitHub")); assert!(html.contains("/api/page-auth/client.js")); let script = app @@ -4888,8 +4888,7 @@ mod tests { .header("content-type", "application/json") .body(axum::body::Body::from( serde_json::json!({ - "username": "alice", - "password_hash": "wrong", + "access_token": "invalid", "return_to": "/p/alice/private-open", }) .to_string(), @@ -4910,8 +4909,7 @@ mod tests { .header("content-type", "application/json") .body(axum::body::Body::from( serde_json::json!({ - "username": "bob", - "password_hash": "hash", + "access_token": "bob", "return_to": "/p/alice/private-open", }) .to_string(), @@ -4933,8 +4931,7 @@ mod tests { .header("content-type", "application/json") .body(axum::body::Body::from( serde_json::json!({ - "username": "alice", - "password_hash": "hash", + "access_token": "alice", "return_to": "/p/alice/private-open", "path_prefix": "/relay", }) @@ -5009,8 +5006,7 @@ mod tests { .header("content-type", "application/json") .body(axum::body::Body::from( serde_json::json!({ - "username": "bob", - "password_hash": "hash", + "access_token": "bob", "return_to": "/p/alice/relay-open", }) .to_string(), @@ -5051,8 +5047,7 @@ mod tests { .header("content-type", "application/json") .body(axum::body::Body::from( serde_json::json!({ - "username": "alice", - "password_hash": "hash", + "access_token": "alice", "return_to": "https://attacker.invalid/", }) .to_string(), @@ -5174,7 +5169,7 @@ mod tests { assert_eq!(sign_in.status(), StatusCode::OK); let body = to_bytes(sign_in.into_body(), usize::MAX).await.unwrap(); let html = String::from_utf8_lossy(&body); - assert!(html.contains("data-page-login-username")); + assert!(html.contains("GitHub")); assert!(html.contains(&format!(r#"data-page-login-state="{state}""#))); assert!(html.contains(r#"src="./client.js""#)); @@ -5188,8 +5183,7 @@ mod tests { .header("content-type", "application/json") .body(axum::body::Body::from( serde_json::json!({ - "username": "alice", - "password_hash": "hash", + "access_token": "alice", "state": state, }) .to_string(), @@ -5313,7 +5307,7 @@ mod tests { let manager = PageAccessManager::new(); let grant = manager .issue_browser_grant( - "u1".into(), + "101".into(), "viewer-1".into(), "site".into(), "generation-one".into(), @@ -5325,14 +5319,14 @@ mod tests { header::COOKIE, HeaderValue::from_str(&format!("{PAGE_ACCESS_COOKIE}={grant}")).unwrap(), ); - assert!(manager.authorizes_page(&headers, "u1", "site", "generation-one", None,)); - assert!(!manager.authorizes_page(&headers, "u1", "site", "generation-two", None,)); + assert!(manager.authorizes_page(&headers, "101", "site", "generation-one", None,)); + assert!(!manager.authorizes_page(&headers, "101", "site", "generation-two", None,)); let bounded = PageAccessManager::new(); for index in 0..MAX_PAGE_BROWSER_GRANTS_PER_USER { bounded .issue_browser_grant( - "u1".into(), + "101".into(), "viewer-1".into(), format!("site-{index}"), "generation".into(), @@ -5343,7 +5337,7 @@ mod tests { assert_eq!( bounded .issue_browser_grant( - "u1".into(), + "101".into(), "viewer-1".into(), "overflow".into(), "generation".into(), @@ -5354,7 +5348,7 @@ mod tests { ); assert!(bounded .issue_browser_grant( - "u1".into(), + "101".into(), "viewer-2".into(), "other".into(), "generation".into(), diff --git a/src/crates/services/relay-service/src/routes/sync.rs b/src/crates/services/relay-service/src/routes/sync.rs deleted file mode 100644 index 8d77399a8d..0000000000 --- a/src/crates/services/relay-service/src/routes/sync.rs +++ /dev/null @@ -1,314 +0,0 @@ -//! Token-authenticated sync endpoints for encrypted session/settings blobs. -//! -//! Each handler validates the `Authorization: Bearer ` header via a -//! shared helper (the relay stays zero-knowledge: it only stores/returns -//! AES-GCM ciphertext encrypted client-side with the account master key). - -use axum::extract::{DefaultBodyLimit, Path, Query, State}; -use axum::http::{header, HeaderMap, StatusCode}; -use axum::routing::{get, post}; -use axum::{Json, Router}; -use serde::{Deserialize, Serialize}; - -use crate::db::{AuthToken, SyncSessionRow, SyncSettingsRow}; -use crate::routes::api::AppState; - -/// Max request body for encrypted session/settings upserts. -/// -/// Session sync uploads a full encrypted `SessionBundle` (metadata + all -/// turns). Axum's default limit is ~2 MiB and will return HTTP 413 for long -/// conversations. Keep an explicit ceiling so reverse proxies and operators -/// can align `client_max_body_size` (nginx) / equivalent limits. -pub const SYNC_BODY_LIMIT: usize = 64 * 1024 * 1024; -const MAX_ENCRYPTED_BLOB_BYTES: usize = 48 * 1024 * 1024; -const MAX_SESSION_ID_BYTES: usize = 256; -const MAX_NONCE_BYTES: usize = 256; - -// Per-account sync-session quotas. -// -// Defaults use i32::MAX so OpenBitFun's own deployments do not hit artificial -// product caps. Keep these knobs (and the upsert/make-room enforcement paths) -// so self-hosted / open-source operators can lower them to bound each user's -// cloud session backup footprint. -const MAX_SYNC_SESSIONS_PER_USER: i64 = i32::MAX as i64; -const MAX_SYNC_SESSION_BYTES_PER_USER: i64 = i32::MAX as i64; - -fn valid_session_id(value: &str) -> bool { - !value.trim().is_empty() - && value.len() <= MAX_SESSION_ID_BYTES - && !value.chars().any(char::is_control) -} - -fn valid_encrypted_blob(encrypted_data: &str, nonce: &str, version: i64) -> bool { - !encrypted_data.is_empty() - && encrypted_data.len() <= MAX_ENCRYPTED_BLOB_BYTES - && !nonce.is_empty() - && nonce.len() <= MAX_NONCE_BYTES - && !nonce.chars().any(char::is_control) - && version > 0 -} - -/// Validated principal extracted from the bearer token. -pub struct AuthUser { - pub user_id: String, - #[allow(dead_code)] - pub device_id: String, -} - -/// Validate the bearer token in `headers`; returns the owning user/device. -pub async fn validate_auth(state: &AppState, headers: &HeaderMap) -> Result { - let token = extract_bearer_token(headers).ok_or(StatusCode::UNAUTHORIZED)?; - validate_token(state, &token).await -} - -/// Extract `Bearer` token from the `Authorization` header. -pub fn extract_bearer_token(headers: &HeaderMap) -> Option { - headers - .get(header::AUTHORIZATION) - .and_then(|v| v.to_str().ok()) - .and_then(|s| s.strip_prefix("Bearer ")) - .map(|t| t.trim().to_string()) - .filter(|t| !t.is_empty()) -} - -/// Validate a raw token string against the account database. -pub async fn validate_token(state: &AppState, token: &str) -> Result { - let db = state.db.as_ref().ok_or(StatusCode::NOT_IMPLEMENTED)?; - let auth = AuthToken::find(db, token) - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? - .ok_or(StatusCode::UNAUTHORIZED)?; - if !auth.is_device_token() { - return Err(StatusCode::FORBIDDEN); - } - Ok(AuthUser { - user_id: auth.user_id, - device_id: auth.device_id, - }) -} - -pub fn sync_router() -> Router { - Router::new() - .route( - "/api/sync/sessions", - post(sessions_upsert) - .get(sessions_list) - .layer(DefaultBodyLimit::max(SYNC_BODY_LIMIT)), - ) - .route( - "/api/sync/sessions/{session_id}", - get(sessions_get).delete(sessions_delete), - ) - .route( - "/api/sync/settings", - post(settings_upsert) - .get(settings_get) - .layer(DefaultBodyLimit::max(SYNC_BODY_LIMIT)), - ) -} - -// ── Session sync ──────────────────────────────────────────────────────── - -#[derive(Deserialize)] -pub struct SessionUpsertRequest { - pub session_id: String, - pub encrypted_data: String, - pub nonce: String, - pub version: i64, -} - -#[derive(Serialize)] -pub struct SessionBlob { - pub session_id: String, - pub encrypted_data: String, - pub nonce: String, - pub version: i64, - pub updated_at: i64, -} - -#[derive(Serialize)] -pub struct SessionListResponse { - pub sessions: Vec, -} - -async fn sessions_upsert( - State(state): State, - headers: HeaderMap, - Json(body): Json, -) -> Result { - let auth = validate_auth(&state, &headers).await?; - if !valid_session_id(&body.session_id) - || !valid_encrypted_blob(&body.encrypted_data, &body.nonce, body.version) - { - return Err(StatusCode::BAD_REQUEST); - } - let db = state.db.as_ref().ok_or(StatusCode::NOT_IMPLEMENTED)?; - let mut stored = SyncSessionRow::upsert_with_quota( - db, - &auth.user_id, - &body.session_id, - &body.encrypted_data, - &body.nonce, - body.version, - MAX_SYNC_SESSIONS_PER_USER, - MAX_SYNC_SESSION_BYTES_PER_USER, - ) - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - if !stored { - // Prefer keeping the session being uploaded: evict LRU cloud backups - // until this blob fits the configured per-user quotas, then retry once. - let evicted = SyncSessionRow::make_room_for_upsert( - db, - &auth.user_id, - &body.session_id, - body.encrypted_data.len() as i64, - MAX_SYNC_SESSIONS_PER_USER, - MAX_SYNC_SESSION_BYTES_PER_USER, - ) - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - if evicted > 0 { - stored = SyncSessionRow::upsert_with_quota( - db, - &auth.user_id, - &body.session_id, - &body.encrypted_data, - &body.nonce, - body.version, - MAX_SYNC_SESSIONS_PER_USER, - MAX_SYNC_SESSION_BYTES_PER_USER, - ) - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - } - } - if !stored { - return Err(StatusCode::INSUFFICIENT_STORAGE); - } - Ok(StatusCode::NO_CONTENT) -} - -async fn sessions_list( - State(state): State, - headers: HeaderMap, - Query(params): Query, -) -> Result, StatusCode> { - let auth = validate_auth(&state, &headers).await?; - let db = state.db.as_ref().ok_or(StatusCode::NOT_IMPLEMENTED)?; - let rows = SyncSessionRow::list_since(db, &auth.user_id, params.since.unwrap_or(0)) - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - let sessions = rows - .into_iter() - .map(|r| SessionBlob { - session_id: r.session_id, - encrypted_data: r.encrypted_data, - nonce: r.nonce, - version: r.version, - updated_at: r.updated_at, - }) - .collect(); - Ok(Json(SessionListResponse { sessions })) -} - -async fn sessions_get( - State(state): State, - headers: HeaderMap, - Path(session_id): Path, -) -> Result, StatusCode> { - let auth = validate_auth(&state, &headers).await?; - if !valid_session_id(&session_id) { - return Err(StatusCode::BAD_REQUEST); - } - let db = state.db.as_ref().ok_or(StatusCode::NOT_IMPLEMENTED)?; - let row = SyncSessionRow::get(db, &auth.user_id, &session_id) - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? - .ok_or(StatusCode::NOT_FOUND)?; - Ok(Json(SessionBlob { - session_id: row.session_id, - encrypted_data: row.encrypted_data, - nonce: row.nonce, - version: row.version, - updated_at: row.updated_at, - })) -} - -async fn sessions_delete( - State(state): State, - headers: HeaderMap, - Path(session_id): Path, -) -> Result { - let auth = validate_auth(&state, &headers).await?; - if !valid_session_id(&session_id) { - return Err(StatusCode::BAD_REQUEST); - } - let db = state.db.as_ref().ok_or(StatusCode::NOT_IMPLEMENTED)?; - SyncSessionRow::delete(db, &auth.user_id, &session_id) - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - Ok(StatusCode::NO_CONTENT) -} - -// ── Settings sync ─────────────────────────────────────────────────────── - -#[derive(Deserialize)] -pub struct SettingsUpsertRequest { - pub encrypted_data: String, - pub nonce: String, - pub version: i64, -} - -#[derive(Serialize)] -pub struct SettingsBlob { - pub encrypted_data: String, - pub nonce: String, - pub version: i64, - pub updated_at: i64, -} - -async fn settings_upsert( - State(state): State, - headers: HeaderMap, - Json(body): Json, -) -> Result { - let auth = validate_auth(&state, &headers).await?; - if !valid_encrypted_blob(&body.encrypted_data, &body.nonce, body.version) { - return Err(StatusCode::BAD_REQUEST); - } - let db = state.db.as_ref().ok_or(StatusCode::NOT_IMPLEMENTED)?; - SyncSettingsRow::upsert( - db, - &auth.user_id, - &body.encrypted_data, - &body.nonce, - body.version, - ) - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - Ok(StatusCode::NO_CONTENT) -} - -async fn settings_get( - State(state): State, - headers: HeaderMap, -) -> Result>, StatusCode> { - let auth = validate_auth(&state, &headers).await?; - let db = state.db.as_ref().ok_or(StatusCode::NOT_IMPLEMENTED)?; - let row = SyncSettingsRow::get(db, &auth.user_id) - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? - .map(|r| SettingsBlob { - encrypted_data: r.encrypted_data, - nonce: r.nonce, - version: r.version, - updated_at: r.updated_at, - }); - Ok(Json(row)) -} - -#[derive(Deserialize)] -pub struct SinceParams { - pub since: Option, -} diff --git a/src/crates/services/relay-service/src/routes/websocket.rs b/src/crates/services/relay-service/src/routes/websocket.rs index 8f3510d74f..ab8090dc41 100644 --- a/src/crates/services/relay-service/src/routes/websocket.rs +++ b/src/crates/services/relay-service/src/routes/websocket.rs @@ -25,21 +25,51 @@ use tokio::sync::{ }; use tracing::{debug, error, info, warn}; -use crate::relay::room::{send_outbound_message, ConnId, OutboundMessage, ResponsePayload}; +use crate::relay::transport::{send_outbound_message, ConnId, OutboundMessage}; use crate::routes::api::AppState; -const OUTBOUND_QUEUE_CAPACITY: usize = i32::MAX as usize; -const MAX_WS_MESSAGE_BYTES: usize = 64 * 1024 * 1024; +const OUTBOUND_QUEUE_CAPACITY: usize = 128; +// Authentication and heartbeats stay small. Device payloads are submitted over +// HTTP, where admission reserves process-wide memory before buffering a body. +// A per-socket 64 MiB allowance otherwise multiplies across idle connections. +const MAX_WS_MESSAGE_BYTES: usize = 16 * 1024; const MAX_ENCRYPTED_PAYLOAD_BYTES: usize = 48 * 1024 * 1024; const MAX_IDENTIFIER_BYTES: usize = 128; const MAX_DEVICE_NAME_BYTES: usize = 256; -const MAX_PUBLIC_KEY_BYTES: usize = 512; const MAX_NONCE_BYTES: usize = 256; const RATE_LIMIT_WINDOW: Duration = Duration::from_secs(60); -const MAX_MESSAGES_PER_WINDOW: u32 = i32::MAX as u32; +const MAX_MESSAGES_PER_WINDOW: u32 = 12_000; +const MAX_WEBSOCKET_CONNECTIONS: usize = 4096; +const AUTHENTICATION_TIMEOUT: Duration = Duration::from_secs(10); +const WRITE_TIMEOUT: Duration = Duration::from_secs(15); const DEVICE_TOKEN_REVALIDATION_INTERVAL: Duration = Duration::from_secs(5); const SOCKET_CLOSE_GRACE: Duration = Duration::from_secs(5); +const MAX_WEBSOCKET_CONNECTIONS_PER_IP: usize = 128; + +#[derive(Default)] +struct IpConnectionSlots( + std::sync::Mutex>>, +); + +impl IpConnectionSlots { + fn acquire(&self, ip: &str) -> Option { + let mut slots = self.0.lock().unwrap_or_else(|error| error.into_inner()); + slots.retain(|_, permits| permits.strong_count() > 0); + let permits = slots + .get(ip) + .and_then(std::sync::Weak::upgrade) + .unwrap_or_else(|| { + let permits = Arc::new(tokio::sync::Semaphore::new( + MAX_WEBSOCKET_CONNECTIONS_PER_IP, + )); + slots.insert(ip.to_owned(), Arc::downgrade(&permits)); + permits + }); + permits.try_acquire_owned().ok() + } +} + struct ConnectionRateLimiter { window_started: Instant, message_count: u32, @@ -70,22 +100,8 @@ impl ConnectionRateLimiter { #[derive(Debug, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] pub enum InboundMessage { - CreateRoom { - room_id: Option, - device_id: String, - #[allow(dead_code)] - device_type: String, - public_key: String, - }, - /// Desktop responds to a bridged HTTP request. - RelayResponse { - correlation_id: String, - encrypted_data: String, - nonce: String, - }, Heartbeat, - /// Account-authenticated connect (parallel to CreateRoom for the device - /// routing pathway). Validates the token and registers the device. + /// Authenticate the socket before admitting any device traffic. AuthConnect { token: String, device_name: String, @@ -107,22 +123,6 @@ pub enum InboundMessage { #[derive(Debug, Serialize)] #[serde(tag = "type", rename_all = "snake_case")] pub enum OutboundProtocol { - RoomCreated { - room_id: String, - }, - /// Mobile pairing request forwarded to desktop. - PairRequest { - correlation_id: String, - public_key: String, - device_id: String, - device_name: String, - }, - /// Encrypted command from mobile forwarded to desktop. - Command { - correlation_id: String, - encrypted_data: String, - nonce: String, - }, HeartbeatAck, Error { message: String, @@ -158,15 +158,46 @@ pub async fn websocket_handler( ws: WebSocketUpgrade, State(state): State, headers: HeaderMap, + peer: Option>>, ) -> Response { if !is_websocket_origin_allowed(&headers, &state.cors_allow_origins) { warn!("Rejected WebSocket connection from a disallowed browser origin"); return StatusCode::FORBIDDEN.into_response(); } - ws.max_message_size(MAX_WS_MESSAGE_BYTES) + let ip = crate::routes::auth::client_ip( + &headers, + peer.map(|axum::Extension(axum::extract::ConnectInfo(addr))| addr), + ); + if !state + .login_rate_limiter + .check_and_record("websocket-connect", &ip, 120, None) + { + return StatusCode::TOO_MANY_REQUESTS.into_response(); + } + static CONNECTIONS: std::sync::OnceLock> = + std::sync::OnceLock::new(); + let permits = CONNECTIONS + .get_or_init(|| Arc::new(tokio::sync::Semaphore::new(MAX_WEBSOCKET_CONNECTIONS))); + let Ok(permit) = Arc::clone(permits).try_acquire_owned() else { + return StatusCode::SERVICE_UNAVAILABLE.into_response(); + }; + static IP_CONNECTIONS: std::sync::OnceLock = std::sync::OnceLock::new(); + let Some(ip_permit) = IP_CONNECTIONS + .get_or_init(IpConnectionSlots::default) + .acquire(&ip) + else { + return StatusCode::TOO_MANY_REQUESTS.into_response(); + }; + ws.read_buffer_size(4 * 1024) + .write_buffer_size(0) + .max_message_size(MAX_WS_MESSAGE_BYTES) .max_frame_size(MAX_WS_MESSAGE_BYTES) - .max_write_buffer_size(MAX_WS_MESSAGE_BYTES) - .on_upgrade(move |socket| handle_socket(socket, state)) + .max_write_buffer_size(64 * 1024 * 1024) + .on_upgrade(move |socket| async move { + let _permit = permit; + let _ip_permit = ip_permit; + handle_socket(socket, state).await; + }) } fn is_websocket_origin_allowed(headers: &HeaderMap, allowed_origins: &[String]) -> bool { @@ -212,8 +243,9 @@ async fn handle_socket(socket: WebSocket, state: AppState) { let (out_tx, mut out_rx) = mpsc::channel::(OUTBOUND_QUEUE_CAPACITY); let (force_close_tx, mut force_close_rx) = watch::channel(false); - let conn_id = state.room_manager.next_conn_id(); + let conn_id = state.device_manager.next_connection_id(); let mut rate_limiter = ConnectionRateLimiter::new(); + let authentication_deadline = tokio::time::Instant::now() + AUTHENTICATION_TIMEOUT; let mut token_expiry_task: Option> = None; info!("WebSocket connected: conn_id={conn_id}"); @@ -237,9 +269,9 @@ async fn handle_socket(socket: WebSocket, state: AppState) { let sent = tokio::select! { biased; _ = writer_force_close_rx.changed() => break, - result = ws_sender.send(Message::Text(msg.text.into())) => result, + result = tokio::time::timeout(WRITE_TIMEOUT, ws_sender.send(Message::Text(msg.text.into()))) => result, }; - if sent.is_err() { + if !matches!(sent, Ok(Ok(()))) { // The read half can remain open after a write-half // failure. Wake the owner loop so it promptly removes // routing/presence instead of leaving a half-open @@ -265,6 +297,11 @@ async fn handle_socket(socket: WebSocket, state: AppState) { } break; } + _ = tokio::time::sleep_until(authentication_deadline), + if !state.device_manager.has_connection(conn_id) => { + warn!("WebSocket authentication deadline exceeded: conn_id={conn_id}"); + break; + } msg = ws_receiver.next() => { let Some(msg) = msg else { break }; msg @@ -322,14 +359,11 @@ async fn handle_socket(socket: WebSocket, state: AppState) { task.abort(); } - state.room_manager.on_disconnect(conn_id); let _presence_projection_guard = state.device_manager.lock_presence_projection().await; if let Some((user_id, device_id)) = state.device_manager.unregister(conn_id) { // Best-effort: mark the device offline in the DB and notify peers. if !state.device_manager.is_device_online(&user_id, &device_id) { - if let Some(db) = state.db.as_ref() { - let _ = crate::db::DeviceRow::set_online(db, &user_id, &device_id, false).await; - } + let _ = crate::db::DeviceRow::set_online(&state.db, &user_id, &device_id, false).await; } state .device_manager @@ -360,13 +394,11 @@ async fn finish_socket_writer(mut write_task: tokio::task::JoinHandle<()>) { } fn ensure_device_token_revalidator(state: &AppState) { - let Some(db) = state.db.clone() else { - return; - }; + let db = Arc::downgrade(&state.db); if !state.device_manager.claim_token_revalidator_start() { return; } - let device_manager = Arc::clone(&state.device_manager); + let device_manager = Arc::downgrade(&state.device_manager); tokio::spawn(async move { let mut interval = tokio::time::interval(DEVICE_TOKEN_REVALIDATION_INTERVAL); interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); @@ -375,6 +407,9 @@ fn ensure_device_token_revalidator(state: &AppState) { interval.tick().await; loop { interval.tick().await; + let (Some(db), Some(device_manager)) = (db.upgrade(), device_manager.upgrade()) else { + break; + }; if let Err(error) = revalidate_active_device_tokens_once(&db, &device_manager).await { warn!(%error, "Failed to revalidate active device tokens"); } @@ -470,8 +505,6 @@ async fn handle_text_message( } }; let message_type = match &msg { - InboundMessage::CreateRoom { .. } => "create_room", - InboundMessage::RelayResponse { .. } => "relay_response", InboundMessage::Heartbeat => "heartbeat", InboundMessage::AuthConnect { .. } => "auth_connect", InboundMessage::DeviceMessage { .. } => "device_message", @@ -482,84 +515,14 @@ async fn handle_text_message( ); match msg { - InboundMessage::CreateRoom { - room_id, - device_id, - device_type, - public_key, - } => { - if state.device_manager.conn_mapping(conn_id).is_some() { - return reject_protocol( - out_tx, - "an authenticated device connection cannot create a pairing room", - ); - } - if !is_valid_identifier(&device_id) - || !is_valid_display_text(&device_type, 32) - || !is_valid_display_text(&public_key, MAX_PUBLIC_KEY_BYTES) - || room_id - .as_deref() - .is_some_and(|value| !crate::relay::room::is_valid_room_id(value)) - { - return reject_protocol(out_tx, "invalid room parameters"); - } - let room_id = room_id.unwrap_or_else(generate_room_id); - let ok = state.room_manager.create_room( - &room_id, - conn_id, - &device_id, - &public_key, - out_tx.clone(), - ); - if ok { - send_json(out_tx, &OutboundProtocol::RoomCreated { room_id }).await - } else { - send_json( - out_tx, - &OutboundProtocol::Error { - message: "failed to create room".into(), - }, - ) - .await - } - } - - InboundMessage::RelayResponse { - correlation_id, - encrypted_data, - nonce, - } => { - if !is_valid_identifier(&correlation_id) - || !is_valid_encrypted_payload(&encrypted_data, &nonce) - { - return reject_protocol(out_tx, "invalid relay response"); - } - debug!("RelayResponse from desktop conn_id={conn_id} corr={correlation_id}"); - if !state.room_manager.resolve_pending_from_conn( - conn_id, - &correlation_id, - ResponsePayload { - encrypted_data, - nonce, - }, - ) { - return reject_protocol(out_tx, "relay response does not match this room"); - } - true - } - InboundMessage::Heartbeat => { - // Account-authenticated device connections have no room; treat - // heartbeat as a keepalive ack when the conn is registered. - if state.room_manager.heartbeat(conn_id) - || state.device_manager.conn_mapping(conn_id).is_some() - { + if state.device_manager.conn_mapping(conn_id).is_some() { send_json_best_effort(out_tx, &OutboundProtocol::HeartbeatAck) } else { send_json_best_effort( out_tx, &OutboundProtocol::Error { - message: "Room not found or expired".into(), + message: "Device is not authenticated".into(), }, ) } @@ -570,9 +533,7 @@ async fn handle_text_message( device_name, device_kind, } => { - if state.room_manager.has_connection(conn_id) - || state.device_manager.has_connection(conn_id) - { + if state.device_manager.has_connection(conn_id) { return reject_protocol(out_tx, "connection is already authenticated"); } if !crate::db::is_valid_auth_token(&token) @@ -583,14 +544,7 @@ async fn handle_text_message( { return reject_protocol(out_tx, "invalid authentication parameters"); } - let Some(db) = state.db.as_ref() else { - return send_json_best_effort( - out_tx, - &OutboundProtocol::AuthError { - message: "account features disabled".into(), - }, - ); - }; + let db = state.db.as_ref(); let auth = match crate::db::AuthToken::find(db, &token).await { Ok(Some(a)) => a, _ => { @@ -692,9 +646,11 @@ async fn handle_text_message( // First check: is this a response to a pending HTTP RPC? // If so, resolve the pending future and don't forward via WS. - let rpc_response = crate::relay::device_manager::RpcResponse { - encrypted_data: encrypted_data.clone(), - nonce: nonce.clone(), + let Some(rpc_response) = crate::relay::device_manager::RpcResponse::try_new( + encrypted_data.clone(), + nonce.clone(), + ) else { + return reject_protocol(out_tx, "response memory budget exhausted"); }; if state.device_manager.resolve_rpc( &correlation_id, @@ -938,7 +894,10 @@ async fn activate_pending_device_if_authorized( async fn send_json(tx: &mpsc::Sender, msg: &T) -> bool { match serde_json::to_string(msg) { - Ok(json) => send_outbound_message(tx, OutboundMessage::text(json)).await, + Ok(json) => match OutboundMessage::try_text(&json) { + Some(message) => send_outbound_message(tx, message).await, + None => false, + }, Err(e) => { warn!("Failed to serialize outbound websocket message: {e}"); false @@ -948,14 +907,19 @@ async fn send_json(tx: &mpsc::Sender, msg: &T) -> fn send_json_best_effort(tx: &mpsc::Sender, msg: &T) -> bool { match serde_json::to_string(msg) { - Ok(json) => match tx.try_send(OutboundMessage::text(json)) { - Ok(()) => true, - Err(TrySendError::Full(_)) => { - warn!("Outbound websocket queue is full; dropping best-effort control response"); - true + Ok(json) => { + let Some(message) = OutboundMessage::try_text(&json) else { + return false; + }; + match tx.try_send(message) { + Ok(()) => true, + Err(TrySendError::Full(_)) => { + warn!("Outbound websocket queue is full; closing slow connection"); + false + } + Err(TrySendError::Closed(_)) => false, } - Err(TrySendError::Closed(_)) => false, - }, + } Err(e) => { warn!("Failed to serialize outbound websocket message: {e}"); false @@ -963,13 +927,23 @@ fn send_json_best_effort(tx: &mpsc::Sender, msg: } } -fn generate_room_id() -> String { - let bytes: [u8; 6] = rand::random(); - bytes.iter().map(|b| format!("{b:02x}")).collect() -} - #[cfg(test)] mod tests { + #[test] + fn connection_slots_limit_one_ip_and_release_after_disconnect() { + let slots = super::IpConnectionSlots::default(); + let mut permits: Vec<_> = (0..super::MAX_WEBSOCKET_CONNECTIONS_PER_IP) + .map(|_| slots.acquire("one").unwrap()) + .collect(); + assert!(slots.acquire("one").is_none()); + assert!(slots.acquire("two").is_some()); + permits.pop(); + assert!(slots.acquire("one").is_some()); + drop(permits); + let _other = slots.acquire("three").unwrap(); + assert_eq!(slots.0.lock().unwrap().len(), 1); + } + use super::{ activate_pending_device_if_authorized, complete_pending_device_activation_if_authorized, is_valid_display_text, is_valid_encrypted_payload, is_valid_identifier, @@ -1005,19 +979,17 @@ mod tests { } #[tokio::test] - async fn client_close_receives_ack_and_removes_room_routing() { + async fn client_close_receives_ack_without_leaking_socket() { use futures_util::{SinkExt, StreamExt}; use tokio_tungstenite::tungstenite::{ protocol::{frame::coding::CloseCode, CloseFrame}, Message, }; - let rooms = crate::RoomManager::new(); let app = crate::build_relay_router( - rooms.clone(), std::sync::Arc::new(crate::MemoryAssetStore::new()), std::time::Instant::now(), - None, + std::sync::Arc::new(crate::db::connect(":memory:").await.unwrap()), "test", ); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); @@ -1026,23 +998,6 @@ mod tests { let (mut client, _) = tokio_tungstenite::connect_async(format!("ws://{address}/ws")) .await .unwrap(); - client - .send(Message::Text( - serde_json::json!({ - "type": "create_room", - "device_id": "close-test", - "device_type": "desktop", - "public_key": "test-public-key" - }) - .to_string() - .into(), - )) - .await - .unwrap(); - let registered = client.next().await.unwrap().unwrap(); - assert!(registered.into_text().unwrap().contains("room_created")); - assert_eq!(rooms.connection_count(), 1); - let close = CloseFrame { code: CloseCode::Normal, reason: "completed".into(), @@ -1057,7 +1012,6 @@ mod tests { .expect("close acknowledgement frame") .expect("clean WebSocket close"); assert_eq!(response, Message::Close(Some(close))); - assert_eq!(rooms.connection_count(), 0); server.abort(); let _ = server.await; } @@ -1068,8 +1022,8 @@ mod tests { assert!(send_json_best_effort(&tx, &OutboundProtocol::HeartbeatAck)); assert!( - send_json_best_effort(&tx, &OutboundProtocol::HeartbeatAck), - "full queue should drop best-effort control response without closing read loop" + !send_json_best_effort(&tx, &OutboundProtocol::HeartbeatAck), + "full queue must close a slow reader without blocking" ); } @@ -1085,12 +1039,14 @@ mod tests { } #[test] - fn websocket_message_rate_is_effectively_unbounded() { - assert_eq!(MAX_MESSAGES_PER_WINDOW, i32::MAX as u32); + fn websocket_message_rate_rejects_over_budget_and_recovers() { let mut limiter = ConnectionRateLimiter::new(); - for _ in 0..64 { + for _ in 0..MAX_MESSAGES_PER_WINDOW { assert!(limiter.allow()); } + assert!(!limiter.allow()); + limiter.window_started -= super::RATE_LIMIT_WINDOW; + assert!(limiter.allow()); } #[test] @@ -1123,9 +1079,7 @@ mod tests { #[tokio::test] async fn token_revoked_between_initial_validation_and_activation_is_rejected() { let db = connect(":memory:").await.unwrap(); - UserRow::create(&db, "owner", "alice", "s", "ks", "{}", "hash", "wmk") - .await - .unwrap(); + UserRow::create(&db, "owner", "alice").await.unwrap(); DeviceRow::upsert(&db, "device-a", "owner", "Device A", None, None) .await .unwrap(); @@ -1169,9 +1123,7 @@ mod tests { #[tokio::test] async fn expired_token_disconnects_active_and_pending_without_ghost_online_projection() { let db = connect(":memory:").await.unwrap(); - UserRow::create(&db, "owner", "alice", "s", "ks", "{}", "hash", "wmk") - .await - .unwrap(); + UserRow::create(&db, "owner", "alice").await.unwrap(); DeviceRow::upsert(&db, "device-a", "owner", "Device A", None, None) .await .unwrap(); @@ -1254,9 +1206,7 @@ mod tests { #[tokio::test] async fn external_token_revocation_reaper_disconnects_idle_device_and_updates_presence() { let db = connect(":memory:").await.unwrap(); - UserRow::create(&db, "owner", "alice", "s", "ks", "{}", "hash", "wmk") - .await - .unwrap(); + UserRow::create(&db, "owner", "alice").await.unwrap(); DeviceRow::upsert(&db, "device-a", "owner", "Device A", None, None) .await .unwrap(); @@ -1347,9 +1297,7 @@ mod tests { #[tokio::test] async fn token_expiring_during_durable_projection_never_receives_auth_ok() { let db = connect(":memory:").await.unwrap(); - UserRow::create(&db, "owner", "alice", "s", "ks", "{}", "hash", "wmk") - .await - .unwrap(); + UserRow::create(&db, "owner", "alice").await.unwrap(); DeviceRow::upsert(&db, "device-a", "owner", "Device A", None, None) .await .unwrap(); @@ -1398,9 +1346,7 @@ mod tests { #[tokio::test] async fn stale_auth_connect_cannot_recreate_a_deleted_device() { let db = connect(":memory:").await.unwrap(); - UserRow::create(&db, "owner", "alice", "s", "ks", "{}", "hash", "wmk") - .await - .unwrap(); + UserRow::create(&db, "owner", "alice").await.unwrap(); DeviceRow::upsert(&db, "device-a", "owner", "Device A", None, None) .await .unwrap(); @@ -1434,9 +1380,7 @@ mod tests { #[tokio::test] async fn pending_device_becomes_routable_only_after_durable_projection() { let db = connect(":memory:").await.unwrap(); - UserRow::create(&db, "owner", "alice", "s", "ks", "{}", "hash", "wmk") - .await - .unwrap(); + UserRow::create(&db, "owner", "alice").await.unwrap(); DeviceRow::upsert(&db, "device-a", "owner", "Device A", None, None) .await .unwrap(); diff --git a/src/crates/services/relay-service/web/page-auth-client.test.mjs b/src/crates/services/relay-service/web/page-auth-client.test.mjs index 999eacb9b3..a6406acfdb 100644 --- a/src/crates/services/relay-service/web/page-auth-client.test.mjs +++ b/src/crates/services/relay-service/web/page-auth-client.test.mjs @@ -1,21 +1,66 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { argon2idAsync } from '@noble/hashes/argon2.js'; +import vm from 'node:vm'; +import { readFile } from 'node:fs/promises'; -test('browser Argon2id output matches the native account client', async () => { - const password = new TextEncoder().encode('correct horse battery staple'); - const salt = Uint8Array.from({ length: 16 }, (_, index) => index); - const output = await argon2idAsync(password, salt, { - m: 8 * 1024, - t: 1, - p: 1, - dkLen: 32, - version: 0x13, - asyncTick: 10, - }); +const script = await readFile(new URL('../src/routes/page_auth_client.bundle.js', import.meta.url), 'utf8'); - assert.equal( - Buffer.from(output).toString('base64'), - 'mu73UxPlhfSSwzxeEtgumtJTt914Yy1Tfomc1O3deJw=', - ); +async function runLogin({ state, status = 'approved', authorizationUrl = 'https://github.com/login/oauth/authorize?state=test' } = {}) { + let submit; + let redirect; + let opened; + let closed = false; + const calls = []; + const button = { disabled: false, textContent: '' }; + const error = { hidden: true, textContent: '' }; + const form = { dataset: { pageLoginState: state }, addEventListener: (_, callback) => { submit = callback; } }; + const elements = { '[data-page-login-form]': form, '[data-page-login-submit]': button, '[data-page-login-error]': error }; + const context = { + document: { querySelector: (selector) => elements[selector] }, + navigator: { language: 'en' }, + URL, Error, Date, + setTimeout: (callback) => callback(), + window: { + location: { pathname: state ? '/v/1.0.0/api/page-auth/login' : '/v/1.0.0/p/alice/demo', search: '?q=1', replace: (value) => { redirect = value; } }, + open: () => ({ opener: {}, location: { replace: (value) => { opened = value; } }, close: () => { closed = true; } }), + }, + fetch: async (url, options) => { + const body = JSON.parse(options.body); + calls.push({ url, body }); + if (url.endsWith('/github/start')) return { ok: true, json: async () => ({ transactionId: 'txn', transactionSecret: 'secret', authorizationUrl, expiresAt: Date.now() / 1000 + 60, pollIntervalSeconds: 3 }) }; + if (url.endsWith('/github/poll')) return { ok: true, json: async () => ({ status, tokens: status === 'approved' ? { accessToken: 'verified-account-token' } : undefined }) }; + if (url.endsWith('/page-auth/login')) return { ok: true, json: async () => ({ redirect_to: state ? 'https://pages.example/callback?code=one-time' : '/p/alice/demo?q=1' }) }; + throw new Error(`Unexpected URL ${url}`); + }, + }; + vm.runInNewContext(script, context); + await submit({ preventDefault() {} }); + return { calls, redirect, opened, closed, error, button }; +} + +test('GitHub exchange preserves version prefix and submits only verified identity for Page access', async () => { + const result = await runLogin(); + assert.equal(result.calls.length, 3); + assert.equal(result.calls[1].url, '/v/1.0.0/api/auth/github/poll'); + assert.deepEqual(result.calls[1].body, { transactionId: 'txn', transactionSecret: 'secret' }); + assert.deepEqual(result.calls[2].body, { access_token: 'verified-account-token', return_to: '/p/alice/demo?q=1', path_prefix: '/v/1.0.0' }); + assert.equal(result.redirect, '/v/1.0.0/p/alice/demo?q=1'); + assert.equal(result.closed, true); + assert.equal(result.button.disabled, false); +}); + +test('isolated Page sign-in retains the one-time login state and callback origin', async () => { + const result = await runLogin({ state: 'login-state' }); + assert.deepEqual(result.calls[2].body, { access_token: 'verified-account-token', state: 'login-state' }); + assert.equal(result.redirect, 'https://pages.example/callback?code=one-time'); +}); + +test('failed authorization or an untrusted OAuth URL never submits Page access', async () => { + for (const options of [{ status: 'expired' }, { authorizationUrl: 'https://attacker.example/login' }]) { + const result = await runLogin(options); + assert.equal(result.calls.some((call) => call.url.endsWith('/page-auth/login')), false); + assert.equal(result.redirect, undefined); + assert.equal(result.error.hidden, false); + assert.equal(result.closed, true); + } }); diff --git a/src/crates/services/relay-service/web/page-auth-client.ts b/src/crates/services/relay-service/web/page-auth-client.ts index 135d558c41..a896925bf5 100644 --- a/src/crates/services/relay-service/web/page-auth-client.ts +++ b/src/crates/services/relay-service/web/page-auth-client.ts @@ -1,27 +1,11 @@ -import { argon2idAsync } from '@noble/hashes/argon2.js'; - -interface LoginChallenge { - kdf_salt: string; - argon2_params: string; -} - -interface KdfParams { - m: number; - t: number; - p: number; -} - interface ErrorBody { error?: string; retry_after_secs?: number; } const form = document.querySelector('[data-page-login-form]'); -const usernameInput = document.querySelector('[data-page-login-username]'); -const passwordInput = document.querySelector('[data-page-login-password]'); const submitButton = document.querySelector('[data-page-login-submit]'); const errorElement = document.querySelector('[data-page-login-error]'); -const toggleButton = document.querySelector('[data-page-login-toggle-password]'); const loginState = form?.dataset.pageLoginState; function relayPathPrefix(): string { @@ -67,39 +51,6 @@ function showError(value: string): void { errorElement.hidden = value.length === 0; } -function decodeBase64(value: string): Uint8Array { - const binary = atob(value); - const bytes = new Uint8Array(binary.length); - for (let index = 0; index < binary.length; index += 1) { - bytes[index] = binary.charCodeAt(index); - } - return bytes; -} - -function encodeBase64(value: Uint8Array): string { - let binary = ''; - for (let offset = 0; offset < value.length; offset += 0x8000) { - binary += String.fromCharCode(...value.subarray(offset, offset + 0x8000)); - } - return btoa(binary); -} - -function parseKdfParams(raw: string): KdfParams { - const value = JSON.parse(raw) as Partial; - if (!Number.isInteger(value.m) - || !Number.isInteger(value.t) - || !Number.isInteger(value.p) - || value.m! < 8 * 1024 - || value.m! > 256 * 1024 - || value.t! < 1 - || value.t! > 10 - || value.p! < 1 - || value.p! > 16) { - throw new Error(message('登录参数无效。', 'The sign-in parameters are invalid.')); - } - return value as KdfParams; -} - async function readError(response: Response): Promise { const fallback = message('登录失败,请重试。', 'Sign-in failed. Try again.'); try { @@ -110,9 +61,6 @@ async function readError(response: Response): Promise { `Too many attempts. Try again in ${body.retry_after_secs} seconds.`, ); } - if (body.error === 'invalid username or password') { - return message('用户名或密码不正确。', 'Incorrect username or password.'); - } if (body.error === 'account does not have access to this Page') { return message('该账号没有此页面的访问权限。', 'This account cannot access the Page.'); } @@ -135,77 +83,64 @@ async function postJson(path: string, body: Record): Promise return response.json() as Promise; } -toggleButton?.addEventListener('click', () => { - if (!passwordInput) return; - const reveal = passwordInput.type === 'password'; - passwordInput.type = reveal ? 'text' : 'password'; - toggleButton.textContent = reveal - ? message('隐藏', 'Hide') - : message('显示', 'Show'); - toggleButton.setAttribute('aria-pressed', String(reveal)); -}); +interface AuthStart { + transactionId: string; + transactionSecret: string; + authorizationUrl: string; + expiresAt: number; + pollIntervalSeconds: number; +} form?.addEventListener('submit', async (event) => { event.preventDefault(); - if (!usernameInput || !passwordInput || !submitButton) return; - - const username = usernameInput.value.trim(); - const password = passwordInput.value; - if (!username || username.length > 128 || !password || password.length > 1024) { - showError(message('请输入有效的用户名和密码。', 'Enter a valid username and password.')); + if (!submitButton || submitButton.disabled) return; + // Open synchronously during the click so browser popup protection permits it. + const popup = window.open('about:blank', '_blank'); + if (!popup) { + showError(message('请允许登录弹窗后重试。', 'Allow the sign-in popup and try again.')); return; } - + popup.opener = null; showError(''); submitButton.disabled = true; - submitButton.textContent = message('正在验证…', 'Signing in…'); - - let passwordBytes: Uint8Array | undefined; - let passwordHash: Uint8Array | undefined; + submitButton.textContent = message('等待 GitHub 授权…', 'Waiting for GitHub…'); try { - const challenge = await postJson( - relayApiPath('/api/auth/login/challenge'), - { username }, - ); - const salt = decodeBase64(challenge.kdf_salt); - if (salt.length !== 16) { - throw new Error(message('登录参数无效。', 'The sign-in parameters are invalid.')); + const start = await postJson(relayApiPath('/api/auth/github/start'), {}); + const authorization = new URL(start.authorizationUrl); + if (authorization.protocol !== 'https:' || authorization.hostname !== 'github.com') { + throw new Error(message('登录地址无效。', 'The sign-in URL is invalid.')); + } + popup.location.replace(authorization.href); + let accessToken: string | undefined; + while (Date.now() < start.expiresAt * 1000) { + await new Promise((resolve) => setTimeout(resolve, Math.max(1, start.pollIntervalSeconds) * 1000)); + const result = await postJson<{ status: string; tokens?: { accessToken: string } }>( + relayApiPath('/api/auth/github/poll'), + { transactionId: start.transactionId, transactionSecret: start.transactionSecret }, + ); + if (result.tokens?.accessToken) { + accessToken = result.tokens.accessToken; + break; + } + if (result.status !== 'pending') { + throw new Error(message('授权未完成,请重新登录。', 'Authorization did not complete. Sign in again.')); + } } - const params = parseKdfParams(challenge.argon2_params); - passwordBytes = new TextEncoder().encode(password); - passwordInput.value = ''; - passwordHash = await argon2idAsync(passwordBytes, salt, { - m: params.m, - t: params.t, - p: params.p, - dkLen: 32, - version: 0x13, - asyncTick: 16, - }); - const loginBody: Record = { - username, - password_hash: encodeBase64(passwordHash), - }; + if (!accessToken) throw new Error(message('登录已过期,请重试。', 'Sign-in expired. Try again.')); + const loginBody: Record = { access_token: accessToken }; if (loginState) { loginBody.state = loginState; } else { loginBody.return_to = currentPageReturnPath(); loginBody.path_prefix = relayPathPrefix(); } - const result = await postJson<{ redirect_to: string }>( - relayApiPath('/api/page-auth/login'), - loginBody, - ); + const result = await postJson<{ redirect_to: string }>(relayApiPath('/api/page-auth/login'), loginBody); window.location.replace(externalRedirectTarget(result.redirect_to)); } catch (error) { - showError(error instanceof Error - ? error.message - : message('登录失败,请重试。', 'Sign-in failed. Try again.')); - passwordInput.focus(); + showError(error instanceof Error ? error.message : message('登录失败,请重试。', 'Sign-in failed. Try again.')); } finally { - passwordBytes?.fill(0); - passwordHash?.fill(0); + popup.close(); submitButton.disabled = false; - submitButton.textContent = message('登录并访问', 'Sign in and continue'); + submitButton.textContent = message('使用 GitHub 登录', 'Sign in with GitHub'); } }); diff --git a/src/crates/services/services-integrations/AGENTS.md b/src/crates/services/services-integrations/AGENTS.md index 182d2c4baa..1f5db83cac 100644 --- a/src/crates/services/services-integrations/AGENTS.md +++ b/src/crates/services/services-integrations/AGENTS.md @@ -24,8 +24,8 @@ slices that are outside pure product logic but still platform-neutral. presentation/validation behavior remain outside this crate unless a reviewed owner move proves behavior equivalence. - Remote-connect platform-neutral primitives belong here: device identity, - pairing/encryption, QR payload generation, relay client protocol, dialog/cancel - orchestration ports, LAN/ngrok provider helpers, IM bot provider clients, + account device encryption, authenticated device invitation generation, relay client protocol, dialog/cancel + orchestration ports, LAN endpoint helpers, IM bot provider clients, provider-private cursor caches, mobile-web relay upload, image-context adapter contracts, remote workspace helpers, and command/response assembly. - The `remote-persistence` feature is the lightweight persisted-shape owner shared @@ -44,12 +44,12 @@ slices that are outside pure product logic but still platform-neutral. SSH features. Stable workspace path/session identity is owned by `services-core::workspace_identity`; `remote_ssh::paths` is only its legacy compatibility re-export and must not regain transport-independent logic. -- One-click relay self-deploy (`remote_ssh/relay_deploy.rs`) stages embedded +- Retained developer Relay deployment tooling (`remote_ssh/relay_deploy.rs`) stages embedded scripts under `~/.openbitfun/relay-deploy/` and clones source to `~/.openbitfun/relay-src/` (never `$HOME/openbitfun`). Embeds `src/apps/relay-server/mirror.sh` and runs `openbitfun_mirror_init` before apt / Docker install / GitHub sync so mainland China hosts use configured mirrors. - Invariants: `src/web-ui/src/features/relay-deploy/README.md`. Desktop Tauri + Operator guide: `src/apps/relay-server/README.md`. The product wizard is retired. Desktop Tauri wrapper: `src/apps/desktop/src/api/relay_deploy_api.rs`. - Workspace search owns the local flashgrep daemon/session lifecycle and indexed-search result conversion behind `workspace-search`; product config diff --git a/src/crates/services/services-integrations/Cargo.toml b/src/crates/services/services-integrations/Cargo.toml index b38dbdaf51..f9eb65a7d9 100644 --- a/src/crates/services/services-integrations/Cargo.toml +++ b/src/crates/services/services-integrations/Cargo.toml @@ -26,7 +26,6 @@ openbitfun-runtime-ports = { path = "../../contracts/runtime-ports", optional = aes-gcm = { workspace = true, optional = true } aes = { workspace = true, optional = true } anyhow = { workspace = true, optional = true } -argon2 = { workspace = true, optional = true } async-trait = { workspace = true, optional = true } base64 = { workspace = true, optional = true } openbitfun-services-core = { path = "../services-core", optional = true } @@ -56,6 +55,7 @@ rmcp = { workspace = true, optional = true } rustls = { workspace = true, optional = true } rustls-native-certs = { version = "0.8", optional = true } sha2 = { workspace = true, optional = true } +hkdf = { workspace = true, optional = true } sherpa-onnx = { workspace = true, optional = true } sse-stream = { workspace = true, optional = true } thiserror = { workspace = true, optional = true } @@ -199,7 +199,18 @@ miniapp-runtime = [ "uuid", "which", ] +account-identity = [ + "dep:openbitfun-product-domains", + "openbitfun-services-core/tls-provider", + "openbitfun-services-core/credential-vault", + "openbitfun-services-core/product-identity", + "chrono", "dirs", "thiserror", + "dep:keyring-core", "dep:windows-native-keyring-store", "dep:zbus-secret-service-keyring-store", + "reqwest", "reqwest/json", "reqwest/rustls-no-provider", + "tokio/rt", "tokio/sync", +] miniapp-market = [ + "account-identity", "openbitfun-services-core/tls-provider", "openbitfun-product-domains/appearance-market", "openbitfun-services-core/credential-vault", @@ -256,11 +267,15 @@ hook-import = [ "uuid", ] remote-connect = [ + "fs2", + "dep:openbitfun-product-domains", + "thiserror", + "dep:hkdf", + "account-identity", "remote-persistence", "anyhow", "aes", "aes-gcm", - "argon2", "async-trait", "base64", "dep:openbitfun-agent-tools", @@ -361,6 +376,7 @@ remote-ssh-concrete = [ "windows", ] remote-persistence = [ + "fs2", "aes-gcm", "anyhow", "base64", diff --git a/src/crates/services/services-integrations/src/miniapp_market/credentials.rs b/src/crates/services/services-integrations/src/account_identity/credentials.rs similarity index 100% rename from src/crates/services/services-integrations/src/miniapp_market/credentials.rs rename to src/crates/services/services-integrations/src/account_identity/credentials.rs diff --git a/src/crates/services/services-integrations/src/account_identity/flow.rs b/src/crates/services/services-integrations/src/account_identity/flow.rs new file mode 100644 index 0000000000..25feaecbe9 --- /dev/null +++ b/src/crates/services/services-integrations/src/account_identity/flow.rs @@ -0,0 +1,77 @@ +//! Host-owned GitHub authorization transactions shared by native surfaces. +use super::{AccountIdentityClient, DesktopAuthPollRequest, MarketClientError}; +use openbitfun_product_domains::account::{ + GitHubAuthPollRequest, GitHubAuthPollResponse, GitHubAuthStart, +}; +use std::{ + collections::HashMap, + sync::OnceLock, + time::{SystemTime, UNIX_EPOCH}, +}; +use tokio::sync::Mutex; + +struct PendingAuth { + request: DesktopAuthPollRequest, + expires_at: i64, +} + +fn pending() -> &'static Mutex> { + static PENDING: OnceLock>> = OnceLock::new(); + PENDING.get_or_init(|| Mutex::new(HashMap::new())) +} + +fn now() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64 +} + +pub async fn start_auth_flow() -> Result { + let client = AccountIdentityClient::from_environment().await?; + let started = client.start_desktop_auth().await?; + let mut transactions = pending().lock().await; + transactions.retain(|_, transaction| transaction.expires_at > now()); + transactions.insert( + started.transaction_id.clone(), + PendingAuth { + request: DesktopAuthPollRequest { + transaction_id: started.transaction_id.clone(), + transaction_secret: started.transaction_secret, + }, + expires_at: started.expires_at, + }, + ); + Ok(GitHubAuthStart { + transaction_id: started.transaction_id, + authorization_url: started.authorization_url, + expires_at: started.expires_at, + poll_interval_seconds: started.poll_interval_seconds, + }) +} + +pub async fn poll_auth_flow( + request: GitHubAuthPollRequest, +) -> Result { + let mut transactions = pending().lock().await; + let transaction = transactions.get(&request.transaction_id).ok_or_else(|| { + super::local_error( + "auth_transaction_missing", + "GitHub authorization transaction was not found.", + ) + })?; + if transaction.expires_at <= now() { + transactions.remove(&request.transaction_id); + return Ok(GitHubAuthPollResponse { + status: "expired".to_string(), + }); + } + let mut client = AccountIdentityClient::from_environment().await?; + let result = client.poll_desktop_auth(&transaction.request).await?; + if result.status != "pending" { + transactions.remove(&request.transaction_id); + } + Ok(GitHubAuthPollResponse { + status: result.status, + }) +} diff --git a/src/crates/services/services-integrations/src/account_identity/mod.rs b/src/crates/services/services-integrations/src/account_identity/mod.rs new file mode 100644 index 0000000000..9026f636ff --- /dev/null +++ b/src/crates/services/services-integrations/src/account_identity/mod.rs @@ -0,0 +1,350 @@ +//! Shared GitHub identity used by every OpenBitFun product surface. +mod credentials; +mod flow; +pub use credentials::{ + clear_market_credentials, load_market_credentials, save_market_credentials, + StoredMarketCredentials, +}; +pub use flow::{poll_auth_flow, start_auth_flow}; +use openbitfun_product_domains::account::GitHubUser; +use reqwest::{RequestBuilder, Response, StatusCode}; +use serde::de::DeserializeOwned; +use serde::{Deserialize, Serialize}; +pub const DEFAULT_ACCOUNT_API_URL: &str = "https://auth.openbitfun.com/api/v1"; +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DesktopAuthStart { + pub transaction_id: String, + pub transaction_secret: String, + pub authorization_url: String, + pub expires_at: i64, + pub poll_interval_seconds: u32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DesktopAuthPollRequest { + pub transaction_id: String, + pub transaction_secret: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DesktopAuthPollResponse { + pub status: String, + pub tokens: Option, +} + +#[derive(Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MarketTokenPair { + pub access_token: String, + pub access_expires_at: i64, + pub refresh_token: String, + pub refresh_expires_at: i64, +} + +impl From for StoredMarketCredentials { + fn from(value: MarketTokenPair) -> Self { + Self { + access_token: value.access_token, + access_expires_at: value.access_expires_at, + refresh_token: value.refresh_token, + refresh_expires_at: value.refresh_expires_at, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MarketMe { + pub user: GitHubUser, + pub is_admin: bool, +} + +#[derive(Debug, Clone, Serialize, thiserror::Error)] +#[error("{message}")] +pub struct MarketClientError { + pub code: String, + pub message: String, + pub request_id: Option, +} + +#[derive(Debug, Deserialize)] +struct ErrorEnvelope { + error: ErrorBody, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ErrorBody { + code: String, + message: String, + request_id: Option, +} + +#[derive(Debug)] +pub struct AccountIdentityClient { + base_url: String, + client: reqwest::Client, + credentials: Option, +} +impl AccountIdentityClient { + /// Verify a controller credential without reading or mutating host credentials. + pub async fn verify_access_token(access_token: &str) -> Result { + if access_token.is_empty() + || access_token.len() > 8192 + || access_token.chars().any(char::is_control) + { + return Err(local_error( + "invalid_identity_token", + "Invalid GitHub account credential.", + )); + } + let client = crate::reqwest_client_builder() + .redirect(reqwest::redirect::Policy::none()) + .connect_timeout(std::time::Duration::from_secs(3)) + .timeout(std::time::Duration::from_secs(5)) + .build() + .map_err(|error| local_error("identity_client_failed", error.to_string()))?; + let response = client + .get(format!("{DEFAULT_ACCOUNT_API_URL}/me")) + .bearer_auth(access_token) + .send() + .await + .map_err(transport_error)?; + let mut response = checked_response(response).await?; + let mut body = Vec::new(); + while let Some(chunk) = response.chunk().await.map_err(transport_error)? { + if body.len() + chunk.len() > 65536 { + return Err(local_error( + "invalid_identity_response", + "Account response is too large.", + )); + } + body.extend_from_slice(&chunk); + } + let me: MarketMe = serde_json::from_slice(&body) + .map_err(|_| local_error("invalid_identity_response", "Invalid account response."))?; + if me.user.github_id <= 0 || me.user.login.trim().is_empty() { + return Err(local_error( + "invalid_identity_response", + "Invalid GitHub identity.", + )); + } + Ok(me) + } + + pub async fn from_environment() -> Result { + let base_url = std::env::var("OPENBITFUN_ACCOUNT_API_URL") + .or_else(|_| std::env::var("OPENBITFUN_MINIAPP_MARKET_API_URL")) + .unwrap_or_else(|_| DEFAULT_ACCOUNT_API_URL.to_string()); + Self::new(base_url).await + } + + pub async fn new(base_url: impl Into) -> Result { + let base_url = base_url.into().trim_end_matches('/').to_string(); + let parsed = reqwest::Url::parse(&base_url) + .map_err(|error| local_error("invalid_market_url", error.to_string()))?; + let local_http = parsed.scheme() == "http" + && parsed + .host_str() + .is_some_and(|host| matches!(host, "127.0.0.1" | "localhost" | "::1")); + if parsed.scheme() != "https" && !local_http { + return Err(local_error( + "invalid_market_url", + "The GitHub account API must use HTTPS.", + )); + } + let client = crate::reqwest_client_builder() + .user_agent(format!("OpenBitFun/{}", env!("CARGO_PKG_VERSION"))) + .redirect(reqwest::redirect::Policy::none()) + .build() + .map_err(|error| local_error("market_client_init_failed", error.to_string()))?; + let credentials = load_market_credentials() + .await + .map_err(|error| local_error("credential_store_unavailable", error))?; + Ok(Self { + base_url, + client, + credentials, + }) + } + + pub async fn start_desktop_auth(&self) -> Result { + self.json(self.client.post(self.url("/auth/desktop/start"))) + .await + } + + pub async fn poll_desktop_auth( + &mut self, + request: &DesktopAuthPollRequest, + ) -> Result { + let response: DesktopAuthPollResponse = self + .json( + self.client + .post(self.url("/auth/desktop/poll")) + .json(request), + ) + .await?; + if let Some(tokens) = response.tokens.clone() { + let credentials: StoredMarketCredentials = tokens.into(); + save_market_credentials(&credentials) + .await + .map_err(|error| local_error("credential_store_unavailable", error))?; + self.credentials = Some(credentials); + } + Ok(response) + } + + pub async fn me(&mut self) -> Result, MarketClientError> { + if self.credentials.is_none() { + return Ok(None); + } + self.refresh_if_needed().await?; + let Some(credentials) = self.credentials.as_ref() else { + return Ok(None); + }; + let response = self + .client + .get(self.url("/me")) + .bearer_auth(&credentials.access_token) + .send() + .await + .map_err(transport_error)?; + if response.status() == StatusCode::UNAUTHORIZED { + clear_market_credentials() + .await + .map_err(|error| local_error("credential_store_unavailable", error))?; + self.credentials = None; + return Ok(None); + } + Ok(Some(decode_json(checked_response(response).await?).await?)) + } + + pub async fn access_token(&mut self) -> Result, MarketClientError> { + self.refresh_if_needed().await?; + Ok(self + .credentials + .as_ref() + .map(|credentials| credentials.access_token.clone())) + } + + pub async fn logout(&mut self) -> Result<(), MarketClientError> { + if let Some(credentials) = self.credentials.as_ref() { + let response = self + .client + .post(self.url("/auth/logout")) + .bearer_auth(&credentials.access_token) + .send() + .await + .map_err(transport_error)?; + if !response.status().is_success() && response.status() != StatusCode::UNAUTHORIZED { + return Err(response_error(response).await); + } + } + clear_market_credentials() + .await + .map_err(|error| local_error("credential_store_unavailable", error))?; + self.credentials = None; + Ok(()) + } + + async fn refresh_if_needed(&mut self) -> Result<(), MarketClientError> { + let Some(credentials) = self.credentials.as_ref() else { + return Ok(()); + }; + let now = chrono::Utc::now().timestamp(); + if credentials.refresh_expires_at <= now { + clear_market_credentials() + .await + .map_err(|error| local_error("credential_store_unavailable", error))?; + self.credentials = None; + return Ok(()); + } + if credentials.access_expires_at > now + 30 { + return Ok(()); + } + let refresh_token = credentials.refresh_token.clone(); + let response = self + .client + .post(self.url("/auth/refresh")) + .json(&serde_json::json!({ "refreshToken": refresh_token })) + .send() + .await + .map_err(transport_error)?; + if response.status() == StatusCode::UNAUTHORIZED { + clear_market_credentials() + .await + .map_err(|error| local_error("credential_store_unavailable", error))?; + self.credentials = None; + return Ok(()); + } + let tokens: MarketTokenPair = decode_json(checked_response(response).await?).await?; + let stored: StoredMarketCredentials = tokens.into(); + save_market_credentials(&stored) + .await + .map_err(|error| local_error("credential_store_unavailable", error))?; + self.credentials = Some(stored); + Ok(()) + } + + async fn json( + &self, + request: RequestBuilder, + ) -> Result { + let response = request.send().await.map_err(transport_error)?; + decode_json(checked_response(response).await?).await + } + + fn url(&self, path: &str) -> String { + format!("{}{}", self.base_url, path) + } +} +async fn checked_response(response: Response) -> Result { + if response.status().is_success() { + Ok(response) + } else { + Err(response_error(response).await) + } +} + +async fn decode_json(response: Response) -> Result { + response + .json() + .await + .map_err(|error| local_error("invalid_market_response", error.to_string())) +} + +async fn response_error(response: Response) -> MarketClientError { + let status = response.status(); + match response.json::().await { + Ok(envelope) => MarketClientError { + code: envelope.error.code, + message: envelope.error.message, + request_id: envelope.error.request_id, + }, + Err(_) => local_error( + "market_request_failed", + format!("The GitHub account service returned HTTP {status}."), + ), + } +} + +fn transport_error(error: reqwest::Error) -> MarketClientError { + local_error("account_unavailable", error.to_string()) +} + +fn local_error(code: impl Into, message: impl Into) -> MarketClientError { + MarketClientError { + code: code.into(), + message: message.into(), + request_id: None, + } +} + +impl std::fmt::Debug for MarketTokenPair { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("AccountTokenPair").finish_non_exhaustive() + } +} diff --git a/src/crates/services/services-integrations/src/appearance_market/client.rs b/src/crates/services/services-integrations/src/appearance_market/client.rs index 2a42f48e62..81bbaf8386 100644 --- a/src/crates/services/services-integrations/src/appearance_market/client.rs +++ b/src/crates/services/services-integrations/src/appearance_market/client.rs @@ -9,8 +9,8 @@ use reqwest::{RequestBuilder, Response}; use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; -use crate::miniapp_market::{ - DesktopAuthPollRequest, DesktopAuthPollResponse, DesktopAuthStart, MarketClient, +use crate::account_identity::{ + AccountIdentityClient, DesktopAuthPollRequest, DesktopAuthPollResponse, DesktopAuthStart, MarketClientError, MarketMe, }; @@ -32,14 +32,14 @@ pub struct AppearanceMarketBrowseRequest { pub struct AppearanceMarketClient { base_url: String, client: reqwest::Client, - identity: MarketClient, + identity: AccountIdentityClient, } impl AppearanceMarketClient { pub async fn from_environment() -> Result { let base_url = std::env::var("OPENBITFUN_APPEARANCE_MARKET_API_URL") .unwrap_or_else(|_| DEFAULT_APPEARANCE_MARKET_API_URL.to_string()); - let identity = MarketClient::from_environment().await?; + let identity = AccountIdentityClient::from_environment().await?; Self::with_identity(base_url, identity) } @@ -47,13 +47,13 @@ impl AppearanceMarketClient { base_url: impl Into, identity_base_url: impl Into, ) -> Result { - let identity = MarketClient::new(identity_base_url).await?; + let identity = AccountIdentityClient::new(identity_base_url).await?; Self::with_identity(base_url, identity) } fn with_identity( base_url: impl Into, - identity: MarketClient, + identity: AccountIdentityClient, ) -> Result { let base_url = base_url.into().trim_end_matches('/').to_string(); validate_market_url(&base_url)?; diff --git a/src/crates/services/services-integrations/src/lib.rs b/src/crates/services/services-integrations/src/lib.rs index 2e3e7a0686..ad34ceac5d 100644 --- a/src/crates/services/services-integrations/src/lib.rs +++ b/src/crates/services/services-integrations/src/lib.rs @@ -4,6 +4,7 @@ //! can opt into only the integration family they need. #[cfg(any( + feature = "account-identity", feature = "mcp", feature = "miniapp-market", feature = "miniapp-runtime", @@ -112,3 +113,6 @@ pub mod web_tools; #[cfg(all(windows, feature = "git"))] #[link(name = "advapi32")] unsafe extern "system" {} + +#[cfg(feature = "account-identity")] +pub mod account_identity; diff --git a/src/crates/services/services-integrations/src/miniapp_market/client.rs b/src/crates/services/services-integrations/src/miniapp_market/client.rs index 0f83d933d2..ac86698252 100644 --- a/src/crates/services/services-integrations/src/miniapp_market/client.rs +++ b/src/crates/services/services-integrations/src/miniapp_market/client.rs @@ -1,11 +1,10 @@ -use super::credentials::{ - clear_market_credentials, load_market_credentials, save_market_credentials, - StoredMarketCredentials, +use crate::account_identity::{ + AccountIdentityClient, DesktopAuthPollRequest, DesktopAuthPollResponse, DesktopAuthStart, + MarketClientError, MarketMe, }; use openbitfun_product_domains::miniapp::market::{ CursorPage, MarketListingDetail, MarketListingSummary, MarketSort, MarketSubmission, - MarketSubmissionDraftRequest, MarketUserSummary, ReviewDecisionRequest, - MARKET_PACKAGE_CONTENT_TYPE, + MarketSubmissionDraftRequest, ReviewDecisionRequest, MARKET_PACKAGE_CONTENT_TYPE, }; use reqwest::{Method, RequestBuilder, Response, StatusCode}; use serde::de::DeserializeOwned; @@ -26,57 +25,6 @@ pub struct MarketBrowseRequest { pub limit: Option, } -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct DesktopAuthStart { - pub transaction_id: String, - pub transaction_secret: String, - pub authorization_url: String, - pub expires_at: i64, - pub poll_interval_seconds: u32, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct DesktopAuthPollRequest { - pub transaction_id: String, - pub transaction_secret: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct DesktopAuthPollResponse { - pub status: String, - pub tokens: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct MarketTokenPair { - pub access_token: String, - pub access_expires_at: i64, - pub refresh_token: String, - pub refresh_expires_at: i64, -} - -impl From for StoredMarketCredentials { - fn from(value: MarketTokenPair) -> Self { - Self { - access_token: value.access_token, - access_expires_at: value.access_expires_at, - refresh_token: value.refresh_token, - refresh_expires_at: value.refresh_expires_at, - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct MarketMe { - pub user: MarketUserSummary, - pub is_admin: bool, -} - #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct RatingAggregate { @@ -92,14 +40,6 @@ pub struct FavoriteAggregate { pub is_favorited: bool, } -#[derive(Debug, Clone, Serialize, thiserror::Error)] -#[error("{message}")] -pub struct MarketClientError { - pub code: String, - pub message: String, - pub request_id: Option, -} - #[derive(Debug, Deserialize)] struct ErrorEnvelope { error: ErrorBody, @@ -117,7 +57,7 @@ struct ErrorBody { pub struct MarketClient { base_url: String, client: reqwest::Client, - credentials: Option, + identity: AccountIdentityClient, } impl MarketClient { @@ -146,13 +86,11 @@ impl MarketClient { .redirect(reqwest::redirect::Policy::none()) .build() .map_err(|error| local_error("market_client_init_failed", error.to_string()))?; - let credentials = load_market_credentials() - .await - .map_err(|error| local_error("credential_store_unavailable", error))?; + let identity = AccountIdentityClient::new(base_url.clone()).await?; Ok(Self { base_url, client, - credentials, + identity, }) } @@ -178,11 +116,15 @@ impl MarketClient { } pub async fn listing(&mut self, slug: &str) -> Result { - self.refresh_if_needed().await?; + let token = self.identity.access_token().await?; let request = self .client .get(self.url(&format!("/listings/{}", urlencoding::encode(slug)))); - self.json(self.with_optional_auth(request)).await + self.json(match token { + Some(token) => request.bearer_auth(token), + None => request, + }) + .await } pub async fn download_release( @@ -205,65 +147,25 @@ impl MarketClient { } pub async fn start_desktop_auth(&self) -> Result { - self.json(self.client.post(self.url("/auth/desktop/start"))) - .await + self.identity.start_desktop_auth().await } pub async fn poll_desktop_auth( &mut self, request: &DesktopAuthPollRequest, ) -> Result { - let response: DesktopAuthPollResponse = self - .json( - self.client - .post(self.url("/auth/desktop/poll")) - .json(request), - ) - .await?; - if let Some(tokens) = response.tokens.clone() { - let credentials: StoredMarketCredentials = tokens.into(); - save_market_credentials(&credentials) - .await - .map_err(|error| local_error("credential_store_unavailable", error))?; - self.credentials = Some(credentials); - } - Ok(response) + self.identity.poll_desktop_auth(request).await } pub async fn me(&mut self) -> Result, MarketClientError> { - if self.credentials.is_none() { - return Ok(None); - } - self.refresh_if_needed().await?; - let Some(credentials) = self.credentials.as_ref() else { - return Ok(None); - }; - let response = self - .client - .get(self.url("/me")) - .bearer_auth(&credentials.access_token) - .send() - .await - .map_err(transport_error)?; - if response.status() == StatusCode::UNAUTHORIZED { - clear_market_credentials() - .await - .map_err(|error| local_error("credential_store_unavailable", error))?; - self.credentials = None; - return Ok(None); - } - Ok(Some(decode_json(checked_response(response).await?).await?)) + self.identity.me().await } /// Returns the shared marketplace access token after applying the normal /// refresh and expiry policy. Appearance Market uses the same desktop /// identity without creating a second credential vault. pub(crate) async fn access_token(&mut self) -> Result, MarketClientError> { - self.refresh_if_needed().await?; - Ok(self - .credentials - .as_ref() - .map(|credentials| credentials.access_token.clone())) + self.identity.access_token().await } pub async fn set_rating( @@ -401,83 +303,20 @@ impl MarketClient { } pub async fn logout(&mut self) -> Result<(), MarketClientError> { - if let Some(credentials) = self.credentials.as_ref() { - let response = self - .client - .post(self.url("/auth/logout")) - .bearer_auth(&credentials.access_token) - .send() - .await - .map_err(transport_error)?; - if !response.status().is_success() && response.status() != StatusCode::UNAUTHORIZED { - return Err(response_error(response).await); - } - } - clear_market_credentials() - .await - .map_err(|error| local_error("credential_store_unavailable", error))?; - self.credentials = None; - Ok(()) + self.identity.logout().await } async fn authorized( &mut self, request: RequestBuilder, ) -> Result { - self.refresh_if_needed().await?; - let credentials = self.credentials.as_ref().ok_or_else(|| { + let token = self.identity.access_token().await?.ok_or_else(|| { local_error( "authentication_required", "Sign in with GitHub to continue.", ) })?; - Ok(request.bearer_auth(&credentials.access_token)) - } - - fn with_optional_auth(&self, request: RequestBuilder) -> RequestBuilder { - match self.credentials.as_ref() { - Some(credentials) => request.bearer_auth(&credentials.access_token), - None => request, - } - } - - async fn refresh_if_needed(&mut self) -> Result<(), MarketClientError> { - let Some(credentials) = self.credentials.as_ref() else { - return Ok(()); - }; - let now = chrono::Utc::now().timestamp(); - if credentials.refresh_expires_at <= now { - clear_market_credentials() - .await - .map_err(|error| local_error("credential_store_unavailable", error))?; - self.credentials = None; - return Ok(()); - } - if credentials.access_expires_at > now + 30 { - return Ok(()); - } - let refresh_token = credentials.refresh_token.clone(); - let response = self - .client - .post(self.url("/auth/refresh")) - .json(&serde_json::json!({ "refreshToken": refresh_token })) - .send() - .await - .map_err(transport_error)?; - if response.status() == StatusCode::UNAUTHORIZED { - clear_market_credentials() - .await - .map_err(|error| local_error("credential_store_unavailable", error))?; - self.credentials = None; - return Ok(()); - } - let tokens: MarketTokenPair = decode_json(checked_response(response).await?).await?; - let stored: StoredMarketCredentials = tokens.into(); - save_market_credentials(&stored) - .await - .map_err(|error| local_error("credential_store_unavailable", error))?; - self.credentials = Some(stored); - Ok(()) + Ok(request.bearer_auth(token)) } async fn json( diff --git a/src/crates/services/services-integrations/src/miniapp_market/mod.rs b/src/crates/services/services-integrations/src/miniapp_market/mod.rs index 8ec280dc96..a6d1172425 100644 --- a/src/crates/services/services-integrations/src/miniapp_market/mod.rs +++ b/src/crates/services/services-integrations/src/miniapp_market/mod.rs @@ -1,19 +1,15 @@ //! Concrete MiniApp marketplace client, credential vault and package IO. mod client; -mod credentials; mod package; mod submit; -pub use client::{ - DesktopAuthPollRequest, DesktopAuthPollResponse, DesktopAuthStart, FavoriteAggregate, - MarketBrowseRequest, MarketClient, MarketClientError, MarketMe, MarketTokenPair, - RatingAggregate, -}; -pub use credentials::{ +pub use crate::account_identity::{ clear_market_credentials, load_market_credentials, save_market_credentials, - StoredMarketCredentials, + DesktopAuthPollRequest, DesktopAuthPollResponse, DesktopAuthStart, MarketClientError, MarketMe, + MarketTokenPair, StoredMarketCredentials, }; +pub use client::{FavoriteAggregate, MarketBrowseRequest, MarketClient, RatingAggregate}; pub use package::{ build_market_package, validate_market_package, MarketPackageError, ValidatedMarketPackage, }; diff --git a/src/crates/services/services-integrations/src/miniapp_market/submit.rs b/src/crates/services/services-integrations/src/miniapp_market/submit.rs index 340e1064b2..128ce65593 100644 --- a/src/crates/services/services-integrations/src/miniapp_market/submit.rs +++ b/src/crates/services/services-integrations/src/miniapp_market/submit.rs @@ -3,8 +3,9 @@ //! Tauri command and the PublishMiniApp agent tool so the two paths cannot //! drift. -use super::client::{MarketClient, MarketClientError}; +use super::client::MarketClient; use super::package::build_market_package; +use crate::account_identity::MarketClientError; use openbitfun_product_domains::miniapp::market::{ MarketSubmission, MarketSubmissionDraftRequest, MarketSubmissionStatus, MARKET_CATEGORIES, MARKET_MAX_SCREENSHOTS, MARKET_MAX_SCREENSHOT_BYTES, diff --git a/src/crates/services/services-integrations/src/remote_connect.rs b/src/crates/services/services-integrations/src/remote_connect.rs index c8abb5ab9c..6128401ae5 100644 --- a/src/crates/services/services-integrations/src/remote_connect.rs +++ b/src/crates/services/services-integrations/src/remote_connect.rs @@ -11,17 +11,15 @@ pub mod account; pub mod bot; mod chat_projection; pub mod device; +pub mod device_crypto; pub mod encryption; mod lan; -mod mobile_web_upload; -mod ngrok; mod page_upload; pub mod pairing; pub mod qr_generator; pub mod relay_client; mod relay_http; pub mod session_store; -pub mod sync_state; pub use chat_projection::{ agent_input_attachment_from_remote_image_context, project_remote_chat_user, @@ -34,10 +32,6 @@ pub use lan::{ LocalNetworkInterface, }; use log::info; -pub use mobile_web_upload::upload_mobile_web_to_relay; -pub use ngrok::{ - cleanup_all_ngrok, detect_running_ngrok, is_ngrok_available, start_ngrok_tunnel, NgrokTunnel, -}; use openbitfun_core_types::{ ModelsDevReasoningCatalog, ProviderCatalog, ReasoningCatalogProjection, }; @@ -62,7 +56,7 @@ pub use page_upload::{ update_page_on_relay, PageContentPublishResult, PageInfo, PageOpenLink, PagePublishResult, PageSaveVersionResult, PageVersionInfo, }; -pub use pairing::{PairingChallenge, PairingProtocol, PairingResponse, PairingState, QrPayload}; +pub use pairing::PairingState; pub use qr_generator::QrGenerator; pub use relay_client::{ ensure_rustls_crypto_provider, ConnectionState, RelayClient, RelayEvent, RelayMessage, @@ -2409,26 +2403,8 @@ pub enum RemoteCommand { path: String, session_id: Option, }, - /// Ask the paired desktop to delegate its logged-in account identity - /// (token + master_key) to this room-channel client so it can call the - /// relay device APIs directly. Answered by the host runtime; other hosts - /// return an error response. - GetDelegatedIdentity, - /// Ask the paired desktop to mint a *full* account device credential for a - /// separate device that cannot type a password (a watch). The desktop calls - /// the relay's `/api/auth/provision-device` with its own device token, then - /// returns the minted credential together with the account master key over - /// this already-encrypted room channel. The relay never sees the master key. - /// - /// Unlike `GetDelegatedIdentity` this yields a 30-day full credential rather - /// than a 24-hour delegated one, because the provisioned device is a primary - /// surface and cannot re-authenticate on its own when the token lapses. - /// - /// `request_id` is minted by the device being provisioned, not by the - /// desktop, so that a retry anywhere along the watch → phone → desktop chain - /// replays one idempotent relay request instead of registering a second - /// device. Answered by the host runtime; other hosts return an error - /// response. + /// Provision a separate device through this authenticated controller. + /// The target host owns token issuance and idempotent request handling. ProvisionPeerDevice { /// 32 lowercase hex characters; the relay rejects any other shape. device_id: String, @@ -2665,18 +2641,8 @@ pub enum RemoteResponse { }, /// Event already delivered out-of-band; ack only. DeviceEventAccepted, - /// Delegated account identity for a paired room-channel client. - /// `master_key` is base64-encoded; `device_id` is the delegating host. - DelegateIdentity { - token: String, - user_id: String, - master_key: String, - device_id: String, - }, - /// A full account device credential minted for a paired client's peer - /// device. `master_key` is base64-encoded; `device_id` echoes the *newly - /// provisioned* device, not the delegating host — the opposite of - /// `DelegateIdentity`, whose `device_id` names the desktop. + /// A device credential and its independent private key, delivered only + /// over the authenticated encrypted device channel. PeerDeviceProvisioned { token: String, user_id: String, @@ -2880,15 +2846,7 @@ where .await, ), - // Answered by the host runtime (which owns the delegated identity - // provider) before dispatch reaches this router; this is the fallback - // for hosts that cannot delegate an account identity. - RemoteCommand::GetDelegatedIdentity => RemoteResponse::Error { - message: "Delegated identity is not available on this host".to_string(), - }, - - // Same contract as GetDelegatedIdentity above: the host runtime owns the - // account credentials and answers before dispatch reaches this router. + // The authenticated host owns credential provisioning. RemoteCommand::ProvisionPeerDevice { .. } => RemoteResponse::Error { message: "Device provisioning is not available on this host".to_string(), }, diff --git a/src/crates/services/services-integrations/src/remote_connect/account.rs b/src/crates/services/services-integrations/src/remote_connect/account.rs index ccc3ab4167..b8c446f534 100644 --- a/src/crates/services/services-integrations/src/remote_connect/account.rs +++ b/src/crates/services/services-integrations/src/remote_connect/account.rs @@ -1,156 +1,126 @@ -//! Account login client: Argon2id key derivation + register/login flows. -//! -//! The relay never sees the plaintext password or the master key. This module -//! derives the KEK and password hash locally, wraps the random master key with -//! the KEK, and sends only non-secret artifacts (salts, hashes, wrapped key) -//! to the relay. The master key lives in memory only after login. +//! GitHub-authenticated device credentials and pairwise encrypted relay messages. +use super::device_crypto; use anyhow::{anyhow, Result}; -use argon2::{Algorithm, Argon2, Params, Version}; use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; -use openbitfun_services_core::session::SessionMetadata; -#[cfg(test)] -use rand::RngCore; use serde::{Deserialize, Serialize}; +use std::{collections::HashMap, sync::Arc}; +use tokio::sync::Mutex; use crate::remote_connect::device::DeviceIdentity; -use crate::remote_connect::encryption::{decrypt, encrypt}; use crate::remote_connect::relay_http::{ relay_http_client, send_with_retry, BufferedRelayResponse, RelayHttpRetry, }; -/// Salt length for provisioning (client-side account-blob export). Only used -/// by tests until that tooling lands. -#[allow(dead_code)] -const SALT_LEN: usize = 16; pub const MASTER_KEY_LEN: usize = 32; -const NONCE_LEN: usize = 12; - -/// Argon2id parameters used for key derivation. Stored on the relay (non-secret) -/// so the client can rebuild the identical KDF on login. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct KdfParams { - /// Memory cost in KiB (e.g. 16384 = 16 MiB). - pub m: u32, - /// Time cost (iterations). - pub t: u32, - /// Parallelism (lanes). - pub p: u32, -} - -impl Default for KdfParams { - fn default() -> Self { - // Resource-aware Argon2id baseline: 16 MiB, 3 iterations, 4 lanes. - Self { - m: 16 * 1024, - t: 3, - p: 4, - } - } -} - -impl KdfParams { - fn build(&self) -> Result> { - // KDF parameters come from a remote server. Bound them before Argon2 - // allocates memory so a malicious or corrupted relay cannot force the - // client into multi-gigabyte allocation or an excessive CPU loop. - if !(8 * 1024..=256 * 1024).contains(&self.m) - || !(1..=10).contains(&self.t) - || !(1..=16).contains(&self.p) - { - return Err(anyhow!( - "argon2 parameters are outside the supported safety range" - )); - } - let params = Params::new(self.m, self.t, self.p, Some(MASTER_KEY_LEN)) - .map_err(|e| anyhow!("invalid argon2 params: {e}"))?; - Ok(Argon2::new(Algorithm::Argon2id, Version::V0x13, params)) - } -} -/// A successful account session: the relay token + the decrypted master key. -/// The master key lives in memory only; it is never persisted to disk. -#[derive(Debug, Clone)] +/// Device-scoped relay credentials and a locally owned X25519 private key. +#[derive(Clone)] pub struct AccountSession { pub token: String, pub user_id: String, pub master_key: [u8; MASTER_KEY_LEN], + peer_keys: Arc>>, } -const RELAY_TURNS_IMPORT_STATE_KEY: &str = "relayTurnsImportState"; -const RELAY_TURNS_IMPORT_PENDING: &str = "pending"; -const RELAY_TURNS_IMPORT_COMPLETE: &str = "complete"; - -/// Return the durable import state used by account-backed Session history. -/// A missing marker denotes a locally-created Session. -pub fn relay_session_history_import_state(metadata: &SessionMetadata) -> Option<&str> { - metadata - .custom_metadata - .as_ref() - .and_then(serde_json::Value::as_object) - .and_then(|custom| custom.get(RELAY_TURNS_IMPORT_STATE_KEY)) - .and_then(serde_json::Value::as_str) -} - -pub fn relay_session_history_import_is_complete(metadata: &SessionMetadata) -> bool { - relay_session_history_import_state(metadata) == Some(RELAY_TURNS_IMPORT_COMPLETE) +impl std::fmt::Debug for AccountSession { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("AccountSession") + .field("user_id", &self.user_id) + .finish_non_exhaustive() + } } -/// Fail closed while an imported Session has not completed its local turn -/// batch. Uploading a metadata-only or partial prefix would overwrite the -/// account's authoritative full-history bundle. -pub fn ensure_relay_session_history_exportable( - metadata: &SessionMetadata, -) -> std::result::Result<(), String> { - match relay_session_history_import_state(metadata) { - None | Some(RELAY_TURNS_IMPORT_COMPLETE) => Ok(()), - Some(state) => Err(format!( - "session {} history import is incomplete ({state})", - metadata.session_id - )), +impl AccountSession { + pub fn new(token: String, user_id: String, device_secret: [u8; 32]) -> Self { + Self { + token, + user_id, + master_key: device_secret, + peer_keys: Arc::new(Mutex::new(HashMap::new())), + } } -} -/// Align exported metadata with the visible history projection. This matters -/// while a staged Session restore hides a physical suffix from every consumer. -pub fn relay_session_export_metadata( - metadata: &SessionMetadata, - visible_turn_count: usize, -) -> SessionMetadata { - let mut exported = metadata.clone(); - exported.turn_count = visible_turn_count; - exported -} + pub async fn clear_peer_keys(&self) { + self.peer_keys.lock().await.clear(); + } -fn set_relay_session_history_import_state(metadata: &mut SessionMetadata, state: &str) { - let mut custom = metadata - .custom_metadata - .as_ref() - .and_then(serde_json::Value::as_object) - .cloned() - .unwrap_or_default(); - custom.insert( - RELAY_TURNS_IMPORT_STATE_KEY.to_string(), - serde_json::Value::String(state.to_string()), - ); - metadata.custom_metadata = Some(serde_json::Value::Object(custom)); -} + pub async fn peer_message_key(&self, relay_url: &str, device_id: &str) -> Result<[u8; 32]> { + let endpoint = AccountClient::endpoint( + relay_url, + &format!("/api/devices/{}/key", urlencoding::encode(device_id)), + )?; + let cache_id = endpoint.to_string(); + let mut keys = self.peer_keys.lock().await; + if let Some(key) = keys.get(&cache_id) { + return Ok(*key); + } + let response = relay_http_client() + .get(endpoint) + .bearer_auth(&self.token) + .send() + .await?; + if !response.status().is_success() { + return Err(AccountClient::into_error(response).await); + } + #[derive(Deserialize)] + struct PeerKey { + device_id: String, + public_key: String, + } + let peer: PeerKey = response.json().await?; + if peer.device_id != device_id { + return Err(anyhow!("relay returned a different device identity")); + } + let public = super::encryption::parse_public_key(&peer.public_key)?; + let key = device_crypto::derive_message_key(&self.master_key, &public)?; + keys.insert(cache_id, key); + Ok(key) + } -pub fn mark_relay_session_history_import_pending(metadata: &mut SessionMetadata) { - set_relay_session_history_import_state(metadata, RELAY_TURNS_IMPORT_PENDING); -} + pub async fn encrypt_for_peer( + &self, + relay_url: &str, + device_id: &str, + plaintext: &str, + ) -> Result<(String, String)> { + let key = self.peer_message_key(relay_url, device_id).await?; + super::encryption::encrypt_to_base64(&key, plaintext) + } -pub fn mark_relay_session_history_import_complete(metadata: &mut SessionMetadata) { - set_relay_session_history_import_state(metadata, RELAY_TURNS_IMPORT_COMPLETE); + pub async fn decrypt_from_peer( + &self, + relay_url: &str, + device_id: &str, + data: &str, + nonce: &str, + ) -> Result { + let key = self.peer_message_key(relay_url, device_id).await?; + match super::encryption::decrypt_from_base64(&key, data, nonce) { + Ok(plaintext) => Ok(plaintext), + Err(_) => { + // A device can rotate its private key while a controller is + // disconnected. Refresh its authenticated key once on failure. + let endpoint = AccountClient::endpoint( + relay_url, + &format!("/api/devices/{}/key", urlencoding::encode(device_id)), + )?; + self.peer_keys.lock().await.remove(endpoint.as_str()); + let key = self.peer_message_key(relay_url, device_id).await?; + super::encryption::decrypt_from_base64(&key, data, nonce) + } + } + } } /// A delegated token for a paired client (mobile-web / IM bot). /// The desktop requests this from the relay and transmits it along -/// with the master_key to the paired client via the E2E room channel. -#[derive(Debug, Clone, Serialize, Deserialize)] +/// with a fresh controller private key over the authenticated E2E channel. +#[derive(Clone)] pub struct DelegateToken { pub token: String, pub user_id: String, + pub device_secret: [u8; 32], } /// Full device credential minted for a distinct SSH host. Unlike @@ -163,90 +133,6 @@ pub struct ProvisionedDeviceToken { pub device_id: String, } -/// A delegated account identity: token + master_key, for paired clients -/// that don't do Argon2id themselves. The desktop delegates both. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct DelegatedIdentity { - pub token: String, - pub user_id: String, - pub master_key: String, // base64-encoded 32-byte master key -} - -// ── Key derivation & wrapping ─────────────────────────────────────────── - -/// Derive the KEK (key-encryption key) from the password. The KEK never leaves -/// the client; it is used only to wrap/unwrap the master key. -fn derive_kek(password: &str, salt: &[u8], params: &KdfParams) -> Result<[u8; MASTER_KEY_LEN]> { - let argon2 = params.build()?; - let mut out = [0u8; MASTER_KEY_LEN]; - argon2 - .hash_password_into(password.as_bytes(), salt, &mut out) - .map_err(|e| anyhow!("argon2 kek: {e}"))?; - Ok(out) -} - -/// Derive the password hash for server-side verification (uses a separate salt -/// so the KEK and the server-verifiable hash cannot be correlated). -fn derive_password_hash(password: &str, kdf_salt: &[u8], params: &KdfParams) -> Result { - let argon2 = params.build()?; - let mut out = [0u8; MASTER_KEY_LEN]; - argon2 - .hash_password_into(password.as_bytes(), kdf_salt, &mut out) - .map_err(|e| anyhow!("argon2 pwd hash: {e}"))?; - Ok(BASE64.encode(out)) -} - -/// Pack wrapped ciphertext + nonce into a single storable string: `"ct.nonce"`. -/// Used by the provisioning path (client-side account-blob export for admin -/// import); not needed for login, hence `dead_code` until that tooling lands. -#[allow(dead_code)] -fn pack_wrapped(ct_b64: &str, nonce_b64: &str) -> String { - format!("{ct_b64}.{nonce_b64}") -} - -/// Split a packed `"ct.nonce"` string back into its parts. -fn unpack_wrapped(packed: &str) -> Result<(String, String)> { - let (ct, nonce) = packed - .split_once('.') - .ok_or_else(|| anyhow!("invalid wrapped master key format"))?; - Ok((ct.to_string(), nonce.to_string())) -} - -/// Wrap (encrypt) the master key with the KEK → `"ct.nonce"`. -/// Provisioning helper (see `pack_wrapped`); unused by login. -#[allow(dead_code)] -fn wrap_master_key( - kek: &[u8; MASTER_KEY_LEN], - master_key: &[u8; MASTER_KEY_LEN], -) -> Result { - let (ct, nonce) = encrypt(kek, master_key.as_slice())?; - Ok(pack_wrapped(&BASE64.encode(ct), &BASE64.encode(&nonce[..]))) -} - -/// Unwrap (decrypt) the master key with the KEK. A GCM tag failure means the -/// password is wrong. -fn unwrap_master_key(kek: &[u8; MASTER_KEY_LEN], packed: &str) -> Result<[u8; MASTER_KEY_LEN]> { - let (ct_b64, nonce_b64) = unpack_wrapped(packed)?; - let ct = BASE64 - .decode(&ct_b64) - .map_err(|e| anyhow!("b64 decode wrapped ct: {e}"))?; - let nonce_vec = BASE64 - .decode(&nonce_b64) - .map_err(|e| anyhow!("b64 decode wrapped nonce: {e}"))?; - if nonce_vec.len() != NONCE_LEN { - return Err(anyhow!("invalid wrapped nonce length")); - } - let mut nonce = [0u8; NONCE_LEN]; - nonce.copy_from_slice(&nonce_vec); - let pt = decrypt(kek, &ct, &nonce)?; - if pt.len() != MASTER_KEY_LEN { - return Err(anyhow!("decrypted master key has wrong length")); - } - let mut mk = [0u8; MASTER_KEY_LEN]; - mk.copy_from_slice(&pt); - Ok(mk) -} - // ── Relay HTTP client ─────────────────────────────────────────────────── #[derive(Deserialize)] @@ -255,16 +141,6 @@ struct AuthResponse { user_id: String, } -#[derive(Deserialize)] -struct ChallengeResponse { - salt: String, - kdf_salt: String, - argon2_params: String, - wrapped_master_key: String, - #[serde(default)] - login_idempotency_supported: bool, -} - #[derive(Deserialize)] struct ErrorBody { error: String, @@ -279,7 +155,7 @@ pub struct AccountClient { /// Check whether an account/relay error message indicates an invalid or /// expired account token (relay auth failure). Shared by Desktop, CLI, and -/// the settings sync engine so they all react to the same relay wording. +/// account surfaces so they all react to the same relay wording. pub fn error_indicates_expired_token(message: &str) -> bool { let lower = message.to_ascii_lowercase(); lower.contains("http 401") @@ -332,6 +208,61 @@ impl Default for AccountClient { } impl AccountClient { + /// Reuse the shared GitHub login used by the OpenBitFun marketplaces. + pub async fn login_with_identity( + &self, + relay_url: &str, + device: &DeviceIdentity, + ) -> Result<( + AccountSession, + openbitfun_product_domains::account::GitHubUser, + )> { + let mut identity = + crate::account_identity::AccountIdentityClient::from_environment().await?; + let profile = identity + .me() + .await? + .ok_or_else(|| anyhow!("Sign in with GitHub to continue"))?; + let access_token = identity + .access_token() + .await? + .ok_or_else(|| anyhow!("Sign in with GitHub to continue"))?; + let device_secret = super::session_store::device_secret( + relay_url, + &profile.user.github_id.to_string(), + &device.device_id, + )?; + let body = serde_json::json!({ + "access_token": access_token, + "device_id": device.device_id, + "device_name": device.device_name, + "device_kind": "desktop", + "public_key": device_crypto::public_key_base64(&device_secret), + "request_id": uuid::Uuid::new_v4().to_string(), + }); + let response = send_with_retry( + "GitHub relay login", + self.http + .post(Self::endpoint(relay_url, "/api/auth/login")?) + .json(&body), + RelayHttpRetry::IdempotentWrite, + ) + .await?; + if !response.status().is_success() { + return Err(Self::into_buffered_error(response)); + } + let auth: AuthResponse = response.json().await?; + if auth.user_id != profile.user.github_id.to_string() { + return Err(anyhow!( + "relay returned a different GitHub account identity" + )); + } + Ok(( + AccountSession::new(auth.token, auth.user_id, device_secret), + profile.user, + )) + } + pub fn new() -> Self { Self { http: relay_http_client(), @@ -386,473 +317,24 @@ impl AccountClient { /// Fetch the login challenge and unwrap the master key locally. /// Does not call `/api/auth/login` and does not mint a token. - async fn unwrap_master_key_for_credentials( - &self, - relay_url: &str, - username: &str, - password: &str, - ) -> Result<([u8; MASTER_KEY_LEN], ChallengeResponse)> { - if username.trim().is_empty() - || username.len() > 128 - || password.is_empty() - || password.len() > 1024 - { - return Err(anyhow!("invalid account credential fields")); - } - let challenge_req = serde_json::json!({ "username": username }); - let resp = send_with_retry( - "login challenge", - self.http - .post(Self::endpoint(relay_url, "/api/auth/login/challenge")?) - .json(&challenge_req), - RelayHttpRetry::SafeRead, - ) - .await?; - if !resp.status().is_success() { - return Err(Self::into_buffered_error(resp)); - } - let challenge: ChallengeResponse = resp.json().await?; - - let salt = BASE64 - .decode(&challenge.salt) - .map_err(|e| anyhow!("b64 decode salt: {e}"))?; - if salt.len() != SALT_LEN { - return Err(anyhow!("invalid account salt length")); - } - let params: KdfParams = serde_json::from_str(&challenge.argon2_params) - .map_err(|e| anyhow!("parse argon2_params: {e}"))?; - - // GCM tag failure means the password is wrong. - let kek = derive_kek(password, &salt, ¶ms)?; - let master_key = unwrap_master_key(&kek, &challenge.wrapped_master_key) - .map_err(|_| anyhow!("invalid username or password"))?; - Ok((master_key, challenge)) - } - - /// Verify username/password against an existing session without minting a - /// new relay token. Uses the same challenge + KEK unwrap path as `login`. - /// Succeeds only when the unwrapped master key matches `expected_master_key`. - pub async fn verify_password_for_master_key( - &self, - relay_url: &str, - username: &str, - password: &str, - expected_master_key: &[u8; MASTER_KEY_LEN], - ) -> Result<()> { - let (master_key, _) = self - .unwrap_master_key_for_credentials(relay_url, username, password) - .await?; - if master_key != *expected_master_key { - return Err(anyhow!("invalid username or password")); - } - Ok(()) - } - - /// Log in to an existing account. Fetches the KDF challenge, derives the KEK - /// locally, unwraps the master key (GCM failure ⇒ wrong password), then - /// verifies the password hash with the relay to obtain a token. - pub async fn login( - &self, - relay_url: &str, - username: &str, - password: &str, - device: &DeviceIdentity, - ) -> Result { - let (master_key, challenge) = self - .unwrap_master_key_for_credentials(relay_url, username, password) - .await?; - - let kdf_salt = BASE64 - .decode(&challenge.kdf_salt) - .map_err(|e| anyhow!("b64 decode kdf_salt: {e}"))?; - if kdf_salt.len() != SALT_LEN { - return Err(anyhow!("invalid account KDF salt length")); - } - let params: KdfParams = serde_json::from_str(&challenge.argon2_params) - .map_err(|e| anyhow!("parse argon2_params: {e}"))?; - - // Derive the server-verifiable hash and submit it. - let password_hash = derive_password_hash(password, &kdf_salt, ¶ms)?; - let login_req = serde_json::json!({ - "username": username, - "password_hash": password_hash, - "device_id": device.device_id, - "device_name": device.device_name, - "device_kind": "desktop", - "request_id": uuid::Uuid::new_v4().to_string(), - }); - let request = self - .http - .post(Self::endpoint(relay_url, "/api/auth/login")?) - .json(&login_req); - // New relays persist request_id with the issued token, making an - // ambiguous response safe to replay. Older relays omit the challenge - // capability flag, so retain one-shot login behavior with them. - let resp = if challenge.login_idempotency_supported { - send_with_retry("account login", request, RelayHttpRetry::IdempotentWrite).await? - } else { - send_with_retry("account login", request, RelayHttpRetry::SingleAttempt).await? - }; - if !resp.status().is_success() { - return Err(Self::into_buffered_error(resp)); - } - let auth: AuthResponse = resp.json().await?; - Ok(AccountSession { - token: auth.token, - user_id: auth.user_id, - master_key, - }) - } - - // ── Encrypted sync (sessions + settings) ──────────────────────────────── - // - // All sync payloads are encrypted with the in-memory master_key via - // AES-256-GCM before leaving this device. The relay stores opaque - // ciphertext only. - fn auth_header(session: &AccountSession) -> String { format!("Bearer {}", session.token) } - /// Encrypt `plaintext` with the master key, returning base64 `(data, nonce)`. - fn seal(session: &AccountSession, plaintext: &str) -> Result<(String, String)> { - encrypt(&session.master_key, plaintext.as_bytes()) - .map(|(ct, nonce)| (BASE64.encode(ct), BASE64.encode(&nonce[..]))) - .map_err(|e| anyhow!("encrypt sync blob: {e}")) - } - - /// Decrypt base64 `(data, nonce)` with the master key. - fn open(session: &AccountSession, data_b64: &str, nonce_b64: &str) -> Result { - let ct = BASE64 - .decode(data_b64) - .map_err(|e| anyhow!("b64 decode sync ct: {e}"))?; - let nonce_vec = BASE64 - .decode(nonce_b64) - .map_err(|e| anyhow!("b64 decode sync nonce: {e}"))?; - if nonce_vec.len() != NONCE_LEN { - return Err(anyhow!("invalid sync nonce length")); - } - let mut nonce = [0u8; NONCE_LEN]; - nonce.copy_from_slice(&nonce_vec); - let pt = decrypt(&session.master_key, &ct, &nonce) - .map_err(|e| anyhow!("decrypt sync blob: {e}"))?; - String::from_utf8(pt).map_err(|e| anyhow!("sync blob utf8: {e}")) - } - - /// Upload (or replace) a single encrypted session blob for this device. - /// Returns the `version` written into the upsert body (client-generated LWW clock). - /// - /// When the relay reports HTTP 507 (account sync quota full), evict the - /// oldest remote session backup (excluding this id) and retry. This keeps - /// recent local backups flowing on relays that do not yet auto-evict. - pub async fn upload_session( - &self, - relay_url: &str, - session: &AccountSession, - session_id: &str, - plaintext: &str, - ) -> Result { - const MAX_QUOTA_RELIEF_ATTEMPTS: usize = 32; - let mut last_quota_error: Option = None; - for _ in 0..MAX_QUOTA_RELIEF_ATTEMPTS { - match self - .upload_session_once(relay_url, session, session_id, plaintext) - .await - { - Ok(version) => return Ok(version), - Err(err) if is_insufficient_storage_error(&err) => { - last_quota_error = Some(err); - if !self - .evict_oldest_remote_session(relay_url, session, session_id) - .await? - { - break; - } - } - Err(err) => return Err(err), - } - } - Err(last_quota_error.unwrap_or_else(|| { - anyhow!( - "relay returned HTTP 507 Insufficient Storage (the configured account or asset quota is full)" - ) - })) - } - - async fn upload_session_once( - &self, - relay_url: &str, - session: &AccountSession, - session_id: &str, - plaintext: &str, - ) -> Result { - let (data, nonce) = Self::seal(session, plaintext)?; - let version = chrono::Utc::now().timestamp_millis(); - let body = serde_json::json!({ - "session_id": session_id, - "encrypted_data": data, - "nonce": nonce, - "version": version, - }); - let resp = send_with_retry( - "upload session", - self.http - .post(Self::endpoint(relay_url, "/api/sync/sessions")?) - .header("Authorization", Self::auth_header(session)) - .json(&body), - RelayHttpRetry::IdempotentWrite, - ) - .await?; - if !resp.status().is_success() { - return Err(Self::into_buffered_error(resp)); - } - Ok(version) - } - - /// Soft-delete the oldest remote sync session other than `keep_session_id`. - /// Returns `true` when a session was removed. - async fn evict_oldest_remote_session( - &self, - relay_url: &str, - session: &AccountSession, - keep_session_id: &str, - ) -> Result { - let mut entries = self - .list_session_entries(relay_url, session, 0) - .await? - .into_iter() - .filter(|entry| entry.session_id != keep_session_id) - .collect::>(); - if entries.is_empty() { - return Ok(false); - } - entries.sort_by(|a, b| { - a.version - .cmp(&b.version) - .then_with(|| a.session_id.cmp(&b.session_id)) - }); - let victim = entries.remove(0); - self.delete_session(relay_url, session, &victim.session_id) - .await?; - Ok(true) - } - - /// List encrypted session blobs updated after `since` without decrypting. - /// `session_id` / `version` are plaintext metadata; payload stays sealed. - pub async fn list_session_entries( - &self, - relay_url: &str, - session: &AccountSession, - since: i64, - ) -> Result> { - let mut url = Self::endpoint(relay_url, "/api/sync/sessions")?; - url.query_pairs_mut() - .append_pair("since", &since.max(0).to_string()); - let resp = send_with_retry( - "list sessions", - self.http - .get(url) - .header("Authorization", Self::auth_header(session)), - RelayHttpRetry::SafeRead, - ) - .await?; - if !resp.status().is_success() { - return Err(Self::into_buffered_error(resp)); - } - let bytes = resp.bytes().await?; - let payload: SessionsListResponse = serde_json::from_slice(&bytes) - .map_err(|e| anyhow!("decode sessions list failed ({} bytes): {e}", bytes.len()))?; - Ok(payload - .sessions - .into_iter() - .map(|entry| ListedSessionEntry { - session_id: entry.session_id, - encrypted_data: entry.encrypted_data, - nonce: entry.nonce, - version: entry.version, - }) - .collect()) - } - - /// Decrypt one listed session entry locally. - pub fn decrypt_session_entry( - session: &AccountSession, - entry: &ListedSessionEntry, - ) -> Result { - let plaintext = Self::open(session, &entry.encrypted_data, &entry.nonce)?; - Ok(FetchedSession { - session_id: entry.session_id.clone(), - plaintext, - version: entry.version, - }) - } - - /// Fetch encrypted session blobs updated after `since` (relay `version`). - /// Pass `since = 0` for a full list. Each entry is decrypted locally. - pub async fn fetch_sessions( - &self, - relay_url: &str, - session: &AccountSession, - since: i64, - ) -> Result> { - let entries = self.list_session_entries(relay_url, session, since).await?; - entries - .into_iter() - .map(|entry| Self::decrypt_session_entry(session, &entry)) - .collect() - } - - /// Fetch and decrypt a single session blob by id. - pub async fn fetch_session( - &self, - relay_url: &str, - session: &AccountSession, - session_id: &str, - ) -> Result> { - let resp = send_with_retry( - "fetch session", - self.http - .get(Self::endpoint( - relay_url, - &format!("/api/sync/sessions/{}", urlencoding::encode(session_id)), - )?) - .header("Authorization", Self::auth_header(session)), - RelayHttpRetry::SafeRead, - ) - .await?; - if resp.status() == reqwest::StatusCode::NOT_FOUND { - return Ok(None); - } - if !resp.status().is_success() { - return Err(Self::into_buffered_error(resp)); - } - let entry: SessionEntry = resp.json().await?; - let plaintext = Self::open(session, &entry.encrypted_data, &entry.nonce)?; - Ok(Some(FetchedSession { - session_id: entry.session_id, - plaintext, - version: entry.version, - })) - } - - /// Delete a session blob (tombstone) — used when a session is removed. - pub async fn delete_session( - &self, - relay_url: &str, - session: &AccountSession, - session_id: &str, - ) -> Result<()> { - let resp = send_with_retry( - "delete session", - self.http - .delete(Self::endpoint( - relay_url, - &format!("/api/sync/sessions/{}", urlencoding::encode(session_id)), - )?) - .header("Authorization", Self::auth_header(session)), - RelayHttpRetry::IdempotentWrite, - ) - .await?; - if !resp.status().is_success() { - return Err(Self::into_buffered_error(resp)); - } - Ok(()) - } - - /// Upload an encrypted settings blob (keyed by the user, not per-device). - /// Returns the version sent with the upload so callers can record a sync - /// cursor that matches what the relay actually stored. - pub async fn upload_settings( - &self, - relay_url: &str, - session: &AccountSession, - plaintext: &str, - ) -> Result { - let (data, nonce) = Self::seal(session, plaintext)?; - let version = chrono::Utc::now().timestamp_millis(); - let body = serde_json::json!({ - "encrypted_data": data, - "nonce": nonce, - "version": version, - }); - let resp = send_with_retry( - "upload settings", - self.http - .post(Self::endpoint(relay_url, "/api/sync/settings")?) - .header("Authorization", Self::auth_header(session)) - .json(&body), - RelayHttpRetry::IdempotentWrite, - ) - .await?; - if !resp.status().is_success() { - return Err(Self::into_buffered_error(resp)); - } - Ok(version) - } - - /// Fetch and decrypt the settings blob. Returns `None` if no settings exist. - pub async fn fetch_settings( - &self, - relay_url: &str, - session: &AccountSession, - ) -> Result> { - Ok(self - .fetch_settings_with_version(relay_url, session) - .await? - .map(|b| b.plaintext)) - } - - /// Fetch and decrypt the settings blob, including the relay version. - /// The version lets the caller skip applying if the cloud hasn't changed - /// since the last pull. - pub async fn fetch_settings_with_version( - &self, - relay_url: &str, - session: &AccountSession, - ) -> Result> { - let resp = send_with_retry( - "fetch settings", - self.http - .get(Self::endpoint(relay_url, "/api/sync/settings")?) - .header("Authorization", Self::auth_header(session)), - RelayHttpRetry::SafeRead, - ) - .await?; - if resp.status() == reqwest::StatusCode::NOT_FOUND { - return Ok(None); - } - if !resp.status().is_success() { - return Err(Self::into_buffered_error(resp)); - } - let opt: Option = resp.json().await?; - match opt { - None => Ok(None), - Some(entry) => { - let pt = Self::open(session, &entry.encrypted_data, &entry.nonce)?; - Ok(Some(SettingsBlob { - plaintext: pt, - version: entry.version, - })) - } - } - } - - // ── Device RPC (browse/control other same-account devices) ──────────── - - /// Delegate a new token for the same account (for paired mobile/IM clients). - /// Returns the new token + user_id. The caller is responsible for securely - /// transmitting the token + master_key to the paired client. + /// Issue an account token for a paired controller. pub async fn delegate_token( &self, relay_url: &str, session: &AccountSession, ) -> Result { + let device_secret = super::device_crypto::generate_secret(); let resp = send_with_retry( "delegate account token", self.http .post(Self::endpoint(relay_url, "/api/auth/delegate")?) - .header("Authorization", Self::auth_header(session)), - RelayHttpRetry::IdempotentWrite, + .header("Authorization", Self::auth_header(session)) + .json(&serde_json::json!({ "public_key": super::device_crypto::public_key_base64(&device_secret) })), + RelayHttpRetry::SingleAttempt, ) .await?; if !resp.status().is_success() { @@ -862,6 +344,7 @@ impl AccountClient { Ok(DelegateToken { token: auth.token, user_id: auth.user_id, + device_secret, }) } @@ -880,11 +363,13 @@ impl AccountClient { device_name: &str, device_kind: &str, request_id: uuid::Uuid, + device_secret: &[u8; 32], ) -> Result { let body = serde_json::json!({ "device_id": device_id, "device_name": device_name, "device_kind": device_kind, + "public_key": device_crypto::public_key_base64(device_secret), "request_id": request_id.to_string(), }); let resp = send_with_retry( @@ -992,7 +477,9 @@ impl AccountClient { plaintext_command: &str, ) -> Result { // Encrypt the command with the master key - let (data, nonce) = Self::seal(session, plaintext_command)?; + let (data, nonce) = session + .encrypt_for_peer(relay_url, target_device_id, plaintext_command) + .await?; let body = serde_json::json!({ "encrypted_data": data, "nonce": nonce, @@ -1012,7 +499,14 @@ impl AccountClient { } let entry: RpcResponseEntry = resp.json().await?; // Decrypt the response with the master key - Self::open(session, &entry.encrypted_data, &entry.nonce) + session + .decrypt_from_peer( + relay_url, + target_device_id, + &entry.encrypted_data, + &entry.nonce, + ) + .await } } @@ -1043,137 +537,10 @@ struct RpcResponseEntry { nonce: String, } -#[derive(Deserialize)] -struct SessionsListResponse { - sessions: Vec, -} - -#[derive(Deserialize)] -struct SessionEntry { - session_id: String, - encrypted_data: String, - nonce: String, - #[serde(default)] - version: i64, -} - -fn is_insufficient_storage_error(err: &anyhow::Error) -> bool { - let msg = err.to_string(); - msg.contains("HTTP 507") - || msg.contains("Insufficient Storage") - || msg.to_ascii_lowercase().contains("quota is full") -} - -/// Decrypted session sync blob with relay version metadata. -#[derive(Debug, Clone)] -pub struct FetchedSession { - pub session_id: String, - pub plaintext: String, - pub version: i64, -} - -/// Encrypted session list entry (`session_id`/`version` are not secret). -#[derive(Debug, Clone)] -pub struct ListedSessionEntry { - pub session_id: String, - pub encrypted_data: String, - pub nonce: String, - pub version: i64, -} - -/// A settings blob with version metadata, returned by `fetch_settings_with_version`. -#[derive(Debug, Clone)] -pub struct SettingsBlob { - pub plaintext: String, - pub version: i64, -} - -#[derive(Deserialize)] -struct SettingsEntry { - encrypted_data: String, - nonce: String, - #[serde(default)] - version: i64, - #[serde(default)] - #[allow(dead_code)] - updated_at: i64, -} - #[cfg(test)] mod tests { use super::*; - #[test] - fn wrap_unwrap_round_trip() { - let params = KdfParams::default(); - let mut salt = [0u8; SALT_LEN]; - let mut master_key = [0u8; MASTER_KEY_LEN]; - rand::thread_rng().fill_bytes(&mut salt); - rand::thread_rng().fill_bytes(&mut master_key); - - let kek = derive_kek("correct-horse-battery", &salt, ¶ms).unwrap(); - let wrapped = wrap_master_key(&kek, &master_key).unwrap(); - let recovered = unwrap_master_key(&kek, &wrapped).unwrap(); - assert_eq!(recovered, master_key); - } - - #[test] - fn wrong_password_fails_to_unwrap() { - let params = KdfParams::default(); - let mut salt = [0u8; SALT_LEN]; - let mut master_key = [0u8; MASTER_KEY_LEN]; - rand::thread_rng().fill_bytes(&mut salt); - rand::thread_rng().fill_bytes(&mut master_key); - - let kek = derive_kek("correct-password", &salt, ¶ms).unwrap(); - let wrapped = wrap_master_key(&kek, &master_key).unwrap(); - - let wrong_kek = derive_kek("wrong-password", &salt, ¶ms).unwrap(); - assert!(unwrap_master_key(&wrong_kek, &wrapped).is_err()); - } - - #[test] - fn kdf_params_round_trip() { - let params = KdfParams::default(); - let json = serde_json::to_string(¶ms).unwrap(); - let parsed: KdfParams = serde_json::from_str(&json).unwrap(); - assert_eq!(parsed.m, params.m); - assert_eq!(parsed.t, params.t); - assert_eq!(parsed.p, params.p); - assert!(params.build().is_ok()); - assert!(KdfParams { - m: u32::MAX, - t: 3, - p: 4, - } - .build() - .is_err()); - } - - #[test] - fn password_hash_matches_the_browser_argon2id_vector() { - let params = KdfParams { - m: 8 * 1024, - t: 1, - p: 1, - }; - let salt = std::array::from_fn::<_, SALT_LEN, _>(|index| index as u8); - assert_eq!( - derive_password_hash("correct horse battery staple", &salt, ¶ms).unwrap(), - "mu73UxPlhfSSwzxeEtgumtJTt914Yy1Tfomc1O3deJw=" - ); - } - - #[test] - fn detects_insufficient_storage_errors_for_quota_relief() { - assert!(is_insufficient_storage_error(&anyhow!( - "relay returned HTTP 507 Insufficient Storage (the configured account or asset quota is full)" - ))); - assert!(!is_insufficient_storage_error(&anyhow!( - "relay returned HTTP 413 Payload Too Large" - ))); - } - #[test] fn relay_endpoint_accepts_http_servers_and_rejects_ambiguous_urls() { let endpoint = @@ -1208,32 +575,4 @@ mod tests { "ws://127.0.0.1:3000/relay/ws" ); } - - #[test] - fn account_history_export_waits_for_the_durable_import_marker() { - let mut metadata = SessionMetadata::new( - "session".to_string(), - "Session".to_string(), - "agentic".to_string(), - "auto".to_string(), - ); - metadata.turn_count = 3; - - assert!(ensure_relay_session_history_exportable(&metadata).is_ok()); - mark_relay_session_history_import_pending(&mut metadata); - assert!(!relay_session_history_import_is_complete(&metadata)); - assert!(ensure_relay_session_history_exportable(&metadata).is_err()); - - mark_relay_session_history_import_complete(&mut metadata); - assert!(relay_session_history_import_is_complete(&metadata)); - assert!(ensure_relay_session_history_exportable(&metadata).is_ok()); - let exported = relay_session_export_metadata(&metadata, 2); - assert_eq!(metadata.turn_count, 3); - assert_eq!(exported.turn_count, 2); - - metadata.custom_metadata = Some(serde_json::json!({ - "relayTurnsImportState": "unknown" - })); - assert!(ensure_relay_session_history_exportable(&metadata).is_err()); - } } diff --git a/src/crates/services/services-integrations/src/remote_connect/bot/locale.rs b/src/crates/services/services-integrations/src/remote_connect/bot/locale.rs index 136d69360a..aeed0e1216 100644 --- a/src/crates/services/services-integrations/src/remote_connect/bot/locale.rs +++ b/src/crates/services/services-integrations/src/remote_connect/bot/locale.rs @@ -330,7 +330,7 @@ const STRINGS_ZH: BotStrings = BotStrings { auto_push_failed_fmt: "发送「{name}」失败:{err}", devices_title: "多设备控制", - devices_account_required: "所连接的桌面端尚未登录 OpenBitFun 账号,无法使用多设备控制。请在桌面端的账号登录对话框中登录,机器人会自动继承账号身份。", + devices_account_required: "所连接的桌面端尚未使用 GitHub 登录,无法使用多设备控制。请在桌面端的账号登录对话框中登录,机器人会自动继承账号身份。", devices_empty: "当前账号下没有其它设备。", devices_status_online: "在线", devices_status_offline: "离线", @@ -487,7 +487,7 @@ const STRINGS_ZH_TW: BotStrings = BotStrings { auto_push_failed_fmt: "發送「{name}」失敗:{err}", devices_title: "多裝置控制", - devices_account_required: "所連接的桌面端尚未登入 OpenBitFun 帳號,無法使用多裝置控制。請在桌面端的帳號登入對話框中登入,機器人會自動繼承帳號身份。", + devices_account_required: "所連接的桌面端尚未使用 GitHub 登入,無法使用多裝置控制。請在桌面端的帳號登入對話框中登入,機器人會自動繼承帳號身份。", devices_empty: "目前帳號下沒有其它裝置。", devices_status_online: "線上", devices_status_offline: "離線", @@ -645,7 +645,7 @@ Open Remote Connect in OpenBitFun Desktop and send the 6-digit pairing code here auto_push_failed_fmt: "Failed to send \"{name}\": {err}", devices_title: "Multi-device Control", - devices_account_required: "The paired desktop is not logged into a OpenBitFun account, so multi-device control is unavailable. Log in via the desktop's Account Login dialog and the bot will inherit the account identity.", + devices_account_required: "The paired desktop is not logged into a GitHub account, so multi-device control is unavailable. Log in via the desktop's Account Login dialog and the bot will inherit the account identity.", devices_empty: "No other devices in this account.", devices_status_online: "online", devices_status_offline: "offline", diff --git a/src/crates/services/services-integrations/src/remote_connect/bot/mod.rs b/src/crates/services/services-integrations/src/remote_connect/bot/mod.rs index 0f6438094b..3c781dd657 100644 --- a/src/crates/services/services-integrations/src/remote_connect/bot/mod.rs +++ b/src/crates/services/services-integrations/src/remote_connect/bot/mod.rs @@ -56,6 +56,8 @@ pub struct BotPairingInfo { /// Persisted bot connection — saved to disk so reconnect survives restarts. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SavedBotConnection { + #[serde(default)] + pub account_user_id: String, pub bot_type: String, pub chat_id: String, pub config: BotConfig, @@ -66,7 +68,6 @@ pub struct SavedBotConnection { /// Persisted remote-connect form values shown in the desktop dialog. #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct RemoteConnectFormState { - pub custom_server_url: String, pub telegram_bot_token: String, pub feishu_app_id: String, pub feishu_app_secret: String, diff --git a/src/crates/services/services-integrations/src/remote_connect/device_crypto.rs b/src/crates/services/services-integrations/src/remote_connect/device_crypto.rs new file mode 100644 index 0000000000..da83b78b19 --- /dev/null +++ b/src/crates/services/services-integrations/src/remote_connect/device_crypto.rs @@ -0,0 +1,108 @@ +//! Device-pair message keys for GitHub-authenticated OpenBitFun Relay sessions. +//! Public keys come from the relay's same-account directory. Private keys are +//! generated and retained by each device. The relay wire envelope is unchanged. + +use anyhow::{anyhow, Result}; +use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; +use hkdf::Hkdf; +use sha2::Sha256; +use x25519_dalek::{PublicKey, StaticSecret}; + +pub const KDF_SALT: &[u8] = b"OpenBitFun Relay v1.0.0 device key"; + +pub fn generate_secret() -> [u8; 32] { + StaticSecret::random_from_rng(rand::rngs::OsRng).to_bytes() +} + +/// Derive a distinct bootstrap key that is stable for an idempotent request. +/// Knowledge of the target key does not reveal the provisioning device's key. +pub fn provisioning_secret( + parent_secret: &[u8; 32], + device_id: &str, + request_id: &str, +) -> [u8; 32] { + let mut secret = [0; 32]; + let info = format!("{device_id}:{request_id}"); + Hkdf::::new( + Some(b"OpenBitFun device provisioning v1.0.0"), + parent_secret, + ) + .expand(info.as_bytes(), &mut secret) + .expect("32-byte HKDF output is valid"); + secret +} + +pub fn public_key(secret: &[u8; 32]) -> [u8; 32] { + PublicKey::from(&StaticSecret::from(*secret)).to_bytes() +} + +pub fn public_key_base64(secret: &[u8; 32]) -> String { + BASE64.encode(public_key(secret)) +} + +/// X25519 + HKDF-SHA256. Ordering public keys makes the derivation symmetric, +/// while binding both identities and the protocol domain to the final AES key. +pub fn derive_message_key(secret: &[u8; 32], peer_public: &[u8; 32]) -> Result<[u8; 32]> { + let local_public = public_key(secret); + let shared = StaticSecret::from(*secret).diffie_hellman(&PublicKey::from(*peer_public)); + if !shared.was_contributory() { + return Err(anyhow!("invalid peer public key")); + } + let (first, second) = if local_public < *peer_public { + (&local_public, peer_public) + } else { + (peer_public, &local_public) + }; + let mut info = [0u8; 64]; + info[..32].copy_from_slice(first); + info[32..].copy_from_slice(second); + let mut key = [0u8; 32]; + Hkdf::::new(Some(KDF_SALT), shared.as_bytes()) + .expand(&info, &mut key) + .map_err(|_| anyhow!("device message key derivation failed"))?; + Ok(key) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::remote_connect::encryption::{decrypt_from_base64, encrypt_to_base64}; + + #[test] + fn provisioning_keys_are_isolated_and_replayable() { + let parent = [7; 32]; + let key = provisioning_secret(&parent, "target", "request"); + assert_ne!(key, parent); + assert_eq!(key, provisioning_secret(&parent, "target", "request")); + assert_ne!(key, provisioning_secret(&parent, "other", "request")); + assert_ne!(key, provisioning_secret(&parent, "target", "other")); + } + + #[test] + fn messages_round_trip_between_independent_devices_only() { + let alice = [7; 32]; + let bob = [11; 32]; + let charlie = [17; 32]; + let sending = derive_message_key(&alice, &public_key(&bob)).unwrap(); + let receiving = derive_message_key(&bob, &public_key(&alice)).unwrap(); + assert_eq!(sending, receiving); + assert_eq!( + sending + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(), + "6e8f5da837e91e9ddb09c5aa7dee229e731fc94499d29d10dcf5f1437193f56c" + ); + let (data, nonce) = encrypt_to_base64(&sending, "remote approval").unwrap(); + assert_eq!( + decrypt_from_base64(&receiving, &data, &nonce).unwrap(), + "remote approval" + ); + let unrelated = derive_message_key(&charlie, &public_key(&alice)).unwrap(); + assert!(decrypt_from_base64(&unrelated, &data, &nonce).is_err()); + assert!(derive_message_key(&alice, &[0; 32]).is_err()); + let mut low_order = [0; 32]; + low_order[0] = 1; + assert!(derive_message_key(&alice, &low_order).is_err()); + } +} diff --git a/src/crates/services/services-integrations/src/remote_connect/mobile_web_upload.rs b/src/crates/services/services-integrations/src/remote_connect/mobile_web_upload.rs deleted file mode 100644 index c1fff20635..0000000000 --- a/src/crates/services/services-integrations/src/remote_connect/mobile_web_upload.rs +++ /dev/null @@ -1,394 +0,0 @@ -//! Mobile-web relay upload helpers for Remote Connect. -//! -//! The relay upload protocol is a reusable integration detail. Product -//! assembly supplies the relay URL and room id; this module owns file -//! collection, hashing, incremental upload checks, and HTTP upload fallback. - -use anyhow::{anyhow, Result}; -use log::info; -use serde::Serialize; -use std::collections::{HashMap, HashSet}; -use std::path::Path; - -const MAX_UPLOAD_BATCH_BASE64_BYTES: usize = 256 * 1024; - -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -struct MobileWebUploadManifestEntry { - path: String, - hash: String, - size: u64, -} - -/// Collected file data ready for upload. -struct CollectedMobileWebFile { - rel_path: String, - content: Vec, - hash: String, -} - -fn mobile_web_upload_manifest( - files: &[CollectedMobileWebFile], -) -> Vec { - files - .iter() - .map(|file| MobileWebUploadManifestEntry { - path: file.rel_path.clone(), - hash: file.hash.clone(), - size: file.content.len() as u64, - }) - .collect() -} - -/// Upload mobile-web assets to a relay server. -pub async fn upload_mobile_web_to_relay( - relay_url: &str, - room_id: &str, - web_dir: &str, -) -> Result<()> { - let all_files = collect_mobile_web_files(Path::new(web_dir))?; - - info!( - "Collected {} mobile-web files ({} bytes total) for room {room_id}", - all_files.len(), - all_files - .iter() - .map(|file| file.content.len()) - .sum::() - ); - - let client = crate::reqwest_client(); - let relay_base = relay_url.trim_end_matches('/'); - - let manifest = mobile_web_upload_manifest(&all_files); - - let check_url = format!("{relay_base}/api/rooms/{room_id}/check-web-files"); - let check_result = client - .post(&check_url) - .json(&serde_json::json!({ "files": manifest })) - .timeout(std::time::Duration::from_secs(15)) - .send() - .await; - - match check_result { - Ok(resp) if resp.status().is_success() => { - let body: serde_json::Value = resp - .json() - .await - .map_err(|error| anyhow!("parse check-web-files response: {error}"))?; - let needed: Vec = body["needed"] - .as_array() - .map(|items| { - items - .iter() - .filter_map(|value| value.as_str().map(String::from)) - .collect() - }) - .unwrap_or_default(); - - let existing = body["existing_count"].as_u64().unwrap_or(0); - let total = body["total_count"].as_u64().unwrap_or(0); - if needed.is_empty() { - info!("All {total} files already exist on relay server, no upload needed"); - return Ok(()); - } - - info!( - "Incremental upload: {existing}/{total} files already on server, uploading {} needed", - needed.len() - ); - - upload_needed_files(&client, relay_base, room_id, &all_files, &needed).await - } - Ok(resp) if resp.status().as_u16() == 404 => { - info!("Relay server does not support incremental upload, falling back to full upload"); - upload_all_files(&client, relay_base, room_id, &all_files).await - } - Ok(resp) => { - let status = resp.status(); - info!("check-web-files returned HTTP {status}, falling back to full upload"); - upload_all_files(&client, relay_base, room_id, &all_files).await - } - Err(error) => { - info!("check-web-files request failed ({error}), falling back to full upload"); - upload_all_files(&client, relay_base, room_id, &all_files).await - } - } -} - -fn collect_mobile_web_files(base: &Path) -> Result> { - if !base.join("index.html").exists() { - return Err(anyhow!( - "mobile-web dir missing index.html: {}", - base.display() - )); - } - - let mut all_files = Vec::new(); - collect_files_with_hash(base, base, &mut all_files)?; - Ok(all_files) -} - -async fn upload_needed_files( - client: &reqwest::Client, - relay_base: &str, - room_id: &str, - all_files: &[CollectedMobileWebFile], - needed: &[String], -) -> Result<()> { - use base64::{engine::general_purpose::STANDARD as B64, Engine}; - - let needed_set: HashSet<&str> = needed.iter().map(String::as_str).collect(); - - let mut files_payload: Vec<(String, serde_json::Value, usize)> = Vec::new(); - for file in all_files { - if needed_set.contains(file.rel_path.as_str()) { - let encoded = B64.encode(&file.content); - let encoded_len = encoded.len(); - files_payload.push(( - file.rel_path.clone(), - serde_json::json!({ - "content": encoded, - "hash": file.hash, - }), - encoded_len, - )); - } - } - - let url = format!("{relay_base}/api/rooms/{room_id}/upload-web-files"); - let total_b64_bytes: usize = files_payload.iter().map(|(_, _, len)| *len).sum(); - - info!( - "Uploading {} needed files ({} bytes base64) to {url}", - files_payload.len(), - total_b64_bytes - ); - - let mut current_batch: HashMap = HashMap::new(); - let mut current_batch_b64_bytes = 0usize; - let mut batch_index = 0usize; - for (path, entry, entry_len) in files_payload { - let should_flush = !current_batch.is_empty() - && current_batch_b64_bytes + entry_len > MAX_UPLOAD_BATCH_BASE64_BYTES; - if should_flush { - upload_web_files_batch( - client, - &url, - batch_index, - ¤t_batch, - current_batch_b64_bytes, - ) - .await?; - batch_index += 1; - current_batch = HashMap::new(); - current_batch_b64_bytes = 0; - } - current_batch.insert(path, entry); - current_batch_b64_bytes += entry_len; - } - - if !current_batch.is_empty() { - upload_web_files_batch( - client, - &url, - batch_index, - ¤t_batch, - current_batch_b64_bytes, - ) - .await?; - } - - Ok(()) -} - -async fn upload_all_files( - client: &reqwest::Client, - relay_base: &str, - room_id: &str, - all_files: &[CollectedMobileWebFile], -) -> Result<()> { - use base64::{engine::general_purpose::STANDARD as B64, Engine}; - - let mut files: Vec<(String, String, usize)> = Vec::new(); - for file in all_files { - let encoded = B64.encode(&file.content); - let encoded_len = encoded.len(); - files.push((file.rel_path.clone(), encoded, encoded_len)); - } - - let url = format!("{relay_base}/api/rooms/{room_id}/upload-web"); - - info!( - "Full upload: {} files ({} bytes base64) to {url}", - files.len(), - files.iter().map(|(_, _, len)| *len).sum::() - ); - - let mut current_batch: HashMap = HashMap::new(); - let mut current_batch_b64_bytes = 0usize; - let mut batch_index = 0usize; - for (path, encoded, encoded_len) in files { - let should_flush = !current_batch.is_empty() - && current_batch_b64_bytes + encoded_len > MAX_UPLOAD_BATCH_BASE64_BYTES; - if should_flush { - upload_web_legacy_batch( - client, - &url, - batch_index, - ¤t_batch, - current_batch_b64_bytes, - ) - .await?; - batch_index += 1; - current_batch = HashMap::new(); - current_batch_b64_bytes = 0; - } - current_batch.insert(path, encoded); - current_batch_b64_bytes += encoded_len; - } - - if !current_batch.is_empty() { - upload_web_legacy_batch( - client, - &url, - batch_index, - ¤t_batch, - current_batch_b64_bytes, - ) - .await?; - } - - Ok(()) -} - -async fn upload_web_files_batch( - client: &reqwest::Client, - url: &str, - batch_index: usize, - files_payload: &HashMap, - _total_b64_bytes: usize, -) -> Result<()> { - let resp = client - .post(url) - .json(&serde_json::json!({ "files": files_payload })) - .timeout(std::time::Duration::from_secs(30)) - .send() - .await - .map_err(|error| anyhow!("upload-web-files batch {batch_index}: {error}"))?; - - if !resp.status().is_success() { - let status = resp.status(); - let body = resp.text().await.unwrap_or_default(); - return Err(anyhow!( - "upload-web-files batch {batch_index} failed: HTTP {status} \u{2014} {body}" - )); - } - Ok(()) -} - -async fn upload_web_legacy_batch( - client: &reqwest::Client, - url: &str, - batch_index: usize, - files_payload: &HashMap, - _total_b64_bytes: usize, -) -> Result<()> { - let resp = client - .post(url) - .json(&serde_json::json!({ "files": files_payload })) - .timeout(std::time::Duration::from_secs(30)) - .send() - .await - .map_err(|error| anyhow!("upload mobile-web batch {batch_index}: {error}"))?; - - if !resp.status().is_success() { - let status = resp.status(); - let body = resp.text().await.unwrap_or_default(); - return Err(anyhow!( - "upload mobile-web batch {batch_index} failed: HTTP {status} \u{2014} {body}" - )); - } - Ok(()) -} - -fn collect_files_with_hash( - base: &Path, - dir: &Path, - out: &mut Vec, -) -> Result<()> { - use sha2::{Digest, Sha256}; - - for entry in std::fs::read_dir(dir)? { - let entry = entry?; - let path = entry.path(); - if path.is_dir() { - collect_files_with_hash(base, &path, out)?; - } else if path.is_file() { - let rel = path - .strip_prefix(base) - .unwrap_or(&path) - .to_string_lossy() - .replace('\\', "/"); - let content = std::fs::read(&path)?; - let mut hasher = Sha256::new(); - hasher.update(&content); - let hash = format!("{:x}", hasher.finalize()); - out.push(CollectedMobileWebFile { - rel_path: rel, - content, - hash, - }); - } - } - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn manifest_preserves_forward_slash_paths_and_hashes() { - let base = std::env::temp_dir().join(format!( - "openbitfun-remote-mobile-web-manifest-{}", - uuid::Uuid::new_v4() - )); - let assets = base.join("assets"); - std::fs::create_dir_all(&assets).unwrap(); - std::fs::write(base.join("index.html"), b"").unwrap(); - std::fs::write(assets.join("app.js"), b"console.log('ok');").unwrap(); - - let files = collect_mobile_web_files(&base).unwrap(); - let mut manifest = mobile_web_upload_manifest(&files); - manifest.sort_by(|left, right| left.path.cmp(&right.path)); - - assert_eq!(manifest.len(), 2); - assert_eq!(manifest[0].path, "assets/app.js"); - assert_eq!(manifest[0].size, b"console.log('ok');".len() as u64); - assert_eq!( - manifest[0].hash, - "16ba942cc0730b9c1416eb532c015b5d26bf8419618e315abe2544b87ae63a16" - ); - assert_eq!(manifest[1].path, "index.html"); - assert_eq!(manifest[1].size, b"".len() as u64); - - let _ = std::fs::remove_dir_all(base); - } - - #[test] - fn manifest_rejects_missing_index_html() { - let base = std::env::temp_dir().join(format!( - "openbitfun-remote-mobile-web-missing-index-{}", - uuid::Uuid::new_v4() - )); - std::fs::create_dir_all(&base).unwrap(); - - let error = match collect_mobile_web_files(&base) { - Ok(_) => panic!("missing index.html should be rejected"), - Err(error) => error, - }; - - assert!(error.to_string().contains("missing index.html")); - let _ = std::fs::remove_dir_all(base); - } -} diff --git a/src/crates/services/services-integrations/src/remote_connect/ngrok.rs b/src/crates/services/services-integrations/src/remote_connect/ngrok.rs deleted file mode 100644 index 4de6f1ffc7..0000000000 --- a/src/crates/services/services-integrations/src/remote_connect/ngrok.rs +++ /dev/null @@ -1,274 +0,0 @@ -//! ngrok tunnel mode for Remote Connect. -//! -//! This service owns ngrok discovery, process lifecycle, and tunnel URL parsing. - -use anyhow::{anyhow, Result}; -use log::{info, warn}; -use openbitfun_services_core::process_manager; -use std::path::PathBuf; -use std::process::Stdio; -use std::sync::atomic::{AtomicU32, Ordering}; -use tokio::io::{AsyncBufReadExt, BufReader}; - -/// Tracks the PID of the ngrok process we started, so it can be killed -/// synchronously during application exit even if async cleanup did not run. -static NGROK_PID: AtomicU32 = AtomicU32::new(0); - -fn find_ngrok() -> Option { - if let Ok(path) = which::which("ngrok") { - return Some(path); - } - - let candidates: Vec = vec![ - PathBuf::from("/usr/local/bin/ngrok"), - PathBuf::from("/opt/homebrew/bin/ngrok"), - dirs::home_dir() - .map(|home| home.join("ngrok")) - .unwrap_or_default(), - dirs::home_dir() - .map(|home| home.join(".ngrok/ngrok")) - .unwrap_or_default(), - dirs::home_dir() - .map(|home| home.join("bin/ngrok")) - .unwrap_or_default(), - #[cfg(target_os = "windows")] - { - let appdata = std::env::var("LOCALAPPDATA").unwrap_or_default(); - PathBuf::from(format!("{appdata}\\ngrok\\ngrok.exe")) - }, - #[cfg(target_os = "windows")] - PathBuf::from("C:\\ngrok\\ngrok.exe"), - ]; - - candidates - .into_iter() - .find(|path| path.exists() && path.is_file()) -} - -/// Check if ngrok is installed and available. -pub async fn is_ngrok_available() -> bool { - find_ngrok().is_some() -} - -/// Check if any ngrok process is already running on the system. -pub fn detect_running_ngrok() -> Option> { - let pids = list_ngrok_pids(); - if pids.is_empty() { - None - } else { - Some(pids) - } -} - -#[cfg(unix)] -fn list_ngrok_pids() -> Vec { - std::process::Command::new("pgrep") - .args(["-x", "ngrok"]) - .output() - .ok() - .and_then(|out| { - if out.status.success() { - let text = String::from_utf8_lossy(&out.stdout); - Some( - text.lines() - .filter_map(|line| line.trim().parse::().ok()) - .collect(), - ) - } else { - None - } - }) - .unwrap_or_default() -} - -#[cfg(windows)] -fn list_ngrok_pids() -> Vec { - process_manager::create_command("tasklist") - .args(["/FI", "IMAGENAME eq ngrok.exe", "/FO", "CSV", "/NH"]) - .output() - .ok() - .map(|out| { - let text = String::from_utf8_lossy(&out.stdout); - text.lines() - .filter_map(|line| { - let parts: Vec<&str> = line.split(',').collect(); - parts - .get(1) - .and_then(|value| value.trim_matches('"').trim().parse::().ok()) - }) - .collect() - }) - .unwrap_or_default() -} - -/// Start an ngrok HTTP tunnel and return the public URL. -pub async fn start_ngrok_tunnel(local_port: u16) -> Result { - let ngrok_path = find_ngrok().ok_or_else(|| { - anyhow!( - "ngrok is not installed.\n\ - Please install ngrok and configure your auth token, then retry.\n\ - No need to start ngrok manually \u{2014} OpenBitFun will start it automatically.\n\ - Setup guide: https://dashboard.ngrok.com/get-started/setup" - ) - })?; - - if let Some(pids) = detect_running_ngrok() { - return Err(anyhow!( - "An ngrok process is already running (PID: {}).\n\ - Please stop the existing ngrok process before starting a new tunnel,\n\ - or use the existing tunnel directly.", - pids.iter() - .map(|pid| pid.to_string()) - .collect::>() - .join(", ") - )); - } - - info!("Using ngrok at: {}", ngrok_path.display()); - - let mut child = process_manager::create_tokio_command(&ngrok_path) - .args([ - "http", - &local_port.to_string(), - "--log", - "stdout", - "--log-format", - "json", - ]) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .map_err(|error| { - anyhow!( - "Failed to start ngrok process: {error}\n\ - Please ensure ngrok is installed and your auth token is configured \ - (run: ngrok config add-authtoken ).\n\ - No need to start ngrok manually \u{2014} OpenBitFun will start it automatically." - ) - })?; - - let pid = child.id().unwrap_or(0); - NGROK_PID.store(pid, Ordering::Relaxed); - info!("ngrok process started, pid={pid}"); - - let stdout = child - .stdout - .take() - .ok_or_else(|| anyhow!("Failed to capture ngrok stdout"))?; - - let public_url = match parse_tunnel_url_from_stdout(stdout).await { - Ok(url) => url, - Err(error) => { - let _ = child.kill().await; - return Err(anyhow!( - "ngrok tunnel failed to establish: {error}\n\ - Possible causes:\n\ - - ngrok auth token not configured (run: ngrok config add-authtoken )\n\ - - Network connectivity issue\n\ - - ngrok service outage\n\ - Note: You do not need to start ngrok manually." - )); - } - }; - - info!("ngrok tunnel established: {public_url}"); - - Ok(NgrokTunnel { - public_url, - local_port, - pid: Some(pid), - process: Some(child), - }) -} - -async fn parse_tunnel_url_from_stdout(stdout: tokio::process::ChildStdout) -> Result { - let reader = BufReader::new(stdout); - let mut lines = reader.lines(); - - let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); - - let (url_tx, url_rx) = tokio::sync::oneshot::channel::(); - let mut url_tx = Some(url_tx); - - tokio::spawn(async move { - while let Ok(Some(line)) = lines.next_line().await { - if let Ok(obj) = serde_json::from_str::(&line) { - if let Some(url) = obj.get("url").and_then(|value| value.as_str()) { - if url.starts_with("https://") || url.starts_with("http://") { - if let Some(tx) = url_tx.take() { - let _ = tx.send(url.to_string()); - } - } - } - } - } - drop(url_tx); - }); - - match tokio::time::timeout_at(deadline, url_rx).await { - Ok(Ok(url)) => Ok(url), - Ok(Err(_)) => Err(anyhow!("ngrok exited before establishing a tunnel")), - Err(_) => Err(anyhow!("timed out (15s)")), - } -} - -#[cfg(unix)] -fn kill_process(pid: u32) { - let _ = std::process::Command::new("kill") - .args(["-9", &pid.to_string()]) - .output(); -} - -#[cfg(windows)] -fn kill_process(pid: u32) { - let _ = process_manager::create_command("taskkill") - .args(["/F", "/PID", &pid.to_string()]) - .output(); -} - -pub struct NgrokTunnel { - pub public_url: String, - pub local_port: u16, - pid: Option, - process: Option, -} - -impl NgrokTunnel { - pub fn ws_url(&self) -> String { - self.public_url - .replace("https://", "wss://") - .replace("http://", "ws://") - } - - pub async fn stop(&mut self) { - if let Some(ref mut child) = self.process { - let _ = child.kill().await; - info!("ngrok tunnel stopped"); - } - self.process = None; - self.pid = None; - NGROK_PID.store(0, Ordering::Relaxed); - } -} - -impl Drop for NgrokTunnel { - fn drop(&mut self) { - if let Some(ref mut child) = self.process { - let _ = child.start_kill(); - } - if let Some(pid) = self.pid.take() { - kill_process(pid); - warn!("Force-killed ngrok process pid={pid} during cleanup"); - } - NGROK_PID.store(0, Ordering::Relaxed); - } -} - -/// Synchronous cleanup: kill the ngrok process we started, if any. -pub fn cleanup_all_ngrok() { - let pid = NGROK_PID.swap(0, Ordering::Relaxed); - if pid != 0 { - info!("Cleaning up ngrok process pid={pid} on application exit"); - kill_process(pid); - } -} diff --git a/src/crates/services/services-integrations/src/remote_connect/pairing.rs b/src/crates/services/services-integrations/src/remote_connect/pairing.rs index df6492a871..13998593e2 100644 --- a/src/crates/services/services-integrations/src/remote_connect/pairing.rs +++ b/src/crates/services/services-integrations/src/remote_connect/pairing.rs @@ -1,19 +1,6 @@ -//! Pairing protocol for establishing E2E encrypted connections. -//! -//! Desktop generates a keypair + room, encodes it in a QR code. -//! Mobile scans QR, joins room, sends its public key. -//! Both sides derive a shared secret via ECDH and verify with a challenge-response. - -use anyhow::{anyhow, Result}; +//! Invitation status and account-linked IM enrollment codes. use rand::Rng; use serde::{Deserialize, Serialize}; -use std::sync::Arc; -use tokio::sync::RwLock; - -use super::device::DeviceIdentity; -use super::encryption::{self, KeyPair}; - -const PAIRING_CHALLENGE_TTL_SECS: i64 = 120; /// Current state of the pairing process. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -28,488 +15,7 @@ pub enum PairingState { Disconnected, } -/// Information encoded in the QR code. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct QrPayload { - pub url: String, - pub room_id: String, - pub device_id: String, - pub device_name: String, - pub public_key: String, - pub version: u8, -} - -/// Challenge sent from desktop to mobile during pairing verification. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PairingChallenge { - pub challenge: String, - pub timestamp: i64, -} - -/// Response from mobile to desktop. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PairingResponse { - pub challenge_echo: String, - pub device_id: String, - pub device_name: String, - #[serde(default)] - pub mobile_install_id: Option, - /// Local pairing user id, or OpenBitFun account username when account auth is required. - #[serde(default)] - pub user_id: Option, - /// OpenBitFun account password when the paired desktop is logged in. Never log this field. - #[serde(default)] - pub password: Option, -} - -impl PairingResponse { - /// Validate the decrypted but still untrusted mobile payload before it is - /// cloned, persisted, or passed into password verification. - pub fn validate_untrusted(&self) -> Result<()> { - fn portable_id(value: &str, max_bytes: usize) -> bool { - !value.is_empty() - && value.len() <= max_bytes - && value - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')) - } - fn bounded_text(value: &str, max_bytes: usize) -> bool { - !value.trim().is_empty() - && value.len() <= max_bytes - && !value.chars().any(char::is_control) - } - - if self.challenge_echo.len() != 32 - || !self - .challenge_echo - .bytes() - .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) - || !portable_id(&self.device_id, 128) - || !bounded_text(&self.device_name, 256) - || self - .mobile_install_id - .as_deref() - .is_some_and(|value| !portable_id(value.trim(), 128)) - || self - .user_id - .as_deref() - .is_some_and(|value| !bounded_text(value, 128)) - || self - .password - .as_deref() - .is_some_and(|value| value.len() > 1024 || value.chars().any(char::is_control)) - { - return Err(anyhow!("invalid pairing response fields")); - } - Ok(()) - } -} - -/// Manages the pairing state machine. -pub struct PairingProtocol { - state: Arc>, - keypair: Option, - shared_secret: Option<[u8; 32]>, - room_id: Option, - device_identity: DeviceIdentity, - challenge: Option, - challenge_issued_at: Option, - peer_device_id: Option, - peer_device_name: Option, -} - -impl PairingProtocol { - pub fn new(device_identity: DeviceIdentity) -> Self { - Self { - state: Arc::new(RwLock::new(PairingState::Idle)), - keypair: None, - shared_secret: None, - room_id: None, - device_identity, - challenge: None, - challenge_issued_at: None, - peer_device_id: None, - peer_device_name: None, - } - } - - pub async fn state(&self) -> PairingState { - self.state.read().await.clone() - } - - pub fn shared_secret(&self) -> Option<&[u8; 32]> { - self.shared_secret.as_ref() - } - - pub fn room_id(&self) -> Option<&str> { - self.room_id.as_deref() - } - - pub fn peer_device_name(&self) -> Option<&str> { - self.peer_device_name.as_deref() - } - - /// Step 1 (Desktop): Generate keypair and prepare QR payload. - pub async fn initiate(&mut self, relay_url: &str) -> Result { - let keypair = KeyPair::generate(); - let room_id = generate_room_id(); - - let payload = QrPayload { - url: relay_url.to_string(), - room_id: room_id.clone(), - device_id: self.device_identity.device_id.clone(), - device_name: self.device_identity.device_name.clone(), - public_key: keypair.public_key_base64(), - version: 1, - }; - - self.keypair = Some(keypair); - self.room_id = Some(room_id); - *self.state.write().await = PairingState::WaitingForScan; - - Ok(payload) - } - - /// Step 2 (Desktop): Peer joined with their public key — derive shared secret. - pub async fn on_peer_joined(&mut self, peer_public_key_b64: &str) -> Result { - let state = self.state().await; - // Mobile browsers do not persist the ephemeral ECDH private key. A - // refresh, a duplicated URL, or a reload during verification therefore - // starts a fresh handshake in the same still-valid QR room. Let the - // latest handshake supersede the previous one; identity authorization - // is still enforced after the encrypted challenge response. - if !matches!( - state, - PairingState::WaitingForScan | PairingState::Verifying | PairingState::Connected - ) { - return Err(anyhow!("pairing request is not valid in the current state")); - } - let keypair = self - .keypair - .as_ref() - .ok_or_else(|| anyhow!("no keypair — call initiate() first"))?; - - let peer_pub = encryption::parse_public_key(peer_public_key_b64)?; - let shared = keypair.derive_shared_secret(&peer_pub); - self.shared_secret = Some(shared); - - let challenge = generate_challenge(); - self.challenge = Some(challenge.clone()); - let issued_at = chrono::Utc::now().timestamp(); - self.challenge_issued_at = Some(issued_at); - - let challenge_payload = PairingChallenge { - challenge, - timestamp: issued_at, - }; - - *self.state.write().await = PairingState::Verifying; - Ok(challenge_payload) - } - - /// Step 3 (Desktop): Verify the peer's challenge response. - pub async fn verify_response(&mut self, response: &PairingResponse) -> Result { - if self.state().await != PairingState::Verifying { - return Err(anyhow!( - "pairing response is not valid in the current state" - )); - } - let expected = self - .challenge - .take() - .ok_or_else(|| anyhow!("no challenge issued"))?; - let issued_at = self - .challenge_issued_at - .take() - .ok_or_else(|| anyhow!("challenge timestamp missing"))?; - - if chrono::Utc::now().timestamp().saturating_sub(issued_at) > PAIRING_CHALLENGE_TTL_SECS { - *self.state.write().await = PairingState::Failed { - reason: "challenge expired".to_string(), - }; - return Ok(false); - } - - if response.challenge_echo != expected { - *self.state.write().await = PairingState::Failed { - reason: "challenge mismatch".to_string(), - }; - return Ok(false); - } - - self.peer_device_id = Some(response.device_id.clone()); - self.peer_device_name = Some(response.device_name.clone()); - *self.state.write().await = PairingState::Connected; - Ok(true) - } - - /// Mobile side: process a received challenge and produce a response. - pub fn answer_challenge( - challenge: &PairingChallenge, - device_identity: &DeviceIdentity, - mobile_install_id: Option, - user_id: Option, - ) -> PairingResponse { - Self::answer_challenge_with_password( - challenge, - device_identity, - mobile_install_id, - user_id, - None, - ) - } - - /// Mobile side: pairing response that may include an account password. - pub fn answer_challenge_with_password( - challenge: &PairingChallenge, - device_identity: &DeviceIdentity, - mobile_install_id: Option, - user_id: Option, - password: Option, - ) -> PairingResponse { - PairingResponse { - challenge_echo: challenge.challenge.clone(), - device_id: device_identity.device_id.clone(), - device_name: device_identity.device_name.clone(), - mobile_install_id, - user_id, - password, - } - } - - pub async fn disconnect(&mut self) { - *self.state.write().await = PairingState::Disconnected; - self.shared_secret = None; - self.challenge = None; - self.challenge_issued_at = None; - self.peer_device_id = None; - self.peer_device_name = None; - } - - /// Keep the QR room/keypair but discard the failed mobile handshake so the - /// user can correct account credentials without restarting Remote Connect. - /// Call only after an authenticated, encrypted pairing response fails an - /// identity/account policy check; cryptographic challenge mismatches remain - /// terminal for the current QR. - pub async fn retry_after_identity_rejection(&mut self) { - self.shared_secret = None; - self.challenge = None; - self.challenge_issued_at = None; - self.peer_device_id = None; - self.peer_device_name = None; - *self.state.write().await = PairingState::WaitingForScan; - } - - pub async fn reset(&mut self) { - *self.state.write().await = PairingState::Idle; - self.keypair = None; - self.shared_secret = None; - self.room_id = None; - self.challenge = None; - self.challenge_issued_at = None; - self.peer_device_id = None; - self.peer_device_name = None; - } - - pub async fn set_bot_connected(&mut self, peer_name: String) { - self.peer_device_name = Some(peer_name); - *self.state.write().await = PairingState::Connected; - } - - /// Generate a 6-digit pairing code for bot connections. - pub fn generate_bot_pairing_code() -> String { - let code: u32 = rand::thread_rng().gen_range(100_000..1_000_000); - format!("{code:06}") - } -} - -fn generate_room_id() -> String { - let mut rng = rand::thread_rng(); - let bytes: [u8; 8] = rng.gen(); - bytes.iter().map(|b| format!("{b:02x}")).collect() -} - -fn generate_challenge() -> String { - let mut rng = rand::thread_rng(); - let bytes: [u8; 16] = rng.gen(); - bytes.iter().map(|b| format!("{b:02x}")).collect() -} - -#[cfg(test)] -mod tests { - use super::*; - - #[tokio::test] - async fn test_pairing_flow() { - let device = DeviceIdentity { - device_id: "test-desktop-id".into(), - device_name: "TestDesktop".into(), - mac_address: "AA:BB:CC:DD:EE:FF".into(), - }; - - let mobile_device = DeviceIdentity { - device_id: "test-mobile-id".into(), - device_name: "TestMobile".into(), - mac_address: "11:22:33:44:55:66".into(), - }; - - let mut protocol = PairingProtocol::new(device); - - // Step 1: Desktop initiates - let qr = protocol.initiate("wss://relay.example.com").await.unwrap(); - assert_eq!(protocol.state().await, PairingState::WaitingForScan); - assert!(!qr.room_id.is_empty()); - - // Simulate mobile generating a keypair and joining - let mobile_keypair = KeyPair::generate(); - let mobile_pub_b64 = mobile_keypair.public_key_base64(); - - // Step 2: Desktop receives mobile's public key - let challenge = protocol.on_peer_joined(&mobile_pub_b64).await.unwrap(); - assert_eq!(protocol.state().await, PairingState::Verifying); - - // Mobile answers the challenge - let response = PairingProtocol::answer_challenge( - &challenge, - &mobile_device, - Some("install-id-1".into()), - Some("alice".into()), - ); - - // Step 3: Desktop verifies - let ok = protocol.verify_response(&response).await.unwrap(); - assert!(ok); - assert_eq!(protocol.state().await, PairingState::Connected); - - // Both sides should have matching shared secrets - let desktop_secret = protocol.shared_secret().unwrap(); - let desktop_pub = encryption::parse_public_key(&qr.public_key).unwrap(); - let mobile_shared = mobile_keypair.derive_shared_secret(&desktop_pub); - assert_eq!(*desktop_secret, mobile_shared); - - // Challenges are single-use even when a response is replayed verbatim. - assert!(protocol.verify_response(&response).await.is_err()); - } - - #[tokio::test] - async fn connected_pairing_accepts_a_fresh_browser_handshake() { - let device = DeviceIdentity { - device_id: "test-desktop-id".into(), - device_name: "TestDesktop".into(), - mac_address: "AA:BB:CC:DD:EE:FF".into(), - }; - let first_mobile = DeviceIdentity { - device_id: "first-mobile-id".into(), - device_name: "FirstMobile".into(), - mac_address: "11:22:33:44:55:66".into(), - }; - let second_mobile = DeviceIdentity { - device_id: "second-mobile-id".into(), - device_name: "SecondMobile".into(), - mac_address: "22:33:44:55:66:77".into(), - }; - let mut protocol = PairingProtocol::new(device); - let qr = protocol.initiate("wss://relay.example.com").await.unwrap(); - let desktop_pub = encryption::parse_public_key(&qr.public_key).unwrap(); - - let first_keypair = KeyPair::generate(); - let first_challenge = protocol - .on_peer_joined(&first_keypair.public_key_base64()) - .await - .unwrap(); - let first_response = - PairingProtocol::answer_challenge(&first_challenge, &first_mobile, None, None); - assert!(protocol.verify_response(&first_response).await.unwrap()); - assert_eq!(protocol.state().await, PairingState::Connected); - - let second_keypair = KeyPair::generate(); - let second_challenge = protocol - .on_peer_joined(&second_keypair.public_key_base64()) - .await - .expect("a valid QR room must allow a browser refresh or another browser"); - assert_eq!(protocol.state().await, PairingState::Verifying); - assert_eq!( - *protocol.shared_secret().unwrap(), - second_keypair.derive_shared_secret(&desktop_pub) - ); - - let second_response = - PairingProtocol::answer_challenge(&second_challenge, &second_mobile, None, None); - assert!(protocol.verify_response(&second_response).await.unwrap()); - assert_eq!(protocol.state().await, PairingState::Connected); - assert_eq!(protocol.peer_device_name(), Some("SecondMobile")); - } - - #[tokio::test] - async fn fresh_pair_request_supersedes_an_abandoned_browser_handshake() { - let device = DeviceIdentity { - device_id: "test-desktop-id".into(), - device_name: "TestDesktop".into(), - mac_address: "AA:BB:CC:DD:EE:FF".into(), - }; - let latest_mobile = DeviceIdentity { - device_id: "latest-mobile-id".into(), - device_name: "LatestMobile".into(), - mac_address: "33:44:55:66:77:88".into(), - }; - let mut protocol = PairingProtocol::new(device); - let qr = protocol.initiate("wss://relay.example.com").await.unwrap(); - let desktop_pub = encryption::parse_public_key(&qr.public_key).unwrap(); - - let abandoned_keypair = KeyPair::generate(); - protocol - .on_peer_joined(&abandoned_keypair.public_key_base64()) - .await - .unwrap(); - assert_eq!(protocol.state().await, PairingState::Verifying); - - let latest_keypair = KeyPair::generate(); - let latest_challenge = protocol - .on_peer_joined(&latest_keypair.public_key_base64()) - .await - .expect("a reload must supersede an unfinished handshake"); - assert_eq!( - *protocol.shared_secret().unwrap(), - latest_keypair.derive_shared_secret(&desktop_pub) - ); - - let latest_response = - PairingProtocol::answer_challenge(&latest_challenge, &latest_mobile, None, None); - assert!(protocol.verify_response(&latest_response).await.unwrap()); - assert_eq!(protocol.state().await, PairingState::Connected); - } - - #[tokio::test] - async fn expired_pairing_challenge_is_rejected_and_consumed() { - let device = DeviceIdentity { - device_id: "test-desktop-id".into(), - device_name: "TestDesktop".into(), - mac_address: "AA:BB:CC:DD:EE:FF".into(), - }; - let mobile_device = DeviceIdentity { - device_id: "test-mobile-id".into(), - device_name: "TestMobile".into(), - mac_address: "11:22:33:44:55:66".into(), - }; - let mut protocol = PairingProtocol::new(device); - protocol.initiate("wss://relay.example.com").await.unwrap(); - let mobile_keypair = KeyPair::generate(); - let challenge = protocol - .on_peer_joined(&mobile_keypair.public_key_base64()) - .await - .unwrap(); - protocol.challenge_issued_at = - Some(chrono::Utc::now().timestamp() - PAIRING_CHALLENGE_TTL_SECS - 1); - let response = PairingProtocol::answer_challenge(&challenge, &mobile_device, None, None); - - assert!(!protocol.verify_response(&response).await.unwrap()); - assert!(protocol.verify_response(&response).await.is_err()); - } - - #[test] - fn test_bot_pairing_code() { - let code = PairingProtocol::generate_bot_pairing_code(); - assert_eq!(code.len(), 6); - assert!(code.chars().all(|c| c.is_ascii_digit())); - } +pub fn generate_bot_pairing_code() -> String { + let code: u32 = rand::thread_rng().gen_range(100_000..1_000_000); + format!("{code:06}") } diff --git a/src/crates/services/services-integrations/src/remote_connect/qr_generator.rs b/src/crates/services/services-integrations/src/remote_connect/qr_generator.rs index 2fb4c7114d..3cfdad61f4 100644 --- a/src/crates/services/services-integrations/src/remote_connect/qr_generator.rs +++ b/src/crates/services/services-integrations/src/remote_connect/qr_generator.rs @@ -4,48 +4,24 @@ use anyhow::{anyhow, Result}; use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; use qrcode::QrCode; -use super::pairing::QrPayload; - pub struct QrGenerator; impl QrGenerator { - /// Build the URL that the QR code points to. - /// `web_app_url` = where the mobile web app is hosted. - /// `payload.url` = the relay server that the mobile WebSocket should connect to. - /// When `account_username` is set, the URL requests account password pairing - /// (`auth=account&user=...`) so mobile can prefill the logged-in username. - pub fn build_url( - payload: &QrPayload, - web_app_url: &str, - language: &str, - account_username: Option<&str>, - ) -> String { - let relay_ws = payload - .url - .replace("https://", "wss://") - .replace("http://", "ws://"); - let mut url = format!( - "{web_app}/#/pair?room={room}&did={did}&pk={pk}&dn={dn}&relay={relay}&v={v}&lang={lang}", - web_app = web_app_url.trim_end_matches('/'), - room = urlencoding::encode(&payload.room_id), - did = urlencoding::encode(&payload.device_id), - pk = urlencoding::encode(&payload.public_key), - dn = urlencoding::encode(&payload.device_name), - relay = urlencoding::encode(&relay_ws), - v = payload.version, - lang = urlencoding::encode(language), - ); - // `Some(_)` enables account-password pairing even when username prefill - // is unavailable (e.g. restored session without a credential hint). - if let Some(username) = account_username { - url.push_str("&auth=account"); - let trimmed = username.trim(); - if !trimmed.is_empty() { - url.push_str("&user="); - url.push_str(&urlencoding::encode(trimmed)); - } + /// Account-device invitations carry only a target id. Identity and public + /// keys are resolved through the authenticated same-account directory. + pub fn build_device_url(web_app_url: &str, device_id: &str) -> Result { + if device_id.is_empty() + || device_id.len() > 128 + || !device_id + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')) + { + return Err(anyhow!("Invalid account device id")); } - url + let mut url = super::account::validate_relay_base_url(web_app_url)?; + url.set_path(&format!("{}/", url.path().trim_end_matches('/'))); + url.set_fragment(Some(&format!("/pair?did={device_id}"))); + Ok(url.to_string()) } /// Generate a QR code as a base64-encoded PNG from a pre-built URL. @@ -79,49 +55,19 @@ impl QrGenerator { } #[cfg(test)] -mod tests { - use super::*; - use crate::remote_connect::pairing::QrPayload; - - #[test] - fn build_url_includes_language_parameter() { - let payload = QrPayload { - room_id: "room_123".to_string(), - url: "https://relay.example.com".to_string(), - device_id: "device_123".to_string(), - device_name: "OpenBitFun Desktop".to_string(), - public_key: "public_key_value".to_string(), - version: 1, - }; - - let url = QrGenerator::build_url(&payload, "https://mobile.example.com", "en-US", None); - assert!(url.contains("lang=en-US")); - assert!(!url.contains("auth=account")); - } - +mod account_device_tests { + use super::QrGenerator; #[test] - fn build_url_includes_account_auth_when_username_provided() { - let payload = QrPayload { - room_id: "room_123".to_string(), - url: "https://relay.example.com".to_string(), - device_id: "device_123".to_string(), - device_name: "OpenBitFun Desktop".to_string(), - public_key: "public_key_value".to_string(), - version: 1, - }; - - let url = QrGenerator::build_url( - &payload, - "https://mobile.example.com", - "zh-CN", - Some("alice"), + fn invitation_uses_only_the_authenticated_device_target() { + assert_eq!( + QrGenerator::build_device_url("https://remote.openbitfun.com/v/1.0.0", "host-1") + .unwrap(), + "https://remote.openbitfun.com/v/1.0.0/#/pair?did=host-1" ); - assert!(url.contains("auth=account")); - assert!(url.contains("user=alice")); - - let auth_only = - QrGenerator::build_url(&payload, "https://mobile.example.com", "zh-CN", Some("")); - assert!(auth_only.contains("auth=account")); - assert!(!auth_only.contains("user=")); + for id in ["", "host&relay=evil", "../host", "host/other"] { + assert!( + QrGenerator::build_device_url("https://remote.openbitfun.com/v/1.0.0", id).is_err() + ); + } } } diff --git a/src/crates/services/services-integrations/src/remote_connect/relay_client.rs b/src/crates/services/services-integrations/src/remote_connect/relay_client.rs index 7f14ad0d2b..9c439a34f2 100644 --- a/src/crates/services/services-integrations/src/remote_connect/relay_client.rs +++ b/src/crates/services/services-integrations/src/remote_connect/relay_client.rs @@ -1,19 +1,15 @@ //! WebSocket client for connecting to the Relay Server. //! -//! Manages the desktop-side WebSocket connection. In the new architecture the -//! relay bridges HTTP requests from mobile to the desktop via WebSocket. -//! The desktop receives `PairRequest` and `Command` messages (with correlation -//! IDs) and responds with `RelayResponse`. -//! -//! Supports automatic reconnect with exponential backoff and room re-creation -//! so that in-flight QR codes remain valid. +//! Account devices authenticate over WebSocket and receive presence and opaque +//! device messages. Payload submission uses bounded HTTP; reconnect repeats +//! account authentication before sending further control messages. use anyhow::{anyhow, Result}; use futures::{SinkExt, StreamExt}; use log::{debug, error, info, warn}; use serde::{Deserialize, Serialize}; use std::sync::{Arc, Mutex}; -use tokio::sync::{mpsc, oneshot, RwLock}; +use tokio::sync::{mpsc, oneshot}; use tokio_tungstenite::tungstenite::Message; #[cfg(windows)] use tokio_tungstenite::{tungstenite::client::IntoClientRequest, Connector}; @@ -45,21 +41,8 @@ pub const RELAY_INBOUND_IDLE_TIMEOUT: std::time::Duration = std::time::Duration: #[serde(tag = "type", rename_all = "snake_case")] pub enum RelayMessage { // ── Outbound (desktop → relay) ────────────────────────────────── - CreateRoom { - room_id: Option, - device_id: String, - device_type: String, - public_key: String, - }, - /// Respond to a bridged HTTP request identified by `correlation_id`. - RelayResponse { - correlation_id: String, - encrypted_data: String, - nonce: String, - }, Heartbeat, - /// Account-authenticated connect (parallel to CreateRoom for device - /// routing). Validates the token and registers this device. + /// Authenticate the socket and register this account device. AuthConnect { token: String, device_name: String, @@ -74,22 +57,6 @@ pub enum RelayMessage { }, // ── Inbound (relay → desktop) ─────────────────────────────────── - RoomCreated { - room_id: String, - }, - /// Mobile pairing request forwarded by the relay. - PairRequest { - correlation_id: String, - public_key: String, - device_id: String, - device_name: String, - }, - /// Encrypted command from mobile forwarded by the relay. - Command { - correlation_id: String, - encrypted_data: String, - nonce: String, - }, HeartbeatAck, Error { message: String, @@ -125,22 +92,6 @@ pub struct DevicePresenceEntry { #[derive(Debug, Clone)] pub enum RelayEvent { Connected, - RoomCreated { - room_id: String, - }, - /// Mobile wants to pair. - PairRequest { - correlation_id: String, - public_key: String, - device_id: String, - device_name: String, - }, - /// Mobile sent an encrypted command. - CommandReceived { - correlation_id: String, - encrypted_data: String, - nonce: String, - }, Reconnected, Disconnected, Error { @@ -178,9 +129,6 @@ pub enum ConnectionState { #[derive(Debug, Clone, Default)] struct ReconnectCtx { ws_url: String, - device_id: String, - room_id: String, - public_key: String, /// Account token for device-routing re-auth after reconnect. token: String, /// Device name for re-auth after reconnect. @@ -207,7 +155,6 @@ const RELAY_WRITE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs( pub struct RelayClient { lifecycle: ConnectionOwner, event_tx: mpsc::UnboundedSender, - room_id: Arc>>, } impl RelayClient { @@ -222,7 +169,6 @@ impl RelayClient { reconnect_ctx: None, })), event_tx, - room_id: Arc::new(RwLock::new(None)), }; (client, event_rx) } @@ -234,7 +180,6 @@ impl RelayClient { pub async fn connect(&self, ws_url: &str) -> Result<()> { let (ready_tx, ready_rx) = oneshot::channel(); let generation = { - let mut room_id = self.room_id.write().await; let mut owner = self.lifecycle.lock().unwrap(); if let Some(task) = owner.task.take() { task.abort(); @@ -246,11 +191,9 @@ impl RelayClient { ws_url: ws_url.to_string(), ..Default::default() }); - *room_id = None; let generation = owner.generation; owner.task = Some(tokio::spawn(Self::run_connection( self.lifecycle.clone(), - self.room_id.clone(), self.event_tx.clone(), generation, ws_url.to_string(), @@ -269,7 +212,6 @@ impl RelayClient { async fn run_connection( lifecycle: ConnectionOwner, - room_id: Arc>>, event_tx: mpsc::UnboundedSender, generation: u64, ws_url: String, @@ -308,7 +250,7 @@ impl RelayClient { let _ = event_tx.send(event); } info!("Relay transport connected"); - Self::run_socket(socket, cmd_rx, &lifecycle, &room_id, &event_tx, generation).await; + Self::run_socket(socket, cmd_rx, &lifecycle, &event_tx, generation).await; { let mut owner = lifecycle.lock().unwrap(); if owner.generation != generation { @@ -345,18 +287,6 @@ impl RelayClient { async fn reconnect(ctx: &ReconnectCtx) -> Result { let mut socket = dial(&ctx.ws_url).await?; - if !ctx.room_id.is_empty() { - write_relay_message( - &mut socket, - &RelayMessage::CreateRoom { - room_id: Some(ctx.room_id.clone()), - device_id: ctx.device_id.clone(), - device_type: "desktop".to_string(), - public_key: ctx.public_key.clone(), - }, - ) - .await?; - } if !ctx.token.is_empty() { write_relay_message( &mut socket, @@ -375,7 +305,6 @@ impl RelayClient { socket: WsStream, mut commands: mpsc::Receiver, lifecycle: &ConnectionOwner, - room_id: &Arc>>, event_tx: &mpsc::UnboundedSender, generation: u64, ) { @@ -386,9 +315,7 @@ impl RelayClient { loop { match await_relay_inbound(reader.next()).await { Ok(Some(Ok(Message::Text(text)))) => match serde_json::from_str(&text) { - Ok(msg) => { - Self::dispatch(msg, event_tx, room_id, lifecycle, generation).await - } + Ok(msg) => Self::dispatch(msg, event_tx, lifecycle, generation).await, Err(error) => warn!("Unparseable relay message: {error}"), }, Ok(Some(Ok(Message::Close(_)))) | Ok(None) => break, @@ -422,50 +349,14 @@ impl RelayClient { async fn dispatch( msg: RelayMessage, event_tx: &mpsc::UnboundedSender, - room_id_store: &Arc>>, lifecycle: &ConnectionOwner, generation: u64, ) { - let mut room_id_store = room_id_store.write().await; let mut owner = lifecycle.lock().unwrap(); if owner.generation != generation { return; } match msg { - RelayMessage::RoomCreated { room_id } => { - debug!("Room created/restored: {room_id}"); - *room_id_store = Some(room_id.clone()); - if let Some(ctx) = owner.reconnect_ctx.as_mut() { - ctx.room_id = room_id.clone(); - } - let _ = event_tx.send(RelayEvent::RoomCreated { room_id }); - } - RelayMessage::PairRequest { - correlation_id, - public_key, - device_id, - device_name, - } => { - info!("PairRequest from {device_id}"); - let _ = event_tx.send(RelayEvent::PairRequest { - correlation_id, - public_key, - device_id, - device_name, - }); - } - RelayMessage::Command { - correlation_id, - encrypted_data, - nonce, - } => { - debug!("Command received, corr={correlation_id}"); - let _ = event_tx.send(RelayEvent::CommandReceived { - correlation_id, - encrypted_data, - nonce, - }); - } RelayMessage::HeartbeatAck => { debug!("Heartbeat acknowledged"); } @@ -524,48 +415,6 @@ impl RelayClient { }) } - pub async fn create_room( - &self, - device_id: &str, - public_key: &str, - room_id: Option<&str>, - ) -> Result<()> { - let mut owner = self.lifecycle.lock().unwrap(); - Self::enqueue( - &owner, - RelayMessage::CreateRoom { - room_id: room_id.map(str::to_string), - device_id: device_id.to_string(), - device_type: "desktop".to_string(), - public_key: public_key.to_string(), - }, - )?; - if let Some(ctx) = owner.reconnect_ctx.as_mut() { - ctx.device_id = device_id.to_string(); - ctx.room_id = room_id.unwrap_or_default().to_string(); - ctx.public_key = public_key.to_string(); - } - Ok(()) - } - - /// Send a relay response back to the relay server for a bridged HTTP request. - pub async fn send_relay_response( - &self, - correlation_id: &str, - encrypted_data: &str, - nonce: &str, - ) -> Result<()> { - self.send(RelayMessage::RelayResponse { - correlation_id: correlation_id.to_string(), - encrypted_data: encrypted_data.to_string(), - nonce: nonce.to_string(), - }) - .await - } - - /// Authenticate this connection with an account token (parallel to - /// `create_room` for the device-routing pathway). The relay validates the - /// token and registers the device; success arrives as `RelayEvent::AuthOk`. pub async fn connect_authenticated(&self, token: &str, device_name: &str) -> Result<()> { let mut owner = self.lifecycle.lock().unwrap(); // Only desktops hold a relay WebSocket — phones and watches talk HTTP — @@ -585,8 +434,8 @@ impl RelayClient { Ok(()) } - /// Send an encrypted payload to another device in the same account. The - /// relay routes by `target_device_id` without decrypting. + /// Submit device payloads over memory-admitted HTTP. The WebSocket remains + /// the receiving/control channel and does not accept attachment-sized input. pub async fn send_device_message( &self, target_device_id: &str, @@ -594,24 +443,43 @@ impl RelayClient { encrypted_data: &str, nonce: &str, ) -> Result<()> { - self.send(RelayMessage::DeviceMessage { - target_device_id: target_device_id.to_string(), - correlation_id: correlation_id.to_string(), - encrypted_data: encrypted_data.to_string(), - nonce: nonce.to_string(), - }) - .await + let context = self + .lifecycle + .lock() + .unwrap() + .reconnect_ctx + .clone() + .filter(|context| !context.token.is_empty()) + .ok_or_else(|| anyhow!("Authenticated relay connection is unavailable"))?; + let endpoint = device_message_endpoint(&context.ws_url, target_device_id)?; + let response = super::relay_http::relay_http_client() + .post(endpoint) + .bearer_auth(&context.token) + .timeout(RELAY_WRITE_TIMEOUT) + .json(&RelayMessage::DeviceMessage { + target_device_id: target_device_id.to_string(), + correlation_id: correlation_id.to_string(), + encrypted_data: encrypted_data.to_string(), + nonce: nonce.to_string(), + }) + .send() + .await?; + if response.status() != reqwest::StatusCode::NO_CONTENT { + return Err(anyhow!( + "Relay device message rejected (HTTP {})", + response.status() + )); + } + Ok(()) } pub async fn disconnect(&self) { let task = { - let mut room_id = self.room_id.write().await; let mut owner = self.lifecycle.lock().unwrap(); owner.generation += 1; owner.state = ConnectionState::Disconnected; owner.cmd_tx = None; owner.reconnect_ctx = None; - *room_id = None; let task = owner.task.take(); if let Some(task) = &task { task.abort(); @@ -626,10 +494,40 @@ impl RelayClient { } info!("Relay client disconnected"); } +} - pub fn room_id(&self) -> &Arc>> { - &self.room_id +fn device_message_endpoint(ws_url: &str, target_device_id: &str) -> Result { + if target_device_id.is_empty() + || target_device_id.len() > 128 + || !target_device_id + .bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.')) + || matches!(target_device_id, "." | "..") + { + return Err(anyhow!("Invalid relay target device id")); } + let mut url = reqwest::Url::parse(ws_url)?; + let scheme = match url.scheme() { + "wss" => "https", + "ws" => "http", + _ => return Err(anyhow!("Invalid relay WebSocket scheme")), + }; + url.set_scheme(scheme) + .map_err(|_| anyhow!("Invalid relay HTTP scheme"))?; + if !url.username().is_empty() + || url.password().is_some() + || url.query().is_some() + || url.fragment().is_some() + { + return Err(anyhow!("Invalid relay WebSocket endpoint")); + } + let base = url + .path() + .strip_suffix("/ws") + .ok_or_else(|| anyhow!("Invalid relay WebSocket path"))?; + let path = format!("{base}/api/devices/{target_device_id}/messages"); + url.set_path(&path); + Ok(url) } impl Drop for RelayClient { @@ -652,7 +550,7 @@ async fn next_relay_outbound( let received = std::pin::pin!(commands.recv()); let tick = std::pin::pin!(heartbeat.tick()); // select polls its first future first. A continuously ready command queue - // must not starve the keepalive that preserves the room and inbound health. + // must not starve the keepalive that preserves device presence and inbound health. match futures::future::select(tick, received).await { futures::future::Either::Left(_) => Some(RelayMessage::Heartbeat), futures::future::Either::Right((command, _)) => command, @@ -750,6 +648,41 @@ where #[cfg(test)] mod tests { + #[test] + fn device_http_endpoint_preserves_version_prefix_and_rejects_path_injection() { + assert_eq!( + super::device_message_endpoint("wss://remote.example/v/1.0.0/ws", "desktop-1") + .unwrap() + .as_str(), + "https://remote.example/v/1.0.0/api/devices/desktop-1/messages" + ); + assert_eq!( + super::device_message_endpoint("ws://127.0.0.1:3000/ws", "desktop") + .unwrap() + .as_str(), + "http://127.0.0.1:3000/api/devices/desktop/messages" + ); + for id in [ + "", + ".", + "..", + "../other", + "device?x=1", + "%2f", + "device#fragment", + ] { + assert!(super::device_message_endpoint("wss://remote.example/ws", id).is_err()); + } + for url in [ + "https://remote.example/ws", + "wss://user@remote.example/ws", + "wss://remote.example/ws?token=x", + "wss://remote.example/wrong", + ] { + assert!(super::device_message_endpoint(url, "desktop").is_err()); + } + } + use super::*; async fn connected_fixture() -> ( @@ -769,6 +702,69 @@ mod tests { (client, events, listener, socket) } + #[tokio::test] + async fn device_payload_uses_authenticated_http_and_reports_rejection() { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let (client, _events, listener, _socket) = connected_fixture().await; + client + .connect_authenticated("fixture-token", "Desktop") + .await + .unwrap(); + let server = tokio::spawn(async move { + for status in ["204 No Content", "503 Service Unavailable"] { + let (mut stream, _) = listener.accept().await.unwrap(); + let mut bytes = Vec::new(); + let (header_end, length) = loop { + let mut buffer = [0u8; 8192]; + assert!(bytes.len() < 1024 * 1024); + let count = stream.read(&mut buffer).await.unwrap(); + assert!(count > 0); + bytes.extend_from_slice(&buffer[..count]); + if let Some(end) = bytes.windows(4).position(|window| window == b"\r\n\r\n") { + let header = String::from_utf8_lossy(&bytes[..end]).to_lowercase(); + assert!( + header.starts_with("post /api/devices/controller/messages http/1.1") + ); + assert!(header.contains("authorization: bearer fixture-token")); + let length: usize = header + .lines() + .find_map(|line| line.strip_prefix("content-length:")) + .unwrap() + .trim() + .parse() + .unwrap(); + break (end + 4, length); + } + }; + while bytes.len() < header_end + length { + let mut buffer = [0u8; 8192]; + let count = stream.read(&mut buffer).await.unwrap(); + assert!(count > 0); + bytes.extend_from_slice(&buffer[..count]); + } + let body: serde_json::Value = + serde_json::from_slice(&bytes[header_end..header_end + length]).unwrap(); + assert_eq!(body["encrypted_data"].as_str().unwrap().len(), 256 * 1024); + assert_eq!(body["correlation_id"], "correlation"); + let reply = + format!("HTTP/1.1 {status}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"); + stream.write_all(reply.as_bytes()).await.unwrap(); + } + }); + let payload = "a".repeat(256 * 1024); + client + .send_device_message("controller", "correlation", &payload, "nonce") + .await + .unwrap(); + let error = client + .send_device_message("controller", "correlation", &payload, "nonce") + .await + .unwrap_err(); + assert!(error.to_string().contains("503")); + server.await.unwrap(); + client.disconnect().await; + } + #[tokio::test] async fn failed_initial_dial_returns_to_disconnected() { let (client, _) = RelayClient::new(); @@ -881,30 +877,13 @@ mod tests { } #[tokio::test] - async fn reconnect_restores_server_assigned_room_and_account_before_new_commands() { - let (client, mut events, listener, mut socket) = connected_fixture().await; - client - .create_room("device", "public-key", None) - .await - .unwrap(); + async fn reconnect_authenticates_account_before_new_commands() { + let (client, _events, listener, mut socket) = connected_fixture().await; client .connect_authenticated("test-token", "test-device") .await .unwrap(); - for _ in 0..2 { - socket.next().await.unwrap().unwrap(); - } - socket - .send(Message::Text( - serde_json::to_string(&RelayMessage::RoomCreated { - room_id: "assigned-room".into(), - }) - .unwrap() - .into(), - )) - .await - .unwrap(); - while !matches!(events.recv().await, Some(RelayEvent::RoomCreated { .. })) {} + socket.next().await.unwrap().unwrap(); socket.close(None).await.unwrap(); let mut replacement = tokio::time::timeout(std::time::Duration::from_secs(5), async { tokio_tungstenite::accept_async(listener.accept().await.unwrap().0) @@ -913,20 +892,6 @@ mod tests { }) .await .unwrap(); - let room: RelayMessage = serde_json::from_str( - &replacement - .next() - .await - .unwrap() - .unwrap() - .into_text() - .unwrap(), - ) - .unwrap(); - assert!( - matches!(room, RelayMessage::CreateRoom { room_id: Some(id), device_id, public_key, .. } - if id == "assigned-room" && device_id == "device" && public_key == "public-key") - ); let auth: RelayMessage = serde_json::from_str( &replacement .next() @@ -953,7 +918,6 @@ mod tests { owner.state = ConnectionState::Connected; owner.cmd_tx = Some(tx); owner.reconnect_ctx = Some(ReconnectCtx { - room_id: "accepted-room".into(), token: "accepted-token".into(), ..Default::default() }); @@ -971,17 +935,12 @@ mod tests { error.to_string(), "Relay send queue is full; request was not queued" ); - assert!(client - .create_room("device", "key", Some("rejected-room")) - .await - .is_err()); assert!(client .connect_authenticated("rejected-token", "device") .await .is_err()); let owner = client.lifecycle.lock().unwrap(); let ctx = owner.reconnect_ctx.as_ref().unwrap(); - assert_eq!(ctx.room_id, "accepted-room"); assert_eq!(ctx.token, "accepted-token"); } @@ -992,10 +951,10 @@ mod tests { let mut heartbeat = tokio::time::interval_at(tokio::time::Instant::now() + period, period); heartbeat.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); for id in ["first", "second"] { - tx.try_send(RelayMessage::RelayResponse { - correlation_id: id.into(), - encrypted_data: "test-payload".into(), - nonce: "test-nonce".into(), + tx.try_send(RelayMessage::AuthConnect { + token: id.into(), + device_name: "test-device".into(), + device_kind: "desktop".into(), }) .unwrap(); } @@ -1010,7 +969,7 @@ mod tests { for expected in ["first", "second"] { assert!(matches!( next_relay_outbound(&mut commands, &mut heartbeat).await, - Some(RelayMessage::RelayResponse { correlation_id, .. }) if correlation_id == expected + Some(RelayMessage::AuthConnect { token, .. }) if token == expected )); } drop(tx); diff --git a/src/crates/services/services-integrations/src/remote_connect/session_store.rs b/src/crates/services/services-integrations/src/remote_connect/session_store.rs index c76ca587e7..145f203bc5 100644 --- a/src/crates/services/services-integrations/src/remote_connect/session_store.rs +++ b/src/crates/services/services-integrations/src/remote_connect/session_store.rs @@ -1,14 +1,14 @@ //! Machine-bound persistent session store. //! -//! Saves the full `AccountSession` (token + master_key + user_id) and relay +//! Saves the device `AccountSession` (token + private key + user_id) and relay //! URL to disk, encrypted with a key that combines machine identity and a //! random per-install secret. Secret files are owner-only on Unix and replaced //! through a private temporary file. This lets Desktop / CLI restart without -//! requiring a fresh password entry while keeping copied session ciphertext +//! requiring a fresh GitHub sign-in while keeping copied session ciphertext //! unusable without the separate install key. //! -//! File location: `/account_session.enc` when configured, -//! otherwise `~/.openbitfun/account_session.enc`. +//! File location: `/relay-v1.0.0/account_session.enc` when configured, +//! otherwise `~/.openbitfun/relay-v1.0.0/account_session.enc`. //! Format: base64(nonce || ciphertext) where the plaintext is a JSON //! payload `{ token, user_id, master_key_b64, relay_url }`. @@ -36,10 +36,12 @@ fn session_store_directory() -> Result { .read() .unwrap_or_else(|poisoned| poisoned.into_inner()); if let Some(path) = override_path.as_ref() { - return Ok(path.clone()); + return Ok(path.join("relay-v1.0.0")); } drop(override_path); - super::product_home_dir().ok_or_else(|| anyhow!("cannot determine OpenBitFun home directory")) + super::product_home_dir() + .map(|path| path.join("relay-v1.0.0")) + .ok_or_else(|| anyhow!("cannot determine OpenBitFun home directory")) } /// Resolve the persistent session file path. @@ -49,6 +51,15 @@ fn session_file_path() -> Result { // ── Public API ────────────────────────────────────────────────────────── +pub fn device_secret(relay_url: &str, user_id: &str, device_id: &str) -> Result<[u8; 32]> { + crate::remote_persistence::load_or_create_device_secret( + &session_store_directory()?, + relay_url, + user_id, + device_id, + ) +} + /// Persist the session (token, master_key, user_id, relay_url) to disk, /// encrypted with the machine-bound key. pub fn save_session( diff --git a/src/crates/services/services-integrations/src/remote_connect/sync_state.rs b/src/crates/services/services-integrations/src/remote_connect/sync_state.rs deleted file mode 100644 index 5e79cce555..0000000000 --- a/src/crates/services/services-integrations/src/remote_connect/sync_state.rs +++ /dev/null @@ -1,151 +0,0 @@ -//! Local account sync cursors and upload content hashes. -//! -//! Persists per-user state under `/account_sync/` (normally -//! `~/.openbitfun/account_sync/`) so incremental -//! `?since=` pulls and upload dedupe survive app restarts. Not secret — -//! hashes are of plaintext session bundles; cursors are relay version ints. -//! -//! Session sync state (`.json`) and the settings sync cursor -//! (`.settings.json`) live in separate files on purpose: the session -//! backup loop and the settings sync engine are independent writers, so a -//! shared read-modify-write file could drop one writer's update. - -use std::path::PathBuf; - -use anyhow::{anyhow, Result}; -use sha2::{Digest, Sha256}; - -/// On-disk sync progress for one account. -pub use crate::remote_persistence::AccountSyncStateRecord as AccountSyncState; - -/// Settings sync progress for one account: the cloud settings blob version -/// this device last uploaded or applied, plus the content hash of that blob. -/// Lets the periodic pull skip unchanged blobs across restarts and the push -/// path skip unchanged content. -pub use crate::remote_persistence::SettingsCursorRecord as SettingsCursor; - -/// SHA-256 hex digest of session bundle plaintext (stable skip key). -pub fn content_hash(plaintext: &str) -> String { - let digest = Sha256::digest(plaintext.as_bytes()); - hex::encode(digest) -} - -fn sync_dir() -> Result { - let home = super::product_home_dir() - .ok_or_else(|| anyhow!("cannot determine OpenBitFun home directory"))?; - Ok(home.join("account_sync")) -} - -fn safe_user_id(user_id: &str) -> String { - user_id - .chars() - .map(|c| { - if c.is_ascii_alphanumeric() || c == '-' || c == '_' { - c - } else { - '_' - } - }) - .collect() -} - -fn sync_state_path(user_id: &str) -> Result { - Ok(sync_dir()?.join(format!("{}.json", safe_user_id(user_id)))) -} - -fn settings_cursor_path(user_id: &str) -> Result { - Ok(sync_dir()?.join(format!("{}.settings.json", safe_user_id(user_id)))) -} - -/// Load sync state for `user_id`, or a default empty state if missing/corrupt. -pub fn load(user_id: &str) -> AccountSyncState { - let path = match sync_state_path(user_id) { - Ok(p) => p, - Err(_) => return AccountSyncState::default(), - }; - crate::remote_persistence::read_account_sync_state(&path) - .ok() - .flatten() - .unwrap_or_default() -} - -/// Persist sync state for `user_id`. -pub fn save(user_id: &str, state: &AccountSyncState) -> Result<()> { - let path = sync_state_path(user_id)?; - crate::remote_persistence::write_account_sync_state(&path, state) -} - -/// Load the settings cursor for `user_id`, defaulting when missing/corrupt. -pub fn load_settings_cursor(user_id: &str) -> SettingsCursor { - let path = match settings_cursor_path(user_id) { - Ok(p) => p, - Err(_) => return SettingsCursor::default(), - }; - crate::remote_persistence::read_settings_cursor(&path) - .ok() - .flatten() - .unwrap_or_default() -} - -/// Persist the settings cursor for `user_id`. -pub fn save_settings_cursor(user_id: &str, cursor: &SettingsCursor) -> Result<()> { - let path = settings_cursor_path(user_id)?; - crate::remote_persistence::write_settings_cursor(&path, cursor) -} - -impl AccountSyncState { - pub fn uploaded_hash(&self, session_id: &str) -> Option<&str> { - self.uploaded_hashes.get(session_id).map(String::as_str) - } - - pub fn set_uploaded_hash(&mut self, session_id: &str, hash: String) { - self.uploaded_hashes.insert(session_id.to_string(), hash); - } - - pub fn clear_uploaded_hash(&mut self, session_id: &str) { - self.uploaded_hashes.remove(session_id); - } - - /// Advance pull cursor to the max version seen in this batch (if any). - pub fn advance_session_since(&mut self, versions: impl IntoIterator) { - let mut max_v = self.last_session_since; - for v in versions { - if v > max_v { - max_v = v; - } - } - self.last_session_since = max_v; - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn legacy_state_deserializes() { - let legacy = r#"{"last_session_since":7,"uploaded_hashes":{"s1":"abc"}}"#; - let state: AccountSyncState = serde_json::from_str(legacy).unwrap(); - assert_eq!(state.last_session_since, 7); - assert_eq!(state.uploaded_hash("s1"), Some("abc")); - } - - #[test] - fn settings_cursor_round_trips() { - let cursor = SettingsCursor { - version: 42, - hash: "deadbeef".to_string(), - }; - let raw = serde_json::to_string(&cursor).unwrap(); - let back: SettingsCursor = serde_json::from_str(&raw).unwrap(); - assert_eq!(back.version, 42); - assert_eq!(back.hash, "deadbeef"); - } - - #[test] - fn settings_cursor_defaults_when_missing_fields() { - let cursor: SettingsCursor = serde_json::from_str("{}").unwrap(); - assert_eq!(cursor.version, 0); - assert!(cursor.hash.is_empty()); - } -} diff --git a/src/crates/services/services-integrations/src/remote_persistence.rs b/src/crates/services/services-integrations/src/remote_persistence.rs index 3c7bda28c3..11b12dd1a6 100644 --- a/src/crates/services/services-integrations/src/remote_persistence.rs +++ b/src/crates/services/services-integrations/src/remote_persistence.rs @@ -13,10 +13,10 @@ use rand::RngCore; use serde::de::DeserializeOwned; use serde::{Deserialize, Deserializer, Serialize}; use sha2::{Digest, Sha256}; -use std::collections::{BTreeMap, HashMap}; +use std::collections::BTreeMap; use std::fs::OpenOptions; use std::io::Write; -use std::path::{Path, PathBuf}; +use std::path::Path; const NONCE_SIZE: usize = 12; @@ -50,22 +50,6 @@ pub struct AccountHintRecord { pub relay_url: String, } -#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] -pub struct AccountSyncStateRecord { - #[serde(default)] - pub last_session_since: i64, - #[serde(default)] - pub uploaded_hashes: HashMap, -} - -#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] -pub struct SettingsCursorRecord { - #[serde(default)] - pub version: i64, - #[serde(default)] - pub hash: String, -} - #[derive(Clone, PartialEq, Eq)] pub struct AccountSessionRecord { pub token: String, @@ -149,22 +133,6 @@ pub fn write_account_hint(path: &Path, value: &AccountHintRecord) -> Result<()> write_json_atomic(path, value, true) } -pub fn read_account_sync_state(path: &Path) -> Result> { - read_optional_json(path) -} - -pub fn write_account_sync_state(path: &Path, value: &AccountSyncStateRecord) -> Result<()> { - write_json_atomic(path, value, false) -} - -pub fn read_settings_cursor(path: &Path) -> Result> { - read_optional_json(path) -} - -pub fn write_settings_cursor(path: &Path, value: &SettingsCursorRecord) -> Result<()> { - write_json_atomic(path, value, false) -} - pub fn read_legacy_account_session( directory: &Path, binding: &MachineBinding, @@ -455,6 +423,8 @@ pub struct BotChatStateRecord { #[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct SavedBotConnectionRecord { + #[serde(default)] + pub account_user_id: String, pub bot_type: String, pub chat_id: String, pub config: BotConfigRecord, @@ -464,7 +434,6 @@ pub struct SavedBotConnectionRecord { #[derive(Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct RemoteConnectFormStateRecord { - pub custom_server_url: String, pub telegram_bot_token: String, pub feishu_app_id: String, pub feishu_app_secret: String, @@ -920,6 +889,65 @@ pub fn write_weixin_sync_buffer(path: &Path, value: &str) -> Result<()> { write_atomic(path, value.as_bytes(), true) } +/// Stable per-account, per-relay device key. Candidate logins must not rotate a +/// public key still used by the active session. A process lock serializes first +/// creation across Desktop and CLI, and malformed keys remain untouched. +pub fn load_or_create_device_secret( + directory: &Path, + relay_url: &str, + user_id: &str, + device_id: &str, +) -> Result<[u8; 32]> { + let mut scope = Sha256::new(); + for part in [relay_url.trim_end_matches('/'), user_id, device_id] { + scope.update((part.len() as u64).to_le_bytes()); + scope.update(part.as_bytes()); + } + let name = format!("{:x}", scope.finalize()); + let keys = directory.join("device-keys"); + std::fs::create_dir_all(&keys).context("create device key directory")?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&keys, std::fs::Permissions::from_mode(0o700))?; + } + let mut options = OpenOptions::new(); + options.read(true).write(true).create(true).truncate(false); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let lock = options.open(keys.join(format!("{name}.lock")))?; + fs2::FileExt::lock_exclusive(&lock).context("lock device key")?; + let path = keys.join(format!("{name}.key")); + match std::fs::read(&path) { + Ok(bytes) => bytes + .try_into() + .map_err(|_| anyhow!("stored device key has an invalid length")), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + // Preserve the key of an already authenticated install on first migration. + let previous = read_current_account_session(directory, &MachineBinding::current())?; + let secret = previous + .filter(|session| { + session.user_id == user_id + && session.relay_url.trim_end_matches('/') + == relay_url.trim_end_matches('/') + && session.device_id.as_deref() == Some(device_id) + }) + .map(|session| session.master_key) + .unwrap_or_else(|| { + let mut secret = [0; 32]; + OsRng.fill_bytes(&mut secret); + secret + }); + write_private_bytes(&path, &secret)?; + Ok(secret) + } + Err(error) => Err(error).context("read device key"), + } +} + pub fn write_private_bytes(path: &Path, value: &[u8]) -> Result<()> { write_atomic(path, value, true) } @@ -1055,33 +1083,10 @@ pub fn is_safe_weixin_account_id(value: &str) -> bool { .all(|character| character.is_ascii_alphanumeric() || matches!(character, '-' | '_')) } -pub fn account_sync_paths(directory: &Path) -> Result> { - let entries = match std::fs::read_dir(directory) { - Ok(entries) => entries, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), - Err(error) => return Err(error).context("read account sync directory"), - }; - let mut paths = Vec::new(); - for entry in entries { - let entry = entry.context("read account sync entry")?; - let file_type = entry.file_type().context("read account sync entry type")?; - if file_type.is_symlink() || !file_type.is_file() { - bail!("account sync directory contains a non-regular entry"); - } - let path = entry.path(); - if path.extension().and_then(|value| value.to_str()) == Some("json") - && !path.to_string_lossy().ends_with(".tmp") - { - paths.push(path); - } - } - paths.sort(); - Ok(paths) -} - #[cfg(test)] mod tests { use super::*; + use std::path::PathBuf; #[test] fn legacy_v2_account_session_reencrypts_for_the_current_owner() { @@ -1253,3 +1258,47 @@ mod tests { .expect("test temporary directory") } } + +#[cfg(test)] +mod device_secret_tests { + use super::*; + + #[test] + fn device_keys_survive_relogin_and_are_scoped_to_account_and_endpoint() { + let dir = tempfile::tempdir().unwrap(); + let key = load_or_create_device_secret(dir.path(), "http://127.0.0.1:9700", "1", "device") + .unwrap(); + assert_eq!( + key, + load_or_create_device_secret(dir.path(), "http://127.0.0.1:9700/", "1", "device") + .unwrap() + ); + assert_ne!( + key, + load_or_create_device_secret(dir.path(), "http://127.0.0.1:9700", "2", "device") + .unwrap() + ); + assert_ne!( + key, + load_or_create_device_secret( + dir.path(), + "https://remote.openbitfun.com/v/1.0.0", + "1", + "device" + ) + .unwrap() + ); + std::thread::scope(|scope| { + for _ in 0..8 { + let path = dir.path(); + scope.spawn(move || { + assert_eq!( + key, + load_or_create_device_secret(path, "http://127.0.0.1:9700", "1", "device") + .unwrap() + ) + }); + } + }); + } +} diff --git a/src/crates/services/services-integrations/src/remote_ssh/mod.rs b/src/crates/services/services-integrations/src/remote_ssh/mod.rs index bbe832b786..8281c36ef6 100644 --- a/src/crates/services/services-integrations/src/remote_ssh/mod.rs +++ b/src/crates/services/services-integrations/src/remote_ssh/mod.rs @@ -31,7 +31,6 @@ mod password_vault; #[cfg(feature = "remote-ssh-concrete")] mod port_forward; #[cfg(feature = "remote-ssh-concrete")] -pub mod relay_deploy; #[cfg(feature = "remote-ssh-concrete")] mod release_verify; #[cfg(feature = "remote-ssh-concrete")] diff --git a/src/crates/services/services-integrations/src/remote_ssh/relay_deploy.rs b/src/crates/services/services-integrations/src/remote_ssh/relay_deploy.rs deleted file mode 100644 index ac2a334cbe..0000000000 --- a/src/crates/services/services-integrations/src/remote_ssh/relay_deploy.rs +++ /dev/null @@ -1,2562 +0,0 @@ -//! One-click relay server self-deploy orchestration over an existing SSH connection. -//! -//! Drives the open-source relay-server deployment on a user-owned server: -//! -//! 1. `run_preflight` — probe OS/arch, Docker access mode, memory, port, existing installs. -//! 2. `start_task` — stage an interactive driver script (run inside a remote PTY so sudo -//! passwords work) that installs Docker when needed, then pulls and starts the signed -//! multi-platform image via `nohup` while `tail -f` streams its log. -//! 3. `poll_task` — detect completion via marker/pid for wizard state transitions. -//! 4. `cancel_task` — stop a running task when the wizard closes (kill process tree; -//! the image script restores any staged previous container). -//! 5. `import_account` — hand a locally-provisioned account to `relay-admin import-user`. -//! -//! Remote deploy state lives under the compiled product data directory. One-click deploy -//! prefers published images and builds current source when no usable image is available. -//! -//! Product / regression invariants (wizard + entry points): -//! `src/web-ui/src/features/relay-deploy/README.md`. Do not change clone destination, -//! password handoff, or “already deployed” semantics without updating that doc. -//! China mirror helpers live in `src/apps/relay-server/mirror.sh` and are embedded -//! here so detection/apply runs before GitHub/Docker downloads. - -use anyhow::{anyhow, Result}; -use serde::{Deserialize, Serialize}; -use std::time::Duration; - -use super::manager::SSHConnectionManager; -use super::product_paths::{ - product_data_path, product_data_relative_path, product_home_shell_path, -}; -#[cfg(test)] -use super::release_verify::RELEASE_PUBKEY; -use super::release_verify::{release_pubkey, release_tag_for_version, verify_minisign}; -use super::remote_git::shell_quote_posix; - -/// Default public relay port, matching `src/apps/relay-server/docker-compose.yml`. -pub const RELAY_PORT: u16 = 9700; - -/// Validate a user-selected relay listen port (1–65535; 0 → default). -pub fn normalize_relay_port(port: u16) -> Result { - if port == 0 { - return Ok(RELAY_PORT); - } - // u16 already caps at 65535; reject only the zero case above. - Ok(port) -} -/// Relay container name, matching docker-compose.yml. -const RELAY_CONTAINER_NAME: &str = "openbitfun-relay"; -/// Account DB path inside the relay container (RELAY_DB_PATH in docker-compose.yml). -const RELAY_CONTAINER_DB: &str = "/app/data/openbitfun_relay.db"; -/// Canonical repository URLs supplied to the shared regional-routing helper. -const REPO_GIT_URL: &str = "https://github.com/GCWing/OpenBitFun.git"; -/// Tarball fallback when git is unavailable or clone/fetch fails. -const REPO_TARBALL_URL: &str = - "https://github.com/GCWing/OpenBitFun/archive/refs/heads/main.tar.gz"; -/// Release asset bases. GitHub is authoritative; OpenBitFun mirrors the same -/// signed bytes and is used when GitHub metadata is unavailable. -const RELEASE_BASE: &str = "https://github.com/GCWing/OpenBitFun/releases"; -const OPENBITFUN_RELEASE_BASE: &str = "https://openbitfun.com/release"; -const RELAY_IMAGE_REPOSITORY: &str = "ghcr.io/gcwing/openbitfun-relay-server"; -const RELAY_IMAGE_DESCRIPTOR_ASSET: &str = "relay-image.json"; -/// Canonical China-mirror helper (shared with `src/apps/relay-server/deploy.sh`). -/// Embedded so Desktop orchestration can select Docker-install and image routes. -const RELAY_MIRROR_SH: &str = include_str!(concat!( - env!("CARGO_MANIFEST_DIR"), - "/../../../apps/relay-server/mirror.sh" -)); -/// Published-binary download + runtime deploy (shared with `deploy.sh`, so the -/// manual and one-click paths run the same code). -const RELAY_RELEASE_DOWNLOAD_SH: &str = include_str!(concat!( - env!("CARGO_MANIFEST_DIR"), - "/../../../apps/relay-server/release-download.sh" -)); -const RELAY_SOURCE_BUILD_SH: &str = include_str!(concat!( - env!("CARGO_MANIFEST_DIR"), - "/../../../apps/relay-server/source-build.sh" -)); -/// Line printed by task scripts on success; polled to detect completion. -const TASK_DONE_MARKER: &str = "RELAY_TASK_DONE"; -/// How long the seeded `preparing` flag may sit with no live driver process -/// before the task counts as dead. Covers PTY startup and the shell prompt; an -/// alive driver (an open sudo password prompt, say) is never bounded by this. -const PREPARE_GRACE_SECONDS: u64 = 90; - -fn deploy_state_relative_dir() -> String { - product_data_relative_path(&["relay-deploy"]) -} - -/// Long-running remote operations that run detached and are polled. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum RelayDeployTask { - InstallDocker, - Deploy, -} - -/// Signed release metadata for the immutable multi-platform Relay image. -#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] -struct RelayImageDescriptor { - schema_version: u8, - image: String, - tag: String, - version: String, - digest: String, - platforms: Vec, -} - -impl RelayDeployTask { - fn stem(self) -> &'static str { - match self { - Self::InstallDocker => "install-docker", - Self::Deploy => "deploy", - } - } -} - -/// Network route used by Docker installation and Relay image pulls. -/// -/// `Auto` keeps server-side detection as the default. The explicit variants -/// are a user-facing escape hatch for cloud IPs whose geolocation or outbound -/// routing does not reflect where the server is actually hosted. -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum RelayMirrorMode { - #[default] - Auto, - Cn, - Global, -} - -impl RelayMirrorMode { - fn as_str(self) -> &'static str { - match self { - Self::Auto => "auto", - Self::Cn => "cn", - Self::Global => "global", - } - } -} - -/// Fine-grained Docker access classification for the current SSH session. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum DockerAccessMode { - Ok, - GroupInactive, - SudoNopass, - SudoNeedsPassword, - BrokenDockerHome, - DaemonDown, - Missing, -} - -/// Result of the remote environment probe. -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct RelayPreflight { - /// `uname -s`, e.g. "Linux". - pub os: String, - /// `uname -m`, e.g. "x86_64" / "aarch64". - pub arch: String, - /// True for Linux x86_64/aarch64, the architectures deploy.sh supports. - pub arch_supported: bool, - pub docker_installed: bool, - /// `docker compose` (v2) or legacy `docker-compose` available (direct or via sudo). - pub compose_available: bool, - /// Legacy coarse daemon string: "ok" | "sudo" | "unreachable". - pub docker_daemon: String, - /// Structured access mode for the wizard / interactive driver. - pub docker_access_mode: DockerAccessMode, - pub active_has_docker_group: bool, - pub in_docker_group_file: bool, - pub docker_home_writable: bool, - pub tar_available: bool, - pub curl_available: bool, - /// Root or passwordless sudo. - pub sudo_available: bool, - /// `sudo` exists but `sudo -n` fails (password required). - pub sudo_needs_password: bool, - pub mem_total_mb: u64, - /// Free space under `$HOME` in MB (task scripts and logs). - pub home_free_mb: u64, - /// Free space on Docker's data root in MB (images and layers). - pub docker_free_mb: u64, - /// Selected listen port already bound by another process. - pub port_busy: bool, - /// Port that was probed (`port_busy` / selected-port health). - pub probed_port: u16, - /// Selected port is published by the existing `openbitfun-relay` container (or - /// answers `/health` as that relay). Used to distinguish "our relay" from - /// an unrelated occupant when the user changes the listen port. - pub port_owned_by_relay: bool, - /// An `openbitfun-relay` container already exists (any state). - pub container_exists: bool, - /// An `openbitfun-relay` container is currently running. - pub container_running: bool, - /// Host port published by the running relay (0 if unknown / not running). - pub existing_relay_port: u16, - /// Relay answers `/health` on the selected port and/or the existing - /// container port (independent of which port the user typed). - pub relay_healthy: bool, - pub home_dir: String, -} - -/// Result of staging an interactive driver script for a PTY session. -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct RelayTaskStart { - /// Absolute remote path of the interactive driver to run in a PTY. - pub script_path: String, -} - -/// Incremental poll result for a detached task. -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct RelayTaskPoll { - /// Byte offset to pass to the next poll. - pub cursor: u64, - /// Log output appended since the previous cursor. - pub output: String, - pub status: RelayTaskStatus, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] -#[serde(rename_all = "snake_case")] -pub enum RelayTaskStatus { - Running, - Succeeded, - Failed, -} - -/// Probe the target server. Never fails on individual checks: probe errors -/// surface as `false`/empty fields so the UI can render them. -pub async fn run_preflight( - manager: &SSHConnectionManager, - connection_id: &str, - port: u16, -) -> Result { - let port = normalize_relay_port(port)?; - let relay_port_path = product_home_shell_path(&["relay-deploy", "relay.port"]); - let script = format!( - r#" -PORT="{port}" -echo "probed_port=$PORT" -echo "os=$(uname -s 2>/dev/null)" -echo "arch=$(uname -m 2>/dev/null)" -echo "home=$HOME" -if command -v docker >/dev/null 2>&1; then echo "docker=1"; else echo "docker=0"; fi -COMPOSE=0 -if docker compose version >/dev/null 2>&1 || command -v docker-compose >/dev/null 2>&1; then COMPOSE=1; fi -if [ "$COMPOSE" = "0" ] && sudo -n docker compose version >/dev/null 2>&1; then COMPOSE=1; fi -echo "compose=$COMPOSE" -if docker info >/dev/null 2>&1; then echo "daemon=ok" -elif sudo -n docker info >/dev/null 2>&1; then echo "daemon=sudo" -elif command -v docker >/dev/null 2>&1 && (systemctl is-active docker >/dev/null 2>&1 || service docker status >/dev/null 2>&1); then echo "daemon=down" -else echo "daemon=unreachable"; fi -if command -v curl >/dev/null 2>&1; then echo "curl=1"; else echo "curl=0"; fi -if command -v tar >/dev/null 2>&1; then echo "tar=1"; else echo "tar=0"; fi -if [ "$(id -u)" = "0" ]; then echo "sudo=1"; elif sudo -n true >/dev/null 2>&1; then echo "sudo=1"; else echo "sudo=0"; fi -if [ "$(id -u)" != "0" ] && command -v sudo >/dev/null 2>&1 && ! sudo -n true >/dev/null 2>&1; then echo "sudo_needs_password=1"; else echo "sudo_needs_password=0"; fi -if id -nG 2>/dev/null | tr ' ' '\n' | grep -qx docker; then echo "active_docker_group=1"; else echo "active_docker_group=0"; fi -U=$(id -un 2>/dev/null || true) -if getent group docker 2>/dev/null | grep -qE "(^|:|,)${{U}}(,|$)"; then echo "in_docker_group_file=1"; else echo "in_docker_group_file=0"; fi -if [ ! -e "$HOME/.docker" ]; then echo "docker_home_writable=1" -elif [ -w "$HOME/.docker" ] && {{ [ ! -e "$HOME/.docker/buildx" ] || [ -w "$HOME/.docker/buildx" ]; }}; then echo "docker_home_writable=1" -else echo "docker_home_writable=0"; fi -echo "mem_kb=$(awk '/MemTotal/ {{print $2}}' /proc/meminfo 2>/dev/null || echo 0)" -# Free space where the work actually lands: the product data home holds task state and -# Docker's data root holds the pulled image and writable layers. -echo "home_free_kb=$(df -Pk "$HOME" 2>/dev/null | awk 'NR==2 {{print $4}}' || echo 0)" -DOCKER_ROOT=$(docker info -f '{{{{.DockerRootDir}}}}' 2>/dev/null \ - || sudo -n docker info -f '{{{{.DockerRootDir}}}}' 2>/dev/null || echo /var/lib/docker) -[ -d "$DOCKER_ROOT" ] || DOCKER_ROOT=/var -echo "docker_free_kb=$(df -Pk "$DOCKER_ROOT" 2>/dev/null | awk 'NR==2 {{print $4}}' || echo 0)" -if command -v ss >/dev/null 2>&1; then PORTS=$(ss -ltn 2>/dev/null); else PORTS=$(netstat -ltn 2>/dev/null); fi -if printf '%s\n' "$PORTS" | awk '{{print $4}}' | grep -q ":${{PORT}}$"; then echo "port_busy=1"; else echo "port_busy=0"; fi -# Prefer a docker CLI that can talk to the daemon (plain or passwordless sudo). -D=docker -if ! docker info >/dev/null 2>&1; then - if sudo -n docker info >/dev/null 2>&1; then D="sudo -n docker"; else D=""; fi -fi -CONTAINER=0 -RUNNING=0 -EXISTING_PORT=0 -if [ -n "$D" ]; then - if $D ps -a --format '{{{{.Names}}}}' 2>/dev/null | grep -qx openbitfun-relay; then CONTAINER=1; fi - if $D ps --format '{{{{.Names}}}}' 2>/dev/null | grep -qx openbitfun-relay; then RUNNING=1; fi - if [ "$CONTAINER" = "1" ]; then - # First published host port on the container (compose maps RELAY_PORT:RELAY_PORT). - EXISTING_PORT=$($D inspect -f '{{{{range $p, $conf := .NetworkSettings.Ports}}}}{{{{range $conf}}}}{{{{if .HostPort}}}}{{{{.HostPort}}}}{{{{end}}}}{{{{end}}}}{{{{end}}}}' openbitfun-relay 2>/dev/null | awk 'NF {{print $1; exit}}') - EXISTING_PORT=$(printf '%s' "$EXISTING_PORT" | tr -cd '0-9') - fi -fi -# Fallback: the last deploy recorded its selected port in the product data home. -if [ -z "$EXISTING_PORT" ] || [ "$EXISTING_PORT" = "0" ]; then - if [ -f "{relay_port_path}" ]; then - EXISTING_PORT=$(tr -cd '0-9' < "{relay_port_path}") - fi -fi -[ -n "$EXISTING_PORT" ] || EXISTING_PORT=0 -echo "container=$CONTAINER" -echo "container_running=$RUNNING" -echo "existing_port=$EXISTING_PORT" -HEALTHY=0 -SELECTED_HEALTHY=0 -if curl -fsS -m 3 "http://127.0.0.1:${{PORT}}/health" >/dev/null 2>&1; then - HEALTHY=1 - SELECTED_HEALTHY=1 -fi -if [ "$HEALTHY" = "0" ] && [ "$EXISTING_PORT" != "0" ] && [ "$EXISTING_PORT" != "$PORT" ]; then - if curl -fsS -m 3 "http://127.0.0.1:${{EXISTING_PORT}}/health" >/dev/null 2>&1; then HEALTHY=1; fi -fi -echo "healthy=$HEALTHY" -PORT_OWNED=0 -if [ "$SELECTED_HEALTHY" = "1" ]; then PORT_OWNED=1 -elif [ "$EXISTING_PORT" != "0" ] && [ "$EXISTING_PORT" = "$PORT" ] && [ "$RUNNING" = "1" ]; then PORT_OWNED=1 -fi -echo "port_owned=$PORT_OWNED" -"#, - port = port, - ); - let (stdout, _stderr, code) = exec_script(manager, connection_id, &script).await?; - if code != 0 { - return Err(anyhow!("preflight probe failed (exit {code})")); - } - Ok(parse_preflight(&stdout, port)) -} - -fn parse_preflight(out: &str, fallback_port: u16) -> RelayPreflight { - let get = |key: &str| -> String { - out.lines() - .find_map(|l| l.strip_prefix(key).and_then(|v| v.strip_prefix('='))) - .unwrap_or("") - .trim() - .to_string() - }; - let probed_port: u16 = get("probed_port").parse().unwrap_or(fallback_port); - let os = get("os"); - let arch = get("arch"); - let arch_supported = os == "Linux" - && (arch == "x86_64" || arch == "amd64" || arch == "aarch64" || arch == "arm64"); - let mem_kb: u64 = get("mem_kb").parse().unwrap_or(0); - let home_free_kb: u64 = get("home_free_kb").parse().unwrap_or(0); - let docker_free_kb: u64 = get("docker_free_kb").parse().unwrap_or(0); - let docker_installed = get("docker") == "1"; - let active_has_docker_group = get("active_docker_group") == "1"; - let in_docker_group_file = get("in_docker_group_file") == "1"; - let docker_home_writable = get("docker_home_writable") != "0"; - let sudo_available = get("sudo") == "1"; - let sudo_needs_password = get("sudo_needs_password") == "1"; - let daemon_raw = { - let d = get("daemon"); - if d.is_empty() { - "unreachable".into() - } else { - d - } - }; - let docker_access_mode = classify_docker_access( - docker_installed, - &daemon_raw, - active_has_docker_group, - in_docker_group_file, - docker_home_writable, - sudo_available, - sudo_needs_password, - ); - let docker_daemon = match docker_access_mode { - DockerAccessMode::Ok | DockerAccessMode::BrokenDockerHome => "ok".into(), - DockerAccessMode::SudoNopass - | DockerAccessMode::SudoNeedsPassword - | DockerAccessMode::GroupInactive => "sudo".into(), - DockerAccessMode::DaemonDown | DockerAccessMode::Missing => "unreachable".into(), - }; - RelayPreflight { - os, - arch, - arch_supported, - docker_installed, - compose_available: get("compose") == "1", - docker_daemon, - docker_access_mode, - active_has_docker_group, - in_docker_group_file, - docker_home_writable, - tar_available: get("tar") == "1", - curl_available: get("curl") == "1", - sudo_available, - sudo_needs_password, - mem_total_mb: mem_kb / 1024, - home_free_mb: home_free_kb / 1024, - docker_free_mb: docker_free_kb / 1024, - port_busy: get("port_busy") == "1", - probed_port, - port_owned_by_relay: get("port_owned") == "1", - container_exists: get("container") == "1", - container_running: get("container_running") == "1", - existing_relay_port: get("existing_port").parse().unwrap_or(0), - relay_healthy: get("healthy") == "1", - home_dir: get("home"), - } -} - -fn classify_docker_access( - docker_installed: bool, - daemon_raw: &str, - active_has_docker_group: bool, - in_docker_group_file: bool, - docker_home_writable: bool, - sudo_available: bool, - sudo_needs_password: bool, -) -> DockerAccessMode { - if !docker_installed { - return DockerAccessMode::Missing; - } - if daemon_raw == "ok" { - if !docker_home_writable { - return DockerAccessMode::BrokenDockerHome; - } - return DockerAccessMode::Ok; - } - if daemon_raw == "down" { - return DockerAccessMode::DaemonDown; - } - if in_docker_group_file && !active_has_docker_group { - return DockerAccessMode::GroupInactive; - } - if daemon_raw == "sudo" || sudo_available { - return DockerAccessMode::SudoNopass; - } - if sudo_needs_password { - return DockerAccessMode::SudoNeedsPassword; - } - if daemon_raw == "unreachable" { - return DockerAccessMode::DaemonDown; - } - DockerAccessMode::Missing -} - -/// Stage an interactive driver script for the task. Does **not** launch it — -/// the wizard runs the script inside a remote PTY so sudo can prompt. -/// -/// `port` is used for deploy (written to `relay.port`); ignored for Docker install. -pub async fn start_task( - manager: &SSHConnectionManager, - connection_id: &str, - task: RelayDeployTask, - port: u16, - mirror_mode: RelayMirrorMode, -) -> Result { - let stem = task.stem(); - let port = normalize_relay_port(port)?; - let body = match task { - RelayDeployTask::InstallDocker => install_docker_body_script(), - RelayDeployTask::Deploy => { - // Authenticate the registry digest here, where the compiled-in - // release trust root exists. The remote host then only needs - // Docker's normal content-addressed pull verification. - match verified_latest_relay_image_descriptor().await? { - Some(descriptor) => deploy_body_script_with_image(port, &descriptor), - None => deploy_body_script_from_source(port), - } - } - }; - let driver = match task { - RelayDeployTask::InstallDocker => interactive_driver_script(stem, "install"), - RelayDeployTask::Deploy => interactive_driver_script(stem, "deploy"), - }; - - // Resolve/authenticate release metadata before mutating remote task state. - let home = resolve_home(manager, connection_id).await?; - let dir = product_data_path(&home, &["relay-deploy"]); - let _ = cancel_task(manager, connection_id, task).await; - exec_ok( - manager, - connection_id, - &format!( - "mkdir -p {} && chmod 700 {}", - shell_quote_posix(&dir), - shell_quote_posix(&dir) - ), - ) - .await?; - - let body_path = format!("{dir}/{stem}-body.sh"); - let script_path = format!("{dir}/{stem}.sh"); - let port_path = format!("{dir}/relay.port"); - let mirror_mode_path = format!("{dir}/relay.mirror-mode"); - // Upload as LF-only: bash on the relay host runs a stray CR as a command. - let body = to_unix_script(&body); - let driver = to_unix_script(&driver); - manager - .sftp_write(connection_id, &body_path, body.as_bytes()) - .await?; - manager - .sftp_write(connection_id, &script_path, driver.as_bytes()) - .await?; - manager - .sftp_write( - connection_id, - &mirror_mode_path, - format!("{}\n", mirror_mode.as_str()).as_bytes(), - ) - .await?; - if matches!(task, RelayDeployTask::Deploy) { - manager - .sftp_write(connection_id, &port_path, format!("{port}\n").as_bytes()) - .await?; - } - // Seed preparing flag before the PTY runs the driver so early polls do not - // race into "failed" (no pid / no flag yet). Clear any driver pid from a - // previous attempt so a recycled pid cannot read as "still preparing". - let prepare_flag = format!("{dir}/{stem}.preparing"); - let log_path = format!("{dir}/{stem}.log"); - let pid_path = format!("{dir}/{stem}.pid"); - let driver_pid_path = format!("{dir}/{stem}.driver.pid"); - exec_ok( - manager, - connection_id, - &stage_scripts_command( - &body_path, - &script_path, - &pid_path, - &driver_pid_path, - &log_path, - &prepare_flag, - ), - ) - .await?; - - Ok(RelayTaskStart { script_path }) -} - -/// Strip CR from a file already on the relay host, in place. -/// -/// Deliberately `tr -d '\r'` and not `sed 's/$//'`: `tr` expands the `\r` -/// escape itself, so the command contains no raw CR byte. A raw CR would be -/// carried through `to_unix_script` on its way out — the CR remover travelling -/// through the CR remover — and any text-mode hop that rewrites line endings -/// would silently turn this into a no-op. Removing every CR rather than only -/// trailing ones is safe here because the scripts are generated bash that never -/// contains an intentional CR (`embedded_scripts_are_lf_only` enforces that). -/// -/// `sed -i` is avoided too: its syntax differs between GNU and BSD userlands. -/// The rewrite replaces the file, so callers must `chmod` afterwards, and the -/// scratch file is cleaned up even when the rewrite fails. -fn strip_cr_command(path: &str) -> String { - let src = shell_quote_posix(path); - let tmp = shell_quote_posix(&format!("{path}.lf")); - format!("{{ tr -d '\\r' < {src} > {tmp} && mv {tmp} {src}; }} || {{ rm -f {tmp}; false; }}") -} - -/// Prepare uploaded scripts for the PTY: normalize line endings, make them -/// executable, and seed the liveness files `poll_task` reads. -/// -/// The CR strip runs **on the relay host, after upload and before execution**, -/// so a CR-free script on disk does not depend on the uploader having called -/// `to_unix_script` (which it does — this is the second line of defence, and -/// the one that still holds if a future upload path forgets). -fn stage_scripts_command( - body_path: &str, - script_path: &str, - pid_path: &str, - driver_pid_path: &str, - log_path: &str, - prepare_flag: &str, -) -> String { - format!( - "{strip_body} && {strip_driver} \ - && chmod 700 {body} {script} \ - && rm -f {pid} {driver_pid} {log} \ - && : > {log} && touch {flag}", - strip_body = strip_cr_command(body_path), - strip_driver = strip_cr_command(script_path), - body = shell_quote_posix(body_path), - script = shell_quote_posix(script_path), - pid = shell_quote_posix(pid_path), - driver_pid = shell_quote_posix(driver_pid_path), - log = shell_quote_posix(log_path), - flag = shell_quote_posix(prepare_flag), - ) -} - -/// Poll a detached task: incremental log output plus liveness/completion status. -pub async fn poll_task( - manager: &SSHConnectionManager, - connection_id: &str, - task: RelayDeployTask, - cursor: u64, -) -> Result { - let stem = task.stem(); - let deploy_state_dir = deploy_state_relative_dir(); - let script = format!( - r#" -D="$HOME/{deploy_state_dir}" -LOG="$D/{stem}.log" -PIDF="$D/{stem}.pid" -DRVF="$D/{stem}.driver.pid" -PREPF="$D/{stem}.preparing" -running=0 -if [ -f "$PIDF" ] && kill -0 "$(cat "$PIDF" 2>/dev/null)" 2>/dev/null; then running=1; fi -# Interactive prepare phase (sudo prompts) before nohup starts. The prompt can -# sit for minutes, so an alive driver keeps "preparing" regardless of age. -preparing=0 -driver_gone=0 -if [ -f "$PREPF" ]; then - preparing=1 - if [ ! -f "$DRVF" ] || ! kill -0 "$(cat "$DRVF" 2>/dev/null)" 2>/dev/null; then - # No driver process. Either the PTY has not started it yet (normal for the - # first few seconds) or it died before installing its cleanup trap — a bad - # script upload, for instance. Without this bound the flag start_task seeded - # would never clear and the wizard would report "running" forever. - prep_age=-1 - prep_now="$(date +%s 2>/dev/null || echo '')" - prep_mtime="$(stat -c %Y "$PREPF" 2>/dev/null || stat -f %m "$PREPF" 2>/dev/null || echo '')" - if [ -n "$prep_now" ] && [ -n "$prep_mtime" ]; then - prep_age=$((prep_now - prep_mtime)) - fi - if [ "$prep_age" -ge {prepare_grace_seconds} ]; then - preparing=0 - driver_gone=1 - fi - fi -fi -log_exists=0 -size=0 -if [ -f "$LOG" ]; then log_exists=1; size=$(wc -c < "$LOG" | tr -d ' '); fi -marker=0 -if [ -f "$LOG" ] && grep -q {TASK_DONE_MARKER} "$LOG"; then marker=1; fi -# A pull or health check may still be progressing even if the wrapper pid -# briefly looks gone; treat a growing log without a marker as running. -echo "running=$running" -echo "preparing=$preparing" -echo "driver_gone=$driver_gone" -echo "log_exists=$log_exists" -echo "size=$size" -echo "marker=$marker" -echo "---" -if [ -f "$LOG" ]; then tail -c +{from} "$LOG"; fi -"#, - from = cursor.saturating_add(1), - prepare_grace_seconds = PREPARE_GRACE_SECONDS, - ); - let (stdout, _stderr, code) = exec_script(manager, connection_id, &script).await?; - if code != 0 { - return Err(anyhow!("poll failed (exit {code})")); - } - let (head, output) = split_poll_stdout(&stdout); - let get = |key: &str| -> String { - head.lines() - .find_map(|l| l.strip_prefix(key).and_then(|v| v.strip_prefix('='))) - .unwrap_or("") - .trim() - .to_string() - }; - let running = get("running") == "1"; - let preparing = get("preparing") == "1"; - let driver_gone = get("driver_gone") == "1"; - let log_exists = get("log_exists") == "1"; - let marker = get("marker") == "1"; - let size: u64 = get("size").parse().unwrap_or(cursor); - let status = decide_task_status( - marker, - running, - preparing, - driver_gone, - log_exists, - size, - cursor, - !output.is_empty(), - ); - // The driver writes its errors to the PTY, not the log, so a prepare-phase - // death leaves the wizard's log pane empty. Say where to look. - let mut output = output.to_string(); - if status == RelayTaskStatus::Failed && driver_gone && size == 0 { - output.push_str( - "\n>>> The prepare step exited before starting the task. \ - See the terminal above for the error.\n", - ); - } - Ok(RelayTaskPoll { - cursor: size, - output, - status, - }) -} - -/// Cancel a running install/deploy task (wizard close / back / retry). -/// -/// Kills the nohup body process tree, clears pid/preparing flags, and appends a -/// cancel marker to the log. The image deploy's TERM trap restores any previous -/// container. Safe to call when nothing is running. -pub async fn cancel_task( - manager: &SSHConnectionManager, - connection_id: &str, - task: RelayDeployTask, -) -> Result<()> { - let stem = task.stem(); - let deploy_state_dir = deploy_state_relative_dir(); - let script = format!( - r#" -set +e -D="$HOME/{deploy_state_dir}" -STEM="{stem}" -LOG="$D/$STEM.log" -PIDF="$D/$STEM.pid" -PREPF="$D/$STEM.preparing" -DRVF="$D/$STEM.driver.pid" -BODY="$D/$STEM-body.sh" -mkdir -p "$D" 2>/dev/null -was_active=0 -[ -f "$PREPF" ] && was_active=1 -rm -f "$PREPF" "$DRVF" -kill_tree() {{ - local p="$1" - local sig="$2" - [ -n "$p" ] || return 0 - for c in $(pgrep -P "$p" 2>/dev/null); do - kill_tree "$c" "$sig" - done - kill "-$sig" "$p" 2>/dev/null || true -}} -if [ -f "$PIDF" ]; then - pid="$(cat "$PIDF" 2>/dev/null | tr -d '[:space:]')" - if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then - was_active=1 - kill_tree "$pid" TERM - sleep 1 - if kill -0 "$pid" 2>/dev/null; then - kill_tree "$pid" KILL - fi - fi - rm -f "$PIDF" -fi -# Body may have been reparented to init after nohup; match the body script only -# (do not pkill broad relay-deploy patterns — that can kill this cancel script). -if [ -n "$BODY" ] && pgrep -f "$BODY" >/dev/null 2>&1; then - was_active=1 - pkill -TERM -f "$BODY" 2>/dev/null || true - sleep 1 - pkill -KILL -f "$BODY" 2>/dev/null || true -fi -if [ "$was_active" = "1" ]; then - echo "" >>"$LOG" 2>/dev/null - echo ">>> Cancelled by client (wizard closed)" >>"$LOG" 2>/dev/null -fi -exit 0 -"#, - stem = stem, - ); - let (_stdout, stderr, code) = exec_script(manager, connection_id, &script).await?; - if code != 0 { - return Err(anyhow!("cancel failed (exit {code}): {stderr}")); - } - Ok(()) -} - -/// Decide poll status from remote probe fields. -/// -/// Pending (PTY not started yet) and active prepare/pull must not look like -/// failure — the wizard polls immediately after staging scripts. -#[allow(clippy::too_many_arguments)] -fn decide_task_status( - marker: bool, - running: bool, - preparing: bool, - driver_gone: bool, - log_exists: bool, - size: u64, - cursor: u64, - got_new_output: bool, -) -> RelayTaskStatus { - if marker { - return RelayTaskStatus::Succeeded; - } - if running || preparing { - return RelayTaskStatus::Running; - } - // The prepare step is definitively dead and never handed off to the body. - // Checked before the empty-log case, which would otherwise read as "still - // starting up" forever. - if driver_gone { - return RelayTaskStatus::Failed; - } - if !log_exists || size == 0 { - return RelayTaskStatus::Running; - } - // Log still growing since last poll — keep running even if pid check flaked. - if got_new_output || cursor < size { - return RelayTaskStatus::Running; - } - RelayTaskStatus::Failed -} - -/// Split poll script stdout into the metadata head and incremental log body. -/// -/// Accepts LF, CRLF, or a standalone `---` line so SSH/OS line endings cannot -/// drop the entire log payload. -fn split_poll_stdout(stdout: &str) -> (&str, &str) { - if let Some((head, output)) = stdout.split_once("---\r\n") { - return (head, output); - } - if let Some((head, output)) = stdout.split_once("---\n") { - return (head, output); - } - let mut offset = 0usize; - for line in stdout.split_inclusive('\n') { - if line.trim_end_matches(['\r', '\n']) == "---" { - return (&stdout[..offset], &stdout[offset + line.len()..]); - } - offset += line.len(); - } - (stdout, "") -} - -/// Import a locally-provisioned account into the running relay container. -/// -/// `account_json` is the serialized `ImportableAccount` produced client-side -/// by `openbitfun_relay_service::admin::provision` — it contains only derived -/// artifacts (salts, Argon2id hash, wrapped master key). The file is written -/// with 0600 permissions and removed immediately after the import attempt. -pub async fn import_account( - manager: &SSHConnectionManager, - connection_id: &str, - account_json: &str, -) -> Result<()> { - let home = resolve_home(manager, connection_id).await?; - let dir = product_data_path(&home, &["relay-deploy"]); - exec_ok( - manager, - connection_id, - &format!( - "mkdir -p {} && chmod 700 {}", - shell_quote_posix(&dir), - shell_quote_posix(&dir) - ), - ) - .await?; - let path = format!("{dir}/import-{}.json", uuid::Uuid::new_v4().as_simple()); - manager - .sftp_write(connection_id, &path, account_json.as_bytes()) - .await?; - - let quoted = shell_quote_posix(&path); - let cmd = format!( - "chmod 600 {q}; \ - dps() {{ docker ps --format '{{{{.Names}}}}' 2>/dev/null; }}; \ - dexec() {{ docker exec -i {name} /app/relay-admin --db {db} import-user; }}; \ - if docker info >/dev/null 2>&1; then :; \ - elif sg docker -c 'docker info' >/dev/null 2>&1; then \ - dps() {{ sg docker -c \"docker ps --format '{{{{.Names}}}}'\" 2>/dev/null; }}; \ - dexec() {{ sg docker -c \"docker exec -i {name} /app/relay-admin --db {db} import-user\"; }}; \ - elif sudo -n docker info >/dev/null 2>&1; then \ - dps() {{ sudo -n docker ps --format '{{{{.Names}}}}' 2>/dev/null; }}; \ - dexec() {{ sudo -n docker exec -i {name} /app/relay-admin --db {db} import-user; }}; \ - else \ - dps() {{ sudo docker ps --format '{{{{.Names}}}}' 2>/dev/null; }}; \ - dexec() {{ sudo docker exec -i {name} /app/relay-admin --db {db} import-user; }}; \ - fi; \ - if dps | grep -qx {name}; then \ - cat {q} | dexec; rc=$?; rm -f {q}; exit $rc; \ - else \ - echo 'relay container {name} is not running' >&2; rm -f {q}; exit 1; \ - fi", - q = quoted, - name = RELAY_CONTAINER_NAME, - db = RELAY_CONTAINER_DB, - ); - let (stdout, stderr, code) = exec_script(manager, connection_id, &cmd).await?; - if code != 0 { - let detail = relay_admin_error(&stdout, &stderr); - return Err(anyhow!(detail)); - } - Ok(()) -} - -/// Health-check the relay from the server itself (loopback). -pub async fn check_relay_health( - manager: &SSHConnectionManager, - connection_id: &str, - port: u16, -) -> Result { - let port = normalize_relay_port(port)?; - let (_o, _e, code) = manager - .execute_command( - connection_id, - &format!("curl -fsS -m 5 http://127.0.0.1:{port}/health >/dev/null 2>&1"), - ) - .await?; - Ok(code == 0) -} - -/// Extract the meaningful relay-admin failure line, if present. -fn relay_admin_error(stdout: &str, stderr: &str) -> String { - for line in stderr.lines().chain(stdout.lines()) { - let l = line.trim(); - if l.contains("already exists") || l.contains("Error") || l.contains("error") { - return l.trim_start_matches("Error: ").to_string(); - } - } - let tail = stderr.trim(); - if tail.is_empty() { - "account import failed".to_string() - } else { - tail.chars().take(300).collect() - } -} - -async fn resolve_home(manager: &SSHConnectionManager, connection_id: &str) -> Result { - let (out, _e, code) = manager - .execute_command(connection_id, "printf %s \"$HOME\"") - .await?; - let home = out.trim(); - if code != 0 || home.is_empty() { - return Err(anyhow!("could not resolve remote $HOME")); - } - Ok(home.to_string()) -} - -/// Strip CR from anything sent to the relay host as bash. -/// -/// Git for Windows checks out with CRLF by default, so both `include_str!` -/// (mirror.sh / release-download.sh) and this file's own `r#"..."#` remote -/// scripts can carry CRLF into the generated script. Remote bash then executes -/// the CR on the first blank line, prints `line N: $'\r': command not found` -/// and — under `set -euo pipefail` — aborts the deploy right there. `.gitattributes` -/// pins LF for fresh checkouts; this keeps existing CRLF working trees safe too. -fn to_unix_script(script: &str) -> String { - script.replace("\r\n", "\n") -} - -/// `execute_command` for remote bash, with line endings normalized first. -async fn exec_script( - manager: &SSHConnectionManager, - connection_id: &str, - script: &str, -) -> Result<(String, String, i32)> { - manager - .execute_command(connection_id, &to_unix_script(script)) - .await -} - -async fn exec_ok(manager: &SSHConnectionManager, connection_id: &str, command: &str) -> Result<()> { - let (stdout, stderr, code) = exec_script(manager, connection_id, command).await?; - if code != 0 { - return Err(anyhow!( - "remote command failed (exit {code}): {}", - if stderr.trim().is_empty() { - stdout.trim().chars().take(300).collect::() - } else { - stderr.trim().chars().take(300).collect::() - } - )); - } - Ok(()) -} - -/// Shared interactive prepare helpers embedded in driver scripts. -fn prepare_helpers_bash() -> String { - // Mirror helpers first so prepare/install/deploy can call openbitfun_mirror_init - // before apt/git/docker downloads. - let helpers = format!( - r#" -# --- begin OpenBitFun Relay mirror.sh (embedded) --- -{mirror} -# --- end OpenBitFun Relay mirror.sh --- -"#, - mirror = RELAY_MIRROR_SH - ) + r#" -# Privilege helpers: -# - Never use `sudo -v` when NOPASSWD is set — on many cloud images `sudo -v` -# still demands a password even though `sudo -n true` works. -# - Prefer already-root → passwordless sudo → interactive sudo / sudo su -. -# - When elevating via `su -`, keep the original HOME so product data paths stay valid. - -openbitfun_have_passwordless_sudo() { - [ "$(id -u)" != "0" ] && sudo -n true >/dev/null 2>&1 -} - -# Run a command with the best available privilege (root / sudo -n / sudo). -openbitfun_priv() { - if [ "$(id -u)" = "0" ]; then - "$@" - elif sudo -n true >/dev/null 2>&1; then - sudo -n "$@" - else - sudo "$@" - fi -} - -# For Docker install: if not root, re-exec this driver as root once. -# Passwordless path uses `sudo su -` (no prompt). Interactive path prompts once. -# Sets OPENBITFUN_ELEVATED=1 to avoid loops. Preserves HOME for product data. -openbitfun_elevate_install_driver() { - local self="$1" - if [ "$(id -u)" = "0" ] || [ "${OPENBITFUN_ELEVATED:-0}" = "1" ]; then - return 0 - fi - local keep_home="${OPENBITFUN_KEEP_HOME:-$HOME}" - local q_self q_home - q_self=$(printf '%q' "$self") - q_home=$(printf '%q' "$keep_home") - if openbitfun_have_passwordless_sudo; then - echo ">>> Root needed for Docker install; elevating via passwordless sudo su -..." - exec sudo -n su - -c "export OPENBITFUN_ELEVATED=1 OPENBITFUN_KEEP_HOME=$q_home HOME=$q_home; cd $q_home 2>/dev/null || cd /; bash $q_self" - fi - echo ">>> Root needed for Docker install; elevating via sudo su - (password may be required)..." - exec sudo su - -c "export OPENBITFUN_ELEVATED=1 OPENBITFUN_KEEP_HOME=$q_home HOME=$q_home; cd $q_home 2>/dev/null || cd /; bash $q_self" -} - -openbitfun_ensure_tools() { - local pkgs=() - if [ "$#" -eq 0 ]; then set -- git curl tar; fi - local tool - for tool in "$@"; do - command -v "$tool" >/dev/null 2>&1 || pkgs+=("$tool") - done - if [ "${#pkgs[@]}" -eq 0 ]; then return 0; fi - echo ">>> Installing missing tools (${pkgs[*]})..." - if [ "$(id -u)" = "0" ]; then - if command -v apt-get >/dev/null 2>&1; then apt-get update -y && apt-get install -y "${pkgs[@]}" - elif command -v dnf >/dev/null 2>&1; then dnf install -y "${pkgs[@]}" - elif command -v yum >/dev/null 2>&1; then yum install -y "${pkgs[@]}" - else echo "ERROR: missing tools (${pkgs[*]}) and no supported package manager" >&2; return 1; fi - else - if command -v apt-get >/dev/null 2>&1; then openbitfun_priv apt-get update -y && openbitfun_priv apt-get install -y "${pkgs[@]}" - elif command -v dnf >/dev/null 2>&1; then openbitfun_priv dnf install -y "${pkgs[@]}" - elif command -v yum >/dev/null 2>&1; then openbitfun_priv yum install -y "${pkgs[@]}" - else echo "ERROR: missing tools (${pkgs[*]}); install them then retry" >&2; return 1; fi - fi -} - -openbitfun_prepare_source_tools() { - openbitfun_ensure_tools git || return 1 - if openbitfun_docker buildx version >/dev/null 2>&1; then return 0; fi - echo ">>> Installing Docker Buildx for source fallback..." - if command -v apt-get >/dev/null 2>&1; then - openbitfun_priv apt-get update -y || return 1 - openbitfun_priv apt-get install -y docker-buildx-plugin \ - || openbitfun_priv apt-get install -y docker-buildx || return 1 - elif command -v dnf >/dev/null 2>&1; then - openbitfun_priv dnf install -y docker-buildx-plugin \ - || openbitfun_priv dnf install -y docker-buildx || return 1 - elif command -v yum >/dev/null 2>&1; then - openbitfun_priv yum install -y docker-buildx-plugin || return 1 - fi - openbitfun_docker buildx version >/dev/null 2>&1 -} - -# Install Docker Engine for the original SSH user. The caller must initialize -# mirror routing first and, when interactive sudo is needed, re-exec the driver -# through openbitfun_elevate_install_driver before calling this helper. -openbitfun_install_docker_engine() { - local deploy_user="${SUDO_USER:-}" installed=0 - if [ -z "$deploy_user" ] || [ "$deploy_user" = "root" ]; then - if [ -n "${OPENBITFUN_KEEP_HOME:-}" ] && [ -d "${OPENBITFUN_KEEP_HOME}" ]; then - deploy_user="$(stat -c '%U' "$OPENBITFUN_KEEP_HOME" 2>/dev/null || true)" - fi - fi - if [ -z "$deploy_user" ] || [ "$deploy_user" = "root" ]; then - deploy_user="$(id -un)" - fi - - openbitfun_ensure_tools curl - echo ">>> Installing Docker as uid=$(id -u) for user=$deploy_user (mirror_mode=${OPENBITFUN_MIRROR_MODE:-global}) ..." - if [ "${OPENBITFUN_MIRROR_MODE:-}" = "cn" ]; then - if openbitfun_mirror_install_docker_aliyun; then - installed=1 - else - echo ">>> Aliyun docker-ce install failed; falling back to get.docker.com mirror..." - fi - fi - if [ "$installed" != "1" ]; then - openbitfun_mirror_fetch_docker_install_script /tmp/openbitfun-get-docker.sh \ - || curl -fsSL --retry 3 https://get.docker.com -o /tmp/openbitfun-get-docker.sh - if [ "$(id -u)" = "0" ]; then - sh /tmp/openbitfun-get-docker.sh - else - openbitfun_priv sh /tmp/openbitfun-get-docker.sh - fi - rm -f /tmp/openbitfun-get-docker.sh - fi - - if [ "$(id -u)" = "0" ]; then - systemctl enable --now docker 2>/dev/null || service docker start - usermod -aG docker "$deploy_user" || true - else - openbitfun_priv systemctl enable --now docker 2>/dev/null || openbitfun_priv service docker start - openbitfun_priv usermod -aG docker "$deploy_user" - fi - if [ "${OPENBITFUN_MIRROR_MODE:-}" = "cn" ]; then - openbitfun_mirror_apply_docker_daemon || true - fi - openbitfun_fix_docker_home - if [ "$(id -u)" = "0" ] && [ -n "$deploy_user" ] && [ "$deploy_user" != "root" ] \ - && [ -d "__OPENBITFUN_PRODUCT_HOME__" ]; then - echo ">>> Restoring ownership of __OPENBITFUN_PRODUCT_HOME__ to $deploy_user..." - chown -R "$deploy_user" "__OPENBITFUN_PRODUCT_HOME__" 2>/dev/null || true - fi - - if docker info >/dev/null 2>&1 \ - || sg docker -c 'docker info' >/dev/null 2>&1 \ - || sudo -n docker info >/dev/null 2>&1 \ - || sudo docker info >/dev/null 2>&1; then - echo ">>> Docker installed and reachable: $(docker --version 2>/dev/null || sudo -n docker --version 2>/dev/null || true)" - return 0 - fi - echo "ERROR: Docker installed but daemon is not reachable" >&2 - return 1 -} - -# Owner of $HOME — the SSH user even when this script runs elevated with their -# HOME preserved (OPENBITFUN_KEEP_HOME). -openbitfun_home_owner() { - stat -c '%U:%G' "$HOME" 2>/dev/null || stat -f '%Su:%Sg' "$HOME" 2>/dev/null || true -} - -# Make DOCKER_CONFIG usable by whoever is running now. -# -# The Docker-install task runs as root but keeps the SSH user's HOME, so it used -# to leave the product Docker config (and its config.json) owned by root:root 0700. -# Every later unprivileged deploy then hit -# WARNING: Error loading config file: .../config.json: permission denied -# and the docker CLI misparsed the command that followed. Repair ownership when -# we have the rights, and otherwise move to a config dir we can actually read. -openbitfun_fix_docker_config() { - export DOCKER_CONFIG="${DOCKER_CONFIG:-__OPENBITFUN_DOCKER_CONFIG__}" - mkdir -p "$DOCKER_CONFIG" 2>/dev/null || true - if [ "$(id -u)" = "0" ]; then - # Hand the tree back to the SSH user; root reads it either way. - local owner - owner="$(openbitfun_home_owner)" - if [ -n "$owner" ] && [ "$owner" != "root:root" ]; then - chown -R "$owner" "$DOCKER_CONFIG" 2>/dev/null || true - fi - elif [ ! -r "$DOCKER_CONFIG" ] || [ ! -w "$DOCKER_CONFIG" ] \ - || { [ -e "$DOCKER_CONFIG/config.json" ] && [ ! -r "$DOCKER_CONFIG/config.json" ]; }; then - echo ">>> $DOCKER_CONFIG is not usable by $(id -un) (left root-owned by an earlier install)." - openbitfun_priv chown -R "$(id -un):$(id -gn)" "$DOCKER_CONFIG" 2>/dev/null || true - if [ ! -r "$DOCKER_CONFIG" ] || [ ! -w "$DOCKER_CONFIG" ] \ - || { [ -e "$DOCKER_CONFIG/config.json" ] && [ ! -r "$DOCKER_CONFIG/config.json" ]; }; then - DOCKER_CONFIG="__OPENBITFUN_DOCKER_CONFIG__-$(id -u)" - export DOCKER_CONFIG - mkdir -p "$DOCKER_CONFIG" - echo ">>> Could not repair it; using DOCKER_CONFIG=$DOCKER_CONFIG instead." - fi - fi - chmod 700 "$DOCKER_CONFIG" 2>/dev/null || true -} - -openbitfun_fix_docker_home() { - openbitfun_fix_docker_config - if [ -e "$HOME/.docker" ] && [ ! -w "$HOME/.docker" ]; then - echo ">>> $HOME/.docker is not writable (often root-owned buildx lock)." - echo ">>> Fixing ownership..." - if [ "$(id -u)" = "0" ]; then - # Prefer original deploy user if HOME still points at their tree. - local owner - owner="$(stat -c '%U:%G' "$HOME" 2>/dev/null || echo root:root)" - chown -R "$owner" "$HOME/.docker" 2>/dev/null \ - || chown -R "$(id -un):$(id -gn)" "$HOME/.docker" - else - openbitfun_priv chown -R "$(id -un):$(id -gn)" "$HOME/.docker" - fi - fi - if [ -e "$HOME/.docker" ] && [ ! -w "$HOME/.docker" ]; then - echo ">>> Still not writable; using isolated DOCKER_CONFIG=$DOCKER_CONFIG" - fi -} - -openbitfun_start_docker_daemon() { - if docker info >/dev/null 2>&1 || sudo -n docker info >/dev/null 2>&1; then return 0; fi - echo ">>> Starting Docker daemon..." - if [ "$(id -u)" = "0" ]; then - systemctl enable --now docker 2>/dev/null || service docker start 2>/dev/null || true - elif sudo -n true >/dev/null 2>&1; then - sudo -n systemctl enable --now docker 2>/dev/null || sudo -n service docker start 2>/dev/null || true - else - echo ">>> sudo password may be required to start Docker..." - sudo systemctl enable --now docker 2>/dev/null || sudo service docker start 2>/dev/null || true - fi - sleep 1 -} - -# Sets OPENBITFUN_DOCKER_MODE to: direct | sg | sudo -openbitfun_resolve_docker_mode() { - openbitfun_fix_docker_home - openbitfun_start_docker_daemon - if docker info >/dev/null 2>&1; then - OPENBITFUN_DOCKER_MODE=direct - return 0 - fi - if id -nG 2>/dev/null | tr ' ' '\n' | grep -qx docker; then - if sg docker -c 'docker info' >/dev/null 2>&1; then - OPENBITFUN_DOCKER_MODE=sg - return 0 - fi - elif getent group docker 2>/dev/null | grep -qE "(^|:|,)$(id -un)(,|$)"; then - echo ">>> User is in docker group but session has not activated it; using sg docker." - if sg docker -c 'docker info' >/dev/null 2>&1; then - OPENBITFUN_DOCKER_MODE=sg - return 0 - fi - fi - if sudo -n docker info >/dev/null 2>&1; then - echo ">>> Using passwordless sudo for Docker." - OPENBITFUN_DOCKER_MODE=sudo - return 0 - fi - echo ">>> Docker needs interactive sudo (enter password if prompted)..." - if sudo docker info >/dev/null 2>&1; then - OPENBITFUN_DOCKER_MODE=sudo - return 0 - fi - echo "ERROR: cannot reach Docker daemon" >&2 - return 1 -} - -# POSIX single-quote each argument so `sg -c` cannot re-split or glob them. -# `sg docker -c "docker $*"` loses argument boundaries: a context path with a -# space, or a `-f '{{.State.Running}}'` format string, arrives mangled. -openbitfun_shell_join() { - local out="" arg - for arg in "$@"; do - out="$out'$(printf '%s' "$arg" | sed "s/'/'\\\\''/g")' " - done - printf '%s' "$out" -} - -openbitfun_docker() { - case "${OPENBITFUN_DOCKER_MODE:-direct}" in - sg) sg docker -c "$(openbitfun_shell_join docker "$@")" ;; - sudo) - if sudo -n true >/dev/null 2>&1; then sudo -n docker "$@"; else sudo docker "$@"; fi - ;; - *) docker "$@" ;; - esac -} - -# Long source builds may outlive sudo's timestamp. Refresh only the already -# authorized Docker command, only while this body exists; never change sudoers -# or try to prompt from the detached task. The driver's PTY owns the timestamp. -openbitfun_keep_docker_authorization() { - [ "${OPENBITFUN_DOCKER_MODE:-direct}" = sudo ] || return 0 - local owner_pid=$$ lease_pid - ( - while kill -0 "$owner_pid" 2>/dev/null && sudo -n docker version >/dev/null 2>&1; do - sleep 30 - done - ) & - lease_pid=$! - trap "kill $lease_pid 2>/dev/null || true" EXIT -} - -"#; - helpers - .replace("__OPENBITFUN_PRODUCT_HOME__", &product_home_shell_path(&[])) - .replace( - "__OPENBITFUN_DOCKER_CONFIG__", - &product_home_shell_path(&["docker-config"]), - ) -} - -/// Interactive driver: prepare (TTY/sudo OK) → nohup body → tail -f log. -fn interactive_driver_script(stem: &str, kind: &str) -> String { - let helpers = prepare_helpers_bash(); - let deploy_state_dir = deploy_state_relative_dir(); - let docker_config = product_home_shell_path(&["docker-config"]); - format!( - r#"#!/usr/bin/env bash -set -euo pipefail -D="$HOME/{deploy_state_dir}" -STEM="{stem}" -LOG="$D/$STEM.log" -PIDF="$D/$STEM.pid" -BODY="$D/$STEM-body.sh" -mkdir -p "$D" -chmod 700 "$D" -# Claim the prepare phase before anything that can fail: poll_task treats a -# missing/dead driver pid as "prepare died" once its grace window elapses. -DRIVER_PIDF="$D/$STEM.driver.pid" -echo $$ >"$DRIVER_PIDF" -{helpers} - -echo ">>> OpenBitFun Relay {kind}: interactive prepare" -echo ">>> Closing the wizard stops this task." -# Preserve the SSH user's home across root elevation (su - would otherwise use /root). -export OPENBITFUN_KEEP_HOME="${{OPENBITFUN_KEEP_HOME:-$HOME}}" -# install: elevate to root first (passwordless sudo su - when available). -if [ "{kind}" = "install" ]; then - openbitfun_elevate_install_driver "$D/$STEM.sh" -fi -# After elevation HOME may need restoring from OPENBITFUN_KEEP_HOME. -if [ -n "${{OPENBITFUN_KEEP_HOME:-}}" ]; then - export HOME="$OPENBITFUN_KEEP_HOME" - D="$HOME/{deploy_state_dir}" - LOG="$D/$STEM.log" - PIDF="$D/$STEM.pid" - BODY="$D/$STEM-body.sh" - DRIVER_PIDF="$D/$STEM.driver.pid" -fi -PREPARE_FLAG="$D/$STEM.preparing" -MIRROR_MODE_FILE="$D/relay.mirror-mode" -# Re-claim the prepare phase: an elevated re-exec is a different process, and D -# may have moved with HOME. -echo $$ >"$DRIVER_PIDF" -# Keep/refresh the preparing flag seeded by start_task — do not clear it first -# or early polls can race into "failed". -rm -f "$PIDF" -: >"$LOG" -touch "$PREPARE_FLAG" -echo ">>> prepare starting (uid=$(id -u) home=$HOME)" | tee -a "$LOG" -cleanup_prepare() {{ rm -f "$PREPARE_FLAG" "$DRIVER_PIDF"; }} -trap cleanup_prepare EXIT -# Region/mirrors before apt tool install and Docker/GitHub downloads. -export OPENBITFUN_REPO_GIT_URL="{REPO_GIT_URL}" -export OPENBITFUN_REPO_TARBALL_URL="{REPO_TARBALL_URL}" -if [ -f "$MIRROR_MODE_FILE" ]; then - requested_mirror_mode="$(tr -d '[:space:]' < "$MIRROR_MODE_FILE")" - case "$requested_mirror_mode" in - auto|cn|global) export OPENBITFUN_MIRROR="$requested_mirror_mode" ;; - *) echo "ERROR: invalid relay mirror mode: $requested_mirror_mode" >&2; exit 1 ;; - esac -fi -openbitfun_mirror_init -export DOCKER_CONFIG="${{DOCKER_CONFIG:-{docker_config}}}" -# May exist root-owned from an older Docker-install run; repair or relocate it -# instead of letting an unwritable dir abort the run under `set -e`. -openbitfun_fix_docker_config - -# Deploy is genuinely one-click: if Docker is absent, install it through -# openbitfun_priv/openbitfun_mirror_priv (interactive sudo is allowed), then continue as -# the original SSH user so cancellation can still signal the detached task. -if [ "{kind}" = "deploy" ] && ! command -v docker >/dev/null 2>&1; then - echo ">>> Docker is not installed; installing it before pulling Relay..." | tee -a "$LOG" - openbitfun_install_docker_engine 2>&1 | tee -a "$LOG" -fi - -# Standalone install resolves nothing; deploy always needs live daemon access. -if [ "{kind}" = "install" ]; then - OPENBITFUN_DOCKER_MODE=direct -else - openbitfun_resolve_docker_mode -fi -export OPENBITFUN_DOCKER_MODE - -# Source fallback runs detached too. Prepare its host dependencies while -# sudo can still prompt; failure must not prevent an available image deploying. -if [ "{kind}" = "deploy" ]; then - if ! openbitfun_prepare_source_tools 2>&1 | tee -a "$LOG"; then - echo ">>> Source prerequisites could not be installed; trying the published image where available." | tee -a "$LOG" - fi -fi - -# Docker install runs in the foreground. The image pull/start task goes through -# nohup so the wizard can poll and follow its log. -if [ "{kind}" = "install" ]; then - echo ">>> Installing Docker..." | tee -a "$LOG" - export OPENBITFUN_KEEP_HOME="${{OPENBITFUN_KEEP_HOME:-$HOME}}" - set +e - if command -v stdbuf >/dev/null 2>&1; then - stdbuf -oL -eL env OPENBITFUN_KEEP_HOME="$OPENBITFUN_KEEP_HOME" \ - OPENBITFUN_MIRROR="${{OPENBITFUN_MIRROR:-auto}}" \ - OPENBITFUN_MIRROR_MODE="${{OPENBITFUN_MIRROR_MODE:-}}" \ - OPENBITFUN_MIRROR_REASON="${{OPENBITFUN_MIRROR_REASON:-}}" \ - bash "$BODY" 2>&1 | tee -a "$LOG" - else - env OPENBITFUN_KEEP_HOME="$OPENBITFUN_KEEP_HOME" \ - OPENBITFUN_MIRROR="${{OPENBITFUN_MIRROR:-auto}}" \ - OPENBITFUN_MIRROR_MODE="${{OPENBITFUN_MIRROR_MODE:-}}" \ - OPENBITFUN_MIRROR_REASON="${{OPENBITFUN_MIRROR_REASON:-}}" \ - bash "$BODY" 2>&1 | tee -a "$LOG" - fi - code=${{PIPESTATUS[0]}} - set -e - rm -f "$PREPARE_FLAG" "$PIDF" "$DRIVER_PIDF" - trap - EXIT - if [ "$code" -ne 0 ]; then - echo "ERROR: Docker install failed (exit $code)" | tee -a "$LOG" - exit "$code" - fi - echo ">>> Docker install finished." | tee -a "$LOG" - exit 0 -fi - -if command -v stdbuf >/dev/null 2>&1; then RUNNER=(stdbuf -oL -eL bash); else RUNNER=(bash); fi -echo ">>> Starting background task (log: $LOG)" | tee -a "$LOG" -nohup env OPENBITFUN_DOCKER_MODE="$OPENBITFUN_DOCKER_MODE" DOCKER_CONFIG="$DOCKER_CONFIG" \ - OPENBITFUN_MIRROR="${{OPENBITFUN_MIRROR:-auto}}" \ - OPENBITFUN_MIRROR_MODE="${{OPENBITFUN_MIRROR_MODE:-}}" \ - OPENBITFUN_MIRROR_REASON="${{OPENBITFUN_MIRROR_REASON:-}}" \ - "${{RUNNER[@]}}" "$BODY" >"$LOG" 2>&1 < /dev/null & -echo $! >"$PIDF" -# The body pid now drives liveness; `exec tail` below would leave a stale driver -# pid behind, so retire it here rather than in the (never-reached) EXIT trap. -rm -f "$PREPARE_FLAG" "$DRIVER_PIDF" -trap - EXIT -echo ">>> Following log..." -exec tail -n +1 -f "$LOG" -"#, - stem = stem, - kind = kind, - helpers = helpers, - REPO_GIT_URL = REPO_GIT_URL, - REPO_TARBALL_URL = REPO_TARBALL_URL, - ) -} - -/// Docker install body (usually run as root after driver elevation). -fn install_docker_body_script() -> String { - let helpers = prepare_helpers_bash(); - let docker_config = product_home_shell_path(&["docker-config"]); - format!( - r#"#!/usr/bin/env bash -set -euo pipefail -{helpers} -# Prefer the original SSH user's home (set by elevated driver). -if [ -n "${{OPENBITFUN_KEEP_HOME:-}}" ]; then export HOME="$OPENBITFUN_KEEP_HOME"; fi -export DOCKER_CONFIG="${{DOCKER_CONFIG:-{docker_config}}}" -mkdir -p "$DOCKER_CONFIG" 2>/dev/null || true -export OPENBITFUN_REPO_GIT_URL="{REPO_GIT_URL}" -export OPENBITFUN_REPO_TARBALL_URL="{REPO_TARBALL_URL}" -openbitfun_mirror_init -openbitfun_install_docker_engine -echo {TASK_DONE_MARKER} -"#, - helpers = helpers, - REPO_GIT_URL = REPO_GIT_URL, - REPO_TARBALL_URL = REPO_TARBALL_URL, - TASK_DONE_MARKER = TASK_DONE_MARKER, - ) -} - -/// Preamble + shared script that pulls the published multi-platform Relay image. -/// The container name, volumes, ports and relay-admin path stay identical to the -/// former source-compose deployment. -/// -/// The body lives in `src/apps/relay-server/release-download.sh` so the manual -/// `deploy.sh` path runs exactly the same code, the same way `mirror.sh` is -/// shared. Desktop additionally supplies a signed, immutable registry digest. -fn release_binary_deploy_bash() -> String { - format!( - r#" -export OPENBITFUN_GITHUB_RELEASE_BASE="{RELEASE_BASE}" -export OPENBITFUN_OPENBITFUN_RELEASE_BASE="{OPENBITFUN_RELEASE_BASE}" -# --- begin OpenBitFun Relay release-download.sh --- -{release_download} -# --- end OpenBitFun Relay release-download.sh --- -"#, - RELEASE_BASE = RELEASE_BASE, - OPENBITFUN_RELEASE_BASE = OPENBITFUN_RELEASE_BASE, - release_download = RELAY_RELEASE_DOWNLOAD_SH, - ) -} - -/// Download and authenticate the image descriptor before any remote mutation. -/// The official release is preferred; openbitfun is a byte mirror and remains -/// safe because the same compiled-in minisign key must verify its descriptor. -async fn verified_latest_relay_image_descriptor() -> Result> { - let pubkey = release_pubkey().ok_or_else(|| { - anyhow!("this build has no Relay release trust root; refusing image deployment") - })?; - let client = crate::reqwest_client_builder() - .connect_timeout(Duration::from_secs(8)) - .timeout(Duration::from_secs(30)) - .build()?; - let bases = [ - format!("{RELEASE_BASE}/latest/download"), - OPENBITFUN_RELEASE_BASE.to_string(), - ]; - - resolve_relay_image_descriptor(&client, &bases, pubkey).await -} - -async fn resolve_relay_image_descriptor( - client: &reqwest::Client, - bases: &[String], - pubkey: &str, -) -> Result> { - let mut verification_error = None; - for base in bases { - let descriptor_url = format!("{base}/{RELAY_IMAGE_DESCRIPTOR_ASSET}"); - let Some(descriptor_text) = fetch_text(&client, &descriptor_url).await else { - log::warn!("Relay image descriptor unavailable: {descriptor_url}"); - continue; - }; - let Some(signature) = fetch_text(&client, &format!("{descriptor_url}.sig")).await else { - log::warn!("Relay image signature unavailable: {descriptor_url}.sig"); - continue; - }; - match verified_relay_image_candidate(&descriptor_text, &signature, pubkey) { - Ok(Some(descriptor)) => return Ok(Some(descriptor)), - Ok(None) => { - log::info!("No current Relay image in {descriptor_url}; checking the next source") - } - Err(error) => { - let error = format!("{descriptor_url} is invalid: {error}"); - log::warn!("Relay image descriptor rejected: {error}"); - verification_error = Some(error); - } - } - } - if let Some(error) = verification_error { - return Err(anyhow!( - "could not verify the latest signed Relay image descriptor: {error}" - )); - } - log::info!("No published current Relay image is available; using a source build"); - Ok(None) -} - -fn verified_relay_image_candidate( - text: &str, - signature: &str, - pubkey: &str, -) -> Result> { - verify_minisign(text.as_bytes(), signature, pubkey)?; - let descriptor: RelayImageDescriptor = serde_json::from_str(text)?; - // A signed release for a different product image is not a deployable image. - // Do not rewrite or pull that repository; build the current source instead. - if descriptor.image != RELAY_IMAGE_REPOSITORY { - return Ok(None); - } - validate_relay_image_descriptor(&descriptor)?; - Ok(Some(descriptor)) -} - -fn validate_relay_image_descriptor(descriptor: &RelayImageDescriptor) -> Result<()> { - if descriptor.schema_version != 1 { - return Err(anyhow!( - "unsupported schema version {}", - descriptor.schema_version - )); - } - if descriptor.image != RELAY_IMAGE_REPOSITORY { - return Err(anyhow!("unexpected image repository")); - } - let version = semver::Version::parse(&descriptor.version) - .map_err(|_| anyhow!("image version is not valid SemVer"))?; - if !version.pre.is_empty() || !version.build.is_empty() { - return Err(anyhow!("latest Relay image must be a stable release")); - } - if descriptor.tag != release_tag_for_version(&descriptor.version) { - return Err(anyhow!("descriptor tag does not match its version")); - } - let digest = descriptor.digest.as_bytes(); - if digest.len() != 71 - || !descriptor.digest.starts_with("sha256:") - || !digest[7..] - .iter() - .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(byte)) - { - return Err(anyhow!("image digest is not canonical lowercase SHA256")); - } - for platform in ["linux/amd64", "linux/arm64"] { - if !descriptor - .platforms - .iter() - .any(|candidate| candidate == platform) - { - return Err(anyhow!("image does not declare {platform}")); - } - } - Ok(()) -} - -async fn fetch_text(client: &reqwest::Client, url: &str) -> Option { - client - .get(url) - .send() - .await - .ok()? - .error_for_status() - .ok()? - .text() - .await - .ok() -} - -/// Non-interactive body for deploy (runs under nohup after prepare). It has one -/// network operation: pull the authenticated image through the selected route. -fn deploy_body_script_with_image(port: u16, descriptor: &RelayImageDescriptor) -> String { - deploy_body_script(port, &format!( - "export OPENBITFUN_RELAY_IMAGE={}\nexport OPENBITFUN_RELAY_IMAGE_DIGEST={}\nexport OPENBITFUN_RELEASE_TAG={}\nexport OPENBITFUN_RELEASE_VERSION={}\nexport OPENBITFUN_REQUIRE_IMAGE_DIGEST=1", - shell_quote_posix(&descriptor.image), shell_quote_posix(&descriptor.digest), - shell_quote_posix(&descriptor.tag), shell_quote_posix(&descriptor.version), - ), "image") -} - -fn deploy_body_script_from_source(port: u16) -> String { - deploy_body_script(port, "", "source") -} - -fn deploy_body_script(port: u16, image_env: &str, mode: &str) -> String { - let helpers = prepare_helpers_bash(); - let release_binary_deploy = release_binary_deploy_bash(); - let deploy_state_dir = deploy_state_relative_dir(); - let docker_config = product_home_shell_path(&["docker-config"]); - format!( - r#"#!/usr/bin/env bash -set -euo pipefail -{helpers} -{release_binary_deploy} -{source_build} -{image_env} -export OPENBITFUN_REPO_GIT_URL={repo_git_url} -export DOCKER_CONFIG="${{DOCKER_CONFIG:-{docker_config}}}" -OPENBITFUN_DOCKER_MODE="${{OPENBITFUN_DOCKER_MODE:-direct}}" -# Repair DOCKER_CONFIG unconditionally: when the driver already resolved a -# non-direct mode, openbitfun_resolve_docker_mode (which normally does this) is -# skipped below, and the docker CLI then fails on an unreadable config.json. -openbitfun_fix_docker_config -if [ "$OPENBITFUN_DOCKER_MODE" = "direct" ] && ! docker info >/dev/null 2>&1; then - openbitfun_resolve_docker_mode -fi -openbitfun_keep_docker_authorization -# Prefer the port staged by the desktop wizard; fall back to embedded default. -PORT_FILE="$HOME/{deploy_state_dir}/relay.port" -if [ -f "$PORT_FILE" ]; then - RELAY_PORT="$(tr -d '[:space:]' < "$PORT_FILE")" -fi -RELAY_PORT="${{RELAY_PORT:-{port}}}" -export RELAY_PORT -echo ">>> Using RELAY_PORT=$RELAY_PORT" -openbitfun_mirror_init -openbitfun_deploy_with_source_fallback {mode} "$HOME/{source_dir}" -echo {TASK_DONE_MARKER} -"#, - helpers = helpers, - release_binary_deploy = release_binary_deploy, - source_build = RELAY_SOURCE_BUILD_SH, - repo_git_url = shell_quote_posix(REPO_GIT_URL), - source_dir = product_data_relative_path(&["relay-src"]), - port = port, - TASK_DONE_MARKER = TASK_DONE_MARKER, - ) -} - -#[cfg(test)] -mod tests { - use super::{ - classify_docker_access, decide_task_status, deploy_body_script_with_image, - install_docker_body_script, interactive_driver_script, parse_preflight, - prepare_helpers_bash, release_binary_deploy_bash, release_tag_for_version, - split_poll_stdout, stage_scripts_command, to_unix_script, validate_relay_image_descriptor, - verify_minisign, DockerAccessMode, RelayImageDescriptor, RelayTaskStatus, - RELAY_IMAGE_REPOSITORY, RELAY_MIRROR_SH, RELAY_RELEASE_DOWNLOAD_SH, RELEASE_PUBKEY, - }; - - const DESCRIPTOR_TEST_PUBKEY: &str = "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXkgMzA3RTRCNzVFRjdENjU5NApSV1NVWlgzdmRVdCtNTzl3cVl4SHJwQVJQMFhlakUySFY4enEwOE5UWnA4SnpTNHd4SllmVkdEZAo="; - const CURRENT_DESCRIPTOR: &str = r#"{"schema_version":1,"image":"ghcr.io/gcwing/openbitfun-relay-server","version":"1.0.0","tag":"v1.0.0","digest":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","platforms":["linux/amd64","linux/arm64"]} -"#; - const CURRENT_DESCRIPTOR_SIGNATURE: &str = "dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIG1pbmlzaWduIHNlY3JldCBrZXkKUlVTVVpYM3ZkVXQrTUlFWmYyZ1o2ZytZemswaXlxdGkzVjR3RGRqQnAwV0NrMUg4Si9jOVpZODZJcHpXUDRheVFBUldpM0laZkJVN3hYRWxuV3NONWF3VllzODRXYVZReFFzPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzg4ODU5MzU5CWZpbGU6ZGVzY3JpcHRvci5qc29uCWhhc2hlZApBbmxyam9QelU4SjB4NHhrVk9pT1FJay9nbHh6dVZUZ0VsWE5JUEpKYzRIb0E1M2ZYN3FNZ0VMWVBKUlEzRlNkbWEzOFd6THBWS3BPQjFhVjBkT2hBdz09Cg=="; - const UNRELATED_DESCRIPTOR: &str = r#"{"schema_version":1,"image":"ghcr.io/example/another-product","version":"1.0.0","tag":"v1.0.0","digest":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","platforms":["linux/amd64","linux/arm64"]} -"#; - const UNRELATED_DESCRIPTOR_SIGNATURE: &str = "dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIG1pbmlzaWduIHNlY3JldCBrZXkKUlVTVVpYM3ZkVXQrTUFtNVE1VDIyUHdFempFTGpNQ1Y3RVduMFhtUGtjeGU0aFl4bXpoejhleHErc3VBUkJPQ1ZCZnZqVnJ2dnR3dEVlZ2xzdy9yZTB4VEdINy9GZTEzYlFNPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzg4ODU5MzU5CWZpbGU6ZGVzY3JpcHRvci5qc29uCWhhc2hlZApjUkNkTW9hOVNSb3g1dkp1d2gzbG5ObmZCREw1UVM0T2hFbWc2d01YcENZYkc5UllmWDlMZThJRHdFN3BwNHNieFR1VEsyQkl5Und0UmY3YVlPQnlCQT09Cg=="; - - fn test_image_descriptor() -> RelayImageDescriptor { - RelayImageDescriptor { - schema_version: 1, - image: RELAY_IMAGE_REPOSITORY.to_string(), - tag: "v1.2.3".to_string(), - version: "1.2.3".to_string(), - digest: format!("sha256:{}", "a".repeat(64)), - platforms: vec!["linux/amd64".into(), "linux/arm64".into()], - } - } - - #[test] - fn source_fallback_never_accepts_a_different_image_or_invalid_signature() { - let current = super::verified_relay_image_candidate( - CURRENT_DESCRIPTOR, - CURRENT_DESCRIPTOR_SIGNATURE, - DESCRIPTOR_TEST_PUBKEY, - ) - .unwrap() - .unwrap(); - assert_eq!(current.image, RELAY_IMAGE_REPOSITORY); - assert!(super::verified_relay_image_candidate( - UNRELATED_DESCRIPTOR, - UNRELATED_DESCRIPTOR_SIGNATURE, - DESCRIPTOR_TEST_PUBKEY, - ) - .unwrap() - .is_none()); - assert!(super::verified_relay_image_candidate( - UNRELATED_DESCRIPTOR, - CURRENT_DESCRIPTOR_SIGNATURE, - DESCRIPTOR_TEST_PUBKEY, - ) - .is_err()); - assert!( - super::verified_relay_image_candidate( - std::str::from_utf8(FIXTURE_DATA).unwrap(), - FIXTURE_SIGNATURE, - FIXTURE_PUBKEY, - ) - .is_err(), - "authentic but malformed metadata must not trigger a source build" - ); - } - - #[cfg(unix)] - #[test] - fn long_builds_refresh_only_authorized_docker_commands_and_stop_refreshing_on_exit() { - let directory = tempfile::tempdir().unwrap(); - let calls = directory.path().join("calls"); - let script = format!( - r#" -set -euo pipefail -{} -export OPENBITFUN_DOCKER_MODE=sudo -sudo() {{ - printf '%s\n' "$*" >> "$TEST_CALLS" -}} -"#, - prepare_helpers_bash() - ); - let script = script - + r#" -sleep() { command sleep 0.01; } -openbitfun_keep_docker_authorization -for attempt in $(seq 1 100); do - if [ -f "$TEST_CALLS" ] && [ "$(wc -l < "$TEST_CALLS")" -ge 2 ]; then break; fi - command sleep 0.01 -done -test "$(wc -l < "$TEST_CALLS")" -ge 2 -"#; - let output = openbitfun_services_core::process_manager::create_command("bash") - .args(["-c", &script]) - .env("TEST_CALLS", &calls) - .output() - .unwrap(); - assert!( - output.status.success(), - "{}", - String::from_utf8_lossy(&output.stderr) - ); - let contents = std::fs::read_to_string(&calls).unwrap(); - assert!(contents.lines().all(|line| line == "-n docker version")); - std::thread::sleep(std::time::Duration::from_millis(50)); - assert_eq!(std::fs::read_to_string(calls).unwrap(), contents); - } - - #[tokio::test] - async fn descriptor_sources_fail_over_before_selecting_source_build() { - use tokio::io::{AsyncReadExt, AsyncWriteExt}; - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let origin = format!("http://{}", listener.local_addr().unwrap()); - let server = tokio::spawn(async move { - loop { - let (mut stream, _) = listener.accept().await.unwrap(); - let mut request = [0u8; 4096]; - let count = stream.read(&mut request).await.unwrap(); - let request = String::from_utf8_lossy(&request[..count]); - let path = request.split_whitespace().nth(1).unwrap_or(""); - let is_signature = path.ends_with(".sig"); - let body = if path.starts_with("/current/") { - Some(if is_signature { - CURRENT_DESCRIPTOR_SIGNATURE - } else { - CURRENT_DESCRIPTOR - }) - } else if path.starts_with("/unrelated/") { - Some(if is_signature { - UNRELATED_DESCRIPTOR_SIGNATURE - } else { - UNRELATED_DESCRIPTOR - }) - } else if path.starts_with("/tampered/") { - Some(if is_signature { - CURRENT_DESCRIPTOR_SIGNATURE - } else { - UNRELATED_DESCRIPTOR - }) - } else if path.starts_with("/unsigned/") && !is_signature { - Some(CURRENT_DESCRIPTOR) - } else { - None - }; - let response = match body { - Some(body) => format!( - "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", - body.len() - ), - None => { - "HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" - .to_string() - } - }; - stream.write_all(response.as_bytes()).await.unwrap(); - } - }); - let client = crate::reqwest_client_builder().no_proxy().build().unwrap(); - for (sources, expected) in [ - (["missing", "current"], "image"), - (["unrelated", "current"], "image"), - (["tampered", "current"], "image"), - (["missing", "unrelated"], "source"), - (["missing", "unsigned"], "source"), - (["missing", "missing"], "source"), - (["tampered", "missing"], "error"), - (["unrelated", "tampered"], "error"), - ] { - let bases = sources.map(|source| format!("{origin}/{source}")); - let result = - super::resolve_relay_image_descriptor(&client, &bases, DESCRIPTOR_TEST_PUBKEY) - .await; - let actual = match result { - Ok(Some(_)) => "image", - Ok(None) => "source", - Err(_) => "error", - }; - assert_eq!(actual, expected, "{sources:?}"); - } - server.abort(); - } - - #[test] - fn embedded_mirror_script_exposes_init_and_cn_defaults() { - assert!( - RELAY_MIRROR_SH.contains("openbitfun_mirror_init"), - "mirror.sh must define openbitfun_mirror_init" - ); - assert!( - RELAY_MIRROR_SH.contains("rsproxy.cn"), - "mirror.sh must default cargo to rsproxy" - ); - assert!( - RELAY_MIRROR_SH.contains("ghfast.top"), - "mirror.sh must default GitHub proxy" - ); - assert!( - RELAY_MIRROR_SH.contains("openbitfun_mirror_country_via_bash_tcp"), - "mirror.sh must keep a country fallback for minimal hosts without curl" - ); - assert!( - RELAY_MIRROR_SH.contains("openbitfun_mirror_restore_host"), - "mirror.sh must support switching a managed host back to global mode" - ); - assert!( - RELAY_MIRROR_SH.contains("OPENBITFUN_MIRROR_REASON"), - "mirror selection must log why auto detection chose its route" - ); - assert!( - !RELAY_MIRROR_SH.contains("data[\"openbitfun-cn-mirror\"]"), - "daemon.json must contain only dockerd-supported directives" - ); - assert!( - !RELAY_MIRROR_SH.contains("openbitfun_mirror_apply_cargo_config"), - "relay deploy must not rewrite the SSH user's global Cargo config" - ); - let helpers = prepare_helpers_bash(); - assert!( - helpers.contains("openbitfun_mirror_init"), - "prepare helpers must embed mirror.sh" - ); - assert!( - helpers.contains("openbitfun_install_docker_engine"), - "prepare helpers must support install-and-continue deployment" - ); - let driver = interactive_driver_script("deploy", "deploy"); - assert!( - driver.contains("relay.mirror-mode") - && driver.contains("auto|cn|global) export OPENBITFUN_MIRROR"), - "the wizard's explicit mirror choice must reach remote preparation" - ); - } - - /// A CRLF checkout (Git for Windows' `core.autocrlf=true` default) used to - /// ship CRLF straight into the uploaded scripts, and the relay host failed - /// with `line 37: $'\r': command not found` — line 37 being the first blank - /// line of the embedded mirror.sh. - #[test] - fn embedded_scripts_are_lf_only() { - for (name, script) in [ - ("mirror.sh", RELAY_MIRROR_SH), - ("release-download.sh", RELAY_RELEASE_DOWNLOAD_SH), - ] { - assert!( - !script.contains('\r'), - "{name} must be checked out LF-only (see .gitattributes)" - ); - } - } - - /// The remote-side half of the CR guarantee: whatever bytes reached the host, - /// the staged scripts are LF before the PTY runs them. Runs the real command - /// against real CRLF files rather than asserting on its text. - #[cfg(unix)] - #[test] - fn staging_strips_cr_on_the_host_before_execution() { - use std::os::unix::fs::PermissionsExt; - - let dir = tempfile::tempdir().expect("temp dir"); - let p = |name: &str| dir.path().join(name).to_string_lossy().into_owned(); - let (body_path, script_path) = (p("deploy-body.sh"), p("deploy.sh")); - let (pid_path, driver_pid_path) = (p("deploy.pid"), p("deploy.driver.pid")); - let (log_path, prepare_flag) = (p("deploy.log"), p("deploy.preparing")); - - // Simulate an uploader that skipped normalization. - let crlf = "#!/usr/bin/env bash\r\nset -euo pipefail\r\n\r\necho hi\r\n"; - std::fs::write(&body_path, crlf).expect("write body"); - std::fs::write(&script_path, crlf).expect("write driver"); - // Stale files from a previous attempt that staging must clear. - std::fs::write(&pid_path, "1234").expect("write pid"); - std::fs::write(&driver_pid_path, "5678").expect("write driver pid"); - - let command = stage_scripts_command( - &body_path, - &script_path, - &pid_path, - &driver_pid_path, - &log_path, - &prepare_flag, - ); - let output = std::process::Command::new("bash") - .args(["-c", &command]) - .output() - .expect("run staging command"); - assert!( - output.status.success(), - "staging command failed:\n{}", - String::from_utf8_lossy(&output.stderr) - ); - - for path in [&body_path, &script_path] { - let staged = std::fs::read_to_string(path).expect("read staged script"); - assert_eq!( - staged, "#!/usr/bin/env bash\nset -euo pipefail\n\necho hi\n", - "{path} must be LF-only on the host" - ); - let mode = std::fs::metadata(path).expect("stat").permissions().mode(); - assert_eq!( - mode & 0o777, - 0o700, - "{path} must stay owner-only executable" - ); - } - // The rewrite must not leave its scratch file behind. - assert!(!std::path::Path::new(&format!("{script_path}.lf")).exists()); - // No raw CR in the command itself: it would be eaten by to_unix_script - // or by any text-mode hop, silently disabling the strip. - assert!( - !command.contains('\r'), - "the CR strip must not depend on a raw CR surviving transport" - ); - assert!( - !std::path::Path::new(&pid_path).exists(), - "stale pid cleared" - ); - assert!( - !std::path::Path::new(&driver_pid_path).exists(), - "stale driver pid cleared" - ); - assert!(std::path::Path::new(&prepare_flag).exists(), "flag seeded"); - assert_eq!( - std::fs::read_to_string(&log_path).expect("read log"), - "", - "log must be truncated for the incremental cursor" - ); - } - - #[test] - fn uploaded_scripts_are_normalized_to_lf() { - // Simulate a CRLF working tree: every generated script must still leave - // this crate as LF-only bash. - let crlf = "#!/usr/bin/env bash\r\nset -euo pipefail\r\n\r\necho hi\r\n"; - assert_eq!( - to_unix_script(crlf), - "#!/usr/bin/env bash\nset -euo pipefail\n\necho hi\n" - ); - - for (name, script) in [ - ( - "deploy driver", - interactive_driver_script("deploy", "deploy"), - ), - ( - "install driver", - interactive_driver_script("install-docker", "install"), - ), - ( - "deploy body", - deploy_body_script_with_image(9700, &test_image_descriptor()), - ), - ("install body", install_docker_body_script()), - ] { - assert!( - !to_unix_script(&script).contains('\r'), - "{name} must reach the relay host without CR" - ); - } - } - - /// `deploy.sh` on the relay host is this driver, not the repo script — it - /// was previously the only generated script with no syntax coverage. - #[cfg(unix)] - #[test] - fn generated_driver_scripts_are_valid_bash() { - for (stem, kind) in [("deploy", "deploy"), ("install-docker", "install")] { - let script = to_unix_script(&interactive_driver_script(stem, kind)); - let output = std::process::Command::new("bash") - .args(["-n", "-c", &script]) - .output() - .expect("parse generated driver script"); - assert!( - output.status.success(), - "generated {kind} driver is invalid:\n{}", - String::from_utf8_lossy(&output.stderr) - ); - } - } - - #[cfg(unix)] - #[test] - fn generated_install_body_is_valid_bash() { - let script = to_unix_script(&install_docker_body_script()); - let output = std::process::Command::new("bash") - .args(["-n", "-c", &script]) - .output() - .expect("parse generated install script"); - assert!( - output.status.success(), - "generated install body is invalid:\n{}", - String::from_utf8_lossy(&output.stderr) - ); - } - - /// The Docker-install task runs as root with the SSH user's HOME, so it - /// creates ~/.openbitfun/docker-config root-owned. Left that way, the next - /// unprivileged deploy hits `config.json: permission denied` and the docker - /// CLI can mis-dispatch the pull that follows. - #[test] - fn docker_config_ownership_is_repaired_across_privilege_levels() { - let helpers = prepare_helpers_bash(); - assert!( - helpers.contains("openbitfun_fix_docker_config"), - "helpers must expose a DOCKER_CONFIG repair" - ); - - let install = install_docker_body_script(); - assert!( - install.contains("openbitfun_install_docker_engine"), - "standalone install must use the shared Docker installer" - ); - assert!( - helpers.contains(r#"chown -R "$deploy_user" "$HOME/.openbitfun""#), - "root install must hand ~/.openbitfun back to the SSH user" - ); - - // The driver exports OPENBITFUN_DOCKER_MODE, so the body skips - // openbitfun_resolve_docker_mode (which is the other caller of the repair) - // for every non-direct mode. It has to repair the config itself. - let body = deploy_body_script_with_image(9700, &test_image_descriptor()); - let repair = body - .find("openbitfun_fix_docker_config") - .expect("deploy body must repair DOCKER_CONFIG"); - let mode_check = body - .find(r#"if [ "$OPENBITFUN_DOCKER_MODE" = "direct" ]"#) - .expect("deploy body must keep the direct-mode probe"); - assert!( - repair < mode_check, - "DOCKER_CONFIG must be repaired before any docker call, not only in direct mode" - ); - - let driver = interactive_driver_script("deploy", "deploy"); - assert!( - driver.contains("Docker is not installed; installing it before pulling Relay") - && driver.contains("openbitfun_install_docker_engine"), - "the deploy button must install a missing Docker engine and continue" - ); - assert!( - !driver.contains("docker compose missing"), - "the pull-only path must not require Docker Compose" - ); - } - - /// `sg docker -c "docker $*"` re-parsed its arguments through a second - /// shell, losing every boundary — paths with spaces and `-f '{{...}}'` - /// format strings arrived mangled. - #[test] - fn sg_docker_preserves_argument_boundaries() { - let helpers = prepare_helpers_bash(); - // Match the dispatch line, not the comment above it that quotes the - // old form for context. - assert!( - !helpers.contains(r#"sg) sg docker -c "docker $*""#), - "sg path must not re-split arguments through an unquoted $*" - ); - assert!( - helpers.contains("openbitfun_shell_join"), - "sg path must quote each argument" - ); - } - - #[cfg(unix)] - #[test] - fn shell_join_round_trips_through_a_second_shell() { - let helpers = to_unix_script(&prepare_helpers_bash()); - let script = format!( - r#"{helpers} -sh -c "$(openbitfun_shell_join printf '%s\n' 'a b' "it's" '{{{{.State.Running}}}}' '*')" -"# - ); - let output = std::process::Command::new("bash") - .arg("-c") - .arg(&script) - .output() - .expect("run shell join round trip"); - assert!( - output.status.success(), - "shell join failed:\n{}", - String::from_utf8_lossy(&output.stderr) - ); - assert_eq!( - String::from_utf8_lossy(&output.stdout), - "a b\nit's\n{{.State.Running}}\n*\n", - "each argument must survive the second shell verbatim" - ); - } - - #[test] - fn one_click_uses_digest_pinned_prebuilt_images_and_regional_routes() { - let script = release_binary_deploy_bash(); - assert!(script.contains("OPENBITFUN_RELAY_IMAGE_DIGEST")); - assert!(script.contains("m.daocloud.io/${OPENBITFUN_RELAY_IMAGE}")); - assert!(script.contains("ghcr.nju.edu.cn/${OPENBITFUN_RELAY_IMAGE#ghcr.io/}")); - assert!(script.contains("official GHCR fallback")); - assert!(script.contains("OPENBITFUN_GITHUB_HEALTHY_BPS")); - assert!(script.contains("openbitfun_probe_github_throughput")); - assert!(script.contains("524288")); - assert!(script.contains("pull --platform")); - assert!(script.contains("openbitfun_restore_previous_relay")); - assert!(script.contains("--name openbitfun-relay")); - assert!(script.contains("relay-server_relay-db:/app/data")); - assert!(script.contains("RELAY_PAGE_PUBLIC_BASE_URL")); - assert!(script.contains("RELAY_PAGE_AUTH_BASE_URL")); - assert!(script.contains("trap 'openbitfun_restore_previous_relay")); - assert!(script.contains("name=^openbitfun-relay-before-image-")); - assert!(!script.contains("docker build")); - assert!(!script.contains("cargo build")); - assert!( - !script.contains("tar -x") && !script.contains("docker load"), - "the raw archive is only a throughput probe, never an install path" - ); - } - - /// `docker logs` relays the container's stderr on its own stderr, and the - /// relay logs through tracing — so `2>/dev/null` hid the one message that - /// explained the failure (`version 'GLIBC_2.38' not found`). - #[test] - fn health_check_failure_keeps_container_diagnostics() { - let script = release_binary_deploy_bash(); - let failure = script - .split_once("failed its health check") - .expect("health failure branch") - .1; - let logs = failure - .split_once("logs --tail 40 openbitfun-relay") - .expect("failure branch must dump container logs") - .1; - assert!( - logs.starts_with(" 2>&1"), - "container stderr must be kept, not sent to /dev/null" - ); - assert!( - failure.contains("Container state:"), - "must report whether the container died or was up but not answering" - ); - } - - #[test] - fn release_tag_tracks_stable_and_nightly_channels() { - assert_eq!(release_tag_for_version("0.2.13"), "v0.2.13"); - assert_eq!( - release_tag_for_version("0.2.14-nightly.20260724+abc123"), - "nightly" - ); - } - - #[test] - fn signed_descriptor_is_strictly_bound_to_repository_tag_digest_and_platforms() { - let descriptor = test_image_descriptor(); - validate_relay_image_descriptor(&descriptor).unwrap(); - - for invalid in [ - RelayImageDescriptor { - image: "ghcr.io/attacker/relay".into(), - ..descriptor.clone() - }, - RelayImageDescriptor { - tag: "v9.9.9".into(), - ..descriptor.clone() - }, - RelayImageDescriptor { - digest: format!("sha256:{}", "A".repeat(64)), - ..descriptor.clone() - }, - RelayImageDescriptor { - platforms: vec!["linux/amd64".into()], - ..descriptor.clone() - }, - ] { - assert!(validate_relay_image_descriptor(&invalid).is_err()); - } - - let body = deploy_body_script_with_image(9700, &descriptor); - assert!(body.contains(&format!( - "export OPENBITFUN_RELAY_IMAGE={}", - descriptor.image - ))); - assert!(body.contains(&format!( - "export OPENBITFUN_RELAY_IMAGE_DIGEST={}", - descriptor.digest - ))); - assert!(body.contains("export OPENBITFUN_RELEASE_TAG=v1.2.3")); - assert!(body.contains("export OPENBITFUN_RELEASE_VERSION=1.2.3")); - assert!(body.contains("export OPENBITFUN_REQUIRE_IMAGE_DIGEST=1")); - assert!(!body.contains("openbitfun_sync_source")); - assert!(!body.contains("openbitfun_run_deploy_sh")); - } - - /// Same fixture as the CLI updater, produced with the Tauri signer CLI. - /// Descriptor bytes from any origin must pass this verification before a - /// digest is sent to a relay host. - const FIXTURE_PUBKEY: &str = "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IERENTQzQUM5RUY0NTIzRTMKUldUakkwWHZ5VHBVM1NOMXJWMHhLVlljSDBOY2x4YlpxVHA2clN1NEJPMWcyY2Qvd2U4VUR2b3AK"; - const FIXTURE_SIGNATURE: &str = "dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUlVUakkwWHZ5VHBVM2RVVFdoR3FNZDltSWNUeEQ1K2ZnNWRUSnYxWk5lUkZzd0h0MkdzSUhUSlV6a0haUTdNZm1aemM5QVBQWW50UWgvaWpFcEp1Zkp4SERWdnhIc1g2YUFrPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzg4NDg2NTU4CWZpbGU6Lm9wZW5iaXRmdW4tbWluaXNpZ24tZml4dHVyZS50eHQKa1QxdDQ3bWtLVlhaZUdFSjR4R0V5R1Z3REVnUlI0RGJqbHFoZkVHdkdLSlFyTGJ5Z05JRTI5V3dwdXRkSFpZckUrK0RaUVVJYUJod1dzcmVydHZnQXc9PQo="; - const FIXTURE_DATA: &[u8] = b"hello-openbitfun\n"; - - #[test] - fn checksum_signature_verifies_and_rejects_tampering() { - verify_minisign(FIXTURE_DATA, FIXTURE_SIGNATURE, FIXTURE_PUBKEY) - .expect("minisign signature in Tauri's base64 wrapper must verify"); - assert!(verify_minisign(b"tampered\n", FIXTURE_SIGNATURE, FIXTURE_PUBKEY).is_err()); - assert!(verify_minisign(FIXTURE_DATA, "bm90LWEtc2ln", FIXTURE_PUBKEY).is_err()); - } - - /// The official key is embedded as the default trust root, so even keyless - /// development builds can verify the published image descriptor before - /// asserting a digest to the remote. - #[test] - fn builds_always_carry_a_release_trust_root() { - assert!(RELEASE_PUBKEY.is_none() || RELEASE_PUBKEY == Some("")); - assert!(super::release_pubkey().is_some()); - } - - #[cfg(unix)] - #[test] - fn generated_deploy_script_is_valid_bash() { - let script = deploy_body_script_with_image(9700, &test_image_descriptor()); - let output = std::process::Command::new("bash") - .args(["-n", "-c", &script]) - .output() - .expect("parse generated deploy script"); - assert!( - output.status.success(), - "generated deploy script is invalid:\n{}", - String::from_utf8_lossy(&output.stderr) - ); - } - - #[cfg(unix)] - #[test] - fn china_image_pull_fails_over_between_digest_pinned_routes() { - let temp = tempfile::tempdir().expect("temp dir"); - let script_path = temp.path().join("release-image.sh"); - let trace_path = temp.path().join("pulls.log"); - std::fs::write(&script_path, release_binary_deploy_bash()).expect("write image script"); - - let output = std::process::Command::new("bash") - .arg("-c") - .arg( - r#" -set -euo pipefail -source "$1" -export TRACE="$2" -export OPENBITFUN_MIRROR_MODE=cn -export OPENBITFUN_RELAY_IMAGE_DIGEST="sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" -openbitfun_image_docker_with_timeout() { - shift - printf '%s\n' "$*" >>"$TRACE" - case "$*" in - *ghcr.nju.edu.cn*) return 1 ;; - *m.daocloud.io*) return 0 ;; - *) return 1 ;; - esac -} -openbitfun_image_docker() { - if [ "$1 $2" = "image inspect" ]; then echo amd64; return 0; fi - return 1 -} -selected="$(openbitfun_pull_relay_image linux/amd64)" -test "$selected" = "m.daocloud.io/ghcr.io/gcwing/openbitfun-relay-server@$OPENBITFUN_RELAY_IMAGE_DIGEST" -"#, - ) - .arg("image-route-failover") - .arg(&script_path) - .arg(&trace_path) - .output() - .expect("run image route harness"); - assert!( - output.status.success(), - "image route harness failed:\nstdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - let pulls = std::fs::read_to_string(trace_path).expect("read pull trace"); - let routes: Vec<_> = pulls.lines().collect(); - assert_eq!(routes.len(), 2); - assert!(routes[0].contains("ghcr.nju.edu.cn/gcwing/openbitfun-relay-server@sha256:")); - assert!(routes[1].contains("m.daocloud.io/ghcr.io/gcwing/openbitfun-relay-server@sha256:")); - } - - #[cfg(unix)] - #[test] - fn automatic_image_pull_keeps_github_when_healthy_and_uses_mirror_when_slow() { - let temp = tempfile::tempdir().expect("temp dir"); - let script_path = temp.path().join("release-image.sh"); - std::fs::write(&script_path, release_binary_deploy_bash()).expect("write image script"); - - let output = std::process::Command::new("bash") - .arg("-c") - .arg( - r#" -set -euo pipefail -source "$1" -export OPENBITFUN_MIRROR_REQUESTED_MODE=auto -export OPENBITFUN_RELAY_IMAGE_DIGEST="sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" -openbitfun_probe_github_throughput() { echo "$MOCK_SPEED"; } -openbitfun_image_docker_with_timeout() { return 0; } -openbitfun_image_docker() { - if [ "$1 $2" = "image inspect" ]; then echo amd64; return 0; fi - return 1 -} - -MOCK_SPEED=524288 -healthy="$(openbitfun_pull_relay_image linux/amd64)" -test "$healthy" = "ghcr.io/gcwing/openbitfun-relay-server@$OPENBITFUN_RELAY_IMAGE_DIGEST" - -MOCK_SPEED=524287 -slow="$(openbitfun_pull_relay_image linux/amd64)" -test "$slow" = "ghcr.nju.edu.cn/gcwing/openbitfun-relay-server@$OPENBITFUN_RELAY_IMAGE_DIGEST" -"#, - ) - .arg("image-speed-policy") - .arg(&script_path) - .output() - .expect("run image speed harness"); - assert!( - output.status.success(), - "image speed harness failed:\nstdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - } - - #[cfg(unix)] - #[test] - fn mirror_docker_config_round_trip_preserves_unmanaged_settings() { - use std::{fs, process::Command}; - - let temp = tempfile::tempdir().expect("create mirror test dir"); - let mirror_path = temp.path().join("mirror.sh"); - let daemon_path = temp.path().join("etc/docker/daemon.json"); - fs::create_dir_all(daemon_path.parent().expect("daemon parent")) - .expect("create daemon dir"); - fs::write(&mirror_path, RELAY_MIRROR_SH).expect("write embedded mirror script"); - fs::write( - &daemon_path, - r#"{ - "debug": true, - "registry-mirrors": ["https://user.example"] -} -"#, - ) - .expect("write initial daemon config"); - - let output = Command::new("bash") - .arg("-c") - .arg( - r#" -set -euo pipefail -export HOME="$2/home" -export OPENBITFUN_DOCKER_DAEMON_JSON="$2/etc/docker/daemon.json" -mkdir -p "$HOME" -source "$1" -openbitfun_mirror_priv() { "$@"; } -openbitfun_mirror_backup_file() { :; } -openbitfun_mirror_restart_docker_if_needed() { :; } -openbitfun_mirror_write_docker_daemon_json \ - "https://docker.1ms.run https://dockerproxy.net" -python3 - "$OPENBITFUN_DOCKER_DAEMON_JSON" <<'PY' -import json, sys -with open(sys.argv[1], encoding="utf-8") as f: - data = json.load(f) -assert data["debug"] is True -assert data["registry-mirrors"] == [ - "https://user.example", - "https://docker.1ms.run", - "https://dockerproxy.net", -] -assert "openbitfun-cn-mirror" not in data -PY -openbitfun_mirror_remove_docker_daemon -python3 - "$OPENBITFUN_DOCKER_DAEMON_JSON" <<'PY' -import json, sys -with open(sys.argv[1], encoding="utf-8") as f: - data = json.load(f) -assert data == { - "debug": True, - "registry-mirrors": ["https://user.example"], -} -PY -"#, - ) - .arg("mirror-round-trip") - .arg(&mirror_path) - .arg(temp.path()) - .output() - .expect("run mirror round-trip test"); - - assert!( - output.status.success(), - "mirror round trip failed:\nstdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - } - - #[test] - fn decide_status_pending_before_pty_is_running() { - // preparing, no log yet. - assert_eq!( - decide_task_status(false, false, true, false, true, 0, 0, false), - RelayTaskStatus::Running - ); - // Nothing staged yet at all. - assert_eq!( - decide_task_status(false, false, false, false, false, 0, 0, false), - RelayTaskStatus::Running - ); - } - - #[test] - fn decide_status_growing_log_without_pid_is_running() { - assert_eq!( - decide_task_status(false, false, false, false, true, 1000, 100, true), - RelayTaskStatus::Running - ); - } - - #[test] - fn decide_status_dead_pid_stale_log_is_failed() { - assert_eq!( - decide_task_status(false, false, false, false, true, 1000, 1000, false), - RelayTaskStatus::Failed - ); - } - - /// A driver that died before installing its cleanup trap (bad script upload, - /// syntax error) leaves the seeded `preparing` flag and an empty log. That - /// used to read as "running" forever; the wizard never surfaced the failure. - #[test] - fn decide_status_dead_driver_with_empty_log_is_failed() { - assert_eq!( - decide_task_status(false, false, false, true, true, 0, 0, false), - RelayTaskStatus::Failed - ); - // An alive driver still outranks the grace window — a sudo password - // prompt can legitimately sit for minutes. - assert_eq!( - decide_task_status(false, false, true, false, true, 0, 0, false), - RelayTaskStatus::Running - ); - // Success wins even if the prepare flag was left behind. - assert_eq!( - decide_task_status(true, false, false, true, true, 10, 0, true), - RelayTaskStatus::Succeeded - ); - } - - #[test] - fn split_poll_stdout_accepts_lf() { - let (head, out) = split_poll_stdout("running=1\nsize=12\nmarker=0\n---\nhello\n"); - assert!(head.contains("running=1")); - assert_eq!(out, "hello\n"); - } - - #[test] - fn split_poll_stdout_accepts_crlf() { - let (head, out) = split_poll_stdout("running=1\r\nsize=12\r\nmarker=0\r\n---\r\nworld\r\n"); - assert!(head.contains("running=1")); - assert_eq!(out, "world\r\n"); - } - - #[test] - fn split_poll_stdout_missing_marker_yields_empty_body() { - let (head, out) = split_poll_stdout("running=0\nsize=0\nmarker=0\n"); - assert!(head.contains("running=0")); - assert_eq!(out, ""); - } - - #[test] - fn classify_broken_docker_home() { - assert_eq!( - classify_docker_access(true, "ok", true, true, false, true, false), - DockerAccessMode::BrokenDockerHome - ); - } - - #[test] - fn classify_group_inactive() { - assert_eq!( - classify_docker_access(true, "unreachable", false, true, true, false, true), - DockerAccessMode::GroupInactive - ); - } - - #[test] - fn parse_preflight_reads_new_fields() { - let out = r#" -os=Linux -arch=x86_64 -home=/home/ubuntu -docker=1 -compose=1 -daemon=ok -curl=1 -tar=1 -sudo=0 -sudo_needs_password=1 -active_docker_group=0 -in_docker_group_file=1 -docker_home_writable=0 -mem_kb=2097152 -home_free_kb=12582912 -docker_free_kb=8388608 -port_busy=0 -container=1 -container_running=1 -existing_port=9700 -healthy=1 -port_owned=0 -"#; - let pf = parse_preflight(out, 9701); - assert!(pf.arch_supported); - assert_eq!(pf.docker_access_mode, DockerAccessMode::BrokenDockerHome); - assert!(pf.in_docker_group_file); - assert!(!pf.docker_home_writable); - assert!(pf.tar_available); - assert!(pf.sudo_needs_password); - assert_eq!(pf.probed_port, 9701); - assert!(pf.container_exists); - assert!(pf.container_running); - assert_eq!(pf.existing_relay_port, 9700); - assert!(pf.relay_healthy); - assert!(!pf.port_owned_by_relay); - assert_eq!(pf.home_free_mb, 12288); - assert_eq!(pf.docker_free_mb, 8192); - } -} diff --git a/src/crates/services/services-integrations/tests/remote_connect_contracts.rs b/src/crates/services/services-integrations/tests/remote_connect_contracts.rs index af31c8c76d..43b4192d48 100644 --- a/src/crates/services/services-integrations/tests/remote_connect_contracts.rs +++ b/src/crates/services/services-integrations/tests/remote_connect_contracts.rs @@ -32,9 +32,8 @@ use openbitfun_services_integrations::remote_connect::{ remote_workspace_updated_response, resolve_remote_agent_type, resolve_remote_cancel_decision, resolve_remote_execution_image_contexts, resolve_remote_file_chunk_range, resolve_remote_workspace_path, should_send_remote_model_catalog, submit_remote_dialog, - ActiveTurnSnapshot, ChatImageAttachment, ChatMessage, ChatMessageItem, DeviceIdentity, - ImageAttachment, KeyPair, PairingChallenge, PairingProtocol, PairingResponse, PairingState, - QrGenerator, QrPayload, RelayMessage, RemoteAssistantWorkspaceFacts, RemoteCancelDecision, + ActiveTurnSnapshot, ChatImageAttachment, ChatMessage, ChatMessageItem, ImageAttachment, + QrGenerator, RelayMessage, RemoteAssistantWorkspaceFacts, RemoteCancelDecision, RemoteCancelRuntimeHost, RemoteCancelTaskRequest, RemoteChatHistoryRound, RemoteChatHistoryTextItem, RemoteChatHistoryThinkingItem, RemoteChatHistoryToolCall, RemoteChatHistoryToolItem, RemoteChatHistoryTurn, RemoteCommand, RemoteCommandRuntimeHost, @@ -55,104 +54,26 @@ use openbitfun_services_integrations::remote_connect::{ use std::path::PathBuf; use std::sync::{Arc, Mutex}; -#[tokio::test] -async fn remote_connect_pairing_primitives_live_in_services_owner() { - let desktop = DeviceIdentity { - device_id: "desktop-id".to_string(), - device_name: "Desktop".to_string(), - mac_address: "00:11:22:33:44:55".to_string(), - }; - let mobile = DeviceIdentity { - device_id: "mobile-id".to_string(), - device_name: "Mobile".to_string(), - mac_address: "66:77:88:99:AA:BB".to_string(), - }; - - let mut protocol = PairingProtocol::new(desktop); - let payload = protocol - .initiate("https://relay.example.com") - .await - .unwrap(); - assert_eq!(protocol.state().await, PairingState::WaitingForScan); - assert_eq!(payload.url, "https://relay.example.com"); - - let mobile_keypair = KeyPair::generate(); - let challenge = protocol - .on_peer_joined(&mobile_keypair.public_key_base64()) - .await - .unwrap(); - let response = PairingProtocol::answer_challenge( - &challenge, - &mobile, - Some("install-1".to_string()), - Some("user-1".to_string()), - ); - - assert!(protocol.verify_response(&response).await.unwrap()); - assert_eq!(protocol.state().await, PairingState::Connected); -} - #[test] -fn remote_connect_qr_and_relay_primitives_live_in_services_owner() { - let payload = QrPayload { - room_id: "room 1".to_string(), - url: "https://relay.example.com/socket".to_string(), - device_id: "device/id".to_string(), - device_name: "Desktop Device".to_string(), - public_key: "public/key".to_string(), - version: 1, - }; - - let url = QrGenerator::build_url(&payload, "https://mobile.example.com/", "zh-CN", None); - assert!(url.starts_with("https://mobile.example.com/#/pair?")); - assert!(url.contains("relay=wss%3A%2F%2Frelay.example.com%2Fsocket")); - assert!(url.contains("lang=zh-CN")); - assert!(!url.contains("auth=account")); - - let account_url = QrGenerator::build_url( - &payload, - "https://mobile.example.com/", - "zh-CN", - Some("alice"), - ); - assert!(account_url.contains("auth=account")); - assert!(account_url.contains("user=alice")); - - let auth_only_url = - QrGenerator::build_url(&payload, "https://mobile.example.com/", "zh-CN", Some("")); - assert!(auth_only_url.contains("auth=account")); - assert!(!auth_only_url.contains("user=")); - - let with_password = PairingProtocol::answer_challenge_with_password( - &PairingChallenge { - challenge: "abc".to_string(), - timestamp: 1, - }, - &DeviceIdentity { - device_id: "m1".to_string(), - device_name: "Phone".to_string(), - mac_address: "00:00:00:00:00:00".to_string(), - }, - Some("install-1".to_string()), - Some("alice".to_string()), - Some("secret".to_string()), - ); - let json = serde_json::to_value(&with_password).expect("serialize pairing response"); - assert_eq!(json["user_id"], "alice"); - assert_eq!(json["password"], "secret"); - let parsed: PairingResponse = - serde_json::from_value(json).expect("deserialize pairing response"); - assert_eq!(parsed.password.as_deref(), Some("secret")); - - let message = RelayMessage::CreateRoom { - room_id: Some(payload.room_id), - device_id: payload.device_id, - device_type: "desktop".to_string(), - public_key: payload.public_key, +fn relay_invitations_and_authentication_use_the_same_protocol_for_all_endpoints() { + for endpoint in [ + "https://remote.openbitfun.com/v/1.0.0", + "http://192.168.1.8:9700", + ] { + assert_eq!( + QrGenerator::build_device_url(endpoint, "desktop-1").unwrap(), + format!("{endpoint}/#/pair?did=desktop-1") + ); + } + let message = RelayMessage::AuthConnect { + token: "test-token".into(), + device_name: "Desktop".into(), + device_kind: "desktop".into(), }; - let json = serde_json::to_value(message).expect("serialize relay message"); - assert_eq!(json["type"], "create_room"); - assert_eq!(json["device_type"], "desktop"); + assert_eq!( + serde_json::to_value(message).unwrap()["type"], + "auth_connect" + ); } #[test] @@ -2287,26 +2208,6 @@ fn remote_connect_command_wire_shape_lives_in_owner_contract() { assert_eq!(poll["since_version"], 7); assert_eq!(poll["known_msg_count"], 3); assert_eq!(poll["known_model_catalog_version"], 11); - - let get_identity = serde_json::to_value(RemoteCommand::GetDelegatedIdentity) - .expect("serialize get delegated identity command"); - assert_eq!(get_identity["cmd"], "get_delegated_identity"); - let parsed: RemoteCommand = serde_json::from_str(r#"{"cmd":"get_delegated_identity"}"#) - .expect("parse get delegated identity command"); - assert_eq!(parsed, RemoteCommand::GetDelegatedIdentity); - - let identity = serde_json::to_value(RemoteResponse::DelegateIdentity { - token: "token-1".to_string(), - user_id: "user-1".to_string(), - master_key: "bWFzdGVyLWtleQ==".to_string(), - device_id: "device-1".to_string(), - }) - .expect("serialize delegate identity response"); - assert_eq!(identity["resp"], "delegate_identity"); - assert_eq!(identity["token"], "token-1"); - assert_eq!(identity["user_id"], "user-1"); - assert_eq!(identity["master_key"], "bWFzdGVyLWtleQ=="); - assert_eq!(identity["device_id"], "device-1"); } #[test] diff --git a/src/miniapp-market-web/README.md b/src/miniapp-market-web/README.md index eaa0c010e1..d744df87d5 100644 --- a/src/miniapp-market-web/README.md +++ b/src/miniapp-market-web/README.md @@ -195,3 +195,9 @@ pnpm run theme:color-audit:all 完整的备份、精确 commit 发布、健康检查和回滚命令见 [生产部署手册](../../deploy/miniapp-market/README.md)。 + +GitHub sign-in uses `https://auth.openbitfun.com/sign-in`; desktop completion +uses its standalone `/complete` page without marketplace navigation. The page +is built from this frontend, while the registered OAuth callback remains on the +market host so MiniApp and Skin receive their existing path-scoped session +cookies. Legacy `/miniapp/auth/desktop-complete` links remain readable. diff --git a/src/miniapp-market-web/src/App.tsx b/src/miniapp-market-web/src/App.tsx index b978cb61a3..323c5166c7 100644 --- a/src/miniapp-market-web/src/App.tsx +++ b/src/miniapp-market-web/src/App.tsx @@ -87,6 +87,30 @@ function navigate(path: string) { } function App() { + const path = window.location.pathname; + if (window.location.hostname === 'auth.openbitfun.com' || path === '/miniapp/auth/complete' || path === '/miniapp/auth/desktop-complete') { + return ; + } + return ; +} + +function GitHubIdentityPage({ complete }: { complete: boolean }) { + const { t } = useLocale(); + useTheme(); + useEffect(() => { document.title = `OpenBitFun · ${t('signIn')}`; }, [t]); + return + {complete ? :
+
+
+
} +
; +} + +function MarketApp() { const { locale, setLocale, t } = useLocale(); const { theme, toggleTheme } = useTheme(); const [route, setRoute] = useState(currentRoute); diff --git a/src/miniapp-market-web/src/api.test.ts b/src/miniapp-market-web/src/api.test.ts index 7e24318b2e..b1ae5ba9b3 100644 --- a/src/miniapp-market-web/src/api.test.ts +++ b/src/miniapp-market-web/src/api.test.ts @@ -19,7 +19,7 @@ describe('market API paths', () => { it('uses the broker camelCase return target contract', () => { expect(loginUrl('/miniapp/admin')).toBe( - '/miniapp/api/v1/auth/github/start?returnTo=%2Fminiapp%2Fadmin', + 'https://auth.openbitfun.com/sign-in?returnTo=%2Fminiapp%2Fadmin', ); }); }); diff --git a/src/miniapp-market-web/src/api.ts b/src/miniapp-market-web/src/api.ts index c4f0d7317e..bb18e0f38b 100644 --- a/src/miniapp-market-web/src/api.ts +++ b/src/miniapp-market-web/src/api.ts @@ -137,7 +137,7 @@ export const marketApi = { }; export function loginUrl(returnTo = window.location.pathname): string { - return `${API}/auth/github/start?returnTo=${encodeURIComponent(returnTo)}`; + return `https://auth.openbitfun.com/sign-in?returnTo=${encodeURIComponent(returnTo)}`; } export function downloadUrl(slug: string, release: number): string { diff --git a/src/miniapp-market-web/src/i18n.ts b/src/miniapp-market-web/src/i18n.ts index ad4b4f6063..27ad5467fa 100644 --- a/src/miniapp-market-web/src/i18n.ts +++ b/src/miniapp-market-web/src/i18n.ts @@ -62,7 +62,8 @@ const messages = { unpublish: 'Unpublish listing', back: 'Back to market', authComplete: 'GitHub authorization complete', - authCompleteBody: 'You can return to OpenBitFun. This browser tab may be closed.', + authCompleteBody: 'Return to the app or browser window where you started signing in. You can close this tab.', + authSharedIdentity: 'Use your GitHub identity for the marketplaces and remote device control.', footerNote: 'Reviewed releases / Hash-locked packages / Manual updates', openbitfunHome: 'OpenBitFun home', getOpenBitFunTitle: 'New to OpenBitFun?', @@ -214,7 +215,8 @@ const messages = { unpublish: '下架 Listing', back: '返回市场', authComplete: 'GitHub 授权完成', - authCompleteBody: '现在可以返回 OpenBitFun,并关闭这个浏览器标签页。', + authCompleteBody: '请返回发起登录的应用或浏览器窗口。你可以关闭此标签页。', + authSharedIdentity: '市场与远程设备控制共用你的 GitHub 身份。', footerNote: '人工审核版本 / 哈希锁定安装包 / 手动更新', openbitfunHome: 'OpenBitFun 官网', getOpenBitFunTitle: '没有 OpenBitFun?', @@ -365,7 +367,8 @@ const messages = { unpublish: '下架 Listing', back: '返回市場', authComplete: 'GitHub 授權完成', - authCompleteBody: '現在可以返回 OpenBitFun,並關閉這個瀏覽器分頁。', + authCompleteBody: '請返回發起登入的應用程式或瀏覽器視窗。你可以關閉此分頁。', + authSharedIdentity: '市場與遠端裝置控制共用你的 GitHub 身分。', footerNote: '人工審核版本 / 雜湊鎖定安裝包 / 手動更新', openbitfunHome: 'OpenBitFun 官網', getOpenBitFunTitle: '還沒有 OpenBitFun?', diff --git a/src/mobile-web/package.json b/src/mobile-web/package.json index 5c292bc2dc..f3410146d1 100644 --- a/src/mobile-web/package.json +++ b/src/mobile-web/package.json @@ -26,7 +26,6 @@ "react-dom": "^18.3.1", "react-markdown": "^10.1.0", "react-syntax-highlighter": "^15.6.6", - "qr-scanner": "^1.4.2", "remark-gfm": "^4.0.1", "zustand": "^5.0.10" }, diff --git a/src/mobile-web/src/App.tsx b/src/mobile-web/src/App.tsx index 4a15d6821d..11e5b6a857 100644 --- a/src/mobile-web/src/App.tsx +++ b/src/mobile-web/src/App.tsx @@ -10,7 +10,7 @@ import { RelayHttpClient } from './services/RelayHttpClient'; import { RemoteSessionManager, } from './services/RemoteSessionManager'; -import { reconcileDelegatedAccountOwner } from './services/delegatedAccountOwner'; +import { reconcileAccountOwner } from './services/accountOwner'; import { clearMobileNavigation, saveMobileNavigation, @@ -56,7 +56,7 @@ const AppContent: React.FC = () => { const isWideLayout = useWideLayout(); const connectionHealth = useMobileStore((state) => state.connectionHealth); const clientRef = useRef(null); - const delegatedOwnerUnlistenRef = useRef<(() => void) | null>(null); + const accountOwnerUnlistenRef = useRef<(() => void) | null>(null); const sessionMgrRef = useRef(null); const [sessionMgr, setSessionMgr] = useState(null); const [accountDirectoryOpen, setAccountDirectoryOpen] = useState(false); @@ -137,14 +137,14 @@ const AppContent: React.FC = () => { navigation?: PairedNavigation, ) => { navigationRef.current = navigation ?? null; - const needsDevice = client.hasDelegatedIdentity && !client.isPaired && !client.pairedDeviceId; + const needsDevice = client.hasAccountIdentity && !client.targetDeviceId; setAccountDirectoryOpen(needsDevice); setPreferredDeviceId(preferredDeviceId); - delegatedOwnerUnlistenRef.current?.(); + accountOwnerUnlistenRef.current?.(); clientRef.current = client; - delegatedOwnerUnlistenRef.current = client.onDelegatedAccountOwnerChange((change) => { + accountOwnerUnlistenRef.current = client.onAccountOwnerChange((change) => { if (clientRef.current !== client) return; - const ownerScopedStateWasReset = reconcileDelegatedAccountOwner(change); + const ownerScopedStateWasReset = reconcileAccountOwner(change); if (!ownerScopedStateWasReset) return; navigationRef.current = null; @@ -260,7 +260,7 @@ const AppContent: React.FC = () => { clearTimeout(timerRef.current); const restored = navigationRef.current?.restored; if (navigationRef.current) navigationRef.current.restored = null; - if (restored && restored.deviceId === clientRef.current?.pairedDeviceId && restored.session) { + if (restored && restored.deviceId === clientRef.current?.targetDeviceId && restored.session) { setActiveSessionId(restored.session.id); setActiveSessionName(restored.session.name); setActiveSessionAgentType(restored.session.agentType); @@ -290,8 +290,8 @@ const AppContent: React.FC = () => { clearMobileNavigation(); setAccountDirectoryOpen(false); setPreferredDeviceId(undefined); - delegatedOwnerUnlistenRef.current?.(); - delegatedOwnerUnlistenRef.current = null; + accountOwnerUnlistenRef.current?.(); + accountOwnerUnlistenRef.current = null; clientRef.current?.resetConnectionIdentity(); clientRef.current = null; sessionMgrRef.current = null; @@ -311,14 +311,14 @@ const AppContent: React.FC = () => { }, []); useEffect(() => () => { - delegatedOwnerUnlistenRef.current?.(); - delegatedOwnerUnlistenRef.current = null; + accountOwnerUnlistenRef.current?.(); + accountOwnerUnlistenRef.current = null; }, []); useEffect(() => { const navigation = navigationRef.current; if (!navigation || accountDirectoryOpen || page === 'pairing' || !controlTarget - || controlTarget.deviceId !== clientRef.current?.pairedDeviceId) return; + || controlTarget.deviceId !== clientRef.current?.targetDeviceId) return; saveMobileNavigation(navigation.scope, { deviceId: controlTarget.deviceId, session: page === 'chat' && activeSessionId ? { diff --git a/src/mobile-web/src/components/PairingForm.tsx b/src/mobile-web/src/components/PairingForm.tsx index 78e2d8bcf4..9c973825ab 100644 --- a/src/mobile-web/src/components/PairingForm.tsx +++ b/src/mobile-web/src/components/PairingForm.tsx @@ -1,188 +1,34 @@ import React from 'react'; -import { - MobileBanner, - MobileButton, - MobileDisclosure, - MobileIconButton, - MobileTextField, -} from '@openbitfun/ui/mobile'; +import { MobileBanner, MobileButton, MobileStatus } from '@openbitfun/ui/mobile'; import { useI18n } from '../i18n'; interface PairingFormProps { - advancedOpen: boolean; + busy: boolean; error: string | null; - hasPairingDescriptor: boolean; - isLocked: boolean; - password: string; - passwordInputRef: React.RefObject; - relayUrl: string; - remainingLockSeconds: number; - requiresAccountAuth: boolean; - showPassword: boolean; - showSpinner: boolean; - submitting: boolean; - userId: string; - usernameInputRef: React.RefObject; - onAdvancedOpenChange: (open: boolean) => void; - onConnect: () => void; - onOpenScanner: () => void; - onPasswordChange: (value: string) => void; - onRelayUrlChange: (value: string) => void; - onShowPasswordChange: (visible: boolean) => void; - onUserIdChange: (value: string) => void; + onSignIn: () => void; + onCancel: () => void; } -/** Visual contract for manual account/pairing authentication. */ -const PairingForm: React.FC = ({ - advancedOpen, - error, - hasPairingDescriptor, - isLocked, - password, - passwordInputRef, - relayUrl, - remainingLockSeconds, - requiresAccountAuth, - showPassword, - showSpinner, - submitting, - userId, - usernameInputRef, - onAdvancedOpenChange, - onConnect, - onOpenScanner, - onPasswordChange, - onRelayUrlChange, - onShowPasswordChange, - onUserIdChange, -}) => { +/** Presentation for the global GitHub sign-in. */ +const PairingForm: React.FC = ({ busy, error, onSignIn, onCancel }) => { const { t } = useI18n(); - const disabled = submitting || isLocked; - return ( -
{ event.preventDefault(); onConnect(); }}> + { event.preventDefault(); onSignIn(); }}>
-

- {requiresAccountAuth ? t('pairing.loginTitle') : t('pairing.connectTitle')} -

-

- {requiresAccountAuth ? t('pairing.loginDescription') : t('pairing.note')} -

-
-
- onAdvancedOpenChange(!advancedOpen)} - open={advancedOpen} - title={t('pairing.advancedOptions')} - > -
-
- {t('pairing.loginServer')} - onRelayUrlChange(event.target.value)} - disabled={disabled} - /> -
-
-
+

{t('pairing.loginTitle')}

+

{t('pairing.githubDescription')}

{error && {error}} + {busy && }
- - {submitting - ? t('pairing.connecting') - : isLocked - ? t('pairing.retryIn', { seconds: remainingLockSeconds }) - : requiresAccountAuth - ? t('pairing.loginAction') - : t('pairing.continue')} + + {t('pairing.githubSignIn')} - {!hasPairingDescriptor && ( - - )} + {busy && {t('common.cancel')}}
); }; - export default PairingForm; diff --git a/src/mobile-web/src/components/QrScannerSheet.tsx b/src/mobile-web/src/components/QrScannerSheet.tsx deleted file mode 100644 index b4c34b1c95..0000000000 --- a/src/mobile-web/src/components/QrScannerSheet.tsx +++ /dev/null @@ -1,162 +0,0 @@ -import React, { useCallback, useEffect, useRef, useState } from 'react'; -import { MobileBanner, MobileButton, MobileFileButton, MobileIconButton, MobileSheet, MobileTextField } from '@openbitfun/ui/mobile'; -import { useI18n } from '../i18n'; -import { parseScannedPairingLink } from '../services/pairingLink'; - -interface QrScannerSheetProps { - onClose: () => void; - onDetected: (url: string) => void; -} - -type ScannerController = { - start: () => Promise; - stop: () => void; - destroy: () => void; -}; - -function errorName(error: unknown): string { - if (error instanceof DOMException) return error.name; - return String((error as { name?: string })?.name || ''); -} - -const QrScannerSheet: React.FC = ({ onClose, onDetected }) => { - const { t } = useI18n(); - const videoRef = useRef(null); - const scannerRef = useRef(null); - const completedRef = useRef(false); - const [starting, setStarting] = useState(true); - const [scanningImage, setScanningImage] = useState(false); - const [manualLink, setManualLink] = useState(''); - const [scanError, setScanError] = useState(null); - - const acceptValue = useCallback((rawValue: string) => { - if (completedRef.current) return; - const pairingUrl = parseScannedPairingLink(rawValue); - if (!pairingUrl) { - setScanError(t('pairing.invalidScannedCode')); - return; - } - completedRef.current = true; - scannerRef.current?.stop(); - onDetected(pairingUrl); - }, [onDetected, t]); - - useEffect(() => { - let disposed = false; - const startScanner = async () => { - try { - const { default: QrScanner } = await import('qr-scanner'); - if (disposed || !videoRef.current) return; - if (!(await QrScanner.hasCamera())) { - setScanError(t('pairing.cameraUnavailable')); - setStarting(false); - return; - } - const scanner = new QrScanner( - videoRef.current, - (result) => acceptValue(typeof result === 'string' ? result : result.data), - { - preferredCamera: 'environment', - returnDetailedScanResult: true, - highlightScanRegion: false, - highlightCodeOutline: false, - maxScansPerSecond: 8, - }, - ); - scannerRef.current = scanner; - await scanner.start(); - if (!disposed) setStarting(false); - } catch (error: unknown) { - if (disposed) return; - const name = errorName(error); - setScanError( - name === 'NotAllowedError' || name === 'PermissionDeniedError' - ? t('pairing.cameraPermissionDenied') - : t('pairing.cameraUnavailable'), - ); - setStarting(false); - } - }; - void startScanner(); - return () => { - disposed = true; - scannerRef.current?.stop(); - scannerRef.current?.destroy(); - scannerRef.current = null; - }; - }, [acceptValue, t]); - - const handleImage = async (event: React.ChangeEvent) => { - const file = event.target.files?.[0]; - event.target.value = ''; - if (!file) return; - setScanningImage(true); - setScanError(null); - try { - const { default: QrScanner } = await import('qr-scanner'); - const result = await QrScanner.scanImage(file, { returnDetailedScanResult: true }); - acceptValue(typeof result === 'string' ? result : result.data); - } catch { - setScanError(t('pairing.invalidScannedCode')); - } finally { - setScanningImage(false); - } - }; - - return ( - - ); -}; - -export default QrScannerSheet; diff --git a/src/mobile-web/src/hooks/useConnectionHealth.ts b/src/mobile-web/src/hooks/useConnectionHealth.ts index 154c870a54..989a9fa25b 100644 --- a/src/mobile-web/src/hooks/useConnectionHealth.ts +++ b/src/mobile-web/src/hooks/useConnectionHealth.ts @@ -1,5 +1,5 @@ import { useEffect, useRef } from 'react'; -import { isDelegatedIdentityChangedError } from '../services/RelayHttpClient'; +import { isAccountIdentityChangedError } from '../services/RelayHttpClient'; import { isRemoteControlTargetChangedError, RemoteSessionManager, @@ -54,7 +54,7 @@ export function useConnectionHealth(sessionMgr: RemoteSessionManager | null) { if (cancelled || generation !== loopGeneration) return; if ( isRemoteControlTargetChangedError(error) - || isDelegatedIdentityChangedError(error) + || isAccountIdentityChangedError(error) ) { setConnectionHealth('checking'); schedule(generation, OWNERSHIP_RETRY_DELAY); diff --git a/src/mobile-web/src/i18n/generatedLocaleContract.ts b/src/mobile-web/src/i18n/generatedLocaleContract.ts index b4f06c2875..91fdb13ddb 100644 --- a/src/mobile-web/src/i18n/generatedLocaleContract.ts +++ b/src/mobile-web/src/i18n/generatedLocaleContract.ts @@ -70,7 +70,6 @@ export const SHARED_TERMS_BY_LOCALE = { }, "connectionMethods": { "lan": "局域网", - "ngrok": "Ngrok", "openbitfunServer": "OpenBitFun Server", "customServer": "自定义服务器", "botFeishu": "飞书机器人", @@ -120,7 +119,6 @@ export const SHARED_TERMS_BY_LOCALE = { }, "connectionMethods": { "lan": "區域網路", - "ngrok": "Ngrok", "openbitfunServer": "OpenBitFun Server", "customServer": "自訂伺服器", "botFeishu": "飛書機器人", @@ -170,7 +168,6 @@ export const SHARED_TERMS_BY_LOCALE = { }, "connectionMethods": { "lan": "LAN", - "ngrok": "Ngrok", "openbitfunServer": "OpenBitFun Server", "customServer": "Custom Server", "botFeishu": "Feishu Bot", diff --git a/src/mobile-web/src/i18n/messages.ts b/src/mobile-web/src/i18n/messages.ts index bc889a5ca6..4379a37371 100644 --- a/src/mobile-web/src/i18n/messages.ts +++ b/src/mobile-web/src/i18n/messages.ts @@ -34,7 +34,14 @@ export const messages: Record = { daysAgo: '{count}d ago', }, pairing: { - loginTitle: 'Sign in to OpenBitFun', + githubSignIn: "Sign in with GitHub", + githubDescription: "Sign in with the same GitHub account as your desktop or CLI. This phone controls the remote device; models and settings stay on that device.", + allowSignInPopup: "Allow the sign-in popup and try again.", + loginFailed: "GitHub sign-in failed. Try again.", + waitingForGitHub: "Complete GitHub sign-in in the opened page.", + sameGitHubAccount: "Use the same GitHub account as {account}.", + + loginTitle: 'Sign in with GitHub', connectTitle: 'Connect to OpenBitFun', loginDescription: 'Sign in to sync devices, sessions, and remote control state.', advancedOptions: 'Advanced options', @@ -45,11 +52,11 @@ export const messages: Record = { heroDescription: 'Continue sessions, follow progress, and respond to OpenBitFun from any screen.', encryptedConnection: 'End-to-end encrypted connection', enterUserIdToContinue: 'Enter your user ID to continue', - enterAccountToContinue: 'Enter your OpenBitFun account to continue', + enterAccountToContinue: 'Enter your GitHub account to continue', connectingAndPairing: 'Connecting and pairing...', pairedLoadingSessions: 'Paired! Loading sessions...', connectionError: 'Connection error', - invalidQrCode: 'Invalid QR code: missing room or public key', + invalidQrCode: 'Invalid QR code: missing or invalid device ID', userIdRequired: 'User ID is required', usernameRequired: 'Username is required', passwordRequired: 'Password is required', @@ -60,7 +67,7 @@ export const messages: Record = { qrExpired: 'This QR code has expired or the desktop stopped sharing. Scan a new code.', rateLimited: 'Too many pairing attempts. Wait one minute, then scan or try again.', relayUnavailable: 'The relay or desktop is temporarily unavailable. Try again shortly.', - accountSessionExpired: 'Your saved account session has expired. Enter your password to sign in again.', + accountSessionExpired: 'Your sign-in has expired. Sign in with GitHub again.', credentialsRejected: 'The username or password was not accepted.', fieldLabel: 'User ID', usernameLabel: 'Username', @@ -71,24 +78,11 @@ export const messages: Record = { showPassword: 'Show password', hidePassword: 'Hide password', note: 'The first successful connection binds this URL to your user ID for the current remote session.', - accountNote: 'Use the same OpenBitFun account currently logged in on the scanned desktop. Your password is not saved on this phone.', + accountNote: 'Use the same GitHub account currently logged in on the scanned desktop. Your GitHub password is never entered in this app.', connecting: 'Connecting...', retryIn: 'Retry in {seconds}s', loginAction: 'Sign in', continue: 'Continue', - scanAction: 'Scan to connect a computer', - scanTitle: 'Scan the desktop QR code', - scanDescription: 'Open Remote Control in OpenBitFun Desktop, then place its QR code inside the frame.', - cameraPreview: 'QR scanner camera preview', - scannerStarting: 'Starting camera...', - cameraPermissionDenied: 'Camera access is required to scan. Allow access in browser settings, or use an image or connection link.', - cameraUnavailable: 'The camera is unavailable. Use a QR image or paste the connection link instead.', - invalidScannedCode: 'This is not a valid OpenBitFun pairing QR code or connection link.', - scanFromImage: 'Choose a QR code image', - scanningImage: 'Reading image...', - pasteLink: 'Or paste a connection link', - connectionLinkPlaceholder: 'https://…/#/pair?room=…', - connectScannedLink: 'Connect', }, sessions: { switchWorkspace: 'Switch workspace', @@ -254,7 +248,7 @@ export const messages: Record = { devices: { title: 'Devices', accountReady: 'Signed in. Select an online device to access its workspaces and sessions.', - noDelegatedIdentity: 'The paired desktop is not logged into a OpenBitFun account. Log in on the desktop to enable multi-device control, then retry.', + noDelegatedIdentity: 'The paired desktop is not logged into a GitHub account. Log in on the desktop to enable multi-device control, then retry.', loading: 'Loading...', refresh: 'Refresh', retry: 'Retry', @@ -268,7 +262,7 @@ export const messages: Record = { switchFailed: 'Failed to switch device', loadFailed: 'Could not load devices. Check the connection and retry.', identityFailed: 'Could not request account access from the paired desktop.', - authorizationExpired: 'Account authorization expired. Keep the paired desktop online and retry.', + authorizationExpired: 'Your sign-in has expired. Sign in with GitHub again.', deviceUnavailable: 'This device is offline or did not respond.', unknownDevice: 'Unnamed device', controllingDevice: 'Controlling {name}', @@ -309,7 +303,14 @@ export const messages: Record = { daysAgo: '{count} 天前', }, pairing: { - loginTitle: '登录 OpenBitFun', + githubSignIn: "使用 GitHub 登录", + githubDescription: "使用与桌面或 CLI 相同的 GitHub 账号登录。手机用于远程控制,模型和设置由被控设备管理。", + allowSignInPopup: "请允许登录弹窗后重试。", + loginFailed: "GitHub 登录失败,请重试。", + waitingForGitHub: "请在打开的页面完成 GitHub 登录。", + sameGitHubAccount: "请使用与 {account} 相同的 GitHub 账号。", + + loginTitle: '使用 GitHub 登录', connectTitle: '连接 OpenBitFun', loginDescription: '登录后即可同步设备、会话和远程控制状态。', advancedOptions: '高级选项', @@ -320,11 +321,11 @@ export const messages: Record = { heroDescription: '在任意屏幕上继续会话、查看进度,并及时回复 OpenBitFun。', encryptedConnection: '端到端加密连接', enterUserIdToContinue: '请输入你的用户 ID 继续', - enterAccountToContinue: '请输入你的 OpenBitFun 账号继续', + enterAccountToContinue: '请输入你的 GitHub 账号继续', connectingAndPairing: '正在连接并配对...', pairedLoadingSessions: '配对成功,正在加载会话...', connectionError: '连接异常', - invalidQrCode: '二维码无效:缺少 room 或 public key', + invalidQrCode: '二维码无效:缺少有效设备 ID', userIdRequired: '用户 ID 不能为空', usernameRequired: '用户名不能为空', passwordRequired: '密码不能为空', @@ -335,7 +336,7 @@ export const messages: Record = { qrExpired: '此二维码已过期,或桌面端已停止共享,请重新扫码。', rateLimited: '配对尝试过多,请等待一分钟后重新扫码或重试。', relayUnavailable: '中继服务或桌面端暂时不可用,请稍后重试。', - accountSessionExpired: '当前账号登录状态已过期,请输入密码重新登录。', + accountSessionExpired: '当前登录状态已过期,请重新使用 GitHub 登录。', credentialsRejected: '用户名或密码未通过验证。', fieldLabel: '用户 ID', usernameLabel: '用户名', @@ -346,24 +347,11 @@ export const messages: Record = { showPassword: '显示密码', hidePassword: '隐藏密码', note: '首次成功连接后,本次远程会话会把该 URL 绑定到你的用户 ID。', - accountNote: '请使用被扫码桌面端当前登录的 OpenBitFun 账号。密码不会保存在本机。', + accountNote: '请使用被扫码桌面端当前登录的 GitHub 账号。无需在此输入 GitHub 密码。', connecting: '连接中...', retryIn: '{seconds} 秒后重试', loginAction: '登录', continue: '继续', - scanAction: '扫码连接电脑', - scanTitle: '扫描桌面端二维码', - scanDescription: '在 OpenBitFun 桌面端打开远程控制,将二维码放入取景框内。', - cameraPreview: '二维码扫描相机画面', - scannerStarting: '正在启动相机...', - cameraPermissionDenied: '扫码需要相机权限,请在浏览器设置中允许访问,或改用二维码图片、连接链接。', - cameraUnavailable: '无法使用相机,请选择二维码图片或粘贴连接链接。', - invalidScannedCode: '这不是有效的 OpenBitFun 配对二维码或连接链接。', - scanFromImage: '选择二维码图片', - scanningImage: '正在识别图片...', - pasteLink: '或粘贴连接链接', - connectionLinkPlaceholder: 'https://…/#/pair?room=…', - connectScannedLink: '连接', }, sessions: { switchWorkspace: '切换工作区', @@ -529,7 +517,7 @@ export const messages: Record = { devices: { title: '设备', accountReady: '已登录账号。选择在线设备后,即可查看它的工作区和会话。', - noDelegatedIdentity: '所连接的桌面端未登录 OpenBitFun 账号。请在桌面端登录以启用多设备控制,然后重试。', + noDelegatedIdentity: '所连接的桌面端未使用 GitHub 登录。请在桌面端登录以启用多设备控制,然后重试。', loading: '加载中...', refresh: '刷新', retry: '重试', @@ -543,7 +531,7 @@ export const messages: Record = { switchFailed: '切换设备失败', loadFailed: '无法加载设备,请检查连接后重试。', identityFailed: '无法从已配对桌面端获取账号访问权限。', - authorizationExpired: '账号授权已过期,请保持已配对桌面端在线并重试。', + authorizationExpired: '当前登录状态已过期,请重新使用 GitHub 登录。', deviceUnavailable: '该设备离线或未响应。', unknownDevice: '未命名设备', controllingDevice: '正在控制 {name}', @@ -584,7 +572,14 @@ export const messages: Record = { daysAgo: '{count} 天前', }, pairing: { - loginTitle: '登入 OpenBitFun', + githubSignIn: "使用 GitHub 登入", + githubDescription: "使用與桌面或 CLI 相同的 GitHub 帳號登入。手機用於遠端控制,模型與設定由被控裝置管理。", + allowSignInPopup: "請允許登入彈出視窗後重試。", + loginFailed: "GitHub 登入失敗,請重試。", + waitingForGitHub: "請在開啟的頁面完成 GitHub 登入。", + sameGitHubAccount: "請使用與 {account} 相同的 GitHub 帳號。", + + loginTitle: '使用 GitHub 登入', connectTitle: '連接 OpenBitFun', loginDescription: '登入後即可同步裝置、會話和遠端控制狀態。', advancedOptions: '進階選項', @@ -595,11 +590,11 @@ export const messages: Record = { heroDescription: '在任意螢幕上繼續對話、查看進度,並及時回覆 OpenBitFun。', encryptedConnection: '端對端加密連線', enterUserIdToContinue: '請輸入你的用戶 ID 繼續', - enterAccountToContinue: '請輸入你的 OpenBitFun 帳號繼續', + enterAccountToContinue: '請輸入你的 GitHub 帳號繼續', connectingAndPairing: '正在連接並配對...', pairedLoadingSessions: '配對成功,正在加載會話...', connectionError: '連接異常', - invalidQrCode: '二維碼無效:缺少 room 或 public key', + invalidQrCode: '二維碼無效:缺少有效裝置 ID', userIdRequired: '用戶 ID 不能為空', usernameRequired: '用戶名不能為空', passwordRequired: '密碼不能為空', @@ -610,7 +605,7 @@ export const messages: Record = { qrExpired: '此 QR Code 已過期,或桌面端已停止分享,請重新掃描。', rateLimited: '配對嘗試過多,請等待一分鐘後重新掃描或重試。', relayUnavailable: '中繼服務或桌面端暫時無法使用,請稍後重試。', - accountSessionExpired: '目前帳號登入狀態已過期,請輸入密碼重新登入。', + accountSessionExpired: '目前登入狀態已過期,請重新使用 GitHub 登入。', credentialsRejected: '帳號或密碼未通過驗證。', fieldLabel: '用戶 ID', usernameLabel: '用戶名', @@ -621,24 +616,11 @@ export const messages: Record = { showPassword: '顯示密碼', hidePassword: '隱藏密碼', note: '首次成功連接後,本次遠程會話會把該 URL 綁定到你的用戶 ID。', - accountNote: '請使用被掃碼桌面端目前登入的 OpenBitFun 帳號。密碼不會保存在本機。', + accountNote: '請使用被掃碼桌面端目前登入的 GitHub 帳號。無需在此輸入 GitHub 密碼。', connecting: '連接中...', retryIn: '{seconds} 秒後重試', loginAction: '登入', continue: '繼續', - scanAction: '掃碼連接電腦', - scanTitle: '掃描桌面端 QR Code', - scanDescription: '在 OpenBitFun 桌面端開啟遠端控制,將 QR Code 放入取景框內。', - cameraPreview: 'QR Code 掃描相機畫面', - scannerStarting: '正在啟動相機...', - cameraPermissionDenied: '掃碼需要相機權限,請在瀏覽器設定中允許存取,或改用 QR Code 圖片、連接連結。', - cameraUnavailable: '無法使用相機,請選擇 QR Code 圖片或貼上連接連結。', - invalidScannedCode: '這不是有效的 OpenBitFun 配對 QR Code 或連接連結。', - scanFromImage: '選擇 QR Code 圖片', - scanningImage: '正在辨識圖片...', - pasteLink: '或貼上連接連結', - connectionLinkPlaceholder: 'https://…/#/pair?room=…', - connectScannedLink: '連接', }, sessions: { switchWorkspace: '切換工作區', @@ -804,7 +786,7 @@ export const messages: Record = { devices: { title: '設備', accountReady: '已登入帳號。選擇在線設備後,即可查看其工作區和會話。', - noDelegatedIdentity: '所連接的桌面端未登入 OpenBitFun 帳號。請在桌面端登入以啟用多設備控制,然後重試。', + noDelegatedIdentity: '所連接的桌面端未使用 GitHub 登入。請在桌面端登入以啟用多設備控制,然後重試。', loading: '加載中...', refresh: '重新整理', retry: '重試', @@ -818,7 +800,7 @@ export const messages: Record = { switchFailed: '切換設備失敗', loadFailed: '無法載入裝置,請檢查連線後重試。', identityFailed: '無法從已配對桌面端取得帳號存取權限。', - authorizationExpired: '帳號授權已過期,請保持已配對桌面端在線並重試。', + authorizationExpired: '目前登入狀態已過期,請重新使用 GitHub 登入。', deviceUnavailable: '該裝置離線或未回應。', unknownDevice: '未命名裝置', controllingDevice: '正在控制 {name}', diff --git a/src/mobile-web/src/pages/ChatPage.tsx b/src/mobile-web/src/pages/ChatPage.tsx index ddf924b665..300308fded 100644 --- a/src/mobile-web/src/pages/ChatPage.tsx +++ b/src/mobile-web/src/pages/ChatPage.tsx @@ -1016,7 +1016,7 @@ const ChatPage: React.FC = ({ return (
= ({ client, onBack, onDeviceSelected = onBac const { t, formatRelativeTime } = useI18n(); const { connectionHealth, setControlTarget, resetForDeviceSwitch } = useMobileStore(); const [devices, setDevices] = useState([]); - const [identityReady, setIdentityReady] = useState(client.hasDelegatedIdentity); - const [identityChecking, setIdentityChecking] = useState(!client.hasDelegatedIdentity); + const [identityReady, setIdentityReady] = useState(client.hasAccountIdentity); + const [identityChecking, setIdentityChecking] = useState(false); const [loading, setLoading] = useState(false); const [switchingId, setSwitchingId] = useState(null); const [error, setError] = useState(null); @@ -94,26 +92,18 @@ const DevicesPage: React.FC = ({ client, onBack, onDeviceSelected = onBac const listedDevices = devices.filter((device) => ( device.device_id !== client.controllerDeviceId )).sort((left, right) => { - const leftCurrent = left.device_id === client.pairedDeviceId; - const rightCurrent = right.device_id === client.pairedDeviceId; + const leftCurrent = left.device_id === client.targetDeviceId; + const rightCurrent = right.device_id === client.targetDeviceId; if (leftCurrent !== rightCurrent) return leftCurrent ? -1 : 1; if (left.online !== right.online) return left.online ? -1 : 1; return (left.device_name || left.device_id).localeCompare(right.device_name || right.device_id); }); - if (client.isPaired && client.pairedDeviceId === null) { - listedDevices.unshift({ - device_id: PAIRED_ROOM_DEVICE_ID, - device_name: '', - online: connectionHealth !== 'unreachable', - room_route: true, - }); - } return listedDevices; - }, [client, client.controllerDeviceId, client.pairedDeviceId, connectionHealth, devices]); + }, [client, client.controllerDeviceId, client.targetDeviceId, connectionHealth, devices]); const friendlyError = useCallback((value: unknown, fallbackKey: string) => { const message = String((value as { message?: string })?.message || value); - if (message.includes('HTTP 401') || message.includes('No delegated identity')) { + if (message.includes('HTTP 401') || message.includes('Sign in with GitHub')) { return t(accountLanding ? 'pairing.accountSessionExpired' : 'devices.authorizationExpired'); } if (message.includes('HTTP 404')) return t('devices.deviceUnavailable'); @@ -134,7 +124,7 @@ const DevicesPage: React.FC = ({ client, onBack, onDeviceSelected = onBac }, []); const refreshDevices = useCallback(async () => { - if (!client.hasDelegatedIdentity) return; + if (!client.hasAccountIdentity) return; const requestId = ++devicesRequestRef.current; const isCurrent = () => ( mountedRef.current @@ -151,9 +141,9 @@ const DevicesPage: React.FC = ({ client, onBack, onDeviceSelected = onBac // RelayHttpClient fences every response against its committed identity. // A concurrent account refresh therefore makes this request stale rather // than user-visible, while a successful 401 refresh + retry remains valid. - if (isDelegatedIdentityChangedError(e)) return; + if (isAccountIdentityChangedError(e)) return; const message = String((e as { message?: string })?.message || e); - if (message.includes('No delegated identity')) { + if (message.includes('Sign in with GitHub')) { setIdentityReady(false); setDevices([]); } else { @@ -162,36 +152,11 @@ const DevicesPage: React.FC = ({ client, onBack, onDeviceSelected = onBac } }, [client, friendlyError]); - // Acquire the delegated identity lazily: the desktop may have logged into - // its account after this mobile session was paired. Force-refresh so a - // desktop account switch is reflected without re-scanning. - const ensureIdentity = useCallback(async (force = false) => { - const requestId = ++identityRequestRef.current; - setIdentityChecking(true); - setError(null); - let granted = false; - try { - granted = await client.requestDelegatedIdentity({ force: force || !client.hasDelegatedIdentity }); - } catch (e: unknown) { - granted = false; - if (mountedRef.current && identityRequestRef.current === requestId) { - setError(friendlyError(e, 'devices.identityFailed')); - } - } - if (mountedRef.current && identityRequestRef.current === requestId) { - setIdentityReady(granted); - setIdentityChecking(false); - return granted; - } - return false; - }, [client, friendlyError]); - useEffect(() => { let cancelled = false; let timer: ReturnType | undefined; const init = async () => { - const granted = await ensureIdentity(false); - if (!granted || cancelled || !mountedRef.current) return; + if (cancelled || !mountedRef.current) return; setLoading(true); await refreshDevices(); if (cancelled || !mountedRef.current) return; @@ -203,28 +168,26 @@ const DevicesPage: React.FC = ({ client, onBack, onDeviceSelected = onBac cancelled = true; if (timer) clearInterval(timer); }; - }, [ensureIdentity, refreshDevices]); + }, [refreshDevices]); const handleManualRefresh = useCallback(async () => { if (loading || switchingId) return; - // Force refresh so desktop account switches are picked up immediately. - const granted = await ensureIdentity(true); - if (!granted) return; + setLoading(true); await refreshDevices(); if (mountedRef.current) setLoading(false); - }, [ensureIdentity, loading, refreshDevices, switchingId]); + }, [loading, refreshDevices, switchingId]); const selectDevice = useCallback(async (d: DeviceInfo, probe = true) => { if (!d.online || switchingId) return; - if (client.pairedDeviceId === d.device_id) return; + if (client.targetDeviceId === d.device_id) return; const requestId = ++switchRequestRef.current; - const accountEpoch = client.delegatedAccountEpoch; + const accountEpoch = client.accountEpoch; let expectedTargetEpoch = client.controlTargetEpoch; const isCurrent = () => ( mountedRef.current && switchRequestRef.current === requestId - && client.delegatedAccountEpoch === accountEpoch + && client.accountEpoch === accountEpoch && client.controlTargetEpoch === expectedTargetEpoch ); setSwitchingId(d.device_id); @@ -244,20 +207,19 @@ const DevicesPage: React.FC = ({ client, onBack, onDeviceSelected = onBac throw new Error(ping.error || t('devices.switchFailed')); } } - client.setPairedDeviceId(d.device_id); + client.setTargetDeviceId(d.device_id); expectedTargetEpoch = client.controlTargetEpoch; resetForDeviceSwitch(); setControlTarget({ deviceId: d.device_id, deviceName: d.device_name, - isHome: d.device_id === client.homeDeviceId, }); onDeviceSelected(); } catch (e: unknown) { if (!isCurrent()) return; - if (isDelegatedIdentityChangedError(e)) return; + if (isAccountIdentityChangedError(e)) return; const message = String((e as { message?: string })?.message || e); - if (message.includes('No delegated identity')) { + if (message.includes('Sign in with GitHub')) { setIdentityReady(false); setDevices([]); } else { @@ -284,10 +246,9 @@ const DevicesPage: React.FC = ({ client, onBack, onDeviceSelected = onBac const renderDeviceList = () => (
{sortedDevices.map((d) => { - const isCurrent = d.room_route || client.pairedDeviceId === d.device_id; - const isHome = client.homeDeviceId === d.device_id; + const isCurrent = client.targetDeviceId === d.device_id; const isSwitching = switchingId === d.device_id; - const clickable = !d.room_route && d.online && !isCurrent && !switchingId; + const clickable = d.online && !isCurrent && !switchingId; return ( = ({ client, onBack, onDeviceSelected = onBac label={( - {d.room_route - ? t('devices.pairedDesktopName') - : d.device_name || t('devices.unknownDevice')} + {d.device_name || t('devices.unknownDevice')} {isCurrent && ( {t('devices.current')} )} - {isHome && !isCurrent && ( - - {t('devices.pairedDesktop')} - - )} + )} supportingText={( @@ -328,9 +283,6 @@ const DevicesPage: React.FC = ({ client, onBack, onDeviceSelected = onBac : d.last_seen_at ? t('devices.lastSeen', { time: formatRelativeTime(d.last_seen_at * 1000) }) : t('devices.offline')} - {!d.room_route && ( - {d.device_id.slice(0, 8)} - )} )} trailing={isSwitching ? ( @@ -366,7 +318,7 @@ const DevicesPage: React.FC = ({ client, onBack, onDeviceSelected = onBac {t('devices.retry')}} - description={t('devices.noDelegatedIdentity')} + description={t('devices.authorizationExpired')} icon={} /> @@ -413,7 +365,7 @@ const DevicesPage: React.FC = ({ client, onBack, onDeviceSelected = onBac />} /> - {accountLanding && {t('devices.accountReady')}} + {accountLanding &&

{t('devices.accountReady')}

} {error && {error}}
diff --git a/src/mobile-web/src/pages/PairingPage.tsx b/src/mobile-web/src/pages/PairingPage.tsx index ce026354a8..7f593d09d5 100644 --- a/src/mobile-web/src/pages/PairingPage.tsx +++ b/src/mobile-web/src/pages/PairingPage.tsx @@ -1,682 +1,130 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { MobileIconButton, MobileStatus } from '@openbitfun/ui/mobile'; +import React, { useEffect, useRef, useState } from 'react'; import PairingForm from '../components/PairingForm'; -import QrScannerSheet from '../components/QrScannerSheet'; +import { accountDeviceIdFromHash, currentRelayUrl } from '../services/pairingLink'; import { useI18n } from '../i18n'; -import { CloudAccountClient, CloudAccountRequestError } from '../services/CloudAccountClient'; -import { - loadMatchingCloudAccountSession, - saveCloudAccountSession, - type StoredCloudAccountSession, -} from '../services/CloudAccountSessionStore'; -import { normalizeRelayUrl, validPairingSecret } from '../services/pairingLink'; +import { useTheme } from '../theme'; +import logoMarkDark from '../assets/openbitfun-mark-dark.png'; +import logoMarkLight from '../assets/openbitfun-mark-light.png'; +import { CloudAccountClient, generateRequestId, type CloudAccountSession } from '../services/CloudAccountClient'; +import { loadMatchingCloudAccountSession, saveCloudAccountSession } from '../services/CloudAccountSessionStore'; import { RelayHttpClient } from '../services/RelayHttpClient'; import { RemoteSessionManager } from '../services/RemoteSessionManager'; import { loadMobileNavigation, type PairedNavigation } from '../services/MobileNavigationStore'; import { useMobileStore } from '../services/store'; interface PairingPageProps { - onPaired: ( - client: RelayHttpClient, - sessionMgr: RemoteSessionManager, - preferredDeviceId?: string, - navigation?: PairedNavigation, - ) => void; + onPaired: (client: RelayHttpClient, sessionMgr: RemoteSessionManager, + preferredDeviceId?: string, navigation?: PairedNavigation) => void; } -interface PairAttemptOptions { - autoReconnect?: boolean; - installId?: string; - accountSession?: StoredCloudAccountSession; -} - -const MOBILE_INSTALL_ID_KEY = 'openbitfun.mobile.install_id'; -const MOBILE_USER_ID_KEY = 'openbitfun.mobile.user_id'; -const MOBILE_LOCK_UNTIL_KEY = 'openbitfun.mobile.user_id_lock_until'; -const MOBILE_FAILURE_COUNT_KEY = 'openbitfun.mobile.user_id_failure_count'; -const MAX_FAILED_USER_ID_ATTEMPTS = 3; -const USER_ID_LOCKOUT_MS = 60_000; - -function isProtectedUserIdError(message: string): boolean { - return message.includes('This remote URL is already protected') - || message.includes('This mobile device must continue using the previously confirmed user ID') - || message.includes('Invalid username or password') - || message.includes('Missing password') - || message.includes('Missing username') - || message.includes('Too many pairing attempts'); -} - -function generateInstallId(): string { - if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { - return crypto.randomUUID(); - } - return `mobile-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; -} - -function getOrCreateInstallId(): string { - const existing = localStorage.getItem(MOBILE_INSTALL_ID_KEY)?.trim(); +function installId(): string { + const key = 'openbitfun.mobile.controller_id'; + const existing = sessionStorage.getItem(key); if (existing) return existing; - const created = generateInstallId(); - localStorage.setItem(MOBILE_INSTALL_ID_KEY, created); + const created = generateRequestId(); + sessionStorage.setItem(key, created); return created; } -function currentPairingRouteKey(): string { - return `${window.location.pathname}${window.location.hash}`; -} - -function resolvePairingTarget(): { - room: string | null; - pk: string | null; - httpBaseUrl: string; - accountAuth: boolean; - accountUsername: string | null; - targetDeviceId: string | null; - targetDeviceName: string | null; - directAccountLogin: boolean; - hasPairingDescriptor: boolean; -} { - const hash = window.location.hash; - const params = new URLSearchParams(hash.replace(/^#\/pair\?/, '')); - const room = params.get('room'); - const pk = params.get('pk'); - const relayParam = params.get('relay'); - const authMode = params.get('auth'); - const isPairingRoute = hash === '#/pair' || hash.startsWith('#/pair?'); - // A direct visit is the account-facing product entry, so it must expose the - // same username/password form as the native mobile app. QR links from older - // Desktop builds remain legacy-compatible when they omit `auth`; they can - // also opt in explicitly with `auth=legacy`. - const accountAuth = authMode === 'account' || (!isPairingRoute && authMode !== 'legacy'); - const accountUsername = params.get('user')?.trim() || null; - const targetDeviceId = params.get('did')?.trim() || null; - const targetDeviceName = params.get('dn')?.trim() || null; - const directAccountLogin = accountAuth && !isPairingRoute; - - if (relayParam) { - const httpBaseUrl = normalizeRelayUrl(relayParam) ?? ''; - return { - room, - pk, - httpBaseUrl, - accountAuth, - accountUsername, - targetDeviceId, - targetDeviceName, - directAccountLogin, - hasPairingDescriptor: validPairingSecret(room, pk) && !!httpBaseUrl, - }; - } - - const origin = window.location.origin; - const pathname = window.location.pathname - .replace(/\/[^/]*$/, '') - .replace(/\/r\/[^/]*$/, ''); - const httpBaseUrl = directAccountLogin ? `${origin}/relay` : origin + pathname; - return { - room, - pk, - httpBaseUrl, - accountAuth, - accountUsername, - targetDeviceId, - targetDeviceName, - directAccountLogin, - hasPairingDescriptor: validPairingSecret(room, pk) && !!normalizeRelayUrl(httpBaseUrl), - }; -} +function routeKey(): string { return `${window.location.pathname}${window.location.hash}`; } const PairingPageContent: React.FC = ({ onPaired }) => { const { t } = useI18n(); - const { - connectionStatus, - setConnectionStatus, - setError, - error, - setAuthenticatedUserId, - setAuthenticatedUserLabel, - } = useMobileStore(); - const [userId, setUserId] = useState(''); - const [password, setPassword] = useState(''); - const [showPassword, setShowPassword] = useState(false); - const [advancedOpen, setAdvancedOpen] = useState(false); - const [scannerOpen, setScannerOpen] = useState(false); - const [submitting, setSubmitting] = useState(false); - const [failureCount, setFailureCount] = useState(0); - const [lockUntil, setLockUntil] = useState(null); - const [now, setNow] = useState(() => Date.now()); - const failureCountRef = useRef(0); - const lockUntilRef = useRef(null); - const usernameInputRef = useRef(null); - const passwordInputRef = useRef(null); - // Generation token so a superseded or unmounted pairing attempt cannot - // overwrite UI after a later bootstrap/manual attempt owns the page. - const pairAttemptGenerationRef = useRef(0); - const attemptPairRef = useRef<( - providedUserId: string, - providedPassword: string, - options?: PairAttemptOptions, - ) => Promise>(async () => {}); + const { isDark } = useTheme(); + const relayUrl = currentRelayUrl(); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const generation = useRef(0); + const pending = useRef(null); + const popup = useRef(null); const onPairedRef = useRef(onPaired); onPairedRef.current = onPaired; + const targetDeviceId = accountDeviceIdFromHash(window.location.hash) || undefined; + + const connect = (session: CloudAccountSession, controllerDeviceId: string, restore: boolean) => { + const client = new RelayHttpClient(relayUrl, { ...session, deviceId: controllerDeviceId }); + saveCloudAccountSession({ relayUrl: relayUrl, username: session.userId, + controllerDeviceId, session }); + const store = useMobileStore.getState(); + store.resetForDeviceSwitch(); + store.setAuthenticatedUserId(session.userId); + store.setAuthenticatedUserLabel(session.userId); + store.setControlTarget(null); + store.setConnectionStatus('paired'); + const scope = { accountId: session.userId, controllerDeviceId, + relayUrl: relayUrl, routeKey: routeKey() }; + const navigation = restore ? loadMobileNavigation(scope) : null; + session.masterKey.fill(0); + onPairedRef.current(client, new RemoteSessionManager(client), + targetDeviceId || navigation?.deviceId || undefined, { scope, restored: navigation }); + }; - const pairingTarget = useMemo(() => resolvePairingTarget(), []); - const [relayUrl, setRelayUrl] = useState(pairingTarget.httpBaseUrl); - const requiresAccountAuth = pairingTarget.accountAuth; - const isLocked = !!lockUntil && lockUntil > now; - const remainingLockSeconds = isLocked - ? Math.max(1, Math.ceil((lockUntil - now) / 1000)) - : 0; - - useEffect(() => { - // Password managers can restore values without dispatching React change - // events. Reconcile the visible controls so enabled/disabled state stays - // identical to the native login page. - const reconcileAutofill = () => { - const restoredUsername = usernameInputRef.current?.value ?? ''; - const restoredPassword = passwordInputRef.current?.value ?? ''; - if (restoredUsername && !userId) setUserId(restoredUsername); - if (restoredPassword && !password) setPassword(restoredPassword); - }; - const frame = window.requestAnimationFrame(reconcileAutofill); - const timer = window.setTimeout(reconcileAutofill, 250); - return () => { - window.cancelAnimationFrame(frame); - window.clearTimeout(timer); - }; - }, [password, userId]); - - const attemptPair = useCallback(async ( - providedUserId: string, - providedPassword: string, - options?: PairAttemptOptions, - ) => { - const roomId = pairingTarget.room; - const desktopPublicKey = pairingTarget.pk; - const httpBaseUrl = normalizeRelayUrl(relayUrl) ?? ''; - const userIdValue = providedUserId.trim(); - // Passwords are opaque credentials: preserve intentional leading or - // trailing spaces exactly as entered. - const passwordValue = providedPassword; - const autoReconnect = options?.autoReconnect === true; - // Prefer the explicit installId from the caller; fall back to the stable - // localStorage-backed id. Do not close over React state here — that used - // to recreate this callback and re-trigger bootstrap side effects. - const currentInstallId = options?.installId || getOrCreateInstallId(); - const activeLockUntil = lockUntilRef.current; - const lockActive = !!activeLockUntil && activeLockUntil > Date.now(); - const currentRemainingLockSeconds = lockActive - ? Math.max(1, Math.ceil((activeLockUntil - Date.now()) / 1000)) - : 0; - const attemptGeneration = ++pairAttemptGenerationRef.current; - const isCurrentAttempt = () => pairAttemptGenerationRef.current === attemptGeneration; - - if (!pairingTarget.directAccountLogin && (!roomId - || !desktopPublicKey - || !validPairingSecret(roomId, desktopPublicKey) - || !httpBaseUrl)) { - if (!isCurrentAttempt()) return; - setError(t('pairing.invalidQrCode')); - setConnectionStatus('error'); - return; - } - if (!userIdValue) { - if (!isCurrentAttempt()) return; - setError(requiresAccountAuth ? t('pairing.usernameRequired') : t('pairing.userIdRequired')); - setConnectionStatus('error'); - return; - } - if (userIdValue.length > 128 || passwordValue.length > 1024) { - if (!isCurrentAttempt()) return; - setError(t('pairing.fieldsTooLong')); - setConnectionStatus('error'); - return; - } - if (requiresAccountAuth && !passwordValue && !options?.accountSession) { - if (!isCurrentAttempt()) return; - setError(t('pairing.passwordRequired')); - setConnectionStatus('error'); - return; - } - if (!autoReconnect && lockActive) { - if (!isCurrentAttempt()) return; - setError(t('pairing.tooManyAttempts', { seconds: currentRemainingLockSeconds })); - setConnectionStatus('error'); - return; - } - - setSubmitting(true); - setError(null); - setConnectionStatus('pairing'); - - const client = new RelayHttpClient(httpBaseUrl, roomId ?? ''); - - try { - // HarmonyOS treats an account-auth QR as an account-device selection: - // once the account proof exists, `did` identifies the exact desktop and - // the room is no longer the data plane. Direct account login uses the - // same route but falls back to the first available desktop. - if (requiresAccountAuth - && (pairingTarget.directAccountLogin || !!pairingTarget.targetDeviceId)) { - const restoredAccount = options?.accountSession; - const accountSession = restoredAccount - ? { - token: restoredAccount.session.token, - userId: restoredAccount.session.userId, - masterKey: restoredAccount.session.masterKey.slice(), - } - : await new CloudAccountClient().login( - httpBaseUrl, - userIdValue, - passwordValue, - currentInstallId, - ); - restoredAccount?.session.masterKey.fill(0); - if (!isCurrentAttempt()) { - accountSession.masterKey.fill(0); - return; - } - client.installDirectAccountIdentity({ - ...accountSession, - deviceId: currentInstallId, - }); - saveCloudAccountSession({ - relayUrl: httpBaseUrl, - username: userIdValue, - controllerDeviceId: currentInstallId, - session: accountSession, - }); - accountSession.masterKey.fill(0); - - // Account authentication is complete. Device discovery and connection - // belong to the authenticated directory, including empty/offline/error - // states; none of them may return a successful login to this form. - const store = useMobileStore.getState(); - store.resetForDeviceSwitch(); - store.setAuthenticatedUserId(accountSession.userId); - store.setAuthenticatedUserLabel(userIdValue); - store.setControlTarget(null); - setConnectionStatus('paired'); - localStorage.setItem(MOBILE_USER_ID_KEY, userIdValue); - localStorage.removeItem(MOBILE_FAILURE_COUNT_KEY); - localStorage.removeItem(MOBILE_LOCK_UNTIL_KEY); - setFailureCount(0); - setLockUntil(null); - setPassword(''); - const navigationScope = { - accountId: accountSession.userId, - controllerDeviceId: currentInstallId, - relayUrl: httpBaseUrl, - routeKey: currentPairingRouteKey(), - }; - // Only a same-tab reconnect may resume a previously chosen device. - // Scanning another QR or signing in manually keeps explicit targeting. - const restoredNavigation = autoReconnect ? loadMobileNavigation(navigationScope) : null; - onPairedRef.current( - client, - new RemoteSessionManager(client), - restoredNavigation?.deviceId || pairingTarget.targetDeviceId?.trim() || undefined, - { scope: navigationScope, restored: restoredNavigation }, - ); - return; - } - - const initialSync = await client.pair(desktopPublicKey!, { - userId: userIdValue, - mobileInstallId: currentInstallId, - password: requiresAccountAuth ? passwordValue : undefined, - }); - if (!isCurrentAttempt()) return; - - setConnectionStatus('paired'); - localStorage.setItem(MOBILE_USER_ID_KEY, userIdValue); - localStorage.removeItem(MOBILE_FAILURE_COUNT_KEY); - localStorage.removeItem(MOBILE_LOCK_UNTIL_KEY); - setFailureCount(0); - setLockUntil(null); - setPassword(''); - // `authenticated_user_id` is the canonical account UUID used for - // ownership checks. A QR pairing id is only a connection credential; it - // must never be presented as a browser-authenticated account. - setAuthenticatedUserId(initialSync.authenticated_user_id ?? null); - setAuthenticatedUserLabel(requiresAccountAuth ? userIdValue : null); - - const sessionMgr = new RemoteSessionManager(client, initialSync.capabilities); - const store = useMobileStore.getState(); - if (initialSync.has_workspace) { - if (initialSync.workspace_kind === 'assistant' && initialSync.path) { - store.setPairedDisplayMode('assistant'); - store.setCurrentAssistant({ - path: initialSync.path, - name: initialSync.project_name ?? 'Claw', - assistant_id: initialSync.assistant_id, - }); - store.setCurrentWorkspace(null); - } else { - store.setPairedDisplayMode('pro'); - store.setCurrentWorkspace({ - has_workspace: true, - path: initialSync.path, - project_name: initialSync.project_name, - git_branch: initialSync.git_branch, - workspace_kind: initialSync.workspace_kind, - assistant_id: initialSync.assistant_id, - remote_connection_id: initialSync.remote_connection_id, - remote_ssh_host: initialSync.remote_ssh_host, - }); - } - } - if (initialSync.sessions) { - store.setSessions(initialSync.sessions); - } - - // Inherit the desktop's logged-in account identity (best-effort). - // When granted, the mobile can list and control same-account devices. - // Soft timeout so a slow/unsupported desktop never blocks pairing; - // DevicesPage retries identity acquisition on demand. - try { - const delegated = await Promise.race([ - client.requestDelegatedIdentity(), - new Promise((resolve) => { - window.setTimeout(() => resolve(false), 10_000); - }), - ]); - if (!isCurrentAttempt()) return; - const homeDeviceId = client.homeDeviceId; - if (delegated && homeDeviceId) { - store.setControlTarget({ deviceId: homeDeviceId, deviceName: null, isHome: true }); - const accountEpoch = client.delegatedAccountEpoch; - const target = client.getControlTargetSnapshot(); - void client - .listDevices() - .then((devices) => { - if ( - client.delegatedAccountEpoch !== accountEpoch - || !client.isControlTargetCurrent(target) - || client.pairedDeviceId !== homeDeviceId - ) return; - const home = devices.find((d) => d.device_id === homeDeviceId); - if (home) { - useMobileStore.getState().setControlTarget({ - deviceId: homeDeviceId, - deviceName: home.device_name, - isHome: true, - }); - } - }) - .catch(() => { - // Device name resolution is cosmetic; ignore failures. - }); - } - } catch { - // Desktop without account login (or delegation failure) is a normal - // single-device pairing; continue without device switching. - } - - if (!isCurrentAttempt()) return; - onPairedRef.current(client, sessionMgr); - } catch (e: any) { - if (!isCurrentAttempt()) return; - const rawErrorMessage = e?.message || ''; - const status = e instanceof CloudAccountRequestError ? e.status : e?.status; - const errorMessage = status === 401 && !!options?.accountSession - ? t('pairing.accountSessionExpired') - : rawErrorMessage.includes('timed out') - ? t('pairing.requestTimedOut') - : status === 404 || rawErrorMessage.includes('HTTP 404') - ? t('pairing.qrExpired') - : status === 429 || rawErrorMessage.includes('HTTP 429') - ? t('pairing.rateLimited') - : status === 503 || status === 504 - || rawErrorMessage.includes('HTTP 503') || rawErrorMessage.includes('HTTP 504') - ? t('pairing.relayUnavailable') - : rawErrorMessage || t('pairing.pairingFailed'); - if (!autoReconnect && isProtectedUserIdError(errorMessage)) { - const nextFailureCount = failureCountRef.current + 1; - const shouldLock = nextFailureCount >= MAX_FAILED_USER_ID_ATTEMPTS; - const nextLockUntil = shouldLock ? Date.now() + USER_ID_LOCKOUT_MS : null; - localStorage.setItem(MOBILE_FAILURE_COUNT_KEY, String(nextFailureCount)); - if (nextLockUntil) { - localStorage.setItem(MOBILE_LOCK_UNTIL_KEY, String(nextLockUntil)); - } else { - localStorage.removeItem(MOBILE_LOCK_UNTIL_KEY); - } - setFailureCount(nextFailureCount); - setLockUntil(nextLockUntil); - setError( - shouldLock - ? t('pairing.tooManyAttempts', { seconds: Math.ceil(USER_ID_LOCKOUT_MS / 1000) }) - : rawErrorMessage.includes('Too many pairing attempts') - ? t('pairing.rateLimited') - : t('pairing.credentialsRejected'), - ); - } else { - setError(errorMessage); - } - setConnectionStatus('error'); - } finally { - if (isCurrentAttempt()) { - setSubmitting(false); - } - } - }, [ - pairingTarget.directAccountLogin, - pairingTarget.pk, - pairingTarget.room, - pairingTarget.targetDeviceId, - pairingTarget.targetDeviceName, - relayUrl, - requiresAccountAuth, - setAuthenticatedUserId, - setAuthenticatedUserLabel, - setConnectionStatus, - setError, - t, - ]); - - attemptPairRef.current = attemptPair; - - // Mount-once bootstrap: restore form fields and optionally auto-reconnect. - // Must NOT depend on `attemptPair` identity — a later callback recreation - // used to reset status to `pairing` without starting a new request, which - // left the page spinning forever after a fast reconnect failure. useEffect(() => { - const savedUserId = localStorage.getItem(MOBILE_USER_ID_KEY)?.trim() ?? ''; - const qrUsername = pairingTarget.accountUsername?.trim() ?? ''; - const currentInstallId = getOrCreateInstallId(); - // Reuse is scan-driven. A plain account landing page must stay idle after - // an explicit disconnect instead of immediately reconnecting itself. - const hasScannedAccountTarget = !!pairingTarget.targetDeviceId; - const restoredAccount = requiresAccountAuth && hasScannedAccountTarget - ? loadMatchingCloudAccountSession( - pairingTarget.httpBaseUrl, - qrUsername, - currentInstallId, - ) - : null; - const prefilledUserId = qrUsername || restoredAccount?.username || savedUserId; - const persistedFailureCount = Number(localStorage.getItem(MOBILE_FAILURE_COUNT_KEY) || '0'); - const persistedLockUntil = Number(localStorage.getItem(MOBILE_LOCK_UNTIL_KEY) || '0'); - const normalizedLockUntil = persistedLockUntil > Date.now() ? persistedLockUntil : null; - if (persistedLockUntil && !normalizedLockUntil) { - localStorage.removeItem(MOBILE_LOCK_UNTIL_KEY); - localStorage.removeItem(MOBILE_FAILURE_COUNT_KEY); - } - // A valid account session is already proof for a same-account QR. Legacy - // room pairing remains auto-reconnectable only in its passwordless mode. - const shouldRestoreAccount = !!restoredAccount; - const shouldRestoreRoom = !requiresAccountAuth - && !!savedUserId - && !!currentInstallId - && !!pairingTarget.room - && !!pairingTarget.pk; - setUserId(prefilledUserId); - setFailureCount(normalizedLockUntil ? persistedFailureCount : 0); - setLockUntil(normalizedLockUntil); - setError(null); - - if (shouldRestoreAccount || shouldRestoreRoom) { - // Show the spinner immediately; attemptPair also sets pairing when the - // network attempt actually starts (after validation). - setConnectionStatus('pairing'); - void attemptPairRef.current(prefilledUserId, '', { - autoReconnect: true, - installId: currentInstallId, - accountSession: restoredAccount ?? undefined, - }); - } else { - setConnectionStatus('idle'); + // Only opening a target invitation resumes the tab's authenticated controller. + if (targetDeviceId) { + const id = installId(); + const saved = loadMatchingCloudAccountSession(relayUrl, '', id); + if (saved) connect(saved.session, id, true); } - return () => { - // Invalidate in-flight pairing so unmount / StrictMode remount cannot - // apply stale success/error onto the next page instance. - pairAttemptGenerationRef.current += 1; + generation.current += 1; + pending.current?.abort(); + popup.current?.close(); }; - // pairingTarget is resolved once from the URL hash on mount. - // eslint-disable-next-line react-hooks/exhaustive-deps -- mount-once bootstrap + // Each QR route remounts this component and owns one connection attempt. + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - useEffect(() => { - failureCountRef.current = failureCount; - lockUntilRef.current = lockUntil; - }, [failureCount, lockUntil]); - - useEffect(() => { - if (!lockUntil) return; - if (lockUntil <= Date.now()) { - setLockUntil(null); - setFailureCount(0); - localStorage.removeItem(MOBILE_LOCK_UNTIL_KEY); - localStorage.removeItem(MOBILE_FAILURE_COUNT_KEY); - return; + const signIn = async () => { + if (busy) return; + const authWindow = window.open('about:blank', '_blank'); + if (!authWindow) { setError(t('pairing.allowSignInPopup')); return; } + authWindow.opener = null; + popup.current = authWindow; + const attempt = ++generation.current; + const controller = new AbortController(); + pending.current = controller; + setBusy(true); setError(null); + try { + const account = new CloudAccountClient(relayUrl); + const accessToken = await account.authorize(authWindow, controller.signal); + if (generation.current !== attempt) return; + const id = installId(); + const session = await account.login(accessToken, id); + if (generation.current !== attempt) { session.masterKey.fill(0); return; } + connect(session, id, false); + } catch (cause) { + if (generation.current === attempt) setError(cause instanceof Error ? cause.message : t('pairing.loginFailed')); + } finally { + authWindow.close(); + if (generation.current === attempt) { setBusy(false); pending.current = null; } } - const timer = window.setInterval(() => { - const currentNow = Date.now(); - setNow(currentNow); - if (lockUntil <= currentNow) { - setLockUntil(null); - setFailureCount(0); - localStorage.removeItem(MOBILE_LOCK_UNTIL_KEY); - localStorage.removeItem(MOBILE_FAILURE_COUNT_KEY); - } - }, 1000); - return () => window.clearInterval(timer); - }, [lockUntil]); - - const handleConnect = async () => { - await attemptPair( - usernameInputRef.current?.value ?? userId, - passwordInputRef.current?.value ?? password, - { autoReconnect: false }, - ); }; - const showSpinner = connectionStatus === 'pairing'; - const showForm = connectionStatus === 'idle' || connectionStatus === 'error'; - - return ( -
-
- -
-
-
- - {showForm && ( - void handleConnect()} - onOpenScanner={() => setScannerOpen(true)} - onPasswordChange={setPassword} - onRelayUrlChange={setRelayUrl} - onShowPasswordChange={setShowPassword} - onUserIdChange={setUserId} - /> - )} + const cancel = () => { + generation.current += 1; + pending.current?.abort(); popup.current?.close(); + setBusy(false); + }; - {!showForm && ( - - )} -
-
- {scannerOpen && ( - setScannerOpen(false)} - onDetected={(url) => window.location.assign(url)} - /> - )} + return
+
+ + OpenBitFun
- ); +
+ void signIn()} onCancel={cancel} /> +
+
; }; -/** - * A scanner result commonly changes only the hash on the current Mobile Web - * document. Hash navigation does not remount React by itself, but pairing - * bootstrap is intentionally mount-scoped so stale attempts cannot cross - * targets. Key the content by the complete pairing route to give every - * scanned descriptor a fresh, single-owner connection lifecycle. - */ const PairingPage: React.FC = (props) => { - const [routeKey, setRouteKey] = useState(currentPairingRouteKey); - + const [route, setRoute] = useState(routeKey); useEffect(() => { - const handleHashChange = () => setRouteKey(currentPairingRouteKey()); - window.addEventListener('hashchange', handleHashChange); - return () => window.removeEventListener('hashchange', handleHashChange); + const change = () => setRoute(routeKey()); + window.addEventListener('hashchange', change); + return () => window.removeEventListener('hashchange', change); }, []); - - return ; + return ; }; - export default PairingPage; diff --git a/src/mobile-web/src/pages/SessionListPage.tsx b/src/mobile-web/src/pages/SessionListPage.tsx index a208e9ebb6..4f8203262b 100644 --- a/src/mobile-web/src/pages/SessionListPage.tsx +++ b/src/mobile-web/src/pages/SessionListPage.tsx @@ -30,7 +30,7 @@ import { useTheme } from '../theme'; import logoMarkDark from '../assets/openbitfun-mark-dark.png'; import logoMarkLight from '../assets/openbitfun-mark-light.png'; import { - isDelegatedIdentityChangedError, + isAccountIdentityChangedError, type RelayHttpClient, } from '../services/RelayHttpClient'; @@ -59,16 +59,13 @@ type CompactDevice = { device_id: string; device_name: string; online: boolean; - /** The QR room is a valid control target even when no account device id was delegated. */ - room_route?: boolean; }; -const COMPACT_PAIRED_ROOM_DEVICE_ID = '__openbitfun_paired_room__'; function compactSelectedDeviceIdForClient(client?: RelayHttpClient): string | null { if (!client) return null; - return client.pairedDeviceId - ?? (client.isPaired ? COMPACT_PAIRED_ROOM_DEVICE_ID : null); + return client.targetDeviceId + ?? null; } type CompactWorkspaceLoadStatus = 'idle' | 'loading' | 'ready' | 'failed'; @@ -399,8 +396,8 @@ const SessionListPage: React.FC = ({ const controlTargetEpoch = useControlTargetEpoch(sessionMgr); const cacheScope = useMemo(() => createRemoteCacheScope( authenticatedUserId, - controlTarget?.deviceId ?? client?.pairedDeviceId, - ), [authenticatedUserId, client?.pairedDeviceId, controlTarget?.deviceId]); + controlTarget?.deviceId ?? client?.targetDeviceId, + ), [authenticatedUserId, client?.targetDeviceId, controlTarget?.deviceId]); const liveDataSeqRef = useRef(0); const sessionListOwnerRef = useRef({ sessionMgr, @@ -776,7 +773,7 @@ const SessionListPage: React.FC = ({ setCompactDirectoryLoading(true); try { const tasks: Promise[] = [loadWorkspaceList()]; - if (client?.hasDelegatedIdentity) { + if (client?.hasAccountIdentity) { tasks.push(client.listDevices().then((list) => { setCompactDevices(list.filter((device) => ( device.device_id !== client.controllerDeviceId @@ -822,15 +819,12 @@ const SessionListPage: React.FC = ({ const handleSelectCompactDevice = useCallback(async (device: CompactDevice) => { if (!client || !device.online || compactSwitchingDeviceId) return; setCompactSelectedDeviceId(device.device_id); - if (device.room_route) { - await loadCompactWorkspaceCatalog(client.controlTargetEpoch); - return; - } - if (client.pairedDeviceId === device.device_id) { + + if (client.targetDeviceId === device.device_id) { await loadCompactWorkspaceCatalog(client.controlTargetEpoch); return; } - const accountEpoch = client.delegatedAccountEpoch; + const accountEpoch = client.accountEpoch; const targetEpoch = client.controlTargetEpoch; setCompactSwitchingDeviceId(device.device_id); setError(null); @@ -845,24 +839,23 @@ const SessionListPage: React.FC = ({ { retryable: true }, ); if ( - client.delegatedAccountEpoch !== accountEpoch + client.accountEpoch !== accountEpoch || client.controlTargetEpoch !== targetEpoch ) return; if (ping.resp === 'host_invoke_result' && ping.ok === false) { throw new Error(ping.error || t('devices.switchFailed')); } - client.setPairedDeviceId(device.device_id); + client.setTargetDeviceId(device.device_id); const switchedTargetEpoch = client.controlTargetEpoch; resetForDeviceSwitch(); setControlTarget({ deviceId: device.device_id, deviceName: device.device_name || null, - isHome: device.device_id === client.homeDeviceId, }); onControlTargetChanged?.(); await loadCompactWorkspaceCatalog(switchedTargetEpoch); } catch (error: unknown) { - if (isDelegatedIdentityChangedError(error)) return; + if (isAccountIdentityChangedError(error)) return; const message = String((error as { message?: string })?.message || error); setError(message || t('devices.switchFailed')); } finally { @@ -1574,19 +1567,18 @@ const SessionListPage: React.FC = ({ query.length === 0 || (session.name || '').toLocaleLowerCase().includes(query) )); const compactWorkspaces = mergeCompactWorkspaces(workspaceList, currentWorkspace, sessions); - const activeDeviceId = client?.pairedDeviceId - ?? (client?.isPaired ? COMPACT_PAIRED_ROOM_DEVICE_ID : null); + const activeDeviceId = client?.targetDeviceId + ?? null; const projectedCompactDevices = !activeDeviceId || compactDevices.some((device) => ( device.device_id === activeDeviceId )) ? compactDevices : [{ device_id: activeDeviceId, - device_name: client?.pairedDeviceId - ? controlTarget?.deviceName || client.pairedDeviceId + device_name: client?.targetDeviceId + ? controlTarget?.deviceName || client.targetDeviceId : t('devices.pairedDesktopName'), online: connectionHealth !== 'unreachable', - room_route: !client?.pairedDeviceId, }, ...compactDevices]; return ( @@ -1878,7 +1870,7 @@ const SessionListPage: React.FC = ({ { switch (connectionHealth) { case 'connected': return t('sessions.connectionConnected'); case 'checking': return t('sessions.connectionChecking'); case 'unreachable': return t('sessions.connectionUnreachable'); default: return t('sessions.connectionUnpaired'); } })()} /> {authenticatedUserLabel} - {controlTarget && !controlTarget.isHome && controlTarget.deviceName && ( + {controlTarget && controlTarget.deviceName && ( {controlTarget.deviceName} @@ -1891,7 +1883,7 @@ const SessionListPage: React.FC = ({ {onOpenDevices && ( diff --git a/src/mobile-web/src/services/CloudAccountClient.ts b/src/mobile-web/src/services/CloudAccountClient.ts index e74da4ae62..6620fa387a 100644 --- a/src/mobile-web/src/services/CloudAccountClient.ts +++ b/src/mobile-web/src/services/CloudAccountClient.ts @@ -1,35 +1,9 @@ -import { argon2idAsync } from '@noble/hashes/argon2.js'; -import { decryptBytes, fromB64, toB64 } from './E2EEncryption'; - -interface AccountChallenge { - salt: string; - kdf_salt: string; - argon2_params: string; - wrapped_master_key: string; -} - -interface AccountAuthResponse { - token: string; - user_id: string; -} - -interface AccountKdfParams { - m: number; - t: number; - p: number; -} - -interface RelayErrorResponse { - error?: string; - retry_after_secs?: number; -} - -export interface CloudAccountSession { - token: string; - userId: string; - masterKey: Uint8Array; -} +import { generateKeyPair, fromB64, toB64 } from './E2EEncryption'; +import { x25519 } from '@noble/curves/ed25519.js'; +import { pairingRelayUrl } from './pairingLink'; +export interface CloudAccountSession { token: string; userId: string; masterKey: Uint8Array; } +interface RelayErrorResponse { error?: string; retry_after_secs?: number; } export class CloudAccountRequestError extends Error { readonly status: number; readonly retryAfterSeconds: number | null; @@ -42,9 +16,7 @@ export class CloudAccountRequestError extends Error { } } -const KDF_TIMEOUT_MS = 30_000; - -function generateRequestId(): string { +export function generateRequestId(): string { if (typeof crypto.randomUUID === 'function') return crypto.randomUUID(); const bytes = crypto.getRandomValues(new Uint8Array(16)); bytes[6] = (bytes[6] & 0x0f) | 0x40; @@ -53,43 +25,6 @@ function generateRequestId(): string { return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; } -function validateKdfParams(params: AccountKdfParams, salt: Uint8Array): void { - if (salt.length < 8 || salt.length > 64 - || !Number.isInteger(params.m) || params.m < 8 * 1024 || params.m > 256 * 1024 - || !Number.isInteger(params.t) || params.t < 1 || params.t > 10 - || !Number.isInteger(params.p) || params.p < 1 || params.p > 16) { - throw new Error('Relay returned invalid account encryption parameters.'); - } -} - -async function derivePasswordHash( - password: string, - salt: Uint8Array, - params: AccountKdfParams, -): Promise { - validateKdfParams(params, salt); - let timeoutId = 0; - try { - return await Promise.race([ - argon2idAsync(password, salt, { - m: params.m, - t: params.t, - p: params.p, - dkLen: 32, - asyncTick: 10, - }), - new Promise((_, reject) => { - timeoutId = window.setTimeout( - () => reject(new Error('Account password derivation timed out.')), - KDF_TIMEOUT_MS, - ); - }), - ]); - } finally { - window.clearTimeout(timeoutId); - } -} - async function requestJson( relayUrl: string, path: string, @@ -136,77 +71,69 @@ async function requestJson( } } -/** Browser implementation of the same zero-knowledge login used by HarmonyOS. */ + +interface AuthStart { + transactionId: string; transactionSecret: string; authorizationUrl: string; + expiresAt: number; pollIntervalSeconds: number; +} + +/** GitHub identity authorizes a separately keyed browser controller. */ export class CloudAccountClient { - async login( - relayUrl: string, - username: string, - password: string, - deviceId: string, - ): Promise { - const normalizedUser = username.trim(); - if (!normalizedUser || normalizedUser.length > 128 - || !password || password.length > 1024) { - throw new Error('Invalid account credentials.'); + private readonly relayUrl: string; + constructor(relayUrl: string) { + const endpoint = pairingRelayUrl(relayUrl); + if (!endpoint) throw new Error('Invalid Relay URL'); + this.relayUrl = endpoint; + } + async authorize(popup: Window, signal: AbortSignal): Promise { + const start = await requestJson(this.relayUrl, '/api/auth/github/start', {}); + const url = new URL(start.authorizationUrl); + if (url.origin !== 'https://github.com' || url.pathname !== '/login/oauth/authorize' || url.username || url.password) { + throw new Error('Untrusted account authorization URL.'); + } + if (signal.aborted) throw new Error('Sign-in cancelled.'); + popup.location.href = url.href; + while (!signal.aborted && Date.now() < start.expiresAt * 1000) { + await new Promise((resolve) => setTimeout(resolve, Math.min(30, Math.max(1, start.pollIntervalSeconds)) * 1000)); + if (signal.aborted) break; + const result = await requestJson<{ status: string; tokens?: { accessToken: string } }>( + this.relayUrl, '/api/auth/github/poll', { + transactionId: start.transactionId, transactionSecret: start.transactionSecret, + }, + ); + if (result.status === 'authorized' && result.tokens?.accessToken) return result.tokens.accessToken; + if (result.status === 'expired' || result.status === 'denied') break; } + throw new Error(signal.aborted ? 'Sign-in cancelled.' : 'GitHub sign-in expired. Try again.'); + } - const challenge = await requestJson( - relayUrl, - '/api/auth/login/challenge', - { username: normalizedUser }, - ); - const params = JSON.parse(challenge.argon2_params) as AccountKdfParams; - const salt = fromB64(challenge.salt); - const kdfSalt = fromB64(challenge.kdf_salt); - const kek = await derivePasswordHash(password, salt, params); - let passwordHash: Uint8Array | null = null; + async login(accessToken: string, deviceId: string): Promise { + const storageKey = `openbitfun.mobile.device_key:${this.relayUrl}:${deviceId}`; + const saved = sessionStorage.getItem(storageKey); + let privateKey: Uint8Array; + if (saved) { + privateKey = fromB64(saved); + if (privateKey.length !== 32) throw new Error('Stored device identity is invalid.'); + } else { + const generated = await generateKeyPair(); + // Another login in this tab may have created the key while generation yielded. + const existing = sessionStorage.getItem(storageKey); + privateKey = existing ? fromB64(existing) : generated.privateKey; + if (privateKey.length !== 32) throw new Error('Stored device identity is invalid.'); + if (!existing) sessionStorage.setItem(storageKey, toB64(privateKey)); + else generated.privateKey.fill(0); + } + const keys = { privateKey, publicKey: x25519.getPublicKey(privateKey) }; try { - const wrappedParts = challenge.wrapped_master_key.split('.'); - if (wrappedParts.length !== 2) { - throw new Error('Relay returned an invalid wrapped master key.'); - } - let masterKey: Uint8Array; - try { - masterKey = decryptBytes(kek, fromB64(wrappedParts[0]), fromB64(wrappedParts[1])); - } catch { - throw new Error('Invalid username or password.'); - } - if (masterKey.length !== 32) { - masterKey.fill(0); - throw new Error('Invalid username or password.'); - } - - passwordHash = await derivePasswordHash(password, kdfSalt, params); - const requestId = generateRequestId(); - try { - const auth = await requestJson( - relayUrl, - '/api/auth/login', - { - username: normalizedUser, - password_hash: toB64(passwordHash), - device_id: deviceId, - device_name: 'Mobile Browser', - device_kind: 'mobile', - request_id: requestId, - }, - ); - if (!auth.token?.trim() || !auth.user_id?.trim()) { - masterKey.fill(0); - throw new Error('Relay returned an invalid account identity.'); - } - return { - token: auth.token, - userId: auth.user_id, - masterKey, - }; - } catch (error) { - masterKey.fill(0); - throw error; - } - } finally { - kek.fill(0); - passwordHash?.fill(0); + const auth = await requestJson<{ token: string; user_id: string }>(this.relayUrl, '/api/auth/login', { + access_token: accessToken, device_id: deviceId, device_name: 'Mobile Browser', + device_kind: 'mobile', public_key: toB64(keys.publicKey), request_id: generateRequestId(), + }); + if (!auth.token?.trim() || !auth.user_id?.trim()) throw new Error('Invalid account identity.'); + return { token: auth.token, userId: auth.user_id, masterKey: keys.privateKey }; + } catch (error) { + keys.privateKey.fill(0); + throw error; } } } diff --git a/src/mobile-web/src/services/CloudAccountSessionStore.ts b/src/mobile-web/src/services/CloudAccountSessionStore.ts index 58e704be01..6ce1d590d6 100644 --- a/src/mobile-web/src/services/CloudAccountSessionStore.ts +++ b/src/mobile-web/src/services/CloudAccountSessionStore.ts @@ -2,16 +2,16 @@ import type { CloudAccountSession } from './CloudAccountClient'; import { fromB64, toB64 } from './E2EEncryption'; import { normalizeRelayUrl } from './pairingLink'; -const ACCOUNT_SESSION_STORAGE_KEY = 'openbitfun.mobile.account_session.v1'; -const ACCOUNT_SESSION_VERSION = 1; +const ACCOUNT_SESSION_STORAGE_KEY = 'openbitfun.mobile.account_session.v2'; +const ACCOUNT_SESSION_VERSION = 2; interface PersistedAccountSessionV1 { - version: 1; + version: 2; relay_url: string; username: string; token: string; user_id: string; - master_key: string; + device_secret: string; controller_device_id: string; } @@ -50,7 +50,7 @@ export function serializeCloudAccountSession(value: StoredCloudAccountSession): username: value.username, token: value.session.token, user_id: value.session.userId, - master_key: toB64(value.session.masterKey), + device_secret: toB64(value.session.masterKey), controller_device_id: value.controllerDeviceId, }; return JSON.stringify(record); @@ -65,13 +65,13 @@ export function deserializeCloudAccountSession(raw: string): StoredCloudAccountS try { const parsed = JSON.parse(raw) as Record; const version = parsed.version; - if (version !== undefined && version !== ACCOUNT_SESSION_VERSION) return null; + if (version !== ACCOUNT_SESSION_VERSION) return null; const relayUrl = normalizeRelayUrl(stringField(parsed.relay_url ?? parsed.relayUrl)); const username = stringField(parsed.username); const token = stringField(parsed.token); const userId = stringField(parsed.user_id ?? parsed.userId); - const masterKeyBase64 = stringField(parsed.master_key ?? parsed.masterKey); + const masterKeyBase64 = stringField(parsed.device_secret); const controllerDeviceId = stringField( parsed.controller_device_id ?? parsed.controllerDeviceId, ); diff --git a/src/mobile-web/src/services/E2EEncryption.ts b/src/mobile-web/src/services/E2EEncryption.ts index 3a30e325ee..62769b1cb2 100644 --- a/src/mobile-web/src/services/E2EEncryption.ts +++ b/src/mobile-web/src/services/E2EEncryption.ts @@ -1,3 +1,5 @@ +import { hkdf } from '@noble/hashes/hkdf.js'; +import { sha256 } from '@noble/hashes/sha2.js'; /** * E2E encryption for the mobile web client. * @@ -82,3 +84,23 @@ function randomBytes(len: number): Uint8Array { (globalThis.crypto || (globalThis as any).msCrypto).getRandomValues(buf); return buf; } + +/** Matches the Relay v1 device key contract used by desktop and CLI. */ +export function deriveDeviceMessageKey(privateKey: Uint8Array, peerPublicKey: Uint8Array): Uint8Array { + if (privateKey.length !== 32 || peerPublicKey.length !== 32) throw new Error('Invalid device key.'); + const ownPublicKey = x25519.getPublicKey(privateKey); + const shared = x25519.getSharedSecret(privateKey, peerPublicKey); + if (shared.every((value: number) => value === 0)) throw new Error('Invalid peer key.'); + let first = ownPublicKey; + let second = peerPublicKey; + for (let i = 0; i < 32; i += 1) { + if (ownPublicKey[i] === peerPublicKey[i]) continue; + if (ownPublicKey[i] > peerPublicKey[i]) [first, second] = [second, first]; + break; + } + const info = new Uint8Array(64); + info.set(first); info.set(second, 32); + try { + return hkdf(sha256, shared, new TextEncoder().encode('OpenBitFun Relay v1.0.0 device key'), info, 32); + } finally { shared.fill(0); } +} diff --git a/src/mobile-web/src/services/RelayHttpClient.ts b/src/mobile-web/src/services/RelayHttpClient.ts index 66f021d747..1a0553ce65 100644 --- a/src/mobile-web/src/services/RelayHttpClient.ts +++ b/src/mobile-web/src/services/RelayHttpClient.ts @@ -1,129 +1,97 @@ -/** - * HTTP client for communicating with the relay server. - * All mobile-to-desktop communication goes through HTTP requests - * that the relay bridges to the desktop via WebSocket. - * - * No WebSocket connection is maintained on the mobile side. - */ +/** Account-authenticated HTTP device directory and encrypted device RPC. */ +import { deriveDeviceMessageKey, encrypt, decrypt, fromB64 } from './E2EEncryption'; +import { normalizeRelayUrl } from './pairingLink'; -import { - generateKeyPair, - deriveSharedKey, - encrypt, - decrypt, - toB64, - fromB64, - type MobileKeyPair, -} from './E2EEncryption'; - -interface DelegatedIdentitySnapshot { +export interface AccountIdentity { token: string; masterKey: Uint8Array; - userId: string | null; - homeDeviceId: string | null; - generation: number; - source: 'paired' | 'direct'; -} - -interface DelegatedAccountIdentity { - userId: string | null; - masterKey: Uint8Array; - homeDeviceId: string | null; + userId: string; + deviceId: string; } - -export type DelegatedAccountOwnerChange = { +interface AccountIdentitySnapshot extends AccountIdentity { generation: number; } +export type AccountOwnerChange = { kind: 'initial' | 'replacement' | 'unavailable'; epoch: number; userId: string | null; - homeDeviceId: string | null; }; - -export type ControlTargetSnapshot = Readonly<{ - deviceId: string | null; - homeDeviceId: string | null; - epoch: number; -}>; - -export class DelegatedIdentityChangedError extends Error { - constructor(message = 'Delegated identity changed') { - super(message); - this.name = 'DelegatedIdentityChangedError'; - } +export type ControlTargetSnapshot = Readonly<{ deviceId: string | null; epoch: number }>; +export class AccountIdentityChangedError extends Error { + constructor() { super('Account identity changed'); this.name = 'AccountIdentityChangedError'; } } - -export class DelegatedAccountChangedError extends DelegatedIdentityChangedError { - constructor() { - super('Delegated account changed'); - this.name = 'DelegatedAccountChangedError'; - } +export function isAccountIdentityChangedError(value: unknown): value is AccountIdentityChangedError { + return value instanceof AccountIdentityChangedError; } - -export function isDelegatedIdentityChangedError( - value: unknown, -): value is DelegatedIdentityChangedError { - return value instanceof DelegatedIdentityChangedError; -} - const RELAY_HTTP_MAX_ATTEMPTS = 5; const RELAY_HTTP_RETRY_BASE_DELAY_MS = 300; const RELAY_HTTP_RETRY_BUDGET_MS = 120_000; const TRANSIENT_RELAY_STATUSES = new Set([408, 425, 500, 502, 503, 504]); +type RelayRequestOptions = { retryable?: boolean; timeoutMs?: number }; -type RelayRequestOptions = { - retryable?: boolean; - timeoutMs?: number; -}; +export class RelayHttpClient { + private readonly relayUrl: string; + private identity: AccountIdentitySnapshot | null = null; + private identityGeneration = 0; + private accountEpochValue = 0; + private ownerListeners = new Set<(change: AccountOwnerChange) => void>(); + private targetDeviceIdValue: string | null = null; + private controlTargetEpochValue = 0; + private controlTargetListeners = new Set<(snapshot: ControlTargetSnapshot) => void>(); + private deviceMessageKeys = new Map }>(); -function equalBytesConstantTime(left: Uint8Array, right: Uint8Array): boolean { - if (left.length !== right.length) return false; - let difference = 0; - for (let index = 0; index < left.length; index += 1) { - difference |= left[index] ^ right[index]; + constructor(relayUrl: string, identity: AccountIdentity) { + const endpoint = normalizeRelayUrl(relayUrl); + if (!endpoint) throw new Error('Invalid Relay URL'); + this.relayUrl = endpoint; + this.setAccountIdentity(identity); } - return difference === 0; -} -function delegatedAccountChanged( - previous: DelegatedAccountIdentity | null, - next: DelegatedAccountIdentity, -): boolean { - if (!previous) return true; - if (!equalBytesConstantTime(previous.masterKey, next.masterKey)) return true; - if (previous.userId !== null && next.userId !== null) { - return previous.userId !== next.userId; + setAccountIdentity(identity: AccountIdentity): void { + if (!identity.token.trim() || !identity.userId.trim() || !identity.deviceId.trim() || identity.masterKey.length !== 32) { + throw new Error('Relay returned an invalid account identity.'); + } + const kind = this.identity ? 'replacement' : 'initial'; + this.identity?.masterKey.fill(0); + this.identity = { ...identity, masterKey: identity.masterKey.slice(), generation: ++this.identityGeneration }; + this.accountEpochValue += 1; + this.deviceMessageKeys.clear(); + this.setTargetDeviceId(null); + for (const listener of this.ownerListeners) listener({ kind, epoch: this.accountEpochValue, userId: identity.userId }); } - // Older Desktop builds omit user_id. The account master key is the stable - // identifier in that case; homeDeviceId separates distinct paired homes. - return previous.homeDeviceId !== next.homeDeviceId; -} - -export class RelayHttpClient { - private relayUrl: string; - private roomId: string; - private sharedKey: Uint8Array | null = null; - private keyPair: MobileKeyPair | null = null; - /** Delegated credentials are committed as one immutable generation. */ - private delegatedIdentity: DelegatedIdentitySnapshot | null = null; - private delegatedIdentityRequestEpoch = 0; - private delegatedIdentityGenerationValue = 0; - private delegatedIdentityRefreshOwner: number | null = null; - private delegatedAccountIdentity: DelegatedAccountIdentity | null = null; - private delegatedAccountEpochValue = 0; - private delegatedAccountOwnerListeners = new Set<( - change: DelegatedAccountOwnerChange, - ) => void>(); - /** The current control-target device_id (for sendDeviceRpc). */ - private pairedDeviceIdValue: string | null = null; - /** This browser's relay device id. Never offer it as a remote control target. */ - private controllerDeviceIdValue: string | null = null; - private controlTargetEpochValue = 0; - private controlTargetListeners = new Set<(snapshot: ControlTargetSnapshot) => void>(); - /** The QR-paired desktop's device_id (the "home" device of this session). */ - public homeDeviceId: string | null = null; - constructor(relayUrl: string, roomId: string) { - this.relayUrl = relayUrl.replace(/\/+$/, ''); - this.roomId = roomId; + resetConnectionIdentity(): void { + this.identity?.masterKey.fill(0); + this.identity = null; + this.identityGeneration += 1; + this.accountEpochValue += 1; + this.deviceMessageKeys.clear(); + this.setTargetDeviceId(null); + for (const listener of this.ownerListeners) listener({ kind: 'unavailable', epoch: this.accountEpochValue, userId: null }); + } + + onAccountOwnerChange(listener: (change: AccountOwnerChange) => void, options?: { emitCurrent?: boolean }): () => void { + this.ownerListeners.add(listener); + if (options?.emitCurrent && this.identity) listener({ kind: 'initial', epoch: this.accountEpochValue, userId: this.identity.userId }); + return () => this.ownerListeners.delete(listener); + } + get hasAccountIdentity(): boolean { return this.identity !== null; } + get accountEpoch(): number { return this.accountEpochValue; } + get accountUserId(): string | null { return this.identity?.userId ?? null; } + get controllerDeviceId(): string | null { return this.identity?.deviceId ?? null; } + get targetDeviceId(): string | null { return this.targetDeviceIdValue; } + get controlTargetEpoch(): number { return this.controlTargetEpochValue; } + setTargetDeviceId(deviceId: string | null): void { + this.targetDeviceIdValue = deviceId; + this.controlTargetEpochValue += 1; + const snapshot = this.getControlTargetSnapshot(); + for (const listener of this.controlTargetListeners) listener(snapshot); + } + getControlTargetSnapshot(): ControlTargetSnapshot { + return { deviceId: this.targetDeviceIdValue, epoch: this.controlTargetEpochValue }; + } + isControlTargetCurrent(snapshot: ControlTargetSnapshot): boolean { return snapshot.epoch === this.controlTargetEpochValue; } + onControlTargetChange(listener: (snapshot: ControlTargetSnapshot) => void): () => void { + this.controlTargetListeners.add(listener); + return () => this.controlTargetListeners.delete(listener); } private async fetchWithTimeout( @@ -158,10 +126,12 @@ export class RelayHttpClient { input: RequestInfo | URL, init: RequestInit, timeoutMs: number, + assertCurrent: () => void = () => {}, ): Promise { let lastError: unknown = null; const deadlineMs = Date.now() + RELAY_HTTP_RETRY_BUDGET_MS; for (let attempt = 1; attempt <= RELAY_HTTP_MAX_ATTEMPTS; attempt += 1) { + assertCurrent(); try { const remainingMs = deadlineMs - Date.now(); if (remainingMs <= 0) { @@ -194,495 +164,13 @@ export class RelayHttpClient { throw lastError; } - /** - * Pair with the desktop via two HTTP round-trips: - * 1. POST /pair with our public key → receive encrypted challenge - * 2. POST /command with encrypted challenge_echo → receive initial_sync - * - * When the desktop is logged into a OpenBitFun account, pass `password` so the - * desktop can verify credentials (same challenge+unwrap path as desktop login). - */ - async pair( - desktopPubKeyB64: string, - identity: { - userId: string; - mobileInstallId: string; - password?: string; - }, - ): Promise { - this.keyPair = await generateKeyPair(); - const desktopPub = fromB64(desktopPubKeyB64); - this.sharedKey = await deriveSharedKey(this.keyPair, desktopPub); - - const deviceId = identity.mobileInstallId; - this.controllerDeviceIdValue = deviceId; - const deviceName = this.getMobileDeviceName(); - const userId = identity.userId.trim(); - const mobileInstallId = identity.mobileInstallId.trim(); - // Passwords are opaque credentials. Never normalize whitespace here or - // credentials accepted by Desktop can fail only on the mobile path. - const password = identity.password && identity.password.length > 0 - ? identity.password - : undefined; - - // Step 1: POST /pair → encrypted challenge - const pairResp = await this.fetchWithRetry( - `${this.relayUrl}/api/rooms/${encodeURIComponent(this.roomId)}/pair`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - public_key: toB64(this.keyPair.publicKey), - device_id: deviceId, - device_name: deviceName, - }), - }, - 35_000, - ); - - if (!pairResp.ok) { - throw new Error(`Pairing failed: HTTP ${pairResp.status}`); - } - - const pairData = await pairResp.json(); - const challengeJson = await decrypt( - this.sharedKey, - pairData.encrypted_data, - pairData.nonce, - ); - const challenge = JSON.parse(challengeJson); - - // Step 2: POST /command with challenge_echo → initial_sync - const challengeResponse: Record = { - challenge_echo: challenge.challenge, - device_id: deviceId, - device_name: deviceName, - mobile_install_id: mobileInstallId, - user_id: userId, - }; - if (password) { - challengeResponse.password = password; - } - const { data: encData, nonce: encNonce } = await encrypt( - this.sharedKey, - JSON.stringify(challengeResponse), - ); - - const cmdResp = await this.fetchWithTimeout( - `${this.relayUrl}/api/rooms/${encodeURIComponent(this.roomId)}/command`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ encrypted_data: encData, nonce: encNonce }), - }, - 65_000, - ); - - if (!cmdResp.ok) { - throw new Error(`Pairing verification failed: HTTP ${cmdResp.status}`); - } - - const cmdData = await cmdResp.json(); - const initialSyncJson = await decrypt( - this.sharedKey, - cmdData.encrypted_data, - cmdData.nonce, - ); - const parsed = JSON.parse(initialSyncJson); - if (parsed?.resp === 'error') { - throw new Error(parsed?.message || 'Pairing rejected'); - } - return parsed; - } - - /** - * Ask the paired desktop to delegate its logged-in account identity - * (token + master_key). Allows this client to call /api/devices and - * /api/devices/:id/rpc directly and control any same-account device. - * - * Returns true when an identity was delegated; false when the desktop is - * not logged into an account (or delegation failed). Never throws for the - * not-logged-in case. - */ - async requestDelegatedIdentity(options?: { force?: boolean }): Promise { - // Direct account login already owns a first-party device credential. It - // must never fall back to a QR-room command, especially on forced refresh. - if (this.delegatedIdentity?.source === 'direct') return true; - if (!options?.force && this.hasDelegatedIdentity) return true; - const requestGeneration = ++this.delegatedIdentityRequestEpoch; - if (options?.force) { - // Keep the last committed credential present for target routing, but - // suspend its use until this refresh settles. On transport failure the - // last confirmed identity becomes usable again. - this.delegatedIdentityRefreshOwner = requestGeneration; - } - try { - const resp = await this.sendCommand<{ - resp: string; - token?: string; - master_key?: string; - user_id?: string; - device_id?: string; - message?: string; - }>({ cmd: 'get_delegated_identity' }, { retryable: true }); - if (this.delegatedIdentityRequestEpoch !== requestGeneration) return false; - if (resp?.resp === 'delegate_identity' && resp.token && resp.master_key) { - const homeDeviceId = resp.device_id ?? null; - const nextIdentity: DelegatedIdentitySnapshot = { - token: resp.token, - masterKey: fromB64(resp.master_key), - userId: resp.user_id ?? null, - homeDeviceId, - generation: ++this.delegatedIdentityGenerationValue, - source: 'paired', - }; - this.delegatedIdentity = nextIdentity; - - const nextAccountIdentity: DelegatedAccountIdentity = { - userId: nextIdentity.userId, - masterKey: nextIdentity.masterKey.slice(), - homeDeviceId: nextIdentity.homeDeviceId, - }; - const accountChanged = delegatedAccountChanged( - this.delegatedAccountIdentity, - nextAccountIdentity, - ); - const hadAccountIdentity = this.delegatedAccountIdentity !== null; - if (accountChanged) this.delegatedAccountEpochValue += 1; - this.delegatedAccountIdentity = nextAccountIdentity; - - const previousHomeDeviceId = this.homeDeviceId; - const wasUsingDefaultRoom = this.pairedDeviceIdValue === null - || this.pairedDeviceIdValue === previousHomeDeviceId; - this.homeDeviceId = homeDeviceId; - if (accountChanged) { - // Every semantic account-owner commit starts a new UI/data - // generation, including a late initial delegation. Explicit React - // epoch subscribers re-initialize same-owner screens safely. - this.setPairedDeviceId(homeDeviceId); - } else if (wasUsingDefaultRoom) { - // Token/home metadata refresh for the same committed account does - // not change the effective QR-room route. - this.pairedDeviceIdValue = homeDeviceId; - } - if (accountChanged) { - this.emitDelegatedAccountOwnerChange({ - kind: hadAccountIdentity ? 'replacement' : 'initial', - epoch: this.delegatedAccountEpochValue, - userId: nextIdentity.userId, - homeDeviceId, - }); - } - return true; - } - this.commitDelegatedAccountUnavailable(); - return false; - } finally { - if (this.delegatedIdentityRefreshOwner === requestGeneration) { - this.delegatedIdentityRefreshOwner = null; - } - } - } - - /** Drop cached delegated credentials so the next request can refresh them. */ - clearDelegatedIdentity(): void { - this.delegatedIdentityRequestEpoch += 1; - this.delegatedIdentityRefreshOwner = null; - if (this.delegatedIdentity) this.delegatedIdentityGenerationValue += 1; - this.delegatedIdentity = null; - } - - /** Fully discard account/control-target state when the mobile disconnects. */ - resetConnectionIdentity(): void { - const targetEpoch = this.controlTargetEpochValue; - this.clearDelegatedIdentity(); - this.commitDelegatedAccountUnavailable(); - this.controllerDeviceIdValue = null; - if (this.controlTargetEpochValue === targetEpoch) { - // Disconnect is an explicit ownership boundary even when this session - // never received delegated credentials and was using only the QR room. - this.setPairedDeviceId(null); - } - } - - /** - * Observe semantic delegated-account owner changes. Token-only refreshes do - * not emit. The listener is synchronous with the credential commit so UI - * state is cleared before an operation can publish data for the new owner. - */ - onDelegatedAccountOwnerChange( - listener: (change: DelegatedAccountOwnerChange) => void, - options?: { emitCurrent?: boolean }, - ): () => void { - this.delegatedAccountOwnerListeners.add(listener); - if (options?.emitCurrent && this.delegatedAccountIdentity) { - listener({ - kind: 'initial', - epoch: this.delegatedAccountEpochValue, - userId: this.delegatedAccountIdentity.userId, - homeDeviceId: this.delegatedAccountIdentity.homeDeviceId, - }); - } - return () => this.delegatedAccountOwnerListeners.delete(listener); - } - - private emitDelegatedAccountOwnerChange(change: DelegatedAccountOwnerChange): void { - for (const listener of this.delegatedAccountOwnerListeners) { - listener(change); - } - } - - private commitDelegatedAccountUnavailable(): void { - if (this.delegatedIdentity) this.delegatedIdentityGenerationValue += 1; - this.delegatedIdentity = null; - if (!this.delegatedAccountIdentity) { - const wasUsingDefaultRoom = this.pairedDeviceIdValue === null - || this.pairedDeviceIdValue === this.homeDeviceId; - this.homeDeviceId = null; - if (wasUsingDefaultRoom) { - // A late "not logged in" result with no committed owner changes no - // route. Do not manufacture a target event that can freeze consumers. - this.pairedDeviceIdValue = null; - } else { - this.setPairedDeviceId(null); - } - return; - } - this.delegatedAccountEpochValue += 1; - this.delegatedAccountIdentity = null; - this.homeDeviceId = null; - this.setPairedDeviceId(null); - this.emitDelegatedAccountOwnerChange({ - kind: 'unavailable', - epoch: this.delegatedAccountEpochValue, - userId: null, - homeDeviceId: null, - }); - } - - /** - * Send an encrypted command to the desktop and return the decrypted response. - */ - async sendCommand( - cmd: object, - options: RelayRequestOptions = {}, - ): Promise { - if (!this.sharedKey) throw new Error('Not paired'); - - const plaintext = JSON.stringify(cmd); - const { data: encData, nonce: encNonce } = await encrypt( - this.sharedKey, - plaintext, - ); - - const body = JSON.stringify({ encrypted_data: encData, nonce: encNonce }); - - const timeoutMs = options.timeoutMs ?? (options.retryable ? 20_000 : 65_000); - const resp = await (options.retryable ? this.fetchWithRetry : this.fetchWithTimeout).call( - this, - `${this.relayUrl}/api/rooms/${encodeURIComponent(this.roomId)}/command`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body, - }, - timeoutMs, - ); - - if (!resp.ok) { - throw new Error(`Command failed: HTTP ${resp.status}`); - } - - const data = await resp.json(); - const decrypted = await decrypt( - this.sharedKey, - data.encrypted_data, - data.nonce, - ); - return JSON.parse(decrypted) as T; - } - - get isPaired(): boolean { - return this.sharedKey !== null; - } - - get hasDelegatedIdentity(): boolean { - return this.delegatedIdentity !== null; - } - - /** - * Install an account session obtained by this browser itself. The existing - * account data plane can then list devices and issue encrypted device RPCs - * without requiring a QR-room connection first. - */ - installDirectAccountIdentity(identity: { - token: string; - masterKey: Uint8Array; - userId: string; - deviceId: string; - }): void { - const token = identity.token.trim(); - const userId = identity.userId.trim(); - const deviceId = identity.deviceId.trim(); - if (!token || !userId || !deviceId || identity.masterKey.length !== 32) { - throw new Error('Relay returned an invalid account identity.'); - } - this.controllerDeviceIdValue = deviceId; - - const hadAccountIdentity = this.delegatedAccountIdentity !== null; - const nextAccountIdentity: DelegatedAccountIdentity = { - userId, - masterKey: identity.masterKey.slice(), - homeDeviceId: null, - }; - const accountChanged = delegatedAccountChanged( - this.delegatedAccountIdentity, - nextAccountIdentity, - ); - - this.delegatedIdentityRequestEpoch += 1; - this.delegatedIdentityRefreshOwner = null; - this.delegatedIdentity = { - token, - masterKey: identity.masterKey.slice(), - userId, - homeDeviceId: null, - generation: ++this.delegatedIdentityGenerationValue, - source: 'direct', - }; - if (accountChanged) this.delegatedAccountEpochValue += 1; - this.delegatedAccountIdentity = nextAccountIdentity; - this.homeDeviceId = null; - this.setPairedDeviceId(null); - if (accountChanged) { - this.emitDelegatedAccountOwnerChange({ - kind: hadAccountIdentity ? 'replacement' : 'initial', - epoch: this.delegatedAccountEpochValue, - userId, - homeDeviceId: null, - }); - } - } - - get pairedDeviceId(): string | null { - return this.pairedDeviceIdValue; - } - - get controllerDeviceId(): string | null { - return this.controllerDeviceIdValue; - } - - /** - * Commit a control target and advance its ownership epoch. Advancing even - * when the device id repeats is intentional: A -> B -> A must invalidate - * requests that were issued during the first A ownership interval. - */ - setPairedDeviceId(deviceId: string | null): void { - this.pairedDeviceIdValue = deviceId; - this.controlTargetEpochValue += 1; - const snapshot = this.getControlTargetSnapshot(); - for (const listener of this.controlTargetListeners) { - listener(snapshot); - } - } - - get controlTargetEpoch(): number { - return this.controlTargetEpochValue; - } - - getControlTargetSnapshot(): ControlTargetSnapshot { - return { - deviceId: this.pairedDeviceIdValue, - homeDeviceId: this.homeDeviceId, - epoch: this.controlTargetEpochValue, - }; - } - - isControlTargetCurrent(snapshot: ControlTargetSnapshot): boolean { - // The epoch represents route ownership. Device/home ids are immutable - // routing inputs captured by the request, but metadata-only home binding - // intentionally leaves an in-flight QR-room request current. - return snapshot.epoch === this.controlTargetEpochValue; - } - - onControlTargetChange( - listener: (snapshot: ControlTargetSnapshot) => void, - ): () => void { - this.controlTargetListeners.add(listener); - return () => this.controlTargetListeners.delete(listener); - } - - get delegatedIdentityGeneration(): number { - return this.delegatedIdentityGenerationValue; - } - - /** Changes only when the delegated account/home identity changes. */ - get delegatedAccountEpoch(): number { - return this.delegatedAccountEpochValue; - } - - /** Canonical delegated account user, or null for legacy Desktop responses. */ - get delegatedUserId(): string | null { - return this.delegatedIdentity?.userId ?? null; - } - - private requireDelegatedIdentity(): DelegatedIdentitySnapshot { - const identity = this.delegatedIdentity; - if (!identity) throw new Error('No delegated identity'); - return identity; - } - - private isDelegatedIdentityCurrent(identity: DelegatedIdentitySnapshot): boolean { - return this.delegatedIdentity?.generation === identity.generation - && this.delegatedIdentityGenerationValue === identity.generation - && this.delegatedIdentityRefreshOwner === null; - } - - private ensureDelegatedIdentityCurrent( - identity: DelegatedIdentitySnapshot, - accountEpoch: number, - ): void { - if (!this.isDelegatedIdentityCurrent(identity)) { - throw this.delegatedIdentityChangeError(accountEpoch); - } - } - - private delegatedIdentityChangeError(accountEpoch: number): DelegatedIdentityChangedError { - return this.delegatedAccountEpochValue === accountEpoch - ? new DelegatedIdentityChangedError() - : new DelegatedAccountChangedError(); - } - - /** - * Refresh delegated identity from the paired desktop after a 401, then - * retry the caller once. - */ - private async refreshDelegatedIdentityAfterUnauthorized( - failedIdentity: DelegatedIdentitySnapshot, - ): Promise { - // A 401 from an old generation must not clear credentials that were - // delegated by a newer desktop account in the meantime. - if (!this.isDelegatedIdentityCurrent(failedIdentity)) { - return this.hasDelegatedIdentity; - } - if (failedIdentity.source === 'direct') return false; - try { - return await this.requestDelegatedIdentity({ force: true }); - } catch { - return false; - } - } - - /** - * List all same-account devices via the relay HTTP API. - * Requires a delegated identity (token + master_key from the paired desktop). - * On HTTP 401, refreshes identity from the paired desktop and retries once. - */ async listDevices(): Promise> { - return this.withDelegatedAuthRetry(async (identity) => { + return this.withAccount(async (identity) => { const resp = await this.fetchWithRetry(`${this.relayUrl}/api/devices`, { headers: { 'Authorization': `Bearer ${identity.token}` }, - }, 20_000); + }, 20_000, () => { + if (identity.generation !== this.identityGeneration) throw new AccountIdentityChangedError(); + }); if (!resp.ok) { const err = new Error(`List devices failed: HTTP ${resp.status}`) as Error & { status?: number; @@ -691,27 +179,47 @@ export class RelayHttpClient { throw err; } return resp.json(); - }, { allowAccountReplacementRetry: true }); + }); } - /** - * Send a RemoteCommand to a target device via the relay HTTP RPC endpoint. - * The command is encrypted with the delegated master_key (same key the - * desktop uses, shared via the room channel at pairing time). - * On HTTP 401, refreshes identity from the paired desktop and retries once. - */ + /** Send a command encrypted with the two account devices' X25519 key agreement. */ async sendDeviceRpc( targetDeviceId: string, command: object, options: RelayRequestOptions = {}, ): Promise { - return this.withDelegatedAuthRetry(async (identity) => { + const targetEpoch = this.controlTargetEpochValue; + return this.withAccount(async (identity) => { + const cacheId = `${identity.generation}:${targetDeviceId}`; + let cached = this.deviceMessageKeys.get(cacheId); + if (!cached || cached.expires < Date.now()) { + const key = (async () => { + const response = await this.fetchWithTimeout( + `${this.relayUrl}/api/devices/${encodeURIComponent(targetDeviceId)}/key`, + { headers: { Authorization: `Bearer ${identity.token}` } }, 20_000, + ); + if (!response.ok) { + const error = new Error(`Device key unavailable: HTTP ${response.status}`) as Error & { status?: number }; + error.status = response.status; + throw error; + } + const peer = await response.json(); + if (peer.device_id !== targetDeviceId) throw new Error('Relay returned a different device identity.'); + return deriveDeviceMessageKey(identity.masterKey, fromB64(peer.public_key)); + })(); + cached = { expires: Date.now() + 60_000, key }; + this.deviceMessageKeys.set(cacheId, cached); + } + const messageKey = await cached.key; const plaintext = JSON.stringify(command); const { data: encData, nonce: encNonce } = await encrypt( - identity.masterKey, + messageKey, plaintext, ); + if (identity.generation !== this.identityGeneration || targetEpoch !== this.controlTargetEpochValue) { + throw new AccountIdentityChangedError(); + } const timeoutMs = options.timeoutMs ?? (options.retryable ? 20_000 : 130_000); const resp = await (options.retryable ? this.fetchWithRetry : this.fetchWithTimeout).call( this, @@ -725,6 +233,11 @@ export class RelayHttpClient { body: JSON.stringify({ encrypted_data: encData, nonce: encNonce }), }, timeoutMs, + () => { + if (identity.generation !== this.identityGeneration || targetEpoch !== this.controlTargetEpochValue) { + throw new AccountIdentityChangedError(); + } + }, ); if (!resp.ok) { @@ -736,7 +249,7 @@ export class RelayHttpClient { } const data = await resp.json(); const decrypted = await decrypt( - identity.masterKey, + messageKey, data.encrypted_data, data.nonce, ); @@ -745,63 +258,22 @@ export class RelayHttpClient { throw new Error(parsed.message || 'Remote error'); } return parsed as T; - }, { allowAccountReplacementRetry: false }); + }).catch((error) => { + this.deviceMessageKeys.clear(); + throw error; + }); } - private async withDelegatedAuthRetry( - operation: (identity: DelegatedIdentitySnapshot) => Promise, - options: { allowAccountReplacementRetry: boolean }, - ): Promise { - let identity = this.requireDelegatedIdentity(); - const accountEpoch = this.delegatedAccountEpochValue; - // A forced refresh keeps the last committed bytes only so routing remains - // explicit; it suspends their authority. Fence before invoking the caller - // because checking only after a device RPC returns is too late for commands - // that may already have produced side effects on the old account. - this.ensureDelegatedIdentityCurrent(identity, accountEpoch); + private async withAccount(operation: (identity: AccountIdentitySnapshot) => Promise): Promise { + const identity = this.identity; + if (!identity) throw new Error('Sign in with GitHub to continue'); try { const result = await operation(identity); - this.ensureDelegatedIdentityCurrent(identity, accountEpoch); - return result; - } catch (e: unknown) { - if (!this.isDelegatedIdentityCurrent(identity)) { - throw this.delegatedIdentityChangeError(accountEpoch); - } - const status = (e as { status?: number })?.status; - // Only the relay HTTP authentication boundary can authorize this retry. - // An encrypted host error may describe an upstream 401 after a mutation - // already ran; its human-readable message is not a transport status. - if (status !== 401) throw e; - - const refreshed = await this.refreshDelegatedIdentityAfterUnauthorized(identity); - if (!refreshed) { - // A browser-restored direct account session has no QR room from which - // it can refresh. Preserve the relay's 401 so the pairing surface can - // explicitly fall back to password login instead of reporting the - // unrelated "No delegated identity" state. - if (identity.source === 'direct') throw e; - throw new Error('No delegated identity'); - } - if ( - !options.allowAccountReplacementRetry - && this.delegatedAccountEpochValue !== accountEpoch - ) { - // Never replay an A-owned RPC target/command with B's credentials. - // The account-owner listener has already cleared A's UI state. - throw new DelegatedAccountChangedError(); - } - identity = this.requireDelegatedIdentity(); - const result = await operation(identity); - this.ensureDelegatedIdentityCurrent(identity, this.delegatedAccountEpochValue); + if (this.identity !== identity) throw new AccountIdentityChangedError(); return result; + } catch (error) { + if (this.identity !== identity) throw new AccountIdentityChangedError(); + throw error; } } - - private getMobileDeviceName(): string { - const ua = navigator.userAgent; - if (/iPhone/i.test(ua)) return 'iPhone'; - if (/iPad/i.test(ua)) return 'iPad'; - if (/Android/i.test(ua)) return 'Android'; - return 'Mobile Browser'; - } } diff --git a/src/mobile-web/src/services/RemoteSessionManager.ts b/src/mobile-web/src/services/RemoteSessionManager.ts index f931eea875..ae65c0b38c 100644 --- a/src/mobile-web/src/services/RemoteSessionManager.ts +++ b/src/mobile-web/src/services/RemoteSessionManager.ts @@ -232,7 +232,7 @@ export class RemoteSessionManager { } get controlTargetDeviceId(): string | null { - return this.client.pairedDeviceId; + return this.client.targetDeviceId; } supportsHostCapability(capability: string): boolean { @@ -272,24 +272,10 @@ export class RemoteSessionManager { const relayOptions = options.timeoutMs === undefined ? { retryable } : { retryable, timeoutMs: options.timeoutMs }; - // The QR-paired desktop keeps the proven room channel. Only a switched - // control target (another same-account device) is reached through the - // relay device RPC API using the delegated identity. const targetDeviceId = target.deviceId; - const isRemoteTarget = - !!targetDeviceId - && targetDeviceId !== target.homeDeviceId; + if (!targetDeviceId) throw new Error('Select an account device to continue'); try { - let resp: T; - if (isRemoteTarget && targetDeviceId) { - resp = await this.client.sendDeviceRpc( - targetDeviceId, - cmdWithId, - relayOptions, - ); - } else { - resp = await this.client.sendCommand(cmdWithId, relayOptions); - } + const resp = await this.client.sendDeviceRpc(targetDeviceId, cmdWithId, relayOptions); this.ensureControlTargetCurrent(target); const respAny = resp as any; if (respAny.resp === 'error') { diff --git a/src/mobile-web/src/services/delegatedAccountOwner.ts b/src/mobile-web/src/services/accountOwner.ts similarity index 54% rename from src/mobile-web/src/services/delegatedAccountOwner.ts rename to src/mobile-web/src/services/accountOwner.ts index aa25060f38..49695c31b0 100644 --- a/src/mobile-web/src/services/delegatedAccountOwner.ts +++ b/src/mobile-web/src/services/accountOwner.ts @@ -1,13 +1,13 @@ -import type { DelegatedAccountOwnerChange } from './RelayHttpClient'; +import type { AccountOwnerChange } from './RelayHttpClient'; import { useMobileStore } from './store'; /** - * Reconcile a transport-level delegated-account commit with the mobile UI. + * Reconcile a transport-level account commit with the mobile UI. * Returns true when cached workspace/session/chat state belonged to a previous * owner and callers should leave any detail page that may still reference it. */ -export function reconcileDelegatedAccountOwner( - change: DelegatedAccountOwnerChange, +export function reconcileAccountOwner( + change: AccountOwnerChange, ): boolean { const store = useMobileStore.getState(); const initialOwnerConflicts = change.kind === 'initial' @@ -21,7 +21,7 @@ export function reconcileDelegatedAccountOwner( if (ownerWasReplaced) { store.resetForDeviceSwitch(); // A canonical account id is not user-facing, and the previous username no - // longer describes the active owner. Wait for a new authenticated pairing + // longer describes the active owner. Wait for a new account login // before showing an account label again. store.setAuthenticatedUserLabel(null); } @@ -32,19 +32,7 @@ export function reconcileDelegatedAccountOwner( return true; } - if (change.userId !== null || change.kind === 'replacement') { - // A current Desktop reports userId. Legacy responses may omit it; only a - // confirmed replacement is allowed to clear an already known old owner. - store.setAuthenticatedUserId(change.userId); - } - - if (ownerWasReplaced || store.controlTarget === null) { - store.setControlTarget(change.homeDeviceId ? { - deviceId: change.homeDeviceId, - deviceName: null, - isHome: true, - } : null); - } - + store.setAuthenticatedUserId(change.userId); + if (ownerWasReplaced) store.setControlTarget(null); return ownerWasReplaced; } diff --git a/src/mobile-web/src/services/pairingLink.ts b/src/mobile-web/src/services/pairingLink.ts index 375a4f6cf4..f52bf06e3f 100644 --- a/src/mobile-web/src/services/pairingLink.ts +++ b/src/mobile-web/src/services/pairingLink.ts @@ -1,48 +1,47 @@ +export const OFFICIAL_RELAY_URL = 'https://remote.openbitfun.com/v/1.0.0'; + export function normalizeRelayUrl(value: string): string | null { try { - const normalized = value - .replace(/^wss:\/\//, 'https://') - .replace(/^ws:\/\//, 'http://') - .replace(/\/ws\/?$/, '') - .replace(/\/$/, ''); - const url = new URL(normalized); - if (!['http:', 'https:'].includes(url.protocol) - || !url.hostname - || url.username - || url.password - || url.search - || url.hash) { - return null; - } - return url.toString().replace(/\/$/, ''); - } catch { - return null; + const url = new URL(value); + if (!['http:', 'https:'].includes(url.protocol) || !url.hostname + || url.username || url.password || url.search || url.hash) return null; + return url.href.replace(/\/+$/, ''); + } catch { return null; } +} + +function isLocalAddress(hostname: string): boolean { + if (hostname === 'localhost' || hostname === '[::1]') return true; + const octets = hostname.split('.').map(Number); + if (octets.length === 4 && octets.every(value => Number.isInteger(value) && value >= 0 && value <= 255)) { + return octets[0] === 10 || octets[0] === 127 + || (octets[0] === 192 && octets[1] === 168) + || (octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31) + || (octets[0] === 169 && octets[1] === 254); } + return /^\[(?:f[cd][0-9a-f]{2}|fe[89ab][0-9a-f]):/i.test(hostname); } -export function validPairingSecret(room: string | null, publicKey: string | null): boolean { - return !!room - && room.length <= 128 - && /^[A-Za-z0-9_-]+$/.test(room) - && !['_store', 'page-data', 'pages'].includes(room) - && !!publicKey - && publicKey.length <= 512 - && /^[A-Za-z0-9+/=_-]+$/.test(publicKey); +/** Invitations may select the official endpoint or a locally hosted Relay. */ +export function pairingRelayUrl(value: string): string | null { + const normalized = normalizeRelayUrl(value); + if (!normalized) return null; + if (normalized === OFFICIAL_RELAY_URL) return normalized; + const url = new URL(normalized); + return isLocalAddress(url.hostname) && url.pathname === '/' ? normalized : null; } -/** Validate a Desktop-generated remote-control URL before navigating to it. */ -export function parseScannedPairingLink(value: string, baseHref = window.location.href): string | null { - try { - const url = new URL(value.trim(), baseHref); - if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) return null; - if (!(url.hash === '#/pair' || url.hash.startsWith('#/pair?'))) return null; +export function currentRelayUrl(location: Pick = window.location): string { + const endpoint = pairingRelayUrl(`${location.origin}${location.pathname.replace(/index\.html$/, '')}`); + if (!endpoint) throw new Error('Open this page from the official Relay or a local Relay invitation.'); + return endpoint; +} - const params = new URLSearchParams(url.hash.replace(/^#\/pair\??/, '')); - if (!validPairingSecret(params.get('room'), params.get('pk'))) return null; - const relay = params.get('relay'); - if (relay && !normalizeRelayUrl(relay)) return null; - return url.toString(); - } catch { - return null; - } +/** A target is resolved only through the authenticated device directory. */ +export function accountDeviceIdFromHash(hash: string): string | null { + if (!hash.startsWith('#/pair?')) return null; + const params = new URLSearchParams(hash.slice(7)); + const ids = params.getAll('did'); + if (Array.from(params.keys()).some(key => key !== 'did') || ids.length !== 1 + || !/^[A-Za-z0-9_.-]{1,128}$/.test(ids[0]) || ['.', '..'].includes(ids[0])) return null; + return ids[0]; } diff --git a/src/mobile-web/src/services/store.ts b/src/mobile-web/src/services/store.ts index 826d38adfe..749de44088 100644 --- a/src/mobile-web/src/services/store.ts +++ b/src/mobile-web/src/services/store.ts @@ -37,9 +37,9 @@ interface MobileStore { * Current same-account control target (delegated identity flow). * `isHome` marks the QR-paired desktop this mobile session started from. */ - controlTarget: { deviceId: string; deviceName: string | null; isHome: boolean } | null; + controlTarget: { deviceId: string; deviceName: string | null } | null; setControlTarget: ( - target: { deviceId: string; deviceName: string | null; isHome: boolean } | null, + target: { deviceId: string; deviceName: string | null } | null, ) => void; sessions: SessionInfo[]; diff --git a/src/mobile-web/src/styles/components/adaptive-shell.scss b/src/mobile-web/src/styles/components/adaptive-shell.scss index 38a6309939..b70d291bfa 100644 --- a/src/mobile-web/src/styles/components/adaptive-shell.scss +++ b/src/mobile-web/src/styles/components/adaptive-shell.scss @@ -178,7 +178,6 @@ /* Shared compact visual language */ .session-list__header, .chat-page__header, -.devices-page__header, .workspace-page__header { min-height: 64px; background: color-mix(in srgb, var(--openbitfun-color-surface-canvas) 90%, transparent); @@ -187,7 +186,6 @@ } .session-list__header-copy h1, -.devices-page__title, .workspace-page__header h1 { font-family: var(--openbitfun-type-heading-compact-page-font-family); font-weight: var(--openbitfun-type-heading-compact-page-font-weight); @@ -425,7 +423,6 @@ cursor: pointer; } -.devices-page__body, .workspace-page__content { width: 100%; } @@ -437,21 +434,6 @@ box-shadow: var(--openbitfun-shadow-xs); } -.devices-page__device-icon, -.devices-page__device.is-current .devices-page__device-icon { - width: 42px; - height: 42px; - border-color: var(--openbitfun-color-border-subtle); - background: var(--openbitfun-color-surface-tertiary); - color: var(--openbitfun-color-content-secondary); -} - -.devices-page__badge--current { - border-color: var(--openbitfun-color-border-subtle); - background: var(--openbitfun-color-surface-panel); - color: var(--openbitfun-color-content-secondary); -} - .workspace-page__btn--primary { background: var(--openbitfun-color-action-primary-background); color: var(--openbitfun-color-action-primary-content); @@ -520,14 +502,12 @@ } .chat-page__header, - .devices-page__header, .workspace-page__header { min-height: 72px; } .chat-page__messages { padding-top: 30px; } - .devices-page__body, .workspace-page__content { max-width: 760px; margin: 0 auto; @@ -569,7 +549,6 @@ .session-list__header-target { display: none; } .chat-page__header, - .devices-page__header, .workspace-page__header { padding-left: max(12px, env(safe-area-inset-left, 0px)); padding-right: max(12px, env(safe-area-inset-right, 0px)); diff --git a/src/mobile-web/src/styles/components/devices.scss b/src/mobile-web/src/styles/components/devices.scss index cdf8274591..40762d27c2 100644 --- a/src/mobile-web/src/styles/components/devices.scss +++ b/src/mobile-web/src/styles/components/devices.scss @@ -13,18 +13,23 @@ } .devices-page__header { - position: relative; - z-index: 1; - display: flex; - align-items: center; - gap: var(--size-gap-3); - padding: 0 var(--size-gap-4); - min-height: 56px; - border-bottom: 1px solid var(--openbitfun-color-border-subtle); - background: var(--openbitfun-color-surface-raised); - backdrop-filter: var(--openbitfun-effect-blur-base); - -webkit-backdrop-filter: var(--openbitfun-effect-blur-base); flex-shrink: 0; + min-height: 72px; + padding: max(var(--size-gap-3), env(safe-area-inset-top, 0px)) var(--size-gap-5) var(--size-gap-3); + background: var(--openbitfun-color-surface-canvas); + + [data-openbitfun-part='title'] { + font-size: var(--openbitfun-type-heading-dialog-font-size); + line-height: var(--openbitfun-type-heading-dialog-line-height); + } +} + +.devices-page__description { + margin: 0; + padding: 0 var(--size-gap-5) var(--size-gap-4); + color: var(--openbitfun-color-content-secondary); + font-size: var(--openbitfun-type-body-md-font-size); + line-height: var(--openbitfun-type-body-md-line-height); } .devices-page__back-btn, @@ -32,45 +37,40 @@ flex-shrink: 0; } -.devices-page__title { - flex: 1; - margin: 0; - font-size: var(--openbitfun-type-flow-title-font-size); - font-weight: var(--openbitfun-type-heading-page-font-weight); - color: var(--openbitfun-color-content-primary); - letter-spacing: var(--openbitfun-type-modifier-tracking-subtle-letter-spacing); -} - .devices-page__body { flex: 1; overflow-y: auto; -webkit-overflow-scrolling: touch; - padding: var(--size-gap-4); + padding: var(--size-gap-2) var(--size-gap-5) max(var(--size-gap-6), env(safe-area-inset-bottom, 0px)); } .devices-page__list { display: flex; flex-direction: column; - gap: var(--size-gap-3); + gap: 0; + overflow: hidden; + border: var(--openbitfun-border-width-default) solid var(--openbitfun-color-border-subtle); + border-radius: var(--size-radius-lg); + background: var(--openbitfun-color-surface-panel); +} + +.devices-page__device { + min-height: 72px; + border: 0; + border-radius: 0; + background: transparent; + box-shadow: none; + + & + & { border-top: var(--openbitfun-border-width-default) solid var(--openbitfun-color-border-subtle); } } .devices-page__device-icon { display: inline-flex; align-items: center; justify-content: center; - width: 38px; - height: 38px; - border: 1px solid var(--openbitfun-color-border-subtle); - border-radius: var(--size-radius-lg); - background: var(--openbitfun-color-surface-subtle); + width: 24px; color: var(--openbitfun-color-content-secondary); flex-shrink: 0; - - .is-current & { - background: var(--openbitfun-color-accent-surface); - border-color: var(--openbitfun-color-accent-surface-strong); - color: var(--openbitfun-color-accent-default); - } } .devices-page__device-copy { @@ -78,7 +78,7 @@ min-width: 0; display: flex; flex-direction: column; - gap: 3px; + gap: var(--size-gap-1); } .devices-page__device-name-row { @@ -118,7 +118,7 @@ .devices-page__device-meta { display: flex; align-items: center; - gap: 5px; + gap: var(--size-gap-2); font-size: var(--openbitfun-type-body-xs-font-size); color: var(--openbitfun-color-content-muted); } @@ -139,12 +139,6 @@ } } -.devices-page__device-id { - margin-left: var(--size-gap-1); - font-family: var(--openbitfun-type-code-md-font-family); - letter-spacing: var(--openbitfun-type-modifier-tracking-wide-letter-spacing); -} - .devices-page__device-spinner { flex-shrink: 0; width: 16px; @@ -211,7 +205,9 @@ } .devices-page__error { - margin: var(--size-gap-3) var(--size-gap-4) 0; + flex-shrink: 0; + width: auto; + margin: 0 var(--size-gap-5) var(--size-gap-2); padding: var(--size-gap-2) var(--size-gap-3); font-size: var(--openbitfun-type-body-xs-font-size); color: var(--openbitfun-color-status-danger-content); @@ -219,3 +215,10 @@ border: 1px solid var(--openbitfun-color-status-danger-border); border-radius: var(--size-radius-base); } + +@media (min-width: 760px) { + .devices-page__header, + .devices-page__description, + .devices-page__body { width: min(100%, 720px); margin-inline: auto; } + .devices-page__error { width: 680px; margin-inline: auto; } +} diff --git a/src/mobile-web/src/styles/components/harmony-native.scss b/src/mobile-web/src/styles/components/harmony-native.scss index b4c70d78ad..291774c7ca 100644 --- a/src/mobile-web/src/styles/components/harmony-native.scss +++ b/src/mobile-web/src/styles/components/harmony-native.scss @@ -864,625 +864,6 @@ animation: harmonySidebarSpin 800ms linear infinite; } -/* Pair/account sheet ------------------------------------------------------ */ - -.pairing-page { - display: flex; - align-items: stretch; - justify-content: center; - min-height: 100dvh; - padding: max(6px, env(safe-area-inset-top, 0px)) 0 0; - background: var(--openbitfun-color-overlay-scrim); - overflow: hidden; -} - -.pairing-page__shell { - display: block; - width: min(100%, 520px); - min-height: calc(100dvh - max(6px, env(safe-area-inset-top, 0px))); - margin: 0 auto; - overflow: hidden; - border: 0; - border-radius: var(--harmony-sheet-radius) var(--harmony-sheet-radius) 0 0; - background: var(--harmony-page); - box-shadow: none; -} - -.pairing-page__hero { - display: none; -} - -.pairing-page__panel { - display: grid; - grid-template-rows: 56px minmax(0, 1fr); - justify-content: stretch; - min-height: inherit; - padding: 0; - background: var(--harmony-page); -} - -.pairing-page__header { - display: grid; - grid-template-columns: minmax(0, 1fr) 44px; - align-items: center; - gap: 10px; - width: 100%; - min-width: 0; - padding: 0 12px 0 20px; -} - -.pairing-page__back { - display: grid; - place-items: center; - width: var(--openbitfun-space-12); - height: var(--openbitfun-space-12); - padding: 0; - border: 0; - border-radius: 50%; - background: transparent; - color: var(--openbitfun-color-content-primary); - cursor: pointer; -} - -.pairing-page__header-spacer { - min-width: 0; -} - -.pairing-page__form { - display: grid; - grid-template-rows: minmax(0, 1fr) auto; - min-height: 0; - padding: 0; -} - -.pairing-page__scroll { - display: flex; - min-height: 0; - overflow-y: auto; - scrollbar-width: none; -} - -.pairing-page__scroll::-webkit-scrollbar { - display: none; -} - -.pairing-page__form-content { - align-self: center; - width: 100%; - max-width: 520px; - padding: 8px 20px 18px; - margin: auto; -} - -.pairing-page__title { - width: 100%; - margin: 0; - color: var(--openbitfun-color-content-primary); - font-size: var(--openbitfun-type-heading-dialog-font-size); - font-weight: var(--openbitfun-type-heading-page-font-weight); - line-height: var(--openbitfun-type-modifier-leading-dense-line-height); - text-align: center; -} - -.pairing-page__intro { - margin: 8px 0 24px; - color: var(--openbitfun-color-content-secondary); - font-size: var(--openbitfun-type-body-md-font-size); - line-height: var(--openbitfun-type-body-md-line-height); - text-align: center; -} - -.pairing-page__credentials { - overflow: hidden; - border: 1px solid var(--harmony-line); - border-radius: 24px; - background: var(--harmony-card); - box-shadow: 0 5px 16px color-mix(in srgb, var(--openbitfun-color-content-primary) 3%, transparent); -} - -.pairing-page__field { - position: relative; - display: flex; - flex-direction: row; - align-items: center; - width: 100%; - height: 60px; - gap: 12px; - padding: 0 12px 0 18px; - border: 0; - border-radius: 0; - background: transparent; - box-shadow: none; -} - -.pairing-page__field:focus-within { - box-shadow: inset 0 0 0 2px var(--openbitfun-color-focus-ring); -} - -.pairing-page__field > [data-openbitfun-part='leading'] { - flex-basis: 24px; - inline-size: 24px; - block-size: 24px; -} - -.pairing-page__field > [data-openbitfun-part='trailing'] { - flex-basis: 44px; - inline-size: 44px; - block-size: 44px; -} - -.pairing-page__credentials .pairing-page__field + .pairing-page__field::before { - content: ''; - position: absolute; - top: 0; - right: 16px; - left: 54px; - height: 1px; - background: var(--harmony-line); -} - -.pairing-page__field-label { - display: none; -} - -.pairing-page__input { - flex: 1; - min-width: 0; - width: auto; - height: 60px; - min-height: 60px; - padding: 0 4px 0 0; - border: 0; - border-radius: 0; - background: transparent; - color: var(--openbitfun-color-content-primary); - font-size: var(--openbitfun-type-flow-title-font-size); - outline: none; - box-shadow: none; -} - -.pairing-page__input::placeholder { - color: var(--openbitfun-color-content-muted); -} - -.pairing-page__input:-webkit-autofill, -.pairing-page__input:-webkit-autofill:hover, -.pairing-page__input:-webkit-autofill:focus { - animation: pairingAutofillReconcile 1ms; - -webkit-text-fill-color: var(--openbitfun-color-content-primary); - box-shadow: 0 0 0 1000px var(--harmony-card) inset; - transition: background-color 9999s ease-out; -} - -@keyframes pairingAutofillReconcile { - from { opacity: 0.999; } - to { opacity: 1; } -} - -.pairing-page__input:focus { - border: 0; - background: transparent; - box-shadow: none; -} - -.pairing-page__password-field { - position: relative; -} - -.pairing-page__field-icon { - display: grid; - place-items: center; - flex: 0 0 24px; - width: 24px; - height: 24px; - color: var(--openbitfun-color-content-secondary); -} - -.pairing-page__password-toggle { - display: grid; - place-items: center; - width: var(--openbitfun-space-12); - height: var(--openbitfun-space-12); - padding: 0; - border: 0; - background: transparent; - color: var(--openbitfun-color-content-primary); -} - -.pairing-page__sr-only { - position: absolute; - width: 1px; - height: 1px; - padding: 0; - margin: -1px; - overflow: hidden; - clip: rect(0, 0, 0, 0); - white-space: nowrap; - border: 0; -} - -.pairing-page__advanced { - margin-top: 14px; - overflow: hidden; - border: 1px solid var(--harmony-line); - border-radius: 20px; - background: var(--harmony-card); - color: var(--openbitfun-color-content-secondary); -} - -.pairing-page__advanced > [data-openbitfun-part='trigger'] { - display: flex; - align-items: center; - min-height: 58px; - gap: 14px; - padding: 10px 18px; - color: var(--openbitfun-color-content-primary); - font-size: var(--openbitfun-type-body-lg-font-size); - font-weight: var(--openbitfun-type-label-lg-font-weight); - cursor: pointer; -} - -.pairing-page__advanced > [data-openbitfun-part='trigger'] > [data-openbitfun-part='title'] { - font: inherit; -} - -.pairing-page__advanced > [data-openbitfun-part='trigger']:focus-visible { - // Keep the keyboard focus ring inside the clipped rounded surface. - outline-offset: -3px; - border-radius: inherit; -} - -.pairing-page__advanced > [data-openbitfun-part='body'] { - // This disclosure has no leading icon; the form content owns its inset. - padding: 0; -} - -.pairing-page__advanced-actions { - display: flex; - align-items: stretch; - flex-direction: column; - gap: 8px; - padding: 14px 18px 18px; - border-top: 1px solid var(--harmony-line); -} - -.pairing-page__relay-field { - display: flex; - flex-direction: column; - gap: 8px; - width: 100%; - color: var(--openbitfun-color-content-secondary); - font-size: var(--openbitfun-type-body-xs-font-size); - line-height: var(--openbitfun-type-modifier-leading-balanced-line-height); -} - -.pairing-page__relay-field [data-openbitfun-component='mobile-text-field'] { - width: 100%; - height: 48px; - padding: 0 14px; - border: 1px solid var(--harmony-line); - border-radius: 14px; - background: var(--harmony-page); - color: var(--openbitfun-color-content-primary); - font: inherit; - font-size: var(--openbitfun-type-body-md-font-size); -} - -.pairing-page__relay-field [data-openbitfun-component='mobile-text-field']:focus-within { - border-color: var(--openbitfun-color-content-secondary); -} - -.pairing-page__theme-btn { - display: inline-flex; - align-items: center; - gap: 8px; - min-height: 48px; - padding: 0 14px; - border: 1px solid var(--harmony-line); - border-radius: 20px; - background: var(--harmony-card); - color: var(--openbitfun-color-content-secondary); -} - -.pairing-page__note { - margin: 10px 18px 16px; - color: var(--openbitfun-color-content-muted); - font-size: var(--openbitfun-type-body-xs-font-size); - line-height: var(--openbitfun-type-modifier-leading-ui-line-height); -} - -.pairing-page__error { - margin: 14px 0 0; - padding: 10px 14px; - border: 0; - border-radius: 8px; - background: var(--openbitfun-color-status-danger-surface); - color: var(--openbitfun-color-status-danger-content); - font-size: var(--openbitfun-type-body-sm-font-size); - line-height: var(--openbitfun-type-support-line-height); -} - -.pairing-page__retry { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 9px; - width: 100%; - min-height: 56px; - margin: 0; - border: 0; - border-radius: 18px; - background: var(--openbitfun-color-action-primary-background); - color: var(--openbitfun-color-action-primary-content); - font-size: var(--openbitfun-type-body-lg-font-size); - font-weight: var(--openbitfun-type-heading-page-font-weight); - cursor: pointer; -} - -.pairing-page__retry:disabled { - background: var(--openbitfun-color-action-primary-background); - color: var(--openbitfun-color-action-primary-content); - opacity: 0.28; -} - -.pairing-page__form:has(.pairing-page__input--username:placeholder-shown:not(:-webkit-autofill)) .pairing-page__retry, -.pairing-page__form:has(.pairing-page__input--password:placeholder-shown:not(:-webkit-autofill)) .pairing-page__retry { - opacity: 0.28; - pointer-events: none; -} - -.pairing-page__action { - display: flex; - flex-direction: column; - gap: 10px; - padding: 12px 20px max(22px, env(safe-area-inset-bottom, 0px)); -} - -.pairing-page__scan-action { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 10px; - width: 100%; - min-height: 52px; - padding: 0 18px; - border: 1px solid var(--harmony-line); - border-radius: 18px; - background: var(--harmony-card); - color: var(--openbitfun-color-content-primary); - font-size: var(--openbitfun-type-body-lg-font-size); - font-weight: var(--openbitfun-type-label-selected-font-weight); - cursor: pointer; -} - -.pairing-page__scan-action:active:not(:disabled) { - background: var(--harmony-soft); - transform: scale(0.99); -} - -.pairing-page__scan-action:disabled { - color: var(--openbitfun-color-content-disabled); - cursor: default; - opacity: var(--openbitfun-opacity-disabled); -} - -.pairing-page__progress { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 16px; - color: var(--openbitfun-color-content-secondary); -} - -/* QR scanner ------------------------------------------------------------- */ - -.qr-scanner-sheet__backdrop { - position: fixed; - inset: 0; - z-index: 1200; - display: flex; - align-items: flex-start; - justify-content: center; - padding-top: max(6px, env(safe-area-inset-top, 0px)); - background: var(--openbitfun-color-overlay-scrim); -} - -.qr-scanner-sheet { - display: grid; - grid-template-rows: auto minmax(0, 1fr); - width: min(100%, 520px); - height: calc(100dvh - max(6px, env(safe-area-inset-top, 0px))); - overflow: hidden; - border-radius: var(--harmony-sheet-radius) var(--harmony-sheet-radius) 0 0; - background: var(--harmony-page); - box-shadow: var(--openbitfun-shadow-xl); -} - -.qr-scanner-sheet__header { - display: grid; - grid-template-columns: minmax(0, 1fr) 44px; - align-items: start; - gap: 14px; - padding: 24px 16px 16px 24px; -} - -.qr-scanner-sheet__header h2 { - margin: 0; - color: var(--openbitfun-color-content-primary); - font-size: var(--openbitfun-type-heading-dialog-font-size); - font-weight: var(--openbitfun-type-heading-page-font-weight); - line-height: var(--openbitfun-type-modifier-leading-dense-line-height); -} - -.qr-scanner-sheet__header p { - margin: 6px 0 0; - color: var(--openbitfun-color-content-secondary); - font-size: var(--openbitfun-type-body-md-font-size); - line-height: var(--openbitfun-type-body-md-line-height); -} - -.qr-scanner-sheet__header button { - display: grid; - place-items: center; - width: var(--openbitfun-space-12); - height: var(--openbitfun-space-12); - padding: 0; - border: 0; - border-radius: 22px; - background: var(--harmony-card); - color: var(--openbitfun-color-content-primary); - box-shadow: var(--openbitfun-shadow-sm); -} - -.qr-scanner-sheet__content { - min-height: 0; - padding: 6px 24px max(28px, env(safe-area-inset-bottom, 0px)); - overflow-y: auto; -} - -.qr-scanner-sheet__camera { - position: relative; - width: min(100%, 320px); - aspect-ratio: 1; - margin: 4px auto 20px; - overflow: hidden; - border: 1px solid var(--harmony-line); - border-radius: 28px; - background: var(--harmony-soft); -} - -.qr-scanner-sheet__camera video, -.qr-scanner-sheet__shade { - position: absolute; - inset: 0; - width: 100%; - height: 100%; -} - -.qr-scanner-sheet__camera video { - object-fit: cover; -} - -.qr-scanner-sheet__shade { - pointer-events: none; - box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--openbitfun-color-content-primary) 10%, transparent); -} - -.qr-scanner-sheet__corner { - position: absolute; - width: 46px; - height: 46px; - border-color: var(--openbitfun-color-action-primary-content); - border-style: solid; - filter: drop-shadow(0 1px 2px var(--openbitfun-color-overlay-scrim)); -} - -.qr-scanner-sheet__corner--tl { top: 20px; left: 20px; border-width: 4px 0 0 4px; border-radius: 12px 0 0; } -.qr-scanner-sheet__corner--tr { top: 20px; right: 20px; border-width: 4px 4px 0 0; border-radius: 0 12px 0 0; } -.qr-scanner-sheet__corner--bl { bottom: 20px; left: 20px; border-width: 0 0 4px 4px; border-radius: 0 0 0 12px; } -.qr-scanner-sheet__corner--br { right: 20px; bottom: 20px; border-width: 0 4px 4px 0; border-radius: 0 0 12px; } - -.qr-scanner-sheet__starting { - position: absolute; - inset: 0; - display: flex; - align-items: center; - justify-content: center; - flex-direction: column; - gap: 12px; - background: color-mix(in srgb, var(--harmony-soft) 88%, transparent); - color: var(--openbitfun-color-content-secondary); - font-size: var(--openbitfun-type-body-sm-font-size); -} - -.qr-scanner-sheet__error { - width: min(100%, 360px); - margin: 0 auto 14px; - padding: 11px 14px; - border-radius: 14px; - background: var(--openbitfun-color-status-danger-surface); - color: var(--openbitfun-color-status-danger-content); - font-size: var(--openbitfun-type-body-sm-font-size); - line-height: var(--openbitfun-type-support-line-height); - text-align: center; -} - -.qr-scanner-sheet__image-action { - display: flex; - align-items: center; - justify-content: center; - gap: 9px; - width: min(100%, 360px); - min-height: 48px; - margin: 0 auto; - border: 1px solid var(--harmony-line); - border-radius: 16px; - background: var(--harmony-card); - color: var(--openbitfun-color-content-primary); - font-size: var(--openbitfun-type-body-md-font-size); - font-weight: var(--openbitfun-type-label-selected-font-weight); - cursor: pointer; -} - -.qr-scanner-sheet__image-action input { - position: absolute; - width: 1px; - height: 1px; - opacity: 0; - pointer-events: none; -} - -.qr-scanner-sheet__manual { - width: min(100%, 360px); - margin: 22px auto 0; -} - -.qr-scanner-sheet__manual > label { - display: block; - margin: 0 4px 8px; - color: var(--openbitfun-color-content-secondary); - font-size: var(--openbitfun-type-body-sm-font-size); -} - -.qr-scanner-sheet__manual > div { - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - gap: 8px; -} - -.qr-scanner-sheet__manual [data-openbitfun-component='mobile-text-field'] { - min-width: 0; - height: 48px; - padding: 0 13px; - border: 1px solid var(--harmony-line); - border-radius: 14px; - background: var(--harmony-card); - color: var(--openbitfun-color-content-primary); - font: inherit; - font-size: var(--openbitfun-type-body-md-font-size); -} - -.qr-scanner-sheet__manual [data-openbitfun-component='mobile-text-field']:focus-within { - border-color: var(--openbitfun-color-content-secondary); -} - -.qr-scanner-sheet__manual button { - min-width: 76px; - height: 48px; - padding: 0 16px; - border: 0; - border-radius: 14px; - background: var(--openbitfun-color-action-primary-background); - color: var(--openbitfun-color-action-primary-content); - font-size: var(--openbitfun-type-body-md-font-size); - font-weight: var(--openbitfun-type-label-selected-font-weight); -} - -.qr-scanner-sheet__manual button:disabled { - opacity: 0.28; -} - /* Root/sidebar-like session surface -------------------------------------- */ .session-list { @@ -1660,55 +1041,6 @@ background: var(--harmony-card); } -/* Native full-page secondary surfaces ----------------------------------- */ - -.devices-page { - background: var(--harmony-page); -} - -.devices-page__header { - min-height: 92px; - padding: max(18px, env(safe-area-inset-top, 0px)) 28px 0; - border: 0; - background: var(--harmony-page); -} - -.devices-page__title { - font-size: var(--openbitfun-type-heading-dialog-font-size); - font-weight: var(--openbitfun-type-heading-page-font-weight); - line-height: var(--openbitfun-type-modifier-leading-dense-line-height); -} - -.devices-page__body { - padding: 10px 28px max(32px, env(safe-area-inset-bottom, 0px)); -} - -.devices-page__device, -.devices-page__empty-card { - border: 1px solid var(--harmony-line); - border-radius: 8px; - background: var(--harmony-card); - box-shadow: none; -} - -.devices-page__list { - overflow: hidden; - border: 1px solid var(--harmony-line); - border-radius: 8px; - background: var(--harmony-card); -} - -.devices-page__device { - min-height: 60px; - border: 0; - border-bottom: 1px solid var(--harmony-line); - border-radius: 0; -} - -.devices-page__device:last-child { - border-bottom: 0; -} - /* Workspace picker sheet ------------------------------------------------- */ .workspace-page { @@ -1990,17 +1322,6 @@ } @media (min-width: 760px) { - .qr-scanner-sheet__backdrop { - align-items: center; - padding: 24px; - } - - .qr-scanner-sheet { - height: min(780px, calc(100dvh - 48px)); - border: 1px solid var(--harmony-line); - border-radius: 28px; - } - .harmony-sidebar__sheet-backdrop { align-items: center; justify-content: center; @@ -2015,64 +1336,6 @@ box-shadow: 0 18px 60px var(--openbitfun-color-overlay-scrim); } - .pairing-page { - align-items: center; - padding: 32px; - background: var(--harmony-page); - } - - .pairing-page__shell { - display: grid; - grid-template-columns: minmax(0, 1.08fr) minmax(380px, 0.92fr); - width: min(100%, 1040px); - min-height: min(700px, calc(100dvh - 64px)); - margin: auto; - border: 1px solid var(--harmony-line); - border-radius: 32px; - background: var(--harmony-card); - box-shadow: 0 18px 60px var(--openbitfun-color-overlay-scrim); - } - - .pairing-page__hero { - display: flex; - padding: 56px 48px 42px; - } - - .pairing-page__hero-copy h2 { - max-width: 440px; - margin: 16px 0 18px; - color: var(--openbitfun-color-content-primary); - font-size: var(--openbitfun-type-display-fluid-hero-font-size); - font-weight: var(--openbitfun-type-heading-page-font-weight); - line-height: var(--openbitfun-type-display-fluid-hero-line-height); - letter-spacing: var(--openbitfun-type-modifier-tracking-tighter-letter-spacing); - } - - .pairing-page__panel { - min-height: 0; - border-radius: 0 32px 32px 0; - background: var(--harmony-page); - } - - .pairing-page__form { - padding: 0; - } - - .pairing-page__form-content { - padding-right: 56px; - padding-left: 56px; - } - - .pairing-page__action { - padding-right: 56px; - padding-bottom: 48px; - padding-left: 56px; - } - - .pairing-page__retry { - margin-top: auto; - } - .remote-shell__master { flex-basis: 360px; width: 360px; diff --git a/src/mobile-web/src/styles/components/pairing.scss b/src/mobile-web/src/styles/components/pairing.scss index 95958d0691..2ab23df3b5 100644 --- a/src/mobile-web/src/styles/components/pairing.scss +++ b/src/mobile-web/src/styles/components/pairing.scss @@ -1,434 +1,82 @@ -@use '../motion' as motion; - +/* Sign-in is one compact task surface at every viewport size. */ .pairing-page { - position: relative; display: grid; + align-items: center; min-height: 100%; min-height: 100dvh; padding: - max(24px, env(safe-area-inset-top, 0px)) - max(20px, env(safe-area-inset-right, 0px)) - max(24px, env(safe-area-inset-bottom, 0px)) - max(20px, env(safe-area-inset-left, 0px)); + max(var(--size-gap-8), env(safe-area-inset-top, 0px)) + max(var(--size-gap-5), env(safe-area-inset-right, 0px)) + max(var(--size-gap-8), env(safe-area-inset-bottom, 0px)) + max(var(--size-gap-5), env(safe-area-inset-left, 0px)); background: var(--openbitfun-color-surface-canvas); overflow-y: auto; - animation: fadeIn var(--motion-slow) motion.$easing-decelerate; -} - -.pairing-page__actions { - position: fixed; - top: calc(16px + env(safe-area-inset-top, 0px)); - right: calc(16px + env(safe-area-inset-right, 0px)); - z-index: 10; - display: flex; - align-items: center; - gap: 8px; -} - -.pairing-page__theme-btn { - display: inline-flex; - align-items: center; - justify-content: center; - width: 44px; - height: 44px; - padding: 0; - border: 1px solid var(--openbitfun-color-border-subtle); - border-radius: 50%; - background: var(--openbitfun-color-surface-panel); - color: var(--openbitfun-color-content-secondary); - box-shadow: var(--openbitfun-shadow-sm); - cursor: pointer; - transition: transform var(--motion-fast) var(--easing-standard), - background var(--motion-fast) var(--easing-standard), - color var(--motion-fast) var(--easing-standard); - - &:hover { - color: var(--openbitfun-color-content-primary); - background: var(--openbitfun-color-surface-tertiary); - } - - &:active { transform: scale(0.96); } } .pairing-page__shell { - width: min(100%, 1080px); - min-height: min(680px, calc(100dvh - 48px)); + width: min(100%, 440px); + min-width: 0; margin: auto; - display: grid; - grid-template-columns: minmax(0, 1.08fr) minmax(360px, 0.92fr); - overflow: hidden; - border: 1px solid var(--openbitfun-color-border-subtle); - border-radius: 32px; - background: var(--openbitfun-color-surface-panel); - box-shadow: 0 28px 80px color-mix(in srgb, var(--openbitfun-color-content-primary) 10%, transparent); -} - -.pairing-page__hero { - position: relative; - display: flex; - flex-direction: column; - justify-content: space-between; - min-height: 100%; - padding: 56px 52px 42px; - overflow: hidden; - background: - radial-gradient(circle at 18% 16%, color-mix(in srgb, var(--openbitfun-color-accent-secondary-border) 70%, transparent), transparent 38%), - linear-gradient(145deg, var(--openbitfun-color-accent-surface), var(--openbitfun-color-surface-tertiary)); - - &::after { - content: ''; - position: absolute; - width: 360px; - height: 360px; - right: -150px; - bottom: -170px; - border: 1px solid color-mix(in srgb, var(--openbitfun-color-content-primary) 10%, transparent); - border-radius: 50%; - box-shadow: - 0 0 0 54px color-mix(in srgb, var(--openbitfun-color-content-primary) 3%, transparent), - 0 0 0 108px color-mix(in srgb, var(--openbitfun-color-content-primary) 2%, transparent); - } } -.pairing-page__hero-copy { - position: relative; - z-index: 1; - max-width: 470px; - - h1 { - max-width: 440px; - margin: 16px 0 18px; - font-family: var(--openbitfun-type-display-fluid-hero-font-family); - font-size: var(--openbitfun-type-display-fluid-hero-font-size); - font-weight: var(--openbitfun-type-display-fluid-hero-font-weight); - line-height: var(--openbitfun-type-display-fluid-hero-line-height); - letter-spacing: var(--openbitfun-type-display-fluid-hero-letter-spacing); - color: var(--openbitfun-color-content-primary); - } - - p { - max-width: 410px; - font-size: var(--openbitfun-type-flow-lead-font-size); - line-height: var(--openbitfun-type-flow-lead-line-height); - color: var(--openbitfun-color-content-secondary); - } -} - -.pairing-page__eyebrow { - display: inline-flex; - align-items: center; - min-height: 26px; - padding: 0 10px; - border: 1px solid var(--openbitfun-color-border-default); - border-radius: 999px; - color: var(--openbitfun-color-content-secondary); - font-size: var(--openbitfun-type-overline-sm-font-size); - font-weight: var(--openbitfun-type-overline-sm-font-weight); - letter-spacing: var(--openbitfun-type-modifier-tracking-caps-letter-spacing); - text-transform: uppercase; - background: color-mix(in srgb, var(--openbitfun-color-surface-panel) 66%, transparent); -} - -.pairing-page__connection-visual { - position: relative; - z-index: 1; +.pairing-page__brand { display: flex; align-items: center; - justify-content: center; - min-height: 190px; - margin: 26px 0 30px; -} - -.pairing-page__device { - position: relative; - flex: 0 0 auto; + gap: var(--size-gap-3); + margin-bottom: var(--size-gap-8); color: var(--openbitfun-color-content-primary); -} - -.pairing-page__device-screen { - display: block; - border: 2px solid currentColor; - background: var(--openbitfun-color-surface-panel); - box-shadow: inset 0 0 0 6px var(--openbitfun-color-surface-tertiary), var(--openbitfun-shadow-lg); -} - -.pairing-page__device--desktop { - width: 178px; - - .pairing-page__device-screen { - width: 178px; - height: 112px; - border-radius: 14px; - } -} - -.pairing-page__device-base { - display: block; - width: 76px; - height: 12px; - margin: 7px auto 0; - border: solid currentColor; - border-width: 0 8px 2px; - border-radius: 0 0 8px 8px; - opacity: 0.72; -} - -.pairing-page__device--phone { - width: 64px; - - .pairing-page__device-screen { - width: 64px; - height: 122px; - border-radius: 17px; - box-shadow: inset 0 0 0 5px var(--openbitfun-color-surface-tertiary), var(--openbitfun-shadow-lg); - } -} - -.pairing-page__connection-line { - position: relative; - width: clamp(58px, 7vw, 92px); - height: 2px; - margin: 0 18px; - background: var(--openbitfun-color-border-strong); - - i { - position: absolute; - top: 50%; - width: 7px; - height: 7px; - border-radius: 50%; - background: var(--openbitfun-color-content-primary); - transform: translate(-50%, -50%); - animation: pairingSignal 2.1s ease-in-out infinite; - - &:nth-child(1) { left: 10%; } - &:nth-child(2) { left: 50%; animation-delay: 0.18s; } - &:nth-child(3) { left: 90%; animation-delay: 0.36s; } - } -} - -@keyframes pairingSignal { - 0%, 55%, 100% { opacity: 0.25; transform: translate(-50%, -50%) scale(0.75); } - 25% { opacity: 1; transform: translate(-50%, -50%) scale(1); } -} - -.pairing-page__security-note { - position: relative; - z-index: 1; - display: flex; - align-items: center; - gap: 8px; - font-size: var(--openbitfun-type-body-xs-font-size); - color: var(--openbitfun-color-content-secondary); -} + font-size: var(--openbitfun-type-body-lg-font-size); + font-weight: var(--openbitfun-type-label-selected-font-weight); -.pairing-page__security-dot { - width: 8px; - height: 8px; - border-radius: 50%; - background: var(--openbitfun-color-status-success-emphasis); - box-shadow: 0 0 0 4px var(--openbitfun-color-status-success-surface); + img { display: block; object-fit: contain; } } .pairing-page__panel { - display: flex; - flex-direction: column; - justify-content: center; - padding: 64px clamp(34px, 5vw, 68px); + padding: var(--size-gap-6); + border: var(--openbitfun-border-width-default) solid var(--openbitfun-color-border-subtle); + border-radius: var(--size-radius-xl); background: var(--openbitfun-color-surface-panel); } -.pairing-page__identity { - display: grid; - grid-template-columns: 52px minmax(0, 1fr) 28px; - align-items: center; - gap: 14px; - margin-bottom: 34px; -} - -.pairing-page__logo { - width: 52px; - height: 52px; - object-fit: contain; -} - -.pairing-page__brand { - font-size: var(--openbitfun-type-heading-compact-page-font-size); - font-weight: var(--openbitfun-type-label-selected-font-weight); - color: var(--openbitfun-color-content-primary); - letter-spacing: var(--openbitfun-type-modifier-tracking-wider-letter-spacing); -} - -.pairing-page__state { - margin-top: 3px; - font-size: var(--openbitfun-type-body-md-font-size); - line-height: var(--openbitfun-type-body-md-line-height); - color: var(--openbitfun-color-content-muted); -} - -.pairing-page__spinner-wrap { - width: 28px; - min-height: 28px; - display: flex; - align-items: center; - justify-content: center; - - .spinner { - width: 22px; - height: 22px; - border-width: 2px; - } -} - .pairing-page__form { - width: 100%; display: flex; flex-direction: column; - gap: 18px; + gap: var(--size-gap-6); + min-width: 0; } -.pairing-page__field { +.pairing-page__form-content { display: flex; flex-direction: column; - gap: 8px; + gap: var(--size-gap-3); } -.pairing-page__field-label { - font-size: var(--openbitfun-type-body-xs-font-size); - color: var(--openbitfun-color-content-secondary); - font-weight: var(--openbitfun-type-label-lg-font-weight); - letter-spacing: var(--openbitfun-type-modifier-tracking-wider-letter-spacing); -} - -.pairing-page__input { - width: 100%; - min-height: 50px; - padding: 0 16px; - border: 1px solid var(--openbitfun-color-border-default); - border-radius: 15px; - background: var(--openbitfun-color-surface-canvas); +.pairing-page__title { + min-width: 0; + margin: 0; + overflow-wrap: anywhere; color: var(--openbitfun-color-content-primary); - font-size: var(--openbitfun-type-body-lg-font-size); - outline: none; - transition: border-color var(--motion-fast), box-shadow var(--motion-fast), background var(--motion-fast); - - &::placeholder { color: var(--openbitfun-color-content-muted); } - &:hover { background: var(--openbitfun-color-surface-tertiary); } - - &:focus { - border-color: var(--openbitfun-color-content-primary); - background: var(--openbitfun-color-surface-panel); - box-shadow: 0 0 0 3px color-mix(in srgb, var(--openbitfun-color-content-primary) 10%, transparent); - } + font-size: var(--openbitfun-type-heading-dialog-font-size); + font-weight: var(--openbitfun-type-heading-dialog-font-weight); + line-height: var(--openbitfun-type-heading-dialog-line-height); } -.pairing-page__note { - margin: -2px 0 0; - font-size: var(--openbitfun-type-body-xs-font-size); - line-height: var(--openbitfun-type-body-md-line-height); - color: var(--openbitfun-color-content-muted); -} - -.pairing-page__error { - margin-top: 18px; - padding: 10px 12px; - font-size: var(--openbitfun-type-body-xs-font-size); - border: 1px solid var(--openbitfun-color-status-danger-border); - border-radius: 12px; - background: var(--openbitfun-color-status-danger-surface); - color: var(--openbitfun-color-status-danger-content); +.pairing-page__intro { + margin: 0; + color: var(--openbitfun-color-content-secondary); + font-size: var(--openbitfun-type-body-md-font-size); line-height: var(--openbitfun-type-body-md-line-height); - text-align: left; } -.pairing-page__retry { - min-height: 50px; - padding: 0 24px; - border: 0; - border-radius: 15px; - background: var(--openbitfun-color-action-primary-background); - color: var(--openbitfun-color-action-primary-content); - font-size: var(--openbitfun-type-body-md-font-size); - font-weight: var(--openbitfun-type-label-lg-font-weight); - cursor: pointer; - box-shadow: var(--openbitfun-shadow-sm); - transition: transform var(--motion-fast), background var(--motion-fast), opacity var(--motion-fast); - - &:hover:not(:disabled) { background: var(--openbitfun-color-action-primary-hover); } - &:active:not(:disabled) { transform: translateY(1px) scale(0.995); } - &:disabled { opacity: 0.5; cursor: not-allowed; } +.pairing-page__action { + display: flex; + flex-direction: column; + gap: var(--size-gap-3); } -@media (max-width: 759px) { - .pairing-page { - display: block; - padding: - max(76px, calc(56px + env(safe-area-inset-top, 0px))) - max(18px, env(safe-area-inset-right, 0px)) - max(24px, env(safe-area-inset-bottom, 0px)) - max(18px, env(safe-area-inset-left, 0px)); - } - - .pairing-page__shell { - display: flex; - flex-direction: column; - min-height: 0; - border: 0; - border-radius: 0; - background: transparent; - box-shadow: none; - overflow: visible; - } - - .pairing-page__hero { - min-height: 0; - padding: 12px 4px 24px; - background: transparent; - overflow: visible; - - &::after { display: none; } - } - - .pairing-page__hero-copy { - h1 { - max-width: 340px; - margin: 12px 0 10px; - font-size: var(--openbitfun-type-display-fluid-title-font-size); - line-height: var(--openbitfun-type-display-fluid-title-line-height); - } - - p { - max-width: 350px; - font-size: var(--openbitfun-type-body-md-font-size); - line-height: var(--openbitfun-type-support-line-height); - } - } - - .pairing-page__connection-visual { display: none; } - .pairing-page__security-note { margin-top: 18px; } - - .pairing-page__panel { - margin-top: 8px; - padding: 24px 20px; - border: 1px solid var(--openbitfun-color-border-subtle); - border-radius: 24px; - box-shadow: var(--openbitfun-shadow-base); - } - - .pairing-page__identity { margin-bottom: 26px; } - .pairing-page__logo { width: 46px; height: 46px; } -} +.pairing-page__error { margin: 0; } @media (max-width: 380px) { - .pairing-page__actions { top: calc(10px + env(safe-area-inset-top, 0px)); } - .pairing-page__panel { padding: 22px 16px; } - .pairing-page__identity { grid-template-columns: 44px minmax(0, 1fr) 24px; gap: 10px; } - .pairing-page__brand { font-size: var(--openbitfun-type-flow-section-title-font-size); } -} - -@media (prefers-reduced-motion: reduce) { - .pairing-page, - .pairing-page__connection-line i { - animation: none; - } + .pairing-page { padding-inline: var(--size-gap-4); } + .pairing-page__panel { padding: var(--size-gap-5); } } diff --git a/src/mobile-web/tests/account-login.test.mjs b/src/mobile-web/tests/account-login.test.mjs index 42a8b514b6..de06dced4d 100644 --- a/src/mobile-web/tests/account-login.test.mjs +++ b/src/mobile-web/tests/account-login.test.mjs @@ -14,6 +14,7 @@ async function loadSource(relativePath, imports = {}) { return { url: `data:text/javascript;base64,${Buffer.from(code).toString('base64')}`, source }; } +const links = await loadSource('../src/services/pairingLink.ts'); const selection = await loadSource('../src/services/accountDeviceSelection.ts'); const { selectAccountDevice } = await import(selection.url); const offline = { device_id: 'desktop-a', device_name: 'Offline desktop', online: false }; @@ -74,45 +75,42 @@ test('online selection preserves exact QR targeting and supports later device av assert.equal(selectAccountDevice([reconnected, online], 'browser', offline.device_id), reconnected); }); -test('real account authentication does not request a device or QR room', async () => { - const { argon2idAsync } = await import('@noble/hashes/argon2.js'); - const { gcm } = await import('@noble/ciphers/aes.js'); +test('GitHub login registers an independent device public key and reuses the device key across sign-ins', async () => { const encryption = await loadSource('../src/services/E2EEncryption.ts'); - const authModule = await loadSource('../src/services/CloudAccountClient.ts', { - './E2EEncryption': encryption.url, - }); + const { deriveDeviceMessageKey } = await import(encryption.url); + const { x25519 } = await import('@noble/curves/ed25519.js'); + const authModule = await loadSource('../src/services/CloudAccountClient.ts', { './E2EEncryption': encryption.url, './pairingLink': links.url }); const { CloudAccountClient } = await import(authModule.url); - const params = { m: 8192, t: 1, p: 1 }; - const password = 'local-test-only'; - const salt = new Uint8Array(16).fill(1); - const kdfSalt = new Uint8Array(16).fill(2); - const masterKey = new Uint8Array(32).fill(3); - const nonce = new Uint8Array(12).fill(4); - const kek = await argon2idAsync(password, salt, { ...params, dkLen: 32 }); - const passwordHash = await argon2idAsync(password, kdfSalt, { ...params, dkLen: 32 }); - const b64 = value => Buffer.from(value).toString('base64'); const originalFetch = globalThis.fetch; const originalWindow = globalThis.window; const requests = []; globalThis.window = { setTimeout, clearTimeout }; + const originalStorage = globalThis.sessionStorage; + const keys = new Map(); + globalThis.sessionStorage = { getItem: key => keys.get(key) ?? null, setItem: (key, value) => keys.set(key, value) }; globalThis.fetch = async (url, options) => { - const path = new URL(url).pathname; - requests.push(path); - if (path.endsWith('/challenge')) return Response.json({ - salt: b64(salt), kdf_salt: b64(kdfSalt), argon2_params: JSON.stringify(params), - wrapped_master_key: `${b64(gcm(kek, nonce).encrypt(masterKey))}.${b64(nonce)}`, - }); - assert.equal(path, '/api/auth/login'); + assert.equal(url, 'https://remote.openbitfun.com/v/1.0.0/api/auth/login'); const body = JSON.parse(options.body); - assert.equal(body.password_hash, b64(passwordHash)); - return Response.json({ token: 'test-account-token', user_id: 'test-account' }); + requests.push(body); + assert.equal(body.access_token, 'verified-github-token'); + assert.equal(body.device_id, 'browser'); + assert.equal(body.password_hash, undefined); + assert.equal(Buffer.from(body.public_key, 'base64').length, 32); + return Response.json({ token: 'test-account-token', user_id: '101' }); }; try { - const account = await new CloudAccountClient().login('http://test.invalid', 'test', password, 'browser'); - assert.equal(account.userId, 'test-account'); - assert.deepEqual(account.masterKey, masterKey); - assert.deepEqual(requests, ['/api/auth/login/challenge', '/api/auth/login']); + const first = await new CloudAccountClient('https://remote.openbitfun.com/v/1.0.0').login('verified-github-token', 'browser'); + const second = await new CloudAccountClient('https://remote.openbitfun.com/v/1.0.0').login('verified-github-token', 'browser'); + assert.equal(first.userId, '101'); + assert.deepEqual(first.masterKey, second.masterKey); + assert.equal(requests[0].public_key, requests[1].public_key); + assert.deepEqual(Buffer.from(requests[0].public_key, 'base64'), Buffer.from(x25519.getPublicKey(first.masterKey))); + assert.deepEqual(deriveDeviceMessageKey(first.masterKey, x25519.getPublicKey(second.masterKey)), + deriveDeviceMessageKey(second.masterKey, x25519.getPublicKey(first.masterKey))); + assert.throws(() => deriveDeviceMessageKey(first.masterKey, new Uint8Array(32))); + assert.equal(Buffer.from(deriveDeviceMessageKey(new Uint8Array(32).fill(7), x25519.getPublicKey(new Uint8Array(32).fill(11)))).toString('hex'), '6e8f5da837e91e9ddb09c5aa7dee229e731fc94499d29d10dcf5f1437193f56c'); } finally { + globalThis.sessionStorage = originalStorage; globalThis.fetch = originalFetch; if (originalWindow === undefined) delete globalThis.window; else globalThis.window = originalWindow; @@ -121,7 +119,7 @@ test('real account authentication does not request a device or QR room', async ( test('account UI entry precedes discovery and mounts no remote workspace surface', async () => { const pairing = await readFile(new URL('../src/pages/PairingPage.tsx', import.meta.url), 'utf8'); - const direct = pairing.slice(pairing.indexOf('const restoredAccount ='), pairing.indexOf('const initialSync =')); + const direct = pairing.slice(pairing.indexOf('const connect ='), pairing.indexOf(' useEffect(')); assert.match(direct, /saveCloudAccountSession/); assert.match(direct, /store\.setControlTarget\(null\)/); assert.match(direct, /onPairedRef\.current/); @@ -134,5 +132,69 @@ test('account UI entry precedes discovery and mounts no remote workspace surface assert.match(devices, /if \(!d.online \|\| switchingId\) return/); assert.match(devices, /automaticSelectionAttemptedRef\.current = true/); assert.match(devices, /selectDevice\(target, false\)/, 'initial account selection must not require a new peer command'); - assert.ok(devices.indexOf('await client.sendDeviceRpc') < devices.indexOf('client.setPairedDeviceId(d.device_id)')); + assert.ok(devices.indexOf('await client.sendDeviceRpc') < devices.indexOf('client.setTargetDeviceId(d.device_id)')); +}); + +test('authorization follows the central GitHub OAuth URL and rejects lookalike destinations', async () => { + const encryption = await loadSource('../src/services/E2EEncryption.ts'); + const authModule = await loadSource('../src/services/CloudAccountClient.ts', { './E2EEncryption': encryption.url, './pairingLink': links.url }); + const { CloudAccountClient } = await import(authModule.url); + const previous = { fetch: globalThis.fetch, window: globalThis.window, setTimeout: globalThis.setTimeout }; + globalThis.window = { setTimeout: previous.setTimeout, clearTimeout }; + globalThis.setTimeout = callback => { queueMicrotask(callback); return 0; }; + try { + for (const authorizationUrl of [ + 'https://github.com/login/oauth/authorize?state=test', + 'https://github.com.attacker.example/login/oauth/authorize', + 'https://github.com/login', 'https://user@github.com/login/oauth/authorize', + 'http://github.com/login/oauth/authorize', + ]) { + let polls = 0; + globalThis.fetch = async url => { + if (url.endsWith('/start')) return Response.json({ + transactionId: 'txn', transactionSecret: 'secret', authorizationUrl, + expiresAt: Date.now() / 1000 + 60, pollIntervalSeconds: 3, + }); + polls++; + return Response.json({ status: 'authorized', tokens: { accessToken: 'verified' } }); + }; + const popup = { location: { href: 'about:blank' } }; + const result = new CloudAccountClient('https://remote.openbitfun.com/v/1.0.0').authorize(popup, new AbortController().signal); + if (authorizationUrl === 'https://github.com/login/oauth/authorize?state=test') { + assert.equal(await result, 'verified'); + assert.equal(popup.location.href, authorizationUrl); + assert.equal(polls, 1); + } else { + await assert.rejects(result, /Untrusted/); + assert.equal(popup.location.href, 'about:blank'); + assert.equal(polls, 0); + } + } + } finally { + globalThis.fetch = previous.fetch; + globalThis.setTimeout = previous.setTimeout; + if (previous.window === undefined) delete globalThis.window; + else globalThis.window = previous.window; + } +}); + + +test('official and local invitations share strict device-only targeting', async () => { + const { currentRelayUrl, pairingRelayUrl, accountDeviceIdFromHash } = await import(links.url); + for (const base of ['https://remote.openbitfun.com/v/1.0.0/', 'http://192.168.1.9:9700/']) { + const url = new URL(`${base}#/pair?did=desktop-1`); + assert.equal(currentRelayUrl(url), base.replace(/\/$/, '')); + assert.equal(accountDeviceIdFromHash(url.hash), 'desktop-1'); + for (const hash of ['did=a&did=b', 'did=a&pk=untrusted', 'did=a&relay=https://evil.example', + 'did=%2Fother', 'room=room&pk=key', 'did=..']) { + assert.equal(accountDeviceIdFromHash(`#/pair?${hash}`), null); + } + } + for (const base of ['https://evil.example/', 'https://remote.openbitfun.com.evil.example/v/1.0.0/', + 'https://user@remote.openbitfun.com/v/1.0.0/', 'http://remote.openbitfun.com/v/1.0.0/', + 'https://remote.openbitfun.com/relay/']) { + assert.equal(pairingRelayUrl(base), null); + } + assert.equal(accountDeviceIdFromHash('#/pair?did=desktop'), 'desktop'); + assert.equal(accountDeviceIdFromHash('#/chat?did=desktop'), null); }); diff --git a/src/mobile-web/tests/mobile-ui-components.test.mjs b/src/mobile-web/tests/mobile-ui-components.test.mjs index 891e22a645..eea3305905 100644 --- a/src/mobile-web/tests/mobile-ui-components.test.mjs +++ b/src/mobile-web/tests/mobile-ui-components.test.mjs @@ -84,9 +84,8 @@ test('pairing and settings styles follow component parts instead of obsolete nat const harmony = await readFile(path.join(sourceDirectory, 'styles/components/harmony-native.scss'), 'utf8'); const overlays = await readFile(path.join(sourceDirectory, 'components/SessionOverlays.tsx'), 'utf8'); const questions = await readFile(path.join(sourceDirectory, 'components/ChatAskQuestionCard.tsx'), 'utf8'); + assert.doesNotMatch(harmony, /\.pairing-page(?:__|\s*\{)/, 'pairing layout has one owner in pairing.scss'); assert.doesNotMatch(harmony, /\.pairing-page__advanced(?:\[open\])?\s+summary/); - assert.match(harmony, /\.pairing-page__advanced > \[data-openbitfun-part='trigger'\]\s*\{[^}]*min-height:\s*58px;[^}]*padding:\s*10px 18px;/); - assert.match(harmony, /\.pairing-page__advanced > \[data-openbitfun-part='body'\]\s*\{[^}]*padding:\s*0;/); assert.doesNotMatch(harmony, /\.harmony-sidebar__settings-row > span:nth-child/); assert.doesNotMatch(harmony, /\.chat-page__(?:back|theme-btn) > svg/); assert.doesNotMatch(harmony, /\.pairing-page__relay-field input\s*\{|\.qr-scanner-sheet__manual input\s*\{/); diff --git a/src/shared/i18n/resources/shared/en-US/terms.json b/src/shared/i18n/resources/shared/en-US/terms.json index 5921a8b86e..c97bb65b12 100644 --- a/src/shared/i18n/resources/shared/en-US/terms.json +++ b/src/shared/i18n/resources/shared/en-US/terms.json @@ -33,7 +33,6 @@ }, "connectionMethods": { "lan": "LAN", - "ngrok": "Ngrok", "openbitfunServer": "OpenBitFun Server", "customServer": "Custom Server", "botFeishu": "Feishu Bot", diff --git a/src/shared/i18n/resources/shared/zh-CN/terms.json b/src/shared/i18n/resources/shared/zh-CN/terms.json index 27aa997a4c..90eadddded 100644 --- a/src/shared/i18n/resources/shared/zh-CN/terms.json +++ b/src/shared/i18n/resources/shared/zh-CN/terms.json @@ -33,7 +33,6 @@ }, "connectionMethods": { "lan": "局域网", - "ngrok": "Ngrok", "openbitfunServer": "OpenBitFun Server", "customServer": "自定义服务器", "botFeishu": "飞书机器人", diff --git a/src/shared/i18n/resources/shared/zh-TW/terms.json b/src/shared/i18n/resources/shared/zh-TW/terms.json index f50c9a51d8..9d648486b2 100644 --- a/src/shared/i18n/resources/shared/zh-TW/terms.json +++ b/src/shared/i18n/resources/shared/zh-TW/terms.json @@ -33,7 +33,6 @@ }, "connectionMethods": { "lan": "區域網路", - "ngrok": "Ngrok", "openbitfunServer": "OpenBitFun Server", "customServer": "自訂伺服器", "botFeishu": "飛書機器人", diff --git a/src/shared/interactive-capabilities/catalog.json b/src/shared/interactive-capabilities/catalog.json index 6d74414c56..f19ff9c2fa 100644 --- a/src/shared/interactive-capabilities/catalog.json +++ b/src/shared/interactive-capabilities/catalog.json @@ -232,6 +232,11 @@ "htmlPreview": [ "html_preview_create", "html_preview_release" + ], + "githubDeviceRouting": [ + "account_get_credential_hint", + "account_login", + "account_token_expired" ] }, "capabilities": [ @@ -2968,13 +2973,13 @@ "reasonEn": "Market sign-in requires the account holder to confirm an external authentication flow; the Agent can open the exact entry but cannot complete that authentication for them." }, "evidence": [ - "command:miniapp_market_auth_start", - "command:miniapp_market_auth_poll", + "command:account_github_start", + "command:account_github_poll", "command:miniapp_market_set_favorite", "command:miniapp_market_set_rating", "command:miniapp_market_installed_origins", - "command:miniapp_market_logout", - "command:miniapp_market_me" + "command:account_logout", + "command:account_github_info" ] }, { @@ -4230,7 +4235,7 @@ "微信", "多设备", "Peer Device", - "账户同步" + "GitHub" ], "keywordsEn": [ "remote connect", @@ -4241,32 +4246,31 @@ "WeChat", "multi-device", "peer device", - "account sync" + "GitHub" ], "highlightsZh": [ - "通过局域网、Ngrok 或自建 Relay 连接", + "通过局域网或官方 Relay 连接", "接入飞书、Telegram 或微信 Bot", - "登录账户、同步会话并进入 Peer Device Mode" + "使用 GitHub 身份管理设备并进入 Peer Device Mode" ], "highlightsEn": [ - "Connect through LAN, Ngrok, or a self-hosted relay", + "Connect through LAN or the official Relay", "Use Feishu, Telegram, or WeChat bots", - "Sign in, sync sessions, and enter Peer Device Mode" + "Use GitHub identity to manage devices and enter Peer Device Mode" ], "items": [ { "id": "connection-methods", - "titleZh": "选择局域网、Ngrok、官方 Relay、自建 Relay 或自定义服务器", - "titleEn": "Choose LAN, Ngrok, the hosted relay, a self-hosted relay, or a custom server", + "titleZh": "选择局域网或官方 Relay", + "titleEn": "Choose LAN or the official Relay", "control": { "kind": "open", "reasonCode": "unstructuredInteraction", - "reasonZh": "“选择局域网、Ngrok、官方 Relay、自建 Relay 或自定义服务器”由多个实时状态相关步骤组成,目前没有一个能确定完成整个流程的单一结构化 Command;Agent 会打开精确入口,并把后续交互保留在用户可见界面。", - "reasonEn": "“Choose LAN, Ngrok, the hosted relay, a self-hosted relay, or a custom server” spans multiple live-state-dependent steps and currently has no single structured Command that can deterministically complete the whole workflow; the Agent opens the exact entry and keeps the remaining interaction visible to the user." + "reasonZh": "“选择局域网或官方 Relay”需要结合当前网络与设备状态,确认目标主机后才能连接;Agent 打开连接入口,由用户完成选择。", + "reasonEn": "“Choose LAN or the official Relay” depends on the current network and device state and requires the user to confirm the target host; the Agent opens the connection entry for that choice." }, "evidence": [ - "command:remote_connect_get_methods", - "command:remote_connect_configure_custom_server" + "command:remote_connect_get_methods" ] }, { @@ -4299,7 +4303,8 @@ "evidence": [ "command:remote_connect_get_lan_ip", "command:remote_connect_get_lan_network_info", - "command:remote_connect_get_form_state" + "command:remote_connect_get_form_state", + "command:remote_connect_set_form_state" ] }, { @@ -4321,45 +4326,22 @@ "command:remote_connect_set_bot_verbose_mode" ] }, - { - "id": "relay-wizard", - "titleZh": "通过向导预检、安装 Docker、部署、注册并验证自建 Relay", - "titleEn": "Preflight, install Docker, deploy, register, and verify a self-hosted relay through the wizard", - "control": { - "kind": "open", - "reasonCode": "unstructuredInteraction", - "reasonZh": "“通过向导预检、安装 Docker、部署、注册并验证自建 Relay”由多个实时状态相关步骤组成,目前没有一个能确定完成整个流程的单一结构化 Command;Agent 会打开精确入口,并把后续交互保留在用户可见界面。", - "reasonEn": "“Preflight, install Docker, deploy, register, and verify a self-hosted relay through the wizard” spans multiple live-state-dependent steps and currently has no single structured Command that can deterministically complete the whole workflow; the Agent opens the exact entry and keeps the remaining interaction visible to the user." - }, - "evidence": [ - "command:relay_deploy_preflight", - "command:relay_deploy_install_docker", - "command:relay_deploy_start", - "command:relay_deploy_register", - "command:relay_deploy_verify", - "command:relay_deploy_cancel", - "command:relay_deploy_poll" - ] - }, { "id": "account", - "titleZh": "登录、退出并查看账户状态和凭据提示", - "titleEn": "Sign in, sign out, and inspect account status and credential hints", + "titleZh": "使用 GitHub 登录、退出并查看身份状态", + "titleEn": "Sign in with GitHub, sign out, and inspect identity status", "control": { "kind": "open", "reasonCode": "externalAuth", - "reasonZh": "OpenBitFun 账户登录必须由用户在外部认证页面确认;Agent 可以打开入口并读取非秘密状态,但不能冒充账户持有人。", - "reasonEn": "OpenBitFun account sign-in requires confirmation on an external authentication page; the Agent may open the entry and read non-secret status but cannot impersonate the account holder." + "reasonZh": "GitHub 账户登录必须由用户在外部认证页面确认;Agent 可以打开入口并读取非秘密状态,但不能冒充账户持有人。", + "reasonEn": "GitHub account sign-in requires confirmation on an external authentication page; the Agent may open the entry and read non-secret status but cannot impersonate the account holder." }, "evidence": [ - "command:account_login", - "command:account_finalize_login", + "command:account_github_start", + "command:account_github_poll", + "command:account_github_info", "command:account_logout", - "command:account_status", - "command:account_get_credential_hint", - "command:account_cancel_pending_login", - "command:account_delegate_to_paired", - "command:account_token_expired" + "command:account_status" ] }, { @@ -4381,44 +4363,6 @@ "command:account_execute_on_device" ] }, - { - "id": "session-sync", - "titleZh": "同步、导出、导入、删除或发送会话到另一台设备", - "titleEn": "Sync, export, import, delete, or send sessions to another device", - "control": { - "kind": "open", - "reasonCode": "unstructuredInteraction", - "reasonZh": "“同步、导出、导入、删除或发送会话到另一台设备”由多个实时状态相关步骤组成,目前没有一个能确定完成整个流程的单一结构化 Command;Agent 会打开精确入口,并把后续交互保留在用户可见界面。", - "reasonEn": "“Sync, export, import, delete, or send sessions to another device” spans multiple live-state-dependent steps and currently has no single structured Command that can deterministically complete the whole workflow; the Agent opens the exact entry and keeps the remaining interaction visible to the user." - }, - "evidence": [ - "command:account_sync_session", - "command:account_export_local_session", - "command:account_import_remote_sessions", - "command:account_delete_synced_session", - "command:account_send_session_to_device", - "command:account_export_all_sessions", - "command:account_fetch_session_turns", - "command:account_fetch_synced_sessions" - ] - }, - { - "id": "settings-sync", - "titleZh": "在设备间自动或手动同步 OpenBitFun 设置", - "titleEn": "Synchronize OpenBitFun settings across devices automatically or on demand", - "control": { - "kind": "open", - "reasonCode": "unstructuredInteraction", - "reasonZh": "“在设备间自动或手动同步 OpenBitFun 设置”由多个实时状态相关步骤组成,目前没有一个能确定完成整个流程的单一结构化 Command;Agent 会打开精确入口,并把后续交互保留在用户可见界面。", - "reasonEn": "“Synchronize OpenBitFun settings across devices automatically or on demand” spans multiple live-state-dependent steps and currently has no single structured Command that can deterministically complete the whole workflow; the Agent opens the exact entry and keeps the remaining interaction visible to the user." - }, - "evidence": [ - "command:account_sync_settings", - "command:account_fetch_settings", - "command:account_auto_sync", - "command:remote_connect_set_form_state" - ] - }, { "id": "peer-device", "titleZh": "进入 Peer Device Mode,把另一台 OpenBitFun 设备作为命令与事件数据面", @@ -4793,12 +4737,12 @@ } ], "stepsZh": [ - "登录 OpenBitFun 账户", + "使用 GitHub 登录", "打开 Pages", "选择页面并确认发布与可见性" ], "stepsEn": [ - "Sign in to a OpenBitFun account", + "Sign in to a GitHub account", "Open Pages", "Choose a page and confirm publishing and visibility" ], @@ -9456,9 +9400,6 @@ "worktree": { "capabilityId": "feature.git" }, - "relay_deploy": { - "capabilityId": "feature.remote-connect" - }, "browser": { "capabilityId": "feature.browser" }, @@ -9548,6 +9489,9 @@ }, "context_upload": { "capabilityId": "feature.files-editor" + }, + "account_identity": { + "capabilityId": "feature.remote-connect" } } } diff --git a/src/skin-market-web/src/account.ts b/src/skin-market-web/src/account.ts index 9fdbd4df30..ece1126b4e 100644 --- a/src/skin-market-web/src/account.ts +++ b/src/skin-market-web/src/account.ts @@ -66,5 +66,5 @@ export const sharedMarketAccountApi = { export function sharedMarketLoginUrl( returnTo = `${window.location.pathname}${window.location.search}`, ): string { - return `${SHARED_ACCOUNT_API_BASE}/auth/github/start?returnTo=${encodeURIComponent(returnTo)}`; + return `https://auth.openbitfun.com/sign-in?returnTo=${encodeURIComponent(returnTo)}`; } diff --git a/src/skin-market-web/src/api.test.ts b/src/skin-market-web/src/api.test.ts index f6799eb3ed..8de33b95f6 100644 --- a/src/skin-market-web/src/api.test.ts +++ b/src/skin-market-web/src/api.test.ts @@ -48,7 +48,7 @@ describe('Skin Market API paths', () => { it('uses the MiniApp auth broker and returns to the current Skin route', () => { expect(sharedMarketLoginUrl('/skin/appearances/ocean-night?q=dark')).toBe( - '/miniapp/api/v1/auth/github/start?returnTo=%2Fskin%2Fappearances%2Focean-night%3Fq%3Ddark', + 'https://auth.openbitfun.com/sign-in?returnTo=%2Fskin%2Fappearances%2Focean-night%3Fq%3Ddark', ); }); diff --git a/src/web-ui/AGENTS-CN.md b/src/web-ui/AGENTS-CN.md index e0de243c1b..b00d94d96e 100644 --- a/src/web-ui/AGENTS-CN.md +++ b/src/web-ui/AGENTS-CN.md @@ -27,8 +27,9 @@ Peer Device Mode(同账号远程完整客户端)的边界见 `docs/architect 前端不变量见 `src/infrastructure/peer-device/README.md`。不要重新引入内嵌会话/聊天壳; 应从设备列表(Remote Connect 的「我的 OpenBitFun」组)进入 peer mode。 -一键部署 Relay:`src/features/relay-deploy/`(见其 README)。Remote Connect「我的 OpenBitFun」 -登录表单与 Self-Hosted 入口必须打开 `RelayDeployWizard`,不要改成外链 README。 +Remote Connect 使用全局 GitHub 账户和官方版本化 Relay。账户控件复用 account-identity +服务,不再提供独立 Relay 账户、自定义服务器或自建部署入口。SSH 与 Docker 远程工作区 +继续独立使用,不受 Relay 登录限制。 ## 本模块规则 diff --git a/src/web-ui/AGENTS.md b/src/web-ui/AGENTS.md index 7c04997f4a..26e81d2fed 100644 --- a/src/web-ui/AGENTS.md +++ b/src/web-ui/AGENTS.md @@ -29,9 +29,10 @@ Peer Device Mode (same-account remote full client) is documented in sessions/chat shells; enter peer mode from the device list (Remote Connect → My OpenBitFun) instead. -One-click relay deploy wizard: `src/features/relay-deploy/` (see its README). -The Remote Connect account group (My OpenBitFun) login form and the Remote Connect -Self-Hosted entries must open `RelayDeployWizard`, not an external README. +Remote Connect uses the global GitHub account and the official versioned Relay. +Account controls use the shared account-identity service; do not expose a separate +Relay account, custom server field, or self-hosted deployment entry. SSH and Docker +workspace connections remain independent of Relay sign-in. ## Local rules diff --git a/src/web-ui/src/app/components/NavPanel/components/DeviceStatusControl.test.tsx b/src/web-ui/src/app/components/NavPanel/components/DeviceStatusControl.test.tsx index a98661c609..0c4474fe49 100644 --- a/src/web-ui/src/app/components/NavPanel/components/DeviceStatusControl.test.tsx +++ b/src/web-ui/src/app/components/NavPanel/components/DeviceStatusControl.test.tsx @@ -45,11 +45,10 @@ function overview(overrides: Partial = {}) { localDeviceName: 'Workstation', peer: null, remoteStatus: { - is_connected: false, - pairing_state: 'idle', + relay_connected: false, + relay_url: null, active_method: null, - peer_device_name: null, - peer_user_id: null, + clients: [], bot_connected: null, bot_verbose_mode: false, }, @@ -129,11 +128,10 @@ describe('device status card', () => { it('keeps connected controllers visible without the connection service card', () => { state.overview = overview({ remoteStatus: { - is_connected: true, - pairing_state: 'connected', - active_method: 'LAN', - peer_device_name: 'My phone', - peer_user_id: 'mobile-user', + relay_connected: true, + relay_url: 'http://192.168.1.2:9700', + active_method: 'lan', + clients: [{ id: 'mobile-user', name: 'My phone' }], bot_connected: null, bot_verbose_mode: false, } }); diff --git a/src/web-ui/src/app/components/NavPanel/components/DeviceStatusControl.tsx b/src/web-ui/src/app/components/NavPanel/components/DeviceStatusControl.tsx index 6698070731..edc31905a5 100644 --- a/src/web-ui/src/app/components/NavPanel/components/DeviceStatusControl.tsx +++ b/src/web-ui/src/app/components/NavPanel/components/DeviceStatusControl.tsx @@ -1,7 +1,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { OverflowText, Button, Card, CardBody, CardFooter, CardHeader, Icon, ScrollArea } from '@openbitfun/ui'; import { createPortal } from 'react-dom'; -import { Monitor, Server, Smartphone, Undo2 } from 'lucide-react'; +import { MessageCircle, Monitor, Server, Smartphone, Undo2 } from 'lucide-react'; import { useI18n } from '@/infrastructure/i18n/hooks/useI18n'; import { getAppearanceOverlayHost } from '@/infrastructure/appearance/runtime/AppearanceOverlayHost'; import { useAnchoredPopoverPosition } from '@/shared/utils/useAnchoredPopoverPosition'; @@ -55,7 +55,7 @@ function DeviceIcon({ case 'message-app': { const chatApp = chatAppBrandFromIdentity(identity); if (chatApp) return ; - return ; + return ; } default: return ; diff --git a/src/web-ui/src/app/components/NavPanel/deviceInterconnectionOverview.test.ts b/src/web-ui/src/app/components/NavPanel/deviceInterconnectionOverview.test.ts index ab2724a1b8..61a1f4d240 100644 --- a/src/web-ui/src/app/components/NavPanel/deviceInterconnectionOverview.test.ts +++ b/src/web-ui/src/app/components/NavPanel/deviceInterconnectionOverview.test.ts @@ -11,11 +11,10 @@ import { } from './deviceInterconnectionOverview'; const disconnectedStatus: RemoteConnectStatus = { - is_connected: false, - pairing_state: 'idle', + relay_connected: false, + relay_url: null, + clients: [], active_method: null, - peer_device_name: null, - peer_user_id: null, bot_connected: null, bot_verbose_mode: false, }; @@ -63,11 +62,10 @@ describe('projectDeviceInterconnectionOverview', () => { const overview = projectDeviceInterconnectionOverview(baseInput({ remoteStatus: { ...disconnectedStatus, - is_connected: true, - pairing_state: 'connected', - active_method: 'OpenBitFunServer', - peer_device_name: 'My iPhone', - peer_user_id: 'mobile-user', + relay_connected: true, + relay_url: 'https://remote.openbitfun.com/v/1.0.0', + active_method: 'openbitfun_server' as const, + clients: [{ id: 'mobile-user', name: 'My iPhone' }], }, })); @@ -190,11 +188,10 @@ describe('projectDeviceInterconnectionOverview', () => { describe('selectActivityFacts', () => { const connectedPhone = { ...disconnectedStatus, - is_connected: true, - pairing_state: 'connected' as const, - active_method: 'OpenBitFunServer', - peer_device_name: 'My iPhone', - peer_user_id: 'mobile-user', + relay_connected: true, + relay_url: 'https://remote.openbitfun.com/v/1.0.0', + active_method: 'openbitfun_server' as const, + clients: [{ id: 'mobile-user', name: 'My iPhone' }], }; it('reports nothing beyond the local host when no device is attached', () => { @@ -271,11 +268,10 @@ describe('selectAttachedGroups', () => { const overview = projectDeviceInterconnectionOverview(baseInput({ remoteStatus: { ...disconnectedStatus, - is_connected: true, - pairing_state: 'connected', - active_method: 'OpenBitFunServer', - peer_device_name: 'My iPhone', - peer_user_id: 'mobile-user', + relay_connected: true, + relay_url: 'https://remote.openbitfun.com/v/1.0.0', + active_method: 'openbitfun_server' as const, + clients: [{ id: 'mobile-user', name: 'My iPhone' }], bot_connected: 'Weixin (family group)', }, dispatchJobs: [ @@ -335,11 +331,10 @@ describe('device display names', () => { localDeviceName: 'Studio-Mac.local', remoteStatus: { ...disconnectedStatus, - is_connected: true, - pairing_state: 'connected', - active_method: 'OpenBitFunServer', - peer_device_name: 'Pixel.lan', - peer_user_id: 'mobile-user', + relay_connected: true, + relay_url: 'https://remote.openbitfun.com/v/1.0.0', + active_method: 'openbitfun_server' as const, + clients: [{ id: 'mobile-user', name: 'Pixel.lan' }], }, dispatchJobs: [ { diff --git a/src/web-ui/src/app/components/NavPanel/deviceInterconnectionOverview.ts b/src/web-ui/src/app/components/NavPanel/deviceInterconnectionOverview.ts index 4e15275f40..5947035d06 100644 --- a/src/web-ui/src/app/components/NavPanel/deviceInterconnectionOverview.ts +++ b/src/web-ui/src/app/components/NavPanel/deviceInterconnectionOverview.ts @@ -207,25 +207,6 @@ export function classifyAccountRelayUrl( return service.kind === 'official' ? 'official-relay' : 'self-hosted-relay'; } -function connectionServiceFromActiveMethod( - activeMethod: string | null, -): DeviceOverviewConnectionService { - const method = activeMethod?.trim().toLowerCase() ?? ''; - if (method.startsWith('lan')) { - return { kind: 'local-network', url: null, host: null }; - } - if (method.startsWith('ngrok')) { - return { kind: 'public-tunnel', url: null, host: null }; - } - if (method.startsWith('openbitfunserver')) { - return { kind: 'official', url: null, host: OFFICIAL_RELAY_HOST }; - } - if (method.startsWith('customserver')) { - return { kind: 'self-hosted', url: null, host: null }; - } - return { kind: 'device-service', url: null, host: null }; -} - function messageApplicationName(botConnected: string): string | undefined { const name = botConnected.split('(', 1)[0]?.trim(); return name || undefined; @@ -291,19 +272,14 @@ export function projectDeviceInterconnectionOverview( const network = selectRemoteNetworkConnection(input.remoteStatus); if (network.connected && input.remoteStatus) { - addOrMergeDevice(devices, { - id: `mobile:${input.remoteStatus.peer_user_id ?? input.remoteStatus.peer_device_name ?? 'connected'}`, - name: (network.roomConnected ? formatDeviceDisplayName(input.remoteStatus.peer_device_name) : '') - || input.fallbackMobileDeviceName || 'Mobile device', - kind: 'mobile', - local: false, - activities: ['controlling'], - backgroundTaskCount: 0, - }); - connectionService ??= network.roomConnected - ? connectionServiceFromActiveMethod(input.remoteStatus.active_method) - : connectionServiceFromRelayUrl(network.accountRelayUrl) - ?? connectionServiceFromActiveMethod(input.remoteStatus.active_method); + for (const client of input.remoteStatus.clients) { + addOrMergeDevice(devices, { + id: `mobile:${client.id}`, + name: formatDeviceDisplayName(client.name) || input.fallbackMobileDeviceName || 'Mobile device', + kind: 'mobile', local: false, activities: ['controlling'], backgroundTaskCount: 0, + }); + } + connectionService ??= connectionServiceFromRelayUrl(network.relayUrl); } // A paired bot contributes a controller and nothing else. It does not claim diff --git a/src/web-ui/src/app/components/RemoteConnectDialog/AccountPanel.scss b/src/web-ui/src/app/components/RemoteConnectDialog/AccountPanel.scss index 3c12490699..857492c2a2 100644 --- a/src/web-ui/src/app/components/RemoteConnectDialog/AccountPanel.scss +++ b/src/web-ui/src/app/components/RemoteConnectDialog/AccountPanel.scss @@ -1,129 +1,76 @@ -/** Account and peer-device content inside the Device & Connections work surface. */ - +/** Account content inherits the Remote Connect surface's spacing and typography. */ .account-panel { position: relative; display: flex; flex: 1; + min-width: 0; min-height: 0; flex-direction: column; - font-family:var(--openbitfun-type-body-sm-font-family); + font-family: var(--openbitfun-type-body-sm-font-family); &__scroll { flex: 1; min-height: 0; - padding: 20px 32px 28px; - } - - &[data-openbitfun-view='login'] &__scroll, - &[data-openbitfun-view='overwrite'] &__scroll { - display: flex; - flex-direction: column; - align-items: stretch; + padding: var(--openbitfun-space-6) var(--openbitfun-space-8) var(--openbitfun-space-8); } &__error-banner { flex-shrink: 0; - padding: 12px 32px 0; - background: transparent; + padding: var(--openbitfun-space-3) var(--openbitfun-space-8) 0; } - &__value-prop { - width: 100%; - max-width: none; - margin: 0 0 16px; - color: var(--openbitfun-color-content-secondary); - font-size: var(--openbitfun-type-label-sm-font-size); - line-height: var(--openbitfun-type-support-line-height); - } - - &__form { + &__login-card { display: flex; - width: 100%; - max-width: none; flex-direction: column; - gap: var(--openbitfun-space-4); - padding: var(--openbitfun-space-5); - border: 0; + align-items: flex-start; + gap: var(--openbitfun-space-3); + padding: var(--openbitfun-space-6); border-radius: var(--openbitfun-layout-field-group-radius); background: var(--openbitfun-color-surface-tertiary); } - &__field { - width: 100%; - } - - &__field-control, - &__input { - width: 100%; + &__login-icon { + display: inline-flex; + margin-bottom: var(--openbitfun-space-2); + color: var(--openbitfun-color-content-secondary); } - &__deploy-entry { - display: flex; - align-items: center; - justify-content: space-between; - gap: var(--openbitfun-space-3); - flex-wrap: wrap; - padding-top: var(--openbitfun-space-3); - border-top: var(--openbitfun-border-width-default) solid var(--openbitfun-color-border-subtle); - color: var(--openbitfun-color-content-secondary); - font-size: var(--openbitfun-type-micro-font-size); + &__value-prop { + margin: 0; + color: var(--openbitfun-color-content-primary); + font-size: var(--openbitfun-type-body-md-font-size); + font-weight: var(--openbitfun-type-label-selected-font-weight); + line-height: var(--openbitfun-type-body-md-line-height); } &__security-note { - margin: -4px 0 0; - color: var(--openbitfun-color-content-muted); - font-size: var(--openbitfun-type-micro-font-size); + max-width: 48ch; + margin: 0; + color: var(--openbitfun-color-content-secondary); + font-size: var(--openbitfun-type-body-sm-font-size); line-height: var(--openbitfun-type-body-sm-line-height); } &__actions { display: flex; - flex-shrink: 0; - justify-content: flex-end; - gap: 8px; + gap: var(--openbitfun-space-2); flex-wrap: wrap; - width: 100%; - max-width: none; - margin-top: 14px; - padding: 0; - background: transparent; + margin-top: var(--openbitfun-space-3); } - &[data-openbitfun-view='devices'] &__actions { - max-width: none; - } - - &__devices-card { - overflow: hidden; - border-radius: var(--openbitfun-layout-field-group-radius); - background: var(--openbitfun-color-surface-tertiary); - - .account-panel__actions { - margin: 0; - padding: var(--openbitfun-space-4) var(--openbitfun-space-5); - border-top: var(--openbitfun-border-width-default) solid var(--openbitfun-color-border-subtle); - } - - .account-panel__sync-indicator { - margin: var(--openbitfun-space-3) var(--openbitfun-space-5); - } - } + [data-openbitfun-component='button'][data-openbitfun-variant='text'] { color: var(--openbitfun-color-content-secondary); } - &__identity-line, - &__server-line { + &__identity-line { display: flex; align-items: center; gap: var(--openbitfun-space-3); - padding: var(--openbitfun-space-4) var(--openbitfun-space-5); - border-bottom: var(--openbitfun-border-width-default) solid var(--openbitfun-color-border-subtle); + padding-bottom: var(--openbitfun-space-6); color: var(--openbitfun-color-content-secondary); - > svg { - flex-shrink: 0; - } + > svg { flex-shrink: 0; } } - &__server-copy { + &__identity-copy { display: flex; flex: 1; min-width: 0; @@ -131,193 +78,62 @@ gap: var(--openbitfun-space-1); } - &__server-label { - color: var(--openbitfun-color-content-primary); - font-size: var(--openbitfun-type-label-md-font-size); - font-weight: var(--openbitfun-type-label-selected-font-weight); + &__identity-label { + color: var(--openbitfun-color-content-muted); + font-size: var(--openbitfun-type-body-sm-font-size); + line-height: var(--openbitfun-type-body-sm-line-height); } &__identity-name { - overflow-wrap: anywhere; - color: var(--openbitfun-color-content-secondary); - font-size: var(--openbitfun-type-label-sm-font-size); - user-select: text; - } - - &__server-url { min-width: 0; - flex: 1; - overflow: hidden; - font-family: var(--openbitfun-type-body-sm-font-family); - font-size: var(--openbitfun-type-label-sm-font-size); - color: var(--openbitfun-color-content-muted); - white-space: nowrap; + color: var(--openbitfun-color-content-primary); + font-size: var(--openbitfun-type-body-md-font-size); + font-weight: var(--openbitfun-type-label-selected-font-weight); + line-height: var(--openbitfun-type-body-md-line-height); + user-select: text; } - &__overwrite-notice { + &__section-heading { display: flex; - width: 100%; - max-width: none; - flex-direction: column; + align-items: center; + justify-content: space-between; gap: var(--openbitfun-space-3); - padding: var(--openbitfun-space-5); - color: var(--openbitfun-color-content-primary); - text-align: left; - align-items: flex-start; + margin-bottom: var(--openbitfun-space-3); - p { + h3 { margin: 0; - font-size: var(--openbitfun-type-label-md-font-size); - line-height: var(--openbitfun-type-body-lg-line-height); - } - } - - &__sync-options { - display: grid; - width: 100%; - max-width: none; - grid-template-columns: minmax(0, 1fr); - gap: 10px; - } - - &__sync-option { - display: flex; - align-items: flex-start; - gap: 10px; - min-height: 76px; - padding: var(--openbitfun-space-4) var(--openbitfun-space-5); - border: 0; - border-radius: var(--openbitfun-layout-field-group-radius); - background: var(--openbitfun-color-surface-tertiary); - color: var(--openbitfun-color-content-primary); - font: inherit; - text-align: left; - cursor: pointer; - transition: background-color var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard); - - &:hover:not(:disabled) { - background: var(--openbitfun-color-action-neutral-surface); - } - - &:active:not(:disabled) { - background: var(--openbitfun-color-action-neutral-surface-pressed); - } - - &:focus-visible { - outline: var(--openbitfun-focus-width) solid var(--openbitfun-color-focus-ring); - outline-offset: var(--openbitfun-focus-offset); - } - - &:disabled { - opacity: var(--openbitfun-opacity-disabled); - cursor: not-allowed; - } - - svg { - flex-shrink: 0; - margin-top: 2px; color: var(--openbitfun-color-content-secondary); + font-size: var(--openbitfun-type-body-sm-font-size); + font-weight: var(--openbitfun-type-label-selected-font-weight); + line-height: var(--openbitfun-type-body-sm-line-height); } } - &__sync-option-text { - display: flex; - flex-direction: column; - gap: 4px; - } - - &__sync-option-title { - font-size: var(--openbitfun-type-label-md-font-size); - font-weight: var(--openbitfun-type-label-selected-font-weight); - } - - &__sync-option-desc { - color: var(--openbitfun-color-content-secondary); - font-size: var(--openbitfun-type-micro-font-size); - line-height: var(--openbitfun-type-flow-support-line-height); - } - - &__sync-indicator { - display: flex; - flex-direction: column; - gap: 7px; - margin-bottom: 12px; - padding: 9px 11px; - border-radius: var(--openbitfun-layout-field-group-radius); - background: var(--openbitfun-color-surface-subtle); - color: var(--openbitfun-color-content-secondary); - font-size: var(--openbitfun-type-micro-font-size); - - &.done { - color: var(--openbitfun-color-status-success-content); - } - - &.failed { - color: var(--openbitfun-color-status-danger-content); - } - } - - &__sync-indicator-row { - display: flex; - align-items: center; - gap: 7px; - min-width: 0; - } - - &__sync-indicator-text { - min-width: 0; - flex: 1; - overflow: hidden; - white-space: nowrap; - } - - &__sync-indicator-percent { - flex-shrink: 0; - color: var(--openbitfun-color-content-muted); - font-variant-numeric: tabular-nums; - } - - &__sync-retry { - flex-shrink: 0; - } - - &__sync-progress-track { - width: 100%; - height: 4px; + &__devices-card { overflow: hidden; - border-radius: var(--openbitfun-radius-pill); - background: var(--openbitfun-color-border-default); - } + border-radius: var(--openbitfun-layout-field-group-radius); + background: var(--openbitfun-color-surface-tertiary); - &__sync-progress-fill { - height: 100%; - border-radius: inherit; - background: var(--openbitfun-color-accent-default); - transition: width var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard); + .account-panel__error-banner { padding: var(--openbitfun-space-4); } } &__device-list { display: flex; flex-direction: column; - overflow: hidden; - border-radius: 0; - background: transparent; } &__empty { - padding: 32px 16px; - border: 0; - background: transparent; - border-radius: 0; - color: var(--openbitfun-color-content-secondary); - font-size: var(--openbitfun-type-label-sm-font-size); + padding: var(--openbitfun-space-8) var(--openbitfun-space-4); + color: var(--openbitfun-color-content-muted); + font-size: var(--openbitfun-type-body-sm-font-size); + line-height: var(--openbitfun-type-body-sm-line-height); text-align: center; &--loading { - display: inline-flex; + display: flex; align-items: center; justify-content: center; - gap: 8px; + gap: var(--openbitfun-space-2); } } @@ -326,31 +142,15 @@ align-items: center; gap: var(--openbitfun-space-3); min-height: 72px; - padding: var(--openbitfun-space-4) var(--openbitfun-space-5); - border: 0; - border-radius: 0; - background: var(--openbitfun-color-surface-tertiary); - cursor: default; - transition: background-color var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard), box-shadow var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard); - - & + & { - border-top: var(--openbitfun-border-width-default) solid var(--openbitfun-color-border-subtle); - } - - &.current { - background: var(--openbitfun-color-surface-tertiary); - } - - &.selectable:not(.offline):has(> .account-panel__device-select:hover) { - background: var(--openbitfun-color-action-neutral-surface); - } - - &.selectable:not(.offline):has(> .account-panel__device-select:active) { - background: var(--openbitfun-color-action-neutral-surface-pressed); - } + padding: var(--openbitfun-space-4); + transition: background-color var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard); - &.selectable:not(.offline):has(> .account-panel__device-select:focus-visible) { - box-shadow: 0 0 0 var(--openbitfun-focus-width) var(--openbitfun-color-focus-ring); + & + & { border-top: var(--openbitfun-border-width-default) solid var(--openbitfun-color-border-subtle); } + &.selectable:has(> .account-panel__device-select:hover) { background: var(--openbitfun-color-action-neutral-surface); } + &.selectable:has(> .account-panel__device-select:active) { background: var(--openbitfun-color-action-neutral-surface-pressed); } + &.selectable:has(> .account-panel__device-select:focus-visible) { + outline: var(--openbitfun-focus-width) solid var(--openbitfun-color-focus-ring); + outline-offset: calc(0px - var(--openbitfun-focus-width)); } } @@ -359,26 +159,18 @@ min-width: 0; flex: 1; align-items: center; - gap: 10px; + gap: var(--openbitfun-space-3); padding: 0; border: 0; background: transparent; - color: inherit; + color: var(--openbitfun-color-content-secondary); font: inherit; text-align: left; cursor: default; - &:is(button):not(:disabled) { - cursor: pointer; - } - - &:disabled { - cursor: default; - } - - &:focus-visible { - outline: 0; - } + > svg { flex-shrink: 0; } + &:is(button):not(:disabled) { cursor: pointer; } + &:focus-visible { outline: 0; } } &__device-info { @@ -386,52 +178,27 @@ min-width: 0; flex: 1; flex-direction: column; - gap: 3px; + gap: var(--openbitfun-space-1); } &__device-name { display: flex; align-items: center; + gap: var(--openbitfun-space-2); min-width: 0; - - > span:not([data-overflow-content]):first-child { - min-width: 0; - overflow: hidden; - } - - > .account-panel__device-badge { - flex-shrink: 0; - } - - overflow: hidden; color: var(--openbitfun-color-content-primary); - font-size: var(--openbitfun-type-label-md-font-size); + font-size: var(--openbitfun-type-body-md-font-size); font-weight: var(--openbitfun-type-label-selected-font-weight); - white-space: nowrap; + line-height: var(--openbitfun-type-body-md-line-height); + + > span:not([data-overflow-content]):first-child { min-width: 0; overflow: hidden; } + > .account-panel__device-badge { flex-shrink: 0; } } &__device-meta { - display: inline-flex; - align-items: baseline; - flex-wrap: wrap; color: var(--openbitfun-color-content-muted); - font-family:var(--openbitfun-type-body-sm-font-family); - font-size: var(--openbitfun-type-micro-font-size); - line-height: var(--openbitfun-type-modifier-leading-ui-line-height); - } - - &__device-id { - font-family: var(--openbitfun-type-body-sm-font-family); - font-variant-numeric: tabular-nums; - } - - &__device-status { - font-family:var(--openbitfun-type-body-sm-font-family); - } - - &__device-badge { - margin-left: var(--openbitfun-space-2); - vertical-align: middle; + font-size: var(--openbitfun-type-body-sm-font-size); + line-height: var(--openbitfun-type-body-sm-line-height); } &__loading-overlay { @@ -442,17 +209,15 @@ flex-direction: column; align-items: center; justify-content: center; - gap: 9px; + gap: var(--openbitfun-space-3); border-radius: inherit; background: color-mix(in srgb, var(--openbitfun-color-surface-raised) 92%, transparent); color: var(--openbitfun-color-content-primary); - font-size: var(--openbitfun-type-label-sm-font-size); + font-size: var(--openbitfun-type-body-sm-font-size); backdrop-filter: var(--openbitfun-effect-blur-subtle); } - .spinning { - animation: openbitfun-account-panel-spin var(--openbitfun-motion-duration-lazy) linear infinite; - } + .spinning { animation: openbitfun-account-panel-spin var(--openbitfun-motion-duration-lazy) linear infinite; } } @keyframes openbitfun-account-panel-spin { @@ -460,47 +225,18 @@ to { transform: rotate(360deg); } } -@media (max-width: 920px) { - .account-panel { - &__scroll { - padding-inline: 28px; - } - - &__error-banner { - padding-inline: 28px; - } - } -} - @media (max-width: 760px) { - .account-panel { - &__scroll { - padding: 24px; - } - - &__error-banner { - padding-inline: 24px; - } + .account-panel__scroll { padding: var(--openbitfun-space-5); } + .account-panel__error-banner { padding-inline: var(--openbitfun-space-5); } + .account-panel__login-card { padding: var(--openbitfun-space-5); } +} - &__sync-options { - grid-template-columns: minmax(0, 1fr); - } - } +@media (max-width: 380px) { + .account-panel__scroll { padding-inline: var(--openbitfun-space-4); } + .account-panel__device-card { padding-inline: var(--openbitfun-space-3); gap: var(--openbitfun-space-2); } } @media (prefers-reduced-motion: reduce) { - .account-panel__sync-option, - .account-panel__device-card, - .account-panel .spinning { - transition: none; - } - - .account-panel .spinning { - animation: none; - transform: none; - } - - .account-panel__sync-progress-fill { - transition: none; - } + .account-panel__device-card { transition: none; } + .account-panel .spinning { animation: none; } } diff --git a/src/web-ui/src/app/components/RemoteConnectDialog/AccountPanel.tsx b/src/web-ui/src/app/components/RemoteConnectDialog/AccountPanel.tsx index 103ca4e223..9871784ed5 100644 --- a/src/web-ui/src/app/components/RemoteConnectDialog/AccountPanel.tsx +++ b/src/web-ui/src/app/components/RemoteConnectDialog/AccountPanel.tsx @@ -1,49 +1,25 @@ -/** - * Account ("My OpenBitFun") panel inside the Remote Connect dialog. - * - * Views: login → overwrite (optional) → devices - * Unlike the old standalone dialog, a successful login keeps the panel open - * and lands on the devices view so sync progress stays visible in place. - * - * Sync-choice invariants (do not regress): - * - When the relay already has cloud settings, `account_login` keeps the - * session memory-only until `account_finalize_login`. Canceling the - * overwrite view, switching away from this panel, or closing the dialog - * must conditionally cancel its opaque owner so a killed process does not - * restore login. - * - One-click deploy opens `RelayDeployWizard` (same feature as the Network - * group), not an external README. See `src/features/relay-deploy/README.md`. - */ +/** Account login and authenticated device connections. */ -import { OverflowText, Alert, Button, Field, Icon, IconButton, Input, ScrollArea, StatusPill } from '@openbitfun/ui'; +import { OverflowText, Alert, Button, Icon, IconButton, ScrollArea, StatusPill } from '@openbitfun/ui'; import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { useI18n } from '@/infrastructure/i18n'; -import { useCurrentWorkspace } from '@/infrastructure/contexts/WorkspaceContext'; import { confirmDanger, - confirmWarning, } from '@/infrastructure/confirm-dialog'; -import { Lock, Server, LogIn, Monitor, CloudDownload, EyeOff, Rocket } from 'lucide-react'; +import { LogIn, Monitor } from 'lucide-react'; import { remoteConnectAPI } from '@/infrastructure/api/service-api/RemoteConnectAPI'; import type { - AccountHint, AccountDeviceInfo, OnlineDeviceInfo, } from '@/infrastructure/api/service-api/RemoteConnectAPI'; -import { RelayDeployWizard } from '@/features/relay-deploy'; -import type { RelayDeployResult } from '@/features/relay-deploy'; -import { configAPI } from '@/infrastructure/api/service-api/ConfigAPI'; -import { configManager } from '@/infrastructure/config/services/ConfigManager'; +import { accountIdentityService, useAccountIdentity } from '@/infrastructure/account-identity'; import { api } from '@/infrastructure/api/service-api/ApiClient'; import { usePeerDeviceMode } from '@/infrastructure/peer-device/peerDeviceContextState'; -import { useAccountSyncStore, ensureAccountSyncProgressListener } from '@/infrastructure/account/accountSyncStore'; -import type { AccountSyncPhase } from '@/infrastructure/account/accountSyncStore'; import { isAccountAuthFailure, isRelayUnreachable, } from '@/infrastructure/account/accountErrorUtils'; import { useNotification } from '@/shared/notification-system'; -import { copyTextToClipboard } from '@/shared/utils/textSelection'; import { createLogger } from '@/shared/utils/logger'; import './AccountPanel.scss'; @@ -53,7 +29,6 @@ const DEVICE_POLL_FALLBACK_MS = 30_000; const DEVICE_CONNECT_MAX_ATTEMPTS = 5; const DEVICE_CONNECT_RECOVERY_INTERVAL_MS = 30_000; const DEVICE_LIST_FAILURE_THRESHOLD = 3; -const ACCOUNT_TRANSITION_MAX_ATTEMPTS = 4; async function connectDevicesWithRetry( isCurrent: () => boolean, @@ -83,162 +58,30 @@ async function connectDevicesWithRetry( throw lastError; } -function parseRelayServer(value: string): URL | null { - try { - const url = new URL(value.trim()); - if (!['http:', 'https:'].includes(url.protocol) - || !url.hostname - || url.username - || url.password - || url.search - || url.hash) { - return null; - } - return url; - } catch { - return null; - } -} - -async function cancelPendingLoginWithRetry(pendingLoginId: string): Promise { - let lastError: unknown = null; - for (let attempt = 1; attempt <= ACCOUNT_TRANSITION_MAX_ATTEMPTS; attempt += 1) { - try { - return await remoteConnectAPI.accountCancelPendingLogin(pendingLoginId); - } catch (error) { - lastError = error; - if (attempt === ACCOUNT_TRANSITION_MAX_ATTEMPTS) break; - log.warn( - `Pending login cancel attempt ${attempt}/${ACCOUNT_TRANSITION_MAX_ATTEMPTS} was ambiguous; retrying`, - error, - ); - await new Promise(resolve => setTimeout(resolve, 250 * (2 ** (attempt - 1)))); - } - } - throw lastError; -} - -async function finalizePendingLoginWithRetry(pendingLoginId: string): Promise { - let lastError: unknown = null; - for (let attempt = 1; attempt <= ACCOUNT_TRANSITION_MAX_ATTEMPTS; attempt += 1) { - try { - await remoteConnectAPI.accountFinalizeLogin(pendingLoginId); - return; - } catch (error) { - lastError = error; - if (attempt === ACCOUNT_TRANSITION_MAX_ATTEMPTS) break; - log.warn( - `Pending login finalize attempt ${attempt}/${ACCOUNT_TRANSITION_MAX_ATTEMPTS} was ambiguous; retrying`, - error, - ); - await new Promise(resolve => setTimeout(resolve, 250 * (2 ** (attempt - 1)))); - } - } - throw lastError; -} - -/** Quota / payload-limit failures will not succeed on blind retry. */ -function isNonRetryableSyncError(error: unknown): boolean { - const msg = error instanceof Error ? error.message : String(error ?? ''); - const lower = msg.toLowerCase(); - return ( - lower.includes('http 507') - || lower.includes('insufficient storage') - || lower.includes('quota is full') - || lower.includes('http 413') - || lower.includes('payload too large') - ); -} - -function syncFailureMessage( - t: (key: string, options?: Record) => string, - error: unknown, -): string { - if (isNonRetryableSyncError(error)) { - const msg = error instanceof Error ? error.message : String(error ?? ''); - if ( - msg.toLowerCase().includes('http 413') - || msg.toLowerCase().includes('payload too large') - ) { - return t('accountLogin.syncPayloadTooLarge'); - } - return t('accountLogin.syncQuotaFull'); - } - return t('accountLogin.syncFailed'); -} - -function syncPhaseLabel( - t: (key: string, options?: Record) => string, - phase: AccountSyncPhase, - current: number | null, - total: number | null, -): string { - switch (phase) { - case 'uploading_settings': - return t('accountLogin.syncPhaseUploadingSettings'); - case 'downloading_settings': - return t('accountLogin.syncPhaseDownloadingSettings'); - case 'applying_settings': - return t('accountLogin.syncPhaseApplyingSettings'); - case 'settings_done': - return t('accountLogin.syncPhaseSettingsDone'); - case 'listing_sessions': - return t('accountLogin.syncPhaseListingSessions'); - case 'exporting_sessions': - return t('accountLogin.syncPhaseExportingSessions', { - current: current ?? 0, - total: total ?? 0, - }); - case 'done': - return t('accountLogin.syncDoneShort'); - case 'failed': - return t('accountLogin.syncFailed'); - case 'starting': - default: - return t('accountLogin.syncing'); - } -} - interface AccountPanelProps { /** Close the whole Remote Connect dialog (used when entering peer mode). */ onCloseDialog: () => void; } -type View = 'login' | 'overwrite' | 'devices'; +type View = 'login' | 'devices'; export const AccountPanel: React.FC = ({ onCloseDialog, }) => { const { t, formatRelativeTime } = useI18n('common'); - const { success, info, warning } = useNotification(); - const { workspacePath } = useCurrentWorkspace(); + const { success } = useNotification(); const { peerMode, switchToDevice, switchToLocal } = usePeerDeviceMode(); - const syncStatus = useAccountSyncStore((s) => s.status); - const syncProgress = useAccountSyncStore((s) => s.progress); - const lastSyncError = useAccountSyncStore((s) => s.lastError); - const lastSyncIsFirstLogin = useAccountSyncStore((s) => s.lastSyncIsFirstLogin); - const setSyncing = useAccountSyncStore((s) => s.setSyncing); - const setSyncDone = useAccountSyncStore((s) => s.setDone); - const setSyncFailed = useAccountSyncStore((s) => s.setFailed); - const clearSync = useAccountSyncStore((s) => s.clear); - - const [username, setUsername] = useState(''); - const [password, setPassword] = useState(''); - const [authServer, setAuthServer] = useState(''); + const identity = useAccountIdentity(); + const username = identity.me?.user.login ?? ''; const [error, setError] = useState(null); const [loading, setLoading] = useState(false); - const [showPassword, setShowPassword] = useState(false); const [view, setView] = useState('login'); - const [showRelayDeploy, setShowRelayDeploy] = useState(false); const [devices, setDevices] = useState([]); const [localDeviceId, setLocalDeviceId] = useState(null); /** True after either device presence or a list_devices response is available. */ const [devicesReady, setDevicesReady] = useState(false); const [relayError, setRelayError] = useState(null); - /** Relay URL of the current account session, shown in the devices view. */ - const [accountRelayUrl, setAccountRelayUrl] = useState(''); - const [copiedServerUrl, setCopiedServerUrl] = useState(false); /** Account epoch whose presence events may update the device list. */ const [activeAccountEpoch, setActiveAccountEpoch] = useState(null); const refreshTimer = useRef | null>(null); @@ -253,14 +96,6 @@ export const AccountPanel: React.FC = ({ const deviceListFailureCountRef = useRef(0); /** Coalesce manual and background recovery so they never replace each other's WS. */ const deviceReconnectInFlightRef = useRef(false); - /** Prevent overlapping background syncs from rapid clicks. */ - const syncInFlightRef = useRef(false); - /** Opaque backend owner ID for the memory-only overwrite decision. */ - const pendingLoginIdRef = useRef(null); - /** Track the overwrite view for conditional unmount cleanup. */ - const viewRef = useRef(view); - viewRef.current = view; - const invalidateAccountRequests = useCallback(() => { accountEpochRef.current += 1; refreshRequestRef.current += 1; @@ -289,39 +124,22 @@ export const AccountPanel: React.FC = ({ setLocalDeviceId(null); setDevicesReady(false); setRelayError(null); - setAccountRelayUrl(''); - setCopiedServerUrl(false); refreshInFlightRef.current = null; deviceRoutingReadyRef.current = false; deviceListFailureCountRef.current = 0; if (refreshTimer.current) { clearInterval(refreshTimer.current); refreshTimer.current = null; } }, []); - const handleCopyRelayUrl = useCallback(async () => { - if (!accountRelayUrl) return; - const copied = await copyTextToClipboard(accountRelayUrl); - if (copied) { - setCopiedServerUrl(true); - window.setTimeout(() => setCopiedServerUrl(false), 1500); - } else { - warning(t('accountLogin.copyServerFailed')); - } - }, [accountRelayUrl, t, warning]); - const handleSessionExpired = useCallback(async (_error: unknown, expectedEpoch: number) => { if (!isAccountEpochCurrent(expectedEpoch)) return; invalidateAccountRequests(); - // Invalidate detached retries before the logout request yields control. - syncInFlightRef.current = false; - pendingLoginIdRef.current = null; - clearSync(); // Authenticated backend commands invalidate only the generation/token that // produced their 401. Do not issue a second unconditional logout here: a // late frontend response must never clear a newer login. resetState(); setView('login'); setError(t('accountLogin.sessionExpired')); - }, [clearSync, invalidateAccountRequests, isAccountEpochCurrent, resetState, t]); + }, [invalidateAccountRequests, isAccountEpochCurrent, resetState, t]); const markRelayUnreachable = useCallback(() => { setDevicesReady(false); @@ -532,7 +350,6 @@ export const AccountPanel: React.FC = ({ useEffect(() => { mountedRef.current = true; - ensureAccountSyncProgressListener(); return () => { mountedRef.current = false; accountEpochRef.current += 1; @@ -540,43 +357,11 @@ export const AccountPanel: React.FC = ({ }; }, []); - // Unmounting (dialog close or group switch) during the sync-choice step - // abandons the incomplete login — pair with `account_finalize_login`. - // The dialog tree unmounts this panel directly, so this must be a cleanup, - // not an effect gated on a prop flip. - useEffect(() => { - return () => { - if (viewRef.current === 'overwrite') { - syncInFlightRef.current = false; - clearSync(); - const pendingLoginId = pendingLoginIdRef.current; - if (pendingLoginId) { - void cancelPendingLoginWithRetry(pendingLoginId) - .then(() => { - if (pendingLoginIdRef.current === pendingLoginId) { - pendingLoginIdRef.current = null; - } - }) - .catch((e) => { - log.warn('pending login cancel on overwrite abandon failed', e); - }); - } - } - }; - }, [clearSync]); - useEffect(() => { const epoch = accountEpochRef.current; remoteConnectAPI.getDeviceInfo().then((info) => { if (isAccountEpochCurrent(epoch)) setLocalDeviceId(info.device_id); }).catch((e) => { log.warn('getDeviceInfo failed', e); }); - remoteConnectAPI.accountGetCredentialHint().then((hint: AccountHint | null) => { - if (hint && isAccountEpochCurrent(epoch)) { - setUsername(hint.username); - setAuthServer(hint.relay_url); - setAccountRelayUrl(hint.relay_url); - } - }); remoteConnectAPI.accountStatus().then(async (status) => { if (isAccountEpochCurrent(epoch) && status.logged_in && status.user_id) { setActiveAccountEpoch(epoch); @@ -625,296 +410,38 @@ export const AccountPanel: React.FC = ({ return unlistenPresence; }, [activeAccountEpoch, applyPresenceOnline, isAccountEpochCurrent]); - const validate = useCallback(() => { - if (!username.trim() || !password || !authServer.trim()) { - setError(t('accountLogin.emptyFields')); - return false; - } - if (username.trim().length > 128 || password.length > 1024) { - setError(t('accountLogin.invalidCredentialsLength')); - return false; - } - if (!parseRelayServer(authServer)) { - setError(t('accountLogin.invalidServer')); - return false; - } - setError(null); - return true; - }, [username, password, authServer, t]); - - /** - * Run cloud sync + device connect in the background. Progress is visible - * in the devices view while it continues. - */ - const startBackgroundSync = useCallback((isFirstLogin: boolean) => { - if (syncInFlightRef.current) { - log.warn('Account sync already in flight; skipping duplicate start'); - return; - } - syncInFlightRef.current = true; - ensureAccountSyncProgressListener(); - setSyncing(isFirstLogin); - const operationId = useAccountSyncStore.getState().operationId; - const isCurrentOperation = () => ( - useAccountSyncStore.getState().operationId === operationId - ); - info(t('accountLogin.syncStarted')); - - void (async () => { - try { - let configJson = '{}'; - if (isFirstLogin) { - useAccountSyncStore.getState().applyProgress({ - operation_id: operationId, - phase: 'uploading_settings', - percent: 2, - }); - try { - const exported = await configAPI.exportConfig(); - configJson = JSON.stringify(exported); - } catch (e) { - log.warn('export config failed', e); - } - if (!isCurrentOperation()) return; - } - const wp = workspacePath || '/'; - // AccountClient owns transient Relay retries with one shared deadline. - // Replaying this entire workflow would also repeat deterministic local - // config/filesystem work and multiply the transport retry budget. - const result = await remoteConnectAPI.accountAutoSync( - isFirstLogin, - wp, - configJson, - operationId, - ); - if (!isCurrentOperation()) return; - log.info( - `Auto-sync done: settings=${result.settings_synced} exported=${result.sessions_exported}`, - ); - if (result.settings_synced && !isFirstLogin) { - if (!isCurrentOperation()) return; - try { - await configAPI.reloadConfig(); - if (!isCurrentOperation()) return; - configManager.clearCache(); - success(t('accountLogin.settingsApplied')); - } catch (e) { - log.warn('reloadConfig after sync failed', e); - } - } - if (!isCurrentOperation()) return; - setSyncDone(result); - success(t('accountLogin.syncDone', { - exported: result.sessions_exported, - })); - } catch (e) { - if (!isCurrentOperation()) return; - log.error('Auto-sync failed', e); - setSyncFailed(e instanceof Error ? e.message : String(e)); - warning(syncFailureMessage(t, e)); - } finally { - if (isCurrentOperation()) { - syncInFlightRef.current = false; - } - } - })(); - }, [ - info, - setSyncDone, - setSyncFailed, - setSyncing, - success, - t, - warning, - workspacePath, - ]); - - const handleRetrySync = useCallback(() => { - if (syncStatus !== 'failed' || syncInFlightRef.current) return; - startBackgroundSync(lastSyncIsFirstLogin ?? false); - }, [lastSyncIsFirstLogin, startBackgroundSync, syncStatus]); - - /** Landing path after a completed login: devices view + background sync. */ + /** Show the authenticated device list and connect routing. */ const completeLogin = useCallback(( - relayUrl: string, - isFirstLogin: boolean, accountEpoch: number, ) => { if (!isAccountEpochCurrent(accountEpoch)) return; setActiveAccountEpoch(accountEpoch); - setAccountRelayUrl(relayUrl); setView('devices'); void initializeDevices(); - startBackgroundSync(isFirstLogin); - }, [initializeDevices, isAccountEpochCurrent, startBackgroundSync]); + }, [initializeDevices, isAccountEpochCurrent]); - const performLogin = useCallback(async (server: string, user: string, pass: string) => { + const handleLogin = useCallback(async () => { const epoch = invalidateAccountRequests(); - // Invalidate detached sync retries before the backend begins replacing the - // account. The store operation id fences any completion from the old run. - syncInFlightRef.current = false; - clearSync(); setLoading(true); setError(null); try { - const stalePendingLoginId = pendingLoginIdRef.current; - if (stalePendingLoginId) { - await cancelPendingLoginWithRetry(stalePendingLoginId); - if (pendingLoginIdRef.current === stalePendingLoginId) { - pendingLoginIdRef.current = null; - } - if (!isAccountEpochCurrent(epoch)) return; - } - const result = await remoteConnectAPI.accountLogin(server, user, pass); - if (!isAccountEpochCurrent(epoch)) { - if (result.pending_login_id) { - await cancelPendingLoginWithRetry(result.pending_login_id); - } - return; - } - if (result.has_cloud_settings) { - if (!result.pending_login_id) { - throw new Error(t('accountLogin.sessionExpired')); - } - pendingLoginIdRef.current = result.pending_login_id; - setView('overwrite'); - setLoading(false); - return; - } - success(t('accountLogin.loginSuccess', { user_id: user })); - completeLogin(server, true, epoch); - } catch (e: unknown) { + const me = await accountIdentityService.signIn(); if (!isAccountEpochCurrent(epoch)) return; - setError(e instanceof Error ? e.message : String(e)); - } finally { - // The account session has its own token after this call; retaining the - // password in React state while the device list is open is unnecessary. - if (isAccountEpochCurrent(epoch)) { - setPassword(''); - setLoading(false); - } - } - }, [clearSync, completeLogin, invalidateAccountRequests, isAccountEpochCurrent, success, t]); - - const handleLogin = useCallback(async () => { - if (!validate()) return; - const relayUrl = parseRelayServer(authServer); - if (!relayUrl) return; - const isLoopback = ['localhost', '127.0.0.1', '[::1]', '::1'].includes(relayUrl.hostname); - if (relayUrl.protocol === 'http:' && !isLoopback) { - const confirmed = await confirmWarning( - t('accountLogin.insecureServerTitle'), - t('accountLogin.insecureServerConfirm'), - { - confirmText: t('accountLogin.continueInsecure'), - cancelText: t('accountLogin.cancel'), - }, - ); - if (!confirmed) return; - } - await performLogin(authServer.trim(), username.trim(), password); - }, [validate, authServer, username, password, performLogin, t]); - - /** - * Deploy wizard finished: relay deployed and the first account registered. - * Fill the form and sign in against the new relay right away. - */ - const handleRelayRegistered = useCallback((result: RelayDeployResult) => { - setShowRelayDeploy(false); - setAuthServer(result.relayUrl); - setUsername(result.username); - setPassword(result.password); - void performLogin(result.relayUrl, result.username, result.password); - }, [performLogin]); - - const finalizeAndSync = useCallback(async (isFirstLogin: boolean) => { - const epoch = accountEpochRef.current; - const pendingLoginId = pendingLoginIdRef.current; - if (!pendingLoginId) { - setError(t('accountLogin.sessionExpired')); - return; - } - setLoading(true); - setError(null); - try { - // The backend records the exact pending owner after commit, so retrying - // the same opaque owner remains fenced from a replacement account. - await finalizePendingLoginWithRetry(pendingLoginId); + await remoteConnectAPI.accountLogin(); if (!isAccountEpochCurrent(epoch)) return; - if (pendingLoginIdRef.current === pendingLoginId) { - pendingLoginIdRef.current = null; - } - success(t('accountLogin.loginSuccess', { user_id: username })); - completeLogin(authServer.trim(), isFirstLogin, epoch); + success(t('accountLogin.loginSuccess', { user_id: me.user.login })); + completeLogin(epoch); } catch (e: unknown) { - if (!isAccountEpochCurrent(epoch)) return; - if (isAccountAuthFailure(e)) { - await handleSessionExpired(e, epoch); - return; - } - setError(e instanceof Error ? e.message : String(e)); - // Stop any detached work before accountLogout can yield. - syncInFlightRef.current = false; - clearSync(); - const cleanupEpoch = invalidateAccountRequests(); - try { - await cancelPendingLoginWithRetry(pendingLoginId); - if (pendingLoginIdRef.current === pendingLoginId) { - pendingLoginIdRef.current = null; - } - } catch (cancelErr) { - log.warn('pending login cancel after finalize failure failed', cancelErr); - if (isAccountEpochCurrent(cleanupEpoch)) setLoading(false); - return; - } - if (!isAccountEpochCurrent(cleanupEpoch)) return; - resetState(); - setView('login'); - setLoading(false); + if (isAccountEpochCurrent(epoch)) setError(e instanceof Error ? e.message : String(e)); } finally { if (isAccountEpochCurrent(epoch)) setLoading(false); } - }, [authServer, clearSync, completeLogin, handleSessionExpired, invalidateAccountRequests, isAccountEpochCurrent, resetState, success, t, username]); - - const handleConfirmOverwrite = useCallback(() => { - void finalizeAndSync(false); - }, [finalizeAndSync]); - - const handleUseLocalOverwrite = useCallback(() => { - void finalizeAndSync(true); - }, [finalizeAndSync]); - - const handleCancelOverwrite = useCallback(async () => { - const epoch = invalidateAccountRequests(); - syncInFlightRef.current = false; - clearSync(); - const pendingLoginId = pendingLoginIdRef.current; - if (pendingLoginId) { - try { - await cancelPendingLoginWithRetry(pendingLoginId); - if (pendingLoginIdRef.current === pendingLoginId) { - pendingLoginIdRef.current = null; - } - } catch (e) { - log.warn('pending login cancel failed', e); - if (isAccountEpochCurrent(epoch)) { - setError(e instanceof Error ? e.message : String(e)); - } - return; - } - } - if (!isAccountEpochCurrent(epoch)) return; - resetState(); - setView('login'); - }, [clearSync, invalidateAccountRequests, isAccountEpochCurrent, resetState]); + }, [completeLogin, invalidateAccountRequests, isAccountEpochCurrent, success, t]); const handleLogout = useCallback(async () => { const epoch = invalidateAccountRequests(); setLoading(true); - syncInFlightRef.current = false; - clearSync(); - pendingLoginIdRef.current = null; try { - await remoteConnectAPI.accountLogout(); + await accountIdentityService.logout(); if (!isAccountEpochCurrent(epoch)) return; resetState(); setView('login'); @@ -927,7 +454,7 @@ export const AccountPanel: React.FC = ({ } finally { if (isAccountEpochCurrent(epoch)) setLoading(false); } - }, [clearSync, invalidateAccountRequests, isAccountEpochCurrent, resetState]); + }, [invalidateAccountRequests, isAccountEpochCurrent, resetState]); const handleDeleteDevice = useCallback(async (deviceId: string, deviceName: string) => { const isLocal = localDeviceId === deviceId; @@ -947,17 +474,9 @@ export const AccountPanel: React.FC = ({ }, ); if (!confirmed) return; - const previousSyncStatus = syncStatus; - const previousSyncDirection = lastSyncIsFirstLogin; setLoading(true); setError(null); const epoch = isLocal ? invalidateAccountRequests() : accountEpochRef.current; - if (isLocal) { - // A current-device removal is also a logout. Invalidate retries and - // late progress before the backend request yields. - syncInFlightRef.current = false; - clearSync(); - } try { await remoteConnectAPI.accountDeleteDevice(deviceId); if (!isAccountEpochCurrent(epoch)) return; @@ -977,33 +496,18 @@ export const AccountPanel: React.FC = ({ const message = e instanceof Error ? e.message : String(e); if (isLocal) setActiveAccountEpoch(epoch); setError(message); - if ( - isLocal - && previousSyncDirection !== null - && (previousSyncStatus === 'syncing' || previousSyncStatus === 'failed') - ) { - // Preserve the direction so Retry remains meaningful after a failed - // current-device removal invalidated the previous generation. - setSyncing(previousSyncDirection); - setSyncFailed(message); - } } } finally { if (isAccountEpochCurrent(epoch)) setLoading(false); } }, [ - clearSync, handleSessionExpired, invalidateAccountRequests, isAccountEpochCurrent, - lastSyncIsFirstLogin, localDeviceId, refreshDevices, resetState, - setSyncFailed, - setSyncing, success, - syncStatus, t, ]); @@ -1012,11 +516,6 @@ export const AccountPanel: React.FC = ({ // Picking this machine is a normal surface switch back, not a no-op: the // window may currently be rendering a peer. const isLocalDevice = Boolean(localDeviceId) && device.device_id === localDeviceId; - if (!isLocalDevice) { - if (syncStatus === 'failed') { - warning(t('accountLogin.syncFailedPeerHint')); - } - } setLoading(true); setError(null); try { @@ -1046,9 +545,7 @@ export const AccountPanel: React.FC = ({ success, switchToDevice, switchToLocal, - syncStatus, t, - warning, ]); return ( @@ -1069,220 +566,39 @@ export const AccountPanel: React.FC = ({ {view === 'login' && ( -

{t('accountLogin.loginValueProp')}

-
- - } - onValueChange={setUsername} - size="sm" - type="text" - value={username} - /> - - - } - onValueChange={setPassword} - size="sm" - trailing={ - : } - onClick={() => setShowPassword(s => !s)} - size="sm" - variant="quiet" - /> - } - type={showPassword ? 'text' : 'password'} - value={password} - /> - - - } - onValueChange={setAuthServer} - placeholder={t('accountLogin.authServerPlaceholder')} - size="sm" - type="url" - value={authServer} - /> - +
+ +

{t('accountLogin.loginValueProp')}

{t('accountLogin.securityNote')}

-
- {t('relayDeploy.entryHint')} -
-
- -
)} - {view === 'overwrite' && ( + {view === 'devices' && ( -
- -

{t('accountLogin.cloudOverwriteWarning')}

-
-
- - +
+
-
-
- - )} - - {view === 'devices' && ( -
- {username.trim() && ( -
- - - {t('accountLogin.signedInAccount')} - {username.trim()} - -
- )} - {accountRelayUrl && ( -
-
- )} - {syncStatus !== 'idle' && !relayError && ( -
-
- {syncStatus === 'syncing' && } - {syncStatus === 'done' && } - {syncStatus === 'failed' && } - - {syncStatus === 'syncing' && syncPhaseLabel( - t, - syncProgress.phase, - syncProgress.current, - syncProgress.total, - )} - {syncStatus === 'done' && t('accountLogin.syncDoneShort')} - {syncStatus === 'failed' && syncFailureMessage(t, lastSyncError)} - - {syncStatus === 'failed' && ( - - )} - {syncStatus === 'syncing' && ( - - {t('accountLogin.syncProgressPercent', { percent: syncProgress.percent })} - - )} -
- {syncStatus === 'syncing' && ( -
-
-
- )} -
- )} {relayError && (
= ({ {isLocal && {t('accountLogin.thisDevice')}} - - {d.device_id.slice(0, 8)} - - {' · '} {d.online ? t('accountLogin.online') : d.last_seen_at @@ -1359,7 +671,6 @@ export const AccountPanel: React.FC = ({ icon={} onClick={(e) => { e.stopPropagation(); handleDeleteDevice(d.device_id, displayName); }} size="sm" - tone="danger" title={removeLabel} variant="quiet" /> @@ -1367,45 +678,12 @@ export const AccountPanel: React.FC = ({ ); })}
-
- {relayError && ( - - )} - {!relayError && ( - - )} - -
)}
- {showRelayDeploy && ( - setShowRelayDeploy(false)} - onRegistered={handleRelayRegistered} - /> - )} + ); }; diff --git a/src/web-ui/src/app/components/RemoteConnectDialog/ConnectionHealth.generation.test.tsx b/src/web-ui/src/app/components/RemoteConnectDialog/ConnectionHealth.generation.test.tsx index f7037af16a..7c863dabf4 100644 --- a/src/web-ui/src/app/components/RemoteConnectDialog/ConnectionHealth.generation.test.tsx +++ b/src/web-ui/src/app/components/RemoteConnectDialog/ConnectionHealth.generation.test.tsx @@ -5,7 +5,7 @@ import { act } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { useConnectionHealth } from '../../../../../mobile-web/src/hooks/useConnectionHealth'; -import { DelegatedIdentityChangedError } from '../../../../../mobile-web/src/services/RelayHttpClient'; +import { AccountIdentityChangedError } from '../../../../../mobile-web/src/services/RelayHttpClient'; import { RemoteControlTargetChangedError, type RemoteSessionManager, @@ -77,10 +77,10 @@ describe('mobile connection health target generations', () => { expect(useMobileStore.getState().connectionHealth).toBe('connected'); }); - it('retries a delegated-identity transition without publishing unreachable', async () => { + it('retries a account identity transition without publishing unreachable', async () => { vi.useFakeTimers(); const ping = vi.fn() - .mockRejectedValueOnce(new DelegatedIdentityChangedError()) + .mockRejectedValueOnce(new AccountIdentityChangedError()) .mockResolvedValueOnce(undefined); const manager = { ping, diff --git a/src/web-ui/src/app/components/RemoteConnectDialog/DevicesPage.generation.contract.test.ts b/src/web-ui/src/app/components/RemoteConnectDialog/DevicesPage.generation.contract.test.ts index d6e00084a5..5172f1502f 100644 --- a/src/web-ui/src/app/components/RemoteConnectDialog/DevicesPage.generation.contract.test.ts +++ b/src/web-ui/src/app/components/RemoteConnectDialog/DevicesPage.generation.contract.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it } from 'vitest'; -import { reconcileDelegatedAccountOwner } from '../../../../../mobile-web/src/services/delegatedAccountOwner'; +import { reconcileAccountOwner } from '../../../../../mobile-web/src/services/accountOwner'; import { useMobileStore } from '../../../../../mobile-web/src/services/store'; const sessionA = { @@ -11,65 +11,54 @@ const sessionA = { message_count: 0, }; -describe('mobile delegated account UI ownership', () => { +describe('mobile account UI ownership', () => { beforeEach(() => { useMobileStore.getState().resetConnectionState(); }); - it('adopts a late initial delegation without discarding matching pairing state', () => { + it('adopts a initial identity without discarding matching account state', () => { const store = useMobileStore.getState(); store.setAuthenticatedUserId('user-a'); store.setSessions([sessionA]); - expect(reconcileDelegatedAccountOwner({ + expect(reconcileAccountOwner({ kind: 'initial', epoch: 1, userId: 'user-a', - homeDeviceId: 'home-a', })).toBe(false); expect(useMobileStore.getState().sessions).toHaveLength(1); - expect(useMobileStore.getState().controlTarget).toEqual({ - deviceId: 'home-a', - deviceName: null, - isHome: true, - }); + expect(useMobileStore.getState().controlTarget).toBeNull(); }); - it('clears A-owned state when a late initial delegation proves account B', () => { + it('clears A-owned state when a initial identity proves account B', () => { const store = useMobileStore.getState(); store.setAuthenticatedUserId('user-a'); store.setSessions([sessionA]); store.setControlTarget({ deviceId: 'home-a', deviceName: 'A', isHome: true }); - expect(reconcileDelegatedAccountOwner({ + expect(reconcileAccountOwner({ kind: 'initial', epoch: 1, userId: 'user-b', - homeDeviceId: 'home-b', })).toBe(true); const next = useMobileStore.getState(); expect(next.sessions).toEqual([]); expect(next.authenticatedUserId).toBe('user-b'); - expect(next.controlTarget).toEqual({ - deviceId: 'home-b', - deviceName: null, - isHome: true, - }); + expect(next.controlTarget).toBeNull(); }); - it('clears known user and target when delegation becomes unavailable', () => { + it('clears known user and target when identity becomes unavailable', () => { const store = useMobileStore.getState(); store.setAuthenticatedUserId('user-a'); store.setSessions([sessionA]); store.setControlTarget({ deviceId: 'peer-a', deviceName: 'Peer A', isHome: false }); - expect(reconcileDelegatedAccountOwner({ + expect(reconcileAccountOwner({ kind: 'unavailable', epoch: 2, userId: null, - homeDeviceId: null, })).toBe(true); const next = useMobileStore.getState(); diff --git a/src/web-ui/src/app/components/RemoteConnectDialog/MobileTargetOwner.contract.test.ts b/src/web-ui/src/app/components/RemoteConnectDialog/MobileTargetOwner.contract.test.ts index c46e2942f9..dab45b2200 100644 --- a/src/web-ui/src/app/components/RemoteConnectDialog/MobileTargetOwner.contract.test.ts +++ b/src/web-ui/src/app/components/RemoteConnectDialog/MobileTargetOwner.contract.test.ts @@ -96,35 +96,16 @@ describe('mobile control-target UI ownership contracts', () => { }); it('fences a device probe and pairing name lookup to their original owners', () => { - expect(devicesSource).toContain('client.delegatedAccountEpoch === accountEpoch'); + expect(devicesSource).toContain('client.accountEpoch === accountEpoch'); expect(devicesSource).toContain('client.controlTargetEpoch === expectedTargetEpoch'); expect(devicesSource).toContain('expectedTargetEpoch = client.controlTargetEpoch;'); - expect(pairingSource).toContain('const target = client.getControlTargetSnapshot();'); - expect(pairingSource).toContain('!client.isControlTargetCurrent(target)'); - expect(pairingSource).toContain('client.pairedDeviceId !== homeDeviceId'); - }); - - it('bootstraps pairing auto-reconnect once without resetting to a stuck spinner', () => { - expect(pairingSource).toContain('attemptPairRef.current'); - expect(pairingSource).toContain('pairAttemptGenerationRef'); - expect(pairingSource).toContain('mount-once bootstrap'); - expect(pairingSource).toContain('eslint-disable-next-line react-hooks/exhaustive-deps -- mount-once bootstrap'); - // Regression: depending on attemptPair and unconditionally setting pairing - // after a failed reconnect left the page spinning with no retry form. - expect(pairingSource).not.toContain('autoReconnectAttemptedRef'); - expect(pairingSource).not.toMatch( - /setConnectionStatus\(shouldAutoReconnect \? 'pairing' : 'idle'\)/, - ); + expect(pairingSource).toContain('generation.current !== attempt'); + expect(pairingSource).toContain('pending.current?.abort()'); }); it('reuses only a matching same-tab mobile account session', () => { - expect(pairingSource).toContain( - 'const hasScannedAccountTarget = !!pairingTarget.targetDeviceId;', - ); - expect(pairingSource).toContain( - 'requiresAccountAuth && hasScannedAccountTarget', - ); + expect(pairingSource).toContain('loadMatchingCloudAccountSession(relayUrl'); const stored = { relayUrl: 'https://relay.example.com', @@ -187,7 +168,7 @@ describe('mobile control-target UI ownership contracts', () => { masterKey: wire.master_key, controllerDeviceId: wire.controller_device_id, }); - expect(deserializeCloudAccountSession(legacy)?.session.userId).toBe('account-a'); + expect(deserializeCloudAccountSession(legacy)).toBeNull(); // Old shared-account keys cannot authenticate a device. expect(deserializeCloudAccountSession(JSON.stringify({ ...wire, version: 99 }))).toBeNull(); }); }); diff --git a/src/web-ui/src/app/components/RemoteConnectDialog/RelayHttpClient.generation.test.ts b/src/web-ui/src/app/components/RemoteConnectDialog/RelayHttpClient.generation.test.ts index 66be586090..6f48e5772f 100644 --- a/src/web-ui/src/app/components/RemoteConnectDialog/RelayHttpClient.generation.test.ts +++ b/src/web-ui/src/app/components/RemoteConnectDialog/RelayHttpClient.generation.test.ts @@ -1,413 +1,74 @@ -import { describe, expect, it, vi } from 'vitest'; -import { - DelegatedAccountChangedError, - RelayHttpClient, -} from '../../../../../mobile-web/src/services/RelayHttpClient'; -import { encrypt, fromB64 } from '../../../../../mobile-web/src/services/E2EEncryption'; - -const masterKey = btoa(String.fromCharCode(...new Uint8Array(32).fill(7))); -const replacementMasterKey = btoa(String.fromCharCode(...new Uint8Array(32).fill(8))); - -function deferred() { - let resolve!: (value: T) => void; - let reject!: (reason?: unknown) => void; - const promise = new Promise((res, rej) => { - resolve = res; - reject = rej; - }); - return { promise, resolve, reject }; -} - -describe('RelayHttpClient delegated identity generations', () => { - it.each(['Unauthorized tool provider', 'Upstream returned HTTP 401'])( - 'does not replay a mutation after an authenticated remote error: %s', - async (message) => { - const client = new RelayHttpClient('https://relay.example.com', 'room'); - const delegate = vi.spyOn(client, 'sendCommand').mockResolvedValue({ - resp: 'delegate_identity', token: 'token-a', master_key: masterKey, - user_id: 'user-a', device_id: 'home-a', - }); - await client.requestDelegatedIdentity(); - const encrypted = await encrypt(fromB64(masterKey), JSON.stringify({ resp: 'error', message })); - const fetchMock = vi.fn().mockImplementation(async () => new Response(JSON.stringify({ - encrypted_data: encrypted.data, nonce: encrypted.nonce, - }), { status: 200 })); - (client as any).fetchWithTimeout = fetchMock; - - await expect(client.sendDeviceRpc('peer-a', { cmd: 'cancel_task', session_id: 'session-a' })) - .rejects.toThrow(message); - expect(fetchMock).toHaveBeenCalledTimes(1); - expect(delegate).toHaveBeenCalledTimes(1); - }, - ); - - it('advances the target epoch for an initial delegated account owner', async () => { - const client = new RelayHttpClient('https://relay.example.com', 'room'); - const changes: number[] = []; - client.onControlTargetChange((snapshot) => changes.push(snapshot.epoch)); - vi.spyOn(client, 'sendCommand').mockResolvedValueOnce({ - resp: 'delegate_identity', token: 'token-a', master_key: masterKey, - user_id: 'user-a', device_id: 'home-a', - }); +import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'; +import { AccountIdentityChangedError, RelayHttpClient } from '../../../../../mobile-web/src/services/RelayHttpClient'; +import { deriveDeviceMessageKey, encrypt, toB64, generateKeyPair } from '../../../../../mobile-web/src/services/E2EEncryption'; + +const identity = (userId = 'user-a') => ({ token: `token-${userId}`, userId, deviceId: 'browser', masterKey: new Uint8Array(32).fill(userId === 'user-a' ? 7 : 8) }); +const peerPublicKey = (await generateKeyPair()).publicKey; +function deferred() { let resolve!: (value: T) => void; const promise = new Promise(done => { resolve = done; }); return { promise, resolve }; } +beforeEach(() => { vi.stubGlobal('window', globalThis); }); +afterEach(() => { vi.unstubAllGlobals(); vi.useRealTimers(); }); + +describe.each(['https://remote.openbitfun.com/v/1.0.0', 'http://192.168.1.9:9700'])('account device routing on %s', (relayUrl) => { + it('rejects an unauthenticated constructor and sends nothing after logout', async () => { + expect(() => new RelayHttpClient(relayUrl, { ...identity(), token: '' })).toThrow(); + const client = new RelayHttpClient(relayUrl, identity()); + const fetch = vi.fn(); vi.stubGlobal('fetch', fetch); + client.resetConnectionIdentity(); + await expect(client.listDevices()).rejects.toThrow('Sign in'); + expect(fetch).not.toHaveBeenCalled(); + }); + it('invalidates target state and notifies observers on account replacement', () => { + const client = new RelayHttpClient(relayUrl, identity()); + client.setTargetDeviceId('desktop'); const epoch = client.controlTargetEpoch; - - await expect(client.requestDelegatedIdentity()).resolves.toBe(true); - + const listener = vi.fn(); client.onAccountOwnerChange(listener, { emitCurrent: true }); + client.setAccountIdentity(identity('user-b')); + expect(client.targetDeviceId).toBeNull(); expect(client.controlTargetEpoch).toBeGreaterThan(epoch); - expect(client.getControlTargetSnapshot()).toMatchObject({ - deviceId: 'home-a', - homeDeviceId: 'home-a', - epoch: client.controlTargetEpoch, - }); - expect(changes).toEqual([client.controlTargetEpoch]); - }); - - it('forces a target epoch advance when the account is replaced on the same home', async () => { - const client = new RelayHttpClient('https://relay.example.com', 'room'); - vi.spyOn(client, 'sendCommand') - .mockResolvedValueOnce({ - resp: 'delegate_identity', token: 'token-a', master_key: masterKey, - user_id: 'user-a', device_id: 'shared-home', - }) - .mockResolvedValueOnce({ - resp: 'delegate_identity', token: 'token-b', master_key: replacementMasterKey, - user_id: 'user-b', device_id: 'shared-home', - }); - await client.requestDelegatedIdentity(); - const firstOwnerEpoch = client.controlTargetEpoch; - - await client.requestDelegatedIdentity({ force: true }); - - expect(client.pairedDeviceId).toBe('shared-home'); - expect(client.controlTargetEpoch).toBeGreaterThan(firstOwnerEpoch); - }); - - it('does not manufacture a target change for an initial unavailable identity', async () => { - const client = new RelayHttpClient('https://relay.example.com', 'room'); - const changes: number[] = []; - client.onControlTargetChange((snapshot) => changes.push(snapshot.epoch)); - vi.spyOn(client, 'sendCommand').mockResolvedValueOnce({ - resp: 'error', message: 'Not logged in', - }); - const epoch = client.controlTargetEpoch; - - await expect(client.requestDelegatedIdentity()).resolves.toBe(false); - - expect(client.controlTargetEpoch).toBe(epoch); - expect(client.pairedDeviceId).toBeNull(); - expect(client.homeDeviceId).toBeNull(); - expect(changes).toEqual([]); - }); - - it('commits only the latest concurrent delegation response', async () => { - const client = new RelayHttpClient('https://relay.example.com///', 'room'); - const first = deferred(); - const second = deferred(); - vi.spyOn(client, 'sendCommand') - .mockImplementationOnce(() => first.promise) - .mockImplementationOnce(() => second.promise); - - const firstRequest = client.requestDelegatedIdentity({ force: true }); - const secondRequest = client.requestDelegatedIdentity({ force: true }); - second.resolve({ - resp: 'delegate_identity', token: 'token-b', master_key: masterKey, - user_id: 'user-b', device_id: 'home-b', - }); - await expect(secondRequest).resolves.toBe(true); - first.resolve({ - resp: 'delegate_identity', token: 'token-a', master_key: masterKey, - user_id: 'user-a', device_id: 'home-a', - }); - await expect(firstRequest).resolves.toBe(false); - - expect(client.homeDeviceId).toBe('home-b'); - expect(client.pairedDeviceId).toBe('home-b'); - }); - - it('keeps the last committed identity available while a forced refresh is pending', async () => { - const client = new RelayHttpClient('https://relay.example.com', 'room'); - const refresh = deferred(); - vi.spyOn(client, 'sendCommand') - .mockResolvedValueOnce({ - resp: 'delegate_identity', token: 'token-a', master_key: masterKey, - user_id: 'user-a', device_id: 'home-a', - }) - .mockImplementationOnce(() => refresh.promise); - await client.requestDelegatedIdentity({ force: true }); - - const pendingRefresh = client.requestDelegatedIdentity({ force: true }); - expect(client.hasDelegatedIdentity).toBe(true); - expect(client.delegatedUserId).toBe('user-a'); - const fetchMock = vi.fn().mockResolvedValueOnce(new Response( - JSON.stringify([{ device_id: 'old-a', device_name: 'Old A', online: true }]), - { status: 200, headers: { 'Content-Type': 'application/json' } }, - )); - (client as any).fetchWithTimeout = fetchMock; - await expect(client.listDevices()).rejects.toThrow('Delegated identity changed'); - expect(fetchMock).not.toHaveBeenCalled(); - - refresh.resolve({ - resp: 'delegate_identity', token: 'token-a2', master_key: masterKey, - user_id: 'user-a', device_id: 'home-a', - }); - await expect(pendingRefresh).resolves.toBe(true); - }); - - it('restores the last confirmed identity after a forced refresh transport failure', async () => { - const client = new RelayHttpClient('https://relay.example.com', 'room'); - vi.spyOn(client, 'sendCommand') - .mockResolvedValueOnce({ - resp: 'delegate_identity', token: 'token-a', master_key: masterKey, - user_id: 'user-a', device_id: 'home-a', - }) - .mockRejectedValueOnce(new Error('Desktop temporarily unavailable')); - await client.requestDelegatedIdentity({ force: true }); - - await expect(client.requestDelegatedIdentity({ force: true })) - .rejects.toThrow('Desktop temporarily unavailable'); - (client as any).fetchWithTimeout = vi.fn().mockResolvedValueOnce(new Response( - JSON.stringify([{ device_id: 'home-a', device_name: 'Home A', online: true }]), - { status: 200, headers: { 'Content-Type': 'application/json' } }, - )); - - await expect(client.listDevices()).resolves.toHaveLength(1); - }); - - it('does not let an old 401 clear a newly delegated account', async () => { - const client = new RelayHttpClient('https://relay.example.com', 'room'); - vi.spyOn(client, 'sendCommand').mockResolvedValueOnce({ - resp: 'delegate_identity', token: 'token-a', master_key: masterKey, - user_id: 'user-a', device_id: 'home-a', - }); - await client.requestDelegatedIdentity({ force: true }); - const oldAccountEpoch = client.delegatedAccountEpoch; - - const oldList = deferred(); - (client as any).fetchWithTimeout = vi.fn(() => oldList.promise); - const staleRequest = client.listDevices(); - - vi.spyOn(client, 'sendCommand').mockResolvedValueOnce({ - resp: 'delegate_identity', token: 'token-b', master_key: masterKey, - user_id: 'user-b', device_id: 'home-b', - }); - await client.requestDelegatedIdentity({ force: true }); - - const unauthorized = new Error('List devices failed: HTTP 401') as Error & { status: number }; - unauthorized.status = 401; - oldList.reject(unauthorized); - await expect(staleRequest).rejects.toBeInstanceOf(DelegatedAccountChangedError); - expect(client.hasDelegatedIdentity).toBe(true); - expect(client.homeDeviceId).toBe('home-b'); - expect(client.pairedDeviceId).toBe('home-b'); - expect(client.delegatedAccountEpoch).toBeGreaterThan(oldAccountEpoch); - expect(client.delegatedUserId).toBe('user-b'); - }); - - it('returns a successful device list after refreshing an unauthorized identity', async () => { - const client = new RelayHttpClient('https://relay.example.com', 'room'); - vi.spyOn(client, 'sendCommand') - .mockResolvedValueOnce({ - resp: 'delegate_identity', token: 'expired-token', master_key: masterKey, - user_id: 'user-a', device_id: 'home-a', - }) - .mockResolvedValueOnce({ - resp: 'delegate_identity', token: 'fresh-token', master_key: masterKey, - user_id: 'user-a', device_id: 'home-a', - }); - await client.requestDelegatedIdentity({ force: true }); - const expiredGeneration = client.delegatedIdentityGeneration; - - (client as any).fetchWithTimeout = vi.fn() - .mockResolvedValueOnce(new Response(null, { status: 401 })) - .mockResolvedValueOnce(new Response(JSON.stringify([ - { device_id: 'peer-a', device_name: 'Peer A', online: true }, - ]), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - })); - - await expect(client.listDevices()).resolves.toEqual([ - { device_id: 'peer-a', device_name: 'Peer A', online: true }, - ]); - expect(client.delegatedIdentityGeneration).toBeGreaterThan(expiredGeneration); - }); - - it('accepts a 401 retry while advancing the account epoch for a replacement account', async () => { - const client = new RelayHttpClient('https://relay.example.com', 'room'); - vi.spyOn(client, 'sendCommand') - .mockResolvedValueOnce({ - resp: 'delegate_identity', token: 'expired-token', master_key: masterKey, - user_id: 'user-a', device_id: 'shared-home', - }) - .mockResolvedValueOnce({ - resp: 'delegate_identity', token: 'replacement-token', master_key: replacementMasterKey, - user_id: 'user-b', device_id: 'shared-home', - }); - await client.requestDelegatedIdentity({ force: true }); - const accountEpoch = client.delegatedAccountEpoch; - client.setPairedDeviceId('peer-from-user-a'); - - (client as any).fetchWithTimeout = vi.fn() - .mockResolvedValueOnce(new Response(null, { status: 401 })) - .mockResolvedValueOnce(new Response(JSON.stringify([ - { device_id: 'peer-b', device_name: 'Peer B', online: true }, - ]), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - })); - - await expect(client.listDevices()).resolves.toEqual([ - { device_id: 'peer-b', device_name: 'Peer B', online: true }, - ]); - expect(client.delegatedAccountEpoch).toBeGreaterThan(accountEpoch); - expect(client.pairedDeviceId).toBe('shared-home'); - expect(client.delegatedUserId).toBe('user-b'); - }); - - it('advances the account epoch when the user changes on the same home device', async () => { - const client = new RelayHttpClient('https://relay.example.com', 'room'); - vi.spyOn(client, 'sendCommand') - .mockResolvedValueOnce({ - resp: 'delegate_identity', token: 'token-a', master_key: masterKey, - user_id: 'user-a', device_id: 'shared-home', - }) - .mockResolvedValueOnce({ - resp: 'delegate_identity', token: 'token-b', master_key: masterKey, - user_id: 'user-b', device_id: 'shared-home', - }); - - await client.requestDelegatedIdentity({ force: true }); - const accountEpoch = client.delegatedAccountEpoch; - client.setPairedDeviceId('peer-from-user-a'); - await client.requestDelegatedIdentity({ force: true }); - - expect(client.delegatedAccountEpoch).toBeGreaterThan(accountEpoch); - expect(client.pairedDeviceId).toBe('shared-home'); - expect(client.delegatedUserId).toBe('user-b'); - }); - - it('keeps the account epoch stable across token refresh for the same account', async () => { - const client = new RelayHttpClient('https://relay.example.com', 'room'); - vi.spyOn(client, 'sendCommand') - .mockResolvedValueOnce({ - resp: 'delegate_identity', token: 'token-a', master_key: masterKey, - user_id: 'user-a', device_id: 'home-a', - }) - .mockResolvedValueOnce({ - resp: 'delegate_identity', token: 'token-a2', master_key: masterKey, - user_id: 'user-a', device_id: 'home-a', - }); - - await client.requestDelegatedIdentity({ force: true }); - const accountEpoch = client.delegatedAccountEpoch; - const targetEpoch = client.controlTargetEpoch; - await client.requestDelegatedIdentity({ force: true }); - - expect(client.delegatedAccountEpoch).toBe(accountEpoch); - expect(client.controlTargetEpoch).toBe(targetEpoch); - }); - - it('does not replay an A-owned device RPC after a 401 delegates account B', async () => { - const client = new RelayHttpClient('https://relay.example.com', 'room'); - vi.spyOn(client, 'sendCommand') - .mockResolvedValueOnce({ - resp: 'delegate_identity', token: 'expired-token', master_key: masterKey, - user_id: 'user-a', device_id: 'shared-home', - }) - .mockResolvedValueOnce({ - resp: 'delegate_identity', token: 'replacement-token', master_key: replacementMasterKey, - user_id: 'user-b', device_id: 'shared-home', - }); - await client.requestDelegatedIdentity({ force: true }); - client.setPairedDeviceId('peer-owned-by-a'); - const changes: string[] = []; - client.onDelegatedAccountOwnerChange((change) => changes.push(change.kind)); - const fetchMock = vi.fn().mockResolvedValueOnce(new Response(null, { status: 401 })); - (client as any).fetchWithTimeout = fetchMock; - - await expect(client.sendDeviceRpc('peer-owned-by-a', { cmd: 'get_sessions' })) - .rejects.toBeInstanceOf(DelegatedAccountChangedError); - expect(fetchMock).toHaveBeenCalledTimes(1); - expect(changes).toEqual(['replacement']); - expect(client.delegatedUserId).toBe('user-b'); - expect(client.pairedDeviceId).toBe('shared-home'); - }); - - it('notifies a listener when a soft-timed-out initial delegation commits later', async () => { - const client = new RelayHttpClient('https://relay.example.com', 'room'); - const delegation = deferred(); - vi.spyOn(client, 'sendCommand').mockImplementationOnce(() => delegation.promise); - const pending = client.requestDelegatedIdentity(); - const changes: Array<{ kind: string; userId: string | null }> = []; - client.onDelegatedAccountOwnerChange((change) => changes.push({ - kind: change.kind, - userId: change.userId, - })); - - delegation.resolve({ - resp: 'delegate_identity', token: 'late-token', master_key: masterKey, - user_id: 'late-user', device_id: 'late-home', - }); - - await expect(pending).resolves.toBe(true); - expect(changes).toEqual([{ kind: 'initial', userId: 'late-user' }]); - }); - - it('can replay an identity committed before the app owner listener attaches', async () => { - const client = new RelayHttpClient('https://relay.example.com', 'room'); - vi.spyOn(client, 'sendCommand').mockResolvedValueOnce({ - resp: 'delegate_identity', token: 'token-b', master_key: replacementMasterKey, - user_id: 'user-b', device_id: 'home-b', - }); - await client.requestDelegatedIdentity(); - - const changes: Array<{ kind: string; userId: string | null }> = []; - client.onDelegatedAccountOwnerChange((change) => changes.push({ - kind: change.kind, - userId: change.userId, - }), { emitCurrent: true }); - - expect(changes).toEqual([{ kind: 'initial', userId: 'user-b' }]); - }); - - it('publishes unavailable only after a confirmed no-identity response', async () => { - const client = new RelayHttpClient('https://relay.example.com', 'room'); - vi.spyOn(client, 'sendCommand') - .mockResolvedValueOnce({ - resp: 'delegate_identity', token: 'token-a', master_key: masterKey, - user_id: 'user-a', device_id: 'home-a', - }) - .mockResolvedValueOnce({ resp: 'error', message: 'Not logged in' }); - await client.requestDelegatedIdentity({ force: true }); - const changes: string[] = []; - client.onDelegatedAccountOwnerChange((change) => changes.push(change.kind)); - - await expect(client.requestDelegatedIdentity({ force: true })).resolves.toBe(false); - expect(changes).toEqual(['unavailable']); - expect(client.hasDelegatedIdentity).toBe(false); - expect(client.homeDeviceId).toBeNull(); - expect(client.pairedDeviceId).toBeNull(); - }); - - it('uses the master key when legacy delegated responses omit user_id', async () => { - const client = new RelayHttpClient('https://relay.example.com', 'room'); - vi.spyOn(client, 'sendCommand') - .mockResolvedValueOnce({ - resp: 'delegate_identity', token: 'token-a', master_key: masterKey, - device_id: 'shared-home', - }) - .mockResolvedValueOnce({ - resp: 'delegate_identity', token: 'token-b', master_key: replacementMasterKey, - device_id: 'shared-home', - }); - - await client.requestDelegatedIdentity({ force: true }); - const accountEpoch = client.delegatedAccountEpoch; - client.setPairedDeviceId('legacy-peer-a'); - await client.requestDelegatedIdentity({ force: true }); - - expect(client.delegatedAccountEpoch).toBeGreaterThan(accountEpoch); - expect(client.pairedDeviceId).toBe('shared-home'); - expect(client.delegatedUserId).toBeNull(); + expect(listener.mock.calls.map(([event]) => event.userId)).toEqual(['user-a', 'user-b']); + client.resetConnectionIdentity(); + expect(listener).toHaveBeenLastCalledWith(expect.objectContaining({ kind: 'unavailable', userId: null })); + }); + it.each([200, 401])('discards the old directory response (%s) without clearing a replacement account', async (status) => { + const pending = deferred(); vi.stubGlobal('fetch', vi.fn(() => pending.promise)); + const client = new RelayHttpClient(relayUrl, identity()); + const result = client.listDevices(); + client.setAccountIdentity(identity('user-b')); + pending.resolve(Response.json([], { status })); + await expect(result).rejects.toBeInstanceOf(AccountIdentityChangedError); + expect(client.accountUserId).toBe('user-b'); + }); + it.each(['Unauthorized tool provider', 'Upstream returned HTTP 401'])('never replays an authenticated mutation error: %s', async (message) => { + const client = new RelayHttpClient(relayUrl, identity()); + const key = deriveDeviceMessageKey(identity().masterKey, peerPublicKey); + const encrypted = await encrypt(key, JSON.stringify({ resp: 'error', message })); + const fetch = vi.fn(async (url: string) => url.endsWith('/key') + ? Response.json({ device_id: 'desktop', public_key: toB64(peerPublicKey) }) + : Response.json({ encrypted_data: encrypted.data, nonce: encrypted.nonce })); + vi.stubGlobal('fetch', fetch); + await expect(client.sendDeviceRpc('desktop', { cmd: 'cancel_task' })).rejects.toThrow(message); + expect(fetch).toHaveBeenCalledTimes(2); + expect(fetch.mock.calls[0][0]).toBe(`${relayUrl}/api/devices/desktop/key`); + }); + it('does not post a command after the target changes during key lookup', async () => { + const pending = deferred(); const fetch = vi.fn(() => pending.promise); vi.stubGlobal('fetch', fetch); + const client = new RelayHttpClient(relayUrl, identity()); client.setTargetDeviceId('desktop'); + const result = client.sendDeviceRpc('desktop', { cmd: 'cancel_task' }); + client.setTargetDeviceId('another'); + pending.resolve(Response.json({ device_id: 'desktop', public_key: toB64(peerPublicKey) })); + await expect(result).rejects.toBeInstanceOf(AccountIdentityChangedError); + expect(fetch).toHaveBeenCalledTimes(1); + }); + it('stops transient directory retries when the account changes during backoff', async () => { + vi.useFakeTimers(); + const fetch = vi.fn(async () => Response.json({}, { status: 503 })); vi.stubGlobal('fetch', fetch); + const client = new RelayHttpClient(relayUrl, identity()); + const result = client.listDevices(); + const checked = expect(result).rejects.toBeInstanceOf(AccountIdentityChangedError); + await vi.advanceTimersByTimeAsync(1); + client.setAccountIdentity(identity('user-b')); + await vi.advanceTimersByTimeAsync(300); + await checked; + expect(fetch).toHaveBeenCalledTimes(1); }); }); diff --git a/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.contract.test.ts b/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.contract.test.ts index 4981ff0cad..5514a47468 100644 --- a/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.contract.test.ts +++ b/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.contract.test.ts @@ -40,7 +40,7 @@ describe('Remote Connect safety contracts', () => { expect(dialogSource).toContain('open={isOpen && (disclaimerIsGate || showDisclaimer)}'); }); - it('presents one overview with account and account-free destinations', () => { + it('presents one overview with account and connection destinations', () => { const overview = dialogSource.slice( dialogSource.indexOf('const renderOverview ='), dialogSource.indexOf('const renderViewHeader'), @@ -56,7 +56,7 @@ describe('Remote Connect safety contracts', () => { expect(dialogSource).not.toContain('remote-connect-group-'); }); - it('keeps persistent connection context beside a single task surface', () => { + it('keeps persistent navigation beside a single task surface', () => { expect(dialogSource).toContain('size="2xl"'); expect(dialogSource).toContain('className="openbitfun-remote-connect-dialog"'); expect(dialogSource).toContain('className="openbitfun-remote-connect-dialog__header"'); @@ -64,7 +64,8 @@ describe('Remote Connect safety contracts', () => { expect(dialogSource).toContain('data-openbitfun-part="sidebar"'); expect(dialogSource).toContain('data-openbitfun-part="sidebarBrand"'); expect(dialogSource).toContain('data-openbitfun-part="main"'); - expect(dialogSource).toContain("t('remoteConnect.overviewIntro')"); + expect(dialogSource).toContain('className="openbitfun-remote-connect__navigation"'); + expect(dialogSource).toContain("aria-current={activeView === view ? 'page' : undefined}"); }); it('keeps the dialog height stable while selected content scrolls inside it', () => { @@ -90,28 +91,22 @@ describe('Remote Connect safety contracts', () => { expect(dialogSource).not.toContain('data-openbitfun-part="subtab"'); }); - it('preserves all network methods and chat providers', () => { + it('offers two relay endpoints and the supported chat providers', () => { const methods = dialogSource.slice( dialogSource.indexOf('const NETWORK_TABS'), - dialogSource.indexOf('const NGROK_SETUP_URL'), + dialogSource.indexOf('const RemoteConnectDialog'), ); expect(methods).toContain("id: 'lan'"); expect(methods).toContain("id: 'openbitfun_server'"); - expect(methods).toContain("id: 'ngrok'"); - expect(methods).toContain("id: 'custom_server'"); + expect(methods).not.toContain("id: 'ngrok'"); + expect(methods).not.toContain("id: 'custom_server'"); expect(methods).toContain("id: 'telegram'"); expect(methods).toContain("id: 'feishu'"); expect(methods).toContain("id: 'weixin'"); }); it('uses the real monochrome app marks for every chat provider', () => { - const overviewBrandStyle = dialogStyleSource.slice( - dialogStyleSource.indexOf('.openbitfun-remote-connect__chat-brand-item'), - dialogStyleSource.indexOf( - "[data-openbitfun-component='remote-connect-dialog'][data-openbitfun-part='overviewAction'][data-openbitfun-group='account']", - ), - ); const identityBrandStyle = dialogStyleSource.slice( dialogStyleSource.indexOf('.openbitfun-remote-connect__bot-identity-icon'), dialogStyleSource.indexOf('.openbitfun-remote-connect__bot-identity-title'), @@ -133,7 +128,7 @@ describe('Remote Connect safety contracts', () => { ); expect(dialogSource).toContain(''); - expect(dialogSource).toContain('openbitfun-remote-connect__chat-brand-group'); + expect(dialogSource).toContain('icon: '); expect(dialogSource).toContain(''); expect(chatAppBrandIconSource).toContain("app === 'telegram'"); expect(chatAppBrandIconSource).toContain("app === 'feishu'"); @@ -141,8 +136,6 @@ describe('Remote Connect safety contracts', () => { expect(chatAppBrandIconSource.match(/fill="currentColor"/g)).toHaveLength(5); expect(deviceStatusControlSource).toContain('chatAppBrandFromIdentity(identity)'); expect(deviceStatusControlSource).toContain(''); - expect(overviewBrandStyle).toContain('border: 0'); - expect(overviewBrandStyle).toContain('background: transparent'); expect(identityBrandStyle).not.toContain('background:'); expect(connectedBrandStyle).not.toContain('background:'); expect(footerMessageBrandStyle).toContain('border: 0'); @@ -228,70 +221,15 @@ describe('Remote Connect safety contracts', () => { expect(recoveryFlow).toContain('startDevicePolling()'); }); - it('delegates transient retries without replaying the complete account sync workflow', () => { - const backgroundSync = accountPanelSource.slice( - accountPanelSource.indexOf('const startBackgroundSync'), - accountPanelSource.indexOf('const handleRetrySync'), - ); - - expect(backgroundSync).toContain('AccountClient owns transient Relay retries'); - expect(backgroundSync).not.toContain('for (let attempt'); - expect(backgroundSync.match(/accountAutoSync/g)).toHaveLength(1); - }); - - it('binds overwrite finalize and cleanup to an opaque pending login id', () => { - expect(accountPanelSource).toContain('pendingLoginIdRef.current = result.pending_login_id'); - expect(accountPanelSource).toContain('accountFinalizeLogin(pendingLoginId)'); - expect(accountPanelSource).toContain('accountCancelPendingLogin(pendingLoginId)'); - - const overwriteCleanupStart = accountPanelSource.indexOf( - '// Unmounting (dialog close or group switch)', - ); - const overwriteCleanup = accountPanelSource.slice( - overwriteCleanupStart, - accountPanelSource.indexOf('remoteConnectAPI.getDeviceInfo()', overwriteCleanupStart), - ); - expect(overwriteCleanup).toContain('cancelPendingLoginWithRetry(pendingLoginId)'); - expect(overwriteCleanup).not.toContain('accountLogout'); - }); - it('does not expose the account bearer token in the login result contract', () => { const loginResult = remoteConnectApiSource.slice( remoteConnectApiSource.indexOf('export interface AccountLoginResult'), remoteConnectApiSource.indexOf('export interface AccountHint'), ); - expect(loginResult).toContain('pending_login_id: string | null'); + expect(loginResult).toContain('user_id: string'); expect(loginResult).not.toContain('token:'); }); - it('uses verified usernames instead of opaque account ids in user-facing login states', () => { - const connectedView = dialogSource.slice( - dialogSource.indexOf('const renderConnectedView'), - dialogSource.indexOf('const handleCopyPairingUrl'), - ); - const performLogin = accountPanelSource.slice( - accountPanelSource.indexOf('const performLogin'), - accountPanelSource.indexOf('const handleLogin'), - ); - - expect(dialogSource).toContain('remoteConnectAPI.accountGetCredentialHint()'); - expect(dialogSource).toContain('setAccountUsername(hint?.username.trim() || null)'); - expect(connectedView).toContain("t('accountLogin.username')"); - expect(connectedView).not.toContain('connectedUserId'); - expect(dialogSource).toMatch(/handleDisconnectRelay,\s+accountUsername,/); - expect(performLogin).toContain("loginSuccess', { user_id: user }"); - expect(performLogin).not.toContain("loginSuccess', { user_id: result.user_id }"); - }); - - it('keeps transport failures distinct from a stale pending-owner response', () => { - const cancelMethod = remoteConnectApiSource.slice( - remoteConnectApiSource.indexOf('async accountCancelPendingLogin'), - remoteConnectApiSource.indexOf('async accountStatus'), - ); - expect(cancelMethod).toContain('throw e'); - expect(cancelMethod).not.toContain('return false'); - }); - it('does not reinterpret an account-status transport failure as logout', () => { const statusMethod = remoteConnectApiSource.slice( remoteConnectApiSource.indexOf('async accountStatus'), @@ -304,54 +242,11 @@ describe('Remote Connect safety contracts', () => { accountPanelSource.indexOf('remoteConnectAPI.accountStatus().then'), ), ); - const sharedStateRefresh = accountLoginStateSource.slice( - accountLoginStateSource.indexOf('const refresh = async () =>'), - accountLoginStateSource.indexOf('void refresh();'), - ); - expect(statusMethod).toContain('throw e'); expect(statusMethod).not.toContain('logged_in: false'); expect(accountPanelInitialization).toContain('}).catch((e) => {'); - expect(sharedStateRefresh).toContain("log.warn('Failed to refresh account login state', error)"); - expect(sharedStateRefresh.indexOf('return;')).toBeLessThan( - sharedStateRefresh.indexOf('setState({ loggedIn: false'), - ); - }); - - it('does not discard a pending owner when conditional cleanup transport fails', () => { - const cancelFlow = accountPanelSource.slice( - accountPanelSource.indexOf('const handleCancelOverwrite'), - accountPanelSource.indexOf('const handleLogout'), - ); - expect(cancelFlow).toContain('await cancelPendingLoginWithRetry(pendingLoginId)'); - expect(cancelFlow.indexOf('pendingLoginIdRef.current = null')).toBeGreaterThan( - cancelFlow.indexOf('await cancelPendingLoginWithRetry(pendingLoginId)'), - ); - expect(cancelFlow).toContain("log.warn('pending login cancel failed', e)"); - expect(cancelFlow).toContain('return;'); - }); - - it('retries an ambiguous finalize response with the same opaque owner', () => { - const retryHelper = accountPanelSource.slice( - accountPanelSource.indexOf('async function finalizePendingLoginWithRetry'), - accountPanelSource.indexOf('/** Quota / payload-limit failures'), - ); - expect(retryHelper).toContain('ACCOUNT_TRANSITION_MAX_ATTEMPTS'); - expect(retryHelper).toContain('accountFinalizeLogin(pendingLoginId)'); - expect(retryHelper).toContain('was ambiguous; retrying'); - }); - - it('invalidates the prior background sync before starting a replacement login', () => { - const performLogin = accountPanelSource.slice( - accountPanelSource.indexOf('const performLogin'), - accountPanelSource.indexOf('const handleLogin'), - ); - expect(performLogin.indexOf('syncInFlightRef.current = false')).toBeLessThan( - performLogin.indexOf('remoteConnectAPI.accountLogin'), - ); - expect(performLogin.indexOf('clearSync()')).toBeLessThan( - performLogin.indexOf('remoteConnectAPI.accountLogin'), - ); + expect(accountLoginStateSource).toContain("identity.status === 'signed-in'"); + expect(accountLoginStateSource).not.toContain('accountStatus('); }); it('fences Weixin poll rejection cleanup to the operation that owns the UI', () => { @@ -382,18 +277,6 @@ describe('Remote Connect safety contracts', () => { expect(dialogSource).toContain('prepareAndStartWeixinBotFromQr'); }); - it('restores an existing relay pairing as cancellable in-progress UI', () => { - const restoreFlow = dialogSource.slice( - dialogSource.indexOf('// On dialog open: check if a connection'), - dialogSource.indexOf("activeView !== 'network'"), - ); - expect(restoreFlow).toContain("pendingOwnerRef.current = 'network'"); - expect(restoreFlow).toContain("setConnectionOwner('network')"); - expect(restoreFlow).toContain('setConnectionResult({'); - expect(restoreFlow).toContain('qr_url: null'); - expect(restoreFlow).toContain("startPolling('relay')"); - }); - it('restores connected method status without hijacking the overview', () => { const applyStatus = dialogSource.slice( dialogSource.indexOf('const applyStatus'), diff --git a/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.scss b/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.scss index 3c8e502fb7..6734512be0 100644 --- a/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.scss +++ b/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.scss @@ -36,53 +36,88 @@ // ==================== Persistent context column ==================== .openbitfun-remote-connect__sidebar { - position: relative; + display: flex; + flex-direction: column; + gap: var(--openbitfun-space-6); min-width: 0; - overflow: hidden; - padding: 32px 24px; + padding: var(--openbitfun-space-8) var(--openbitfun-space-4) var(--openbitfun-space-5); border-right: var(--openbitfun-border-width-default) solid var(--openbitfun-color-border-subtle); background: var(--openbitfun-color-surface-panel); } .openbitfun-remote-connect__sidebar-brand { - position: relative; - z-index: 1; display: flex; flex-direction: column; - align-items: flex-start; + gap: var(--openbitfun-space-3); + padding-inline: var(--openbitfun-space-3); } .openbitfun-remote-connect__sidebar-icon { display: inline-flex; align-items: center; - justify-content: center; - width: 52px; - height: 32px; - margin-bottom: 16px; - color: var(--openbitfun-color-content-primary); + color: var(--openbitfun-color-content-secondary); } .openbitfun-remote-connect__sidebar-title { margin: 0; color: var(--openbitfun-color-content-primary); - font-size: var(--openbitfun-type-heading-dialog-font-size); - font-weight: var(--openbitfun-type-label-selected-font-weight); - letter-spacing: var(--openbitfun-type-heading-dialog-letter-spacing); - line-height: var(--openbitfun-type-label-md-line-height); + font-size: var(--openbitfun-type-heading-section-font-size); + font-weight: var(--openbitfun-type-heading-section-font-weight); + line-height: var(--openbitfun-type-heading-section-line-height); } .openbitfun-remote-connect__title-extra { - display: inline-flex; - align-items: center; - margin-top: 10px; + margin-top: auto; + padding-inline: var(--openbitfun-space-2); + + button { color: var(--openbitfun-color-content-muted); } } -.openbitfun-remote-connect__sidebar-description { - max-width: 172px; - margin: 16px 0 0; + + +.openbitfun-remote-connect__navigation { + display: flex; + flex-direction: column; + gap: var(--openbitfun-space-1); +} + +.openbitfun-remote-connect__navigation-item { + display: flex; + align-items: center; + gap: var(--openbitfun-space-3); + min-width: 0; + min-height: 40px; + padding: var(--openbitfun-space-2) var(--openbitfun-space-3); + border: 0; + border-radius: var(--openbitfun-radius-base); + background: transparent; color: var(--openbitfun-color-content-secondary); - font-size: var(--openbitfun-type-label-sm-font-size); - line-height: var(--openbitfun-type-body-lg-line-height); + font-family: var(--openbitfun-type-body-sm-font-family); + font-size: var(--openbitfun-type-body-sm-font-size); + line-height: var(--openbitfun-type-body-sm-line-height); + text-align: left; + cursor: pointer; + + > span:first-child { + display: inline-flex; + flex-shrink: 0; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + } + > span:last-child { min-width: 0; white-space: nowrap; } + &[aria-current] { + background: var(--openbitfun-color-action-neutral-surface); + color: var(--openbitfun-color-content-primary); + font-weight: var(--openbitfun-type-label-selected-font-weight); + } + &:hover { background: var(--openbitfun-color-action-neutral-surface); } + &:active { background: var(--openbitfun-color-action-neutral-surface-pressed); } + &:focus-visible { + outline: var(--openbitfun-focus-width) solid var(--openbitfun-color-focus-ring); + outline-offset: 2px; + } } // ==================== Main work surface ==================== @@ -103,28 +138,27 @@ flex: 1; min-height: 0; flex-direction: column; - gap: 28px; - padding: 64px 28px 28px; + gap: var(--openbitfun-space-8); + padding: var(--openbitfun-space-8); } .openbitfun-remote-connect__overview-section { display: flex; flex-direction: column; - gap: 14px; + gap: var(--openbitfun-space-3); } .openbitfun-remote-connect__overview-section-heading { display: flex; - align-items: baseline; - justify-content: space-between; - gap: 8px; - flex-wrap: wrap; + flex-direction: column; + align-items: flex-start; + gap: var(--openbitfun-space-2); } .openbitfun-remote-connect__overview-section-title { display: inline-flex; align-items: center; - gap: 12px; + gap: var(--openbitfun-space-3); margin: 0; color: var(--openbitfun-color-content-primary); font-size: var(--openbitfun-type-heading-section-font-size); @@ -135,7 +169,7 @@ .openbitfun-remote-connect__overview-section-description { margin: 0; color: var(--openbitfun-color-content-muted); - font-size: var(--openbitfun-type-label-sm-font-size); + font-size: var(--openbitfun-type-body-sm-font-size); line-height: var(--openbitfun-type-body-sm-line-height); } @@ -148,12 +182,12 @@ .openbitfun-remote-connect__overview-action { width: 100%; - min-height: 88px; + min-height: 84px; display: grid; - grid-template-columns: 52px minmax(0, 1fr) auto 16px; + grid-template-columns: 24px minmax(0, 1fr) auto 16px; align-items: center; - gap: 12px; - padding: 16px 20px; + gap: var(--openbitfun-space-3); + padding: var(--openbitfun-space-4); border: 0; background: transparent; color: inherit; @@ -194,32 +228,13 @@ } .openbitfun-remote-connect__overview-action-icon { - width: 52px; - height: 32px; display: inline-flex; align-items: center; justify-content: center; + width: 24px; color: var(--openbitfun-color-content-secondary); } -.openbitfun-remote-connect__chat-brand-group { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 2px; -} - -.openbitfun-remote-connect__chat-brand-item { - width: 16px; - height: 20px; - display: inline-flex; - align-items: center; - justify-content: center; - border: 0; - background: transparent; - color: var(--openbitfun-color-content-primary); -} - .openbitfun-remote-connect__overview-action-copy { min-width: 0; display: flex; @@ -237,7 +252,7 @@ .openbitfun-remote-connect__overview-action-description { max-width: 410px; color: var(--openbitfun-color-content-muted); - font-size: var(--openbitfun-type-label-sm-font-size); + font-size: var(--openbitfun-type-body-sm-font-size); line-height: var(--openbitfun-type-support-line-height); } @@ -253,7 +268,7 @@ max-width: 150px; overflow: hidden; color: var(--openbitfun-color-content-muted); - font-size: var(--openbitfun-type-label-sm-font-size); + font-size: var(--openbitfun-type-body-sm-font-size); white-space: nowrap; } @@ -268,25 +283,35 @@ display: flex; flex-shrink: 0; flex-direction: column; - gap: var(--openbitfun-space-3); - padding: 24px 32px 0; + gap: var(--openbitfun-space-4); + padding: var(--openbitfun-space-8) var(--openbitfun-space-8) 0; } .openbitfun-remote-connect__back { + display: none; align-self: flex-start; } .openbitfun-remote-connect__view-page-header { max-width: 560px; + padding-inline-end: var(--openbitfun-space-8); + + [data-openbitfun-part='heading'] { + font-size: var(--openbitfun-type-heading-dialog-font-size); + line-height: var(--openbitfun-type-heading-dialog-line-height); + } + [data-openbitfun-part='description'] { + max-width: 56ch; + color: var(--openbitfun-color-content-secondary); + } } // ==================== Method / provider selectors ==================== .openbitfun-remote-connect__subtabs { flex-shrink: 0; - margin: 16px 32px 0; - overflow-x: auto; - padding-bottom: 2px; + margin: var(--openbitfun-space-5) var(--openbitfun-space-8) 0; + max-width: 100%; } .openbitfun-remote-connect__tab-group { @@ -343,7 +368,7 @@ flex-direction: column; align-items: stretch; gap: var(--openbitfun-space-4); - padding: 20px 32px 28px; + padding: var(--openbitfun-space-5) var(--openbitfun-space-8) var(--openbitfun-space-8); } .openbitfun-remote-connect__network-card { @@ -356,13 +381,13 @@ display: flex; align-items: center; gap: var(--openbitfun-space-3); - padding: var(--openbitfun-space-4) var(--openbitfun-space-5) 0; + padding: var(--openbitfun-space-4); color: var(--openbitfun-color-content-secondary); h3 { margin: 0; color: var(--openbitfun-color-content-primary); - font-size: var(--openbitfun-type-label-md-font-size); + font-size: var(--openbitfun-type-body-md-font-size); font-weight: var(--openbitfun-type-label-selected-font-weight); line-height: var(--openbitfun-type-body-md-line-height); } @@ -376,7 +401,7 @@ display: flex; flex-direction: column; gap: var(--openbitfun-space-3); - padding: var(--openbitfun-space-4) var(--openbitfun-space-5); + padding: var(--openbitfun-space-4); border-top: var(--openbitfun-border-width-default) solid var(--openbitfun-color-border-subtle); &:empty { @@ -395,64 +420,56 @@ flex-direction: column; align-items: stretch; gap: var(--openbitfun-space-3); - padding: var(--openbitfun-space-4) var(--openbitfun-space-5); + padding: var(--openbitfun-space-4); border-top: var(--openbitfun-border-width-default) solid var(--openbitfun-color-border-subtle); } .openbitfun-remote-connect__primary-action { - align-self: flex-end; + align-self: flex-start; max-width: 100%; } // ==================== QR / pairing ==================== .openbitfun-remote-connect__body--pairing { - gap: 14px; + gap: var(--openbitfun-space-4); } .openbitfun-remote-connect__pairing-card { display: grid; - grid-template-columns: 220px minmax(0, 1fr); - overflow: hidden; - border: 0; + grid-template-columns: 204px minmax(0, 1fr); + gap: var(--openbitfun-space-4); + min-width: 0; + padding: var(--openbitfun-space-4); border-radius: var(--openbitfun-layout-field-group-radius); background: var(--openbitfun-color-surface-tertiary); &--compact { grid-template-columns: minmax(0, 1fr); - - .openbitfun-remote-connect__pairing-visual { - padding: var(--openbitfun-space-5) var(--openbitfun-space-5) 0; - border: 0; - } - + text-align: center; .openbitfun-remote-connect__pairing-details { - align-items: center; - gap: var(--openbitfun-space-3); - text-align: center; - } - + display: flex; + min-width: 0; + flex-direction: column; + justify-content: center; + align-items: flex-start; + padding-block: var(--openbitfun-space-2); +} .openbitfun-remote-connect__pairing-status { - align-self: center; - margin-bottom: 0; - } - - .openbitfun-remote-connect__hint { - margin: 0; - } + display: flex; + align-self: flex-start; + max-width: 100%; + margin-bottom: var(--openbitfun-space-4); +} } } .openbitfun-remote-connect__pairing-visual { display: flex; - flex-direction: column; align-items: center; justify-content: center; - gap: var(--openbitfun-space-3); min-width: 0; - padding: var(--openbitfun-space-5); - border-right: var(--openbitfun-border-width-default) solid var(--openbitfun-color-border-subtle); - background: transparent; + padding: var(--openbitfun-space-2) 0; } .openbitfun-remote-connect__qr-box { @@ -497,7 +514,7 @@ min-width: 0; flex-direction: column; justify-content: center; - padding: var(--openbitfun-space-4) var(--openbitfun-space-5); + padding: var(--openbitfun-space-4); } .openbitfun-remote-connect__pairing-status { @@ -508,48 +525,37 @@ } .openbitfun-remote-connect__pairing-label { - margin-bottom: 7px; + margin-bottom: var(--openbitfun-space-1); color: var(--openbitfun-color-content-muted); - font-size: var(--openbitfun-type-label-sm-font-size); - line-height: var(--openbitfun-type-modifier-leading-ui-line-height); + font-size: var(--openbitfun-type-body-sm-font-size); + line-height: var(--openbitfun-type-body-sm-line-height); } .openbitfun-remote-connect__pairing-url-row { display: flex; - align-items: center; - gap: 10px; - padding-bottom: 18px; + align-items: flex-start; + gap: var(--openbitfun-space-2); + width: 100%; + min-width: 0; - > span:not([data-overflow-content]) { - min-width: 0; - flex: 1; - overflow: hidden; - color: var(--openbitfun-color-content-primary); - font-family: var(--openbitfun-type-body-sm-font-family); - font-size: var(--openbitfun-type-label-md-font-size); - font-weight: var(--openbitfun-type-label-selected-font-weight); - line-height: var(--openbitfun-type-flow-support-line-height); - white-space: nowrap; - } + > button { flex-shrink: 0; } } -.openbitfun-remote-connect__pairing-instruction { - display: grid; - grid-template-columns: 20px minmax(0, 1fr); - gap: 10px; - padding: 14px 0; - border-top: var(--openbitfun-border-width-default) solid var(--openbitfun-color-border-subtle); - color: var(--openbitfun-color-content-muted); - - svg { - margin-top: 1px; - } +.openbitfun-remote-connect__pairing-url { + flex: 1; + min-width: 0; + overflow-wrap: anywhere; + user-select: text; + color: var(--openbitfun-color-content-primary); + font-size: var(--openbitfun-type-body-sm-font-size); + line-height: var(--openbitfun-type-body-sm-line-height); +} - p { - margin: 0; - font-size: var(--openbitfun-type-label-sm-font-size); - line-height: var(--openbitfun-type-body-lg-line-height); - } +.openbitfun-remote-connect__pairing-instruction { + margin: var(--openbitfun-space-4) 0 0; + color: var(--openbitfun-color-content-secondary); + font-size: var(--openbitfun-type-body-sm-font-size); + line-height: var(--openbitfun-type-body-sm-line-height); } .openbitfun-remote-connect__pairing-actions { @@ -559,7 +565,7 @@ .openbitfun-remote-connect__pairing-code { max-width: 100%; - padding: var(--openbitfun-space-3) var(--openbitfun-space-5); + padding: var(--openbitfun-space-4); border: var(--openbitfun-border-width-default) solid var(--openbitfun-color-border-subtle); border-radius: var(--openbitfun-radius-pill); background: var(--openbitfun-color-surface-canvas); @@ -576,7 +582,7 @@ // ==================== Bot setup card ==================== .openbitfun-remote-connect__body--bot { - padding-top: 18px; + padding-top: var(--openbitfun-space-5); } .openbitfun-remote-connect__bot-card { @@ -593,7 +599,7 @@ grid-template-columns: 28px minmax(0, 1fr); align-items: center; gap: var(--openbitfun-space-2) var(--openbitfun-space-3); - padding: var(--openbitfun-space-4) var(--openbitfun-space-5); + padding: var(--openbitfun-space-4); border-bottom: var(--openbitfun-border-width-default) solid var(--openbitfun-color-border-subtle); } @@ -608,16 +614,16 @@ .openbitfun-remote-connect__bot-identity-title { margin: 0; color: var(--openbitfun-color-content-primary); - font-size: var(--openbitfun-type-heading-section-font-size); + font-size: var(--openbitfun-type-body-md-font-size); font-weight: var(--openbitfun-type-label-selected-font-weight); - line-height: var(--openbitfun-type-modifier-leading-ui-line-height); + line-height: var(--openbitfun-type-body-md-line-height); } .openbitfun-remote-connect__bot-identity-description { margin: 0; grid-column: 2; color: var(--openbitfun-color-content-muted); - font-size: var(--openbitfun-type-label-sm-font-size); + font-size: var(--openbitfun-type-body-sm-font-size); line-height: var(--openbitfun-type-body-lg-line-height); } @@ -625,11 +631,11 @@ display: flex; min-width: 0; flex-direction: column; - gap: 14px; - padding: var(--openbitfun-space-4) var(--openbitfun-space-5); + gap: var(--openbitfun-space-4); + padding: var(--openbitfun-space-4); > .openbitfun-remote-connect__primary-action { - align-self: flex-end; + align-self: flex-start; } } @@ -638,7 +644,7 @@ display: flex; flex-direction: column; align-items: stretch; - gap: 14px; + gap: var(--openbitfun-space-4); .openbitfun-remote-connect__info-card { max-width: none; @@ -652,15 +658,19 @@ } > .openbitfun-remote-connect__primary-action { - align-self: flex-end; + align-self: flex-start; } } +.openbitfun-remote-connect__info-text + .openbitfun-remote-connect__steps { + margin-top: var(--openbitfun-space-3); +} + .openbitfun-remote-connect__steps { display: flex; width: 100%; flex-direction: column; - gap: 12px; + gap: var(--openbitfun-space-3); } .openbitfun-remote-connect__step { @@ -668,10 +678,10 @@ width: 100%; grid-template-columns: 22px minmax(0, 1fr); align-items: start; - gap: 12px; + gap: var(--openbitfun-space-3); margin: 0; color: var(--openbitfun-color-content-secondary); - font-size: var(--openbitfun-type-label-sm-font-size); + font-size: var(--openbitfun-type-body-sm-font-size); line-height: var(--openbitfun-type-body-lg-line-height); text-align: left; } @@ -696,7 +706,7 @@ width: 100%; max-width: none; margin: 0; - padding: var(--openbitfun-space-4) var(--openbitfun-space-5); + padding: var(--openbitfun-space-4); box-sizing: border-box; border: 0; border-radius: var(--openbitfun-layout-field-group-radius); @@ -707,7 +717,7 @@ .openbitfun-remote-connect__info-meta { margin: 0; color: var(--openbitfun-color-content-muted); - font-size: var(--openbitfun-type-label-sm-font-size); + font-size: var(--openbitfun-type-body-sm-font-size); line-height: var(--openbitfun-type-body-lg-line-height); text-align: left; } @@ -724,12 +734,12 @@ .openbitfun-remote-connect__lan-ip-select { display: flex; align-items: center; - gap: 10px; + gap: var(--openbitfun-space-3); } .openbitfun-remote-connect__info-meta-label { color: var(--openbitfun-color-content-muted); - font-size: var(--openbitfun-type-label-sm-font-size); + font-size: var(--openbitfun-type-body-sm-font-size); white-space: nowrap; } @@ -773,12 +783,12 @@ .openbitfun-remote-connect__status { display: flex; align-items: center; - gap: 10px; + gap: var(--openbitfun-space-3); } .openbitfun-remote-connect__peer-username { color: var(--openbitfun-color-content-secondary); - font-size: var(--openbitfun-type-label-sm-font-size); + font-size: var(--openbitfun-type-body-sm-font-size); } .openbitfun-remote-connect__connected { @@ -794,7 +804,7 @@ } .openbitfun-remote-connect__connected > button { - align-self: flex-end; + align-self: flex-start; } .openbitfun-remote-connect__connected > .openbitfun-remote-connect__hint { @@ -831,7 +841,7 @@ min-width: 0; display: flex; flex-direction: column; - gap: 3px; + gap: var(--openbitfun-space-1); strong { color: var(--openbitfun-color-content-primary); @@ -843,7 +853,7 @@ > span { max-width: 470px; color: var(--openbitfun-color-content-muted); - font-size: var(--openbitfun-type-label-sm-font-size); + font-size: var(--openbitfun-type-body-sm-font-size); line-height: var(--openbitfun-type-body-sm-line-height); } } @@ -852,7 +862,7 @@ display: grid; grid-template-columns: 18px minmax(0, 1fr); align-items: start; - gap: 10px; + gap: var(--openbitfun-space-3); margin: 16px 20px 0; padding: 13px 14px; border: var(--openbitfun-border-width-default) solid var(--openbitfun-color-border-subtle); @@ -866,7 +876,7 @@ p { margin: 0; - font-size: var(--openbitfun-type-label-sm-font-size); + font-size: var(--openbitfun-type-body-sm-font-size); line-height: var(--openbitfun-type-body-lg-line-height); } } @@ -877,7 +887,7 @@ justify-content: space-between; flex-wrap: wrap; gap: var(--openbitfun-space-3); - padding: var(--openbitfun-space-4) var(--openbitfun-space-5); + padding: var(--openbitfun-space-4); border-bottom: var(--openbitfun-border-width-default) solid var(--openbitfun-color-border-subtle); } @@ -893,7 +903,7 @@ max-width: 480px; margin: 0; color: var(--openbitfun-color-content-muted); - font-size: var(--openbitfun-type-label-sm-font-size); + font-size: var(--openbitfun-type-body-sm-font-size); line-height: var(--openbitfun-type-body-lg-line-height); } @@ -905,17 +915,10 @@ text-align: left; } -.openbitfun-remote-connect__ngrok-usage-link { - margin: 0; - padding: 16px 32px 0; - font-size: var(--openbitfun-type-label-sm-font-size); - text-align: right; -} - .openbitfun-remote-connect__error { margin: 0; color: var(--openbitfun-color-status-danger-content); - font-size: var(--openbitfun-type-label-sm-font-size); + font-size: var(--openbitfun-type-body-sm-font-size); line-height: var(--openbitfun-type-body-sm-line-height); overflow-wrap: anywhere; text-align: left; @@ -937,7 +940,7 @@ gap: var(--openbitfun-space-2); color: var(--openbitfun-color-content-muted); font-family:var(--openbitfun-type-body-sm-font-family); - font-size: var(--openbitfun-type-label-sm-font-size); + font-size: var(--openbitfun-type-body-sm-font-size); > span[data-active='true'] { color: var(--openbitfun-color-content-primary); @@ -953,7 +956,7 @@ display: flex; flex-direction: column; align-items: center; - gap: 10px; + gap: var(--openbitfun-space-3); margin-top: 4px; text-align: center; } @@ -975,7 +978,7 @@ width: 100%; flex-direction: column; align-items: stretch; - gap: 10px; + gap: var(--openbitfun-space-3); } .openbitfun-remote-connect__weixin-qr-img { @@ -1002,183 +1005,49 @@ // ==================== Responsive containment ==================== -@media (max-width: 920px) { - .openbitfun-remote-connect { - grid-template-columns: 184px minmax(0, 1fr); - } - - .openbitfun-remote-connect__sidebar { - padding: 32px 20px; - } - - .openbitfun-remote-connect__overview { - padding: 60px 28px 34px; - } - - .openbitfun-remote-connect__view-header { - padding-inline: 28px; - } - - .openbitfun-remote-connect__subtabs { - margin-inline: 28px; - } - - .openbitfun-remote-connect__body { - padding-inline: 28px; - } - - .openbitfun-remote-connect__bot-setup { - padding: 24px; - } -} - @media (max-width: 760px) { .openbitfun-remote-connect-dialog { - block-size: calc(100vh - 2 * var(--openbitfun-overlay-dialog-viewport-gutter)); - min-block-size: calc(100vh - 2 * var(--openbitfun-overlay-dialog-viewport-gutter)); - max-block-size: calc(100vh - 2 * var(--openbitfun-overlay-dialog-viewport-gutter)); + block-size: calc(100dvh - 2 * var(--openbitfun-overlay-dialog-viewport-gutter)); + min-block-size: calc(100dvh - 2 * var(--openbitfun-overlay-dialog-viewport-gutter)); + max-block-size: calc(100dvh - 2 * var(--openbitfun-overlay-dialog-viewport-gutter)); } - - .openbitfun-remote-connect-dialog__header { - inset-block-start: 18px; - inset-inline-end: 18px; - } - - .openbitfun-remote-connect { - grid-template-columns: minmax(0, 1fr); - grid-template-rows: auto minmax(0, 1fr); - } - + .openbitfun-remote-connect { grid-template-columns: minmax(0, 1fr); grid-template-rows: auto minmax(0, 1fr); } .openbitfun-remote-connect__sidebar { - padding: 20px 56px 18px 22px; - border-right: 0; - border-bottom: var(--openbitfun-border-width-default) solid var(--openbitfun-color-border-subtle); - } - - .openbitfun-remote-connect__sidebar-brand { - display: grid; - grid-template-columns: 38px minmax(0, 1fr); - column-gap: 12px; + flex-direction: row; align-items: center; - } - - .openbitfun-remote-connect__sidebar-icon { - width: 38px; - height: 38px; - grid-row: 1 / 3; - margin: 0; - } - - .openbitfun-remote-connect__sidebar-title { - font-size: var(--openbitfun-type-heading-section-font-size); - } - - .openbitfun-remote-connect__title-extra { - margin-top: 2px; - } - - .openbitfun-remote-connect__sidebar-description { - display: none; - } - - .openbitfun-remote-connect__overview { - gap: 32px; - padding: 32px 24px; - } - - .openbitfun-remote-connect__overview-action { - grid-template-columns: 52px minmax(0, 1fr) 16px; - gap: 14px; - padding: 18px; - } - - .openbitfun-remote-connect__overview-action-icon { - width: 52px; - height: 32px; - } - - .openbitfun-remote-connect__overview-action-status { - grid-column: 2; - align-items: flex-start; - justify-content: flex-start; - } - - .openbitfun-remote-connect__overview-action-chevron { - grid-column: 3; - grid-row: 1 / 3; - } - - .openbitfun-remote-connect__view-header { - padding: 28px 24px 0; - } - - .openbitfun-remote-connect__subtabs { - margin-inline: 24px; - } - - .openbitfun-remote-connect__subtabs[data-openbitfun-group='network'] .openbitfun-remote-connect__tab-group { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - - [role='tab'] { - width: 100%; - } - } - - .openbitfun-remote-connect__body { - padding: 24px; - } - - .openbitfun-remote-connect__lan-ip-select { - align-items: stretch; - flex-direction: column; - } - - .openbitfun-remote-connect__pairing-card, - .openbitfun-remote-connect__bot-card { - grid-template-columns: minmax(0, 1fr); - } - - .openbitfun-remote-connect__pairing-visual, - .openbitfun-remote-connect__bot-identity { + gap: var(--openbitfun-space-2); + padding: var(--openbitfun-space-4) 56px var(--openbitfun-space-4) var(--openbitfun-space-5); border-right: 0; border-bottom: var(--openbitfun-border-width-default) solid var(--openbitfun-color-border-subtle); } - - .openbitfun-remote-connect__bot-identity { - padding: var(--openbitfun-space-4); - } - - .openbitfun-remote-connect__bot-identity-description { - max-width: 360px; - } - - .openbitfun-remote-connect__connected { - margin: 20px 24px 24px; - } - - .openbitfun-remote-connect__connected-app { - grid-template-columns: 42px minmax(0, 1fr); - padding: 18px; - } - - .openbitfun-remote-connect__connected-app-icon { - width: 42px; - height: 42px; - } - - .openbitfun-remote-connect__connected-app .openbitfun-remote-connect__status { - grid-column: 2; - justify-self: start; - } - - .openbitfun-remote-connect__connected-notice { - margin-inline: 18px; - } - - .openbitfun-remote-connect__connected-actions { - padding-inline: 18px; - } + .openbitfun-remote-connect__sidebar-brand { flex-direction: row; align-items: center; gap: var(--openbitfun-space-2); padding: 0; } + .openbitfun-remote-connect__sidebar-icon { display: none; } + .openbitfun-remote-connect__sidebar-title { font-size: var(--openbitfun-type-body-md-font-size); } + .openbitfun-remote-connect__navigation { display: none; } + .openbitfun-remote-connect__title-extra { margin: 0 0 0 auto; padding: 0; } + .openbitfun-remote-connect__overview { padding: var(--openbitfun-space-5); gap: var(--openbitfun-space-6); } + .openbitfun-remote-connect__overview-action { grid-template-columns: 24px minmax(0, 1fr) 16px; padding: var(--openbitfun-space-4); } + .openbitfun-remote-connect__overview-action-status { grid-column: 2; align-items: flex-start; flex-direction: row; flex-wrap: wrap; } + .openbitfun-remote-connect__overview-action-chevron { grid-column: 3; grid-row: 1 / 3; } + .openbitfun-remote-connect__view-header { padding: var(--openbitfun-space-5) var(--openbitfun-space-5) 0; } + .openbitfun-remote-connect__back { display: inline-flex; } + .openbitfun-remote-connect__view-page-header { padding: 0; } + .openbitfun-remote-connect__subtabs { margin-inline: var(--openbitfun-space-5); } + .openbitfun-remote-connect__body { padding: var(--openbitfun-space-5); } + .openbitfun-remote-connect__pairing-card { grid-template-columns: minmax(0, 1fr); gap: var(--openbitfun-space-3); } + .openbitfun-remote-connect__pairing-visual { padding-top: var(--openbitfun-space-3); } + .openbitfun-remote-connect__connected { margin: var(--openbitfun-space-5); } + .openbitfun-remote-connect__connected-app { grid-template-columns: 24px minmax(0, 1fr); } + .openbitfun-remote-connect__connected-app .openbitfun-remote-connect__status { grid-column: 2; justify-self: start; } +} + +@media (max-width: 380px) { + .openbitfun-remote-connect__overview, + .openbitfun-remote-connect__body { padding-inline: var(--openbitfun-space-4); } + .openbitfun-remote-connect__view-header { padding-inline: var(--openbitfun-space-4); } + .openbitfun-remote-connect__subtabs { margin-inline: var(--openbitfun-space-4); } + .openbitfun-remote-connect__tab-brand { display: none; } + .openbitfun-remote-connect__sidebar { padding-inline-start: var(--openbitfun-space-4); } } @media (prefers-reduced-motion: reduce) { @@ -1189,12 +1058,16 @@ } } -.openbitfun-remote-connect__relay-address { - padding: var(--openbitfun-space-4) var(--openbitfun-space-5); +.openbitfun-remote-connect__relay-card > .openbitfun-remote-connect__network-heading { + padding-bottom: var(--openbitfun-space-4); +} + +.openbitfun-remote-connect__relay-settings { + padding: 0 var(--openbitfun-space-4) var(--openbitfun-space-4); } .openbitfun-remote-connect__connections-content { - padding: var(--openbitfun-space-4) var(--openbitfun-space-5); + padding: var(--openbitfun-space-4); border-top: var(--openbitfun-border-width-default) solid var(--openbitfun-color-border-subtle); } @@ -1208,7 +1081,7 @@ h4 { margin: 0; color: var(--openbitfun-color-content-secondary); - font-size: var(--openbitfun-type-label-sm-font-size); + font-size: var(--openbitfun-type-body-sm-font-size); font-weight: var(--openbitfun-type-label-selected-font-weight); line-height: var(--openbitfun-type-body-sm-line-height); } @@ -1248,7 +1121,7 @@ } .openbitfun-remote-connect__relay-actions { - padding: var(--openbitfun-space-3) var(--openbitfun-space-5); + padding: var(--openbitfun-space-4); border-top: var(--openbitfun-border-width-default) solid var(--openbitfun-color-border-subtle); } @@ -1261,7 +1134,7 @@ } .openbitfun-remote-connect__relay-invitation { - padding: var(--openbitfun-space-4) var(--openbitfun-space-5); + padding: var(--openbitfun-space-4); border-top: var(--openbitfun-border-width-default) solid var(--openbitfun-color-border-subtle); .openbitfun-remote-connect__pairing-card { @@ -1270,3 +1143,10 @@ background: transparent; } } + +.openbitfun-remote-connect [data-openbitfun-part='panel'][data-openbitfun-group='account'] { display: flex; } + +.openbitfun-remote-connect__relay-invitation { + .openbitfun-remote-connect__pairing-visual, + .openbitfun-remote-connect__pairing-details { padding: 0; } +} diff --git a/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.status.test.tsx b/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.status.test.tsx index 502b15ce41..879bb82abb 100644 --- a/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.status.test.tsx +++ b/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.status.test.tsx @@ -13,6 +13,7 @@ import { setRemoteConnectDisclaimerAgreed } from './remoteConnectDisclaimerStora const boundary = vi.hoisted(() => ({ backend: null as RemoteConnectStatus | null, hasWorkspace: true, + loggedIn: true, getStatus: vi.fn(), startConnection: vi.fn(), stopConnection: vi.fn(), @@ -34,7 +35,7 @@ vi.mock('@/infrastructure/api/service-api/RemoteConnectAPI', async importOrigina stopBot: boundary.stopBot, getFormState: boundary.getFormState, setFormState: vi.fn().mockResolvedValue(undefined), - getLanNetworkInfo: vi.fn().mockResolvedValue(null), + getLanNetworkInfo: vi.fn().mockResolvedValue({ local_ip: '192.168.1.2', available_ips: [{ ip: '192.168.1.2', interface_name: 'en0' }] }), getDeviceInfo: vi.fn().mockResolvedValue({ device_id: 'desktop', device_name: 'Workstation', mac_address: '' }), accountGetCredentialHint: vi.fn().mockResolvedValue({ username: 'sora', relay_url: 'https://relay.example.test/remote/a' }), }, @@ -57,7 +58,7 @@ vi.mock('@/infrastructure/i18n/hooks/useI18n', () => ({ })); vi.mock('@/infrastructure/contexts/WorkspaceContext', () => ({ useCurrentWorkspace: () => ({ hasWorkspace: boundary.hasWorkspace }) })); vi.mock('@/infrastructure/account/useAccountLoginState', () => ({ - useAccountLoginState: () => ({ loggedIn: true, deviceName: 'Workstation' }), + useAccountLoginState: () => ({ loggedIn: boundary.loggedIn, deviceName: 'Workstation' }), })); vi.mock('@/infrastructure/appearance/runtime/AppearanceOverlayHost', () => ({ getAppearanceOverlayHost: () => document.body })); vi.mock('@/infrastructure/peer-device/peerDeviceContextState', () => ({ usePeerDeviceModeOptional: () => null })); @@ -65,26 +66,22 @@ vi.mock('@/features/dispatch/dispatchJobStore', () => ({ useDispatchJobStore: (s vi.mock('@/shared/notification-system', () => ({ useNotification: () => ({ success: vi.fn(), warning: vi.fn(), error: vi.fn() }) })); vi.mock('@/infrastructure/confirm-dialog', () => ({ confirmWarning: vi.fn().mockResolvedValue(true) })); vi.mock('./AccountPanel', () => ({ AccountPanel: () => null })); -vi.mock('@/features/relay-deploy', () => ({ RelayDeployWizard: () => null })); -const relayA = 'https://relay.example.test/remote/a'; -const relayB = 'https://relay.example.test/remote/b'; +const relayA = 'https://remote.openbitfun.com/v/1.0.0'; function status(overrides: Partial = {}): RemoteConnectStatus { return { - is_connected: false, pairing_state: 'idle', active_method: null, - peer_device_name: null, peer_user_id: null, + relay_connected: false, relay_url: null, active_method: null, clients: [], bot_connected: 'Weixin (desktop-bot)', bot_verbose_mode: false, - account_control_connected: false, account_control_relay_url: null, ...overrides, }; } function invitation(relay = relayA): ConnectionResult { return { - method: { custom_server: { url: relay } }, + method: relay === relayA ? 'openbitfun_server' : { lan: { ip: '192.168.1.2' } }, qr_data: null, qr_svg: null, - qr_url: `https://mobile.example.test/#/pair?relay=${encodeURIComponent(relay)}`, + qr_url: `${relay}/#/pair?did=desktop`, bot_pairing_code: null, bot_link: null, pairing_state: 'waiting_for_scan', }; } @@ -129,16 +126,9 @@ async function clickText(key: string) { async function tick(ms = 2000) { await act(async () => { await vi.advanceTimersByTimeAsync(ms); }); } async function render(initialGroup?: 'network' | 'bot') { await act(async () => { root.render(); }); } async function openNetwork() { await click(overviewNetwork()); } -async function generateInvitation(relay = relayA) { +async function generateInvitation() { await openNetwork(); - await click(element('#remote-connect-network-tab-custom_server')); - if (relay !== relayA) { - const input = element('input[placeholder="https://relay.example.com:9700"]') as HTMLInputElement; - await act(async () => { - Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(input, relay); - input.dispatchEvent(new Event('input', { bubbles: true })); - }); - } + await click(element('#remote-connect-network-tab-openbitfun_server')); await clickText('remoteConnect.showConnectionCode'); } async function closeDialog() { @@ -162,16 +152,19 @@ beforeEach(() => { }); setRemoteConnectDisclaimerAgreed(); boundary.hasWorkspace = true; + boundary.loggedIn = true; boundary.copyText.mockResolvedValue(true); boundary.backend = status(); boundary.getStatus.mockImplementation(async () => ({ ...boundary.backend! })); - boundary.getFormState.mockResolvedValue({ custom_server_url: relayA }); - boundary.startConnection.mockImplementation(async (_method: string, relay: string) => { - boundary.backend = { ...boundary.backend!, active_method: `CustomServer { url: "${relay}" }`, pairing_state: 'waiting_for_scan' }; - return invitation(relay); + boundary.getFormState.mockResolvedValue({}); + boundary.startConnection.mockImplementation(async (method: string) => { + const relay = method === 'lan' ? 'http://192.168.1.2:9700' : relayA; + const result = invitation(relay); + boundary.backend = status({ ...boundary.backend!, relay_connected: true, relay_url: relay, active_method: result.method }); + return result; }); boundary.stopConnection.mockImplementation(async () => { - boundary.backend = { ...boundary.backend!, is_connected: false, pairing_state: 'idle', active_method: null, peer_device_name: null, peer_user_id: null }; + boundary.backend = status({ bot_connected: boundary.backend!.bot_connected }); }); boundary.stopBot.mockImplementation(async () => { boundary.backend = { ...boundary.backend!, bot_connected: null }; }); remoteConnectStatusSource.invalidate(); @@ -190,349 +183,149 @@ afterEach(async () => { }); describe('Remote Connect shared status through the real dialog and sidebar', () => { - it.each([ - ['openbitfun_server', 'https://remote.openbitfun.com/relay'], - ['custom_server', relayA], - ] as const)('shows the same live clients and relay URL for %s', async (method, relay) => { - boundary.backend = status({ - account_control_connected: true, - account_control_relay_url: relay, - account_control_clients: [ - { id: 'phone', name: 'Safari · iOS' }, - { id: 'browser', name: 'Chrome · Windows' }, - ], - account_control_has_unidentified_clients: false, - }); - await render('network'); - const connections = element('[data-openbitfun-part="connections"]'); - expect(connections.textContent).toContain('remoteConnect.clientCount:2'); - expect(connections.querySelectorAll('li')).toHaveLength(2); - expect(connections.textContent).toContain('Safari · iOS'); - expect(connections.textContent).toContain('Chrome · Windows'); - expect(dialog().querySelectorAll('.openbitfun-remote-connect__network-card')).toHaveLength(1); - expect(connections.querySelectorAll('input[type="url"]')).toHaveLength(1); - const input = element('input[type="url"]') as HTMLInputElement; - expect(input.value).toBe(relay); - expect(input.readOnly).toBe(method === 'openbitfun_server'); - await click(element('button[aria-label="remoteConnect.copyServerUrl"]')); - expect(boundary.copyText).toHaveBeenCalledWith(relay); - boundary.backend = { ...boundary.backend!, account_control_clients: [{ id: 'browser', name: 'Chrome · Windows' }] }; - await tick(); - expect(connections.textContent).toContain('remoteConnect.clientCount:1'); - expect(connections.textContent).not.toContain('Safari · iOS'); - boundary.backend = { ...boundary.backend!, account_control_connected: false, account_control_clients: [] }; - await tick(); - expect(element('[data-openbitfun-part="connections"]').textContent).toContain('remoteConnect.clientCount:0'); - expect(element('[data-openbitfun-part="connections"]').querySelectorAll('li')).toHaveLength(0); + it.each([undefined, 'network', 'bot'] as const)('requires GitHub identity for the %s entry', async group => { + boundary.loggedIn = false; + await render(group); + expect(document.querySelector('#remote-connect-access-title')).toBeNull(); + expect(document.querySelector('#remote-connect-network-tabpanel')).toBeNull(); + expect(document.querySelector('#remote-connect-bot-tabpanel')).toBeNull(); + expect(boundary.startConnection).not.toHaveBeenCalled(); }); - it('does not invent a total for old clients or mix account clients into another relay tab', async () => { - boundary.backend = status({ account_control_connected: true, account_control_relay_url: relayA }); + it('closes connection setup on account logout', async () => { await render('network'); - expect(element('[data-openbitfun-part="connections"]').textContent).toContain('remoteConnect.clientDetailsUnavailable'); - expect(dialog().textContent).not.toContain('remoteConnect.clientCount:'); - await click(element('#remote-connect-network-tab-openbitfun_server')); - expect(element('[data-openbitfun-part="connections"]').textContent).toContain('remoteConnect.clientCount:0'); - expect(element('[data-openbitfun-part="connections"]').querySelectorAll('li')).toHaveLength(0); + expect(element('#remote-connect-network-tabpanel')).toBeDefined(); + boundary.loggedIn = false; + await render('network'); + expect(document.querySelector('#remote-connect-network-tabpanel')).toBeNull(); }); - it('allows connection setup and shows live status without a selected workspace', async () => { - boundary.hasWorkspace = false; - await render(); - expect((overviewNetwork() as HTMLButtonElement).disabled).toBe(false); - expect(overviewNetwork().textContent).toContain('remoteConnect.notConnected'); - const bot = element('[data-openbitfun-part="overviewAction"][data-openbitfun-group="bot"]') as HTMLButtonElement; - expect(bot.disabled).toBe(false); - expect(bot.textContent).toContain('remoteConnect.stateConnected'); - await click(bot); - expect(element('#remote-connect-bot-tabpanel')).toBeDefined(); - await clickText('remoteConnect.backToOverview'); - await generateInvitation(); - expect(boundary.startConnection).toHaveBeenCalledOnce(); - expect(cardStatus()).toBe('remoteConnect.stateWaiting'); - boundary.backend = { ...boundary.backend!, account_control_connected: true, account_control_relay_url: relayA }; + it.each([ + ['openbitfun_server', relayA], ['lan', 'http://192.168.1.2:9700'], + ] as const)('uses identical connection, QR, presence and disconnect actions for %s', async (method, relay) => { + await render('network'); + await click(element(`#remote-connect-network-tab-${method}`)); + await clickText('remoteConnect.showConnectionCode'); await tick(); - await clickText('remoteConnect.backToOverview'); - expect(overviewNetwork().textContent).toContain('remoteConnect.stateConnected'); - }); - - it.each(['network', 'bot'] as const)('keeps the contextual %s destination open without a selected workspace', async group => { - boundary.hasWorkspace = false; - await render(group); - expect(element(`#remote-connect-${group}-tabpanel`)).toBeDefined(); - expect(document.querySelector('[data-openbitfun-part="overviewAction"]')).toBeNull(); - }); - - it('keeps an active invitation when the selected workspace is cleared', async () => { - await render(); - await generateInvitation(); - boundary.hasWorkspace = false; - await render(); + expect(boundary.startConnection).toHaveBeenCalledWith(method, method === 'lan' ? '192.168.1.2' : undefined); + expect(dialog().textContent).toContain(invitation(relay).qr_url); expect(cardStatus()).toBe('remoteConnect.stateWaiting'); + expect(element('[data-openbitfun-part="connections"]').textContent).not.toContain('remoteConnect.noConnectedClients'); + expect(attachedMobile()).toBeNull(); + expect(dialog().textContent).not.toContain('remoteConnect.accountConnectedHint'); + boundary.backend = { ...boundary.backend!, clients: [{ id: 'phone', name: 'Safari · iOS' }, { id: 'browser', name: 'Chrome' }] }; + await tick(); + expect(element('[data-openbitfun-part="connections"]').querySelectorAll('li')).toHaveLength(2); + expect(cardStatus()).toBe('remoteConnect.stateConnected'); + expect(dialog().textContent).toContain('remoteConnect.accountConnectedHint'); + expect(attachedMobile()).not.toBeNull(); + await click(element('button[aria-label="remoteConnect.copyUrl"]')); + expect(boundary.copyText).toHaveBeenCalledWith(invitation(relay).qr_url); + await clickText('remoteConnect.cancelInvitation'); + expect(document.querySelector('[data-openbitfun-part="pairingCard"]')).toBeNull(); expect(boundary.stopConnection).not.toHaveBeenCalled(); + expect(attachedMobile()).not.toBeNull(); + await clickText('remoteConnect.disconnect'); + expect(boundary.stopConnection).toHaveBeenCalledOnce(); + expect(boundary.stopBot).not.toHaveBeenCalled(); + expect(attachedMobile()).toBeNull(); + expect(attachedBot()).not.toBeNull(); }); - it('keeps QR, overview, close/reopen and sidebar connected, then permits another invitation', async () => { + it('retains the Relay route across overview navigation and closing the dialog', async () => { await render(); - expect(overviewNetwork().textContent).toContain('remoteConnect.notConnected'); - expect(attachedMobile()).toBeNull(); - expect(attachedBot()).not.toBeNull(); await generateInvitation(); - expect(cardStatus()).toBe('remoteConnect.stateWaiting'); - boundary.backend = { ...boundary.backend!, account_control_connected: true, account_control_relay_url: relayA }; + boundary.backend = { ...boundary.backend!, clients: [{ id: 'phone', name: 'Safari' }] }; await tick(); - expect(cardStatus()).toBe('remoteConnect.stateConnected'); - expect(attachedMobile()).not.toBeNull(); - expect(attachedBot()).not.toBeNull(); - expect(dialog().textContent).toContain('remoteConnect.cancelInvitation'); - expect(dialog().textContent).toContain('remoteConnect.accountConnectedHint'); - expect(dialog().textContent).not.toContain('remoteConnect.disconnect'); await clickText('remoteConnect.backToOverview'); expect(overviewNetwork().textContent).toContain('remoteConnect.stateConnected'); - expect(boundary.stopConnection).toHaveBeenCalledOnce(); - expect(boundary.stopBot).not.toHaveBeenCalled(); - expect(boundary.backend!.account_control_connected).toBe(true); + expect(boundary.stopConnection).not.toHaveBeenCalled(); await closeDialog(); expect(attachedMobile()).not.toBeNull(); - await click(element('[data-testid="nav-footer-device-status"]')); - const devices = element('[data-testid="nav-device-status-connected-devices"]'); - expect(devices.querySelector('[data-openbitfun-device-kind="mobile"] strong')?.textContent).toBe('remoteConnect.mobileBrowserTitle'); - expect(devices.querySelector('[data-openbitfun-device-kind="message-app"] strong')?.textContent).toBe('remoteConnect.weixin'); - expect(document.querySelector('[data-testid="nav-device-connection-service"]')).toBeNull(); - await click(element('[data-testid="nav-footer-device-status"]')); await click(element('[data-testid="reopen-remote-connect"]')); - expect(overviewNetwork().textContent).toContain('remoteConnect.stateConnected'); await openNetwork(); + await click(element('#remote-connect-network-tab-openbitfun_server')); expect(cardStatus()).toBe('remoteConnect.stateConnected'); - expect((element('input[type="url"]') as HTMLInputElement).value).toBe(relayA); - expect(dialog().textContent).not.toContain('remoteConnect.disconnect'); await clickText('remoteConnect.showConnectionCode'); expect(boundary.startConnection).toHaveBeenCalledTimes(2); - expect(cardStatus()).toBe('remoteConnect.stateConnected'); }); - it('propagates expiry and reconnect while pairing, on the overview, and after closing the dialog', async () => { + it('keeps an invitation through disconnect and reconnect while presence follows the live route', async () => { await render(); await generateInvitation(); for (const connected of [true, false, true]) { - boundary.backend = { ...boundary.backend!, account_control_connected: connected, account_control_relay_url: relayA }; + boundary.backend = { ...boundary.backend!, relay_connected: connected, clients: [{ id: 'phone', name: 'Safari' }] }; await tick(); expect(cardStatus()).toBe(connected ? 'remoteConnect.stateConnected' : 'remoteConnect.stateWaiting'); + expect(dialog().textContent).toContain(invitation().qr_url); expect(Boolean(attachedMobile())).toBe(connected); - expect(attachedBot()).not.toBeNull(); } - await clickText('remoteConnect.backToOverview'); - boundary.backend = { ...boundary.backend!, account_control_connected: false }; - await tick(); - expect(overviewNetwork().textContent).toContain('remoteConnect.notConnected'); - boundary.backend = { ...boundary.backend!, account_control_connected: true }; - await tick(); - expect(overviewNetwork().textContent).toContain('remoteConnect.stateConnected'); - await closeDialog(); - boundary.backend = { ...boundary.backend!, account_control_connected: false }; - await tick(15_000); - expect(attachedMobile()).toBeNull(); - expect(attachedBot()).not.toBeNull(); - boundary.backend = { ...boundary.backend!, account_control_connected: true }; - await tick(15_000); - expect(attachedMobile()).not.toBeNull(); - }); - - it('keeps configuration selectable beside an account connection and does not mark another relay path connected', async () => { - boundary.backend = status({ account_control_connected: true, account_control_relay_url: relayA }); - await render(); - await openNetwork(); - await click(element('#remote-connect-network-tab-lan')); - await tick(4000); - expect(element('#remote-connect-network-tab-lan').getAttribute('aria-selected')).toBe('true'); - await clickText('remoteConnect.backToOverview'); - await generateInvitation(relayB); - expect(boundary.startConnection).toHaveBeenCalledWith('custom_server', relayB, undefined); - expect(cardStatus()).toBe('remoteConnect.stateWaiting'); - await tick(4000); - expect(cardStatus()).toBe('remoteConnect.stateWaiting'); - expect(element('#remote-connect-network-tab-custom_server').getAttribute('aria-selected')).toBe('true'); - expect((element('#remote-connect-network-tab-lan') as HTMLButtonElement).disabled).toBe(true); - expect(attachedMobile()).not.toBeNull(); - await clickText('remoteConnect.cancelAndBack'); - expect(overviewNetwork().textContent).toContain('remoteConnect.stateConnected'); - expect(boundary.backend!.account_control_relay_url).toBe(relayA); - }); - - it('keeps legacy room disconnect explicit and independent of a coexisting account route and WeChat', async () => { - boundary.backend = status({ is_connected: true, pairing_state: 'connected', active_method: 'OpenBitFunServer', peer_device_name: 'Phone' }); - delete boundary.backend.account_control_connected; - delete boundary.backend.account_control_relay_url; - await render(); - expect(overviewNetwork().textContent).toContain('remoteConnect.stateConnected'); - expect(attachedMobile()).not.toBeNull(); - await openNetwork(); - await clickText('remoteConnect.backToOverview'); - expect(boundary.stopConnection).not.toHaveBeenCalled(); - boundary.backend = { ...boundary.backend!, account_control_connected: true, account_control_relay_url: relayA }; - await tick(); - await openNetwork(); - await clickText('remoteConnect.disconnect'); - expect(boundary.stopConnection).toHaveBeenCalledOnce(); - expect(boundary.stopBot).not.toHaveBeenCalled(); - expect(boundary.backend!.account_control_connected).toBe(true); - expect(attachedMobile()).not.toBeNull(); - await clickText('remoteConnect.backToOverview'); - expect(overviewNetwork().textContent).toContain('remoteConnect.stateConnected'); }); - it('does not override a selected method when account control appears during the initial status probes', async () => { - boundary.backend = status({ bot_connected: null }); - await render(); - await openNetwork(); - await click(element('#remote-connect-network-tab-custom_server')); - await click(element('#remote-connect-network-tab-lan')); - boundary.backend = { ...boundary.backend!, account_control_connected: true, account_control_relay_url: relayA }; - await tick(4000); - expect(element('#remote-connect-network-tab-lan').getAttribute('aria-selected')).toBe('true'); - expect(attachedMobile()).not.toBeNull(); - expect(boundary.startConnection).not.toHaveBeenCalled(); - }); - - it('preserves a fresh QR across initial probes when no chat app is connected', async () => { - boundary.backend = status({ bot_connected: null }); + it('allows account connection without a selected workspace', async () => { + boundary.hasWorkspace = false; await render(); + expect((overviewNetwork() as HTMLButtonElement).disabled).toBe(false); await generateInvitation(); - await tick(4000); + await tick(); expect(cardStatus()).toBe('remoteConnect.stateWaiting'); - expect(dialog().textContent).toContain(invitation().qr_url); - expect(dialog().querySelector('.openbitfun-remote-connect__qr-box svg')).not.toBeNull(); - expect(boundary.startConnection).toHaveBeenCalledOnce(); - expect(boundary.stopConnection).not.toHaveBeenCalled(); }); - it('preserves an explicit method selection while the first dialog read refreshes a cached sidebar snapshot', async () => { - boundary.backend = status({ bot_connected: null }); - await remoteConnectStatusSource.refresh(); + it('preserves a selected method when the initial status read finishes late', async () => { const pending = deferred(); boundary.getStatus.mockReturnValueOnce(pending.promise); - await render(); - await openNetwork(); + await render('network'); await click(element('#remote-connect-network-tab-lan')); - boundary.backend = { ...boundary.backend!, account_control_connected: true, account_control_relay_url: relayA }; + boundary.backend = status({ relay_connected: true, relay_url: relayA, active_method: 'openbitfun_server' }); await act(async () => { pending.resolve(boundary.backend!); }); expect(element('#remote-connect-network-tab-lan').getAttribute('aria-selected')).toBe('true'); - expect(attachedMobile()).not.toBeNull(); - }); - - it('publishes a slow read to both surfaces and shows unavailable instead of waiting when a later read fails', async () => { - const pending = deferred(); - boundary.getStatus.mockReturnValueOnce(pending.promise); - await render(); - expect(overviewNetwork().textContent).toContain('remoteConnect.statusChecking'); - await tick(4000); - expect(boundary.getStatus).toHaveBeenCalledOnce(); - boundary.backend = status({ account_control_connected: true, account_control_relay_url: relayA }); - await act(async () => { pending.resolve(boundary.backend!); }); - expect(overviewNetwork().textContent).toContain('remoteConnect.stateConnected'); - expect(attachedMobile()).not.toBeNull(); - boundary.getStatus.mockRejectedValueOnce(new Error('transport temporarily unavailable')); - await tick(); - expect(overviewNetwork().textContent).toContain('remoteConnect.statusUnavailable'); - expect(overviewNetwork().textContent).not.toContain('remoteConnect.notConnected'); - await openNetwork(); - expect(cardStatus()).toBe('remoteConnect.statusUnavailable'); - await tick(); - expect(cardStatus()).toBe('remoteConnect.stateConnected'); - expect(attachedMobile()).not.toBeNull(); - }); - - it('cleans up an invitation created after closing or unmounting without publishing it back into the UI', async () => { - for (const unmount of [false, true]) { - const pending = deferred(); - boundary.startConnection.mockReturnValueOnce(pending.promise); - await render(); - await generateInvitation(); - if (unmount) { - await act(async () => { root.unmount(); }); - mounted = false; - } else await closeDialog(); - const stopCount = boundary.stopConnection.mock.calls.length; - await act(async () => { - boundary.backend = status({ active_method: `CustomServer { url: "${relayA}" }`, pairing_state: 'waiting_for_scan' }); - pending.resolve(invitation()); - }); - expect(boundary.stopConnection).toHaveBeenCalledTimes(stopCount + 1); - expect(boundary.backend!.pairing_state).toBe('idle'); - expect(document.querySelector('[data-openbitfun-part="pairingCard"]')).toBeNull(); - expect(attachedMobile()).toBeNull(); - if (!unmount) await click(element('[data-testid="reopen-remote-connect"]')); - } - }); - - it('keeps a bot read failure explicit without opening another login, and exposes retry in the sidebar', async () => { - await render(); - await click(element('[data-openbitfun-part="overviewAction"][data-openbitfun-group="bot"]')); - expect(dialog().textContent).toContain('remoteConnect.disconnect'); - boundary.getStatus.mockRejectedValue(new Error('status unavailable')); - await tick(); - expect(cardStatus()).toBe('remoteConnect.statusUnavailable'); - expect(dialog().textContent).not.toContain('remoteConnect.botWeixinQrButton'); - expect(dialog().textContent).not.toContain('remoteConnect.getPairingCode'); - expect(dialog().querySelector('input')).toBeNull(); - await clickText('remoteConnect.backToOverview'); - expect(element('[data-openbitfun-part="overviewAction"][data-openbitfun-group="bot"]').textContent).toContain('remoteConnect.statusUnavailable'); - await closeDialog(); - await click(element('[data-testid="nav-footer-device-status"]')); - const notice = element('.openbitfun-device-overview__notice'); - expect(notice.textContent).toBe('deviceOverview.statusUnavailable'); - boundary.getStatus.mockImplementation(async () => ({ ...boundary.backend! })); - await click(notice); - expect(document.querySelector('.openbitfun-device-overview__notice')).toBeNull(); - expect(attachedBot()).not.toBeNull(); - expect(boundary.stopBot).not.toHaveBeenCalled(); expect(boundary.startConnection).not.toHaveBeenCalled(); }); - it('preserves an invitation during a failed read and restores its confirmed account state afterward', async () => { + it('reports failed reads without erasing a QR or claiming disconnection', async () => { await render(); await generateInvitation(); - boundary.backend = { ...boundary.backend!, account_control_connected: true, account_control_relay_url: relayA }; await tick(); - expect(cardStatus()).toBe('remoteConnect.stateConnected'); boundary.getStatus.mockRejectedValueOnce(new Error('status unavailable')); await tick(); expect(cardStatus()).toBe('remoteConnect.statusUnavailable'); expect(dialog().textContent).toContain(invitation().qr_url); - expect(dialog().textContent).toContain('remoteConnect.cancelInvitation'); await tick(); - expect(cardStatus()).toBe('remoteConnect.stateConnected'); + expect(cardStatus()).toBe('remoteConnect.stateWaiting'); expect(boundary.stopConnection).not.toHaveBeenCalled(); }); - it('ignores connected replies begun before and during the actual Disconnect handler', async () => { - const connected = status({ is_connected: true, pairing_state: 'connected', active_method: 'OpenBitFunServer' }); - boundary.backend = connected; + it('cleans up a connection that finishes after the dialog closes', async () => { + const pending = deferred(); + boundary.startConnection.mockReturnValueOnce(pending.promise); await render(); - await openNetwork(); + await generateInvitation(); + await closeDialog(); + await act(async () => { + boundary.backend = status({ relay_connected: true, relay_url: relayA, active_method: 'openbitfun_server' }); + pending.resolve(invitation()); + }); + expect(boundary.stopConnection).toHaveBeenCalledOnce(); + expect(boundary.backend!.relay_connected).toBe(false); + expect(document.querySelector('[data-openbitfun-part="pairingCard"]')).toBeNull(); + }); + + it('fences stale connected replies when an explicit disconnect wins', async () => { + const connected = status({ relay_connected: true, relay_url: relayA, active_method: 'openbitfun_server', clients: [{ id: 'phone', name: 'Safari' }] }); + boundary.backend = connected; + await render('network'); const before = deferred(); const during = deferred(); boundary.getStatus.mockReturnValueOnce(before.promise).mockReturnValueOnce(during.promise); await tick(); const stop = deferred(); - boundary.stopConnection.mockImplementationOnce(async () => { - await stop.promise; - boundary.backend = status(); - }); + boundary.stopConnection.mockImplementationOnce(async () => { await stop.promise; boundary.backend = status(); }); await clickText('remoteConnect.disconnect'); await tick(); await act(async () => { stop.resolve(); }); - expect(attachedMobile()).toBeNull(); - expect(attachedBot()).not.toBeNull(); - expect(dialog().textContent).toContain('remoteConnect.showConnectionCode'); - await act(async () => { - during.resolve(connected); - before.resolve(connected); - }); + await act(async () => { during.resolve(connected); before.resolve(connected); }); expect(attachedMobile()).toBeNull(); expect(dialog().textContent).not.toContain('remoteConnect.disconnect'); - await clickText('remoteConnect.backToOverview'); - expect(overviewNetwork().textContent).toContain('remoteConnect.notConnected'); expect(boundary.stopConnection).toHaveBeenCalledOnce(); }); }); diff --git a/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.tsx b/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.tsx index 57ac90390a..9a5768edb9 100644 --- a/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.tsx +++ b/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.tsx @@ -1,11 +1,9 @@ /** * Device & Connections center. * - * The overview keeps account-backed devices and account-free access in one - * coherent place without presenting their different trust models as peer - * modes. Detail views retain the complete existing capability set: - * - My devices (account, sync, and peer-device control) - * - Phone or browser (LAN / ngrok / OpenBitFun Relay / self-hosted) + * Every connection uses the signed-in GitHub account. The detail views expose: + * - My devices (account and peer-device control) + * - Phone or browser (official or locally hosted Relay) * - Chat apps (Telegram / Feishu / WeChat) * Connections are host-level services and do not require a selected project; * remote clients can use the primary assistant workspace. @@ -32,21 +30,19 @@ import { OverflowText, } from '@openbitfun/ui'; import React, { useState, useEffect, useCallback, useRef } from 'react'; import { QRCodeSVG } from 'qrcode.react'; -import { Monitor, MonitorSmartphone, Smartphone } from 'lucide-react'; +import { MessageCircle, Monitor, MonitorSmartphone, Smartphone } from 'lucide-react'; import { useI18n } from '@/infrastructure/i18n'; import { getLocaleFallbackChain, type LocaleId } from '@/infrastructure/i18n/presets'; -import { confirmWarning } from '@/infrastructure/confirm-dialog'; import { systemAPI } from '@/infrastructure/api/service-api/SystemAPI'; import { api } from '@/infrastructure/api/service-api/ApiClient'; import { useAccountLoginState } from '@/infrastructure/account/useAccountLoginState'; import { remoteConnectStatusSource, useRemoteConnectStatus } from '@/infrastructure/remote-connect/remoteConnectStatus'; -import { OFFICIAL_RELAY_URL, relayUrlFromMethod, remoteNetworkMethod, selectRemoteNetworkConnection, type RemoteNetworkMethod } from '@/infrastructure/remote-connect/remoteConnectionState'; +import { isDeviceInvitation, invitationRelayUrl, OFFICIAL_RELAY_URL, selectRemoteNetworkConnection, type RemoteNetworkMethod } from '@/infrastructure/remote-connect/remoteConnectionState'; import { useNotification } from '@/shared/notification-system'; import { copyTextToClipboard } from '@/shared/utils/textSelection'; import { AccountPanel } from './AccountPanel'; import { remoteConnectAPI, - remotePairingStateName, type ConnectionResult, type RemoteConnectStatus, type LanNetworkInterface, @@ -58,8 +54,6 @@ import { getRemoteConnectDisclaimerAgreed, setRemoteConnectDisclaimerAgreed, } from './remoteConnectDisclaimerStorage'; -import { RelayDeployWizard } from '@/features/relay-deploy'; -import type { RelayDeployResult } from '@/features/relay-deploy'; import { stopAfterPendingStart, updateIfOperationCurrent, @@ -97,9 +91,7 @@ function isWeixinRasterQrSrc(raw: string): boolean { const NETWORK_TABS: { id: NetworkTab; labelKey: string }[] = [ { id: 'lan', labelKey: 'remoteConnect.methodSameNetwork' }, - { id: 'ngrok', labelKey: 'remoteConnect.methodNgrok' }, { id: 'openbitfun_server', labelKey: 'remoteConnect.methodOpenBitFunRelay' }, - { id: 'custom_server', labelKey: 'remoteConnect.methodSelfHosted' }, ]; const BOT_TABS: { id: BotTab; label: string }[] = [ @@ -108,7 +100,6 @@ const BOT_TABS: { id: BotTab; label: string }[] = [ { id: 'weixin', label: '' }, ]; -const NGROK_SETUP_URL = 'https://dashboard.ngrok.com/get-started/setup'; const FEISHU_SETUP_GUIDE_URLS = { 'zh-CN': 'https://github.com/GCWing/OpenBitFun/blob/main/docs/remote-connect/feishu-bot-setup.zh-CN.md', 'en-US': 'https://github.com/GCWing/OpenBitFun/blob/main/docs/remote-connect/feishu-bot-setup.md', @@ -123,24 +114,7 @@ function pickLocalizedUrl(urls: Partial>, locale: Local return urls['en-US'] ?? Object.values(urls)[0] ?? ''; } -function parseRelayServer(value: string): URL | null { - try { - const url = new URL(value.trim()); - if (!['http:', 'https:'].includes(url.protocol) - || !url.hostname - || url.username - || url.password - || url.search - || url.hash) { - return null; - } - return url; - } catch { - return null; - } -} -const methodToNetworkTab = remoteNetworkMethod; const botInfoToBotTab = (info: string | null | undefined): BotTab | null => { if (!info) return null; @@ -192,11 +166,8 @@ export const RemoteConnectDialog: React.FC = ({ const [showDisclaimer, setShowDisclaimer] = useState(false); const [hasAgreedDisclaimer, setHasAgreedDisclaimer] = useState(() => getRemoteConnectDisclaimerAgreed()); const [botVerboseMode, setBotVerboseMode] = useState(false); - const [showRelayDeploy, setShowRelayDeploy] = useState(false); - const [accountUsername, setAccountUsername] = useState(null); const [qrCopied, setQrCopied] = useState(false); - const [customUrl, setCustomUrl] = useState(''); const [tgToken, setTgToken] = useState(''); const [feishuAppId, setFeishuAppId] = useState(''); const [feishuAppSecret, setFeishuAppSecret] = useState(''); @@ -211,7 +182,6 @@ export const RemoteConnectDialog: React.FC = ({ const [weixinQrPollNonce, setWeixinQrPollNonce] = useState(0); const formSnapshotRef = useRef({ - customUrl: '', tgToken: '', feishuAppId: '', feishuAppSecret: '', @@ -248,9 +218,9 @@ export const RemoteConnectDialog: React.FC = ({ operationGenerationRef.current += 1; const currentStatus = remoteConnectStatusSource.getSnapshot().status; const candidateOwner = pendingOwnerRef.current ?? connectionOwnerRef.current; - // Leaving a view cancels only its unfinished invitation. A completed room - // or bot connection requires its explicit Disconnect action. - const owner = (candidateOwner === 'network' && selectRemoteNetworkConnection(currentStatus).roomConnected) + // Closing an invitation preserves an established account route. + // Disconnect is an explicit action for both Relay endpoints. + const owner = (candidateOwner === 'network' && !pendingStartRef.current && selectRemoteNetworkConnection(currentStatus).connected) || (candidateOwner === 'bot' && currentStatus?.bot_connected) ? null : candidateOwner; const pendingStart = pendingStartRef.current; @@ -331,25 +301,27 @@ export const RemoteConnectDialog: React.FC = ({ const applyStatus = useCallback((nextStatus: RemoteConnectStatus, restoreSelection = false) => { const network = selectRemoteNetworkConnection(nextStatus, connectionResultRef.current); + // Keep the device invitation visible while the route remains available. + const deviceInvitation = isDeviceInvitation(connectionResultRef.current); // Relay and bot connections can coexist. Restore both selected subtabs // before choosing which group to show, otherwise the bot-first open path // can leave a connected OpenBitFun Server relay rendering the default LAN UI. const hasPendingInvitation = connectionOwnerRef.current === 'network' && connectionResultRef.current !== null; - if (network.roomConnected || (restoreSelection && network.accountConnected - && (!hasPendingInvitation || network.invitationAccountConnected))) { + if (!deviceInvitation && restoreSelection && network.connected + && (!hasPendingInvitation || network.invitationConnected)) { const connectedTab = network.method; if (connectedTab) setNetworkTab(connectedTab); } const connectedBot = botInfoToBotTab(nextStatus.bot_connected); if (connectedBot) setBotTab(connectedBot); const owner = connectionOwnerRef.current; - if ((owner === 'network' && network.roomConnected) || (owner === 'bot' && connectedBot)) { + if (owner === 'bot' && connectedBot) { pendingOwnerRef.current = null; connectionOwnerRef.current = null; setConnectionOwner(null); setConnectionResult(null); - } else if (owner === 'network' && !nextStatus.active_method && !pendingStartRef.current) { + } else if (owner === 'network' && !deviceInvitation && !nextStatus.active_method && !pendingStartRef.current) { pendingOwnerRef.current = null; connectionOwnerRef.current = null; setConnectionOwner(null); @@ -399,30 +371,6 @@ export const RemoteConnectDialog: React.FC = ({ restoreSelection = false; setBotVerboseMode(s.bot_verbose_mode); - if (!pendingOwnerRef.current && !connectionOwnerRef.current && ['waiting_for_scan', 'verifying', 'handshaking'].includes( - remotePairingStateName(s.pairing_state), - )) { - const tab = methodToNetworkTab(s.active_method); - if (!selectRemoteNetworkConnection(s).connected) setActiveView('network'); - if (tab) setNetworkTab(tab); - pendingOwnerRef.current = 'network'; - connectionOwnerRef.current = 'network'; - setConnectionOwner('network'); - // Status cannot recover the original QR payload. Restore an - // explicit in-progress surface with a cancel action instead of - // silently showing the configuration form or restarting pairing. - setConnectionResult({ - method: s.active_method ?? tab ?? 'relay', - qr_data: null, - qr_svg: null, - qr_url: null, - bot_pairing_code: null, - bot_link: null, - pairing_state: s.pairing_state, - }); - startPolling('relay'); - return; - } if (selectRemoteNetworkConnection(s).connected || s.bot_connected) return; } catch { /* ignore */ } if (attempt < 2) { @@ -472,7 +420,6 @@ export const RemoteConnectDialog: React.FC = ({ try { const formState = await remoteConnectAPI.getFormState(); if (cancelled) return; - setCustomUrl(formState.custom_server_url ?? ''); setTgToken(formState.telegram_bot_token ?? ''); setFeishuAppId(formState.feishu_app_id ?? ''); setFeishuAppSecret(formState.feishu_app_secret ?? ''); @@ -489,27 +436,18 @@ export const RemoteConnectDialog: React.FC = ({ }; }, [isOpen, hasAgreedDisclaimer]); - // Keep the Self-Hosted server URL in sync with account login state. The - // backend already persists the mirrored value; this refreshes the input - // while the dialog is open (fill on login, clear on logout). + // Refresh connection status when the active identity changes. useEffect(() => { const unlisten = api.listen<{ logged_in: boolean; relay_url?: string }>( 'account://login-state', - (payload) => { - if (payload?.logged_in && payload.relay_url) { - setCustomUrl(payload.relay_url); - } else if (payload && !payload.logged_in) { - setCustomUrl(''); - } - // Account changes rotate an unpaired QR invitation, but an established - // room is an independent control channel and stays connected. Refresh - // first, then clear only UI state that the backend actually retired. + () => { + // Invitations belong to the active account route. remoteConnectStatusSource.invalidate(); void remoteConnectStatusSource.refresh().then((nextStatus) => { if (!nextStatus) return; if (!isOpenRef.current) return; applyStatus(nextStatus); - if (remotePairingStateName(nextStatus.pairing_state) !== 'connected') { + if (!selectRemoteNetworkConnection(nextStatus, connectionResultRef.current).invitationConnected) { pendingOwnerRef.current = null; connectionOwnerRef.current = null; setConnectionOwner(null); @@ -523,33 +461,13 @@ export const RemoteConnectDialog: React.FC = ({ }; }, [applyStatus]); - // The account status and pairing status intentionally expose opaque UUIDs - // for identity checks. Resolve the persisted, non-secret login hint for the - // user-facing connected state instead. - useEffect(() => { - if (!isOpen || !accountLoggedIn) { - setAccountUsername(null); - return; - } - let cancelled = false; - void remoteConnectAPI.accountGetCredentialHint().then((hint) => { - if (!cancelled) { - setAccountUsername(hint?.username.trim() || null); - } - }); - return () => { - cancelled = true; - }; - }, [accountLoggedIn, isOpen]); - useEffect(() => { formSnapshotRef.current = { - customUrl, tgToken, feishuAppId, feishuAppSecret, }; - }, [customUrl, tgToken, feishuAppId, feishuAppSecret]); + }, [tgToken, feishuAppId, feishuAppSecret]); const prepareAndStartWeixinBotFromQr = useCallback(async ( ilinkToken: string, @@ -558,7 +476,6 @@ export const RemoteConnectDialog: React.FC = ({ ): Promise => { const fs = formSnapshotRef.current; await remoteConnectAPI.setFormState({ - custom_server_url: fs.customUrl, telegram_bot_token: fs.tgToken, feishu_app_id: fs.feishuAppId, feishu_app_secret: fs.feishuAppSecret, @@ -704,6 +621,11 @@ export const RemoteConnectDialog: React.FC = ({ // ── Connection handlers ────────────────────────────────────────── const handleConnect = useCallback(async () => { + if (!accountLoggedIn) { + setActiveView('account'); + return; + } + if (!hasAgreedDisclaimer) { setShowDisclaimer(true); return; @@ -727,27 +649,7 @@ export const RemoteConnectDialog: React.FC = ({ try { await cleanupPromiseRef.current.catch(() => undefined); if (!isCurrent()) return; - if (activeView === 'network' && networkTab === 'custom_server') { - const relayUrl = parseRelayServer(customUrl); - if (!relayUrl) { - setError(t('accountLogin.invalidServer')); - return; - } - const isLoopback = ['localhost', '127.0.0.1', '[::1]', '::1'].includes(relayUrl.hostname); - if (relayUrl.protocol === 'http:' && !isLoopback) { - const confirmed = await confirmWarning( - t('accountLogin.insecureServerTitle'), - t('accountLogin.insecureServerConfirm'), - { - confirmText: t('accountLogin.continueInsecure'), - cancelText: t('accountLogin.cancel'), - }, - ); - if (!confirmed || !isCurrent()) return; - } - } await remoteConnectAPI.setFormState({ - custom_server_url: customUrl, telegram_bot_token: tgToken, feishu_app_id: feishuAppId, feishu_app_secret: feishuAppSecret, @@ -758,7 +660,6 @@ export const RemoteConnectDialog: React.FC = ({ if (!isCurrent()) return; let method: string; - let serverUrl: string | undefined; if (activeView === 'bot') { if (botTab === 'telegram') { @@ -785,11 +686,10 @@ export const RemoteConnectDialog: React.FC = ({ if (!isCurrent()) return; } else { method = networkTab; - if (networkTab === 'custom_server') serverUrl = customUrl || undefined; } const lanIp = networkTab === 'lan' ? (selectedLanIp || undefined) : undefined; remoteConnectStatusSource.invalidateReads(); - const startPromise = remoteConnectAPI.startConnection(method, serverUrl, lanIp); + const startPromise = remoteConnectAPI.startConnection(method, lanIp); const pendingStart = { owner, generation: operationGeneration, promise: startPromise }; pendingStartRef.current = pendingStart; const result = await startPromise; @@ -816,7 +716,7 @@ export const RemoteConnectDialog: React.FC = ({ setLoading(false); } } - }, [activeView, networkTab, botTab, customUrl, tgToken, feishuAppId, feishuAppSecret, weixinIlinkToken, weixinBaseUrl, weixinBotAccountId, selectedLanIp, startPolling, t, hasAgreedDisclaimer]); + }, [accountLoggedIn, activeView, networkTab, botTab, tgToken, feishuAppId, feishuAppSecret, weixinIlinkToken, weixinBaseUrl, weixinBotAccountId, selectedLanIp, startPolling, hasAgreedDisclaimer]); const handleStartWeixinQr = useCallback(async () => { if (!hasAgreedDisclaimer) { @@ -909,23 +809,6 @@ export const RemoteConnectDialog: React.FC = ({ } catch { /* best effort */ } }, [applyStatus, cancelPendingWork]); - const handleOpenNgrokSetup = useCallback(() => { - void systemAPI.openExternal(NGROK_SETUP_URL); - }, []); - - /** Self-Hosted tab entry: open the in-app wizard, never an external README. */ - const handleOpenRelayDeploy = useCallback(() => { - setShowRelayDeploy(true); - }, []); - - const handleRelayDeployRegistered = useCallback((result: RelayDeployResult) => { - setShowRelayDeploy(false); - setCustomUrl(result.relayUrl); - setNetworkTab('custom_server'); - setActiveView('network'); - setError(null); - }, []); - const handleOpenFeishuGuide = useCallback(() => { void systemAPI.openExternal(pickLocalizedUrl(FEISHU_SETUP_GUIDE_URLS, currentLanguage)); }, [currentLanguage]); @@ -962,22 +845,15 @@ export const RemoteConnectDialog: React.FC = ({

{label}

-

- {botTab === 'weixin' - ? t('remoteConnect.botWeixinIntro') - : t('remoteConnect.desc_bot')} -

+ {botTab === 'weixin' &&

+ {t('remoteConnect.botWeixinIntro')} +

}
); }; // ── Sub-tab disabled logic ─────────────────────────────────────── - const isNetworkSubDisabled = (tabId: NetworkTab): boolean => { - if (networkConnection.roomConnected && networkConnection.roomMethod && networkConnection.roomMethod !== tabId) return true; - return false; - }; - const isBotSubDisabled = (tabId: BotTab): boolean => { if (isBotConnected && connectedBotTab && connectedBotTab !== tabId) return true; return false; @@ -987,39 +863,14 @@ export const RemoteConnectDialog: React.FC = ({ const renderErrorBlock = () => { if (!error) return null; - const isNgrokErr = error.includes('ngrok is not installed'); return (

{error}

- {isNgrokErr && ( - - )} +
); }; - const renderConnectedView = ( - onDisconnect: () => void, - username?: string | null, - ) => ( -
-
- {t('remoteConnect.stateConnected')} - {username && ( - - {t('accountLogin.username')}: {username} - - )} -
-

{t('remoteConnect.connectedHint')}

- -
- ); - const handleCopyPairingUrl = useCallback(async () => { if (!connectionResult?.qr_url) return; const copied = await copyTextToClipboard(connectionResult.qr_url); @@ -1043,7 +894,7 @@ export const RemoteConnectDialog: React.FC = ({ qrUrl={connectionResult.qr_url} pairingCode={connectionResult.bot_pairing_code} owner={connectionOwner === 'bot' ? 'bot' : 'network'} - connected={connectionOwner === 'network' && networkConnection.invitationAccountConnected} + connected={connectionOwner === 'network' && networkConnection.invitationConnected} statusState={statusState} copied={qrCopied} onCopyUrl={handleCopyPairingUrl} @@ -1053,8 +904,8 @@ export const RemoteConnectDialog: React.FC = ({ {connectionOwner === 'network' ? t('remoteConnect.cancelInvitation') : t('remoteConnect.cancel')}
- {connectionOwner === 'network' && networkConnection.invitationAccountConnected && ( -

{t('remoteConnect.accountConnectedHint')}

+ {connectionOwner === 'network' && networkConnection.invitationConnected && ( +

{t('remoteConnect.connectedHint')}

)}
); @@ -1062,148 +913,40 @@ export const RemoteConnectDialog: React.FC = ({ // ── Network group content ──────────────────────────────────────── - const NGROK_USAGE_URL = 'https://dashboard.ngrok.com/legacy/usage'; - const networkLabel = (tabId: NetworkTab | null): string | null => { const tab = NETWORK_TABS.find(item => item.id === tabId); return tab ? t(tab.labelKey) : null; }; const renderNetworkContent = () => { - if (statusState !== 'ready' && !connectionResult) { - return {}} />; - } - if (networkTab === 'openbitfun_server' || networkTab === 'custom_server') { - const invitation = connectionOwner === 'network' ? connectionResult : null; - const relayUrl = networkTab === 'openbitfun_server' ? OFFICIAL_RELAY_URL - : invitation ? relayUrlFromMethod(invitation.method) ?? customUrl - : networkConnection.roomConnected ? relayUrlFromMethod(status?.active_method) ?? customUrl - : customUrl; - return ; - } - if (networkConnection.roomConnected && networkConnection.roomMethod === networkTab) { - return ( - <> - {networkTab === 'ngrok' && ( -

- systemAPI.openExternal(NGROK_USAGE_URL)} - onKeyDown={(e) => { if (e.key === 'Enter') systemAPI.openExternal(NGROK_USAGE_URL); }} - > - {t('remoteConnect.ngrokUsageLink')} - -

- )} - {renderConnectedView( - handleDisconnectRelay, - accountUsername, - )} - - ); - } - if (connectionResult && connectionOwner === 'network') { - return renderPairingInProgress(); - } - return ( -
-
-
-
-
-

- {networkTab === 'ngrok' ? ( - <> - {t('remoteConnect.desc_ngrok_prefix')} - { if (e.key === 'Enter') handleOpenNgrokSetup(); }} - > - {t('remoteConnect.desc_ngrok_link')} - - {t('remoteConnect.desc_ngrok_suffix')} - - ) : ( - t(`remoteConnect.desc_${networkTab}`) - )} -

-
-
- {networkTab === 'lan' && (lanNetworkInfo?.availableIps.length || lanNetworkInfo?.gatewayIp) && ( -
- {lanNetworkInfo && lanNetworkInfo.availableIps.length > 0 && ( -
- - {t('remoteConnect.currentIp')} - - } - onClick={() => void copyUrl()} - />} - /> - -
-
+ {settings &&
{settings}
} + {invitation &&
+ +
} + {(count > 0 || !invitation) &&

{t('remoteConnect.connectedClients')}

- {unknown - ? count ? t('remoteConnect.clientCountAtLeast', { count, formattedCount: formatNumber(count) }) : t('remoteConnect.stateConnected') - : t('remoteConnect.clientCount', { count, formattedCount: formatNumber(count) })} + 0 ? 'success' : 'neutral'}>{t('remoteConnect.clientCount', { count, formattedCount: formatNumber(count) })}
{count > 0 &&
    3 ? 0 : undefined}> @@ -102,36 +76,21 @@ export function RemoteNetworkConnections({ {client.name || t('remoteConnect.mobileBrowserTitle')} {t('remoteConnect.clientNumber', { number: formatNumber(index + 1) })} )} - {room &&
  • -
  • } +
} - {unknown &&

{t('remoteConnect.clientDetailsUnavailable')}

} - {!connected &&

{t('remoteConnect.noConnectedClients')}

} -
- {invitation &&
- + {count === 0 &&

{t('remoteConnect.noConnectedClients')}

}
}
{error}
- - {room ? - : invitation ? - : } + {invitation + ? + : } + {connected && }
- {account && invitation &&

{t('remoteConnect.accountConnectedHint')}

} + {connection.invitationConnected && invitation &&

{t('remoteConnect.accountConnectedHint')}

}
; diff --git a/src/web-ui/src/app/components/RemoteConnectDialog/RemotePairingCard.tsx b/src/web-ui/src/app/components/RemoteConnectDialog/RemotePairingCard.tsx index df020dda29..e7b1571062 100644 --- a/src/web-ui/src/app/components/RemoteConnectDialog/RemotePairingCard.tsx +++ b/src/web-ui/src/app/components/RemoteConnectDialog/RemotePairingCard.tsx @@ -1,4 +1,4 @@ -import { OverflowText, Icon, IconButton, StatusPill } from '@openbitfun/ui'; +import { Icon, IconButton, StatusPill } from '@openbitfun/ui'; import { QRCodeSVG } from 'qrcode.react'; import { useI18n } from '@/infrastructure/i18n'; @@ -65,7 +65,7 @@ export function RemotePairingCard({ qrUrl, pairingCode, owner, connected = false {t('remoteConnect.workspaceAddress')}
- {qrUrl} + {qrUrl}
-
-
-
-
+

{t('remoteConnect.scanHint')}

) : owner === 'bot' && pairingCode ? (

{t('remoteConnect.botHint')}

diff --git a/src/web-ui/src/app/components/RemoteConnectDialog/RemoteSessionManager.routing.test.ts b/src/web-ui/src/app/components/RemoteConnectDialog/RemoteSessionManager.routing.test.ts index 3c0af987d8..2c35e3dcfd 100644 --- a/src/web-ui/src/app/components/RemoteConnectDialog/RemoteSessionManager.routing.test.ts +++ b/src/web-ui/src/app/components/RemoteConnectDialog/RemoteSessionManager.routing.test.ts @@ -15,21 +15,27 @@ function deferred() { return { promise, resolve, reject }; } +function clientForTest() { + const client = new RelayHttpClient('https://relay.example.com', { token: 'test', userId: 'user-a', deviceId: 'browser', masterKey: new Uint8Array(32).fill(7) }); + client.setTargetDeviceId('home-device'); + return client; +} + describe('mobile RemoteSessionManager target routing', () => { it('keeps one browser-page identity across heartbeat requests and manager recreation', async () => { - const client = new RelayHttpClient('https://relay.example.com', 'room'); - const send = vi.spyOn(client, 'sendCommand').mockResolvedValue({ resp: 'pong' }); + const client = clientForTest(); + const send = vi.spyOn(client, 'sendDeviceRpc').mockResolvedValue({ resp: 'pong' }); await new RemoteSessionManager(client).ping(); await new RemoteSessionManager(client).ping(); - const first = send.mock.calls[0][0] as { client: { id: string; name: string } }; + const first = send.mock.calls[0][1] as { client: { id: string; name: string } }; expect(first.client.id).toMatch(/^[a-f0-9]{32}$/); expect(first.client.name).toBeTruthy(); - expect(send.mock.calls[1][0]).toEqual(expect.objectContaining({ cmd: 'ping', client: first.client })); + expect(send.mock.calls[1][1]).toEqual(expect.objectContaining({ cmd: 'ping', client: first.client })); }); it('attaches request-proven SSH identity to legacy session rows sharing one path', async () => { - const client = new RelayHttpClient('https://relay.example.com', 'room'); - const send = vi.spyOn(client, 'sendCommand').mockResolvedValue({ + const client = clientForTest(); + const send = vi.spyOn(client, 'sendDeviceRpc').mockResolvedValue({ resp: 'sessions', sessions: [{ session_id: 'legacy-session', workspace_path: '/projects/herdr' }], has_more: false, @@ -39,7 +45,7 @@ describe('mobile RemoteSessionManager target routing', () => { const result = await manager.listSessions('/projects/herdr', 30, 0, '', { remoteConnectionId: `ssh-${host}`, remoteSshHost: `host-${host}`, }); - expect(send).toHaveBeenLastCalledWith(expect.objectContaining({ + expect(send).toHaveBeenLastCalledWith('home-device', expect.objectContaining({ cmd: 'list_sessions', workspace_path: '/projects/herdr', remote_connection_id: `ssh-${host}`, remote_ssh_host: `host-${host}`, }), expect.anything()); @@ -49,58 +55,25 @@ describe('mobile RemoteSessionManager target routing', () => { } }); - it('invalidates an active room request and starts a usable generation after late identity', async () => { - const client = new RelayHttpClient('https://relay.example.com', 'room'); + it('invalidates an in-flight command when a new account logs in', async () => { + const client = clientForTest(); const workspace = deferred(); - const targetChanges: number[] = []; - client.onControlTargetChange((snapshot) => targetChanges.push(snapshot.epoch)); - let workspaceRequestCount = 0; - vi.spyOn(client, 'sendCommand').mockImplementation((command: any) => { - if (command.cmd === 'get_workspace_info') { - workspaceRequestCount += 1; - if (workspaceRequestCount === 1) return workspace.promise; - return Promise.resolve({ - resp: 'workspace_info', - has_workspace: true, - project_name: 'Current home workspace', - }); - } - if (command.cmd === 'get_delegated_identity') { - return Promise.resolve({ - resp: 'delegate_identity', - token: 'late-token', - master_key: btoa(String.fromCharCode(...new Uint8Array(32).fill(7))), - user_id: 'late-user', - device_id: 'home-device', - }); - } - throw new Error(`Unexpected command: ${command.cmd}`); - }); + vi.spyOn(client, 'sendDeviceRpc').mockImplementationOnce(() => workspace.promise) + .mockResolvedValue({ resp: 'workspace_info', has_workspace: true }); const manager = new RemoteSessionManager(client); - const initialEpoch = client.controlTargetEpoch; - - const activeRoomRequest = manager.getWorkspaceInfo(); - await expect(client.requestDelegatedIdentity()).resolves.toBe(true); - expect(client.pairedDeviceId).toBe('home-device'); - expect(client.homeDeviceId).toBe('home-device'); - expect(client.controlTargetEpoch).toBeGreaterThan(initialEpoch); - expect(targetChanges).toEqual([client.controlTargetEpoch]); - - workspace.resolve({ - resp: 'workspace_info', - has_workspace: true, - project_name: 'Home workspace', - }); - await expect(activeRoomRequest).rejects.toBeInstanceOf(RemoteControlTargetChangedError); - await expect(manager.getWorkspaceInfo()).resolves.toMatchObject({ - project_name: 'Current home workspace', - }); + const stale = manager.getWorkspaceInfo(); + client.setAccountIdentity({ token: 'b', userId: 'user-b', deviceId: 'browser', masterKey: new Uint8Array(32).fill(8) }); + expect(client.targetDeviceId).toBeNull(); + workspace.resolve({ resp: 'workspace_info', has_workspace: true }); + await expect(stale).rejects.toBeInstanceOf(RemoteControlTargetChangedError); + client.setTargetDeviceId('new-device'); + await expect(manager.getWorkspaceInfo()).resolves.toMatchObject({ has_workspace: true }); }); - it('invalidates an in-flight room request on disconnect without delegated credentials', async () => { - const client = new RelayHttpClient('https://relay.example.com', 'room'); + it('invalidates an in-flight device request on account disconnect', async () => { + const client = clientForTest(); const workspace = deferred(); - vi.spyOn(client, 'sendCommand').mockImplementation(() => workspace.promise); + vi.spyOn(client, 'sendDeviceRpc').mockImplementation(() => workspace.promise); const manager = new RemoteSessionManager(client); const initialEpoch = client.controlTargetEpoch; @@ -112,29 +85,17 @@ describe('mobile RemoteSessionManager target routing', () => { await expect(activeRoomRequest).rejects.toBeInstanceOf(RemoteControlTargetChangedError); }); - it('never falls back to the home room when a remote target temporarily lacks credentials', async () => { - const client = new RelayHttpClient('https://relay.example.com', 'room'); - client.homeDeviceId = 'home-device'; - client.setPairedDeviceId('remote-device'); - const remoteRequest = vi.spyOn(client, 'sendDeviceRpc') - .mockRejectedValueOnce(new Error('No delegated identity')); - const homeRequest = vi.spyOn(client, 'sendCommand') - .mockResolvedValueOnce({ resp: 'workspace_info', has_workspace: true }); - const manager = new RemoteSessionManager(client); - - await expect(manager.getWorkspaceInfo()).rejects.toThrow('No delegated identity'); - expect(remoteRequest).toHaveBeenCalledWith( - 'remote-device', - expect.objectContaining({ cmd: 'get_workspace_info' }), - { retryable: true }, - ); - expect(homeRequest).not.toHaveBeenCalled(); + it('rejects a missing target without sending any command', async () => { + const client = clientForTest(); + client.setTargetDeviceId(null); + const request = vi.spyOn(client, 'sendDeviceRpc'); + await expect(new RemoteSessionManager(client).getWorkspaceInfo()).rejects.toThrow(); + expect(request).not.toHaveBeenCalled(); }); it('rejects a deferred A response after the control target switches to B', async () => { - const client = new RelayHttpClient('https://relay.example.com', 'room'); - client.homeDeviceId = 'home-device'; - client.setPairedDeviceId('device-a'); + const client = clientForTest(); + client.setTargetDeviceId('device-a'); const responseA = deferred(); vi.spyOn(client, 'sendDeviceRpc').mockImplementation((deviceId) => { if (deviceId === 'device-a') return responseA.promise; @@ -147,7 +108,7 @@ describe('mobile RemoteSessionManager target routing', () => { const manager = new RemoteSessionManager(client); const requestA = manager.getWorkspaceInfo(); - client.setPairedDeviceId('device-b'); + client.setTargetDeviceId('device-b'); await expect(manager.getWorkspaceInfo()).resolves.toMatchObject({ project_name: 'Device B', }); @@ -161,17 +122,16 @@ describe('mobile RemoteSessionManager target routing', () => { }); it('rejects a deferred A error after an A to B to A ABA switch', async () => { - const client = new RelayHttpClient('https://relay.example.com', 'room'); - client.homeDeviceId = 'home-device'; - client.setPairedDeviceId('device-a'); + const client = clientForTest(); + client.setTargetDeviceId('device-a'); const responseA = deferred(); vi.spyOn(client, 'sendDeviceRpc').mockImplementation(() => responseA.promise); const manager = new RemoteSessionManager(client); const firstAEpoch = client.controlTargetEpoch; const requestA = manager.getWorkspaceInfo(); - client.setPairedDeviceId('device-b'); - client.setPairedDeviceId('device-a'); + client.setTargetDeviceId('device-b'); + client.setTargetDeviceId('device-a'); expect(client.controlTargetEpoch).toBeGreaterThan(firstAEpoch); responseA.reject(new Error('Device A request failed')); @@ -180,9 +140,8 @@ describe('mobile RemoteSessionManager target routing', () => { }); it('does not send a later file chunk to B after a download starts on A', async () => { - const client = new RelayHttpClient('https://relay.example.com', 'room'); - client.homeDeviceId = 'home-device'; - client.setPairedDeviceId('device-a'); + const client = clientForTest(); + client.setTargetDeviceId('device-a'); const remoteRequest = vi.spyOn(client, 'sendDeviceRpc').mockResolvedValue({ resp: 'file_chunk', name: 'from-a.txt', @@ -195,7 +154,7 @@ describe('mobile RemoteSessionManager target routing', () => { const manager = new RemoteSessionManager(client); const download = manager.readFile('/tmp/from-a.txt', undefined, () => { - client.setPairedDeviceId('device-b'); + client.setTargetDeviceId('device-b'); }); await expect(download).rejects.toBeInstanceOf(RemoteControlTargetChangedError); @@ -211,9 +170,8 @@ describe('mobile RemoteSessionManager target routing', () => { }); it('bounds a remote reasoning-setting write independently from long-running commands', async () => { - const client = new RelayHttpClient('https://relay.example.com', 'room'); - client.homeDeviceId = 'home-device'; - client.setPairedDeviceId('remote-device'); + const client = clientForTest(); + client.setTargetDeviceId('remote-device'); const remoteRequest = vi.spyOn(client, 'sendDeviceRpc').mockResolvedValue({ resp: 'session_model_updated', session_id: 'session-a', diff --git a/src/web-ui/src/app/components/RemoteConnectDialog/appearance.ts b/src/web-ui/src/app/components/RemoteConnectDialog/appearance.ts index 7fd1ae2db6..51d8127463 100644 --- a/src/web-ui/src/app/components/RemoteConnectDialog/appearance.ts +++ b/src/web-ui/src/app/components/RemoteConnectDialog/appearance.ts @@ -41,17 +41,11 @@ export const remoteAccountPanelAppearanceDescriptor: AppearanceSurfaceDescriptor { id: 'scroll' }, { id: 'form' }, { id: 'actions' }, - { id: 'syncOptions' }, - { id: 'syncOption' }, - { id: 'server' }, - { id: 'syncStatus' }, - { id: 'progressTrack' }, - { id: 'progressFill' }, { id: 'deviceList' }, { id: 'deviceCard' }, ], facets: [ - { id: 'view', attribute: 'data-openbitfun-view', values: ['login', 'overwrite', 'devices'] }, + { id: 'view', attribute: 'data-openbitfun-view', values: ['login', 'devices'] }, ], states: [ { id: 'offline', selector: { kind: 'self', suffix: '[data-openbitfun-state~="offline"]' } }, diff --git a/src/web-ui/src/app/global-search/generated/interactive-capabilities.json b/src/web-ui/src/app/global-search/generated/interactive-capabilities.json index 267f045143..79004fea76 100644 --- a/src/web-ui/src/app/global-search/generated/interactive-capabilities.json +++ b/src/web-ui/src/app/global-search/generated/interactive-capabilities.json @@ -4,7 +4,7 @@ "title": "OpenBitFun Playbook", "origin": "https://playbook.openbitfun.com", "source": "src/shared/interactive-capabilities/catalog.json", - "digest": "409e6dbef7ebceafccc11606e2855227b9a33fa1ed71f5b4608da04e8a23de7d", + "digest": "896281a3cd5cac2b50ec607988e04624224444911ddb2d58ba59a5d7d06493c5", "ownerDigest": "c0e5c187cf62bc6ed06196ce8520b3eb427bf268cf24659b72d2552fb1d99c54", "searchAcceptance": [ { @@ -138,11 +138,11 @@ "features": 22, "settings": 21, "userFacing": 43, - "documentedItems": 322, + "documentedItems": 319, "controlCoverage": { "direct": 48, "delegated": 61, - "interactive": 213, + "interactive": 210, "unsupported": 0 } }, @@ -8666,11 +8666,8 @@ "start-stop-status", "network", "bots", - "relay-wizard", "account", "devices", - "session-sync", - "settings-sync", "peer-device" ], "kind": "query", @@ -8896,52 +8893,6 @@ "eventName": "openbitfun:open-remote-connect" } }, - { - "id": "feature.remote-connect:open:relay-wizard", - "capabilityId": "feature.remote-connect", - "itemIds": [ - "relay-wizard" - ], - "kind": "open", - "risk": "ui", - "executionHost": "presentationSurface", - "availability": { - "desktop": { - "available": true - }, - "cli": { - "available": false, - "reason": "This delivery profile has no live presentation surface" - }, - "peer": { - "available": true, - "requiredCapabilities": [ - "product_control_v1", - "product_control_presentation_v1" - ] - }, - "remoteControl": { - "available": true - }, - "detachedDispatch": { - "available": false, - "reason": "This delivery profile has no live presentation surface" - } - }, - "inputSchema": { - "type": "object", - "additionalProperties": false - }, - "outputSchema": { - "type": "object", - "additionalProperties": true - }, - "openReason": "unstructuredInteraction", - "presentationTarget": { - "kind": "event", - "eventName": "openbitfun:open-remote-connect" - } - }, { "id": "feature.remote-connect:open:account", "capabilityId": "feature.remote-connect", @@ -9034,98 +8985,6 @@ "eventName": "openbitfun:open-remote-connect" } }, - { - "id": "feature.remote-connect:open:session-sync", - "capabilityId": "feature.remote-connect", - "itemIds": [ - "session-sync" - ], - "kind": "open", - "risk": "ui", - "executionHost": "presentationSurface", - "availability": { - "desktop": { - "available": true - }, - "cli": { - "available": false, - "reason": "This delivery profile has no live presentation surface" - }, - "peer": { - "available": true, - "requiredCapabilities": [ - "product_control_v1", - "product_control_presentation_v1" - ] - }, - "remoteControl": { - "available": true - }, - "detachedDispatch": { - "available": false, - "reason": "This delivery profile has no live presentation surface" - } - }, - "inputSchema": { - "type": "object", - "additionalProperties": false - }, - "outputSchema": { - "type": "object", - "additionalProperties": true - }, - "openReason": "unstructuredInteraction", - "presentationTarget": { - "kind": "event", - "eventName": "openbitfun:open-remote-connect" - } - }, - { - "id": "feature.remote-connect:open:settings-sync", - "capabilityId": "feature.remote-connect", - "itemIds": [ - "settings-sync" - ], - "kind": "open", - "risk": "ui", - "executionHost": "presentationSurface", - "availability": { - "desktop": { - "available": true - }, - "cli": { - "available": false, - "reason": "This delivery profile has no live presentation surface" - }, - "peer": { - "available": true, - "requiredCapabilities": [ - "product_control_v1", - "product_control_presentation_v1" - ] - }, - "remoteControl": { - "available": true - }, - "detachedDispatch": { - "available": false, - "reason": "This delivery profile has no live presentation surface" - } - }, - "inputSchema": { - "type": "object", - "additionalProperties": false - }, - "outputSchema": { - "type": "object", - "additionalProperties": true - }, - "openReason": "unstructuredInteraction", - "presentationTarget": { - "kind": "event", - "eventName": "openbitfun:open-remote-connect" - } - }, { "id": "feature.remote-connect:open:peer-device", "capabilityId": "feature.remote-connect", @@ -23826,7 +23685,7 @@ "微信", "多设备", "Peer Device", - "账户同步" + "GitHub" ], "keywordsEn": [ "remote connect", @@ -23837,28 +23696,28 @@ "WeChat", "multi-device", "peer device", - "account sync" + "GitHub" ], "highlightsZh": [ - "通过局域网、Ngrok 或自建 Relay 连接", + "通过局域网或官方 Relay 连接", "接入飞书、Telegram 或微信 Bot", - "登录账户、同步会话并进入 Peer Device Mode" + "使用 GitHub 身份管理设备并进入 Peer Device Mode" ], "highlightsEn": [ - "Connect through LAN, Ngrok, or a self-hosted relay", + "Connect through LAN or the official Relay", "Use Feishu, Telegram, or WeChat bots", - "Sign in, sync sessions, and enter Peer Device Mode" + "Use GitHub identity to manage devices and enter Peer Device Mode" ], "items": [ { "id": "connection-methods", - "titleZh": "选择局域网、Ngrok、官方 Relay、自建 Relay 或自定义服务器", - "titleEn": "Choose LAN, Ngrok, the hosted relay, a self-hosted relay, or a custom server", + "titleZh": "选择局域网或官方 Relay", + "titleEn": "Choose LAN or the official Relay", "control": { "kind": "open", "reasonCode": "unstructuredInteraction", - "reasonZh": "“选择局域网、Ngrok、官方 Relay、自建 Relay 或自定义服务器”由多个实时状态相关步骤组成,目前没有一个能确定完成整个流程的单一结构化 Command;Agent 会打开精确入口,并把后续交互保留在用户可见界面。", - "reasonEn": "“Choose LAN, Ngrok, the hosted relay, a self-hosted relay, or a custom server” spans multiple live-state-dependent steps and currently has no single structured Command that can deterministically complete the whole workflow; the Agent opens the exact entry and keeps the remaining interaction visible to the user." + "reasonZh": "“选择局域网或官方 Relay”需要结合当前网络与设备状态,确认目标主机后才能连接;Agent 打开连接入口,由用户完成选择。", + "reasonEn": "“Choose LAN or the official Relay” depends on the current network and device state and requires the user to confirm the target host; the Agent opens the connection entry for that choice." } }, { @@ -23894,26 +23753,15 @@ "reasonEn": "“Configure Feishu, Telegram, WeChat, and other bots, and stop a bot independently” spans multiple live-state-dependent steps and currently has no single structured Command that can deterministically complete the whole workflow; the Agent opens the exact entry and keeps the remaining interaction visible to the user." } }, - { - "id": "relay-wizard", - "titleZh": "通过向导预检、安装 Docker、部署、注册并验证自建 Relay", - "titleEn": "Preflight, install Docker, deploy, register, and verify a self-hosted relay through the wizard", - "control": { - "kind": "open", - "reasonCode": "unstructuredInteraction", - "reasonZh": "“通过向导预检、安装 Docker、部署、注册并验证自建 Relay”由多个实时状态相关步骤组成,目前没有一个能确定完成整个流程的单一结构化 Command;Agent 会打开精确入口,并把后续交互保留在用户可见界面。", - "reasonEn": "“Preflight, install Docker, deploy, register, and verify a self-hosted relay through the wizard” spans multiple live-state-dependent steps and currently has no single structured Command that can deterministically complete the whole workflow; the Agent opens the exact entry and keeps the remaining interaction visible to the user." - } - }, { "id": "account", - "titleZh": "登录、退出并查看账户状态和凭据提示", - "titleEn": "Sign in, sign out, and inspect account status and credential hints", + "titleZh": "使用 GitHub 登录、退出并查看身份状态", + "titleEn": "Sign in with GitHub, sign out, and inspect identity status", "control": { "kind": "open", "reasonCode": "externalAuth", - "reasonZh": "OpenBitFun 账户登录必须由用户在外部认证页面确认;Agent 可以打开入口并读取非秘密状态,但不能冒充账户持有人。", - "reasonEn": "OpenBitFun account sign-in requires confirmation on an external authentication page; the Agent may open the entry and read non-secret status but cannot impersonate the account holder." + "reasonZh": "GitHub 账户登录必须由用户在外部认证页面确认;Agent 可以打开入口并读取非秘密状态,但不能冒充账户持有人。", + "reasonEn": "GitHub account sign-in requires confirmation on an external authentication page; the Agent may open the entry and read non-secret status but cannot impersonate the account holder." } }, { @@ -23927,28 +23775,6 @@ "reasonEn": "“List, connect, inspect online status, and remove same-account devices” spans multiple live-state-dependent steps and currently has no single structured Command that can deterministically complete the whole workflow; the Agent opens the exact entry and keeps the remaining interaction visible to the user." } }, - { - "id": "session-sync", - "titleZh": "同步、导出、导入、删除或发送会话到另一台设备", - "titleEn": "Sync, export, import, delete, or send sessions to another device", - "control": { - "kind": "open", - "reasonCode": "unstructuredInteraction", - "reasonZh": "“同步、导出、导入、删除或发送会话到另一台设备”由多个实时状态相关步骤组成,目前没有一个能确定完成整个流程的单一结构化 Command;Agent 会打开精确入口,并把后续交互保留在用户可见界面。", - "reasonEn": "“Sync, export, import, delete, or send sessions to another device” spans multiple live-state-dependent steps and currently has no single structured Command that can deterministically complete the whole workflow; the Agent opens the exact entry and keeps the remaining interaction visible to the user." - } - }, - { - "id": "settings-sync", - "titleZh": "在设备间自动或手动同步 OpenBitFun 设置", - "titleEn": "Synchronize OpenBitFun settings across devices automatically or on demand", - "control": { - "kind": "open", - "reasonCode": "unstructuredInteraction", - "reasonZh": "“在设备间自动或手动同步 OpenBitFun 设置”由多个实时状态相关步骤组成,目前没有一个能确定完成整个流程的单一结构化 Command;Agent 会打开精确入口,并把后续交互保留在用户可见界面。", - "reasonEn": "“Synchronize OpenBitFun settings across devices automatically or on demand” spans multiple live-state-dependent steps and currently has no single structured Command that can deterministically complete the whole workflow; the Agent opens the exact entry and keeps the remaining interaction visible to the user." - } - }, { "id": "peer-device", "titleZh": "进入 Peer Device Mode,把另一台 OpenBitFun 设备作为命令与事件数据面", @@ -24001,7 +23827,7 @@ "微信", "多设备", "Peer Device", - "账户同步", + "GitHub", "remote connect", "remote control", "mobile", @@ -24009,31 +23835,24 @@ "WeChat", "multi-device", "peer device", - "account sync", - "通过局域网、Ngrok 或自建 Relay 连接", + "通过局域网或官方 Relay 连接", "接入飞书、Telegram 或微信 Bot", - "登录账户、同步会话并进入 Peer Device Mode", - "Connect through LAN, Ngrok, or a self-hosted relay", + "使用 GitHub 身份管理设备并进入 Peer Device Mode", + "Connect through LAN or the official Relay", "Use Feishu, Telegram, or WeChat bots", - "Sign in, sync sessions, and enter Peer Device Mode", - "选择局域网、Ngrok、官方 Relay、自建 Relay 或自定义服务器", - "Choose LAN, Ngrok, the hosted relay, a self-hosted relay, or a custom server", + "Use GitHub identity to manage devices and enter Peer Device Mode", + "选择局域网或官方 Relay", + "Choose LAN or the official Relay", "启动、停止 Remote Connect 并查看实时连接状态和设备信息", "Start or stop Remote Connect and inspect live status and device information", "查看局域网 IP、网络信息与可分享的连接配置", "Inspect LAN IP, network details, and shareable connection configuration", "配置飞书、Telegram、微信等 Bot 并单独停止 Bot", "Configure Feishu, Telegram, WeChat, and other bots, and stop a bot independently", - "通过向导预检、安装 Docker、部署、注册并验证自建 Relay", - "Preflight, install Docker, deploy, register, and verify a self-hosted relay through the wizard", - "登录、退出并查看账户状态和凭据提示", - "Sign in, sign out, and inspect account status and credential hints", + "使用 GitHub 登录、退出并查看身份状态", + "Sign in with GitHub, sign out, and inspect identity status", "列出、连接、查看在线状态和删除同账户设备", "List, connect, inspect online status, and remove same-account devices", - "同步、导出、导入、删除或发送会话到另一台设备", - "Sync, export, import, delete, or send sessions to another device", - "在设备间自动或手动同步 OpenBitFun 设置", - "Synchronize OpenBitFun settings across devices automatically or on demand", "进入 Peer Device Mode,把另一台 OpenBitFun 设备作为命令与事件数据面", "Enter Peer Device Mode and use another OpenBitFun device as the command and event data plane", "打开 Remote Connect", @@ -24361,12 +24180,12 @@ } ], "stepsZh": [ - "登录 OpenBitFun 账户", + "使用 GitHub 登录", "打开 Pages", "选择页面并确认发布与可见性" ], "stepsEn": [ - "Sign in to a OpenBitFun account", + "Sign in to a GitHub account", "Open Pages", "Choose a page and confirm publishing and visibility" ], diff --git a/src/web-ui/src/app/scenes/miniapps/views/MiniAppLibraryView.tsx b/src/web-ui/src/app/scenes/miniapps/views/MiniAppLibraryView.tsx index e1533519a5..d38e49db7f 100644 --- a/src/web-ui/src/app/scenes/miniapps/views/MiniAppLibraryView.tsx +++ b/src/web-ui/src/app/scenes/miniapps/views/MiniAppLibraryView.tsx @@ -35,7 +35,7 @@ import { openMainSession } from '@/flow_chat/services/sessionActivation'; import { useGallerySceneAutoRefresh } from '@/app/hooks/useGallerySceneAutoRefresh'; import { useSceneManager } from '@/app/hooks/useSceneManager'; import { flowChatSessionConfigForCurrentWorkspace } from '@/app/utils/projectSessionWorkspace'; -import { MarketAccountControls } from '@/features/market-account'; +import { AccountIdentityControls } from '@/features/market-account'; import { flowChatManager } from '@/flow_chat/services/FlowChatManager'; import type { MiniAppMeta, @@ -59,7 +59,7 @@ import { systemAPI } from '@/infrastructure/api/service-api/SystemAPI'; import { getAppearanceOverlayHost } from '@/infrastructure/appearance/runtime/AppearanceOverlayHost'; import { useCurrentWorkspace } from '@/infrastructure/contexts/WorkspaceContext'; import { useI18n } from '@/infrastructure/i18n'; -import { useMarketAccount } from '@/infrastructure/market-account'; +import { useAccountIdentity } from '@/infrastructure/account-identity'; import { useNotification } from '@/shared/notification-system'; import { isRemoteWorkspace } from '@/shared/types'; import { isImeOwnedKeyboardEvent } from '@/shared/utils/ime'; @@ -116,7 +116,7 @@ const MiniAppLibraryView: React.FC = ({ tabs }) => { const { openScene, activateScene, closeScene, openTabs } = useSceneManager(); const { t, formatNumber, currentLanguage } = useI18n('scenes/miniapp'); const miniAppActivities = useMiniAppActivity(); - const { me } = useMarketAccount(); + const { me } = useAccountIdentity(); const [query, setQuery] = useState(''); const [category, setCategory] = useState('all'); @@ -760,7 +760,7 @@ const MiniAppLibraryView: React.FC = ({ tabs }) => { subtitle={t('subtitle')} actions={(
- = ({ tabs }) => { const { openScene, activateScene, openTabs } = useSceneManager(); const upsertApp = useMiniAppStore((state) => state.upsertApp); const setMarketOrigin = useMiniAppStore((state) => state.setMarketOrigin); - const { me } = useMarketAccount(); + const { me } = useAccountIdentity(); const [query, setQuery] = useState(''); const [category, setCategory] = useState<(typeof CATEGORIES)[number]>('all'); const [sort, setSort] = useState('newest'); @@ -283,7 +283,7 @@ const MiniAppMarketView: React.FC = ({ tabs }) => { placeholder={t('market.search')} size="sm" /> - = ({ tabs }) const notification = useNotification(); const { workspace } = useCurrentWorkspace(); const { openScene, activateScene, openTabs } = useSceneManager(); - const { me, resolved: authResolved } = useMarketAccount(); + const { me, resolved: authResolved } = useAccountIdentity(); const [apps, setApps] = useState([]); const [submissions, setSubmissions] = useState([]); const [selectedAppId, setSelectedAppId] = useState(''); @@ -266,7 +266,7 @@ const MiniAppSubmissionsView: React.FC = ({ tabs }) } + action={} /> ); @@ -283,7 +283,7 @@ const MiniAppSubmissionsView: React.FC = ({ tabs }) {t('market.submissions.refresh')} - +
)} /> diff --git a/src/web-ui/src/features/market-account/MarketAccountControls.scss b/src/web-ui/src/features/market-account/AccountIdentityControls.scss similarity index 100% rename from src/web-ui/src/features/market-account/MarketAccountControls.scss rename to src/web-ui/src/features/market-account/AccountIdentityControls.scss diff --git a/src/web-ui/src/features/market-account/MarketAccountControls.test.tsx b/src/web-ui/src/features/market-account/AccountIdentityControls.test.tsx similarity index 88% rename from src/web-ui/src/features/market-account/MarketAccountControls.test.tsx rename to src/web-ui/src/features/market-account/AccountIdentityControls.test.tsx index 59fe43078a..e5fd6990e9 100644 --- a/src/web-ui/src/features/market-account/MarketAccountControls.test.tsx +++ b/src/web-ui/src/features/market-account/AccountIdentityControls.test.tsx @@ -3,8 +3,8 @@ import React, { act } from 'react'; import { createRoot } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { MarketAccountControls } from './MarketAccountControls'; -import { calculateMarketAccountMenuPosition } from './marketAccountMenuPosition'; +import { AccountIdentityControls } from './AccountIdentityControls'; +import { calculateAccountIdentityMenuPosition } from './marketAccountMenuPosition'; const mocks = vi.hoisted(() => ({ account: { @@ -23,18 +23,18 @@ const mocks = vi.hoisted(() => ({ error: vi.fn(), })); -vi.mock('@/infrastructure/market-account', () => ({ - MarketAccountError: class MarketAccountError extends Error { +vi.mock('@/infrastructure/account-identity', () => ({ + AccountIdentityError: class AccountIdentityError extends Error { constructor(public readonly code: string, message: string) { super(message); } }, - marketAccountService: { + accountIdentityService: { signIn: mocks.signIn, cancelSignIn: mocks.cancelSignIn, logout: mocks.logout, }, - useMarketAccount: () => mocks.account, + useAccountIdentity: () => mocks.account, })); vi.mock('@/infrastructure/i18n', () => ({ @@ -64,7 +64,7 @@ vi.mock('@openbitfun/ui', () => ({ DialogTitle: ({ children }: any) =>

{children}

, })); -describe('MarketAccountControls', () => { +describe('AccountIdentityControls', () => { let container: HTMLDivElement; let root: ReturnType; @@ -93,7 +93,7 @@ describe('MarketAccountControls', () => { }); it('opens the shared GitHub login dialog and starts the vault-backed flow', async () => { - await act(async () => root.render()); + await act(async () => root.render()); const signIn = [...container.querySelectorAll('button')] .find(button => button.textContent?.includes('market.signIn')); await act(async () => signIn?.click()); @@ -111,7 +111,7 @@ describe('MarketAccountControls', () => { user: { githubId: 42, login: 'octocat', avatarUrl: 'https://example.com/avatar.png' }, isAdmin: false, }; - await act(async () => root.render()); + await act(async () => root.render()); const trigger = container.querySelector('[aria-haspopup="menu"]'); await act(async () => trigger?.click()); @@ -124,7 +124,7 @@ describe('MarketAccountControls', () => { }); it('keeps the portalled menu aligned to the trigger and inside the viewport', () => { - const position = calculateMarketAccountMenuPosition( + const position = calculateAccountIdentityMenuPosition( { top: 16, right: 218, bottom: 46 }, { width: 230, height: 112 }, { width: 240, height: 180 }, @@ -132,7 +132,7 @@ describe('MarketAccountControls', () => { expect(position).toEqual({ top: 52, left: 8 }); expect( - calculateMarketAccountMenuPosition( + calculateAccountIdentityMenuPosition( { top: 150, right: 218, bottom: 180 }, { width: 230, height: 112 }, { width: 240, height: 200 }, diff --git a/src/web-ui/src/features/market-account/MarketAccountControls.tsx b/src/web-ui/src/features/market-account/AccountIdentityControls.tsx similarity index 92% rename from src/web-ui/src/features/market-account/MarketAccountControls.tsx rename to src/web-ui/src/features/market-account/AccountIdentityControls.tsx index 34b63ec1ec..9136f802a0 100644 --- a/src/web-ui/src/features/market-account/MarketAccountControls.tsx +++ b/src/web-ui/src/features/market-account/AccountIdentityControls.tsx @@ -17,37 +17,37 @@ import { Github, Loader2, LogOut } from 'lucide-react'; import { getAppearanceOverlayHost } from '@/infrastructure/appearance/runtime/AppearanceOverlayHost'; import { useI18n } from '@/infrastructure/i18n'; import { - MarketAccountError, - marketAccountService, - useMarketAccount, -} from '@/infrastructure/market-account'; + AccountIdentityError, + accountIdentityService, + useAccountIdentity, +} from '@/infrastructure/account-identity'; import { useNotification } from '@/shared/notification-system'; import { isImeOwnedKeyboardEvent } from '@/shared/utils/ime'; import { - calculateMarketAccountMenuPosition, - type MarketAccountMenuPosition, + calculateAccountIdentityMenuPosition, + type AccountIdentityMenuPosition, } from './marketAccountMenuPosition'; -import './MarketAccountControls.scss'; +import './AccountIdentityControls.scss'; -export interface MarketAccountControlsProps { +export interface AccountIdentityControlsProps { className?: string; loginOpen?: boolean; onLoginOpenChange?: (open: boolean) => void; onIdentityChanged?: () => void | Promise; } -export function MarketAccountControls({ +export function AccountIdentityControls({ className, loginOpen: controlledLoginOpen, onLoginOpenChange, onIdentityChanged, -}: MarketAccountControlsProps) { +}: AccountIdentityControlsProps) { const { t } = useI18n('scenes/miniapp'); const notification = useNotification(); - const account = useMarketAccount(); + const account = useAccountIdentity(); const [internalLoginOpen, setInternalLoginOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false); - const [menuPosition, setMenuPosition] = useState(null); + const [menuPosition, setMenuPosition] = useState(null); const menuRef = useRef(null); const menuPanelRef = useRef(null); const menuTriggerRef = useRef(null); @@ -63,7 +63,7 @@ export function MarketAccountControls({ const updateMenuPosition = useCallback(() => { if (!menuTriggerRef.current || !menuPanelRef.current) return; setMenuPosition( - calculateMarketAccountMenuPosition( + calculateAccountIdentityMenuPosition( menuTriggerRef.current.getBoundingClientRect(), menuPanelRef.current.getBoundingClientRect(), { width: window.innerWidth, height: window.innerHeight }, @@ -129,7 +129,7 @@ export function MarketAccountControls({ }, [account.me, loginOpen]); const closeLogin = () => { - if (startedHere.current) marketAccountService.cancelSignIn(); + if (startedHere.current) accountIdentityService.cancelSignIn(); startedHere.current = false; setLoginOpen(false); }; @@ -137,14 +137,14 @@ export function MarketAccountControls({ const signIn = async () => { startedHere.current = true; try { - await marketAccountService.signIn(); + await accountIdentityService.signIn(); startedHere.current = false; setLoginOpen(false); await onIdentityChanged?.(); notification.success(t('market.messages.signedIn')); } catch (error) { - if (error instanceof MarketAccountError && error.code === 'cancelled') return; - notification.error(error instanceof MarketAccountError && error.code === 'expired' + if (error instanceof AccountIdentityError && error.code === 'cancelled') return; + notification.error(error instanceof AccountIdentityError && error.code === 'expired' ? t('market.messages.authExpired') : t('market.messages.authFailed', { error: String(error) })); } @@ -153,7 +153,7 @@ export function MarketAccountControls({ const signOut = async () => { setMenuOpen(false); try { - await marketAccountService.logout(); + await accountIdentityService.logout(); await onIdentityChanged?.(); notification.success(t('market.messages.signedOut')); } catch (error) { diff --git a/src/web-ui/src/features/market-account/index.ts b/src/web-ui/src/features/market-account/index.ts index 106933327f..852ce6179b 100644 --- a/src/web-ui/src/features/market-account/index.ts +++ b/src/web-ui/src/features/market-account/index.ts @@ -1 +1 @@ -export * from './MarketAccountControls'; +export * from './AccountIdentityControls'; diff --git a/src/web-ui/src/features/market-account/marketAccountMenuPosition.ts b/src/web-ui/src/features/market-account/marketAccountMenuPosition.ts index 869ef93a4f..9f6534e18f 100644 --- a/src/web-ui/src/features/market-account/marketAccountMenuPosition.ts +++ b/src/web-ui/src/features/market-account/marketAccountMenuPosition.ts @@ -3,16 +3,16 @@ import { type FixedPopoverViewport, } from '@/shared/utils/fixedPopoverViewport'; -export interface MarketAccountMenuPosition { +export interface AccountIdentityMenuPosition { top: number; left: number; } -export function calculateMarketAccountMenuPosition( +export function calculateAccountIdentityMenuPosition( triggerRect: Pick, menuRect: Pick, viewport: FixedPopoverViewport, -): MarketAccountMenuPosition { +): AccountIdentityMenuPosition { return computeFixedPopoverPositionInViewport( { left: triggerRect.right - menuRect.width, diff --git a/src/web-ui/src/features/relay-deploy/README.md b/src/web-ui/src/features/relay-deploy/README.md deleted file mode 100644 index f1b742d2ad..0000000000 --- a/src/web-ui/src/features/relay-deploy/README.md +++ /dev/null @@ -1,141 +0,0 @@ -# One-click Relay Deploy - -Desktop wizard that SSHes to a user-owned Linux host, installs Docker when it -is missing, tries the signed OpenBitFun Relay image, and builds current source -when no usable image is available. Account import remains optional. - -Entry points: - -- Remote Connect → My OpenBitFun → login form → “一键部署到自己的服务器” -- Remote Connect → Network Relay → Self-Hosted → same action (must open this - wizard, not an external README) - -Backend orchestration: -`src/crates/services/services-integrations/src/remote_ssh/relay_deploy.rs` -Desktop Tauri surface: `src/apps/desktop/src/api/relay_deploy_api.rs` - -## Invariants (do not regress) - -1. **One click means install-if-needed, pull-or-build, start.** The deploy action must - continue through Docker Engine installation in the same interactive task. - Prepare Git and Docker Buildx while the PTY can answer sudo prompts. A failure - to prepare source dependencies must not block an available published image. - Docker Compose and host Cargo are not prerequisites. - -2. **Unavailable images fall back to current source.** Missing/unreachable - metadata, a verified descriptor for another image repository, exhausted pull - routes, or a failed image start trigger a source build with a visible terminal - message. Do not accept or rewrite another repository's image. Invalid signatures - or malformed signed metadata still fail after trying the other metadata origin. - Fetch current `main` into a fresh task-owned directory below - `~/.openbitfun/relay-src/`, record its commit, build natively with one Cargo job - by default, and clean only that temporary checkout. Never reset a user's checkout. - -3. **Authenticate the latest image before touching the server.** Desktop reads - the latest `relay-image.json` and `relay-image.json.sig`, verifies the - descriptor using its compiled-in minisign trust root, validates the exact - repository, stable release tag/version, lowercase SHA256 digest, and both - supported platforms, then sends only that trusted repository + digest to the - remote script. The Desktop package version does not pin Relay deployment. - -4. **Always start by digest.** Tags are discovery metadata, not an execution - identity. Image deployment sets `OPENBITFUN_REQUIRE_IMAGE_DIGEST=1`; Docker pulls - and runs `@sha256:...`, so every manifest and layer remains - content-addressed. Source builds start by the immutable local image ID. - -5. **Registry prefixes are transport, not trust roots.** Automatic mode keeps - official GHCR first when a 10-second GitHub byte probe reaches 512 KiB/s; a - slower or unreachable GitHub moves `ghcr.nju.edu.cn/...` and - `m.daocloud.io/ghcr.io/...` ahead of official GHCR. Explicit global/China - choices remain authoritative. Every route uses the same signed digest and - has a bounded attempt before failover. - -6. **The release must be publicly pullable.** `desktop-package.yml` builds one - amd64/arm64 manifest, signs its descriptor, logs out of GHCR, and verifies an - anonymous manifest read. A private package must fail publication rather than - produce a green workflow customers cannot use. - -7. **One implementation, two callers.** Pull, route fallback, container start, - rollback, and health logic live in - `src/apps/relay-server/release-download.sh`; `deploy.sh` sources it and - `relay_deploy.rs` embeds it with `include_str!`. Do not fork that behavior - back into a Rust string template. - `source-build.sh` adds the wizard's source fallback and calls that same - container lifecycle code; it must not fork volumes, health checks, or rollback. - -8. **Preserve the container contract.** Keep container name `openbitfun-relay`, - volumes `relay-server_relay-db` and `relay-server_room-web`, selected port, - `/app/data`, `/app/room-web`, and `/app/relay-admin` stable across upgrades. - -9. **Never stop a healthy Relay before the image is ready.** Pull or build first, then - rename the existing container, start the replacement, and remove the backup - only after `/health` succeeds. Start, cancellation, or health failure must - restore the previous container. Keep container stderr in failure diagnostics. - -10. **Close wizard = cancel remote task.** Kill the detached body process tree. - The image script's TERM/INT trap owns restoration; cancellation must not - stop an unrelated healthy Relay or broad BuildKit/Compose processes. - -11. **Account password never leaves this device.** Provision locally, then - `relay-admin import-user` over the SSH session. Do not send plaintext - passwords to the remote as env/script args. - -12. **“Already deployed” is container-aware, not only selected-port health.** - Changing the listen port must not hide a running `openbitfun-relay`. “Create - account” must use the running container's actual published port. - -13. **Port conflict ≠ our Relay.** `port_busy && !port_owned_by_relay` blocks - deploy; busy-because-openbitfun-relay does not. - -14. **Privilege handling stays interactive and minimal.** Never call `sudo -v` - unconditionally. Detect root / passwordless sudo / interactive sudo. A - missing Docker engine elevates once, installs through the selected regional - route, repairs ownership, and continues without requiring a new login. - Long deployments refresh an already-authorized noninteractive Docker - command while the body is alive, so sudo does not expire during compilation; - the refresh process stops when the task exits. - -15. **`DOCKER_CONFIG` must remain usable by the SSH user.** Root installation - keeps the user's HOME, so hand `~/.openbitfun` back before continuing. Repair or - relocate an unreadable Docker config before any pull. Do not forward the - user's config into an unrelated root home. - -16. **Scripts on the Relay host are LF-only in three layers.** `.gitattributes` - pins LF; `to_unix_script` normalizes generated uploads; and - `stage_scripts_command` strips CR on the host before execution. Keep the - host-side defense even when the client already normalized bytes. - -17. **`sg -c` takes one string.** Quote every Docker argument with - `openbitfun_shell_join` (`shell_join` in `common.sh`); never interpolate `$*` - directly through the second shell. - -18. **Prepare-phase death must surface as failure.** Keep reporting `preparing` - while the driver PID is alive (a sudo prompt is unbounded), but treat a dead - driver past the grace window as failed rather than running forever. - -19. **The runtime image keeps the compatibility gate.** `Dockerfile.release` - uses `debian:trixie-slim` and inspects `ldd` output for both binaries. This - covers older arm64 Relay artifacts that required GLIBC 2.38 even though the - current release builders assert a GLIBC 2.35 ceiling. - -20. **Release metadata has a byte mirror, not a second authority.** - `scripts/openbitfun-release-sync.sh` mirrors the signed descriptor into both its - version directory and `/release/relay-image.json`. Desktop may fetch those - bytes when GitHub is unreachable, but the same built-in minisign key must - verify them. - -## Related docs - -Focused checks: - -```bash -cargo test --locked -p openbitfun-services-integrations --no-default-features --features remote-ssh-concrete --lib remote_ssh::relay_deploy::tests:: -node --test scripts/relay/*.test.mjs -``` - -These use local HTTP, Git and Docker-command fixtures. They do not replace a -real Linux SSH deployment with sudo, cancellation and a running prior container. - -- Relay runtime / admin: [`src/apps/relay-server/README.md`](../../../apps/relay-server/README.md) -- Account login + sync choice: comments on `account_login` / - `account_finalize_login` in `src/apps/desktop/src/api/remote_connect_api.rs` diff --git a/src/web-ui/src/features/relay-deploy/RelayDeployWizard.scss b/src/web-ui/src/features/relay-deploy/RelayDeployWizard.scss deleted file mode 100644 index 1f3e337d00..0000000000 --- a/src/web-ui/src/features/relay-deploy/RelayDeployWizard.scss +++ /dev/null @@ -1,681 +0,0 @@ -.relay-deploy-dialog { - height: min(760px, calc(100dvh - 2 * var(--openbitfun-overlay-dialog-viewport-gutter))); - - &__body { - overflow: hidden; - } -} - -/** - * Relay Deploy Wizard Styles - * Mirrors the account-login / SSH connection dialog visual language. - */ - -.relay-deploy-wizard { - display: flex; - flex-direction: column; - flex: 1; - // Fixed shell height so step / error / terminal changes do not resize the modal. - width: 100%; - min-height: 0; - overflow: hidden; - container: relay-deploy / inline-size; - font-family:var(--openbitfun-type-body-sm-font-family); - - // ── step indicator ────────────────────────────────────────────────────── - &__steps { - display: flex; - align-items: center; - gap: 6px; - padding: var(--openbitfun-space-4) var(--openbitfun-space-5); - border-bottom: 1px solid var(--openbitfun-color-border-subtle); - flex-shrink: 0; - } - - &__step { - display: flex; - align-items: center; - gap: 5px; - color: var(--openbitfun-color-content-muted); - - &.active { - color: var(--openbitfun-color-accent-default); - - .relay-deploy-wizard__step-dot { - border-color: var(--openbitfun-color-accent-default); - background: var(--openbitfun-color-accent-default); - color: var(--openbitfun-color-content-on-dark); - } - } - - &.completed { - color: var(--openbitfun-color-status-success-content); - } - } - - &__step-dot { - display: inline-flex; - align-items: center; - justify-content: center; - width: 18px; - height: 18px; - border-radius: 50%; - border: 1px solid var(--openbitfun-color-border-default); - font-size: var(--openbitfun-type-micro-font-size); - font-weight: var(--openbitfun-type-label-selected-font-weight); - flex-shrink: 0; - } - - &__step-label { - font-size: var(--openbitfun-type-meta-font-size); - font-weight: var(--openbitfun-type-label-sm-font-weight); - white-space: normal; - } - - &__step-connector { - flex: 1; - height: 1px; - background: var(--openbitfun-color-border-subtle); - min-width: 8px; - } - - // ── shared ────────────────────────────────────────────────────────────── - &__error-banner { - flex-shrink: 0; - padding: 8px 16px 10px; - border-bottom: 1px solid var(--openbitfun-color-border-subtle); - background: var(--openbitfun-color-surface-canvas); - } - - &__error-alert { - padding: 8px 10px; - align-items: center; - } - - &__scroll { - flex: 1; - min-height: 0; - padding: var(--openbitfun-space-5) 0; - display: flex; - flex-direction: column; - gap: var(--openbitfun-space-4); - - > * { - flex-shrink: 0; - } - } - - &__desc { - margin: 0; - padding: 0 var(--openbitfun-space-5); - font-size: var(--openbitfun-type-label-sm-font-size); - color: var(--openbitfun-color-content-secondary); - line-height: var(--openbitfun-type-body-sm-line-height); - } - - &__section { - padding: 0 var(--openbitfun-space-5); - margin-bottom: 0; - } - - &__section-header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; - margin-bottom: var(--openbitfun-space-3); - } - - &__section-title { - margin: 0; - font-size: var(--openbitfun-type-label-md-font-size); - font-weight: var(--openbitfun-type-label-selected-font-weight); - color: var(--openbitfun-color-content-primary); - line-height: var(--openbitfun-type-body-sm-line-height); - } - - &__search { - width: 140px; - } - - &__server-list { - display: flex; - flex-direction: column; - overflow: hidden; - border-radius: var(--openbitfun-layout-field-group-radius); - background: var(--openbitfun-color-surface-tertiary); - } - - &__search-empty { - display: grid; - place-items: center; - min-height: 42px; - padding: 8px 10px; - color: var(--openbitfun-color-content-muted); - font-size: var(--openbitfun-type-label-sm-font-size); - text-align: center; - } - - &__server-item { - display: flex; - align-items: center; - gap: 8px; - padding: var(--openbitfun-space-3) var(--openbitfun-space-4); - border-radius: 0; - background: var(--openbitfun-color-surface-tertiary); - border: 0; - cursor: pointer; - transition: background-color var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard); - - &:hover { background: var(--openbitfun-color-action-neutral-surface-hover); } - - &:focus-visible { - outline: var(--openbitfun-focus-width) solid var(--openbitfun-color-focus-ring); - outline-offset: var(--openbitfun-focus-offset); - } - - & + & { - border-top: var(--openbitfun-border-width-default) solid var(--openbitfun-color-border-subtle); - } - } - - &__server-icon { - display: flex; - align-items: center; - justify-content: center; - width: 26px; - height: 26px; - border-radius: var(--openbitfun-radius-base); - background: transparent; - color: var(--openbitfun-color-content-secondary); - flex-shrink: 0; - } - - &__server-info { - flex: 1; - min-width: 0; - display: flex; - flex-direction: column; - } - - &__server-name { - font-size: var(--openbitfun-type-label-sm-font-size); - font-weight: var(--openbitfun-type-label-sm-font-weight); - color: var(--openbitfun-color-content-primary); - overflow: hidden; - white-space: nowrap; - } - - &__server-detail { - font-size: var(--openbitfun-type-micro-font-size); - color: var(--openbitfun-color-content-muted); - font-family: var(--openbitfun-type-body-sm-font-family); - } - - &__divider { - padding: 0 var(--openbitfun-space-5); - color: var(--openbitfun-color-content-primary); - font-size: var(--openbitfun-type-label-md-font-size); - font-weight: var(--openbitfun-type-label-selected-font-weight); - } - - &__form { - display: flex; - flex-direction: column; - gap: 12px; - padding: var(--openbitfun-space-4); - margin-inline: var(--openbitfun-space-5); - background: var(--openbitfun-color-surface-tertiary); - border-radius: var(--openbitfun-layout-field-group-radius); - scroll-margin-top: 8px; - transition: background-color var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard); - - &--highlighted { - animation: relay-deploy-form-reveal 1.2s ease-out; - } - } - - &__reg-mode { - display: flex; - gap: 8px; - margin: 0 16px 12px; - } - - &__reg-mode-btn { - flex: 1; - padding: 6px 10px; - border-radius: var(--openbitfun-radius-base); - border: 1px solid var(--openbitfun-color-border-subtle); - background: var(--openbitfun-color-surface-panel); - color: var(--openbitfun-color-content-secondary); - font: inherit; - font-size: var(--openbitfun-type-label-sm-font-size); - cursor: pointer; - transition: border-color var(--openbitfun-motion-duration-fast), color var(--openbitfun-motion-duration-fast); - - &:focus-visible { - outline: var(--openbitfun-focus-width) solid var(--openbitfun-color-focus-ring); - outline-offset: var(--openbitfun-focus-offset); - } - - &:hover:not(:disabled) { - color: var(--openbitfun-color-content-primary); - } - - &:disabled { - opacity: 0.5; - cursor: not-allowed; - } - - &.is-active { - border-color: var(--openbitfun-color-border-default); - background: var(--openbitfun-color-action-neutral-surface-hover); - color: var(--openbitfun-color-content-primary); - font-weight: var(--openbitfun-type-label-selected-font-weight); - } - } - - &__row { - display: flex; - gap: 8px; - } - - &__field { - display: flex; - flex-direction: column; - - &--flex { flex: 1; min-width: 0; } - &--port { width: 76px; flex-shrink: 0; } - } - - &__label { - font-size: var(--openbitfun-type-label-sm-font-size); - font-weight: var(--openbitfun-type-label-sm-font-weight); - color: var(--openbitfun-color-content-secondary); - margin-bottom: 4px; - } - - &__actions { - display: flex; - justify-content: flex-end; - align-items: center; - gap: 8px; - flex-shrink: 0; - margin-top: auto; - padding: var(--openbitfun-space-4); - flex-wrap: wrap; - border-top: 1px solid var(--openbitfun-color-border-subtle); - background: transparent; - - } - - &__hint { - font-size: var(--openbitfun-type-meta-font-size); - color: var(--openbitfun-color-content-muted); - margin-right: auto; - } - - // ── preflight ─────────────────────────────────────────────────────────── - &__server-banner { - display: flex; - align-items: center; - gap: 6px; - margin: 0 16px 10px; - padding: 6px 10px; - border-radius: var(--openbitfun-radius-base); - background: var(--openbitfun-color-surface-panel); - border: 1px solid var(--openbitfun-color-border-subtle); - font-size: var(--openbitfun-type-meta-font-size); - font-family: var(--openbitfun-type-body-sm-font-family); - color: var(--openbitfun-color-content-secondary); - } - - &__checking { - display: flex; - align-items: center; - justify-content: center; - gap: 8px; - padding: 32px 16px; - color: var(--openbitfun-color-content-secondary); - font-size: var(--openbitfun-type-label-sm-font-size); - - // Match terminal pane height so swap does not jump the dialog. - &--terminal { - margin: 0 16px 10px; - min-height: 320px; - height: 320px; - border-radius: var(--openbitfun-radius-base); - border: 1px solid var(--openbitfun-color-border-subtle); - background: var(--openbitfun-color-surface-panel); - box-sizing: border-box; - } - } - - &__checks { - display: flex; - flex-direction: column; - gap: 2px; - margin: 0 16px 10px; - border: 1px solid var(--openbitfun-color-border-subtle); - border-radius: var(--openbitfun-radius-base); - overflow: hidden; - } - - &__check-row { - display: flex; - align-items: center; - gap: 8px; - padding: 6px 10px; - font-size: var(--openbitfun-type-label-sm-font-size); - background: var(--openbitfun-color-surface-canvas); - - &:not(:last-child) { - border-bottom: 1px solid var(--openbitfun-color-border-subtle); - } - } - - &__check-icon { - flex-shrink: 0; - - &--ok { color: var(--openbitfun-color-status-success-emphasis); } - &--warn { color: var(--openbitfun-color-status-warning-emphasis); } - &--fail { color: var(--openbitfun-color-status-danger-emphasis); } - } - - &__check-label { - font-weight: var(--openbitfun-type-label-sm-font-weight); - color: var(--openbitfun-color-content-primary); - flex-shrink: 0; - } - - &__check-detail { - color: var(--openbitfun-color-content-secondary); - font-size: var(--openbitfun-type-meta-font-size); - overflow: hidden; - } - - &__port-row { - display: flex; - align-items: flex-end; - gap: 12px; - margin: 0 16px 10px; - flex-wrap: wrap; - } - - &__port-hint { - margin: 0 0 6px; - font-size: var(--openbitfun-type-meta-font-size); - color: var(--openbitfun-color-content-muted); - line-height: var(--openbitfun-type-modifier-leading-ui-line-height); - flex: 1; - min-width: 160px; - } - - &__mirror-row { - display: flex; - align-items: flex-end; - gap: 12px; - margin: 0 16px 10px; - flex-wrap: wrap; - } - - &__field--mirror { - width: 180px; - flex-shrink: 0; - } - - &__mirror-hint { - margin: 0 0 6px; - font-size: var(--openbitfun-type-meta-font-size); - color: var(--openbitfun-color-content-muted); - line-height: var(--openbitfun-type-modifier-leading-ui-line-height); - flex: 1; - min-width: 200px; - } - - &__notice { - display: flex; - align-items: flex-start; - gap: 10px; - margin: 0 16px 10px; - padding: 10px 12px; - border-radius: var(--openbitfun-layout-field-group-radius); - font-size: var(--openbitfun-type-label-sm-font-size); - - &--info { - background: var(--openbitfun-color-status-info-surface); - border: 1px solid var(--openbitfun-color-status-info-border); - color: var(--openbitfun-color-content-primary); - - svg { color: var(--openbitfun-color-accent-default); flex-shrink: 0; margin-top: 1px; } - } - - &--warn { - background: var(--openbitfun-color-status-warning-surface); - border: 1px solid var(--openbitfun-color-status-warning-border); - color: var(--openbitfun-color-content-primary); - - svg { color: var(--openbitfun-color-status-warning-emphasis); flex-shrink: 0; margin-top: 1px; } - } - } - - &__notice-text { - display: flex; - flex-direction: column; - gap: 3px; - } - - &__notice-title { - font-weight: var(--openbitfun-type-label-selected-font-weight); - } - - &__notice-desc { - font-size: var(--openbitfun-type-meta-font-size); - color: var(--openbitfun-color-content-secondary); - line-height: var(--openbitfun-type-body-sm-line-height); - } - - // ── embedded remote PTY ───────────────────────────────────────────────── - &__terminal { - margin: 0 16px 10px; - border-radius: var(--openbitfun-radius-base); - border: 1px solid var(--openbitfun-color-border-subtle); - background: var(--openbitfun-color-surface-panel); - overflow: hidden; - // Fixed geometry — avoids modal jump from flex growth / xterm measure. - flex: 0 0 auto; - width: calc(100% - 32px); - box-sizing: border-box; - min-height: 220px; - height: 220px; - display: flex; - flex-direction: column; - - .openbitfun-terminal { - flex: 1; - min-height: 0; - height: 100%; - // Slightly denser than default panel terminal (fontSize 12 via options). - .xterm { - font-size: var(--openbitfun-type-label-sm-font-size); - } - } - - &--large { - min-height: 320px; - height: 320px; - } - } - - // ── legacy log (kept for non-PTY fallbacks) ───────────────────────────── - &__log { - margin: 0 16px 10px; - padding: 10px 12px; - border-radius: var(--openbitfun-radius-base); - background: var(--openbitfun-color-surface-panel); - border: 1px solid var(--openbitfun-color-border-subtle); - font-family:var(--openbitfun-type-code-md-font-family); - font-size: var(--openbitfun-type-meta-font-size); - line-height: var(--openbitfun-type-body-sm-line-height); - color: var(--openbitfun-color-content-secondary); - white-space: pre-wrap; - word-break: break-all; - overflow-y: auto; - max-height: 220px; - - &--large { - flex: 1; - min-height: 200px; - max-height: none; - } - } - - &__task-status { - display: flex; - align-items: center; - gap: 6px; - padding: 0 16px 8px; - font-size: var(--openbitfun-type-label-sm-font-size); - color: var(--openbitfun-color-content-secondary); - flex-shrink: 0; - min-height: 22px; - - &--failed { color: var(--openbitfun-color-status-danger-content); } - } - - &__task-header { - display: flex; - align-items: flex-start; - gap: 10px; - margin: 0 16px 10px; - padding: 10px 12px; - border-radius: var(--openbitfun-layout-field-group-radius); - background: var(--openbitfun-color-surface-panel); - border: 1px solid var(--openbitfun-color-border-subtle); - flex-shrink: 0; - // Reserve status block so running → failed does not shift layout. - min-height: 52px; - box-sizing: border-box; - - svg { flex-shrink: 0; margin-top: 2px; } - } - - &__task-header-text { - display: flex; - flex-direction: column; - gap: 3px; - } - - &__task-title { - font-size: var(--openbitfun-type-label-sm-font-size); - font-weight: var(--openbitfun-type-label-selected-font-weight); - color: var(--openbitfun-color-content-primary); - } - - &__task-desc { - font-size: var(--openbitfun-type-meta-font-size); - color: var(--openbitfun-color-content-secondary); - line-height: var(--openbitfun-type-body-sm-line-height); - } - - // ── done ──────────────────────────────────────────────────────────────── - &__done { - display: flex; - flex-direction: column; - align-items: center; - gap: 10px; - padding: 24px 16px; - text-align: center; - } - - &__done-icon { - color: var(--openbitfun-color-accent-default); - } - - &__done-title { - font-size: var(--openbitfun-type-body-md-font-size); - font-weight: var(--openbitfun-type-label-selected-font-weight); - color: var(--openbitfun-color-content-primary); - } - - &__done-url { - padding: 5px 12px; - border-radius: var(--openbitfun-radius-base); - background: var(--openbitfun-color-surface-panel); - border: 1px solid var(--openbitfun-color-border-subtle); - font-family: var(--openbitfun-type-body-sm-font-family); - font-size: var(--openbitfun-type-label-sm-font-size); - color: var(--openbitfun-color-accent-default); - } - - &__verify { - display: flex; - align-items: center; - gap: 6px; - font-size: var(--openbitfun-type-label-sm-font-size); - max-width: 100%; - line-height: var(--openbitfun-type-body-sm-line-height); - - &.ok { color: var(--openbitfun-color-status-success-content); } - &.failed { color: var(--openbitfun-color-status-warning-content); } - - svg { flex-shrink: 0; } - } - - .spinning { - animation: relay-deploy-spin 1s linear infinite; - } -} - -@keyframes relay-deploy-spin { - from { transform: rotate(0deg); } - to { transform: rotate(360deg); } -} - -@keyframes relay-deploy-form-reveal { - 0% { - background: color-mix(in srgb, var(--openbitfun-color-accent-default) 14%, transparent); - } - 40% { - background: color-mix(in srgb, var(--openbitfun-color-accent-default) 8%, transparent); - } - 100% { - background: transparent; - } -} - -@container relay-deploy (max-width: 440px) { - .relay-deploy-wizard__steps { - align-items: flex-start; - gap: var(--openbitfun-space-1); - } - - .relay-deploy-wizard__step { - flex: 1; - min-width: 0; - flex-direction: column; - text-align: center; - } - - .relay-deploy-wizard__step-label { - overflow-wrap: anywhere; - } - - .relay-deploy-wizard__step-connector { - flex: 0 0 var(--openbitfun-space-2); - margin-top: var(--openbitfun-space-2); - } - - .relay-deploy-wizard__section-header { - flex-wrap: wrap; - } -} - -@media (prefers-reduced-motion: reduce) { - .relay-deploy-wizard { - .spinning, - &__form--highlighted { - animation: none; - } - } -} diff --git a/src/web-ui/src/features/relay-deploy/RelayDeployWizard.tsx b/src/web-ui/src/features/relay-deploy/RelayDeployWizard.tsx deleted file mode 100644 index 1463ef08a0..0000000000 --- a/src/web-ui/src/features/relay-deploy/RelayDeployWizard.tsx +++ /dev/null @@ -1,1476 +0,0 @@ -/** - * Relay Deploy Wizard — one-click self-hosted relay server deployment. - * - * Steps: connect (pick an SSH server) → preflight (environment checks, optional - * Docker install) → deploy (interactive remote PTY + background build) → - * register (create the first account, provisioned locally so the plaintext - * password never leaves this device) → done. - * - * Deploy/install run inside an embedded remote PTY so sudo passwords work. - * Closing the wizard cancels any in-progress remote task. - */ - -import { OverflowText, - Alert, - Button, - Field, - Icon, - IconButton, - Input as DesignInput, - Select, - Tooltip, - ScrollArea, - Dialog, - DialogBody, - DialogClose, - DialogHeader, - DialogHeading, - DialogTitle, -} from '@openbitfun/ui'; -import React, { useState, useEffect, useCallback, useRef } from 'react'; -import { useI18n } from '@/infrastructure/i18n'; -import { Server, Lock, Key, FolderOpen, Loader2, Play, AlertTriangle, EyeOff, Rocket, PartyPopper } from 'lucide-react'; -import { sshApi } from '../ssh-remote/sshApi'; -import { pickSshPrivateKeyPath } from '../ssh-remote/pickSshPrivateKeyPath'; -import { SSHAuthPromptDialog, type SSHAuthPromptSubmitPayload } from '../ssh-remote/SSHAuthPromptDialog'; -import type { - SavedConnection, - SSHConfigEntry, - SSHConnectionConfig, - SSHAuthMethod, -} from '../ssh-remote/types'; -import { - relayDeployApi, - type RelayPreflight, - type RelayDeployTask, - type RelayTaskStatus, - type RelayVerifyResult, - type DockerAccessMode, - type RelayMirrorMode, -} from './relayDeployApi'; -import { buildRelayServerSearchState, getRelayConnectionHost } from './serverSearch'; -import { ConnectedTerminal, getTerminalService } from '@/tools/terminal'; -import { createLogger } from '@/shared/utils/logger'; -import { getMotionAwareScrollBehavior } from '@/shared/utils/motionPreference'; -import { getTypographyTokenPx } from '@/infrastructure/design-system/typographyRuntime'; -import './RelayDeployWizard.scss'; - -const log = createLogger('RelayDeployWizard'); - -const DEFAULT_RELAY_PORT = 9700; -const POLL_INTERVAL_MS = 1500; -const MAX_POLL_FAILURES = 10; - -function parseRelayPort(raw: string): number | null { - const n = Number.parseInt(raw.trim(), 10); - if (!Number.isFinite(n) || n < 1 || n > 65535) return null; - return n; -} -/** Use the compact design-system step for the embedded deployment terminal. */ -const DEPLOY_TERMINAL_FONT_SIZE = getTypographyTokenPx('font.size.xs'); -const DEPLOY_TERMINAL_OPTIONS = { fontSize: DEPLOY_TERMINAL_FONT_SIZE }; - -type Step = 'connect' | 'preflight' | 'deploy' | 'register' | 'done'; - -export interface RelayDeployResult { - relayUrl: string; - username: string; - password: string; -} - -interface RelayDeployWizardProps { - isOpen: boolean; - onClose: () => void; - /** Called with the registered credentials so the login dialog can sign in. */ - onRegistered: (result: RelayDeployResult) => void; -} - -function errMsg(e: unknown): string { - return e instanceof Error ? e.message : String(e); -} - -/** Keeps the same id scheme as the SSH connection dialog. */ -function generateConnectionId(host: string, username: string): string { - return `ssh-${username}@${host}`; -} - -export const RelayDeployWizard: React.FC = ({ - isOpen, - onClose, - onRegistered, -}) => { - const { t } = useI18n('common'); - - const [step, setStep] = useState('connect'); - const [error, setError] = useState(null); - - // ── connect ───────────────────────────────────────────────────────────── - const [savedConnections, setSavedConnections] = useState([]); - const [sshConfigHosts, setSSHConfigHosts] = useState([]); - const [savedSearch, setSavedSearch] = useState(''); - const [configSearch, setConfigSearch] = useState(''); - const [formData, setFormData] = useState({ - name: '', - host: '', - port: '22', - username: '', - authType: 'password' as 'password' | 'privateKey', - password: '', - keyPath: '~/.ssh/id_rsa', - passphrase: '', - }); - const [connecting, setConnecting] = useState(false); - const [credentialsPrompt, setCredentialsPrompt] = useState(null); - const [showPassword, setShowPassword] = useState(false); - const [showPassphrase, setShowPassphrase] = useState(false); - const connectFormRef = useRef(null); - const connectFormHighlightTimerRef = useRef(null); - const [connectFormHighlighted, setConnectFormHighlighted] = useState(false); - - const revealConnectForm = useCallback(() => { - const el = connectFormRef.current; - if (!el) return; - el.scrollIntoView({ - behavior: getMotionAwareScrollBehavior('smooth'), - block: 'start', - }); - setConnectFormHighlighted(true); - if (connectFormHighlightTimerRef.current != null) { - window.clearTimeout(connectFormHighlightTimerRef.current); - } - connectFormHighlightTimerRef.current = window.setTimeout(() => { - setConnectFormHighlighted(false); - connectFormHighlightTimerRef.current = null; - }, 1200); - }, []); - - useEffect(() => { - return () => { - if (connectFormHighlightTimerRef.current != null) { - window.clearTimeout(connectFormHighlightTimerRef.current); - } - }; - }, []); - - // ── connected session ──────────────────────────────────────────────────── - const [connectionId, setConnectionId] = useState(null); - const [serverHost, setServerHost] = useState(''); - const [serverLabel, setServerLabel] = useState(''); - - // ── preflight ──────────────────────────────────────────────────────────── - const [preflight, setPreflight] = useState(null); - const [preflightLoading, setPreflightLoading] = useState(false); - const [relayPortInput, setRelayPortInput] = useState(String(DEFAULT_RELAY_PORT)); - const [mirrorMode, setMirrorMode] = useState('auto'); - - // ── interactive PTY task (install docker / deploy) ─────────────────────── - const [activeTask, setActiveTask] = useState(null); - const [taskStatus, setTaskStatus] = useState(null); - const [terminalSessionId, setTerminalSessionId] = useState(null); - const pollRef = useRef | null>(null); - const pollActiveRef = useRef(false); - const cursorRef = useRef(0); - const pollFailuresRef = useRef(0); - const terminalSessionIdRef = useRef(null); - const connectionIdRef = useRef(null); - const activeTaskRef = useRef(null); - const taskStatusRef = useRef(null); - const launchGenerationRef = useRef(0); - - // ── register ───────────────────────────────────────────────────────────── - const [regUsername, setRegUsername] = useState(''); - const [regPassword, setRegPassword] = useState(''); - const [regConfirm, setRegConfirm] = useState(''); - const [regLoading, setRegLoading] = useState(false); - const [showRegPassword, setShowRegPassword] = useState(false); - /** - * Redeploys keep the relay database, so account creation can fail with - * "already exists". `existing` skips provisioning and hands the typed - * credentials straight to the caller's real login. - */ - const [regMode, setRegMode] = useState<'create' | 'existing'>('create'); - - // ── done ───────────────────────────────────────────────────────────────── - const [verify, setVerify] = useState(null); - - const relayPort = parseRelayPort(relayPortInput) ?? DEFAULT_RELAY_PORT; - const existingRelayPort = - preflight && preflight.existingRelayPort > 0 ? preflight.existingRelayPort : null; - const relayUrl = `http://${serverHost}:${relayPort}`; - // Unrelated process on the selected port — block deploy. Busy-because-our - // relay is handled by alreadyDeployed / portOwnedByRelay instead. - const portConflict = !!preflight && preflight.portBusy && !preflight.portOwnedByRelay; - // Container-aware: health on the typed port alone misses a running - // openbitfun-relay when the user changes RELAY_PORT. See feature README. - const alreadyDeployed = !!preflight && ( - preflight.relayHealthy || preflight.containerRunning - ); - - const stopPolling = useCallback(() => { - pollActiveRef.current = false; - if (pollRef.current) { - clearTimeout(pollRef.current); - pollRef.current = null; - } - }, []); - - const closeDeployTerminal = useCallback(async () => { - const sid = terminalSessionIdRef.current; - terminalSessionIdRef.current = null; - setTerminalSessionId(null); - if (!sid) return; - try { - await getTerminalService().closeSession(sid, true); - } catch (e) { - log.warn('failed to close deploy terminal', e); - } - }, []); - - /** Snapshot refs first — close/reset must not clear them before cancel runs. */ - const cancelRemoteTaskIfRunning = useCallback(async ( - snapshot?: { - connectionId: string | null; - task: RelayDeployTask | null; - status: RelayTaskStatus | null; - }, - ) => { - const connId = snapshot?.connectionId ?? connectionIdRef.current; - const task = snapshot?.task ?? activeTaskRef.current; - const status = snapshot?.status ?? taskStatusRef.current; - if (!connId || !task || status !== 'running') return; - try { - await relayDeployApi.cancel(connId, task); - } catch (e) { - log.warn('failed to cancel remote deploy task', e); - } - }, []); - - useEffect(() => { - connectionIdRef.current = connectionId; - }, [connectionId]); - - useEffect(() => { - activeTaskRef.current = activeTask; - }, [activeTask]); - - useEffect(() => { - taskStatusRef.current = taskStatus; - }, [taskStatus]); - - // ── lifecycle ──────────────────────────────────────────────────────────── - // Closing the wizard MUST cancel the remote task (kill pid tree / best-effort - // the image script's rollback trap). Never leave a detached pull running after dismiss. - useEffect(() => { - if (!isOpen) { - launchGenerationRef.current += 1; - stopPolling(); - // Capture before any state reset so cancel still has connection/task ids. - const cancelSnapshot = { - connectionId: connectionIdRef.current, - task: activeTaskRef.current, - status: taskStatusRef.current, - }; - void (async () => { - await cancelRemoteTaskIfRunning(cancelSnapshot); - await closeDeployTerminal(); - })(); - setStep('connect'); - setError(null); - setSavedSearch(''); - setConfigSearch(''); - setFormData({ - name: '', host: '', port: '22', username: '', - authType: 'password', password: '', keyPath: '~/.ssh/id_rsa', passphrase: '', - }); - setConnecting(false); - setCredentialsPrompt(null); - setConnectionId(null); - setServerHost(''); - setServerLabel(''); - setPreflight(null); - setPreflightLoading(false); - setRelayPortInput(String(DEFAULT_RELAY_PORT)); - setMirrorMode('auto'); - setActiveTask(null); - setTaskStatus(null); - setRegUsername(''); - setRegPassword(''); - setRegConfirm(''); - setRegLoading(false); - setRegMode('create'); - setVerify(null); - return; - } - sshApi.listSavedConnections().then(setSavedConnections).catch(() => setSavedConnections([])); - sshApi.listSSHConfigHosts().then(setSSHConfigHosts).catch(() => setSSHConfigHosts([])); - }, [isOpen, stopPolling, closeDeployTerminal, cancelRemoteTaskIfRunning]); - - // Stop polling / cancel remote task / close PTY on unmount. - useEffect(() => { - return () => { - launchGenerationRef.current += 1; - stopPolling(); - const cancelSnapshot = { - connectionId: connectionIdRef.current, - task: activeTaskRef.current, - status: taskStatusRef.current, - }; - void (async () => { - await cancelRemoteTaskIfRunning(cancelSnapshot); - await closeDeployTerminal(); - })(); - }; - }, [stopPolling, closeDeployTerminal, cancelRemoteTaskIfRunning]); - - // Auto-fill the form from ~/.ssh/config when the host changes (same behavior - // as the SSH connection dialog). - useEffect(() => { - if (!formData.host.trim()) return; - const timeout = setTimeout(async () => { - try { - const result = await sshApi.getSSHConfig(formData.host.trim()); - if (result.found && result.config) { - const config = result.config; - setFormData((prev) => ({ - ...prev, - port: config.port ? String(config.port) : prev.port, - username: config.user || prev.username, - keyPath: config.identityFile || prev.keyPath, - authType: config.identityFile ? 'privateKey' : prev.authType, - })); - } - } catch { - // ~/.ssh/config lookup is best-effort - } - }, 300); - return () => clearTimeout(timeout); - }, [formData.host]); - - // ── preflight ──────────────────────────────────────────────────────────── - const runPreflight = useCallback(async (connId: string, portOverride?: number) => { - const port = portOverride ?? parseRelayPort(relayPortInput) ?? DEFAULT_RELAY_PORT; - setPreflightLoading(true); - setError(null); - try { - const pf = await relayDeployApi.preflight(connId, port); - setPreflight(pf); - setStep('preflight'); - } catch (e) { - log.warn('preflight failed', e); - setError(`${t('relayDeploy.checkFailed')}: ${errMsg(e)}`); - } finally { - setPreflightLoading(false); - } - }, [t, relayPortInput]); - - const onConnected = useCallback((connId: string, host: string, label: string) => { - setConnectionId(connId); - setServerHost(host); - setServerLabel(label); - void runPreflight(connId); - }, [runPreflight]); - - // Re-probe when the user changes the relay listen port on the check step. - useEffect(() => { - if (step !== 'preflight' || !connectionId || activeTask) return; - const port = parseRelayPort(relayPortInput); - if (port == null) return; - if (preflight?.probedPort === port) return; - const timer = window.setTimeout(() => { - void runPreflight(connectionId, port); - }, 450); - return () => window.clearTimeout(timer); - }, [step, connectionId, relayPortInput, preflight?.probedPort, activeTask, runPreflight]); - - // ── connect handlers ───────────────────────────────────────────────────── - const buildAuthMethod = (): SSHAuthMethod => { - if (formData.authType === 'password') { - return { type: 'Password', password: formData.password }; - } - return { - type: 'PrivateKey', - keyPath: formData.keyPath, - passphrase: formData.passphrase || undefined, - }; - }; - - const handleFormConnect = async () => { - if (!formData.host.trim()) { setError(t('ssh.remote.hostRequired')); return; } - if (!formData.username.trim()) { setError(t('ssh.remote.usernameRequired')); return; } - const port = parseInt(formData.port, 10); - if (isNaN(port) || port < 1 || port > 65535) { setError(t('ssh.remote.portInvalid')); return; } - if (formData.authType === 'password' && !formData.password) { setError(t('ssh.remote.passwordRequired')); return; } - if (formData.authType === 'privateKey' && !formData.keyPath.trim()) { setError(t('ssh.remote.keyPathRequired')); return; } - - const hostInput = formData.host.trim(); - let connectHost = hostInput; - try { - const lookup = await sshApi.getSSHConfig(hostInput); - const resolved = lookup.found && lookup.config?.hostname?.trim(); - if (resolved) connectHost = resolved; - } catch { - // proceed with the raw host - } - - const config: SSHConnectionConfig = { - id: generateConnectionId(connectHost, formData.username.trim()), - name: formData.name || `${formData.username.trim()}@${hostInput}`, - host: connectHost, - port, - username: formData.username.trim(), - auth: buildAuthMethod(), - }; - - setConnecting(true); - setError(null); - try { - const result = await sshApi.connect(config); - onConnected(result.connectionId || config.id, connectHost, config.name); - } catch (e) { - setError(errMsg(e)); - } finally { - setConnecting(false); - } - }; - - const handleQuickConnect = async (conn: SavedConnection) => { - setConnecting(true); - setError(null); - const auth: SSHAuthMethod = conn.authType.type === 'Password' - ? { type: 'Password', password: '' } - : conn.authType.type === 'PrivateKey' - ? { - type: 'PrivateKey', - keyPath: conn.authType.keyPath, - certificatePath: conn.authType.certificatePath, - } - : conn.authType.type === 'Agent' - ? { - type: 'Agent', - keyFingerprint: conn.authType.keyFingerprint, - fallbackKeyPath: conn.authType.fallbackKeyPath, - } - : { type: 'KeyboardInteractive', responses: [] }; - try { - const result = await sshApi.connect({ - id: conn.id, - name: conn.name, - host: conn.host, - port: conn.port, - username: conn.username, - auth, - }); - onConnected(result.connectionId || conn.id, conn.host, conn.name); - } catch (e) { - if (conn.authType.type !== 'Agent') { - // Runtime-only credentials may be unavailable — prompt for them. - setCredentialsPrompt(conn); - } else { - setError(errMsg(e)); - } - } finally { - setConnecting(false); - } - }; - - const handleCredentialsSubmit = async (payload: SSHAuthPromptSubmitPayload) => { - const conn = credentialsPrompt; - if (!conn) return; - setConnecting(true); - setError(null); - try { - const result = await sshApi.connect({ - id: conn.id, - name: conn.name, - host: conn.host, - port: conn.port, - username: payload.username, - auth: payload.auth, - }); - setCredentialsPrompt(null); - onConnected(result.connectionId || conn.id, conn.host, conn.name); - } catch (e) { - setError(errMsg(e)); - setCredentialsPrompt(null); - } finally { - setConnecting(false); - } - }; - - const handleFillFromConfig = (entry: SSHConfigEntry) => { - const hasKey = !!entry.identityFile?.trim(); - const connectHost = getRelayConnectionHost(entry); - setFormData({ - name: entry.host, - host: connectHost, - port: entry.port ? String(entry.port) : '22', - username: entry.user || '', - authType: hasKey ? 'privateKey' : 'password', - password: '', - keyPath: entry.identityFile?.trim() || '~/.ssh/id_rsa', - passphrase: '', - }); - // Config list sits above the form; scroll so the filled fields are visible. - requestAnimationFrame(() => revealConnectForm()); - }; - - const handleBrowsePrivateKey = useCallback(async () => { - const path = await pickSshPrivateKeyPath({ title: t('ssh.remote.pickPrivateKeyDialogTitle') }); - if (path) setFormData((prev) => ({ ...prev, keyPath: path })); - }, [t]); - - // ── status polling (PTY shows live output; poll only drives wizard state) ─ - const startTaskPolling = useCallback((task: RelayDeployTask, connId: string, generation: number) => { - stopPolling(); - cursorRef.current = 0; - pollFailuresRef.current = 0; - pollActiveRef.current = true; - - const pollOnce = async (): Promise => { - if (!pollActiveRef.current || launchGenerationRef.current !== generation) return false; - try { - const res = await relayDeployApi.poll(connId, task, cursorRef.current); - if (!pollActiveRef.current || launchGenerationRef.current !== generation) return false; - cursorRef.current = res.cursor; - pollFailuresRef.current = 0; - if (res.status !== 'running') { - stopPolling(); - setTaskStatus(res.status); - if (res.status === 'succeeded') { - if (task === 'install_docker') { - setActiveTask(null); - void closeDeployTerminal(); - void runPreflight(connId); - } else { - window.setTimeout(() => { - if (launchGenerationRef.current === generation) setStep('register'); - }, 800); - } - } - return false; - } - } catch (e) { - if (!pollActiveRef.current || launchGenerationRef.current !== generation) return false; - pollFailuresRef.current += 1; - log.warn('task poll failed', e); - if (pollFailuresRef.current >= MAX_POLL_FAILURES) { - stopPolling(); - setTaskStatus('failed'); - setError(`[poll] ${errMsg(e)}`); - return false; - } - } - return pollActiveRef.current; - }; - - const scheduleNext = () => { - if (!pollActiveRef.current) return; - pollRef.current = setTimeout(() => { - void pollOnce().then((shouldContinue) => { - if (shouldContinue) scheduleNext(); - }); - }, POLL_INTERVAL_MS); - }; - - void pollOnce().then((shouldContinue) => { - if (shouldContinue) scheduleNext(); - }); - }, [runPreflight, stopPolling, closeDeployTerminal]); - - const launchInteractiveTask = useCallback(async ( - task: RelayDeployTask, - connId: string, - scriptPath: string, - generation: number, - ) => { - await closeDeployTerminal(); - if (launchGenerationRef.current !== generation) return; - const session = await getTerminalService().createSession({ - connectionId: connId, - name: task === 'deploy' ? 'Relay Deploy' : 'Relay Docker Install', - cols: 100, - rows: 28, - source: 'manual', - }); - if (launchGenerationRef.current !== generation) { - await getTerminalService().closeSession(session.id, true); - return; - } - terminalSessionIdRef.current = session.id; - setTerminalSessionId(session.id); - // Give the shell a moment to print its prompt before sending the command. - await new Promise((r) => window.setTimeout(r, 400)); - if (launchGenerationRef.current !== generation) return; - const quoted = `'${scriptPath.replace(/'/g, `'\\''`)}'`; - await getTerminalService().sendCommand(session.id, `bash ${quoted}`); - if (launchGenerationRef.current !== generation) { - await relayDeployApi.cancel(connId, task); - return; - } - startTaskPolling(task, connId, generation); - }, [closeDeployTerminal, startTaskPolling]); - - const handleStartDeploy = async () => { - if (!connectionId) return; - const port = parseRelayPort(relayPortInput); - if (port == null) { - setError(t('relayDeploy.portInvalid')); - return; - } - setError(null); - setStep('deploy'); - setTaskStatus('running'); - setActiveTask('deploy'); - const generation = ++launchGenerationRef.current; - try { - const started = await relayDeployApi.startDeploy(connectionId, port, mirrorMode); - if (launchGenerationRef.current !== generation) return; - await launchInteractiveTask('deploy', connectionId, started.scriptPath, generation); - } catch (e) { - if (launchGenerationRef.current !== generation) return; - setTaskStatus('failed'); - setError(`[start] ${errMsg(e)}`); - } - }; - - // ── register / finish ──────────────────────────────────────────────────── - const handleRegister = async () => { - if (!connectionId) return; - if (!regUsername.trim()) { setError(t('relayDeploy.usernameRequired')); return; } - if (regPassword.length < 8) { setError(t('relayDeploy.passwordTooShort')); return; } - if (regPassword !== regConfirm) { setError(t('relayDeploy.passwordMismatch')); return; } - setRegLoading(true); - setError(null); - try { - await relayDeployApi.register(connectionId, regUsername.trim(), regPassword); - const v = await relayDeployApi - .verify(relayUrl) - .catch(() => ({ reachable: false, version: null }) as RelayVerifyResult); - setVerify(v); - setStep('done'); - } catch (e) { - const msg = errMsg(e); - // Redeploy keeps the database — point the user at the existing-account - // mode instead of leaving a raw relay error. - setError(/already exists/i.test(msg) - ? `${msg} — ${t('relayDeploy.accountExistsHint')}` - : msg); - } finally { - setRegLoading(false); - } - }; - - /** Existing-account mode: no provisioning; the caller performs the login. */ - const handleUseExisting = () => { - if (!regUsername.trim()) { setError(t('relayDeploy.usernameRequired')); return; } - if (!regPassword) { setError(t('accountLogin.emptyFields')); return; } - setError(null); - onRegistered({ relayUrl, username: regUsername.trim(), password: regPassword }); - }; - - const handleFinish = () => { - onRegistered({ relayUrl, username: regUsername.trim(), password: regPassword }); - }; - - const handleBackToPreflight = () => { - stopPolling(); - const cancelSnapshot = { - connectionId: connectionIdRef.current, - task: activeTaskRef.current, - status: taskStatusRef.current, - }; - void (async () => { - await cancelRemoteTaskIfRunning(cancelSnapshot); - await closeDeployTerminal(); - })(); - setActiveTask(null); - setTaskStatus(null); - if (connectionId) { - void runPreflight(connectionId); - } else { - setStep('connect'); - } - }; - - const dockerAccessHint = (mode: DockerAccessMode | undefined): string => { - switch (mode) { - case 'ok': - return t('relayDeploy.checkDockerOk'); - case 'group_inactive': - return t('relayDeploy.checkDockerGroupInactive'); - case 'sudo_nopass': - return t('relayDeploy.checkDockerSudoNopass'); - case 'sudo_needs_password': - return t('relayDeploy.checkDockerSudoPassword'); - case 'broken_docker_home': - return t('relayDeploy.checkDockerHomeBroken'); - case 'daemon_down': - return t('relayDeploy.checkDockerDaemonDown'); - case 'missing': - return t('relayDeploy.checkDockerMissing'); - default: - return t('relayDeploy.checkDockerMissing'); - } - }; - - // ── derived view data ──────────────────────────────────────────────────── - const { - filteredSavedConnections, - filteredSSHConfigHosts, - hasSavedConnections, - hasSSHConfigHosts, - } = buildRelayServerSearchState( - savedConnections, - sshConfigHosts, - savedSearch, - configSearch, - ); - - const accessMode = preflight?.dockerAccessMode; - const dockerRecoverable = !!preflight && preflight.dockerInstalled - && accessMode !== 'missing'; - const canInstallDocker = !!preflight && !preflight.dockerInstalled - && (preflight.sudoAvailable || preflight.sudoNeedsPassword); - const portValid = parseRelayPort(relayPortInput) != null; - const canDeploy = !!preflight && preflight.archSupported - && (dockerRecoverable || canInstallDocker) - && portValid - && (!preflight.portBusy || preflight.portOwnedByRelay); - - const steps: Array<{ key: Step; label: string }> = [ - { key: 'connect', label: t('relayDeploy.stepServer') }, - { key: 'preflight', label: t('relayDeploy.stepCheck') }, - { key: 'deploy', label: t('relayDeploy.stepDeploy') }, - { key: 'register', label: t('relayDeploy.stepAccount') }, - { key: 'done', label: t('shared:statuses.done') }, - ]; - const stepIndex = steps.findIndex((s) => s.key === step); - - const authOptions = [ - { label: t('ssh.remote.password'), value: 'password', icon: }, - { label: t('ssh.remote.privateKey'), value: 'privateKey', icon: }, - ]; - - const mirrorModeOptions = [ - { label: t('relayDeploy.mirrorModeAuto'), value: 'auto' }, - { label: t('relayDeploy.mirrorModeCn'), value: 'cn' }, - { label: t('relayDeploy.mirrorModeGlobal'), value: 'global' }, - ]; - - // ── step renderers ─────────────────────────────────────────────────────── - const renderConnect = () => ( - <> - -

{t('relayDeploy.selectServerDesc')}

- - {hasSavedConnections && ( -
-
-

{t('ssh.remote.savedConnections')}

- setSavedSearch(e.target.value)} - placeholder={t('actions.search')} - leading={} - size="sm" - /> -
-
- {filteredSavedConnections.length === 0 ? ( -
- {t('empty.noResults')} -
- ) : filteredSavedConnections.map((conn) => ( -
!connecting && handleQuickConnect(conn)} - role="button" - tabIndex={0} - onKeyDown={(e) => e.key === 'Enter' && !connecting && handleQuickConnect(conn)} - > -
-
- {conn.name} - - {conn.username}@{conn.host}:{conn.port} - -
- -
- ))} -
-
- )} - - {hasSSHConfigHosts && ( -
-
-

{t('ssh.remote.sshConfigHosts')}

- setConfigSearch(e.target.value)} - placeholder={t('actions.search')} - leading={} - size="sm" - /> -
-
- {filteredSSHConfigHosts.length === 0 ? ( -
- {t('empty.noResults')} -
- ) : filteredSSHConfigHosts.map((entry) => ( -
handleFillFromConfig(entry)} - role="button" - tabIndex={0} - onKeyDown={(e) => e.key === 'Enter' && handleFillFromConfig(entry)} - > -
-
- {entry.host} - - {entry.user || ''}@{entry.hostname || entry.host}:{entry.port || 22} - -
- -
- ))} -
-
- )} - - {(hasSavedConnections || hasSSHConfigHosts) && ( -
{t('ssh.remote.newConnection')}
- )} - -
-
-
- - setFormData((p) => ({ ...p, host: e.target.value }))} - leading={} disabled={connecting} /> - -
-
- - setFormData((p) => ({ ...p, port: e.target.value }))} - placeholder="22" disabled={connecting} /> - -
-
-
- - setFormData((p) => ({ ...p, username: e.target.value }))} - leading={} disabled={connecting} /> - -
-
- - setMirrorMode(String(value) as RelayMirrorMode)} - size="md" - disabled={taskRunning || preflightLoading} - /> -
-

{t('relayDeploy.mirrorModeHint')}

-
- - {portConflict && ( -
- -
- - {t('relayDeploy.portConflictTitle', { port: relayPort })} - - - {t('relayDeploy.portConflictDesc')} - -
-
- )} - {!portValid && ( -
- -
- {t('relayDeploy.portInvalid')} -
-
- )} - -
- {renderCheckRow( - pf.archSupported, - t('relayDeploy.checkOs'), - pf.archSupported - ? `${pf.os} / ${pf.arch}` - : `${pf.os} / ${pf.arch} — ${t('relayDeploy.checkOsUnsupported')}`, - )} - {renderCheckRow( - dockerOk ? true : (dockerWarn || dockerWillInstall) ? 'warn' : false, - t('relayDeploy.checkDocker'), - dockerWillInstall - ? t('relayDeploy.dockerAutoInstallHint') - : dockerAccessHint(pf.dockerAccessMode), - )} - {renderCheckRow( - !pf.portBusy || pf.portOwnedByRelay, - t('relayDeploy.checkPort', { port: pf.probedPort || relayPort }), - !pf.portBusy - ? t('relayDeploy.checkPortFree') - : pf.portOwnedByRelay - ? t('relayDeploy.checkPortOwned') - : t('relayDeploy.checkPortBusy'), - )} - {!pf.dockerInstalled && renderCheckRow( - pf.sudoAvailable || pf.sudoNeedsPassword ? (pf.sudoAvailable ? true : 'warn') : false, - t('relayDeploy.checkSudo'), - pf.sudoAvailable - ? t('relayDeploy.checkSudoOk') - : pf.sudoNeedsPassword - ? t('relayDeploy.checkSudoPasswordOk') - : t('relayDeploy.checkSudoMissing'), - )} -
- - {dockerWarn && !taskRunning && ( -

{t('relayDeploy.interactiveTerminalHint')}

- )} - - {(taskRunning || taskFailed) && terminalSessionId && ( -
- -
- )} - {taskRunning && ( -
- - {t('relayDeploy.installingDocker')} -
- )} - {taskFailed && ( -
- - {t('relayDeploy.dockerInstallFailed')} -
- )} - - - - )} -
- {!preflightLoading && pf && ( -
- {alreadyDeployed ? ( - <> - - - - ) : ( - <> - - {!pf.dockerInstalled && !canInstallDocker && !taskRunning && ( - {t('relayDeploy.dockerManualHint')} - )} - - - )} -
- )} - - ); - }; - - const renderDeploy = () => ( - <> - -
- - {serverLabel} -
-
- {taskStatus === 'running' && ( - <> - -
- {t('relayDeploy.deployingTitle')} - {t('relayDeploy.deployingHint')} -
- - )} - {taskStatus === 'succeeded' && ( - <> - -
- {t('relayDeploy.deploySucceeded')} -
- - )} - {taskStatus === 'failed' && ( - <> - -
- {t('relayDeploy.deployFailed')} -
- - )} -
- {terminalSessionId ? ( -
- -
- ) : taskStatus === 'running' ? ( -
- - {t('relayDeploy.openingTerminal')} -
- ) : null} -
-
- - {taskStatus === 'failed' && ( - - )} -
- - ); - - const renderRegister = () => ( - <> - -
- - {relayUrl} -
-
- - -
-
- -
- - {regMode === 'create' ? t('relayDeploy.registerTitle') : t('relayDeploy.registerExistingTitle')} - - - {regMode === 'create' ? t('relayDeploy.registerDesc') : t('relayDeploy.registerExistingDesc')} - -
-
-
-
- - setRegUsername(e.target.value)} - leading={} disabled={regLoading} /> - -
-
- - setRegPassword(e.target.value)} - leading={} disabled={regLoading} - trailing={ - setShowRegPassword((s) => !s)} - tabIndex={-1} - icon={showRegPassword ? : } - /> - } /> - -
- {regMode === 'create' && ( -
- - setRegConfirm(e.target.value)} - leading={} disabled={regLoading} /> - -
- )} -
-
-
- - {regMode === 'create' ? ( - - ) : ( - - )} -
- - ); - - const renderDone = () => ( - <> - -
- - {t('relayDeploy.doneTitle')} - {relayUrl} - {verify && ( -
- {verify.reachable ? : } - {verify.reachable ? t('relayDeploy.verifyOk') : t('relayDeploy.verifyFailed')} -
- )} -
-
-
- -
- - ); - - return ( - <> - { if (!nextOpen) onClose(); }} - size="lg" - className="relay-deploy-dialog" - closeOnPointerOutside={false} - > - - - {t('relayDeploy.title')} - - - - -
-
- {steps.map((s, i) => ( - -
- - {i < stepIndex ? : i + 1} - - {s.label} -
- {i < steps.length - 1 &&
} - - ))} -
- - {error && ( -
- setError(null)} - className="relay-deploy-wizard__error-alert" /> -
- )} - - {step === 'connect' && renderConnect()} - {step === 'preflight' && renderPreflight()} - {step === 'deploy' && renderDeploy()} - {step === 'register' && renderRegister()} - {step === 'done' && renderDone()} -
- -
- - {credentialsPrompt && ( - setCredentialsPrompt(null)} - isConnecting={connecting} - /> - )} - - ); -}; - -export default RelayDeployWizard; diff --git a/src/web-ui/src/features/relay-deploy/appearance.ts b/src/web-ui/src/features/relay-deploy/appearance.ts deleted file mode 100644 index 1a0aa66352..0000000000 --- a/src/web-ui/src/features/relay-deploy/appearance.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; - -export const relayDeployAppearanceDescriptor: AppearanceSurfaceDescriptor = { - id: 'relay-deploy', - parts: [ - { id: 'root' }, - { id: 'steps' }, - { id: 'step' }, - { id: 'error' }, - ], - states: [ - { id: 'active', selector: { kind: 'self', suffix: '[data-openbitfun-state~="active"]' } }, - { id: 'completed', selector: { kind: 'self', suffix: '[data-openbitfun-state~="completed"]' } }, - ], -}; diff --git a/src/web-ui/src/features/relay-deploy/index.ts b/src/web-ui/src/features/relay-deploy/index.ts deleted file mode 100644 index 55f7d9e83b..0000000000 --- a/src/web-ui/src/features/relay-deploy/index.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * One-click self-hosted relay deploy (Desktop SSH wizard). - * - * Invariants and ownership: see `./README.md`. Do not rewire entry points to - * open an external README instead of `RelayDeployWizard`. - */ -export { RelayDeployWizard, default } from './RelayDeployWizard'; -export type { RelayDeployResult } from './RelayDeployWizard'; -export { relayDeployApi } from './relayDeployApi'; -export type { - RelayPreflight, - RelayDeployTask, - RelayTaskStatus, - RelayTaskPoll, - RelayVerifyResult, -} from './relayDeployApi'; diff --git a/src/web-ui/src/features/relay-deploy/relayDeployApi.ts b/src/web-ui/src/features/relay-deploy/relayDeployApi.ts deleted file mode 100644 index 639b6bd430..0000000000 --- a/src/web-ui/src/features/relay-deploy/relayDeployApi.ts +++ /dev/null @@ -1,126 +0,0 @@ -/** - * Relay Deploy Feature - API Service - * - * Wraps the desktop `relay_deploy_*` Tauri commands: deploy the open-source - * OpenBitFun relay server to a user-owned host over an existing SSH connection. - */ - -import { api } from '@/infrastructure/api/service-api/ApiClient'; - -export type RelayDeployTask = 'install_docker' | 'deploy'; -export type RelayTaskStatus = 'running' | 'succeeded' | 'failed'; -export type RelayMirrorMode = 'auto' | 'cn' | 'global'; - -export type DockerAccessMode = - | 'ok' - | 'group_inactive' - | 'sudo_nopass' - | 'sudo_needs_password' - | 'broken_docker_home' - | 'daemon_down' - | 'missing'; - -export interface RelayPreflight { - os: string; - arch: string; - archSupported: boolean; - dockerInstalled: boolean; - composeAvailable: boolean; - /** Legacy coarse string: "ok" | "sudo" | "unreachable" */ - dockerDaemon: string; - dockerAccessMode: DockerAccessMode; - activeHasDockerGroup: boolean; - inDockerGroupFile: boolean; - dockerHomeWritable: boolean; - tarAvailable: boolean; - curlAvailable: boolean; - sudoAvailable: boolean; - sudoNeedsPassword: boolean; - memTotalMb: number; - portBusy: boolean; - /** Port that was probed for busy/selected-port health checks. */ - probedPort: number; - /** Selected port belongs to the existing openbitfun-relay (not an unrelated process). */ - portOwnedByRelay: boolean; - containerExists: boolean; - /** openbitfun-relay container is currently running. */ - containerRunning: boolean; - /** Host port published by the running relay (0 if unknown). */ - existingRelayPort: number; - /** Relay answers /health on the selected port and/or the existing container port. */ - relayHealthy: boolean; - homeDir: string; -} - -export interface RelayTaskStart { - scriptPath: string; -} - -export interface RelayTaskPoll { - cursor: number; - output: string; - status: RelayTaskStatus; -} - -export interface RelayVerifyResult { - reachable: boolean; - version: string | null; -} - -export const relayDeployApi = { - /** Probe the remote environment (OS/arch, Docker, memory, port, existing relay). */ - async preflight(connectionId: string, port?: number): Promise { - return api.invoke('relay_deploy_preflight', { - connectionId, - port: port && port > 0 ? port : undefined, - }); - }, - - /** Stage the interactive Docker-install driver; run scriptPath in a remote PTY. */ - async installDocker( - connectionId: string, - mirrorMode: RelayMirrorMode = 'auto', - ): Promise { - return api.invoke('relay_deploy_install_docker', { connectionId, mirrorMode }); - }, - - /** Stage the interactive deploy driver; run scriptPath in a remote PTY. */ - async startDeploy( - connectionId: string, - port?: number, - mirrorMode: RelayMirrorMode = 'auto', - ): Promise { - return api.invoke('relay_deploy_start', { - connectionId, - port: port && port > 0 ? port : undefined, - mirrorMode, - }); - }, - - /** Poll detached build status (marker/pid); PTY shows live output. */ - async poll( - connectionId: string, - task: RelayDeployTask, - cursor: number, - ): Promise { - return api.invoke('relay_deploy_poll', { connectionId, task, cursor }); - }, - - /** Stop a running install/deploy task (wizard closed or navigated away). */ - async cancel(connectionId: string, task: RelayDeployTask): Promise { - return api.invoke('relay_deploy_cancel', { connectionId, task }); - }, - - /** - * Provision a relay account locally and import it into the deployed relay. - * The plaintext password never leaves this device. - */ - async register(connectionId: string, username: string, password: string): Promise { - return api.invoke('relay_deploy_register', { connectionId, username, password }); - }, - - /** Check the relay URL is reachable from this device (firewall/security-group check). */ - async verify(relayUrl: string): Promise { - return api.invoke('relay_deploy_verify', { relayUrl }); - }, -}; diff --git a/src/web-ui/src/features/relay-deploy/serverSearch.test.ts b/src/web-ui/src/features/relay-deploy/serverSearch.test.ts deleted file mode 100644 index f1e3f27ce5..0000000000 --- a/src/web-ui/src/features/relay-deploy/serverSearch.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import type { SavedConnection, SSHConfigEntry } from '../ssh-remote/types'; -import { buildRelayServerSearchState, getRelayConnectionHost } from './serverSearch'; - -const savedAliyun: SavedConnection = { - id: 'ssh-root@47.99.85.183', - name: 'Aliyun', - host: '47.99.85.183', - port: 22, - username: 'root', - authType: { type: 'Agent' }, -}; - -const aliyunAlias: SSHConfigEntry = { - host: 'aliyun_wgq', - hostname: '47.99.85.183', - port: 22, - user: 'root', - agent: true, -}; - -describe('buildRelayServerSearchState', () => { - it('uses HostName for SSH config connections while retaining the alias for display', () => { - expect(getRelayConnectionHost(aliyunAlias)).toBe('47.99.85.183'); - }); - - it('keeps an SSH config alias even when a saved connection uses the same endpoint', () => { - const state = buildRelayServerSearchState( - [savedAliyun], - [aliyunAlias], - '', - 'aliyun_wgq', - ); - - expect(state.hasSSHConfigHosts).toBe(true); - expect(state.filteredSSHConfigHosts).toEqual([aliyunAlias]); - }); - - it('keeps the SSH config section visible when a search has no matches', () => { - const state = buildRelayServerSearchState( - [savedAliyun], - [aliyunAlias], - '', - 'missing-server', - ); - - expect(state.hasSSHConfigHosts).toBe(true); - expect(state.filteredSSHConfigHosts).toEqual([]); - }); -}); diff --git a/src/web-ui/src/features/relay-deploy/serverSearch.ts b/src/web-ui/src/features/relay-deploy/serverSearch.ts deleted file mode 100644 index 0fb48f28ba..0000000000 --- a/src/web-ui/src/features/relay-deploy/serverSearch.ts +++ /dev/null @@ -1,42 +0,0 @@ -import type { SavedConnection, SSHConfigEntry } from '../ssh-remote/types'; - -export interface RelayServerSearchState { - filteredSavedConnections: SavedConnection[]; - filteredSSHConfigHosts: SSHConfigEntry[]; - hasSavedConnections: boolean; - hasSSHConfigHosts: boolean; -} - -export function getRelayConnectionHost(entry: SSHConfigEntry): string { - return entry.hostname?.trim() || entry.host; -} - -export function buildRelayServerSearchState( - savedConnections: SavedConnection[], - sshConfigHosts: SSHConfigEntry[], - savedSearch: string, - configSearch: string, -): RelayServerSearchState { - const savedQuery = savedSearch.trim().toLowerCase(); - const configQuery = configSearch.trim().toLowerCase(); - - return { - filteredSavedConnections: savedConnections.filter((connection) => ( - !savedQuery - || connection.name.toLowerCase().includes(savedQuery) - || connection.host.toLowerCase().includes(savedQuery) - || connection.username.toLowerCase().includes(savedQuery) - )), - filteredSSHConfigHosts: sshConfigHosts.filter((entry) => { - if (!configQuery) return true; - const hostname = getRelayConnectionHost(entry); - return ( - entry.host.toLowerCase().includes(configQuery) - || hostname.toLowerCase().includes(configQuery) - || (entry.user || '').toLowerCase().includes(configQuery) - ); - }), - hasSavedConnections: savedConnections.length > 0, - hasSSHConfigHosts: sshConfigHosts.length > 0, - }; -} diff --git a/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts b/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts index 503f4faa9c..5fe9d5fcfc 100644 --- a/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts +++ b/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts @@ -27,7 +27,6 @@ const apiMocks = vi.hoisted(() => ({ restoreSessionView: vi.fn(), restoreSessionWithTurns: vi.fn(), loadSessionTurnWindow: vi.fn(), - accountFetchSessionTurns: vi.fn(), cancelSession: vi.fn(), cancelDispatchJob: vi.fn(), onPermissionRequestEvent: vi.fn(() => () => {}), @@ -104,7 +103,6 @@ vi.mock('@/features/dispatch/dispatchApi', () => ({ vi.mock('@/infrastructure/api/service-api/RemoteConnectAPI', () => ({ remoteConnectAPI: { - accountFetchSessionTurns: apiMocks.accountFetchSessionTurns, }, })); @@ -1485,7 +1483,6 @@ describe('FlowChatStore historical session hydration state', () => { apiMocks.restoreSessionWithTurns.mockReset(); apiMocks.loadSessionTurns.mockReset(); apiMocks.loadSessionTurnWindow.mockReset(); - apiMocks.accountFetchSessionTurns.mockResolvedValue(false); vi.stubGlobal('CustomEvent', class { type: string; detail: unknown; @@ -1591,12 +1588,8 @@ describe('FlowChatStore historical session hydration state', () => { }); }); - it('checks relay history completeness before restoring Core context', async () => { + it('restores history directly from the execution host', async () => { const order: string[] = []; - apiMocks.accountFetchSessionTurns.mockImplementationOnce(async () => { - order.push('relay'); - return true; - }); apiMocks.restoreSessionView.mockImplementationOnce(async () => { order.push('restore'); return { @@ -1625,31 +1618,11 @@ describe('FlowChatStore historical session hydration state', () => { await flowChatStore.loadSessionHistory('history-1', 'D:/workspace/OpenBitFun'); - expect(order).toEqual(['relay', 'restore']); + expect(order).toEqual(['restore']); }); - it('fails closed before Core restore when relay history is incomplete', async () => { - apiMocks.accountFetchSessionTurns.mockRejectedValueOnce(new Error('relay unavailable')); - flowChatStore.setState(() => ({ - sessions: new Map([ - ['history-1', createSession({ - sessionId: 'history-1', - isHistorical: true, - historyState: 'metadata-only', - })], - ]), - activeSessionId: 'history-1', - })); - - await expect( - flowChatStore.loadSessionHistory('history-1', 'D:/workspace/OpenBitFun') - ).rejects.toThrow('relay unavailable'); - - expect(apiMocks.restoreSessionView).not.toHaveBeenCalled(); - expect(flowChatStore.getState().sessions.get('history-1')?.historyState).toBe('failed'); - }); - it('skips cloud turn fetch in Peer Device Mode and restores from the peer host', async () => { + it('restores history from the peer host in Peer Device Mode', async () => { peerModeFlagMock.active = true; apiMocks.restoreSessionView.mockResolvedValueOnce({ session: { @@ -1676,7 +1649,6 @@ describe('FlowChatStore historical session hydration state', () => { await flowChatStore.loadSessionHistory('history-1', '/Users/host/project'); - expect(apiMocks.accountFetchSessionTurns).not.toHaveBeenCalled(); expect(apiMocks.restoreSessionView).toHaveBeenCalled(); expect(flowChatStore.getState().sessions.get('history-1')?.historyState).not.toBe('failed'); }); @@ -3318,7 +3290,6 @@ describe('FlowChatStore historical session hydration state', () => { await flowChatStore.loadSessionHistory('session-1', '/source'); - expect(apiMocks.accountFetchSessionTurns).not.toHaveBeenCalled(); expect(apiMocks.restoreSessionView).not.toHaveBeenCalled(); expect(apiMocks.restoreSessionWithTurns).not.toHaveBeenCalled(); expect(apiMocks.restoreSession).not.toHaveBeenCalled(); @@ -6807,7 +6778,6 @@ describe('FlowChatStore reconcile snapshot content safety', () => { beforeEach(() => { peerModeFlagMock.active = false; apiMocks.restoreSessionView.mockReset(); - apiMocks.accountFetchSessionTurns.mockResolvedValue(false); vi.stubGlobal('CustomEvent', class { type: string; detail: unknown; @@ -7051,7 +7021,6 @@ describe('FlowChatStore device surfaces', () => { apiMocks.restoreSessionView.mockReset(); apiMocks.restoreSessionWithTurns.mockReset(); apiMocks.listSessionsPage.mockReset(); - apiMocks.accountFetchSessionTurns.mockResolvedValue(false); }); afterEach(() => { diff --git a/src/web-ui/src/flow_chat/store/FlowChatStore.ts b/src/web-ui/src/flow_chat/store/FlowChatStore.ts index d6ecdbe375..87c44a2a9e 100644 --- a/src/web-ui/src/flow_chat/store/FlowChatStore.ts +++ b/src/web-ui/src/flow_chat/store/FlowChatStore.ts @@ -35,7 +35,6 @@ import { } from '@/shared/utils/startupTrace'; import { elapsedMs, nowMs } from '@/shared/utils/timing'; import { normalizeRemoteSessionScope } from '@/shared/utils/remoteSessionScope'; -import { isPeerDeviceModeActive } from '@/infrastructure/peer-device/peerModeFlag'; import { isSurfaceReconcileEnabled } from '@/infrastructure/peer-device/deviceSurfaceReconcile'; import { persistedMayWriteTurn } from '@/flow_chat/session-stream/SessionStream'; import { sessionCompletionReceipt } from '../utils/sessionCompletionReceipt'; @@ -8089,50 +8088,6 @@ export class FlowChatStore { let restoredCurrentContextUsage: SessionContextUsage | null | undefined; let restoredRuntimeEventSnapshot: SessionRuntimeEventSnapshot | undefined; - // Finish or resume relay history import before Core restores its model - // context. Ordinary local sessions return after one metadata read, while - // an incomplete relay import fails closed instead of publishing a - // truncated UI/Core history pair. - // - // Peer Device Mode: cloud turn fetch is paused on the controller; session - // history must come from the peer host via restore_session_view. - if (!remote && storageWorkspacePath && !isPeerDeviceModeActive()) { - const relayImportStartedAt = nowMs(); - startupTrace.markPhase('historical_session_relay_import_start', { - remote, - sessionId, - sessionTraceId, - }); - try { - const { remoteConnectAPI } = await import( - '@/infrastructure/api/service-api/RemoteConnectAPI' - ); - const fetched = await remoteConnectAPI.accountFetchSessionTurns( - sessionId, - storageWorkspacePath - ); - startupTrace.markPhase('historical_session_relay_import_end', { - remote, - sessionId, - sessionTraceId, - fetched, - durationMs: elapsedMs(relayImportStartedAt), - }); - } catch (fetchErr) { - startupTrace.markPhase('historical_session_relay_import_failed', { - remote, - sessionId, - sessionTraceId, - durationMs: elapsedMs(relayImportStartedAt), - }); - log.warn('Relay session history is incomplete; retry opening the session', { - sessionId, - error: fetchErr, - }); - throw fetchErr; - } - } - const stateMachineManagerPromise = import('../state-machine'); if (!isAcpSession) { const restoreStartedAt = nowMs(); diff --git a/src/web-ui/src/infrastructure/market-account/MarketAccountService.test.ts b/src/web-ui/src/infrastructure/account-identity/AccountIdentityService.test.ts similarity index 61% rename from src/web-ui/src/infrastructure/market-account/MarketAccountService.test.ts rename to src/web-ui/src/infrastructure/account-identity/AccountIdentityService.test.ts index 9b80fde41a..db58f89e44 100644 --- a/src/web-ui/src/infrastructure/market-account/MarketAccountService.test.ts +++ b/src/web-ui/src/infrastructure/account-identity/AccountIdentityService.test.ts @@ -2,26 +2,26 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { - MarketAccountService, - type MarketAccountChangedEvent, - type MarketAccountServiceDependencies, - type MarketAccountSyncPort, -} from './MarketAccountService'; + AccountIdentityService, + type AccountIdentityChangedEvent, + type AccountIdentityServiceDependencies, + type AccountIdentitySyncPort, +} from './AccountIdentityService'; -class FakeSyncPort implements MarketAccountSyncPort { - readonly published: MarketAccountChangedEvent[] = []; - private readonly listeners = new Set<(event: MarketAccountChangedEvent) => void>(); +class FakeSyncPort implements AccountIdentitySyncPort { + readonly published: AccountIdentityChangedEvent[] = []; + private readonly listeners = new Set<(event: AccountIdentityChangedEvent) => void>(); - publish(event: MarketAccountChangedEvent): void { + publish(event: AccountIdentityChangedEvent): void { this.published.push(event); } - subscribe(listener: (event: MarketAccountChangedEvent) => void): () => void { + subscribe(listener: (event: AccountIdentityChangedEvent) => void): () => void { this.listeners.add(listener); return () => this.listeners.delete(listener); } - emit(event: MarketAccountChangedEvent): void { + emit(event: AccountIdentityChangedEvent): void { this.listeners.forEach(listener => listener(event)); } } @@ -45,7 +45,7 @@ function setup() { logout: vi.fn().mockResolvedValue(undefined), onAccountChanged: vi.fn(() => () => undefined), }; - const dependencies: MarketAccountServiceDependencies = { + const dependencies: AccountIdentityServiceDependencies = { api, openExternal: vi.fn().mockResolvedValue(undefined), syncPort, @@ -53,17 +53,17 @@ function setup() { sleep: vi.fn().mockResolvedValue(undefined), sourceId: 'window-a', }; - const service = new MarketAccountService(dependencies); + const service = new AccountIdentityService(dependencies); return { api, dependencies, service, syncPort }; } -const activeServices: MarketAccountService[] = []; +const activeServices: AccountIdentityService[] = []; afterEach(() => { activeServices.splice(0).forEach(service => service.dispose()); }); -describe('MarketAccountService', () => { +describe('AccountIdentityService', () => { it('uses the MiniApp desktop OAuth flow, keeps tokens out of the renderer, and shares identity', async () => { const { api, dependencies, service, syncPort } = setup(); activeServices.push(service); @@ -106,4 +106,28 @@ describe('MarketAccountService', () => { sourceId: 'window-a', })); }); + it('ignores a profile request started before logout', async () => { + const { api, service } = setup(); activeServices.push(service); + await service.initialize(); + let resolve!: (value: typeof profile) => void; + api.me.mockImplementationOnce(() => new Promise(done => { resolve = done; })); + const stale = service.refresh(); + await service.logout(); + resolve(profile); + await stale; + expect(service.getSnapshot()).toMatchObject({ status: 'signed-out', me: null }); + }); + + it('keeps a late initial profile failure from erasing a newer sign-in', async () => { + const { api, service } = setup(); activeServices.push(service); + let reject!: (reason: Error) => void; + api.me.mockImplementationOnce(() => new Promise((_, fail) => { reject = fail; })); + const stale = service.initialize(); + api.me.mockResolvedValue(profile); + await service.signIn(); + reject(new Error('old network failure')); + await stale; + expect(service.getSnapshot()).toMatchObject({ status: 'signed-in', me: profile }); + }); + }); diff --git a/src/web-ui/src/infrastructure/market-account/MarketAccountService.ts b/src/web-ui/src/infrastructure/account-identity/AccountIdentityService.ts similarity index 70% rename from src/web-ui/src/infrastructure/market-account/MarketAccountService.ts rename to src/web-ui/src/infrastructure/account-identity/AccountIdentityService.ts index 493210e6dd..d5b5652c78 100644 --- a/src/web-ui/src/infrastructure/market-account/MarketAccountService.ts +++ b/src/web-ui/src/infrastructure/account-identity/AccountIdentityService.ts @@ -1,33 +1,33 @@ import { - miniAppMarketAPI, + accountIdentityAPI, type DesktopAuthStart, type MarketMe, -} from '@/infrastructure/api/service-api/MiniAppMarketAPI'; +} from '@/infrastructure/api/service-api/AccountIdentityAPI'; import { systemAPI } from '@/infrastructure/api/service-api/SystemAPI'; -export type MarketAccountStatus = 'loading' | 'signed-out' | 'signed-in' | 'authorizing'; +export type AccountIdentityStatus = 'loading' | 'signed-out' | 'signed-in' | 'authorizing'; -export interface MarketAccountSnapshot { +export interface AccountIdentitySnapshot { resolved: boolean; - status: MarketAccountStatus; + status: AccountIdentityStatus; me: MarketMe | null; - lastError?: MarketAccountError; + lastError?: AccountIdentityError; } -export type MarketAccountErrorCode = 'cancelled' | 'expired' | 'failed'; +export type AccountIdentityErrorCode = 'cancelled' | 'expired' | 'failed'; -export class MarketAccountError extends Error { +export class AccountIdentityError extends Error { constructor( - public readonly code: MarketAccountErrorCode, + public readonly code: AccountIdentityErrorCode, message: string, public readonly cause?: unknown, ) { super(message); - this.name = 'MarketAccountError'; + this.name = 'AccountIdentityError'; } } -export interface MarketAccountApi { +export interface AccountIdentityApi { me(): Promise; authStart(): Promise; authPoll(transaction: DesktopAuthStart): Promise<'pending' | 'authorized' | 'expired'>; @@ -35,24 +35,24 @@ export interface MarketAccountApi { onAccountChanged?(handler: () => void): () => void; } -export interface MarketAccountChangedEvent { +export interface AccountIdentityChangedEvent { kind: 'identity-changed'; eventId: string; sourceId: string; } -export interface MarketAccountSyncPort { - publish(event: MarketAccountChangedEvent): void | Promise; - subscribe(listener: (event: MarketAccountChangedEvent) => void): () => void; +export interface AccountIdentitySyncPort { + publish(event: AccountIdentityChangedEvent): void | Promise; + subscribe(listener: (event: AccountIdentityChangedEvent) => void): () => void; dispose?(): void; } -const noopSyncPort: MarketAccountSyncPort = { +const noopSyncPort: AccountIdentitySyncPort = { publish: () => undefined, subscribe: () => () => undefined, }; -function isMarketAccountChangedEvent(value: unknown): value is MarketAccountChangedEvent { +function isAccountIdentityChangedEvent(value: unknown): value is AccountIdentityChangedEvent { if (!value || typeof value !== 'object' || Array.isArray(value)) return false; const event = value as Record; return event.kind === 'identity-changed' @@ -60,18 +60,18 @@ function isMarketAccountChangedEvent(value: unknown): value is MarketAccountChan && typeof event.sourceId === 'string'; } -class BroadcastMarketAccountSyncPort implements MarketAccountSyncPort { - private readonly listeners = new Set<(event: MarketAccountChangedEvent) => void>(); +class BroadcastAccountIdentitySyncPort implements AccountIdentitySyncPort { + private readonly listeners = new Set<(event: AccountIdentityChangedEvent) => void>(); constructor(private readonly channel: BroadcastChannel) { channel.addEventListener('message', this.handleMessage); } - publish(event: MarketAccountChangedEvent): void { + publish(event: AccountIdentityChangedEvent): void { this.channel.postMessage(event); } - subscribe(listener: (event: MarketAccountChangedEvent) => void): () => void { + subscribe(listener: (event: AccountIdentityChangedEvent) => void): () => void { this.listeners.add(listener); return () => this.listeners.delete(listener); } @@ -84,24 +84,24 @@ class BroadcastMarketAccountSyncPort implements MarketAccountSyncPort { private readonly handleMessage = (message: MessageEvent): void => { const event = message.data; - if (!isMarketAccountChangedEvent(event)) return; + if (!isAccountIdentityChangedEvent(event)) return; this.listeners.forEach(listener => listener(event)); }; } -export function createMarketAccountSyncPort(): MarketAccountSyncPort { +export function createAccountIdentitySyncPort(): AccountIdentitySyncPort { if (typeof BroadcastChannel === 'undefined') return noopSyncPort; try { - return new BroadcastMarketAccountSyncPort(new BroadcastChannel('openbitfun-market-account')); + return new BroadcastAccountIdentitySyncPort(new BroadcastChannel('openbitfun-account-identity')); } catch { return noopSyncPort; } } -export interface MarketAccountServiceDependencies { - api: MarketAccountApi; +export interface AccountIdentityServiceDependencies { + api: AccountIdentityApi; openExternal(url: string): Promise; - syncPort: MarketAccountSyncPort; + syncPort: AccountIdentitySyncPort; now(): number; sleep(milliseconds: number): Promise; sourceId: string; @@ -114,37 +114,38 @@ function createId(): string { return `${Date.now()}-${Math.random().toString(36).slice(2)}`; } -const defaultDependencies = (): MarketAccountServiceDependencies => ({ - api: miniAppMarketAPI, +const defaultDependencies = (): AccountIdentityServiceDependencies => ({ + api: accountIdentityAPI, openExternal: url => systemAPI.openExternal(url), - syncPort: createMarketAccountSyncPort(), + syncPort: createAccountIdentitySyncPort(), now: () => Date.now(), sleep: milliseconds => new Promise(resolve => globalThis.setTimeout(resolve, milliseconds)), sourceId: createId(), }); -export class MarketAccountService { - private snapshot: MarketAccountSnapshot = Object.freeze({ +export class AccountIdentityService { + private snapshot: AccountIdentitySnapshot = Object.freeze({ resolved: false, status: 'loading', me: null, }); - private readonly listeners = new Set<(snapshot: MarketAccountSnapshot) => void>(); + private readonly listeners = new Set<(snapshot: AccountIdentitySnapshot) => void>(); private initializePromise: Promise | null = null; private refreshPromise: Promise | null = null; private authPromise: Promise | null = null; private authGeneration = 0; + private identityGeneration = 0; private stopSync: (() => void) | null = null; private stopNativeAccountEvents: (() => void) | null = null; private observingHost = false; - constructor(private readonly dependencies: MarketAccountServiceDependencies = defaultDependencies()) {} + constructor(private readonly dependencies: AccountIdentityServiceDependencies = defaultDependencies()) {} - getSnapshot(): MarketAccountSnapshot { + getSnapshot(): AccountIdentitySnapshot { return this.snapshot; } - subscribe(listener: (snapshot: MarketAccountSnapshot) => void): () => void { + subscribe(listener: (snapshot: AccountIdentitySnapshot) => void): () => void { this.listeners.add(listener); void this.initialize(); return () => this.listeners.delete(listener); @@ -160,14 +161,16 @@ export class MarketAccountService { void this.refresh(false).catch(() => undefined); }) ?? null; this.attachHostObservation(); + const generation = this.identityGeneration; this.initializePromise = this.refresh() .then(() => undefined) .catch(error => { + if (generation !== this.identityGeneration) return; this.setSnapshot({ resolved: true, status: 'signed-out', me: null, - lastError: asMarketAccountError(error), + lastError: asAccountIdentityError(error), }); }); return this.initializePromise; @@ -176,8 +179,10 @@ export class MarketAccountService { refresh(broadcastChange = true): Promise { if (this.refreshPromise) return this.refreshPromise; const previous = this.snapshot; - this.refreshPromise = this.dependencies.api.me() + const generation = this.identityGeneration; + const operation = this.dependencies.api.me() .then(me => { + if (generation !== this.identityGeneration) return this.snapshot.me; this.setSnapshot({ resolved: true, status: me ? 'signed-in' : this.snapshot.status === 'authorizing' @@ -193,15 +198,17 @@ export class MarketAccountService { return me; }) .finally(() => { - this.refreshPromise = null; + if (this.refreshPromise === operation) this.refreshPromise = null; }); - return this.refreshPromise; + this.refreshPromise = operation; + return operation; } signIn(): Promise { if (this.snapshot.me) return Promise.resolve(this.snapshot.me); if (this.authPromise) return this.authPromise; + this.invalidateRefresh(); const generation = ++this.authGeneration; this.setSnapshot({ ...this.snapshot, @@ -211,7 +218,7 @@ export class MarketAccountService { }); const operation = this.runSignIn(generation) .catch(error => { - const failure = asMarketAccountError(error); + const failure = asAccountIdentityError(error); if (generation === this.authGeneration) { this.setSnapshot({ resolved: true, @@ -232,6 +239,7 @@ export class MarketAccountService { cancelSignIn(): void { if (this.snapshot.status !== 'authorizing') return; this.authGeneration += 1; + this.invalidateRefresh(); this.setSnapshot({ resolved: true, status: this.snapshot.me ? 'signed-in' : 'signed-out', @@ -241,13 +249,16 @@ export class MarketAccountService { async logout(): Promise { this.cancelSignIn(); + this.invalidateRefresh(); await this.dependencies.api.logout(); + this.invalidateRefresh(); this.setSnapshot({ resolved: true, status: 'signed-out', me: null }); await this.publishIdentityChanged(); } dispose(): void { this.authGeneration += 1; + this.invalidateRefresh(); this.stopSync?.(); this.stopSync = null; this.stopNativeAccountEvents?.(); @@ -273,18 +284,24 @@ export class MarketAccountService { const me = await this.dependencies.api.me(); this.ensureCurrentAuth(generation); if (!me) { - throw new MarketAccountError('failed', 'The market authorized GitHub but returned no account.'); + throw new AccountIdentityError('failed', 'OpenBitFun authorized GitHub but returned no account.'); } + this.invalidateRefresh(); this.setSnapshot({ resolved: true, status: 'signed-in', me }); await this.publishIdentityChanged(); return me; } - throw new MarketAccountError('expired', 'The GitHub authorization expired.'); + throw new AccountIdentityError('expired', 'The GitHub authorization expired.'); + } + + private invalidateRefresh(): void { + this.identityGeneration += 1; + this.refreshPromise = null; } private ensureCurrentAuth(generation: number): void { if (generation !== this.authGeneration) { - throw new MarketAccountError('cancelled', 'The GitHub authorization was cancelled.'); + throw new AccountIdentityError('cancelled', 'The GitHub authorization was cancelled.'); } } @@ -296,7 +313,7 @@ export class MarketAccountService { }); } - private setSnapshot(snapshot: MarketAccountSnapshot): void { + private setSnapshot(snapshot: AccountIdentitySnapshot): void { this.snapshot = Object.freeze(snapshot); this.listeners.forEach(listener => listener(this.snapshot)); } @@ -330,9 +347,9 @@ export class MarketAccountService { } } -function asMarketAccountError(error: unknown): MarketAccountError { - if (error instanceof MarketAccountError) return error; - return new MarketAccountError( +function asAccountIdentityError(error: unknown): AccountIdentityError { + if (error instanceof AccountIdentityError) return error; + return new AccountIdentityError( 'failed', error instanceof Error ? error.message : String(error), error, @@ -343,4 +360,4 @@ function identityKey(me: MarketMe | null): string { return me ? `${me.user.githubId}:${me.user.login}` : ''; } -export const marketAccountService = new MarketAccountService(); +export const accountIdentityService = new AccountIdentityService(); diff --git a/src/web-ui/src/infrastructure/account-identity/index.ts b/src/web-ui/src/infrastructure/account-identity/index.ts new file mode 100644 index 0000000000..b6a6406834 --- /dev/null +++ b/src/web-ui/src/infrastructure/account-identity/index.ts @@ -0,0 +1,2 @@ +export * from './AccountIdentityService'; +export * from './useAccountIdentity'; diff --git a/src/web-ui/src/infrastructure/account-identity/useAccountIdentity.ts b/src/web-ui/src/infrastructure/account-identity/useAccountIdentity.ts new file mode 100644 index 0000000000..02a96478e0 --- /dev/null +++ b/src/web-ui/src/infrastructure/account-identity/useAccountIdentity.ts @@ -0,0 +1,16 @@ +import { useEffect, useSyncExternalStore } from 'react'; +import { accountIdentityService } from './AccountIdentityService'; + +export function useAccountIdentity() { + const snapshot = useSyncExternalStore( + listener => accountIdentityService.subscribe(listener), + () => accountIdentityService.getSnapshot(), + () => accountIdentityService.getSnapshot(), + ); + + useEffect(() => { + void accountIdentityService.initialize(); + }, []); + + return snapshot; +} diff --git a/src/web-ui/src/infrastructure/account/accountSyncStore.test.ts b/src/web-ui/src/infrastructure/account/accountSyncStore.test.ts deleted file mode 100644 index 002d6250ba..0000000000 --- a/src/web-ui/src/infrastructure/account/accountSyncStore.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { afterEach, describe, expect, it } from 'vitest'; -import { useAccountSyncStore } from './accountSyncStore'; - -describe('accountSyncStore retry direction', () => { - afterEach(() => { - useAccountSyncStore.getState().clear(); - }); - - it('retains an upload direction after failure so retry is safe', () => { - useAccountSyncStore.getState().setSyncing(true); - useAccountSyncStore.getState().setFailed('relay unavailable'); - - const state = useAccountSyncStore.getState(); - expect(state.status).toBe('failed'); - expect(state.lastSyncIsFirstLogin).toBe(true); - }); - - it('retains a download direction and clears it on logout/reset', () => { - useAccountSyncStore.getState().setSyncing(false); - useAccountSyncStore.getState().setFailed('relay unavailable'); - expect(useAccountSyncStore.getState().lastSyncIsFirstLogin).toBe(false); - - useAccountSyncStore.getState().clear(); - expect(useAccountSyncStore.getState().lastSyncIsFirstLogin).toBeNull(); - expect(useAccountSyncStore.getState().status).toBe('idle'); - }); - - it('invalidates detached completions when a sync is cleared or replaced', () => { - useAccountSyncStore.getState().setSyncing(false); - const firstOperation = useAccountSyncStore.getState().operationId; - - useAccountSyncStore.getState().clear(); - expect(useAccountSyncStore.getState().operationId).toBeGreaterThan(firstOperation); - - const clearedOperation = useAccountSyncStore.getState().operationId; - useAccountSyncStore.getState().setSyncing(true); - expect(useAccountSyncStore.getState().operationId).toBeGreaterThan(clearedOperation); - }); - - it('ignores progress emitted by an older operation', () => { - useAccountSyncStore.getState().setSyncing(false); - const operationId = useAccountSyncStore.getState().operationId; - - useAccountSyncStore.getState().applyProgress({ - operation_id: operationId - 1, - phase: 'exporting_sessions', - percent: 90, - }); - expect(useAccountSyncStore.getState().progress.percent).toBe(0); - - useAccountSyncStore.getState().applyProgress({ - operation_id: operationId, - phase: 'exporting_sessions', - percent: 40, - }); - expect(useAccountSyncStore.getState().progress.percent).toBe(40); - }); -}); diff --git a/src/web-ui/src/infrastructure/account/accountSyncStore.ts b/src/web-ui/src/infrastructure/account/accountSyncStore.ts deleted file mode 100644 index bdfbfbff2a..0000000000 --- a/src/web-ui/src/infrastructure/account/accountSyncStore.ts +++ /dev/null @@ -1,176 +0,0 @@ -import { create } from 'zustand'; -import type { AutoSyncResult } from '@/infrastructure/api/service-api/RemoteConnectAPI'; -import { api } from '@/infrastructure/api/service-api/ApiClient'; -import { createLogger } from '@/shared/utils/logger'; - -const log = createLogger('AccountSyncStore'); - -export type AccountSyncStatus = 'idle' | 'syncing' | 'done' | 'failed'; - -export type AccountSyncPhase = - | 'starting' - | 'uploading_settings' - | 'downloading_settings' - | 'applying_settings' - | 'settings_done' - | 'listing_sessions' - | 'exporting_sessions' - | 'done' - | 'failed'; - -export interface AccountSyncProgress { - operation_id: number; - phase: AccountSyncPhase; - percent: number; - current: number | null; - total: number | null; - detail: string | null; -} - -interface AccountSyncState { - /** Monotonic generation used to ignore stale detached sync completions. */ - operationId: number; - status: AccountSyncStatus; - progress: AccountSyncProgress; - lastResult: AutoSyncResult | null; - lastError: string | null; - /** Last sync direction; true uploads local settings, false downloads cloud settings. */ - lastSyncIsFirstLogin: boolean | null; - setSyncing: (isFirstLogin: boolean) => void; - applyProgress: ( - progress: Partial & Pick & { phase: string } - ) => void; - setDone: (result: AutoSyncResult) => void; - setFailed: (error: string) => void; - clear: () => void; -} - -const createInitialProgress = (operationId = 0): AccountSyncProgress => ({ - operation_id: operationId, - phase: 'starting', - percent: 0, - current: null, - total: null, - detail: null, -}); - -function normalizePhase(phase: string): AccountSyncPhase { - switch (phase) { - case 'uploading_settings': - case 'downloading_settings': - case 'applying_settings': - case 'settings_done': - case 'listing_sessions': - case 'exporting_sessions': - case 'done': - case 'failed': - case 'starting': - return phase; - // Legacy phases from older builds that still imported cloud sessions. - case 'fetching_remote_sessions': - case 'importing_sessions': - return 'exporting_sessions'; - default: - return 'starting'; - } -} - -/** - * Survives Remote Connect dialog close/reopen so users can reopen My OpenBitFun - * and still see in-progress cloud sync after choosing local/cloud overwrite. - */ -export const useAccountSyncStore = create((set) => ({ - operationId: 0, - status: 'idle', - progress: createInitialProgress(), - lastResult: null, - lastError: null, - lastSyncIsFirstLogin: null, - setSyncing: (isFirstLogin) => - set((state) => { - const operationId = state.operationId + 1; - return { - operationId, - status: 'syncing', - lastError: null, - lastSyncIsFirstLogin: isFirstLogin, - progress: createInitialProgress(operationId), - }; - }), - applyProgress: (progress) => - set((state) => { - if (progress.operation_id !== state.operationId) { - return state; - } - return { - status: progress.phase === 'failed' ? 'failed' : state.status === 'done' ? 'done' : 'syncing', - progress: { - operation_id: state.operationId, - phase: normalizePhase(progress.phase), - percent: typeof progress.percent === 'number' - ? Math.max(0, Math.min(100, progress.percent)) - : state.progress.percent, - current: progress.current ?? null, - total: progress.total ?? null, - detail: progress.detail ?? null, - }, - }; - }), - setDone: (result) => - set((state) => ({ - status: 'done', - lastResult: result, - lastError: null, - progress: { - operation_id: state.operationId, - phase: 'done', - percent: 100, - current: result.sessions_exported, - total: result.sessions_exported, - detail: null, - }, - })), - setFailed: (error) => - set((state) => ({ - status: 'failed', - lastError: error, - progress: { ...state.progress, phase: 'failed' }, - })), - clear: () => - set((state) => { - const operationId = state.operationId + 1; - return { - operationId, - status: 'idle', - lastResult: null, - lastError: null, - lastSyncIsFirstLogin: null, - progress: createInitialProgress(operationId), - }; - }), -})); - -let progressUnlisten: (() => void) | null = null; - -/** Register once so progress updates continue while the dialog is closed. */ -export function ensureAccountSyncProgressListener(): void { - if (progressUnlisten) { - return; - } - try { - progressUnlisten = api.listen('account://sync-progress', (payload) => { - if (!payload?.phase) { - return; - } - const state = useAccountSyncStore.getState(); - // Logout/device removal invalidates the active operation by returning the - // store to idle. Ignore late backend progress from the detached request. - if (state.status === 'idle' || payload.operation_id !== state.operationId) { - return; - } - state.applyProgress(payload); - }); - } catch (error) { - log.warn('Failed to register account sync progress listener', error); - } -} diff --git a/src/web-ui/src/infrastructure/account/useAccountLoginState.test.tsx b/src/web-ui/src/infrastructure/account/useAccountLoginState.test.tsx index ecc2942c44..c3a4e1b414 100644 --- a/src/web-ui/src/infrastructure/account/useAccountLoginState.test.tsx +++ b/src/web-ui/src/infrastructure/account/useAccountLoginState.test.tsx @@ -1,100 +1,52 @@ -/** - * @vitest-environment jsdom - */ - +/** @vitest-environment jsdom */ import React, { act } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { useAccountLoginState } from './useAccountLoginState'; const mocks = vi.hoisted(() => ({ - accountStatus: vi.fn(), + identity: { status: 'signed-in', me: { user: { githubId: 1 } } }, getDeviceInfo: vi.fn(), - unlisten: vi.fn(), - loginStateListener: null as null | ((payload: { logged_in: boolean }) => void), })); - +vi.mock('@/infrastructure/account-identity', () => ({ useAccountIdentity: () => mocks.identity })); vi.mock('@/infrastructure/api/service-api/RemoteConnectAPI', () => ({ - remoteConnectAPI: { - accountStatus: mocks.accountStatus, - getDeviceInfo: mocks.getDeviceInfo, - }, + remoteConnectAPI: { getDeviceInfo: mocks.getDeviceInfo }, })); - -vi.mock('@/infrastructure/api/service-api/ApiClient', () => ({ - api: { - listen: vi.fn((_event: string, listener: (payload: { logged_in: boolean }) => void) => { - mocks.loginStateListener = listener; - return mocks.unlisten; - }), - }, -})); - -vi.mock('@/shared/utils/logger', () => ({ - createLogger: () => ({ warn: vi.fn() }), -})); - -function deferred() { - let resolve!: (value: T) => void; - const promise = new Promise((res) => { resolve = res; }); - return { promise, resolve }; -} - -function AccountStateHarness(): React.ReactElement { +function Harness() { const state = useAccountLoginState(); - return ( -
- ); + return
; } - -describe('useAccountLoginState request ownership', () => { - let container: HTMLDivElement; - let root: Root; - - beforeEach(() => { - mocks.accountStatus.mockReset(); - mocks.getDeviceInfo.mockReset(); - mocks.unlisten.mockReset(); - mocks.loginStateListener = null; - container = document.createElement('div'); - document.body.appendChild(container); - root = createRoot(container); +let container: HTMLDivElement; +let root: Root; +const render = () => act(async () => { root.render(); }); +const attr = (name: string) => container.firstElementChild?.getAttribute(name); +beforeEach(() => { + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + mocks.identity = { status: 'signed-in', me: { user: { githubId: 1 } } }; + mocks.getDeviceInfo.mockReset().mockRejectedValue(new Error('Relay unavailable')); + container = document.createElement('div'); document.body.append(container); root = createRoot(container); +}); +afterEach(() => { act(() => root.unmount()); container.remove(); }); +describe('shared GitHub identity', () => { + it('remains signed in when Relay device information is unavailable', async () => { + await render(); + expect(attr('data-logged-in')).toBe('true'); + expect(attr('data-device-name')).toBe(''); }); - - afterEach(() => { - act(() => root.unmount()); - container.remove(); + it('ignores a pending device reply after logout', async () => { + let resolve!: (value: { device_name: string }) => void; + mocks.getDeviceInfo.mockReturnValue(new Promise(res => { resolve = res; })); + await render(); + mocks.identity.status = 'signed-out'; await render(); + await act(async () => { resolve({ device_name: 'Previous device' }); }); + expect(attr('data-logged-in')).toBe('false'); + expect(attr('data-device-name')).toBe(''); }); - - it('does not let an older logged-in probe overwrite a newer logout event', async () => { - const oldDeviceInfo = deferred<{ device_name: string }>(); - mocks.accountStatus - .mockResolvedValueOnce({ logged_in: true, user_id: 'user-a' }) - .mockResolvedValueOnce({ logged_in: false, user_id: null }); - mocks.getDeviceInfo.mockImplementationOnce(() => oldDeviceInfo.promise); - - await act(async () => { - root.render(); - }); - await vi.waitFor(() => expect(mocks.getDeviceInfo).toHaveBeenCalledTimes(1)); - - await act(async () => { - mocks.loginStateListener?.({ logged_in: false }); - }); - await vi.waitFor(() => { - expect(mocks.accountStatus).toHaveBeenCalledTimes(2); - expect(container.firstElementChild?.getAttribute('data-logged-in')).toBe('false'); - }); - - await act(async () => { - oldDeviceInfo.resolve({ device_name: 'Old account device' }); - await oldDeviceInfo.promise; - }); - - expect(container.firstElementChild?.getAttribute('data-logged-in')).toBe('false'); - expect(container.firstElementChild?.getAttribute('data-device-name')).toBe(''); + it('does not show the previous identity device name after switching identities', async () => { + mocks.getDeviceInfo.mockResolvedValueOnce({ device_name: 'First device' }); + await render(); expect(attr('data-device-name')).toBe('First device'); + mocks.identity.me.user.githubId = 2; await render(); + expect(attr('data-logged-in')).toBe('true'); + expect(attr('data-device-name')).toBe(''); }); }); diff --git a/src/web-ui/src/infrastructure/account/useAccountLoginState.ts b/src/web-ui/src/infrastructure/account/useAccountLoginState.ts index 47f1383bea..5bc4086a83 100644 --- a/src/web-ui/src/infrastructure/account/useAccountLoginState.ts +++ b/src/web-ui/src/infrastructure/account/useAccountLoginState.ts @@ -1,84 +1,27 @@ -/** - * Shared account login state for UI chrome (menu items and device overviews). - * - * Initial state comes from `account_status`; afterwards the backend pushes - * `account://login-state` on login / logout / finalize. Token expiry clears - * the session without an event, so a slow poll keeps the state honest. - * Components that need the full device list or relay details should still - * query the API directly. - */ - -import { useEffect, useRef, useState } from 'react'; -import { api } from '@/infrastructure/api/service-api/ApiClient'; +/** UI sign-in state follows the shared GitHub identity, independently of Relay availability. */ +import { useEffect, useState } from 'react'; +import { useAccountIdentity } from '@/infrastructure/account-identity'; import { remoteConnectAPI } from '@/infrastructure/api/service-api/RemoteConnectAPI'; -import { createLogger } from '@/shared/utils/logger'; - -const STATUS_POLL_MS = 60_000; -const log = createLogger('AccountLoginState'); export interface AccountLoginState { loggedIn: boolean; - /** Friendly name of this device, shown as the logged-in label. */ deviceName: string | null; } export function useAccountLoginState(): AccountLoginState { - const [state, setState] = useState({ loggedIn: false, deviceName: null }); - const refreshGenerationRef = useRef(0); - + const identity = useAccountIdentity(); + const githubId = identity.me?.user.githubId; + const loggedIn = identity.status === 'signed-in' && githubId !== undefined; + const [device, setDevice] = useState<{ githubId: number; name: string | null } | null>(null); useEffect(() => { - let cancelled = false; - const refresh = async () => { - const generation = ++refreshGenerationRef.current; - const isCurrent = () => ( - !cancelled && refreshGenerationRef.current === generation - ); - let status; - try { - status = await remoteConnectAPI.accountStatus(); - } catch (error) { - // Status transport failures are not logout evidence. Keep the last - // confirmed state until a later response or login-state event wins. - log.warn('Failed to refresh account login state', error); - return; - } - if (!isCurrent()) return; - if (!status.logged_in) { - setState({ loggedIn: false, deviceName: null }); - return; - } - // accountStatus only exposes a UUID user_id; the menu label needs the - // human-readable device name instead. - let deviceName: string | null = null; - try { - const info = await remoteConnectAPI.getDeviceInfo(); - deviceName = info.device_name || null; - } catch { - deviceName = null; - } - if (isCurrent()) { - setState({ loggedIn: true, deviceName }); - } - }; - void refresh(); - - const unlisten = api.listen<{ logged_in: boolean }>( - 'account://login-state', - () => { - // The event payload does not carry the device name; re-read the status - // so the label always reflects the persisted account session. - void refresh(); - }, - ); - const poll = setInterval(() => { void refresh(); }, STATUS_POLL_MS); - - return () => { - cancelled = true; - refreshGenerationRef.current += 1; - unlisten(); - clearInterval(poll); - }; - }, []); - - return state; + if (!loggedIn || githubId === undefined) return; + let current = true; + void remoteConnectAPI.getDeviceInfo().then(info => { + if (current) setDevice({ githubId, name: info.device_name || null }); + }).catch(() => { + if (current) setDevice({ githubId, name: null }); + }); + return () => { current = false; }; + }, [loggedIn, githubId]); + return { loggedIn, deviceName: loggedIn && device?.githubId === githubId ? device.name : null }; } diff --git a/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.test.ts b/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.test.ts index 4b06204a80..d1059113e6 100644 --- a/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.test.ts +++ b/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.test.ts @@ -156,12 +156,13 @@ describe('peerInvokePriorityFor', () => { expect(peerInvokePriorityFor('get_available_modes')).toBe('high'); }); - it('keeps account finalize and relay deploy on the controller', () => { - expect(isPeerLocalOnlyCommand('account_finalize_login')).toBe(true); - expect(isPeerLocalOnlyCommand('account_cancel_pending_login')).toBe(true); - expect(isPeerLocalOnlyCommand('account_fetch_session_turns')).toBe(true); - expect(isPeerLocalOnlyCommand('relay_deploy_start')).toBe(true); - expect(isPeerLocalOnlyCommand('relay_deploy_cancel')).toBe(true); + it('keeps GitHub identity and device account operations on the controller', () => { + for (const command of [ + 'account_github_start', 'account_github_poll', 'account_github_info', + 'account_login', 'account_logout', 'account_list_devices', 'account_device_rpc', + ]) { + expect(isPeerLocalOnlyCommand(command), command).toBe(true); + } expect(isPeerLocalOnlyCommand('create_session')).toBe(false); }); diff --git a/src/web-ui/src/infrastructure/api/generated/productControl.ts b/src/web-ui/src/infrastructure/api/generated/productControl.ts index 38ef84c514..c40a5aca7d 100644 --- a/src/web-ui/src/infrastructure/api/generated/productControl.ts +++ b/src/web-ui/src/infrastructure/api/generated/productControl.ts @@ -1,5 +1,5 @@ // Generated by scripts/generate-interactive-capabilities.mjs; do not edit. -export const PRODUCT_CONTROL_GRAPH_DIGEST = "409e6dbef7ebceafccc11606e2855227b9a33fa1ed71f5b4608da04e8a23de7d" as const; +export const PRODUCT_CONTROL_GRAPH_DIGEST = "896281a3cd5cac2b50ec607988e04624224444911ddb2d58ba59a5d7d06493c5" as const; export type ProductControlCapabilityId = "feature.ai-assistant" | "feature.agents" | "feature.personal-assistants" | "feature.projects" | "feature.files-editor" | "feature.terminal" | "feature.git" | "feature.code-review" | "feature.browser" | "feature.computer-use" | "feature.skills" | "feature.miniapps" | "feature.canvas" | "feature.tasks-automation" | "feature.insights" | "feature.ecosystem-compatibility" | "feature.remote-workspaces" | "feature.remote-connect" | "feature.detached-dispatch" | "feature.pages" | "feature.voice-input" | "feature.desktop-pet" | "setting.application.general" | "setting.application.appearance" | "setting.application.pet" | "setting.application.input" | "setting.application.shortcuts" | "setting.application.development" | "setting.ai.models" | "setting.ai.memory" | "setting.workspace.session" | "setting.workspace.worktrees" | "setting.tools.execution" | "setting.application.terminal" | "setting.tools.desktop-control" | "setting.tools.browser-control" | "setting.tools.automation" | "setting.tools.web-search" | "setting.tools.mcp" | "setting.tools.acp" | "setting.data.usage" | "setting.data.archived" | "setting.data.diagnostics"; diff --git a/src/web-ui/src/infrastructure/api/generated/remoteSurface.test.ts b/src/web-ui/src/infrastructure/api/generated/remoteSurface.test.ts index 62e510a061..84844b61e7 100644 --- a/src/web-ui/src/infrastructure/api/generated/remoteSurface.test.ts +++ b/src/web-ui/src/infrastructure/api/generated/remoteSurface.test.ts @@ -68,7 +68,10 @@ describe('remote surface generated bindings', () => { it('keeps controller-owned anchors local on every surface', () => { for (const command of [ 'account_login', - 'account_cancel_pending_login', + 'account_github_start', + 'account_github_poll', + 'account_github_info', + 'account_logout', 'peer_mode_ping', 'dispatch_submit', 'mark_openbitfun_control_surface_ready', diff --git a/src/web-ui/src/infrastructure/api/generated/remoteSurface.ts b/src/web-ui/src/infrastructure/api/generated/remoteSurface.ts index 8a7098b483..cc2017bb32 100644 --- a/src/web-ui/src/infrastructure/api/generated/remoteSurface.ts +++ b/src/web-ui/src/infrastructure/api/generated/remoteSurface.ts @@ -1,6 +1,6 @@ // Generated by scripts/generate-interactive-capabilities.mjs; do not edit. // Source: openbitfun_product_domains::remote_surface (Product Operation Registry). -export const REMOTE_SURFACE_REGISTRY_DIGEST = "fnv1a64:255dceea3d2c1591" as const; +export const REMOTE_SURFACE_REGISTRY_DIGEST = "fnv1a64:4347c91266c12da9" as const; /** * Registered Tauri commands the Peer Device controller keeps on the controller @@ -10,30 +10,19 @@ export const REMOTE_SURFACE_REGISTRY_DIGEST = "fnv1a64:255dceea3d2c1591" as cons * peer host's explicit refusal reaches the controller. */ export const PEER_CONTROLLER_LOCAL_COMMANDS: ReadonlySet = new Set([ - "account_auto_sync", - "account_cancel_pending_login", "account_connect_devices", - "account_delegate_to_paired", "account_delete_device", - "account_delete_synced_session", "account_device_rpc", "account_execute_on_device", - "account_export_all_sessions", - "account_export_local_session", - "account_fetch_session_turns", - "account_fetch_settings", - "account_fetch_synced_sessions", - "account_finalize_login", "account_get_credential_hint", - "account_import_remote_sessions", + "account_github_info", + "account_github_poll", + "account_github_start", "account_list_devices", "account_login", "account_logout", "account_online_devices", - "account_send_session_to_device", "account_status", - "account_sync_session", - "account_sync_settings", "account_token_expired", "appearance_market_browse", "appearance_market_download_release", @@ -113,15 +102,7 @@ export const PEER_CONTROLLER_LOCAL_COMMANDS: ReadonlySet = new Set([ "peer_host_invoke_complete", "peer_mode_ping", "quit_app", - "relay_deploy_cancel", - "relay_deploy_install_docker", - "relay_deploy_poll", - "relay_deploy_preflight", - "relay_deploy_register", - "relay_deploy_start", - "relay_deploy_verify", "remote_connect_configure_bot", - "remote_connect_configure_custom_server", "remote_connect_get_bot_verbose_mode", "remote_connect_get_device_info", "remote_connect_get_form_state", diff --git a/src/web-ui/src/infrastructure/api/service-api/AccountIdentityAPI.ts b/src/web-ui/src/infrastructure/api/service-api/AccountIdentityAPI.ts new file mode 100644 index 0000000000..5c78ce0bbe --- /dev/null +++ b/src/web-ui/src/infrastructure/api/service-api/AccountIdentityAPI.ts @@ -0,0 +1,68 @@ +import { api } from './ApiClient'; +import { createTauriCommandError } from '../errors/TauriCommandError'; + +export interface MarketUserSummary { + githubId: number; + login: string; + avatarUrl: string; +} + +export interface MarketMe { + user: MarketUserSummary; + isAdmin: boolean; +} + +export interface DesktopAuthStart { + transactionId: string; + authorizationUrl: string; + expiresAt: number; + pollIntervalSeconds: number; +} + +class AccountIdentityAPI { + async authStart(): Promise { + try { + return await api.invoke('account_github_start', {}); + } catch (error) { + throw createTauriCommandError('account_github_start', error); + } + } + + async authPoll(transaction: DesktopAuthStart): Promise<'pending' | 'authorized' | 'expired'> { + try { + const response = await api.invoke<{ status: 'pending' | 'authorized' | 'expired' }>( + 'account_github_poll', + { + request: { + transactionId: transaction.transactionId, + }, + }, + ); + return response.status; + } catch (error) { + throw createTauriCommandError('account_github_poll', error); + } + } + + async me(): Promise { + try { + return await api.invoke('account_github_info', {}); + } catch (error) { + throw createTauriCommandError('account_github_info', error); + } + } + + async logout(): Promise { + try { + await api.invoke('account_logout', {}); + } catch (error) { + throw createTauriCommandError('account_logout', error); + } + } + + onAccountChanged(handler: () => void): () => void { + return api.listen('account-identity-changed', handler); + } +} + +export const accountIdentityAPI = new AccountIdentityAPI(); diff --git a/src/web-ui/src/infrastructure/api/service-api/MiniAppMarketAPI.test.ts b/src/web-ui/src/infrastructure/api/service-api/MiniAppMarketAPI.test.ts index 961403b6aa..cf8b45b699 100644 --- a/src/web-ui/src/infrastructure/api/service-api/MiniAppMarketAPI.test.ts +++ b/src/web-ui/src/infrastructure/api/service-api/MiniAppMarketAPI.test.ts @@ -30,9 +30,10 @@ describe('MiniAppMarketAPI account bridge', () => { const started = await market.authStart(); const status = await market.authPoll(started); + expect(mocks.invoke).toHaveBeenNthCalledWith(1, 'account_github_start', {}); expect(started).not.toHaveProperty('transactionSecret'); expect(status).toBe('authorized'); - expect(mocks.invoke).toHaveBeenNthCalledWith(2, 'miniapp_market_auth_poll', { + expect(mocks.invoke).toHaveBeenNthCalledWith(2, 'account_github_poll', { request: { transactionId: 'transaction-1' }, }); }); @@ -41,6 +42,6 @@ describe('MiniAppMarketAPI account bridge', () => { const market = new MiniAppMarketAPI(); const handler = vi.fn(); market.onAccountChanged(handler); - expect(mocks.listen).toHaveBeenCalledWith('miniapp-market-account-changed', handler); + expect(mocks.listen).toHaveBeenCalledWith('account-identity-changed', handler); }); }); diff --git a/src/web-ui/src/infrastructure/api/service-api/MiniAppMarketAPI.ts b/src/web-ui/src/infrastructure/api/service-api/MiniAppMarketAPI.ts index 228326762d..6287f55563 100644 --- a/src/web-ui/src/infrastructure/api/service-api/MiniAppMarketAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/MiniAppMarketAPI.ts @@ -1,3 +1,4 @@ +import { accountIdentityAPI } from './AccountIdentityAPI'; import { api } from './ApiClient'; import { createTauriCommandError } from '../errors/TauriCommandError'; import type { @@ -184,45 +185,10 @@ export class MiniAppMarketAPI { } } - async authStart(): Promise { - try { - return await api.invoke('miniapp_market_auth_start', {}); - } catch (error) { - throw createTauriCommandError('miniapp_market_auth_start', error); - } - } - - async authPoll(transaction: DesktopAuthStart): Promise<'pending' | 'authorized' | 'expired'> { - try { - const response = await api.invoke<{ status: 'pending' | 'authorized' | 'expired' }>( - 'miniapp_market_auth_poll', - { - request: { - transactionId: transaction.transactionId, - }, - }, - ); - return response.status; - } catch (error) { - throw createTauriCommandError('miniapp_market_auth_poll', error); - } - } - - async me(): Promise { - try { - return await api.invoke('miniapp_market_me', {}); - } catch (error) { - throw createTauriCommandError('miniapp_market_me', error); - } - } - - async logout(): Promise { - try { - await api.invoke('miniapp_market_logout', {}); - } catch (error) { - throw createTauriCommandError('miniapp_market_logout', error); - } - } + authStart = () => accountIdentityAPI.authStart(); + authPoll = (transaction: DesktopAuthStart) => accountIdentityAPI.authPoll(transaction); + me = () => accountIdentityAPI.me(); + logout = () => accountIdentityAPI.logout(); async setRating(slug: string, value?: number): Promise<{ average: number; @@ -362,7 +328,7 @@ export class MiniAppMarketAPI { } onAccountChanged(handler: () => void): () => void { - return api.listen('miniapp-market-account-changed', handler); + return accountIdentityAPI.onAccountChanged(handler); } } diff --git a/src/web-ui/src/infrastructure/api/service-api/RemoteConnectAPI.ts b/src/web-ui/src/infrastructure/api/service-api/RemoteConnectAPI.ts index 9dd8a6cf4c..1d4f32c3e2 100644 --- a/src/web-ui/src/infrastructure/api/service-api/RemoteConnectAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/RemoteConnectAPI.ts @@ -44,11 +44,9 @@ export function remotePairingFailureReason( : null; } -/** Fresh results use Rust's externally tagged enum; restored status uses a Debug string. */ export type RemoteConnectionMethod = - | string - | { lan: { ip: string | null } } - | { custom_server: { url: string } }; + | 'openbitfun_server' | 'bot_feishu' | 'bot_telegram' | 'bot_weixin' + | { lan: { ip: string | null } }; export interface ConnectionResult { method: RemoteConnectionMethod; @@ -61,18 +59,10 @@ export interface ConnectionResult { } export interface RemoteConnectStatus { - is_connected: boolean; - pairing_state: RemotePairingState; - active_method: string | null; - peer_device_name: string | null; - peer_user_id: string | null; - /** Added by hosts that track authenticated account-route control heartbeats. */ - account_control_connected?: boolean; - /** Relay of the live account route; independent of the temporary QR invitation. */ - account_control_relay_url?: string | null; - /** Heartbeat leases for browser pages, not a count of physical devices. */ - account_control_clients?: Array<{ id: string; name: string }>; - account_control_has_unidentified_clients?: boolean; + relay_connected: boolean; + relay_url: string | null; + active_method: RemoteConnectionMethod | null; + clients: Array<{ id: string; name: string }>; bot_connected: string | null; bot_verbose_mode: boolean; } @@ -90,7 +80,6 @@ export interface LanNetworkInfo { } export interface RemoteConnectFormState { - custom_server_url: string; telegram_bot_token: string; feishu_app_id: string; feishu_app_secret: string; @@ -124,8 +113,6 @@ export interface WeixinQrPollResponse { export interface AccountLoginResult { user_id: string; - pending_login_id: string | null; - has_cloud_settings: boolean; } export interface AccountHint { @@ -133,12 +120,6 @@ export interface AccountHint { relay_url: string; } -export interface AutoSyncResult { - settings_synced: boolean; - sessions_exported: number; - sessions_imported: number; -} - export interface AccountStatus { logged_in: boolean; user_id: string | null; @@ -156,11 +137,6 @@ export interface AccountDeviceInfo { last_seen_at: number | null; } -export interface SyncedSession { - session_id: string; - session_json: string; -} - class RemoteConnectAPIService { private get adapter() { return getTransportAdapter(); @@ -202,10 +178,10 @@ class RemoteConnectAPIService { } } - async startConnection(method: string, customServerUrl?: string, lanIp?: string): Promise { + async startConnection(method: string, lanIp?: string): Promise { try { return await this.adapter.request('remote_connect_start', { - request: { method, custom_server_url: customServerUrl ?? null, lan_ip: lanIp ?? null }, + request: { method, lan_ip: lanIp ?? null }, }); } catch (e) { log.error('startConnection failed', e); @@ -258,15 +234,6 @@ class RemoteConnectAPIService { } } - async configureCustomServer(url: string): Promise { - try { - await this.adapter.request('remote_connect_configure_custom_server', { url }); - } catch (e) { - log.error('configureCustomServer failed', e); - throw e; - } - } - async configureBot(params: { botType: string; appId?: string; @@ -340,10 +307,10 @@ class RemoteConnectAPIService { } } - async accountLogin(relayUrl: string, username: string, password: string): Promise { + async accountLogin(): Promise { try { return await this.adapter.request('account_login', { - request: { relay_url: relayUrl, username, password }, + request: {}, }); } catch (e) { log.error('accountLogin failed', e); @@ -351,36 +318,6 @@ class RemoteConnectAPIService { } } - /** - * Persist an in-memory login after the user accepts the cloud/local settings - * choice. Without this, a process kill during the choice dialog must not - * restore a logged-in session. - */ - async accountFinalizeLogin(pendingLoginId: string): Promise { - try { - await this.adapter.request('account_finalize_login', { - request: { pending_login_id: pendingLoginId }, - }); - } catch (e) { - log.error('accountFinalizeLogin failed', e); - throw e; - } - } - - async accountCancelPendingLogin(pendingLoginId: string): Promise { - try { - return await this.adapter.request('account_cancel_pending_login', { - request: { pending_login_id: pendingLoginId }, - }); - } catch (e) { - log.warn('accountCancelPendingLogin failed', e); - // `false` is reserved for a successful backend compare-and-act that - // found a stale owner. Transport failures must stay observable so the - // caller does not discard the only cleanup owner. - throw e; - } - } - async accountStatus(): Promise { try { return await this.adapter.request('account_status'); @@ -440,128 +377,6 @@ class RemoteConnectAPIService { } } - async accountSendSessionToDevice( - targetDeviceId: string, - sessionId: string, - sessionJson: string, - ): Promise { - try { - await this.adapter.request('account_send_session_to_device', { - targetDeviceId, - sessionId, - sessionJson, - }); - } catch (e) { - log.error('accountSendSessionToDevice failed', e); - throw e; - } - } - - // ── P4: Session / settings sync ───────────────────────────────────────── - - async accountSyncSession(sessionId: string, sessionJson: string): Promise { - try { - await this.adapter.request('account_sync_session', { - sessionId, - sessionJson, - }); - } catch (e) { - log.error('accountSyncSession failed', e); - throw e; - } - } - - async accountFetchSyncedSessions(): Promise { - try { - return await this.adapter.request('account_fetch_synced_sessions'); - } catch (e) { - log.error('accountFetchSyncedSessions failed', e); - throw e; - } - } - - async accountDeleteSyncedSession(sessionId: string): Promise { - try { - await this.adapter.request('account_delete_synced_session', { - sessionId, - }); - } catch (e) { - log.error('accountDeleteSyncedSession failed', e); - throw e; - } - } - - async accountSyncSettings(settingsJson: string): Promise { - try { - await this.adapter.request('account_sync_settings', { - settingsJson, - }); - } catch (e) { - log.error('accountSyncSettings failed', e); - throw e; - } - } - - async accountFetchSettings(): Promise { - try { - return await this.adapter.request('account_fetch_settings'); - } catch (e) { - log.error('accountFetchSettings failed', e); - return null; - } - } - - // ── High-level session sync ─────────────────────────────────────────────── - - async accountExportLocalSession( - sessionId: string, - workspacePath: string, - ): Promise { - try { - await this.adapter.request('account_export_local_session', { - sessionId, - workspacePath, - }); - } catch (e) { - log.error('accountExportLocalSession failed', e); - throw e; - } - } - - async accountExportAllSessions(workspacePath: string): Promise { - try { - return await this.adapter.request('account_export_all_sessions', { - workspacePath, - }); - } catch (e) { - log.error('accountExportAllSessions failed', e); - throw e; - } - } - - async accountImportRemoteSessions(workspacePath: string): Promise { - try { - return await this.adapter.request('account_import_remote_sessions', { - workspacePath, - }); - } catch (e) { - log.error('accountImportRemoteSessions failed', e); - throw e; - } - } - - /** Complete or resume a relay-imported session's lazy turn import. */ - async accountFetchSessionTurns(sessionId: string, workspacePath: string): Promise { - try { - return await this.adapter.request('account_fetch_session_turns', { - sessionId, - workspacePath, - }); - } catch (e) { - log.error('accountFetchSessionTurns failed', e); - throw e; - } - } async accountExecuteOnDevice( targetDeviceId: string, @@ -584,25 +399,6 @@ class RemoteConnectAPIService { } } - async accountAutoSync( - isFirstLogin: boolean, - workspacePath: string, - configJson: string, - syncOperationId: number, - ): Promise { - try { - return await this.adapter.request('account_auto_sync', { - isFirstLogin, - workspacePath, - configJson, - syncOperationId, - }); - } catch (e) { - log.error('accountAutoSync failed', e); - throw e; - } - } - async accountListDevices(): Promise { try { return await this.adapter.request('account_list_devices'); @@ -638,16 +434,7 @@ class RemoteConnectAPIService { } } - async accountDelegateToPaired(correlationId: string): Promise { - try { - return await this.adapter.request('account_delegate_to_paired', { - correlationId, - }); - } catch (e) { - log.warn('accountDelegateToPaired failed', e); - throw e; - } - } + } export const remoteConnectAPI = new RemoteConnectAPIService(); diff --git a/src/web-ui/src/infrastructure/appearance/registry/defaultAppearanceRegistry.ts b/src/web-ui/src/infrastructure/appearance/registry/defaultAppearanceRegistry.ts index ccc5a0fdcc..9e7a7f4d8f 100644 --- a/src/web-ui/src/infrastructure/appearance/registry/defaultAppearanceRegistry.ts +++ b/src/web-ui/src/infrastructure/appearance/registry/defaultAppearanceRegistry.ts @@ -60,7 +60,6 @@ import { fileSystemAppearanceDescriptor } from '@/tools/file-system/appearance'; import { gitToolAppearanceDescriptor } from '@/tools/git/appearance'; import { terminalToolAppearanceDescriptor } from '@/tools/terminal/appearance'; import { workspaceToolAppearanceDescriptor } from '@/tools/workspace/appearance'; -import { relayDeployAppearanceDescriptor } from '@/features/relay-deploy/appearance'; import { marketAccountControlsAppearanceDescriptor } from '@/features/market-account/appearance'; import { sshRemoteAppearanceDescriptor } from '@/features/ssh-remote/appearance'; import { workbenchAppearanceDescriptor } from '@/app/appearance'; @@ -342,7 +341,6 @@ export function createDefaultAppearanceRegistry(): AppearanceRegistry { .registerComponent(remoteConnectDisclaimerAppearanceDescriptor) .registerComponent(diffFullscreenViewerAppearanceDescriptor) .registerComponent(notificationButtonAppearanceDescriptor) - .registerComponent(relayDeployAppearanceDescriptor) .registerComponent(marketAccountControlsAppearanceDescriptor) .registerComponent(sshRemoteAppearanceDescriptor) .registerComponent(aboutDialogAppearanceDescriptor) diff --git a/src/web-ui/src/infrastructure/config/components/AppearanceMarketDialog.test.tsx b/src/web-ui/src/infrastructure/config/components/AppearanceMarketDialog.test.tsx index bc892f635c..50cb6791b5 100644 --- a/src/web-ui/src/infrastructure/config/components/AppearanceMarketDialog.test.tsx +++ b/src/web-ui/src/infrastructure/config/components/AppearanceMarketDialog.test.tsx @@ -101,11 +101,11 @@ vi.mock('@/infrastructure/confirm-dialog', () => ({ })); vi.mock('@/features/market-account', () => ({ - MarketAccountControls: () =>
, + AccountIdentityControls: () =>
, })); -vi.mock('@/infrastructure/market-account', () => ({ - useMarketAccount: () => mocks.accountState, +vi.mock('@/infrastructure/account-identity', () => ({ + useAccountIdentity: () => mocks.accountState, })); vi.mock('@/infrastructure/i18n/hooks/useI18n', () => ({ diff --git a/src/web-ui/src/infrastructure/config/components/AppearanceMarketDialog.tsx b/src/web-ui/src/infrastructure/config/components/AppearanceMarketDialog.tsx index b1d308564a..854eded37f 100644 --- a/src/web-ui/src/infrastructure/config/components/AppearanceMarketDialog.tsx +++ b/src/web-ui/src/infrastructure/config/components/AppearanceMarketDialog.tsx @@ -15,7 +15,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { AlertTriangle, PackageCheck } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { confirmDialog } from '@/infrastructure/confirm-dialog'; -import { MarketAccountControls } from '@/features/market-account'; +import { AccountIdentityControls } from '@/features/market-account'; import { appearanceMarketAPI, type AppearanceMarketBrowseRequest, @@ -35,7 +35,7 @@ import { useAppearance, type AppearanceCatalogEntry, } from '@/infrastructure/appearance'; -import { useMarketAccount } from '@/infrastructure/market-account'; +import { useAccountIdentity } from '@/infrastructure/account-identity'; import { notificationService } from '@/shared/notification-system'; import { getVersionInfo } from '@/shared/utils/version'; import { @@ -105,7 +105,7 @@ function requiresNewerOpenBitFun(minimum: string): boolean { export function AppearanceMarketDialog({ isOpen, onClose }: AppearanceMarketDialogProps) { const { t } = useTranslation('settings/appearance'); - const account = useMarketAccount(); + const account = useAccountIdentity(); const { appearances, selectedAppearanceId, @@ -457,7 +457,7 @@ export function AppearanceMarketDialog({ isOpen, onClose }: AppearanceMarketDial > - {t('package.market.title')}{} + {t('package.market.title')}{} diff --git a/src/web-ui/src/infrastructure/i18n/presets/generatedLocaleContract.ts b/src/web-ui/src/infrastructure/i18n/presets/generatedLocaleContract.ts index f3f039bedd..cde3d388b3 100644 --- a/src/web-ui/src/infrastructure/i18n/presets/generatedLocaleContract.ts +++ b/src/web-ui/src/infrastructure/i18n/presets/generatedLocaleContract.ts @@ -64,7 +64,6 @@ export const SHARED_TERMS_BY_LOCALE = { }, "connectionMethods": { "lan": "局域网", - "ngrok": "Ngrok", "openbitfunServer": "OpenBitFun Server", "customServer": "自定义服务器", "botFeishu": "飞书机器人", @@ -114,7 +113,6 @@ export const SHARED_TERMS_BY_LOCALE = { }, "connectionMethods": { "lan": "LAN", - "ngrok": "Ngrok", "openbitfunServer": "OpenBitFun Server", "customServer": "Custom Server", "botFeishu": "Feishu Bot", @@ -164,7 +162,6 @@ export const SHARED_TERMS_BY_LOCALE = { }, "connectionMethods": { "lan": "區域網路", - "ngrok": "Ngrok", "openbitfunServer": "OpenBitFun Server", "customServer": "自訂伺服器", "botFeishu": "飛書機器人", diff --git a/src/web-ui/src/infrastructure/market-account/index.ts b/src/web-ui/src/infrastructure/market-account/index.ts deleted file mode 100644 index b376b91899..0000000000 --- a/src/web-ui/src/infrastructure/market-account/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './MarketAccountService'; -export * from './useMarketAccount'; diff --git a/src/web-ui/src/infrastructure/market-account/useMarketAccount.ts b/src/web-ui/src/infrastructure/market-account/useMarketAccount.ts deleted file mode 100644 index be0944a165..0000000000 --- a/src/web-ui/src/infrastructure/market-account/useMarketAccount.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { useEffect, useSyncExternalStore } from 'react'; -import { marketAccountService } from './MarketAccountService'; - -export function useMarketAccount() { - const snapshot = useSyncExternalStore( - listener => marketAccountService.subscribe(listener), - () => marketAccountService.getSnapshot(), - () => marketAccountService.getSnapshot(), - ); - - useEffect(() => { - void marketAccountService.initialize(); - }, []); - - return snapshot; -} diff --git a/src/web-ui/src/infrastructure/remote-connect/remoteConnectionState.test.ts b/src/web-ui/src/infrastructure/remote-connect/remoteConnectionState.test.ts index 12558ae324..654edc3fa5 100644 --- a/src/web-ui/src/infrastructure/remote-connect/remoteConnectionState.test.ts +++ b/src/web-ui/src/infrastructure/remote-connect/remoteConnectionState.test.ts @@ -1,99 +1,44 @@ import { describe, expect, it } from 'vitest'; -import type { ConnectionResult, RemoteConnectStatus } from '../api/service-api/RemoteConnectAPI'; -import { remoteNetworkMethod, selectRemoteNetworkConnection } from './remoteConnectionState'; - -const officialRelay = 'https://remote.openbitfun.com/relay'; -const customRelay = 'https://relay.example.test/remote/a'; - -function status(overrides: Partial = {}): RemoteConnectStatus { - return { - is_connected: false, - pairing_state: 'waiting_for_scan', - active_method: `CustomServer { url: "${customRelay}" }`, - peer_device_name: null, - peer_user_id: null, - bot_connected: null, - bot_verbose_mode: false, - ...overrides, - }; -} - -function invitation(relay = customRelay): ConnectionResult { - return { - method: { custom_server: { url: relay } }, - qr_data: null, - qr_svg: null, - qr_url: `https://mobile.example.test/#/pair?relay=${encodeURIComponent(relay)}`, - bot_pairing_code: null, - bot_link: null, - pairing_state: 'waiting_for_scan', - }; -} +import type { ConnectionResult, RemoteConnectStatus, RemoteConnectionMethod } from '../api/service-api/RemoteConnectAPI'; +import { remoteNetworkMethod, selectRemoteNetworkConnection, invitationRelayUrl } from './remoteConnectionState'; + +const official = 'https://remote.openbitfun.com/v/1.0.0'; +const lan = 'http://192.168.1.2:9700'; +const method = (url: string): RemoteConnectionMethod => url === official ? 'openbitfun_server' : { lan: { ip: '192.168.1.2' } }; +const status = (url: string): RemoteConnectStatus => ({ + relay_connected: true, relay_url: url, active_method: method(url), clients: [], bot_connected: null, bot_verbose_mode: false, +}); +const invitation = (url: string): ConnectionResult => ({ + method: method(url), qr_data: null, qr_svg: null, qr_url: `${url}/#/pair?did=desktop-1`, + bot_pairing_code: null, bot_link: null, pairing_state: 'waiting_for_scan', +}); -describe('remote connection presentation facts', () => { - it('keeps a live account route connected after the unused QR room is removed', () => { - const selected = selectRemoteNetworkConnection(status({ - pairing_state: 'idle', - active_method: null, - account_control_connected: true, - account_control_relay_url: customRelay, - })); - expect(selected).toMatchObject({ - connected: true, - roomConnected: false, - accountConnected: true, - method: 'custom_server', - invitationAccountConnected: false, +describe('one account device connection contract', () => { + it.each([official, lan])('uses the same authenticated invitation and status at %s', url => { + expect(selectRemoteNetworkConnection(status(url), invitation(url))).toMatchObject({ + connected: true, relayUrl: url, invitationConnected: false, }); + expect(selectRemoteNetworkConnection({ ...status(url), clients: [{ id: 'phone', name: 'Safari' }] }, invitation(url)).invitationConnected).toBe(true); + expect(selectRemoteNetworkConnection(status(url))).toMatchObject({ connected: true, invitationConnected: false }); + expect(selectRemoteNetworkConnection({ ...status(url), relay_connected: false }, invitation(url)).invitationConnected).toBe(false); }); - - it('only marks the QR connected when its exact relay matches the live account route', () => { - const account = status({ account_control_connected: true, account_control_relay_url: customRelay }); - expect(selectRemoteNetworkConnection(account, invitation()).invitationAccountConnected).toBe(true); - const otherPath = selectRemoteNetworkConnection(account, invitation('https://relay.example.test/remote/b')); - expect(otherPath.connected).toBe(true); - expect(otherPath.invitationAccountConnected).toBe(false); - expect(selectRemoteNetworkConnection(account, invitation(officialRelay)).invitationAccountConnected).toBe(false); - expect(selectRemoteNetworkConnection(account, invitation('wss://relay.example.test/remote/a/')).invitationAccountConnected).toBe(true); + it('does not reuse an invitation after switching endpoints', () => { + expect(selectRemoteNetworkConnection(status(lan), invitation(official)).invitationConnected).toBe(false); + expect(selectRemoteNetworkConnection(status(official), invitation(lan)).invitationConnected).toBe(false); }); - - it('accepts real Rust enum results and restored Debug methods without depending on string identity', () => { + it('accepts only canonical typed methods', () => { expect(remoteNetworkMethod({ lan: { ip: '192.168.1.2' } })).toBe('lan'); - expect(remoteNetworkMethod({ custom_server: { url: customRelay } })).toBe('custom_server'); - expect(remoteNetworkMethod('open_bit_fun_server')).toBe('openbitfun_server'); - expect(remoteNetworkMethod('OpenBitFunServer')).toBe('openbitfun_server'); - const account = status({ account_control_connected: true }); - expect(selectRemoteNetworkConnection(account, invitation()).invitationAccountConnected).toBe(true); - expect(selectRemoteNetworkConnection(account, { ...invitation(), qr_url: null }).invitationAccountConnected).toBe(true); - expect(selectRemoteNetworkConnection(account, { - ...invitation(), method: account.active_method!, qr_url: null, - }).invitationAccountConnected).toBe(true); - expect(selectRemoteNetworkConnection(status({ - active_method: 'OpenBitFunServer', account_control_connected: true, - }), { ...invitation(officialRelay), method: 'open_bit_fun_server' }).invitationAccountConnected).toBe(true); + expect(remoteNetworkMethod('openbitfun_server')).toBe('openbitfun_server'); + expect(remoteNetworkMethod('bot_feishu')).toBe(null); }); - - it('does not guess a match when a new host reports an unknown or invalid account relay', () => { - for (const url of [null, 'invalid', 'https://user:password@relay.example.test/remote/a']) { - expect(selectRemoteNetworkConnection(status({ - account_control_connected: true, account_control_relay_url: url, - }), invitation()).invitationAccountConnected).toBe(false); + it('does not infer connectivity from a missing or invalid endpoint', () => { + for (const url of [null, 'invalid', 'https://user:password@relay.test']) { + expect(selectRemoteNetworkConnection({ ...status(official), relay_url: url }, invitation(official)).connected).toBe(false); } - expect(selectRemoteNetworkConnection(status({ account_control_connected: true }), { - ...invitation(), qr_url: 'https://mobile.example.test/#/pair', - }).invitationAccountConnected).toBe(false); }); - - it('keeps room truth independent from account expiry and ignores stale legacy connectivity when a pairing state exists', () => { - expect(selectRemoteNetworkConnection(status({ is_connected: true })).connected).toBe(false); - expect(selectRemoteNetworkConnection(status({ pairing_state: 'connected', account_control_connected: false }))).toMatchObject({ - connected: true, roomConnected: true, accountConnected: false, - }); - expect(selectRemoteNetworkConnection(status({ account_control_connected: false }), invitation())).toMatchObject({ - connected: false, roomConnected: false, invitationAccountConnected: false, - }); - const legacy = status({ is_connected: true }); - delete (legacy as Partial).pairing_state; - expect(selectRemoteNetworkConnection(legacy).roomConnected).toBe(true); + it('rejects legacy room invitations and ambiguous target selectors', () => { + for (const suffix of ['room=old&pk=old', 'did=a&did=b', 'did=a&pk=old', 'did=']) { + expect(invitationRelayUrl({ ...invitation(official), qr_url: `${official}/#/pair?${suffix}` })).toBe(null); + } }); }); diff --git a/src/web-ui/src/infrastructure/remote-connect/remoteConnectionState.ts b/src/web-ui/src/infrastructure/remote-connect/remoteConnectionState.ts index ef05363a51..4073443d1a 100644 --- a/src/web-ui/src/infrastructure/remote-connect/remoteConnectionState.ts +++ b/src/web-ui/src/infrastructure/remote-connect/remoteConnectionState.ts @@ -1,94 +1,48 @@ import type { ConnectionResult, RemoteConnectionMethod, RemoteConnectStatus } from '../api/service-api/RemoteConnectAPI'; -export type RemoteNetworkMethod = 'lan' | 'ngrok' | 'openbitfun_server' | 'custom_server'; - -export const OFFICIAL_RELAY_URL = 'https://remote.openbitfun.com/relay'; +export type RemoteNetworkMethod = 'lan' | 'openbitfun_server'; +export const OFFICIAL_RELAY_URL = 'https://remote.openbitfun.com/v/1.0.0'; export function remoteNetworkMethod(method: RemoteConnectionMethod | null | undefined): RemoteNetworkMethod | null { - if (typeof method === 'object' && method !== null) { - if ('lan' in method) return 'lan'; - if ('custom_server' in method) return 'custom_server'; - return null; - } - const value = typeof method === 'string' ? method.toLowerCase() : undefined; - if (value?.startsWith('lan')) return 'lan'; - if (value?.startsWith('ngrok')) return 'ngrok'; - if (value?.startsWith('openbitfunserver') || value === 'openbitfun_server' || value === 'open_bit_fun_server') return 'openbitfun_server'; - if (value?.startsWith('customserver') || value === 'custom_server') return 'custom_server'; - return null; + if (typeof method === 'object' && method !== null && 'lan' in method) return 'lan'; + return method === 'openbitfun_server' ? 'openbitfun_server' : null; } export function normalizeRelayUrl(value: string | null | undefined): string | null { if (!value) return null; try { const url = new URL(value); - if (url.protocol === 'ws:') url.protocol = 'http:'; - if (url.protocol === 'wss:') url.protocol = 'https:'; if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) return null; return url.href.replace(/\/+$/, ''); - } catch { - return null; - } + } catch { return null; } } -export function relayUrlFromMethod(method: RemoteConnectionMethod | null | undefined): string | null { - if (remoteNetworkMethod(method) === 'openbitfun_server') return OFFICIAL_RELAY_URL; - if (typeof method === 'object' && method !== null && 'custom_server' in method) { - return normalizeRelayUrl(method.custom_server.url); - } - // Restored invitations have no QR payload. The existing status method is - // Rust's Debug shape, which retains the exact custom relay URL. - const encodedUrl = typeof method === 'string' ? method.match(/url:\s*("(?:[^"\\]|\\.)*")/)?.[1] : null; - if (!encodedUrl) return null; +export function invitationRelayUrl(invitation: ConnectionResult | null | undefined): string | null { + if (!invitation?.qr_url) return null; try { - return normalizeRelayUrl(JSON.parse(encodedUrl)); - } catch { - return null; - } + const url = new URL(invitation.qr_url); + if (!url.hash.startsWith('#/pair?')) return null; + const params = new URLSearchParams(url.hash.slice(7)); + if (Array.from(params.keys()).some(key => key !== 'did') || params.getAll('did').length !== 1 + || !/^[A-Za-z0-9_.-]{1,128}$/.test(params.get('did') ?? '')) return null; + return normalizeRelayUrl(`${url.origin}${url.pathname}`); + } catch { return null; } } -function invitationRelayUrl(invitation: ConnectionResult): string | null { - if (invitation.qr_url) { - try { - const hash = new URL(invitation.qr_url).hash; - const query = hash.slice(hash.indexOf('?') + 1); - return normalizeRelayUrl(new URLSearchParams(query).get('relay')); - } catch { - return null; - } - } - return relayUrlFromMethod(invitation.method); +/** Every invitation points to the same authenticated device protocol. */ +export function isDeviceInvitation(invitation: ConnectionResult | null | undefined): boolean { + return invitationRelayUrl(invitation) !== null; } -/** Presentation facts only. Account control never completes or disconnects a QR room. */ -export function selectRemoteNetworkConnection( - status: RemoteConnectStatus | null | undefined, - invitation?: ConnectionResult | null, -) { - const roomConnected = status?.pairing_state === 'connected' - || (status?.pairing_state == null && status?.is_connected === true); - const accountConnected = status?.account_control_connected === true; - const roomMethod = remoteNetworkMethod(status?.active_method); - // Older hosts scoped this boolean to the active room's relay. Recover only - // that exact URL; a matching method name alone cannot identify a relay. - const accountRelayUrl = status?.account_control_relay_url === undefined - ? relayUrlFromMethod(status?.active_method) - : normalizeRelayUrl(status.account_control_relay_url); - const accountMethod: RemoteNetworkMethod | null = accountConnected && accountRelayUrl - ? accountRelayUrl === OFFICIAL_RELAY_URL ? 'openbitfun_server' : 'custom_server' - : null; - const invitationRelay = invitation ? invitationRelayUrl(invitation) : null; - const invitationAccountConnected = accountConnected && invitationRelay !== null - && invitationRelay === accountRelayUrl; - +export function selectRemoteNetworkConnection(status: RemoteConnectStatus | null | undefined, invitation?: ConnectionResult | null) { + const relayUrl = normalizeRelayUrl(status?.relay_url); + const connected = status?.relay_connected === true && relayUrl !== null; + const method = remoteNetworkMethod(status?.active_method) + ?? (relayUrl ? relayUrl === OFFICIAL_RELAY_URL ? 'openbitfun_server' : 'lan' : null); return { - connected: roomConnected || accountConnected, - roomConnected, - accountConnected, - roomMethod, - accountMethod, - accountRelayUrl, - method: roomConnected ? roomMethod : accountConnected ? accountMethod : roomMethod, - invitationAccountConnected, + connected, + method, + relayUrl, + invitationConnected: connected && (status?.clients.length ?? 0) > 0 && invitationRelayUrl(invitation) === relayUrl, }; } diff --git a/src/web-ui/src/locales/en-US/common.json b/src/web-ui/src/locales/en-US/common.json index 0dbc38fbc2..a1c3e1e4b0 100644 --- a/src/web-ui/src/locales/en-US/common.json +++ b/src/web-ui/src/locales/en-US/common.json @@ -790,7 +790,7 @@ "usernamePlaceholder": "Enter username", "passwordPlaceholder": "Enter password", "authServerPlaceholder": "https://auth.example.com", - "login": "Log In", + "login": "Sign in with GitHub", "cancel": "Cancel", "emptyFields": "Please fill in all fields", "invalidCredentialsLength": "Username or password is too long", @@ -798,7 +798,7 @@ "insecureServerTitle": "Unencrypted server connection", "insecureServerConfirm": "This server uses unencrypted HTTP. A network attacker could intercept your login session. Continue anyway?", "continueInsecure": "Continue anyway", - "securityNote": "Your password is processed on this device; the relay receives only a derived verification value.", + "securityNote": "Authorize OpenBitFun in your browser with GitHub. Device messages are encrypted end to end.", "showPassword": "Show password", "hidePassword": "Hide password", "loginSuccess": "Logged in: {{user_id}}", @@ -885,7 +885,8 @@ "syncPhaseExportingSessions": "Backing up sessions {{current}}/{{total}}", "copyServerUrl": "Copy server address", "copyServerFailed": "Could not copy the server address. Check clipboard permission and try again.", - "loginValueProp": "Log in to link your devices: control one from another and keep settings & sessions in sync." + "loginValueProp": "Sign in to connect your devices and control them remotely.", + "linkedDevices": "Linked devices" }, "relayDeploy": { "entryHint": "No relay server yet?", @@ -977,7 +978,7 @@ "noConnectedClients": "No devices connected.", "serverUrlCopied": "Server address copied", "copyServerUrlFailed": "Could not copy the server address. Please copy it manually.", - "connectedClients": "Connected browsers and devices", + "connectedClients": "Connected devices", "clientCountHint": "Each browser page counts as one connection.", "clientCount": "{{formattedCount}} connected", "clientCountAtLeast": "At least {{formattedCount}} connected", @@ -985,35 +986,33 @@ "pairedClient": "Paired connection", "clientDetailsUnavailable": "Connection details are currently unavailable.", "centerTitle": "Devices & Connections", - "overviewIntro": "Manage your OpenBitFun devices, or let another device connect to the current workspace.", + "overviewIntro": "Manage your devices and connections to this workspace.", "myDevicesTitle": "My devices", - "accountDevicesTitle": "OpenBitFun account & devices", - "myDevicesDescription": "Sign in to sync settings and sessions and switch between your devices.", + "accountDevicesTitle": "GitHub account & devices", + "myDevicesDescription": "Choose an online device to view and continue its sessions.", "accountSignedIn": "Signed in", "accountSignedOut": "Signed out", "connectThisDeviceTitle": "Connect to this device", - "connectThisDeviceDescription": "No OpenBitFun account required", + "connectThisDeviceDescription": "Connect after signing in with the same GitHub account", "mobileBrowserTitle": "Phone or browser", - "mobileBrowserDescription": "Open this workspace from a phone or another browser over your network or a relay.", + "mobileBrowserDescription": "Continue this workspace on your phone or browser.", "chatAppsTitle": "Chat apps", - "chatAppsDescription": "Send messages to this workspace through Telegram, Feishu, or WeChat.", + "chatAppsDescription": "Connect a messaging app to send messages to this workspace.", "notConnected": "Not connected", "backToOverview": "Back to Devices & Connections", "cancelAndBack": "Cancel connection and go back", "methodSameNetwork": "Same network", - "methodOpenBitFunRelay": "OpenBitFun Relay", - "methodNgrok": "Network tunnel", + "methodOpenBitFunRelay": "Official Relay", "methodSelfHosted": "Self-hosted", "showConnectionCode": "Show connection code", "getPairingCode": "Get pairing code", "connecting": "Connecting...", "disconnect": "Disconnect", "cancel": "Cancel", - "scanHint": "Scan the QR code with your phone to connect", + "scanHint": "Scan the code, then sign in with the same GitHub account.", "botHint": "Send the pairing code to the bot to complete pairing", "connectedHint": "Mobile device connected. You can close this dialog.", "connectedUserId": "User ID", - "ngrokUsageLink": "View ngrok tunnel usage & status.", "serverUrl": "Server URL", "currentIp": "Current IP", "gatewayIp": "Gateway IP", @@ -1023,7 +1022,7 @@ "stateWaiting": "Waiting for connection...", "urlCopied": "URL Copied", "copyUrl": "Copy pairing URL", - "workspaceAddress": "Current workspace address", + "workspaceAddress": "Connection link", "copyUrlFailed": "Could not copy the pairing URL. Copy it manually or check clipboard permission.", "weixinQrAlt": "WeChat login QR code", "stateWaitingBot": "Waiting for bot confirmation...", @@ -1037,10 +1036,6 @@ "stateDisconnected": "Disconnected", "desc_lan": "Connect via local network. Both devices must be on the same LAN/WiFi. If connection fails, check router security settings (such as AP/client isolation) and make sure proxy/tunneling settings do not route or block LAN traffic (allow direct LAN access) on both devices.", "desc_openbitfun_server": "Connect via OpenBitFun relay server. The server is currently self-hosted by the OpenBitFun team and is mainly provided for convenient trials and feature evaluation. Although message payloads are protected with end-to-end encryption, the relay service and related infrastructure may still observe necessary metadata, and any public-facing service can introduce information security or data leakage risks due to misconfiguration, log exposure, operational mistakes, or attacks. For long-term use, production environments, or higher-security scenarios, we strongly recommend using a self-hosted server under your own deployment and operational control.", - "desc_ngrok": "Connect via ngrok tunnel. For first-time use, just install ngrok and configure auth token. No need to start ngrok manually.", - "desc_ngrok_prefix": "Connect via ngrok tunnel. First-time use need to ", - "desc_ngrok_link": "install ngrok", - "desc_ngrok_suffix": " and configure auth token. No need to start ngrok manually.", "desc_custom_server": "Deploy to your own server", "desc_custom_server_prefix": "", "desc_custom_server_link": "Deploy to your own server", @@ -1078,7 +1073,6 @@ "botVerboseMode": "Verbose Mode", "botConciseMode": "Concise Mode", "botConnectedDescription": "This workspace can now send and receive messages through this chat app.", - "openNgrokSetup": "Open ngrok setup page", "disclaimerTitle": "Remote Connect Disclaimer", "disclaimerIntro": "Before enabling Remote Connect, please read and accept the following:", "disclaimerKeyRisks": "Key risks", @@ -1088,10 +1082,9 @@ "disclaimerItemEncryption": "Remote message payloads are protected with end-to-end encryption (X25519 ECDH + AES-256-GCM with ephemeral key pairs per session); relay servers cannot decrypt message content. However, required metadata (such as device name, connection state, service endpoint, and other connection-context details) is not covered by business-message encryption and may still be visible to network paths, service nodes, or other infrastructure.", "disclaimerItemOpenSource": "OpenBitFun's Remote Connect encryption implementation is fully open-source. You are free to audit the source code to verify its security.", "disclaimerItemPrivacy": "Do not transmit sensitive content in untrusted environments. Bot platforms, third-party networks, host logging policies, and other external service practices may affect your exposure surface.", - "disclaimerItemDataUsage": "OpenBitFun does not use your Remote Connect message content for model training or commercial data exploitation. Third-party services you choose to use (such as ngrok, Telegram, Feishu, self-hosted infrastructure tooling, and other integrations) may process data under their own terms.", - "disclaimerItemCredentials": "For ngrok, self-hosted relay, Telegram/Feishu bot modes, and other integration pathways, you are responsible for protecting URLs, tokens, app IDs, app secrets, keys, and other credentials. Credential leakage can cause unauthorized access or other security incidents.", + "disclaimerItemDataUsage": "OpenBitFun does not use your Remote Connect message content for model training or commercial data exploitation. Third-party services you choose to use (such as Telegram, Feishu, self-hosted infrastructure tooling, and other integrations) may process data under their own terms.", + "disclaimerItemCredentials": "For Relay, Telegram/Feishu bot modes, and other integration pathways, you are responsible for protecting URLs, tokens, app IDs, app secrets, keys, and other credentials. Credential leakage can cause unauthorized access or other security incidents.", "disclaimerItemQrCode": "QR codes contain connection details including relay address, room ID, public key, and other session identifiers. Do not share screenshots, recordings, or displays of these codes with unrelated parties.", - "disclaimerItemNgrok": "When using ngrok or other third-party tunneling providers, traffic passes through external service links. You should evaluate account security, jurisdiction/compliance constraints, quota limits, service availability, and potential intermediary-link risks.", "disclaimerItemSelfHosted": "When using a self-hosted relay, you are responsible for host/container security (including but not limited to patching, access control, TLS certificates, port exposure, firewall rules, DDoS/brute-force protection, audit logging, backup/recovery, and other operational safeguards). A compromised or misconfigured server, leaked keys, or other operational failures may lead to service disruption, data leakage, or tampering risks.", "disclaimerItemNetwork": "Connectivity and stability may be affected by firewall rules, router isolation policies (e.g., AP/client isolation), proxy/tunneling settings, and other network-control policies.", "disclaimerItemBot": "In bot mode, pairing codes are for current-session binding only. Do not share them with unrelated parties. Other bot-platform behaviors or policy changes may also affect security and availability.", @@ -1103,7 +1096,8 @@ "disclaimerStatusAgreed": "Agreed", "disclaimerStatusPending": "Not agreed", "disclaimerDecline": "Decline", - "disclaimerAgree": "Agree and Continue" + "disclaimerAgree": "Agree and Continue", + "overviewTitle": "Overview" }, "about": { "close": "Close", @@ -1699,17 +1693,17 @@ "targetRemoteDocker": "Docker on SSH host", "targetLocalDocker": "Local Docker container", "targetContainerSshd": "Container sshd", - "targetWsl": "Windows WSL", - "wslDistribution": "Linux distribution", - "wslSelectDistribution": "Select a distribution", - "wslRefresh": "Refresh distributions", - "wslUser": "Linux user (optional)", - "wslDefaultUser": "Distribution default user", - "wslHint": "Connect to an installed WSL distribution on the Windows host. No SSH server is needed.", - "wslUnsupported": "WSL requires a Windows OpenBitFun host. This host does not support native WSL connections.", - "wslNoDistributions": "No WSL distributions found. Install a Linux distribution on the Windows host, then refresh.", - "wslDistributionRequired": "Select an installed WSL distribution.", - "wslDiscoveryFailed": "Could not load WSL distributions.", + "targetWsl": "Windows WSL", + "wslDistribution": "Linux distribution", + "wslSelectDistribution": "Select a distribution", + "wslRefresh": "Refresh distributions", + "wslUser": "Linux user (optional)", + "wslDefaultUser": "Distribution default user", + "wslHint": "Connect to an installed WSL distribution on the Windows host. No SSH server is needed.", + "wslUnsupported": "WSL requires a Windows OpenBitFun host. This host does not support native WSL connections.", + "wslNoDistributions": "No WSL distributions found. Install a Linux distribution on the Windows host, then refresh.", + "wslDistributionRequired": "Select an installed WSL distribution.", + "wslDiscoveryFailed": "Could not load WSL distributions.", "proxyJump": "Jump hosts (ProxyJump)", "proxyJumpPlaceholder": "jump1,jump2", "proxyJumpHint": "Comma-separated SSH config aliases or [user@]host[:port]. Each hop can use its own User and IdentityFile from ~/.ssh/config.", diff --git a/src/web-ui/src/locales/en-US/scenes/miniapp.json b/src/web-ui/src/locales/en-US/scenes/miniapp.json index 77487956b4..b45e4380fd 100644 --- a/src/web-ui/src/locales/en-US/scenes/miniapp.json +++ b/src/web-ui/src/locales/en-US/scenes/miniapp.json @@ -117,7 +117,7 @@ "account": { "menuLabel": "GitHub account @{{login}}", "githubAccount": "GitHub market account", - "dialogTitle": "Sign in to OpenBitFun Market", + "dialogTitle": "Sign in with GitHub", "dialogHeading": "Continue with GitHub", "dialogDescription": "MiniApp Market and Skin Market share this account. Authorization opens in your browser, while credentials stay in the desktop system vault.", "waiting": "Finish authorization in the browser. OpenBitFun will update automatically.", diff --git a/src/web-ui/src/locales/zh-CN/common.json b/src/web-ui/src/locales/zh-CN/common.json index 54f8988e1c..b29299a9d8 100644 --- a/src/web-ui/src/locales/zh-CN/common.json +++ b/src/web-ui/src/locales/zh-CN/common.json @@ -790,7 +790,7 @@ "usernamePlaceholder": "请输入用户名", "passwordPlaceholder": "请输入密码", "authServerPlaceholder": "https://auth.example.com", - "login": "登录", + "login": "使用 GitHub 登录", "cancel": "取消", "emptyFields": "请填写所有字段", "invalidCredentialsLength": "用户名或密码过长", @@ -798,7 +798,7 @@ "insecureServerTitle": "服务器连接未加密", "insecureServerConfirm": "此服务器使用未加密的 HTTP,网络攻击者可能截获登录会话。仍要继续吗?", "continueInsecure": "仍要继续", - "securityNote": "密码仅在本设备处理,中继服务只会收到派生后的验证值。", + "securityNote": "在浏览器中通过 GitHub 授权 OpenBitFun。设备间消息使用端到端加密。", "showPassword": "显示密码", "hidePassword": "隐藏密码", "loginSuccess": "登录成功:{{user_id}}", @@ -885,7 +885,8 @@ "syncPhaseExportingSessions": "正在备份会话 {{current}}/{{total}}", "copyServerUrl": "复制服务器地址", "copyServerFailed": "无法复制服务器地址,请检查剪贴板权限后重试。", - "loginValueProp": "登录以关联你的多台设备:互相远程控制,并云同步设置与会话" + "loginValueProp": "登录以关联你的多台设备,并互相远程控制。", + "linkedDevices": "已关联设备" }, "relayDeploy": { "entryHint": "还没有中继服务器?", @@ -977,7 +978,7 @@ "noConnectedClients": "暂无设备连接。", "serverUrlCopied": "服务器地址已复制", "copyServerUrlFailed": "无法复制服务器地址,请手动复制。", - "connectedClients": "已连接的浏览器与设备", + "connectedClients": "已接入设备", "clientCountHint": "每个浏览器页面计为一个连接。", "clientCount": "{{formattedCount}} 个连接", "clientCountAtLeast": "至少 {{formattedCount}} 个连接", @@ -985,35 +986,33 @@ "pairedClient": "配对连接", "clientDetailsUnavailable": "暂时无法获取连接明细。", "centerTitle": "设备与连接", - "overviewIntro": "管理登录 OpenBitFun 的设备,或让其他设备接入当前工作区", + "overviewIntro": "管理你的设备,以及接入当前工作区的连接。", "myDevicesTitle": "我的设备", - "accountDevicesTitle": "OpenBitFun 账号与设备", - "myDevicesDescription": "登录后同步设置和会话,并在自己的设备之间切换", + "accountDevicesTitle": "GitHub 账号与设备", + "myDevicesDescription": "选择在线设备,查看并继续它的会话。", "accountSignedIn": "已登录", "accountSignedOut": "未登录", "connectThisDeviceTitle": "连接这台设备", - "connectThisDeviceDescription": "无需登录 OpenBitFun 账号", + "connectThisDeviceDescription": "使用同一 GitHub 账号登录后连接", "mobileBrowserTitle": "手机或浏览器", - "mobileBrowserDescription": "通过同一网络、中继或 ngrok,在手机或其他浏览器中打开当前工作区", + "mobileBrowserDescription": "在手机或浏览器中继续当前工作区。", "chatAppsTitle": "聊天应用", - "chatAppsDescription": "通过 Telegram、飞书或微信向当前工作区发送消息", + "chatAppsDescription": "连接聊天应用,向当前工作区发送消息。", "notConnected": "未连接", "backToOverview": "返回设备与连接", "cancelAndBack": "取消连接并返回", "methodSameNetwork": "同一网络", - "methodOpenBitFunRelay": "OpenBitFun 中继", - "methodNgrok": "内网穿透", + "methodOpenBitFunRelay": "官方中继", "methodSelfHosted": "自建中继", "showConnectionCode": "显示连接码", "getPairingCode": "获取配对码", "connecting": "连接中...", "disconnect": "断开连接", "cancel": "取消", - "scanHint": "使用手机扫描二维码进行连接", + "scanHint": "扫描二维码后,使用同一 GitHub 账号登录。", "botHint": "将配对码发送给机器人完成配对", "connectedHint": "移动设备已连接,可以关闭此对话框", "connectedUserId": "用户 ID", - "ngrokUsageLink": "查看 ngrok 隧道用量与状态", "serverUrl": "服务器地址", "currentIp": "当前 IP", "gatewayIp": "网关 IP", @@ -1023,7 +1022,7 @@ "stateWaiting": "等待连接...", "urlCopied": "已复制 URL", "copyUrl": "复制配对链接", - "workspaceAddress": "当前工作区地址", + "workspaceAddress": "连接地址", "copyUrlFailed": "无法复制配对链接,请手动复制或检查剪贴板权限。", "weixinQrAlt": "微信登录二维码", "stateWaitingBot": "等待机器人确认...", @@ -1037,10 +1036,6 @@ "stateDisconnected": "已断开", "desc_lan": "通过局域网连接,两台设备需在同一LAN/WiFi下。若连接失败,请检查路由器安全设置(如AP隔离/客户端隔离)是否限制局域网互访,并确认两台设备的代理服务设置未影响局域网流量(允许直连局域网)。", "desc_openbitfun_server": "通过OpenBitFun中继服务器连接。当前该服务器由OpenBitFun团队自建,主要用于便捷试用与功能体验。尽管业务消息采用端到端加密,中继服务及其相关基础设施仍可能感知必要元数据,且任何公网服务都可能因配置缺陷、日志暴露、运营失误或遭受攻击而带来信息安全与数据泄露风险。若用于长期使用、正式环境或更高安全要求场景,建议优先使用自建服务器,并自行控制部署与运维策略。", - "desc_ngrok": "通过ngrok隧道连接,首次使用需安装ngrok并完成授权,无需手动启动ngrok服务。", - "desc_ngrok_prefix": "通过ngrok隧道连接,首次使用需", - "desc_ngrok_link": "安装ngrok", - "desc_ngrok_suffix": "并完成授权,无需手动启动ngrok服务。", "desc_custom_server": "一键部署到自己的服务器", "desc_custom_server_prefix": "", "desc_custom_server_link": "一键部署到自己的服务器", @@ -1078,7 +1073,6 @@ "botVerboseMode": "详细模式", "botConciseMode": "简洁模式", "botConnectedDescription": "当前工作区已可通过该聊天应用收发消息", - "openNgrokSetup": "打开 ngrok 安装与配置页面", "disclaimerTitle": "远程连接免责声明", "disclaimerIntro": "启用远程连接前,请确认你已理解并接受以下事项:", "disclaimerKeyRisks": "核心风险", @@ -1088,10 +1082,9 @@ "disclaimerItemEncryption": "远程连接采用端到端加密(X25519 ECDH + AES-256-GCM,每次会话生成临时密钥对)传输业务消息,中继服务器无法解密消息内容;但设备名称、连接状态、服务地址等必要元数据及其他连接上下文信息不属于业务消息密文范畴,仍可能被网络路径、服务节点或其他基础设施感知。", "disclaimerItemOpenSource": "OpenBitFun 远程连接的加密实现完全开源,你可以自行审计源码以验证安全性。", "disclaimerItemPrivacy": "请勿在不受信任环境中传输敏感信息;机器人消息平台、第三方网络、系统日志策略等可能影响你的数据暴露面,其他外部服务策略变化亦可能带来额外风险。", - "disclaimerItemDataUsage": "OpenBitFun 不会将你的远程连接业务内容用于训练或商业化使用;但你选择接入的第三方服务(如 ngrok、Telegram、飞书、自建服务器运维组件等)可能按其条款处理数据,其他关联服务亦可能产生数据处理行为。", - "disclaimerItemCredentials": "使用 ngrok、自建中继、Telegram/飞书机器人等方式时,相关地址、Token、App Secret、密钥及其他访问凭证由你自行保管,泄露可能导致未授权访问或其他安全事件。", + "disclaimerItemDataUsage": "OpenBitFun 不会将你的远程连接业务内容用于训练或商业化使用;但你选择接入的第三方服务(如 Telegram、飞书、自建服务器运维组件等)可能按其条款处理数据,其他关联服务亦可能产生数据处理行为。", + "disclaimerItemCredentials": "使用 自建中继、Telegram/飞书机器人等方式时,相关地址、Token、App Secret、密钥及其他访问凭证由你自行保管,泄露可能导致未授权访问或其他安全事件。", "disclaimerItemQrCode": "二维码中包含中继地址、房间标识、公钥等连接信息及其他会话标识,请避免将其截图、录屏或展示给无关人员。", - "disclaimerItemNgrok": "使用 ngrok 或其他第三方隧道服务时,流量会经过外部服务链路。请自行评估账号安全、地域合规、配额限制、服务可用性与潜在中间链路风险等因素。", "disclaimerItemSelfHosted": "使用自建中继服务时,你需自行负责主机与容器安全(包括但不限于系统补丁、访问控制、TLS 证书、端口暴露、防火墙、DDoS/爆破防护、日志审计、备份恢复等)。服务器被攻击、配置错误、密钥泄露或其他运维失误可能导致连接中断、数据泄露或被篡改风险。", "disclaimerItemNetwork": "网络环境、防火墙、路由器隔离策略(如 AP/客户端隔离)、代理/隧道服务设置等以及其他网络策略都可能影响连接结果与稳定性。", "disclaimerItemBot": "机器人模式下发送的配对码仅用于当前会话绑定,请勿向无关人员泄露;其他机器人平台机制或策略变更也可能影响安全性与可用性。", @@ -1103,7 +1096,8 @@ "disclaimerStatusAgreed": "已同意", "disclaimerStatusPending": "未同意", "disclaimerDecline": "不同意", - "disclaimerAgree": "同意并继续" + "disclaimerAgree": "同意并继续", + "overviewTitle": "概览" }, "about": { "close": "关闭", @@ -1699,17 +1693,17 @@ "targetRemoteDocker": "SSH 主机上的 Docker", "targetLocalDocker": "本地 Docker 容器", "targetContainerSshd": "容器 sshd", - "targetWsl": "Windows WSL", - "wslDistribution": "Linux 发行版", - "wslSelectDistribution": "选择发行版", - "wslRefresh": "刷新发行版", - "wslUser": "Linux 用户(可选)", - "wslDefaultUser": "使用发行版默认用户", - "wslHint": "直接连接 Windows 主机上已安装的 WSL 发行版,无需配置 SSH 服务。", - "wslUnsupported": "WSL 需要运行在 Windows 上的 OpenBitFun 主机,当前主机不支持直接连接 WSL。", - "wslNoDistributions": "未找到 WSL 发行版,请在 Windows 主机上安装 Linux 发行版后刷新。", - "wslDistributionRequired": "请选择已安装的 WSL 发行版。", - "wslDiscoveryFailed": "无法加载 WSL 发行版。", + "targetWsl": "Windows WSL", + "wslDistribution": "Linux 发行版", + "wslSelectDistribution": "选择发行版", + "wslRefresh": "刷新发行版", + "wslUser": "Linux 用户(可选)", + "wslDefaultUser": "使用发行版默认用户", + "wslHint": "直接连接 Windows 主机上已安装的 WSL 发行版,无需配置 SSH 服务。", + "wslUnsupported": "WSL 需要运行在 Windows 上的 OpenBitFun 主机,当前主机不支持直接连接 WSL。", + "wslNoDistributions": "未找到 WSL 发行版,请在 Windows 主机上安装 Linux 发行版后刷新。", + "wslDistributionRequired": "请选择已安装的 WSL 发行版。", + "wslDiscoveryFailed": "无法加载 WSL 发行版。", "proxyJump": "跳板链(ProxyJump)", "proxyJumpPlaceholder": "jump1,jump2", "proxyJumpHint": "使用逗号分隔 SSH 配置别名或 [user@]host[:port]。每一跳可从 ~/.ssh/config 使用独立的 User 和 IdentityFile。", diff --git a/src/web-ui/src/locales/zh-CN/scenes/miniapp.json b/src/web-ui/src/locales/zh-CN/scenes/miniapp.json index 4d0720cfa2..7ff2afceee 100644 --- a/src/web-ui/src/locales/zh-CN/scenes/miniapp.json +++ b/src/web-ui/src/locales/zh-CN/scenes/miniapp.json @@ -117,7 +117,7 @@ "account": { "menuLabel": "GitHub 账号 @{{login}}", "githubAccount": "GitHub 市场账号", - "dialogTitle": "登录 OpenBitFun 市场", + "dialogTitle": "使用 GitHub 登录", "dialogHeading": "使用 GitHub 继续", "dialogDescription": "MiniApp 市场与 Skin 市场共用此账号。授权将在浏览器中完成,凭证只保存在桌面系统凭证库中。", "waiting": "请在浏览器中完成授权,OpenBitFun 会自动更新登录状态。", diff --git a/src/web-ui/src/locales/zh-TW/common.json b/src/web-ui/src/locales/zh-TW/common.json index f4b6d9ea13..3fda5d093a 100644 --- a/src/web-ui/src/locales/zh-TW/common.json +++ b/src/web-ui/src/locales/zh-TW/common.json @@ -790,7 +790,7 @@ "usernamePlaceholder": "請輸入帳號", "passwordPlaceholder": "請輸入密碼", "authServerPlaceholder": "https://auth.example.com", - "login": "登入", + "login": "使用 GitHub 登入", "cancel": "取消", "emptyFields": "請填寫所有欄位", "invalidCredentialsLength": "帳號或密碼過長", @@ -798,7 +798,7 @@ "insecureServerTitle": "伺服器連線未加密", "insecureServerConfirm": "此伺服器使用未加密的 HTTP,網路攻擊者可能截取登入工作階段。仍要繼續嗎?", "continueInsecure": "仍要繼續", - "securityNote": "密碼只在本裝置處理,中繼服務只會收到衍生後的驗證值。", + "securityNote": "在瀏覽器中透過 GitHub 授權 OpenBitFun。裝置間訊息使用端對端加密。", "showPassword": "顯示密碼", "hidePassword": "隱藏密碼", "loginSuccess": "登入成功:{{user_id}}", @@ -885,7 +885,8 @@ "syncPhaseExportingSessions": "正在備份工作階段 {{current}}/{{total}}", "copyServerUrl": "複製伺服器位址", "copyServerFailed": "無法複製伺服器位址,請檢查剪貼簿權限後重試。", - "loginValueProp": "登入以關聯你的多台裝置:互相遠端控制,並雲端同步設定與工作階段" + "loginValueProp": "登入以連結你的多台裝置,並互相遠端控制。", + "linkedDevices": "已關聯裝置" }, "relayDeploy": { "entryHint": "還沒有中繼伺服器?", @@ -977,7 +978,7 @@ "noConnectedClients": "尚無裝置連線。", "serverUrlCopied": "已複製伺服器位址", "copyServerUrlFailed": "無法複製伺服器位址,請手動複製。", - "connectedClients": "已連線的瀏覽器與裝置", + "connectedClients": "已接入裝置", "clientCountHint": "每個瀏覽器頁面計為一個連線。", "clientCount": "{{formattedCount}} 個連線", "clientCountAtLeast": "至少 {{formattedCount}} 個連線", @@ -985,35 +986,33 @@ "pairedClient": "配對連線", "clientDetailsUnavailable": "暫時無法取得連線明細。", "centerTitle": "裝置與連線", - "overviewIntro": "管理登入 OpenBitFun 的裝置,或讓其他裝置連線至目前工作區", + "overviewIntro": "管理你的裝置,以及接入目前工作區的連線。", "myDevicesTitle": "我的裝置", - "accountDevicesTitle": "OpenBitFun 帳號與裝置", - "myDevicesDescription": "登入後同步設定與工作階段,並在自己的裝置之間切換", + "accountDevicesTitle": "GitHub 帳號與裝置", + "myDevicesDescription": "選擇線上裝置,查看並繼續它的對話。", "accountSignedIn": "已登入", "accountSignedOut": "未登入", "connectThisDeviceTitle": "連線至此裝置", - "connectThisDeviceDescription": "無需登入 OpenBitFun 帳號", + "connectThisDeviceDescription": "使用同一 GitHub 帳號登入後連線", "mobileBrowserTitle": "手機或瀏覽器", - "mobileBrowserDescription": "透過相同網路、中繼或 ngrok,在手機或其他瀏覽器中開啟目前工作區", + "mobileBrowserDescription": "在手機或瀏覽器中繼續目前工作區。", "chatAppsTitle": "聊天應用程式", - "chatAppsDescription": "透過 Telegram、飛書或微信向目前工作區傳送訊息", + "chatAppsDescription": "連接聊天應用程式,向目前工作區傳送訊息。", "notConnected": "尚未連線", "backToOverview": "返回裝置與連線", "cancelAndBack": "取消連線並返回", "methodSameNetwork": "相同網路", - "methodOpenBitFunRelay": "OpenBitFun 中繼", - "methodNgrok": "內網穿透", + "methodOpenBitFunRelay": "官方中繼", "methodSelfHosted": "自建中繼", "showConnectionCode": "顯示連線碼", "getPairingCode": "取得配對碼", "connecting": "連接中...", "disconnect": "斷開連接", "cancel": "取消", - "scanHint": "使用手機掃描二維碼進行連接", + "scanHint": "掃描 QR Code 後,使用相同 GitHub 帳號登入。", "botHint": "將配對碼發送給機器人完成配對", "connectedHint": "移動設備已連接,可以關閉此對話框", "connectedUserId": "用戶 ID", - "ngrokUsageLink": "查看 ngrok 隧道用量與狀態", "serverUrl": "伺服器地址", "currentIp": "目前 IP", "gatewayIp": "網關 IP", @@ -1023,7 +1022,7 @@ "stateWaiting": "等待連接...", "urlCopied": "已複製 URL", "copyUrl": "複製配對連結", - "workspaceAddress": "目前工作區位址", + "workspaceAddress": "連線位址", "copyUrlFailed": "無法複製配對連結,請手動複製或檢查剪貼簿權限。", "weixinQrAlt": "微信登入二維碼", "stateWaitingBot": "等待機器人確認...", @@ -1037,10 +1036,6 @@ "stateDisconnected": "已斷開", "desc_lan": "通過局域網連接,兩臺設備需在同一LAN/WiFi下。若連接失敗,請檢查路由器安全設置(如AP隔離/客戶端隔離)是否限制局域網互訪,並確認兩臺設備的代理服務設置未影響局域網流量(允許直連局域網)。", "desc_openbitfun_server": "通過OpenBitFun中繼伺服器連接。目前該伺服器由OpenBitFun團隊自建,主要用於便捷試用與功能體驗。儘管業務消息採用端到端加密,中繼服務及其相關基礎設施仍可能感知必要元資料,且任何公網服務都可能因設定缺陷、日誌暴露、運營失誤或遭受攻擊而帶來資訊安全與資料洩露風險。若用於長期使用、正式環境或更高安全要求場景,建議優先使用自建伺服器,並自行控制部署與運維策略。", - "desc_ngrok": "通過ngrok隧道連接,首次使用需安裝ngrok並完成授權,無需手動啟動ngrok服務。", - "desc_ngrok_prefix": "通過ngrok隧道連接,首次使用需", - "desc_ngrok_link": "安裝ngrok", - "desc_ngrok_suffix": "並完成授權,無需手動啟動ngrok服務。", "desc_custom_server": "一鍵部署到自己的伺服器", "desc_custom_server_prefix": "", "desc_custom_server_link": "一鍵部署到自己的伺服器", @@ -1078,7 +1073,6 @@ "botVerboseMode": "詳細模式", "botConciseMode": "簡潔模式", "botConnectedDescription": "目前工作區已可透過此聊天應用程式收發訊息", - "openNgrokSetup": "開啟 ngrok 安裝與設定頁面", "disclaimerTitle": "遠程連接免責聲明", "disclaimerIntro": "啟用遠程連接前,請確認你已理解並接受以下事項:", "disclaimerKeyRisks": "核心風險", @@ -1088,10 +1082,9 @@ "disclaimerItemEncryption": "遠程連接採用端到端加密(X25519 ECDH + AES-256-GCM,每次會話生成臨時密鑰對)傳輸業務消息,中繼伺服器無法解密消息內容;但設備名稱、連接狀態、服務地址等必要元資料及其他連接上下文資訊不屬於業務消息密文範疇,仍可能被網絡路徑、服務節點或其他基礎設施感知。", "disclaimerItemOpenSource": "OpenBitFun 遠程連接的加密實現完全開源,你可以自行審計源碼以驗證安全性。", "disclaimerItemPrivacy": "請勿在不受信任環境中傳輸敏感資訊;機器人消息平臺、第三方網絡、系統日誌策略等可能影響你的資料暴露面,其他外部服務策略變化亦可能帶來額外風險。", - "disclaimerItemDataUsage": "OpenBitFun 不會將你的遠程連接業務內容用於訓練或商業化使用;但你選擇接入的第三方服務(如 ngrok、Telegram、飛書、自建伺服器運維組件等)可能按其條款處理資料,其他關聯服務亦可能產生資料處理行為。", - "disclaimerItemCredentials": "使用 ngrok、自建中繼、Telegram/飛書機器人等方式時,相關地址、Token、App Secret、密鑰及其他訪問憑證由你自行保管,洩露可能導致未授權訪問或其他安全事件。", + "disclaimerItemDataUsage": "OpenBitFun 不會將你的遠程連接業務內容用於訓練或商業化使用;但你選擇接入的第三方服務(如 Telegram、飛書、自建伺服器運維組件等)可能按其條款處理資料,其他關聯服務亦可能產生資料處理行為。", + "disclaimerItemCredentials": "使用 自建中繼、Telegram/飛書機器人等方式時,相關地址、Token、App Secret、密鑰及其他訪問憑證由你自行保管,洩露可能導致未授權訪問或其他安全事件。", "disclaimerItemQrCode": "二維碼中包含中繼地址、房間標識、公鑰等連接資訊及其他會話標識,請避免將其截圖、錄屏或展示給無關人員。", - "disclaimerItemNgrok": "使用 ngrok 或其他第三方隧道服務時,流量會經過外部服務鏈路。請自行評估賬號安全、地域合規、配額限制、服務可用性與潛在中間鏈路風險等因素。", "disclaimerItemSelfHosted": "使用自建中繼服務時,你需自行負責主機與容器安全(包括但不限於系統補丁、訪問控制、TLS 證書、端口暴露、防火牆、DDoS/爆破防護、日誌審計、備份恢復等)。伺服器被攻擊、設定錯誤、密鑰洩露或其他運維失誤可能導致連接中斷、資料洩露或被篡改風險。", "disclaimerItemNetwork": "網絡環境、防火牆、路由器隔離策略(如 AP/客戶端隔離)、代理/隧道服務設置等以及其他網絡策略都可能影響連接結果與穩定性。", "disclaimerItemBot": "機器人模式下發送的配對碼僅用於目前會話綁定,請勿向無關人員洩露;其他機器人平臺機制或策略變更也可能影響安全性與可用性。", @@ -1103,7 +1096,8 @@ "disclaimerStatusAgreed": "已同意", "disclaimerStatusPending": "未同意", "disclaimerDecline": "不同意", - "disclaimerAgree": "同意並繼續" + "disclaimerAgree": "同意並繼續", + "overviewTitle": "概覽" }, "about": { "close": "關閉", @@ -1699,17 +1693,17 @@ "targetRemoteDocker": "SSH 主機上的 Docker", "targetLocalDocker": "本機 Docker 容器", "targetContainerSshd": "容器 sshd", - "targetWsl": "Windows WSL", - "wslDistribution": "Linux 發行版", - "wslSelectDistribution": "選擇發行版", - "wslRefresh": "重新整理發行版", - "wslUser": "Linux 使用者(選填)", - "wslDefaultUser": "使用發行版預設使用者", - "wslHint": "直接連線至 Windows 主機上已安裝的 WSL 發行版,無需設定 SSH 服務。", - "wslUnsupported": "WSL 需要執行於 Windows 的 OpenBitFun 主機,目前主機不支援直接連線至 WSL。", - "wslNoDistributions": "找不到 WSL 發行版,請在 Windows 主機上安裝 Linux 發行版後重新整理。", - "wslDistributionRequired": "請選擇已安裝的 WSL 發行版。", - "wslDiscoveryFailed": "無法載入 WSL 發行版。", + "targetWsl": "Windows WSL", + "wslDistribution": "Linux 發行版", + "wslSelectDistribution": "選擇發行版", + "wslRefresh": "重新整理發行版", + "wslUser": "Linux 使用者(選填)", + "wslDefaultUser": "使用發行版預設使用者", + "wslHint": "直接連線至 Windows 主機上已安裝的 WSL 發行版,無需設定 SSH 服務。", + "wslUnsupported": "WSL 需要執行於 Windows 的 OpenBitFun 主機,目前主機不支援直接連線至 WSL。", + "wslNoDistributions": "找不到 WSL 發行版,請在 Windows 主機上安裝 Linux 發行版後重新整理。", + "wslDistributionRequired": "請選擇已安裝的 WSL 發行版。", + "wslDiscoveryFailed": "無法載入 WSL 發行版。", "proxyJump": "跳板鏈(ProxyJump)", "proxyJumpPlaceholder": "jump1,jump2", "proxyJumpHint": "使用逗號分隔 SSH 設定別名或 [user@]host[:port]。每一跳可從 ~/.ssh/config 使用獨立的 User 和 IdentityFile。", diff --git a/src/web-ui/src/locales/zh-TW/scenes/miniapp.json b/src/web-ui/src/locales/zh-TW/scenes/miniapp.json index 0199f5ada3..7406efa4d1 100644 --- a/src/web-ui/src/locales/zh-TW/scenes/miniapp.json +++ b/src/web-ui/src/locales/zh-TW/scenes/miniapp.json @@ -117,7 +117,7 @@ "account": { "menuLabel": "GitHub 帳號 @{{login}}", "githubAccount": "GitHub 市場帳號", - "dialogTitle": "登入 OpenBitFun 市場", + "dialogTitle": "使用 GitHub 登入", "dialogHeading": "使用 GitHub 繼續", "dialogDescription": "MiniApp 市場與 Skin 市場共用此帳號。授權會在瀏覽器中完成,憑證只儲存在桌面系統憑證庫中。", "waiting": "請在瀏覽器中完成授權,OpenBitFun 會自動更新登入狀態。",