diff --git a/.agents/notes/implemented/bug-fix/2026-10-13-file-transfer-settings-read.md b/.agents/notes/implemented/bug-fix/2026-10-13-file-transfer-settings-read.md new file mode 100644 index 00000000..a5e3bdf1 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-10-13-file-transfer-settings-read.md @@ -0,0 +1,19 @@ +# Agent Note: 文件传输设置在可见且已登录时读取 + +Status: implemented + +## Problem + +文件传输设置挂载时立即读取网关,登录前会把“请登录”留在外观面板。后续成功读取只更新输入值,没有清除这条旧错误。桌面高级面板也会在隐藏时提前请求设置。 + +## Decision + +文件传输设置仅在面板可见、页面未隐藏且调用方已就绪时读取。Web 调用方通过已显示的登录后应用判定就绪;桌面本地调用方显式提供就绪条件。面板揭示、窗口聚焦及登录完成事件触发读取,不增加轮询。成功读取清除旧读取错误,正在编辑的值不被覆盖。 + +## Alternatives considered + +在登录时自动重试而保留挂载时请求,触发次数更少,但仍会产生一次无意义的未授权请求,也不能覆盖桌面隐藏面板的场景。 + +## Consequences + +未打开的设置面板不再触发读取。错误只在用户实际查看设置时出现,并会随下一次成功读取消失。独立保存按钮仍更新 clickDownloadMiB,不进入网关通用设置草稿。桌面设置测试验证隐藏挂载、揭示读取与独立保存。 diff --git a/.agents/notes/implemented/bug-fix/2026-10-13-gateway-cookie-namespace.md b/.agents/notes/implemented/bug-fix/2026-10-13-gateway-cookie-namespace.md new file mode 100644 index 00000000..90031c22 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-10-13-gateway-cookie-namespace.md @@ -0,0 +1,21 @@ +# Agent Note: 网关会话 Cookie 按实例隔离 + +Status: implemented + +## Problem + +两个本地网关可以使用同一 IP 的不同端口,但浏览器和 WebView 的 Cookie 不按端口隔离。固定的 `codex_mobile_session` 名称会让后登录的网关覆盖前一个网关的登录状态。 + +## Decision + +网关在自己的 `auth-sessions.json` 中持久保存随机 `cookieName`。它与每次启动变化的 `instanceId` 分离,也不随密码策略修改而变化。新登录只签发该名称;请求优先使用该名称,即使其值无效,也不回退到旧名称。只有旧 Cookie 的令牌仍在本机网关会话表中有效时才接受并通过下一次成功响应迁移。注销仅清除本机名称;另一个网关可能占有旧名称。 + +`/api/auth` 返回 `cookieName` 供原生客户端按保存的连接地址选择 Cookie。Android 和 iOS 在认证成功后记录名称;旧网关和路径隔离的中继仍使用旧名称。通知、下载、连接发现和移除流程沿用该地址的名称,不把进程临时 ID 当作持久身份。 + +## Alternatives considered + +直接把随机 `instanceId` 放入 Cookie 名称能立即区分并发进程,但网关重启就要求手机重新配对,违反持久登录。按端口设置 Cookie 不可行,因为 Cookie 协议不隔离端口。 + +## Consequences + +同一主机的不同网关可以并存并保留各自登录;旧令牌在有效期内有条件迁移。新增公开的 Cookie 名称不是认证秘密。原生客户端必须维护按连接地址的名称映射,且真实设备的 Cookie 存储行为仍需平台构建验证。 diff --git a/.agents/notes/implemented/feature/2026-10-12-shared-client-list-refresh.md b/.agents/notes/implemented/feature/2026-10-12-shared-client-list-refresh.md new file mode 100644 index 00000000..6eb23f6a --- /dev/null +++ b/.agents/notes/implemented/feature/2026-10-12-shared-client-list-refresh.md @@ -0,0 +1,35 @@ +# Agent Note: Claude 与 DSH 列表的手动下拉刷新 + +Status: implemented + +## Problem + +Claude 与 DSH 只有自动轮询,用户无法像 Codex 一样通过下拉确认列表已更新。原刷新入口吞掉读取错误,不能直接作为手动刷新成功的判据。 + +## Decision + +Claude 与 DSH 将共享 `ListSync` 接到实际 `.sessions` 滚动容器,复用顶部下拉阈值、横向取消及加载、成功、失败重试提示。提示位于独立列表区域,拉动内容不改变滚动区域高度,短列表和空列表也保留触摸区域。 + +`DesktopSessionsView.refreshList()` 只读取列表,手动刷新不触发会话详情读取、恢复、消息或审批。它与自动轮询、前台恢复共用 `sharedRead` 及当前视图的列表处理 Promise;同一轮读取不追加重复请求。列表失败会向手动刷新传递错误,断开连接的响应不显示更新成功。原有自动轮询负责定时及前台恢复,桌面客户端的 `ListSync` 不再单独启动另一套定时器。 + +客户端切换、账号读取失效、登出及页面隐藏重置手势与提示;刷新修订号保证旧请求不能修改新提示或解除新请求的忙碌状态。现有 generation、读取 epoch 继续约束数据结果。渲染使用最新搜索和筛选,保留所选会话、草稿及可保留的列表位置;列表中部优先按首个可见会话锚定,数据插入后仍保持该会话的相对位置,锚点消失时保留可用的滚动偏移。 + +已有列表时,前两次自动读取失败保持旧列表且不弹出提示;连续第三次失败显示可点击重试。手动失败和没有旧列表的首次失败立即提示。当前身份与修订号核验通过的任何列表成功读取都会清除旧错误,包括手势之外的列表入口;过期结果不能清除它。认证失败交给登录流程,不作为列表重试提示。 + +历史决策检索未发现活跃笔记中已有同一 ListSync/手动下拉刷新契约。本记录落实本地 v2 优化评审第 4 项,不引入连接管理或写操作的重试策略。 + +## Alternatives considered + +- 各客户端独立实现手势:能单独调整体验,但会重复阈值、取消与提示逻辑,现有共享组件已覆盖这些行为。 +- 直接调用原轮询入口:能复用现有读取流程,但还会读取已选会话详情且吞掉错误,无法准确反馈手动列表刷新结果。 +- 整页重新加载:容易实现,但会丢失阅读状态并触发无关初始化,不采用。 + +## Testing + +Node 回归覆盖并发读取合并、等待完成、失败与断开连接重试、筛选和草稿保留、客户端与账号失效、旧请求不得解锁新请求。隔离浏览器检查 390/844/1440px 下两个客户端的长列表、短列表、空列表、顶部手势、中部滚动、横向取消及失败重试;使用合成数据,不操作真实账号或任务。 + +另有 ListSync 回归覆盖短暂自动失败不打断旧列表、连续失败提示、其他入口成功清除错误、认证失败与过期结果保护。 + +## Consequences + +三个客户端共享手势及结果反馈,手动确认不增加写操作或重复轮询。代价是共享组件需要维护可重置的请求修订号,列表读取与轮询编排分开。浏览器验证不代替 Android/iOS 真机 WebView 的回弹体验验收。 diff --git a/.agents/notes/implemented/feature/2026-10-13-native-binding-recovery.md b/.agents/notes/implemented/feature/2026-10-13-native-binding-recovery.md new file mode 100644 index 00000000..50334f22 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-10-13-native-binding-recovery.md @@ -0,0 +1,23 @@ +# Agent Note: 原生设备绑定恢复 + +Status: implemented + +## Problem + +手机原生壳的 WebView Cookie 可能在应用升级或系统清理后丢失。服务器仍保留已认证、可撤销的设备会话,但原生壳没有独立的安全副本,用户因此需要重新登录或扫码。 + +## Decision + +两端只备份已通过 `/api/auth` 确认的 `trustedDevice` 会话令牌,按保存的电脑地址索引。Android 使用 AndroidKeyStore 的 AES-GCM 密钥加密副本,密文保存在应用私有偏好设置;iOS 使用 `WhenUnlockedThisDeviceOnly` Keychain 项。令牌仍由网关或中继的现有会话表撤销,不新增密码、二维码或不可撤销的第二套凭据。 + +WebView 加载前,如 Cookie 缺失或与安全副本不符,原生端用只读、同源、禁止重定向、限时限量的 `/api/auth` 检查当前 Cookie 和安全副本。首次登录时先记录完整 Cookie 快照,待网关返回 `cookieName` 后才选择对应令牌;旧名迁移只接受同一令牌,不因同主机其他网关的 Cookie 误存。当前 Cookie 有效则保留并在正常认证回调中更新副本;安全副本被服务器确认有效且其 Cookie 名称属于该网关时才恢复。无 `cookieName` 的旧直连网关及中继仅接受旧 Cookie 名。明确无效时删除副本,网络错误时保留以供下次尝试。已打开页面收到 401 时,每次失效周期只触发一次原生校验和只读页面重新加载;恢复不重放消息或写请求。异步校验完成时再次检查设备凭据、页面代际和当前 Cookie,避免退出或移除后的迟到结果复活会话。显式退出与移除删除该地址的安全副本及对应 Cookie。 + +## Alternatives considered + +重新保存密码或可复用扫码载荷能够再次登录,但扩大了长期秘密和配对能力。另设恢复令牌及 HTTP 端点可以与登录令牌分离,却需要新的服务器存储、撤销和中继协议;现有原生设备会话已经持久且可撤销,复用它更小。 + +## Consequences + +Cookie 丢失后,仍有效的绑定可在加载登录界面前或已打开页面遇到 401 时恢复;服务器撤销后不能恢复。需要恢复时会增加有限时的只读校验,离线时仍进入原有页面错误路径。Android KeyStore 或 iOS Keychain 不可用时不会把明文令牌写入普通偏好设置。真实设备上的存储与系统清理行为仍需设备验证。 + +相关 Cookie 名称和旧版兼容规则见[网关会话 Cookie 隔离记录](../bug-fix/2026-10-13-gateway-cookie-namespace.md)。历史笔记只有该记录部分重叠;它规定网关命名,本篇规定原生安全副本与恢复时序。 diff --git a/.agents/notes/implemented/feature/2026-10-13-quiet-client-list-status.md b/.agents/notes/implemented/feature/2026-10-13-quiet-client-list-status.md new file mode 100644 index 00000000..17bce836 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-10-13-quiet-client-list-status.md @@ -0,0 +1,19 @@ +# Agent Note: Claude 与 DSH 列表状态只提示需关注的结果 + +Status: implemented + +## Problem + +客户端列表在每次轮询成功后显示“已连接桌面”,并在已有缓存会话时反复显示“正在读取聊天”。这两种正常状态占用列表空间,却没有给用户新的行动信息。 + +## Decision + +有会话的正常列表读取完成后清空列表状态;已有缓存会话时,重新读取不显示等待文案。首次无缓存读取仍显示等待状态;空列表显示无聊天提示。连接初始化、断开、失败和可操作的重试提示继续由现有连接状态流程展示。 + +## Alternatives considered + +保留“已连接桌面”并只减小字号可以继续提供连接确认,但它仍在每次成功轮询后常驻,无法让真正的异常更突出。 + +## Consequences + +列表更安静,空列表和连接异常仍有明确解释。连接成功本身不再是列表中的持续文案;连接状态仍由客户端管理和会话详情各自展示。桌面会话 UI 回归覆盖正常、空列表、失败与初始化状态。 diff --git a/.agents/notes/implemented/feature/2026-10-13-third-party-notification-settings.md b/.agents/notes/implemented/feature/2026-10-13-third-party-notification-settings.md new file mode 100644 index 00000000..142f556e --- /dev/null +++ b/.agents/notes/implemented/feature/2026-10-13-third-party-notification-settings.md @@ -0,0 +1,23 @@ +# Agent Note: 手机端第三方通知配置 + +Status: implemented + +## Problem + +手机端原先只能配置 PushPlus,Bark 和 ntfy 需要回到电脑端。三个通道实际共用电脑网关的通知配置与发送器,因此手机端缺少入口会让同一份设置出现不同的操作能力。 + +## Decision + +手机网页和 App 的「第三方通知」列出 Bark、ntfy、PushPlus,各通道详情只编辑自己的开关、服务地址、主题或密钥。通用区编辑 `includeTitle`、`clickBase`、`securityEnabled`、`addressEnabled` 和 `addressName`;全会话请求与完成通知继续由现有设置控制,原生手机通知权限与收件箱保持独立。 + +HTTP 在已认证与 CSRF 校验之后,通过 `/api/notifications/channels/{bark,ntfy,pushplus}` 和 `/api/notifications/channels/common` 分别限制可写字段。它复用 `bridge/features/notifications/channels.py` 的持久化、校验和发送器,读取密钥只返回是否已保存。留空密钥沿用原配置;更换 Bark 或 ntfy 服务地址时清除旧密钥。`/test` 只发送该通道已经保存的配置,只有用户按下测试按钮才会发送。旧 `/api/notifications/pushplus` 路由继续可用。 + +通用区读取成功之前不能保存;关闭、重新打开或进入通道详情后,旧读取结果不得回填当前表单。服务列表显示未配置、已关闭、已开启或读取失败,便于判断下一步操作。 + +## Alternatives considered + +可以在手机端维护另一份通知配置,但会产生与桌面网关状态冲突的密钥和开关。也可以直接开放桌面设置快照,然而其中包含更多与手机端无关的网关选项,扩大可写范围。按通道与通用字段分开限制,更贴近现有权限边界。 + +## Consequences + +手机端与电脑端修改同一份网关配置;所有已登录设备可修改这些通道。通道测试使用现有外部发送器,页面确认服务接受请求不等于手机已收到。自动入口通知仍依赖网关运行和原有入口发现逻辑。回归测试用本地模拟响应和受控 loopback HTTP,未发送真实第三方通知。 diff --git a/README.md b/README.md index ad4e6b4e..b40c18d7 100644 --- a/README.md +++ b/README.md @@ -5,11 +5,11 @@ # Codex Mobile Bridge · 多客户端远程工作台 -> **v2.0.2 正式版**:[发布与下载](https://github.com/try2love/codex-mobile-bridge/releases/tag/v2.0.2) · [完整更新说明](RELEASE_NOTES.md) · [手机安装说明](mobile/README.md)。本版改善文件链接、手机文件打开、登录自启与聊天列表滚动刷新,保留 Codex、Claude Desktop 与 DeepSeek Harness 的统一远程入口。 +> **v2.0.3 正式版**:[发布页](https://github.com/try2love/codex-mobile-bridge/releases/tag/v2.0.3) · [完整更新说明](RELEASE_NOTES.md) · [手机安装说明](mobile/README.md)。本版新增 Android 应用内更新与下载方式选择,支持手机和 Web 配置第三方通知,改善搜索筛选、横屏布局和连接恢复。 [简体中文](README.md) · [English](README_EN.md) -**[双语介绍与使用演示 ↗](https://try2love.github.io/codex-mobile-bridge/?lang=zh)** · **[下载桌面与手机 App](https://github.com/try2love/codex-mobile-bridge/releases/tag/v2.0.2)** +**[双语介绍与使用演示 ↗](https://try2love.github.io/codex-mobile-bridge/?lang=zh)** · **[下载桌面与手机 App](https://github.com/try2love/codex-mobile-bridge/releases/tag/v2.0.3)** [![桌面网关:客户端与接入(演示数据)](site/assets/v2-clients.png)](https://try2love.github.io/codex-mobile-bridge/?lang=zh) @@ -17,6 +17,13 @@ 任务继续在原电脑执行,Codex 的 SSH 任务继续在原服务器执行。网关复用原客户端的会话、认证和执行环境,不让手机重新运行一套 Agent。手机无需登录相同的模型账号,使用网关登录或可撤销的扫码配对即可接入。 +## v2.0.3 更新 + +- **Android 更新**:在 App 内下载并安装新版,自主选择手机下载或电脑下载;下载完成后可继续安装或导出 APK。详见[手机安装与升级](mobile/README.md#安装与升级)。 +- **第三方通知**:手机与 Web 可选择 Bark(iPhone)、ntfy(Android)或 PushPlus(微信),配置、保存并测试,与电脑网关共用设置。 +- **搜索与布局**:搜索框保留「搜索 / Search」,筛选按内容分配宽度;改善英文、横屏和平板列表,支持 Claude/DSH 下拉刷新,恢复后清除旧错误提示。 +- **连接与工作台**:手机安全保留有效绑定并尝试恢复登录,改善 Claude 重连与初始化;设置入口和文件传输保存文案更清晰,关闭工具标签时保留仍有内容的分屏。 + ## v2 工作台 - **多应用接入**:自动扫描已安装客户端,配置就绪后启用;桌面、Web 和手机可管理启停及重连。Claude Code / Cowork 分组可折叠,应用切换支持图标与可关闭的左右滑动。 @@ -47,18 +54,18 @@ ## 下载与快速开始(推荐) -日常使用直接下载桌面 App,**无需 Agent 帮忙部署,也无需安装 Python、Node.js 或打开终端**。当前版本为 **v2.0.2 正式版**。 +日常使用直接下载桌面 App,**无需 Agent 帮忙部署,也无需安装 Python、Node.js 或打开终端**。下列链接对应 **v2.0.3 正式版**。 | 系统 | 下载 | 打开方式 | | --- | --- | --- | -| Windows x64(推荐安装包) | [下载 Setup.exe](https://github.com/try2love/codex-mobile-bridge/releases/download/v2.0.2/Codex-Mobile-Bridge-2.0.2-Windows-x64-Setup.exe) | 运行安装包,从快捷方式打开 | -| Windows x64(免安装) | [下载完整 ZIP](https://github.com/try2love/codex-mobile-bridge/releases/download/v2.0.2/Codex-Mobile-Bridge-2.0.2-Windows-x64.zip) | 完整解压后运行 `Codex Mobile Bridge.exe`,不要单独移动 exe | -| macOS Apple Silicon(M 系列) | [下载 arm64 DMG](https://github.com/try2love/codex-mobile-bridge/releases/download/v2.0.2/Codex-Mobile-Bridge-2.0.2-macOS-arm64.dmg) | 打开 DMG,将 App 拖入“应用程序” | -| macOS Intel(英特尔) | [下载 x64 DMG](https://github.com/try2love/codex-mobile-bridge/releases/download/v2.0.2/Codex-Mobile-Bridge-2.0.2-macOS-x64.dmg) | 打开 DMG,将 App 拖入“应用程序” | -| Ubuntu x64(实验性) | [下载 .deb](https://github.com/try2love/codex-mobile-bridge/releases/download/v2.0.2/Codex-Mobile-Bridge-2.0.2-Linux-amd64.deb) | Ubuntu 22.04;安装后以普通用户启动 | -| Ubuntu ARM64(实验性) | [下载 .deb](https://github.com/try2love/codex-mobile-bridge/releases/download/v2.0.2/Codex-Mobile-Bridge-2.0.2-Linux-arm64.deb) | Ubuntu 22.04 ARM64;不含 32 位 ARM | +| Windows x64(推荐安装包) | [下载 Setup.exe](https://github.com/try2love/codex-mobile-bridge/releases/download/v2.0.3/Codex-Mobile-Bridge-2.0.3-Windows-x64-Setup.exe) | 运行安装包,从快捷方式打开 | +| Windows x64(免安装) | [下载完整 ZIP](https://github.com/try2love/codex-mobile-bridge/releases/download/v2.0.3/Codex-Mobile-Bridge-2.0.3-Windows-x64.zip) | 完整解压后运行 `Codex Mobile Bridge.exe`,不要单独移动 exe | +| macOS Apple Silicon(M 系列) | [下载 arm64 DMG](https://github.com/try2love/codex-mobile-bridge/releases/download/v2.0.3/Codex-Mobile-Bridge-2.0.3-macOS-arm64.dmg) | 打开 DMG,将 App 拖入“应用程序” | +| macOS Intel(英特尔) | [下载 x64 DMG](https://github.com/try2love/codex-mobile-bridge/releases/download/v2.0.3/Codex-Mobile-Bridge-2.0.3-macOS-x64.dmg) | 打开 DMG,将 App 拖入“应用程序” | +| Ubuntu x64(实验性) | [下载 .deb](https://github.com/try2love/codex-mobile-bridge/releases/download/v2.0.3/Codex-Mobile-Bridge-2.0.3-Linux-amd64.deb) | Ubuntu 22.04;安装后以普通用户启动 | +| Ubuntu ARM64(实验性) | [下载 .deb](https://github.com/try2love/codex-mobile-bridge/releases/download/v2.0.3/Codex-Mobile-Bridge-2.0.3-Linux-arm64.deb) | Ubuntu 22.04 ARM64;不含 32 位 ARM | -[查看所有版本与更新说明](https://github.com/try2love/codex-mobile-bridge/releases) · [下载 SHA256 校验文件](https://github.com/try2love/codex-mobile-bridge/releases/download/v2.0.2/SHA256SUMS.txt)。两种 Mac 同时提供 ZIP,供应用内更新或手动替换使用。Windows ARM 暂无专用安装包。Mac 版采用本地完整性签名但未公证,首次打开可能需要手动允许;Windows 版未做证书签名。详见下方 macOS 首次打开说明。 +[查看所有版本与更新说明](https://github.com/try2love/codex-mobile-bridge/releases) · [下载 SHA256 校验文件](https://github.com/try2love/codex-mobile-bridge/releases/download/v2.0.3/SHA256SUMS.txt)。两种 Mac 同时提供 ZIP,供应用内更新或手动替换使用。Windows ARM 暂无专用安装包。Mac 版采用本地完整性签名但未公证,首次打开可能需要手动允许;Windows 版未做证书签名。详见下方 macOS 首次打开说明。 1. 安装并登录要使用的桌面客户端,打开 **Codex Mobile Bridge**。在 **客户端与接入** 检查扫描结果,完成需要的授权,并启用配置就绪的客户端。 2. 在 **网络访问** 保留局域网访问或添加 HTTPS 入口,保存后从概览或客户端页点击 **启动网关**。已有配置时沿用原端口。 @@ -67,7 +74,7 @@ 需要外网访问时,可选 [Tailscale 固定 HTTPS](docs/tailscale.md)、内置 Cloudflare 临时 HTTPS、[固定 Cloudflare Tunnel](docs/fixed-domain.md)、[自有服务器 + SSH](docs/server-ssh.md) 或 NAS 反代。详细要求见[连接方式说明](#connections)。固定地址不保证国内直连速度,首次配置请用实际蜂窝网络测试。 -Android APK、iOS 未签名 IPA 与 Xcode 源码包见同一 [v2.0.2 Release](https://github.com/try2love/codex-mobile-bridge/releases/tag/v2.0.2)。Android 覆盖安装以保留数据;iOS 需自行签名,暂无 App Store/TestFlight 分发。源码运行与自定义部署见后面的[部署 Agent 执行说明](#agent-deployment)。 +Android APK、iOS 未签名 IPA 与 Xcode 源码包见同一 [v2.0.3 Release](https://github.com/try2love/codex-mobile-bridge/releases/tag/v2.0.3)。Android 覆盖安装以保留数据;iOS 需自行签名,暂无 App Store/TestFlight 分发。源码运行与自定义部署见后面的[部署 Agent 执行说明](#agent-deployment)。 @@ -77,10 +84,13 @@ Android APK、iOS 未签名 IPA 与 Xcode 源码包见同一 [v2.0.2 Release](ht 从 `v0.2.0-beta.5` 起,可在桌面 App 的「应用更新」中检查新版、查看说明并点击「更新并重启」。下载与校验完成后短暂重启网关,保留登录、网络、通知和关注聊天配置;失败时尝试恢复原版本。此前版本需要先手动安装一次支持更新的版本。使用临时 HTTPS 时,重启后请打开最新地址。发布与恢复说明见 [桌面更新文档](docs/desktop-updates.md)。 -**Windows beta.7 / 1.0.0 用户:**旧更新器可能因目录占用报 `WinError 32` 并回退。请先在托盘选择“停止网关并退出”,再用 **2.0.2 Setup.exe** 安装到原位置;无需卸载或删除数据。1.1.0 修复后续更新的目录占用问题。 +**Windows beta.7 / 1.0.0 用户:**旧更新器可能因目录占用报 `WinError 32` 并回退。请先在托盘选择“停止网关并退出”,再用 **2.0.3 Setup.exe** 安装到原位置;无需卸载或删除数据。1.1.0 修复后续更新的目录占用问题。 + +Android v2.0.3 起支持应用内 APK 更新,并可选择手机下载或电脑下载;旧版首次升级可手动覆盖安装。电脑协助方式需要配套网关,下载失败可重选方式,已完成的包可继续安装或保存。iOS 仍按签名安装指引升级,详见[手机说明](mobile/README.md#安装与升级)。 ### 手机阅读与显示设置 +- 手机「外观与显示」与 Web 设置并列放置底部 App 图标开关、应用管理、第三方通知;英文使用简短标签。文件传输的保存按钮仅保存传输大小配置。 - 对话页收拢顶部导航,点击标题可查看完整名称、项目、运行设备和模型信息。 - 点击模型、提醒、Skill 右侧的箭头,可收起或展开下方输入框与发送/停止栏。收起保留草稿,发送成功不会自动收起;失败时展开错误提示。 - 在右上角 **“··· → 显示设置”** 独立开关 **思考摘要**、**执行过程**。执行过程包含命令、工具调用、文件变更与中途进度说明;关闭两项可专注阅读回复。用户消息、错误和待确认卡片仍保留;缺少阶段标记的旧回复也会显示。 @@ -120,10 +130,10 @@ v2.0.0 在线查询订阅周期日期,显示剩余时间和具体日期;接 当前 Mac 版具有本地完整性签名(ad-hoc),**没有 Apple Developer ID 签名和公证**,首次打开仍可能被 macOS 拦截。旧版 `v0.2.0-beta.1` 还存在包签名缺陷,请优先下载 `v1.1.0` 或后续版本。 -1. 从本仓库的 Release 下载对应芯片的 DMG 或 ZIP,以及 `SHA256SUMS.txt`。计算下载文件的 SHA-256,与校验文件中同名文件的一行比较;不一致时重新下载,不要放行。以下以 2.0.2 的 M 系列 Mac DMG 为例,其他文件请替换文件名: +1. 从本仓库的 Release 下载对应芯片的 DMG 或 ZIP,以及 `SHA256SUMS.txt`。计算下载文件的 SHA-256,与校验文件中同名文件的一行比较;不一致时重新下载,不要放行。以下以 2.0.3 的 M 系列 Mac DMG 为例,其他文件请替换文件名: ```sh - shasum -a 256 "$HOME/Downloads/Codex-Mobile-Bridge-2.0.2-macOS-arm64.dmg" + shasum -a 256 "$HOME/Downloads/Codex-Mobile-Bridge-2.0.3-macOS-arm64.dmg" ``` 2. 打开 DMG,将 `Codex Mobile Bridge.app` 拖入“应用程序”,再推出磁盘映像;ZIP 则先解压并移动 App。尝试从“应用程序”打开后,前往 **系统设置 → 隐私与安全性 → 仍要打开**,按系统提示确认。 @@ -220,7 +230,13 @@ Cloudflare Tunnel 和 Tailscale 公网转发/中继可能经过境外节点, 电脑 App 顶部和手机网页 **更多 → 显示设置** 中有 **Language / 语言** 选择器,各自记住选择。无需重启网关;切换时保留未保存配置、消息草稿和待确认表单的输入。翻译仅覆盖界面文案,聊天原文、模型 / Skill 内容、命令和日志原文保持不变。部署 ZIP 同时包含中文和英文说明,“复制给部署 Agent”按当前界面语言生成。 -### 配置手机通知:Bark 与 ntfy +### 配置第三方通知:Bark、ntfy 与 PushPlus + +直连网关后,从手机「外观与显示」、Web 设置或聊天列表底部进入 **第三方通知**,选择 **Bark(iPhone)、ntfy(Android)或 PushPlus(微信)**,再进入对应配置页。英文入口显示 **Push alerts**。共享中继不开放此配置入口。 + +手机和 Web 与电脑端使用同一份网关配置。通道页面可启停、修改地址及主题、填写或清除密钥,保存后点击 **测试已保存的配置**;通用设置可调整通知跳转地址、是否显示聊天标题、安全事件及网关启动/入口变化通知。已保存的密钥不会返回手机页面,留空保留;更换服务地址时重新填写。测试以实际设备收到通知为准。 + +下面说明电脑端的 Bark、ntfy 配置;手机和 Web 可填写相同的服务信息。微信通道见 [PushPlus 说明](#pushplus-通知与聊天重命名)。 1. **iPhone 推荐 Bark**:安装 Bark 并允许通知,在电脑 App 的“消息通知 → Bark 推送”填写服务地址和 Device Key。例如手机展示 `https://api.day.app/你的密钥`,服务地址填 `https://api.day.app`,Device Key 只填密钥。也支持自建服务;手机须在对应服务器注册。密钥保存后默认遮罩显示,可点击眼睛图标查看;更换服务器须重新填写。参阅 [Bark 官方说明](https://bark.day.app/#/tutorial)。 2. **Android 使用 ntfy**:安装 ntfy 并允许系统通知、锁屏显示和后台运行。首次测试可用 `https://ntfy.sh`:点击“生成随机主题”,在手机 ntfy 订阅相同的服务和完整主题名,公共匿名主题的 Token 留空。主题自动创建,无需单独注册。匿名主题没有访问控制,知道名字的人可读写;使用随机长名称并保持聊天标题关闭。正式使用可选择受访问控制保护的主题,按服务要求填写 Token。 @@ -232,7 +248,7 @@ Cloudflare Tunnel 和 Tailscale 公网转发/中继可能经过境外节点, 被关注聊天在手机网页关闭后继续监听,前提是电脑、网关、原 Codex App 及相关 SSH 连接仍可用。每个通道、接收目标、聊天和事件分别记录投递状态,网关重连或重启后去重;一个通道失败不影响另一个。更换接收目标或新增通道不补发已完成历史;首次实时连接时仍在运行的任务及之后的新完成事件可触发通知。旧 ntfy 配置和已投递记录会保留。发送失败会退避重试,并在重试前重新核对请求是否仍待处理;完成通知在该会话仍开启此选项时重试,关闭后取消重试。首次实时连接建立前的已完成历史不会补发。网络中断时不承诺严格恰好投递一次。 -通知跳转地址留空时优先使用已配置的固定 HTTPS 入口,再选临时 HTTPS 或局域网地址;手动填写的通知地址始终优先。临时域名变更不会改变 ntfy 订阅,但旧通知中的旧链接可能失效。自建 ntfy 的 iPhone 即时通知需要 APNs 上游配置;Android 后台接收也受系统电池与网络设置影响。参阅 [ntfy 手机说明](https://docs.ntfy.sh/subscribe/phone/)及 [iOS 即时推送配置](https://docs.ntfy.sh/config/#ios-instant-notifications)。 +通知跳转地址留空时优先使用已配置的固定 HTTPS 入口,再选临时 HTTPS 或局域网地址;手动填写的通知地址始终优先。临时域名变更不会改变 ntfy 订阅,但旧通知中的旧链接可能失效。Android 后台接收受系统电池与网络设置影响。参阅 [ntfy 手机说明](https://docs.ntfy.sh/subscribe/phone/)。 ### 网关启动与入口通知 @@ -264,7 +280,7 @@ npm run pack:desktop Windows 可再执行 `npm run build:windows` 生成安装包与 ZIP;文件位于 `dist/desktop/`。随后执行 `npm run test:windows-app` 验证实际打包窗口、内置运行时、托盘恢复与启停。此检查使用临时端口和合成数据,不发送模型请求或真实通知,需要可显示窗口的 Windows 会话和 Node.js 24。开发测试可用 `CMB_DATA_DIR` 指定独立数据目录,`CMB_PYTHON` 指定开发用 Python;打包后的 App 使用内置运行时。运行 `python -B -m unittest discover -s tests -v` 和 `npm run test:desktop` 进行自动检查。产品图标源为 `assets/icon.png`,可用 `npm run icons:desktop` 更新桌面 PNG/ICO、手机网页和介绍页图标。macOS 打包时会从桌面 PNG 生成系统图标。 -App 设置界面通过本机进程通信管理网关;网关启停、更新及网络配置管理不对局域网或隧道开放。网页登录用户可以修改全局 PushPlus 通知配置,沿用 Host、Origin、登录与 CSRF 校验。配置、通知密钥、凭据和投递记录位于网关数据目录,三个推送通道默认关闭;打包与提交不包含 `.local`、`.tmp` 或个人配置。 +App 设置界面通过本机进程通信管理网关;网关启停、更新及网络配置管理不对局域网或隧道开放。直连网关的网页登录用户可以修改 Bark、ntfy、PushPlus 及通用通知配置,沿用 Host、Origin、登录与 CSRF 校验;共享中继不开放这些配置入口。配置、通知密钥、凭据和投递记录位于网关数据目录,三个推送通道默认关闭;打包与提交不包含 `.local`、`.tmp` 或个人配置。 ## Codex 会话功能 @@ -788,6 +804,10 @@ python3 -B -m unittest discover -s tests -v 感谢 [@qybgh(Luoran Yau)](https://github.com/qybgh) 在 [PR #8](https://github.com/try2love/codex-mobile-bridge/pull/8) 中对移动端计划、目标模式、附件预览和界面体验的贡献。v1.3.1 在该贡献基础上完成目标控制、通知和交互优化。 +感谢 [@heiqiu47](https://github.com/heiqiu47) 在 [PR #24](https://github.com/try2love/codex-mobile-bridge/pull/24) 中贡献 v2.0.2 的文件兼容、手机文件打开、系统栏与登录自启改进。 + +感谢 [@SevenPwZ(des1re)](https://github.com/SevenPwZ) 在 [PR #32](https://github.com/try2love/codex-mobile-bridge/pull/32) 中贡献 Android 应用内更新;v2.0.3 在此基础上整合电脑协助下载、下载方式选择与安装恢复优化。 + ## 许可证与参考 项目源码使用 [MIT License](LICENSE)。Codex App 为独立软件,不随本项目分发。桌面构建内置 cloudflared 并附带其许可证;二进制不提交到源码仓库,遵循其自身许可。 @@ -815,9 +835,10 @@ PushPlus 通过微信接收通知,**接入前需要付费实名认证,最低 **额度限制:**微信渠道普通实名用户每天 200 次请求、每分钟 5 次;会员每天 2,000 次、每 10 秒 5 次。两者均限制相同内容每小时最多 3 条。失败请求也计入额度,超限可能暂停推送;多个聊天及其他共用此账户的应用共同消耗额度。详见[官方额度说明](https://www.pushplus.plus/doc/guide/use.html)与[推送限制](https://www.pushplus.plus/doc/help/limit.html)。 -- 在网页 **设置** 或聊天列表底部点击 **PushPlus 通知**,填写在 [PushPlus 官网](https://www.pushplus.plus/) 获取的 Token,勾选启用并保存,然后点击 **测试已保存的配置**。桌面启动器的「手机通知」中也可以配置和测试。 -- PushPlus 配置由整个网关共享,更换 Token 会改变所有已关注聊天的 PushPlus 接收目标。所有已登录设备均可修改;开启免密访问时,能够访问网关的设备也拥有此权限。 +- 直连网关时,在网页 **设置** 或聊天列表底部点击 **第三方通知 → PushPlus**,填写在 [PushPlus 官网](https://www.pushplus.plus/) 获取的 Token,勾选启用并保存,然后点击 **测试已保存的配置**。桌面启动器的「手机通知」中也可以配置和测试。 +- PushPlus 配置由整个网关共享,更换 Token 会改变所有已关注聊天的 PushPlus 接收目标。直连网关的已登录设备均可修改;开启免密访问时,能直连网关的设备也拥有此权限。共享中继不提供第三方通知通道配置。 - **设置 → 全会话通知** 控制所有聊天的请求处理与运行完毕通知;每个聊天的 **提醒** 可独立覆盖。网关持续运行时,关闭网页仍会发送通知。PushPlus 可与 Bark、ntfy 同时使用。 -- **设置 → 主页快捷入口** 可分别隐藏 PushPlus 和账户管理入口;功能仍可从设置打开,显示偏好仅保存在当前浏览器。通知策略保存在网关,各设备共享。 +- 直连网关的 **第三方通知** 可在手机网页或 App 中分别配置 Bark、ntfy、PushPlus 的启用状态、服务地址与密钥,并测试已保存的单个通道;通用通知设置集中管理安全提醒、入口通知、网关名称、聊天标题和跳转地址。密钥读取时只显示是否已保存,留空保存保留原值,更换 Bark/ntfy 服务地址会清除原密钥。 +- **设置 → 主页快捷入口** 可分别隐藏第三方通知和账户管理入口;功能仍可从设置打开,显示偏好仅保存在当前浏览器。通知策略保存在网关,各设备共享。 - Token 保存到本机通知配置文件;电脑 App 默认遮罩显示并支持查看,网页不回传已保存的 Token。留空保留原 Token,关闭通道后可勾选清除。 - 点击聊天顶部标题,在聊天详情中选择 **修改聊天名称**,输入新名称并保存(最多 120 个字符)。名称写入该聊天所在主机的 Codex;支持本机和 SSH 聊天,网页列表与标题同步更新。 diff --git a/README_EN.md b/README_EN.md index a7911e58..6803b6a3 100644 --- a/README_EN.md +++ b/README_EN.md @@ -5,11 +5,11 @@ # Codex Mobile Bridge · A remote workbench for desktop agents -> **v2.0.2 stable**: [Release and downloads](https://github.com/try2love/codex-mobile-bridge/releases/tag/v2.0.2) · [Full release notes](RELEASE_NOTES.md) · [Mobile installation](mobile/README.md). This release improves file links, mobile file opening, login startup and chat-list scrolling and refresh for the shared Codex, Claude Desktop and DeepSeek Harness gateway. +> **v2.0.3 (stable)**: [Release page](https://github.com/try2love/codex-mobile-bridge/releases/tag/v2.0.3) · [Full release notes](RELEASE_NOTES.md) · [Mobile installation](mobile/README.md). This release adds Android in-app updates with download choices and mobile/Web third-party notification settings, and improves search, landscape layouts and connection recovery. [简体中文](README.md) · [English](README_EN.md) -**[Product guide and interactive demos ↗](https://try2love.github.io/codex-mobile-bridge/?lang=en)** · **[Download desktop and mobile apps](https://github.com/try2love/codex-mobile-bridge/releases/tag/v2.0.2)** +**[Product guide and interactive demos ↗](https://try2love.github.io/codex-mobile-bridge/?lang=en)** · **[Download desktop and mobile apps](https://github.com/try2love/codex-mobile-bridge/releases/tag/v2.0.3)** [![Desktop gateway: clients and access, with demo data](site/assets/v2-clients.png)](https://try2love.github.io/codex-mobile-bridge/?lang=en) @@ -17,6 +17,13 @@ Continue **Codex, Claude Code / Cowork and DeepSeek Harness** sessions from a ph Tasks run on the original computer, or the original SSH host for Codex remote sessions. Bridge reuses the desktop session, authentication and execution environment. Your phone does not need the same model-service account: use gateway login or revocable QR pairing. +## What's new in v2.0.3 + +- **Android updates:** download and install updates in the app, choose phone or computer downloads, and resume installation or export a completed APK. See [mobile installation and upgrades](mobile/README.md). +- **Third-party notifications:** configure, save and test Bark for iPhone, ntfy for Android or PushPlus for WeChat on mobile or Web, using the same settings as the desktop gateway. +- **Search and layout:** keep Search visible, size filters to their contents, improve English and landscape/tablet lists, add Claude/DSH pull-to-refresh and clear stale errors after recovery. +- **Connections and workbench:** securely retain valid mobile bindings for sign-in recovery, improve Claude reconnection and setup, clarify settings and file-transfer actions, and preserve occupied split panes when closing tool tabs. + ## The v2 workbench - **Multiple clients:** discover installed apps and enable configured ones; manage lifecycle and reconnection from desktop, Web or mobile. Claude Code/Cowork have collapsible groups. Switch by icon or optional swipe gesture. @@ -47,18 +54,18 @@ Supports macOS, Windows and experimental Ubuntu x64/ARM64. See the [Linux guide] ## Download and quick start (recommended) -Download the desktop App for everyday use. **No deployment Agent, Python, Node.js or terminal is required.** The current release is **v2.0.2 (stable)**. +Download the desktop App for everyday use. **No deployment Agent, Python, Node.js or terminal is required.** The links below target **v2.0.3 (stable)**. | Platform | Download | Open | | --- | --- | --- | -| Windows x64 (recommended installer) | [Download Setup.exe](https://github.com/try2love/codex-mobile-bridge/releases/download/v2.0.2/Codex-Mobile-Bridge-2.0.2-Windows-x64-Setup.exe) | Run the installer and launch from the shortcut | -| Windows x64 (no installation) | [Download full ZIP](https://github.com/try2love/codex-mobile-bridge/releases/download/v2.0.2/Codex-Mobile-Bridge-2.0.2-Windows-x64.zip) | Extract the entire ZIP and run `Codex Mobile Bridge.exe`; do not move just the exe | -| macOS Apple Silicon (M series) | [Download arm64 DMG](https://github.com/try2love/codex-mobile-bridge/releases/download/v2.0.2/Codex-Mobile-Bridge-2.0.2-macOS-arm64.dmg) | Open the DMG and drag the App to Applications | -| macOS Intel | [Download x64 DMG](https://github.com/try2love/codex-mobile-bridge/releases/download/v2.0.2/Codex-Mobile-Bridge-2.0.2-macOS-x64.dmg) | Open the DMG and drag the App to Applications | -| Ubuntu x64 (experimental) | [Download .deb](https://github.com/try2love/codex-mobile-bridge/releases/download/v2.0.2/Codex-Mobile-Bridge-2.0.2-Linux-amd64.deb) | Ubuntu 22.04; launch as a regular user | -| Ubuntu ARM64 (experimental) | [Download .deb](https://github.com/try2love/codex-mobile-bridge/releases/download/v2.0.2/Codex-Mobile-Bridge-2.0.2-Linux-arm64.deb) | Ubuntu 22.04 ARM64; no 32-bit ARM build | +| Windows x64 (recommended installer) | [Download Setup.exe](https://github.com/try2love/codex-mobile-bridge/releases/download/v2.0.3/Codex-Mobile-Bridge-2.0.3-Windows-x64-Setup.exe) | Run the installer and launch from the shortcut | +| Windows x64 (no installation) | [Download full ZIP](https://github.com/try2love/codex-mobile-bridge/releases/download/v2.0.3/Codex-Mobile-Bridge-2.0.3-Windows-x64.zip) | Extract the entire ZIP and run `Codex Mobile Bridge.exe`; do not move just the exe | +| macOS Apple Silicon (M series) | [Download arm64 DMG](https://github.com/try2love/codex-mobile-bridge/releases/download/v2.0.3/Codex-Mobile-Bridge-2.0.3-macOS-arm64.dmg) | Open the DMG and drag the App to Applications | +| macOS Intel | [Download x64 DMG](https://github.com/try2love/codex-mobile-bridge/releases/download/v2.0.3/Codex-Mobile-Bridge-2.0.3-macOS-x64.dmg) | Open the DMG and drag the App to Applications | +| Ubuntu x64 (experimental) | [Download .deb](https://github.com/try2love/codex-mobile-bridge/releases/download/v2.0.3/Codex-Mobile-Bridge-2.0.3-Linux-amd64.deb) | Ubuntu 22.04; launch as a regular user | +| Ubuntu ARM64 (experimental) | [Download .deb](https://github.com/try2love/codex-mobile-bridge/releases/download/v2.0.3/Codex-Mobile-Bridge-2.0.3-Linux-arm64.deb) | Ubuntu 22.04 ARM64; no 32-bit ARM build | -[All releases and release notes](https://github.com/try2love/codex-mobile-bridge/releases) · [Download SHA256 checksums](https://github.com/try2love/codex-mobile-bridge/releases/download/v2.0.2/SHA256SUMS.txt). Both Mac architectures also include ZIPs for in-app updates or manual replacement. Windows ARM packages are not available. The Mac build is ad-hoc signed but not notarized and may require manual approval; Windows builds have no certificate signature. See the macOS first-launch instructions below. +[All releases and release notes](https://github.com/try2love/codex-mobile-bridge/releases) · [Download SHA256 checksums](https://github.com/try2love/codex-mobile-bridge/releases/download/v2.0.3/SHA256SUMS.txt). Both Mac architectures also include ZIPs for in-app updates or manual replacement. Windows ARM packages are not available. The Mac build is ad-hoc signed but not notarized and may require manual approval; Windows builds have no certificate signature. See the macOS first-launch instructions below. 1. Install and sign in to the desktop clients you need. Open **Codex Mobile Bridge**, inspect **Clients and access**, complete required permissions and enable configured clients. 2. Keep LAN access or add an HTTPS entry under **Network access**, save, and select **Start gateway** from Overview or Clients. Preserve your configured port. @@ -67,7 +74,7 @@ Download the desktop App for everyday use. **No deployment Agent, Python, Node.j For remote access, choose [Tailscale HTTPS](docs/tailscale.md), bundled temporary Cloudflare HTTPS, a [fixed Cloudflare tunnel](docs/fixed-domain.md), [your own SSH server](docs/server-ssh.md) or a NAS proxy. See [Parallel connections](#parallel-connections). Fixed addresses do not guarantee mainland China performance; test the actual cellular network. -Android APK, unsigned iOS IPA and Xcode source downloads are in the same [v2.0.2 Release](https://github.com/try2love/codex-mobile-bridge/releases/tag/v2.0.2). Install Android over the existing app; iOS requires your own signing, with no App Store/TestFlight distribution. Source/custom setups are covered by the [Deployment Agent instructions](#deployment-agent-instructions). +Android APK, unsigned iOS IPA and Xcode source downloads are in the same [v2.0.3 Release](https://github.com/try2love/codex-mobile-bridge/releases/tag/v2.0.3). Install Android over the existing app; iOS requires your own signing, with no App Store/TestFlight distribution. Source/custom setups are covered by the [Deployment Agent instructions](#deployment-agent-instructions). ### In-app updates @@ -75,10 +82,13 @@ Android APK, unsigned iOS IPA and Xcode source downloads are in the same [v2.0.2 Starting with `v0.2.0-beta.5`, use **App updates** to check for a release, read its notes, and select **Update and restart**. The app verifies the signed manifest and package before replacing itself, preserves login, network, notification and watched-chat settings, and restores a previously running gateway. Installation failures attempt to restore the previous app. Older versions need one manual upgrade to a version with this feature. Temporary HTTPS addresses change when the gateway restarts. -**Windows beta.7 / 1.0.0 users:** the old updater may roll back with `WinError 32` because its working directory is locked. Choose **Stop gateway and quit** in the tray, then install **2.0.2 Setup.exe** to the same location. Do not uninstall or delete your data. Version 1.1.0 fixes this working-directory issue for subsequent updates. +**Windows beta.7 / 1.0.0 users:** the old updater may roll back with `WinError 32` because its working directory is locked. Choose **Stop gateway and quit** in the tray, then install **2.0.3 Setup.exe** to the same location. Do not uninstall or delete your data. Version 1.1.0 fixes this working-directory issue for subsequent updates. + +Android v2.0.3 adds in-app APK updates with a phone/computer download choice. Older apps can be upgraded manually once; computer-assisted downloads need a compatible gateway. Failed downloads allow another route, and completed APKs remain available for installation or export. iOS still uses the signing and installation flow described in the [mobile guide](mobile/README.md). ### Phone reading and display settings +- Mobile appearance and Web settings group **Show/Hide icons**, **Apps** and **Push alerts** together. The file-transfer save action applies only to transfer-size settings. - Compact chat navigation leaves more room for replies. Tap the title for the full name, project, device and model details. - Use the arrow beside Model, Notifications and Skill to hide or show the composer and send/stop controls. Drafts are retained; a successful send does not collapse the composer automatically, and a failed send reopens it to show the error. - Open **“··· → Display settings”** to independently toggle **Reasoning summaries** and **Execution activity**. Activity includes commands, tools, file changes and intermediate progress updates. Turn both off to focus on replies; user messages, errors and pending requests remain visible. Older replies without phase metadata are also retained. @@ -115,10 +125,10 @@ View remaining percentages, reset times and available usage resets. In v2.0.0, r The Mac build has an **ad-hoc integrity signature**, but **no Apple Developer ID signature or notarization**. macOS can still block the first launch. The older `v0.2.0-beta.1` also has a bundle-signing defect; use `v1.1.0` or later. -1. Download the DMG or ZIP for your Mac's chip and `SHA256SUMS.txt` from this repository's Release. Calculate the downloaded file's hash and compare it with the matching filename in the checksum file. If they differ, download again instead of allowing the app. The example below uses the 2.0.2 Apple Silicon DMG; replace the filename for other downloads. +1. Download the DMG or ZIP for your Mac's chip and `SHA256SUMS.txt` from this repository's Release. Calculate the downloaded file's hash and compare it with the matching filename in the checksum file. If they differ, download again instead of allowing the app. The example below uses the 2.0.3 Apple Silicon DMG; replace the filename for other downloads. ```sh - shasum -a 256 "$HOME/Downloads/Codex-Mobile-Bridge-2.0.2-macOS-arm64.dmg" + shasum -a 256 "$HOME/Downloads/Codex-Mobile-Bridge-2.0.3-macOS-arm64.dmg" ``` 2. Open the DMG, drag `Codex Mobile Bridge.app` to Applications, then eject the disk image. For a ZIP, extract it and move the App instead. Try opening it from Applications, then go to **System Settings → Privacy & Security → Open Anyway** and confirm. @@ -233,19 +243,25 @@ Adding a URL does **not** create a tunnel, configure DNS or obtain a certificate The desktop App and phone website each have a **Language / 语言** selector and remember their own choice. Switching language does not restart the gateway or translate chat content, commands, model/Skill descriptions, user input or raw logs. Unsaved settings, message drafts and pending-confirmation form input are preserved. Language switching is disabled while a confirmation is being submitted. -### Phone notifications: Bark and ntfy +### Third-party notifications: Bark, ntfy and PushPlus + +With a direct gateway connection, open **Push alerts** from mobile appearance settings, Web settings or the chat-list footer. Choose **Bark for iPhone, ntfy for Android or PushPlus for WeChat**, then open that channel's configuration page. Shared-relay connections do not expose this configuration entry. + +Mobile, Web and desktop edit the same gateway settings. Enable a channel, edit its server/topic and credentials, save, then test the saved configuration. Common settings include notification links, title visibility, security events and gateway startup/address-change notifications. Saved secrets are not returned to the mobile page: leave them blank to keep them, and enter them again when changing the server. Confirm actual receipt on the target device. + +The steps below describe desktop Bark/ntfy setup; mobile and Web accept the same service details. See [PushPlus instructions](#pushplus-notifications-and-chat-names) for WeChat. 1. **Bark for iPhone:** install Bark and allow notifications. In the desktop App, enter the server URL and Device Key from the phone app. For `https://api.day.app/YOUR_KEY`, use `https://api.day.app` as the server and only `YOUR_KEY` as the key. Self-hosted servers are supported; register your phone with that server first. Saved keys are hidden, blank keeps the key, and changing the server requires re-entering it. See the [Bark guide](https://bark.day.app/#/tutorial). 2. **ntfy for Android:** install ntfy and allow notifications, lock-screen display and background activity. For an initial test, use `https://ntfy.sh`. Generate a random topic in the desktop App and subscribe to exactly that server and topic on the phone. Public anonymous topics need no token and are created automatically. Anyone who knows such a topic can read and publish; use a long random name and leave chat titles hidden. Consider a protected topic for regular use. 3. Enable the channels you need, save, then click **Send Bark test notification** or **Send ntfy test notification**. Either channel or both can receive the same watched-chat notifications. Confirm actual receipt on the phone. This test does not require the gateway to run; server acceptance alone is not proof of phone delivery. -4. Start the gateway, refresh the phone website, open **Reminders** in a chat, check **Enable chat reminders** and save. Local and SSH chats are watched separately. +4. Start the gateway and refresh the phone website. In Web settings, choose the global defaults for requests and successful run completions. New configurations enable request reminders and disable completion reminders; at least one delivery channel must be configured and enabled. 5. New command, file, permission or question requests trigger a notification. Clicking it opens that chat using the normal web login and confirmation flow. -6. Each watched chat has an optional **Notify when a run completes** checkbox, off by default. Enable it for the current running turn and future successful runs. Failed, stopped and already completed historical runs do not trigger it. Turning only this option off keeps confirmation reminders enabled. -7. In the desktop App, **Notifications → Watched chats** shows each chat's name, device, directory and ID. Toggle completion notifications or **Remove watch**; changes save immediately. Removing a watch stops that chat's reminders without deleting the chat. Add new watches from the phone. Saved watches remain manageable when the gateway is stopped or notification channels are disabled. Remote names use the last available title; the device and chat ID remain visible when the name is unavailable. +6. In each chat's **Reminders**, requests and completions independently support **Follow global settings**, **On** or **Off**. Existing explicit choices survive upgrades. Completion reminders cover runs observed in progress on the first live connection and future successful runs, not failed, stopped or previously completed history. +7. In the desktop App, **Notifications → Watched chats** shows each chat's name, device, directory and ID. Toggle completion notifications or **Remove watch**; changes save immediately. Removing a watch stops that chat's reminders without deleting the chat. Manage global defaults and per-chat overrides from Web settings or chat reminders. Saved watches remain manageable when the gateway is stopped or notification channels are disabled. Remote names use the last available title; the device and chat ID remain visible when the name is unavailable. Watched chats remain monitored after the phone page closes, provided the computer, gateway, original App and relevant SSH connections stay online. Delivery and retries are tracked separately per channel, destination, chat and event, including after restart. Failure in one channel does not affect the other. New channels or destinations do not replay completed history; runs observed in progress on their first live connection and future completions can notify. Existing ntfy configuration and delivery records are preserved. Failed delivery retries with backoff and rechecks whether the request is still pending. Completion retries continue while that chat has the option enabled and stop when it is disabled. Completed history from before the first live connection is not replayed. Strict exactly-once delivery is not guaranteed during network failures. -An explicitly configured notification click URL takes priority over automatic selection. Old notifications retain their old URLs after a temporary domain changes. Self-hosted ntfy needs an APNs upstream for instant iPhone delivery; Android delivery also depends on background/battery permissions. See the official [phone subscription guide](https://docs.ntfy.sh/subscribe/phone/) and [iOS instant notification setup](https://docs.ntfy.sh/config/#ios-instant-notifications). +An explicitly configured notification click URL takes priority over automatic selection. Old notifications retain their old URLs after a temporary domain changes. Android delivery depends on background and battery permissions. See the official [phone subscription guide](https://docs.ntfy.sh/subscribe/phone/). ### Gateway startup and entry notifications @@ -253,7 +269,7 @@ In the desktop App, open **Notifications → Gateway startup and entry notificat New configurations enable address notifications by default; saved opt-outs survive upgrades. Configure at least one notification channel for delivery. Every gateway start sends the enabled addresses, even when unchanged: LAN, NAS / existing reverse-proxy domains, personal servers once SSH forwarding connects, and temporary HTTPS once ready. Later-ready entries and address changes trigger an update. Disabled interfaces, disabled entries and loopback addresses are excluded. LAN links require the same network; fixed domains require completed deployment. -The switch is off by default and independent of chat notifications. Messages use current entries rather than the chat click URL, contain no passwords or login tokens, and retry per channel. Stopping the gateway or disabling the switch stops new sends. Configure and test after upgrading to v1.3.2 or later before relying on notifications for subsequent restarts; earlier versions do not support this feature. +This switch is independent of chat notifications, defaults to on for new configurations and preserves saved choices on upgrade. Messages use current entries rather than the chat click URL, contain no passwords or login tokens, and retry per channel. Stopping the gateway or disabling the switch stops new sends. Configure and test after upgrading to v1.3.2 or later before relying on notifications for subsequent restarts; earlier versions do not support this feature. ## Codex session features @@ -380,7 +396,7 @@ Gateway data stays in its configured directory, normally `.local`. It includes l Uploaded files are retained in `uploads/` or `hosts//uploads/` inside gateway data, separated by chat. Remote copies are stored under that host's `$CODEX_HOME/mobile-bridge/uploads/` (default `~/.codex/mobile-bridge/uploads/`). Removing a draft attachment does not delete uploaded files, and automatic cleanup is not provided. Manually deleting a file can break historical or queued references to it. -The local desktop management interface uses guarded Electron IPC and a private stdio worker. Gateway start/stop, updates and network configuration are not exposed through the phone HTTP API. Signed-in web clients can change the global PushPlus settings. Public HTTP access remains constrained by authentication, allowed Host/Origin checks and CSRF validation. +The local desktop management interface uses guarded Electron IPC and a private stdio worker. Gateway start/stop, updates and network configuration are not exposed through the phone HTTP API. Signed-in clients connected directly to the gateway can change Bark, ntfy, PushPlus and common notification settings; shared-relay connections do not expose these configuration routes. Public HTTP access remains constrained by authentication, allowed Host/Origin checks and CSRF validation. ## Known limits @@ -493,6 +509,10 @@ Thanks to the [LINUX DO](https://linux.do/) community and its members for their Thanks to [@qybgh (Luoran Yau)](https://github.com/qybgh) for contributing mobile Plan and Goal support, attachment previews and UI improvements in [PR #8](https://github.com/try2love/codex-mobile-bridge/pull/8). Version 1.3.1 builds on that contribution with Goal controls, notification preferences and interaction refinements. +Thanks to [@heiqiu47](https://github.com/heiqiu47) for file compatibility, mobile file opening, system-bar and login-startup improvements in [PR #24](https://github.com/try2love/codex-mobile-bridge/pull/24), released in v2.0.2. + +Thanks to [@SevenPwZ (des1re)](https://github.com/SevenPwZ) for Android in-app updates in [PR #32](https://github.com/try2love/codex-mobile-bridge/pull/32). v2.0.3 builds on that work with gateway-assisted downloads, explicit download choices and installation recovery improvements. + ## License and references [MIT License](LICENSE). @@ -523,7 +543,7 @@ PushPlus delivers notifications through WeChat and **requires paid real-name ver **Limits:** the WeChat channel allows ordinary verified users 200 requests per day and 5 per minute; members receive 2,000 per day and 5 per 10 seconds. Both allow up to 3 identical messages per hour. Failed requests count toward the allowance, and exceeding limits may suspend delivery. All chats and other apps using the same account share this allowance. See the official [quota guide](https://www.pushplus.plus/doc/guide/use.html) and [delivery restrictions](https://www.pushplus.plus/doc/help/limit.html). -- Click **PushPlus notifications** below the web chat list, enter the token from [PushPlus](https://www.pushplus.plus/), enable the channel, save, and test the saved settings. The desktop notification panel also supports PushPlus. +- With a direct gateway connection, open **Push alerts → PushPlus** from settings or the chat-list footer. Enter the token from [PushPlus](https://www.pushplus.plus/), enable the channel, save, and test the saved settings. The desktop notification panel also supports PushPlus. - PushPlus settings are shared across the gateway. Changing the token changes the PushPlus recipient for all watched chats. Every signed-in device can edit these settings; passwordless access also grants this permission to devices that can reach the gateway. - Enable reminders in each chat you want to follow, optionally including successful run completion. Notifications continue after closing the page while the gateway remains running. - Saved tokens are never returned to the page. Leave the token blank to keep it, or disable the channel and select the clear option to remove it. diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 58e1cbdf..3a7b7a57 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,3 +1,37 @@ +## v2.0.3 · 手机更新、第三方通知与界面优化 + +- **Android 应用内更新**:检查新版、查看更新说明,在 App 内下载 APK 并交给系统安装器。安装前校验大小和 SHA-256;完成下载后,即使离开 App 授予安装权限或重新打开 App,也可继续安装或保存 APK。 +- **更新下载方式自己选**:可选「手机下载」或「电脑下载」。手机无法查询 GitHub 时可通过当前已登录网关查询;选择电脑下载后,由电脑下载并校验,再传给手机安装。下载失败可重新选择方式,或通过「GitHub ↗」在浏览器下载对应 APK,不会自动替换用户选定的下载方式。 +- **手机与 Web 配置第三方通知**:从设置或聊天列表进入「第三方通知」,选择 **Bark(iPhone)、ntfy(Android)或 PushPlus(微信)**,分别配置、保存和测试;与电脑网关共用配置,也可修改通知跳转地址、标题显示及网关入口通知。该配置入口用于直连网关,共享中继保留原有权限范围。 +- **搜索与筛选更紧凑**:搜索框始终可见,显示「搜索 / Search」;各选择框按当前内容分配宽度,其余空间留给搜索。精简英文按钮,改善手机横屏、平板和展开折叠屏上的 Claude、Codex、DSH 列表布局。 +- **聊天列表减少干扰**:Claude 与 DSH 支持手动下拉刷新,保留搜索、筛选和阅读位置;移除常驻的读取与连接成功提示,恢复成功后清除过期错误,连接失败仍提供说明和重试入口。修复刷新失败提示持续停留的问题。 +- **设置与工作台更清楚**:底部 App 图标开关、应用管理与第三方通知入口并列,兼顾中英文窄屏显示;文件传输保存按钮明确说明用途,修复未打开设置时遗留「请登录」提示的问题。关闭当前工具标签时切换到可用标签,避免不必要地退出分屏。 +- **手机连接恢复**:Android/iOS 安全保存仍有效的扫码绑定凭据,WebView 登录状态丢失时尝试恢复;不保存网关密码,退出、移除电脑或撤销绑定后不再恢复。改善网关登录会话隔离、Claude 已初始化连接的重连入口,以及 macOS Claude 初始化时长 Console 输入的核验与连接效率。 + +### 致谢 + +感谢 [@SevenPwZ(des1re)](https://github.com/SevenPwZ) 在 [PR #32](https://github.com/try2love/codex-mobile-bridge/pull/32) 中贡献 Android 应用内更新。本版在该贡献基础上整合电脑协助下载、下载方式选择与安装恢复优化。 + +### 升级说明 + +建议电脑网关与 Android/iOS App 一起更新至 v2.0.3,以使用完整的通知配置、绑定恢复和更新改进。网页用户更新网关后重新加载页面即可。旧 Android 版本首次升级可下载 APK 覆盖安装,不要先卸载;后续更新可使用新版应用内更新。电脑协助下载需要配套网关,电脑仍须能访问 GitHub;仅更新手机时也可继续使用手机直连下载。iOS IPA 仍需自行签名,另附 Xcode 源码包;Linux 仍为实验性支持,使用安装包手动升级。正式下载文件可用 `SHA256SUMS.txt` 校验。 + +### English + +- **Android in-app updates:** check releases, read notes, download the APK and open the system installer. Verify size and SHA-256 before installation. Keep a completed download available for installation or export after granting install permission or reopening the app. +- **Choose the download route:** download on the phone or let the signed-in computer gateway download, verify and transfer the APK. Release lookup can use the gateway if the phone cannot reach GitHub. Failed downloads offer route selection and a browser link to the matching APK; they do not silently switch the chosen route. +- **Third-party notifications on mobile and Web:** open **Push alerts**, select **Bark for iPhone, ntfy for Android or PushPlus for WeChat**, then configure, save and test that channel. Settings are shared with the desktop gateway, including notification links, title visibility and gateway address notifications. Configuration is available through direct gateway connections; shared-relay permissions are unchanged. +- **Compact search and filters:** keep Search visible, size selectors to their selected text and give the remaining width to search. Shorter English labels and consistent list layouts improve landscape phones, tablets and unfolded devices. +- **Quieter chat lists:** add manual pull-to-refresh to Claude and DSH, retain filters and reading position, remove persistent loading/connected messages and clear stale errors after recovery. Connection failures still show actionable messages and retry controls. +- **Clearer settings and workbench behavior:** place app-icon visibility, Apps and Push alerts together with narrow-screen English support. Clarify the file-transfer save action and prevent stale sign-in messages in unopened settings. Closing the active tool tab selects an available tab without unnecessarily leaving split view. +- **Connection recovery:** securely retain valid scanned-device credentials on Android/iOS and try to restore lost WebView sign-in state without storing the gateway password. Sign-out, removal and revocation stop recovery. Improve gateway session isolation, reconnection to initialized Claude sessions and long Console input verification during macOS Claude setup. + +Thanks to [@SevenPwZ (des1re)](https://github.com/SevenPwZ) for Android in-app updates in [PR #32](https://github.com/try2love/codex-mobile-bridge/pull/32). This release builds on that contribution with gateway-assisted downloads, explicit download choices and installation recovery improvements. + +Update both the gateway and mobile app for all improvements; reload browser pages after the gateway update. Older Android versions can install the APK over the existing app once, then use in-app updates. Computer-assisted downloads require a compatible gateway and computer access to GitHub. The unsigned iOS IPA requires your own signing; Linux remains experimental with manual package upgrades. Verify published downloads with `SHA256SUMS.txt`. + +--- + ## v2.0.2 · 文件兼容与聊天列表体验优化 - **聊天列表更连贯**:Codex 列表滚动接近底部时自动加载后续聊天,移除常驻“加载更多”按钮。下拉刷新提示不再挤压列表,手势跟随与回弹更顺滑,成功提示更快收起;内容未变化时保留已有列表节点和阅读位置。 diff --git a/VERIFICATION.md b/VERIFICATION.md index 8525103d..2a475ab2 100644 --- a/VERIFICATION.md +++ b/VERIFICATION.md @@ -1,3 +1,35 @@ +## v2.0.3 正式版验证(2026-10-13) + +本节记录 v2.0.3 的用户验收、源码回归和原生平台构建结果。桌面、Android、iOS 版本统一为 2.0.3,Android/iOS 构建号为 30。下表为优化期间的用户验收,正式构建检查列于其后。 + +| 范围 | 已完成的验证 | 边界 | +| --- | --- | --- | +| 聊天列表、搜索与设置 | 本地前端完整回归 560 项:559 通过、1 跳过;后端完整回归 1488 项:26 跳过、无失败。浏览器检查中英文、三个客户端、手机横竖屏、平板宽度及 310px 侧栏;后续设置行在 320px、390px 下无裁切或重叠 | 为优化期间的阶段性回归,不是最终发布构建的全平台验收;浏览器尺寸模拟不能覆盖全部真实平板与折叠屏 | +| 列表恢复与工作台 | 验证下拉刷新、错误提示恢复、搜索筛选保留、横竖屏草稿与滚动位置,以及关闭工具标签后的分屏状态 | 采用受控浏览器夹具与定向回归 | +| 登录与设备绑定 | 真实本机 HTTP 验证会话 Cookie 隔离;Android/iOS 原生凭据校验夹具通过;打包网关扫码、一次性兑换、持久绑定、重启恢复与撤销通过 | 不代表所有系统清理、钥匙串异常或设备迁移场景均已真机覆盖 | +| 第三方通知 | 通道读取、保存、测试路由、过期请求隔离及共享中继权限回归通过 | 使用模拟响应与受控本机 HTTP;未据此宣称 Bark、ntfy、PushPlus 在真实手机后台全部送达 | +| macOS Claude 连接 | 最终修复的 78 项定向回归通过,隔离编辑器的完整输入与异常输入检查通过;用户随后确认测试网关实际连接通过 | 真实连接确认来自本轮 macOS 测试;不外推至所有 Claude 版本、Windows 或锁屏冷启动 | +| macOS 测试 App | Apple Silicon 测试包的架构、签名完整性、内置运行时、HTTPS 与隔离启动检查通过 | 使用本地测试签名,未执行公证;不代表正式发布包或 Intel 原生验证完成 | +| Android 更新 | 应用内下载、大小/SHA-256 校验、安装权限交接、进程退出后的已下载包恢复、导出与失败重试经回归和 Android 模拟器验证;电脑协助下载及显式下载方式选择已完成隔离测试,用户确认 Android 测试版可用 | 安装演练使用受控局域网更新源;不能代替每个用户网络对 GitHub 正式资产的下载验证 | +| iOS 测试 App | 完成 Swift 检查、签名及共享资源核对,并已安装到用户连接的 iPhone 供实测 | 设备安装不等于所有 iOS 功能完成验收;正式分发仍需用户自行签名 | + +### 正式构建验收 + +- [源码 CI](https://github.com/try2love/codex-mobile-bridge/actions/runs/38368945783) 全部通过。各环境均运行 1489 项后端测试:Windows Python 3.9/3.13 各跳过 37 项,Ubuntu Python 3.9/3.13 各跳过 35 项,macOS Python 3.13 跳过 33 项;均无失败。跳过项为平台条件或可选环境限制。 +- Desktop/Web 回归:560 项,559 通过、1 项平台条件跳过;更新器回归:32 项,31 通过、1 项平台条件跳过。Android/iOS 版本解析、移动下载与安装恢复、原生绑定选择和 iOS 绑定恢复夹具通过。 +- [桌面原生 CI](https://github.com/try2love/codex-mobile-bridge/actions/runs/38368989555) 的 Windows x64、macOS arm64/x64、Linux x64/arm64 五个任务全部通过。 +- Windows:生成 Setup EXE 与 ZIP,打包 App 启动、连续终端、HTTPS、通知、登录、扫码配对、更新成功/回滚及真实 UI 更新交接通过。原生 Claude 控件夹具通过,正常连接沿用 v2.0.2 路径;这不等于在用户的所有 Windows/Claude 版本上完成真实账号连接或安装器实测。 +- macOS:Apple Silicon 与 Intel 均由对应架构的原生 runner 构建;DMG/ZIP 解包、架构、签名完整性、内置运行时、HTTPS、启动、通知、登录、配对与更新链路检查通过。签名为 ad-hoc,未公证;Intel 结果不是由 Rosetta 代替。 +- Linux:x64/ARM64 的 Debian 包安装、架构/版本、打包运行时、HTTPS、通知、登录、配对及 AppImage 解包启动检查通过。Linux 仍为实验性支持,Claude 自动初始化仍不支持。 +- Android:2.0.3 / versionCode 30 的 APK 已构建,v2/v3 签名校验通过,签名证书与 v2.0.2 发布 APK 一致;保留覆盖安装所需的应用标识与签名。 +- [iOS 原生 CI](https://github.com/try2love/codex-mobile-bridge/actions/runs/38368994811) 通过。下载后核对 IPA 主 App、两个扩展均为 2.0.3 / build 30,标识和资源正确,未包含签名或描述文件;Xcode 源码 ZIP 与候选提交一致。安装仍需用户自行签名。 +- [v2.0.3 标签构建](https://github.com/try2love/codex-mobile-bridge/actions/runs/38371899527) 的五个桌面原生目标与 iOS 全部通过,生成正式分发文件。 +- [发布文件完整性 CI](https://github.com/try2love/codex-mobile-bridge/actions/runs/38375594938) 两次下载完整 Release 草稿,核对全部 15 个文件及 14 项内容文件的 SHA-256;更新校验清单后再次下载验证通过。Android 文件与已核验签名的本地 APK 一致;Windows x64、macOS arm64/x64 的更新清单签名、安装包大小和哈希通过 v2.0.2 升级校验。本机仅取回小型报告,复核文件 ID、大小、摘要及已批准的发布说明与草稿一致。 + +历史验证记录保留如下,不作为 v2.0.3 的新增验收结果。 + +--- + ## v1.4.0 发布候选检查(2026-10-06) - 用户已验收 Preview 并授权正式发布;保留 PR #11 原始提交历史及 Preview 期间的兼容性修复。 diff --git a/bridge/api/httpd.py b/bridge/api/httpd.py index f3c0599e..9e8a2a9d 100644 --- a/bridge/api/httpd.py +++ b/bridge/api/httpd.py @@ -19,7 +19,7 @@ from bridge.features.workspace.workspace import MAX_TRANSFER, Workspace from bridge.features.workspace.preferences import transfer_settings from bridge.features.workspace.files import workspace_relative_path -from bridge.features.notifications.channels import settings as notification_settings, save_settings as save_notification_settings, publish_pushplus +from bridge.features.notifications.channels import settings as notification_settings, save_settings as save_notification_settings, publish, publish_bark, publish_pushplus from socketserver import TCPServer from urllib.parse import parse_qs, urlsplit, quote @@ -183,7 +183,7 @@ def cookie(self, token, clear=False): secure = "; Secure" if self.headers.get("Host") in self.server.secure_hosts else "" age = 0 if clear else self.server.auth.cookie_age(token) lifetime = f"; Max-Age={age}" if age is not None else "" - return f"{Auth.COOKIE}={token}; Path=/; HttpOnly; SameSite=Strict{lifetime}{secure}" + return f"{self.server.auth.cookie_name}={token}; Path=/; HttpOnly; SameSite=Strict{lifetime}{secure}" def client(self): return self.server.auth.client(self.client_address[0], self.headers, self.headers.get('Host') in self.server.secure_hosts) @@ -195,7 +195,13 @@ def token(self): cookie = SimpleCookie() try: cookie.load(self.headers.get("Cookie", "")) - return cookie[Auth.COOKIE].value if Auth.COOKIE in cookie else "" + name = self.server.auth.cookie_name + if name in cookie: + return cookie[name].value + # Legacy cookies are accepted only while their token belongs to + # this gateway. A successful response migrates to the new name. + legacy = cookie[Auth.COOKIE].value if Auth.COOKIE in cookie else "" + return legacy if legacy and self.server.auth.get(legacy) else "" except Exception: return "" @@ -294,7 +300,7 @@ def handle_method(self, write): discovery = getattr(self.server, 'connection_discovery', None) return self.output(200, {"uiLanguage": self.server.ui_language(), "authenticated": bool(session), "trustedDevice": bool(session and session.get("trustedDevice")), "csrf": session["csrf"] if session else None, "loginStatus": self.server.auth.login_status(self.client()["ip"]), - "instanceId": self.server.instance_id, + "instanceId": self.server.instance_id, "cookieName": self.server.auth.cookie_name, **({"computer": {"name": socket.gethostname(), "platform": platform.system()}} if session else {}), **({'connection': discovery.descriptor(self.token())} if session and discovery else {}), "notifications": self.server.notifications is not None, @@ -554,10 +560,32 @@ def handle_method(self, write): if self.server.notifications is None: raise ValueError('当前网关未启用通知服务') return self.output(200, self.server.notifications.defaults(self.read_json() if write else None)) - if path in ('/api/notifications/pushplus', '/api/notifications/pushplus/test'): + if path == '/api/notifications/channels/common': manager = self.server.notifications if manager is None: raise ValueError('当前网关未启用通知服务') + fields = ('includeTitle', 'clickBase', 'securityEnabled', 'addressEnabled', 'addressName') + with manager.lock: + if write: + body = self.read_json() + if not isinstance(body, dict) or set(body) - set(fields): + raise ValueError('通知配置格式不正确') + config = save_notification_settings(manager.data_dir, body) + else: + config = notification_settings(manager.data_dir) + return self.output(200, {key: config[key] for key in fields}) + channel_parts = path.removeprefix('/api/notifications/channels/').split('/') if path.startswith('/api/notifications/channels/') else [] + channel = channel_parts[0] if channel_parts and channel_parts[0] in ('ntfy', 'bark', 'pushplus') and channel_parts[1:] in ([], ['test']) else None + if channel or path in ('/api/notifications/pushplus', '/api/notifications/pushplus/test'): + manager = self.server.notifications + if manager is None: + raise ValueError('当前网关未启用通知服务') + selected = channel or 'pushplus' + fields = {'ntfy': ('enabled', 'server', 'topic', 'token', 'clearToken'), + 'bark': ('barkEnabled', 'barkServer', 'barkKey', 'clearBarkKey'), + 'pushplus': ('pushplusEnabled', 'pushplusToken', 'clearPushplusToken')}[selected] + secret, marker = {'ntfy': ('token', 'hasToken'), 'bark': ('barkKey', 'hasBarkKey'), + 'pushplus': ('pushplusToken', 'hasPushplusToken')}[selected] with manager.lock: config = notification_settings(manager.data_dir) if path.endswith('/test'): @@ -565,17 +593,23 @@ def handle_method(self, write): return self.output(405, {'error': '需要 POST 请求'}) self.read_json() try: - publish_pushplus(config, 'Codex 手机通知测试', '收到这条消息表示 PushPlus 通道已连通。') + sender = {'ntfy': publish, 'bark': publish_bark, 'pushplus': publish_pushplus}[selected] + name = {'ntfy': 'ntfy', 'bark': 'Bark', 'pushplus': 'PushPlus'}[selected] + sender(config, 'Codex 手机通知测试', f'收到这条消息表示 {name} 通道已连通。') except Exception: - raise ValueError('PushPlus 测试失败,请检查 Token 和网络') from None + message = 'PushPlus 测试失败,请检查 Token 和网络' if not channel else f'{name} 测试失败,请检查服务地址、认证和网络' + raise ValueError(message) from None return self.output(200, {'ok': True}) if write: body = self.read_json() - if set(body) - {'pushplusEnabled', 'pushplusToken', 'clearPushplusToken'}: - raise ValueError('PushPlus 配置格式不正确') + allowed = set(fields) + if not isinstance(body, dict) or set(body) - allowed or any( + key in body and not isinstance(body[key], bool) for key in allowed if key.startswith('clear')): + raise ValueError('通知配置格式不正确' if channel else 'PushPlus 配置格式不正确') config = save_notification_settings(manager.data_dir, body) - return self.output(200, {'pushplusEnabled': config['pushplusEnabled'], - 'hasPushplusToken': bool(config['pushplusToken'])}) + visible = {key: config[key] for key in fields if key != secret and not key.startswith('clear')} + visible[marker] = bool(config[secret]) + return self.output(200, visible) if desktop_workspace: if self.server.desktop_sessions is None: return self.output(503, {'error': '桌面会话接入不可用'}) diff --git a/bridge/clients/claude/adapter.py b/bridge/clients/claude/adapter.py index c4e6824c..95b2975f 100644 --- a/bridge/clients/claude/adapter.py +++ b/bridge/clients/claude/adapter.py @@ -97,6 +97,7 @@ async def initialize(): asyncio.run_coroutine_threadsafe(initialize(), self.loop).result(timeout=5) config = {'transport': 'file', 'cwd': str(self.directory), 'token': token, 'generation': self.desktop.generation, 'reconnect': True, + 'inspectionMarker': secrets.token_hex(8), 'connectorRevision': CONNECTOR_REVISION, 'request': 'request.json', 'response': 'response.json'} source = Path(__file__).with_name('connector.js').read_text(encoding='utf-8') @@ -107,7 +108,7 @@ async def initialize(): console.write_text(console_source(source, config), encoding='utf-8') console.chmod(0o600) return {'workspace': str(self.directory), 'scriptPath': str(target), 'script': script, - 'consolePath': str(console)} + 'consolePath': str(console), 'inspectionMarker': config['inspectionMarker']} def _setup_state(self, state, reason, **values): with self.setup_lock: @@ -475,6 +476,7 @@ def _connect_native(self, restart, cancel_path, cancel=None, *, existing_only=Fa self._setup_state('needs-retry', result.get('reason', 'Claude 自动连接未完成,请点击连接重试')) return deadline = time.monotonic() + (8 if sys.platform == 'win32' else 18) + inspect_at = time.monotonic() + 2 while not cancel.is_set() and time.monotonic() < deadline: if self.desktop.connected: if force_console: @@ -493,18 +495,26 @@ def _connect_native(self, restart, cancel_path, cancel=None, *, existing_only=Fa self._attempt_state(cancel, 'connected', 'Claude 已连接;开发者工具未自动关闭,请手动关闭' if pending else '桌面连接可用', consoleCleanupPending=pending) return + if time.monotonic() >= inspect_at: + inspection = native_action('inspect-error', pid=pid, executable=executable, + script=prepared.get('inspectionMarker'), cancelled=cancel) + if inspection.get('setupState') == 'owner-conflict': + self._attempt_state(cancel, 'owner-conflict', inspection['reason']) + return + inspect_at = time.monotonic() + 3 cancel.wait(.25) else: if not cancel.is_set(): - result = native_action('inspect-error', pid=pid, executable=executable, cancelled=cancel) + result = native_action('inspect-error', pid=pid, executable=executable, + script=prepared.get('inspectionMarker'), cancelled=cancel) if result.get('setupState') in DESKTOP_WAIT_STATES: if sys.platform == 'win32': self._attempt_state(cancel, result['setupState'], result.get('reason', 'Claude 连接未就绪')) return if self._await_desktop(result, cancel): continue return - self._setup_state('needs-trust' if result.get('setupState') == 'needs-trust' else 'failed', - result.get('reason') if result.get('setupState') == 'needs-trust' + self._setup_state(result.get('setupState') if result.get('setupState') in ('needs-trust', 'owner-conflict') else 'failed', + result.get('reason') if result.get('setupState') in ('needs-trust', 'owner-conflict') else '未收到 Claude 连接确认,请检查登录和目录信任后重试') return except Exception as exc: diff --git a/bridge/clients/claude/connector.js b/bridge/clients/claude/connector.js index e2f1cf37..8d155f4f 100644 --- a/bridge/clients/claude/connector.js +++ b/bridge/clients/claude/connector.js @@ -9,7 +9,44 @@ if (!api?.LocalSessions?.getAll || (config.transport !== 'cdp' && (!api.LocalSessions.readFileAtCwd || !api.LocalSessions.writeSessionFile))) { throw new Error('当前 Claude Desktop 不提供所需的本地文件接口'); } - window.__claudeMobileBridge?.stop(); + const normalizedWorkspace = cwd => { + const value = cwd.replaceAll('\\', '/').replace(/\/$/, ''); + return /^[A-Za-z]:\//.test(value) ? value.toLowerCase() : value; + }; + const sameWorkspace = cwd => typeof cwd === 'string' && + normalizedWorkspace(cwd) === normalizedWorkspace(config.cwd); + const existing = window.__claudeMobileBridge; + let sameOwner = false; + if (existing) { + let state; + try { + state = existing.status(); + if (typeof existing.owns === 'function') sameOwner = existing.owns(config.cwd) === true; + else if (state?.transport === 'file' && state.sessionId) { + // Legacy connectors expose only a session ID. Check Desktop's live + // session list before deciding whether that renderer is ours. + const rows = await api.LocalSessions.getAll(); + const sessions = Array.isArray(rows) ? rows : rows?.sessions; + const session = sessions?.find(row => row.sessionId === state.sessionId); + sameOwner = sameWorkspace(session?.cwd); + } + } catch { /* Unknown ownership is occupied, never permission to replace. */ } + if (!state || state.busy || state.writing || !sameOwner && state.suspended !== true) { + const message = sameOwner ? 'Claude 桥接仍有请求进行中,请稍后重试连接' : + 'Claude 桥接连接被其他网关占用,请先停止旧网关;旧版连接还需重新加载 Claude'; + window.__codingRemoteInjectionError = message; + console.error('Error: ' + message + ' [' + (config.inspectionMarker || config.generation) + ']'); + throw new Error(message); + } + } + function replaceExisting() { + if (!existing) return; + const state = existing.status(); + if (!state || window.__claudeMobileBridge !== existing || state.busy || state.writing || + !sameOwner && state.suspended !== true) + throw new Error('Claude 桥接状态已变化,请刷新后重试连接'); + existing.stop(); + } const encoder = new TextEncoder(); const key = await crypto.subtle.importKey('raw', encoder.encode(config.token), {name: 'HMAC', hash: 'SHA-256'}, false, ['sign', 'verify']); @@ -48,13 +85,28 @@ capabilities[kind].push('mobileCatalog', 'mobileProgress', 'mobileAccount'); if(kind==='code')capabilities[kind].push('mobileList'); if(typeof api[surface]?.getSession==='function'&&typeof api[surface]?.getTranscript==='function')capabilities[kind].push('mobileDetail'); - const target = api[surface], original = target?.setAvailableCodeModels; - if (typeof original === 'function') { - const observer = function (models, ...rest) { - if (Array.isArray(models)) offeredModels.set(target, models); - return original.call(target, models, ...rest); - }; - try { target.setAvailableCodeModels = observer; restore.push(() => { if (target.setAvailableCodeModels === observer) target.setAvailableCodeModels = original; }); } catch {} + } + function installObservers() { + for (const surface of Object.values(surfaces)) { + const target = api[surface], original = target?.setAvailableCodeModels; + if (typeof original === 'function') { + const observer = function (models, ...rest) { + if (Array.isArray(models)) offeredModels.set(target, models); + return original.call(target, models, ...rest); + }; + try { target.setAvailableCodeModels = observer; restore.push(() => { if (target.setAvailableCodeModels === observer) target.setAvailableCodeModels = original; }); } catch {} + } + } + } + function activate(bridge) { + replaceExisting(); + try { + installObservers(); + window.__claudeMobileBridge = bridge; + delete window.__codingRemoteInjectionError; + } catch (error) { + for (const undo of restore) undo(); + throw error; } } async function mobileCatalog(target, kind, sessionId, includeSkills=true) { @@ -204,16 +256,18 @@ return {contextUsage:null,contextUsageStatus:'unavailable'}; } if (config.transport === 'cdp') { - let stopped = false, highWater = 0; - window.__claudeMobileBridge = { + let stopped = false, inFlight = 0, highWater = 0; + activate({ stop() { stopped = true; for (const undo of restore) undo(); }, - status() { return {transport:'cdp', stopped, connectorRevision:config.connectorRevision, surfaces:capabilities}; }, + owns(cwd) { return sameWorkspace(cwd); }, + status() { return {transport:'cdp', stopped, busy:inFlight > 0, connectorRevision:config.connectorRevision, surfaces:capabilities}; }, async invoke(msg) { if (stopped || msg?.token !== config.token || msg.generation !== config.generation) return {error:'连接已过期'}; if (!Number.isSafeInteger(msg.seq) || msg.seq <= highWater) return {error:'请求已处理,不会重复执行'}; highWater = msg.seq; if (typeof msg.expires !== 'number' || msg.expires < Date.now()/1000) return {error:'请求已过期'}; + inFlight++; try { const target = api[surfaces[msg.surface]]; if (!target || !Array.isArray(msg.args)) throw new Error('不支持的桌面操作'); @@ -225,8 +279,9 @@ if (!allowed.has(msg.method) || typeof target[msg.method] !== 'function') throw new Error('不支持的桌面操作'); return {result:sanitize(msg.method, await target[msg.method](...callArgs(msg.method,msg.args)))}; } catch (error) { return {error:String(error.message || error)}; } + finally { inFlight--; } } - }; + }); return; } const initial = await read(); @@ -253,7 +308,7 @@ if (!sessionId) throw new Error('无法创建文件桥接会话;请在 Code 页面确认目录信任'); let generation = initial.generation; const generations = new Set([generation]); - let stopped = false, suspended = initial.type === 'stop', busy = false, highWater = initial.seq, lastWrite = 0, dirty = true; + let stopped = false, suspended = initial.type === 'stop', busy = false, inFlight = 0, writing = 0, highWater = initial.seq, lastWrite = 0, dirty = true; let wake, wakeRequested=false; const wakePoll=()=>{wakeRequested=true;wake?.();}; const pause=async()=>{ @@ -265,19 +320,26 @@ wakeRequested=false; }; let state = {seq: 0, done: false}; - window.__claudeMobileBridge = {stop() { stopped = true;wakePoll(); for (const undo of restore) undo(); }, - status() { return {transport: 'file', stopped, busy, sessionId, lastWrite, connectorRevision:config.connectorRevision}; }}; + activate({stop() { stopped = true;wakePoll(); for (const undo of restore) undo(); }, + owns(cwd) { return sameWorkspace(cwd); }, + status() { return {transport: 'file', stopped, suspended, busy:busy || inFlight > 0, + writing:writing > 0, sessionId, lastWrite, connectorRevision:config.connectorRevision}; }}); async function publish() { - let value = {...state, generation, connected: !stopped && !suspended, - timestamp: Date.now() / 1000, connectorRevision:config.connectorRevision, surfaces: capabilities}; - let text = await pack(value); - if (encoder.encode(text).length > 32 * 1024 * 1024) { - state = {seq: state.seq, done: true, error: '会话内容超过 32 MiB,请在桌面查看'}; - text = await pack({...value, ...state, result: undefined}); - } - const result = await api.LocalSessions.writeSessionFile(sessionId, config.response, text); - if (!result?.hash) throw new Error('Claude 拒绝写入桥接文件,请检查目录信任和文件权限'); - lastWrite = Date.now(); + writing++; + try { + let value = {...state, generation, connected: !stopped && !suspended, + timestamp: Date.now() / 1000, connectorRevision:config.connectorRevision, surfaces: capabilities}; + let text = await pack(value); + if (stopped) return; + if (encoder.encode(text).length > 32 * 1024 * 1024) { + state = {seq: state.seq, done: true, error: '会话内容超过 32 MiB,请在桌面查看'}; + text = await pack({...value, ...state, result: undefined}); + } + if (stopped) return; + const result = await api.LocalSessions.writeSessionFile(sessionId, config.response, text); + if (!result?.hash) throw new Error('Claude 拒绝写入桥接文件,请检查目录信任和文件权限'); + lastWrite = Date.now(); + } finally { writing--; } } console.info('Claude 文件桥接启动中;状态:window.__claudeMobileBridge.status()'); try { @@ -289,6 +351,7 @@ await new Promise(resolve => setTimeout(resolve, 1000)); continue; } + if (stopped) break; if (msg && msg.generation !== generation) { // A signed idle packet is the only restart handshake. Never replay a request. if (!config.reconnect || generations.has(msg.generation) || msg.type !== 'idle' || msg.seq !== 0) { @@ -322,6 +385,10 @@ } return sanitize(msg.method, await target[msg.method](...callArgs(msg.method,msg.args))); }); + // The deadline settles the gateway reply, not the Desktop call. + // Retain ownership until the underlying call itself settles. + inFlight++; + operation.then(() => { inFlight--; }, () => { inFlight--; }); bounded(operation,Math.max(100,Math.min(90000,(msg.expires-Date.now()/1000)*1000)),'桌面操作超时,结果待核对;不会重复执行').then(result => { if (generation === dispatchedGeneration) state = {seq: msg.seq, done: true, result}; }, error => { if (generation === dispatchedGeneration) state = {seq: msg.seq, done: true, error: String(error.message || error)}; }) .finally(() => { if (generation === dispatchedGeneration) { busy = false; dirty = true;wakePoll(); } }); diff --git a/bridge/clients/manager.py b/bridge/clients/manager.py index ff15befa..7fa9207f 100644 --- a/bridge/clients/manager.py +++ b/bridge/clients/manager.py @@ -886,7 +886,8 @@ def clients(self, refresh=False): row['initializationMode'] = 'background' if sys.platform == 'win32' else 'foreground' row['reconnectSupported'] = self._claude_reconnect_supported() row['canReconnect'] = bool(row['reconnectSupported'] and self.gateway_running and row['enabled'] and - row['mainRunning'] and not native.get('unknown', True)) + len(native.get('mainPids', [])) == 1 and not native.get('unknown', True) and + row['setupStatus'] != 'connecting') row['canConfirmQuit'] = bool(sys.platform in ('darwin', 'win32') and row['mainRunning']) if not self.gateway_running and row['enabled'] and row['setupStatus'] != 'recovery-required': if row['id'] == 'claude' and sys.platform == 'darwin': diff --git a/bridge/features/auth/auth.py b/bridge/features/auth/auth.py index 3fb8229f..6bbb65c3 100644 --- a/bridge/features/auth/auth.py +++ b/bridge/features/auth/auth.py @@ -72,6 +72,9 @@ def __init__(self, config, data_dir=None): self.identity = hashlib.sha256(json.dumps(identity, sort_keys=True).encode()).hexdigest() legacy_identity = hashlib.sha256(json.dumps({**identity, 'sessionHours': self.hours}, sort_keys=True).encode()).hexdigest() self.path = Path(data_dir) / 'auth-sessions.json' if data_dir is not None else None + # A cookie namespace belongs to the gateway data directory, not the + # process or its (possibly changed) login policy. + self.cookie_name = self.COOKIE + '_' + secrets.token_hex(12) self.sessions = {} self.policy = access_policy({}) self.failures = {} @@ -79,6 +82,7 @@ def __init__(self, config, data_dir=None): self.lock = threading.RLock() if self.path and self.path.exists(): saved = json.loads(self.path.read_text(encoding='utf-8')) + self.cookie_name = saved.get('cookieName') or self.cookie_name self.policy = access_policy(saved['policy']) self.failures = saved.get('failures', {}) self.auto_blocks = saved.get('autoBlocks', {}) @@ -88,6 +92,10 @@ def __init__(self, config, data_dir=None): self.persist() else: self.persist() + if not saved.get('cookieName'): + self.persist() + elif self.path: + self.persist() @staticmethod def valid(session): @@ -103,7 +111,7 @@ def persist(self): temporary = self.path.with_name(self.path.name + '.' + secrets.token_hex(8) + '.tmp') try: with os.fdopen(os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600), 'w', encoding='utf-8') as stream: - json.dump({'identity': self.identity, 'policy': self.policy, 'sessions': self.sessions, 'failures': self.failures, 'autoBlocks': self.auto_blocks}, stream) + json.dump({'identity': self.identity, 'cookieName': self.cookie_name, 'policy': self.policy, 'sessions': self.sessions, 'failures': self.failures, 'autoBlocks': self.auto_blocks}, stream) temporary.replace(self.path) finally: temporary.unlink(missing_ok=True) diff --git a/bridge/platforms/macos/claude-helper.swift b/bridge/platforms/macos/claude-helper.swift index 87b16482..249b5739 100644 --- a/bridge/platforms/macos/claude-helper.swift +++ b/bridge/platforms/macos/claude-helper.swift @@ -225,11 +225,46 @@ final class Connector { try fail("failed", "未找到 Claude Console,请确认开发者工具窗口可用后重试") } func verify(_ prompt: AXUIElement, _ expected: String) throws { - for _ in 0..<40 { + func read() throws -> String? { Thread.sleep(forTimeInterval: 0.025); try check(prompt) - if contents(prompt) == expected { return } + return (attribute(prompt, kAXValueAttribute) as? String)?.trimmingCharacters(in: .newlines) + } + var observed: String? + for _ in 0..<40 { + observed = try read() + if observed == expected { return } } - try fail("needs-attention", "Console 内容未完整到达,未执行连接脚本,请重试") + if !expected.isEmpty && observed?.isEmpty == false { + // A long Console line can expose only a rendered slice through AX. + // Move the caret to reveal more; only a single full-value match + // establishes that the editor contains the expected source. + var settable = DarwinBoolean(false) + try check(prompt) + if AXUIElementIsAttributeSettable(prompt, kAXSelectedTextRangeAttribute as CFString, &settable) == .success && settable.boolValue { + for _ in 0..<3 { + let live = try read() + if live == expected { + try key(125, flags: .maskCommand, prompt: prompt) + return + } + guard let live = live, !live.isEmpty else { break } + let position = min(expected.utf16.count / 2, live.utf16.count - 1) + var range = CFRange(location: position, length: 0) + guard let selected = AXValueCreate(.cfRange, &range) else { break } + try check(prompt) + guard AXUIElementSetAttributeValue(prompt, kAXSelectedTextRangeAttribute as CFString, selected) == .success else { break } + // A selection alone need not scroll CodeMirror; Right updates its viewport. + try key(124, prompt: prompt) + for _ in 0..<4 { + if try read() == expected { + try key(125, flags: .maskCommand, prompt: prompt) + return + } + } + } + } + } + try fail("needs-attention", "Console 无法核验完整内容,未执行连接脚本,请重试") } func submit(_ prompt: AXUIElement) throws { try check(prompt) @@ -293,7 +328,7 @@ final class Connector { if end % 128 == 0 || end == units.count { try verify(prompt, String(decoding: units[0.. 3 ? args[3] : nil) } else { try fail("needs-attention", "连接组件参数无效") } } } diff --git a/bridge/platforms/macos/claude.py b/bridge/platforms/macos/claude.py index 55397111..3c67ad91 100644 --- a/bridge/platforms/macos/claude.py +++ b/bridge/platforms/macos/claude.py @@ -56,6 +56,8 @@ def helper_command(helper, action, pid, executable, script, cancel_path): args += [str(pid), str(executable)] if action == 'connect': args += [str(script), str(cancel_path)] + elif action == 'inspect-error' and script: + args += [str(script)] return args, {} diff --git a/bridge/platforms/windows/claude-helper.cs b/bridge/platforms/windows/claude-helper.cs index 03642e30..c8cada4a 100644 --- a/bridge/platforms/windows/claude-helper.cs +++ b/bridge/platforms/windows/claude-helper.cs @@ -1305,6 +1305,7 @@ [STAThread] static int Main(string[] args) { var texts=AutomationElement.FromHandle(WaitAppWindow(process,false)).FindAll(TreeScope.Descendants,new PropertyCondition(AutomationElement.ControlTypeProperty,ControlType.Text)); foreach(AutomationElement item in texts) { string value=item.Current.Name; + if(args.Length==4 && value.Contains("Claude 桥接连接被其他网关占用") && value.Contains("["+args[3]+"]")) {Stage("OWNER_CONFLICT");return 0;} if((value.StartsWith("Error:")||value.StartsWith("Uncaught"))&&(value.Contains("尚未信任目录")||value.Contains("WorkspaceTrustError")||value.Contains("trust_required"))) {Stage("NEEDS_TRUST");return 0;} } Stage("NO_TRUST_ERROR");return 0; diff --git a/bridge/platforms/windows/claude.py b/bridge/platforms/windows/claude.py index 172ac884..f9a7088f 100644 --- a/bridge/platforms/windows/claude.py +++ b/bridge/platforms/windows/claude.py @@ -101,6 +101,8 @@ def desktop_blocked(): return {'setupState': 'ready'} argument = '--connect-background='+str(script) if background else str(script) if action == 'connect' else '--'+action args = [str(helper), str(pid), argument, str(executable)] + if action == 'inspect-error' and script: + args.append(str(script)) if cancel_path: args.append(str(cancel_path)) options = {'creationflags': getattr(subprocess, 'CREATE_NO_WINDOW', 0)} @@ -191,6 +193,9 @@ def desktop_blocked(): else 'needs-desktop' if '桌面不可用' in message else 'needs-retry') return {'setupState': state, 'reason': message[:300]} if action == 'inspect-error': + if 'OWNER_CONFLICT' in stdout: + return {'setupState': 'owner-conflict', + 'reason': 'Claude 桥接连接被其他网关占用,请先停止旧网关;旧版连接还需重新加载 Claude'} return {'setupState': 'needs-trust' if 'NEEDS_TRUST' in stdout else 'failed', 'reason': '请在 Claude Code 中确认连接目录信任,然后重新连接'} return {'setupState': 'needs-developer-mode' if action == 'enable-devtools' else 'submitted', diff --git a/desktop/features/files/settings.js b/desktop/features/files/settings.js index 020327b3..69d63f07 100644 --- a/desktop/features/files/settings.js +++ b/desktop/features/files/settings.js @@ -1,2 +1,2 @@ 'use strict'; -BridgeFileActions.mountSettings(document.querySelector('[data-panel=advanced]'),value=>window.bridgeDesktop.transferSettings(value)).id='file-transfer-settings'; +BridgeFileActions.mountSettings(document.querySelector('[data-panel=advanced]'),value=>window.bridgeDesktop.transferSettings(value),{ready:()=>true}).id='file-transfer-settings'; diff --git a/mobile/README.md b/mobile/README.md index 4d4240e2..2503402d 100644 --- a/mobile/README.md +++ b/mobile/README.md @@ -2,14 +2,25 @@ 手机扫描网关二维码后连接 Codex、Claude Desktop 和 DeepSeek Harness,电脑继续执行任务。手机 App 保存多台电脑,提供原生扫码、剪贴板、文件下载和通知;浏览器入口同时保留。 -**[v2.0.2 下载](https://github.com/try2love/codex-mobile-bridge/releases/tag/v2.0.2)** · [完整更新说明](../RELEASE_NOTES.md) · [桌面客户端接入](../docs/deepseek-harness.md) +**[v2.0.3 发布页](https://github.com/try2love/codex-mobile-bridge/releases/tag/v2.0.3)** · [完整更新说明](../RELEASE_NOTES.md) · [桌面客户端接入](../docs/deepseek-harness.md) + +> v2.0.3 正式版;下列安装包名称和 + +## v2.0.3 更新 + +- Android 应用内更新,手机下载与电脑下载自主选择,已下载 APK 可恢复安装或导出。 +- Android/iOS 安全保留有效扫码绑定,在 WebView 登录状态丢失时尝试恢复,无需保存网关密码。 +- 第三方通知支持选择 Bark(iPhone)、ntfy(Android)、PushPlus(微信),在手机或 Web 配置、保存和测试。 +- 搜索框常显、筛选按内容分配宽度,改善英文与横屏/平板布局;Claude/DSH 下拉刷新、错误恢复与工作台分屏更连贯。 ## 安装与升级 -- **Android 8.0 及以上**:从 Release 下载 `Codex-Mobile-Bridge-2.0.2-Android.apk`,按系统提示安装。升级时覆盖安装,不要先卸载,以保留电脑列表和登录。相机权限仅供扫码,拒绝后仍可粘贴地址登录。 +- **Android 8.0 及以上**:从 Release 下载 `Codex-Mobile-Bridge-2.0.3-Android.apk`,按系统提示安装。升级时覆盖安装,不要先卸载,以保留电脑列表和登录。相机权限仅供扫码,拒绝后仍可粘贴地址登录。 - **iPhone / iPad**:Release 提供未签名 IPA 和 Xcode 源码包,需使用自己的 Apple 账号签名;不能直接点击 IPA 安装,暂无 App Store / TestFlight 分发。免费签名可能需要定期重新安装。 - 使用 Release 中的 `SHA256SUMS.txt` 校验下载。编译通过不代表已验证所有机型、系统版本和网络环境。 +首次从较旧版本升级到 v2.0.3,可先下载 APK 覆盖安装;新版 Android 再次更新时可直接使用以下应用内流程。 + 在「手机设置 → 应用更新」查看当前版本并检查更新。正式版只检查正式发布,预览版也可检查后续预览;不会在后台自动下载或安装。确认更新后,Android 会在 App 内下载 APK 并打开系统安装器,iOS 打开新版与签名安装指引。访问公开 GitHub Release 的请求不携带网关凭据或 Cookie。 Android 在安装前核对发布信息中的大小和 SHA-256;缺少校验信息时提供版本页下载入口。首次安装更新需按系统提示允许此 App 安装应用。已完成的更新可在重新打开 App 后继续安装,也可选择「保存」导出 APK。 @@ -30,7 +41,7 @@ Android 手机无法查询 GitHub 时,会尝试通过当前已连接并登录 3. 在「我的电脑」点击卡片或应用图标,进入对应客户端聊天列表。电脑按最近访问排序,可重命名、移除或手动刷新在线状态。 4. 保持电脑、网关和相应客户端在线。局域网需手机能访问电脑;外出可选择 [Tailscale](../docs/tailscale.md)、[Cloudflare](../docs/fixed-domain.md)、[自有服务器 + SSH](../docs/server-ssh.md) 或已有 HTTPS 反代。 -扫码建立可撤销的可信设备绑定,不保存网关密码,正常重启 App 或网关后仍可连接。手机登录不受普通浏览器登录有效期影响。电脑端可在「登录与设备」撤销绑定;移除电脑、清除 App 数据、更改认证或主动撤销后可能需要重新配对。已经失效的旧登录不会无凭据地升级为新绑定。 +扫码建立可撤销的可信设备绑定,不保存网关密码。Android 使用系统密钥加密保存绑定副本,iOS 使用钥匙串保存;WebView 登录状态丢失后,先向对应网关核验,仍有效才恢复。显式退出、移除电脑或服务端撤销后不再恢复;网络暂时不可达不会直接删除副本。正常重启 App 或网关后仍可连接。手机登录不受普通浏览器登录有效期影响。电脑端可在「登录与设备」撤销绑定;移除电脑、清除 App 数据、更改认证或主动撤销后可能需要重新配对。已经失效的旧登录不会无凭据地升级为新绑定。 相机扫码不建立网络通道。固定地址也不保证国内直连速度;请在实际 Wi-Fi、蜂窝网络验证。Tailscale Funnel 不要求手机安装 Tailscale;私有 Serve 则要求手机接入有权限的 tailnet。 @@ -42,8 +53,9 @@ Android 手机无法查询 GitHub 时,会尝试通过当前已连接并登录 ## 聊天列表与应用切换 -- 当前应用图标和名称紧邻「聊天」,右侧新建与更多设置遵循原生菜单留白。搜索、最近交互/按项目和归档筛选放在同一行。 -- 功能栏位于应用切换栏上方,账号管理、通知管理、应用管理可选择展示,「返回电脑列表」位于右侧;普通 Web 不显示返回电脑列表。 +- 当前应用图标和名称紧邻「聊天」,右侧新建与更多设置遵循原生菜单留白。搜索框始终显示「搜索 / Search」,最近交互/按项目、Claude 会话类型等选择框按当前内容分配宽度,其余空间留给搜索;英文使用 Recent、Project、All、Code、Cowork 等短标签。 +- Claude 与 DSH 支持手动下拉刷新,保留当前搜索、筛选和阅读位置。列表不常驻显示读取中或连接成功提示;连接失败仍显示原因与恢复入口,恢复成功后清除旧错误。 +- 功能栏位于应用切换栏上方,账号管理、第三方通知、应用管理可选择展示,「返回电脑列表」位于右侧;普通 Web 不显示返回电脑列表。 - 仅一个应用启用时隐藏底部切换栏;更多设置可选择隐藏图标或整栏。隐藏只影响显示,不关闭电脑应用。 - 左右滑动切换应用默认开启,有拖动预览;可在显示设置关闭。此前主动关闭的选择保留,不拦截纵向滚动、输入或边缘返回手势。 - Claude Code 与 Cowork 分组可折叠;返回从当前会话回到该应用列表。模型、权限、上下文用量与账号入口均随当前应用变化,缺失能力不伪装成 Codex 的功能。 @@ -64,10 +76,16 @@ Android 手机无法查询 GitHub 时,会尝试通过当前已连接并登录 3. App 在前台同步已保存电脑的新事件,切入后台取消巡检。首次同步、回前台或事件流重置不会集中弹出旧提醒,历史仍在收件箱。 4. 点击通知回到相应电脑和聊天。手机设置的十秒测试只检验本机显示,不证明远程推送已经接通。 -**标准手机包不保证离开 App、锁屏或被系统终止后的通知送达。** 可使用电脑端 Bark、ntfy、PushPlus;启用「通知链接优先打开手机 App」后,支持该链接的通道可跳转至已保存的电脑。 +**标准手机包不保证离开 App、锁屏或被系统终止后的通知送达。** 可在「第三方通知」配置 Bark、ntfy、PushPlus;启用「通知链接优先打开手机 App」后,支持该链接的通道可跳转至已保存的电脑。 Android 通知大图标显示来源应用,状态栏遵守单色图标要求;iOS 使用来源名称和图片附件,系统小图标仍属于 Bridge,不能逐条替换成其他应用图标。图片由 App 自带,不从远端下载。 +### 第三方通知 + +直连电脑网关时,从「外观与显示」、Web 设置或聊天列表底部进入 **第三方通知**(英文入口为 **Push alerts**)。先选择 **Bark(iPhone)、ntfy(Android)或 PushPlus(微信)**,再进入详情启用通道、填写服务信息、保存并测试已保存配置。共享中继不提供此配置入口。 + +手机和电脑端修改同一份网关配置,变更接收目标会影响使用该通道的提醒。已保存密钥不回传手机,留空保留;更换服务地址时重新填写。通用设置可修改通知点击地址、标题显示、安全事件和网关启动/入口变化通知;全会话与单聊天通知选择仍独立管理。接口接受测试不等于已送达,请在 Bark、ntfy 或微信实际确认。 + ### 灵动岛与锁屏实时活动 需要 iOS 16.2 或更新版本。在聊天更多菜单选择「在灵动岛显示此聊天」,支持的机型显示灵动岛,其他兼容机型可显示锁屏活动;点击回到聊天,选择结束显示可关闭。卡片不含聊天标题/正文。 @@ -114,6 +132,8 @@ macOS/Linux 使用登录 shell 与 PTY;Windows 10 1809 及以上使用 ConPTY 宽浏览器、平板或展开的折叠屏可通过标签栏分屏按钮并排查看聊天与工具,拖动分隔条调整宽度,也可将工具标签放到右侧。窗口收窄恢复单栏,重新展开恢复选择;保留草稿、标签与阅读位置。 +「外观与显示」及 Web 设置顶部并列放置底部 App 图标开关、应用管理、第三方通知;英文使用 Show/Hide icons、Apps、Push alerts。只有一个应用时无需显示图标切换开关。文件传输的保存按钮只保存传输大小配置,不影响其他外观设置。关闭当前工具标签时,仍有内容的另一侧分屏保持打开。 + 更多设置可切换中英文、浅色/深色/跟随系统、强调色、文字和代码字号、间距、思考摘要与执行过程。正文、日志、模型/Skill 名称和用户标题不强行翻译。输入区箭头可收起/展开,保留草稿。复制消息或代码使用系统剪贴板。 ## 开发与构建 diff --git a/mobile/android-push/AndroidManifest.xml b/mobile/android-push/AndroidManifest.xml index cac63f13..12d71ae2 100644 --- a/mobile/android-push/AndroidManifest.xml +++ b/mobile/android-push/AndroidManifest.xml @@ -1,4 +1,4 @@ - + diff --git a/mobile/android-push/build.gradle b/mobile/android-push/build.gradle index 63394858..3b455d30 100644 --- a/mobile/android-push/build.gradle +++ b/mobile/android-push/build.gradle @@ -3,7 +3,7 @@ android { namespace 'io.github.try2love.codexbridge.preview' compileSdk 35 buildToolsVersion "35.0.0" - defaultConfig { applicationId 'io.github.try2love.codexbridge.preview'; minSdk 26; targetSdk 35; versionCode 29; versionName '2.0.2' } + defaultConfig { applicationId 'io.github.try2love.codexbridge.preview'; minSdk 26; targetSdk 35; versionCode 30; versionName '2.0.3' } sourceSets { main { manifest.srcFile 'AndroidManifest.xml'; java.srcDirs = ['../android/src', 'src']; res.srcDirs = ['../android/res']; assets.srcDirs = ['../shared/web', '../android/assets', '../../web/client-icons'] } } compileOptions { sourceCompatibility JavaVersion.VERSION_1_8; targetCompatibility JavaVersion.VERSION_1_8 } signingConfigs { preview { storeFile file('../../.tmp/mobile-tools/preview-debug.p12'); storePassword 'android'; keyAlias 'preview'; keyPassword 'android' } } diff --git a/mobile/android/AndroidManifest.xml b/mobile/android/AndroidManifest.xml index 0d4fa2a0..4066a38c 100644 --- a/mobile/android/AndroidManifest.xml +++ b/mobile/android/AndroidManifest.xml @@ -1,4 +1,4 @@ - + diff --git a/mobile/android/assets/mobile-session.js b/mobile/android/assets/mobile-session.js index 6839367d..95d965c8 100644 --- a/mobile/android/assets/mobile-session.js +++ b/mobile/android/assets/mobile-session.js @@ -2,6 +2,8 @@ if (window !== window.top) return; const persist = () => window.prompt('codexbridge-session:__BRIDGE_CLIPBOARD_TOKEN__', ''); document.addEventListener('bridge-authenticated', persist); + document.addEventListener('bridge-logged-out', () => window.prompt('codexbridge-logout:__BRIDGE_CLIPBOARD_TOKEN__', '')); + document.addEventListener('bridge-auth-lost', () => window.prompt('codexbridge-auth-lost:__BRIDGE_CLIPBOARD_TOKEN__', '')); const app = document.getElementById('app'); if (app && !app.hidden) persist(); })(); diff --git a/mobile/android/src/io/github/try2love/codexbridge/BindingSelection.java b/mobile/android/src/io/github/try2love/codexbridge/BindingSelection.java new file mode 100644 index 00000000..5dddae9f --- /dev/null +++ b/mobile/android/src/io/github/try2love/codexbridge/BindingSelection.java @@ -0,0 +1,16 @@ +package io.github.try2love.codexbridge; + +/** Select only the cookie name confirmed by this gateway's auth response. */ +final class BindingSelection { + static boolean name(String value){return value!=null&&value.matches("codex_mobile_session_[a-f0-9]{24}|codex_mobile_session");} + static String cookie(String header,String name){ + if(header!=null&&name(name))for(String part:header.split(";")){String value=part.trim();if(value.startsWith(name+"="))return value;} + return ""; + } + static String match(String sent,String current,String name){ + String selected=cookie(current,name),previous=cookie(sent,name); + if(previous.isEmpty()&&!"codex_mobile_session".equals(name))previous=cookie(sent,"codex_mobile_session"); + if(selected.isEmpty()||previous.isEmpty()||!selected.substring(selected.indexOf('=')+1).equals(previous.substring(previous.indexOf('=')+1)))return ""; + return selected; + } +} diff --git a/mobile/android/src/io/github/try2love/codexbridge/ComputerNotifications.java b/mobile/android/src/io/github/try2love/codexbridge/ComputerNotifications.java index 2873ea82..9a1bc012 100644 --- a/mobile/android/src/io/github/try2love/codexbridge/ComputerNotifications.java +++ b/mobile/android/src/io/github/try2love/codexbridge/ComputerNotifications.java @@ -17,7 +17,7 @@ private static final class Snapshot {final String binding;final JSONArray client private static void changed(){Runnable callback=onChange;if(callback!=null)callback.run();} static synchronized JSONArray clients(Context context,String origin){ Snapshot snapshot=snapshots.get(origin);SharedPreferences prefs=context.getSharedPreferences("bridge",0); - if(snapshot==null||!prefs.getStringSet("origins",Collections.emptySet()).contains(origin)||!snapshot.binding.equals(EventClient.sessionCookie(CookieManager.getInstance().getCookie(origin+"/"))))return new JSONArray(); + if(snapshot==null||!prefs.getStringSet("origins",Collections.emptySet()).contains(origin)||!snapshot.binding.equals(EventClient.sessionCookie(CookieManager.getInstance().getCookie(origin+"/"),origin)))return new JSONArray(); return snapshot.clients==null?new JSONArray():snapshot.clients; } static synchronized boolean fresh(String origin){Snapshot snapshot=snapshots.get(origin);return snapshot!=null&&snapshot.fresh;} diff --git a/mobile/android/src/io/github/try2love/codexbridge/ConnectionDiscovery.java b/mobile/android/src/io/github/try2love/codexbridge/ConnectionDiscovery.java index e13655fa..27aeef58 100644 --- a/mobile/android/src/io/github/try2love/codexbridge/ConnectionDiscovery.java +++ b/mobile/android/src/io/github/try2love/codexbridge/ConnectionDiscovery.java @@ -18,7 +18,7 @@ final class ConnectionDiscovery { void cancel(){cancelled=true;HttpURLConnection c=active;if(c!=null)c.disconnect();} static String hex(byte[] bytes){StringBuilder s=new StringBuilder();for(byte b:bytes)s.append(String.format("%02x",b&255));return s.toString();} static String hash(String value)throws Exception{return hex(MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8)));} - static String secret(String cookie){String value=EventClient.sessionCookie(cookie);return value.isEmpty()?"":value.substring(value.indexOf('=')+1);} + static String secret(String cookie){return cookie==null||!cookie.contains("=")?"":cookie.substring(cookie.indexOf('=')+1);} static JSONObject descriptor(JSONObject auth,String cookie)throws Exception { JSONObject d=auth.optJSONObject("connection");String secret=secret(cookie); if(!auth.optBoolean("authenticated")||d==null||secret.isEmpty())return null; diff --git a/mobile/android/src/io/github/try2love/codexbridge/DeviceBinding.java b/mobile/android/src/io/github/try2love/codexbridge/DeviceBinding.java new file mode 100644 index 00000000..a51144b8 --- /dev/null +++ b/mobile/android/src/io/github/try2love/codexbridge/DeviceBinding.java @@ -0,0 +1,76 @@ +package io.github.try2love.codexbridge; + +import android.content.Context; +import android.content.SharedPreferences; +import android.security.keystore.KeyGenParameterSpec; +import android.security.keystore.KeyProperties; +import android.util.Base64; +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.security.KeyStore; +import javax.crypto.Cipher; +import javax.crypto.KeyGenerator; +import javax.crypto.SecretKey; +import javax.crypto.spec.GCMParameterSpec; +import org.json.JSONObject; + +/** Device-only encrypted copy of a server-revocable native session. */ +final class DeviceBinding { + private static final String ALIAS="codex-bridge-device-binding-v1"; + static final class Credential { + final String name,token; + Credential(String name,String token){this.name=name;this.token=token;} + String cookie(){return name+"="+token;} + } + static boolean name(String value){return value!=null&&value.matches("codex_mobile_session_[a-f0-9]{24}|codex_mobile_session");} + static SharedPreferences prefs(Context context){return context.getSharedPreferences("bridge-bindings",0);} + private static SecretKey key()throws Exception { + KeyStore store=KeyStore.getInstance("AndroidKeyStore");store.load(null); + SecretKey key=(SecretKey)store.getKey(ALIAS,null); + if(key!=null)return key; + KeyGenerator generator=KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES,"AndroidKeyStore"); + generator.init(new KeyGenParameterSpec.Builder(ALIAS,KeyProperties.PURPOSE_ENCRYPT|KeyProperties.PURPOSE_DECRYPT) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM).setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE).setKeySize(256).build()); + return generator.generateKey(); + } + static void save(Context context,String origin,String name,String token)throws Exception { + if(!GatewayURL.origin(origin).equals(origin)||!name(name)||token==null||!token.matches("[A-Za-z0-9_-]{32,128}"))return; + Cipher cipher=Cipher.getInstance("AES/GCM/NoPadding");cipher.init(Cipher.ENCRYPT_MODE,key()); + byte[] payload=new JSONObject().put("name",name).put("token",token).toString().getBytes(StandardCharsets.UTF_8); + byte[] iv=cipher.getIV(),encrypted=cipher.doFinal(payload),combined=new byte[iv.length+encrypted.length]; + System.arraycopy(iv,0,combined,0,iv.length);System.arraycopy(encrypted,0,combined,iv.length,encrypted.length); + prefs(context).edit().putString(origin,Base64.encodeToString(combined,Base64.NO_WRAP)).apply(); + } + static Credential load(Context context,String origin){ + String encoded=prefs(context).getString(origin,null);if(encoded==null)return null; + try{ + byte[] bytes=Base64.decode(encoded,Base64.NO_WRAP);if(bytes.length<29||bytes.length>1024)return null; + Cipher cipher=Cipher.getInstance("AES/GCM/NoPadding");cipher.init(Cipher.DECRYPT_MODE,key(),new GCMParameterSpec(128,bytes,0,12)); + JSONObject value=new JSONObject(new String(cipher.doFinal(bytes,12,bytes.length-12),StandardCharsets.UTF_8)); + String name=value.getString("name"),token=value.getString("token"); + return name(name)&&token.matches("[A-Za-z0-9_-]{32,128}")?new Credential(name,token):null; + }catch(Exception invalid){return null;} + } + static void clear(Context context,String origin){prefs(context).edit().remove(origin).apply();} + /** Returns null for a network/transport error, false for server-confirmed invalidity. */ + static Boolean verify(String origin,Credential credential){ + HttpURLConnection connection=null; + try{ + if(!GatewayURL.origin(origin).equals(origin))return false; + connection=(HttpURLConnection)new URL(origin+"/api/auth").openConnection(); + connection.setInstanceFollowRedirects(false);connection.setConnectTimeout(4000);connection.setReadTimeout(4000); + connection.setRequestProperty("Cookie",credential.cookie());connection.setRequestProperty("Origin",GatewayURL.httpOrigin(origin));connection.setRequestProperty("User-Agent",EventClient.USER_AGENT); + if(connection.getResponseCode()!=200||!GatewayURL.sameOrigin(connection.getURL().toString(),origin))return null; + ByteArrayOutputStream out=new ByteArrayOutputStream();try(InputStream in=connection.getInputStream()){ + byte[] bytes=new byte[4096];int n;while((n=in.read(bytes))!=-1){if(out.size()+n>65536)return null;out.write(bytes,0,n);} + } + JSONObject auth=new JSONObject(out.toString("UTF-8")); + if(!auth.optBoolean("authenticated"))return false; + String returned=auth.optString("cookieName",""); + return returned.isEmpty()?credential.name.equals("codex_mobile_session"):returned.equals(credential.name); + }catch(Exception unavailable){return null;}finally{if(connection!=null)connection.disconnect();} + } +} diff --git a/mobile/android/src/io/github/try2love/codexbridge/EventClient.java b/mobile/android/src/io/github/try2love/codexbridge/EventClient.java index eaeac1a4..b00594a2 100644 --- a/mobile/android/src/io/github/try2love/codexbridge/EventClient.java +++ b/mobile/android/src/io/github/try2love/codexbridge/EventClient.java @@ -7,6 +7,9 @@ final class EventClient { static final String USER_AGENT="BridgeMobile/0.1-Android"; + private static final java.util.Map names=new java.util.concurrent.ConcurrentHashMap<>(); + static void setName(String origin,String name){if(name!=null&&name.matches("codex_mobile_session_[a-f0-9]{24}|codex_mobile_session"))names.put(origin,name);} + static String cookieName(String origin){return names.getOrDefault(origin,"codex_mobile_session");} static final class Read { private volatile boolean cancelled; private HttpURLConnection connection; @@ -16,16 +19,19 @@ static final class Read { void cancel(){HttpURLConnection current;synchronized(this){cancelled=true;current=connection;connection=null;}if(current!=null)current.disconnect();} } static String sessionCookie(String header){if(header!=null)for(String part:header.split(";"))if(part.trim().startsWith("codex_mobile_session="))return part.trim();return "";} - static synchronized void forget(String origin){CookieManager store=CookieManager.getInstance();store.setCookie(origin+"/","codex_mobile_session=; Path="+GatewayURL.cookiePath(origin)+"; Max-Age=0");store.flush();} + static String sessionCookie(String header,String origin){String wanted=cookieName(origin)+"=";if(header!=null)for(String part:header.split(";"))if(part.trim().startsWith(wanted))return part.trim();return "";} + static String tokenValue(String cookie){return cookie.isEmpty()?"":cookie.substring(cookie.indexOf('=')+1);} + static synchronized void forget(String origin){CookieManager store=CookieManager.getInstance();store.setCookie(origin+"/",cookieName(origin)+"=; Path="+GatewayURL.cookiePath(origin)+"; Max-Age=0");store.flush();names.remove(origin);} static synchronized void renew(HttpURLConnection connection,String origin,String sent){ - CookieManager store=CookieManager.getInstance();String previous=sessionCookie(sent); - if(previous.isEmpty()||!previous.equals(sessionCookie(store.getCookie(origin+"/"))))return; + CookieManager store=CookieManager.getInstance();String previous=sessionCookie(sent,origin); + if(previous.isEmpty()||!previous.equals(sessionCookie(store.getCookie(origin+"/"),origin)))return; // Only renew the same authenticated binding. A late response must not // restore a removed connection or overwrite a newer sign-in. for(java.util.Map.Entry> field:connection.getHeaderFields().entrySet())if("Set-Cookie".equalsIgnoreCase(field.getKey()))for(String value:field.getValue()){ - if(!previous.equals(value.split(";",2)[0].trim()))continue; + String replacement=value.split(";",2)[0].trim(); + if(!previous.equals(replacement)&&!(previous.startsWith("codex_mobile_session=")&&replacement.startsWith("codex_mobile_session_")&&replacement.endsWith(previous.substring(previous.indexOf('=')))))continue; try{java.util.List parsed=HttpCookie.parse(value);if(parsed.size()!=1||parsed.get(0).getDomain()!=null||!GatewayURL.cookiePath(origin).equals(parsed.get(0).getPath()))continue;}catch(IllegalArgumentException invalid){continue;} - store.setCookie(origin+"/",value);store.flush();return; + store.setCookie(origin+"/",value);store.flush();if(replacement.startsWith("codex_mobile_session_"))setName(origin,replacement.substring(0,replacement.indexOf('=')));return; } } static JSONObject read(String origin)throws Exception { @@ -33,14 +39,14 @@ static JSONObject read(String origin)throws Exception { } static JSONObject read(String origin,Long after,Read pending)throws Exception { if(!GatewayURL.origin(origin).equals(origin))throw new IOException("连接地址无效"); - String cookie=CookieManager.getInstance().getCookie(origin+"/");if(cookie==null||cookie.isEmpty())throw new IOException("请先登录电脑网关"); + String cookie=CookieManager.getInstance().getCookie(origin+"/"),binding=sessionCookie(cookie,origin);if(binding.isEmpty())throw new IOException("请先登录电脑网关"); HttpURLConnection c=(HttpURLConnection)new URL(origin+"/api/mobile/events"+(after==null?"":"?after="+Math.max(0,after))).openConnection(); c.setInstanceFollowRedirects(false);c.setConnectTimeout(7000);c.setReadTimeout(10000);c.setRequestProperty("Cookie",cookie);c.setRequestProperty("Origin",GatewayURL.httpOrigin(origin));c.setRequestProperty("User-Agent",USER_AGENT); try{if(pending!=null)pending.attach(c);int code=c.getResponseCode();if(code==401||code==403)throw new IOException("登录已失效,请重新连接电脑");if(code==404)throw new IOException("请使用配套的电脑端 Preview 网关");if(code!=200)throw new IOException("暂时无法读取提醒,请检查连接"); if(pending!=null)pending.check(); renew(c,origin,cookie); ByteArrayOutputStream out=new ByteArrayOutputStream();try(InputStream in=c.getInputStream()){byte[] b=new byte[8192];int n;while((n=in.read(b))!=-1){if(pending!=null)pending.check();if(out.size()+n>512000)throw new IOException("通知数据过大");out.write(b,0,n);}} - if(pending!=null){pending.check();if(!sessionCookie(cookie).equals(sessionCookie(CookieManager.getInstance().getCookie(origin+"/"))))throw new IOException("登录已失效,请重新连接电脑");} + if(pending!=null){pending.check();if(!tokenValue(binding).equals(tokenValue(sessionCookie(CookieManager.getInstance().getCookie(origin+"/"),origin))))throw new IOException("登录已失效,请重新连接电脑");} return new JSONObject(out.toString("UTF-8")); }finally{if(pending!=null)pending.detach();c.disconnect();} } @@ -60,7 +66,7 @@ static JSONObject request(String origin,String path,JSONObject body,String csrf) try{if(body!=null){c.setRequestMethod("POST");c.setDoOutput(true);c.setRequestProperty("Content-Type","application/json");c.setRequestProperty("X-CSRF-Token",csrf);try(OutputStream out=c.getOutputStream()){out.write(body.toString().getBytes(StandardCharsets.UTF_8));}} if(c.getResponseCode()!=200)throw new IOException("电脑未配置系统推送,或登录已失效"); renew(c,origin,cookie); - ByteArrayOutputStream out=new ByteArrayOutputStream();try(InputStream in=c.getInputStream()){byte[] b=new byte[4096];int n;while((n=in.read(b))!=-1){if(out.size()+n>65536)throw new IOException("响应过大");out.write(b,0,n);}}return new JSONObject(out.toString("UTF-8")); + ByteArrayOutputStream out=new ByteArrayOutputStream();try(InputStream in=c.getInputStream()){byte[] b=new byte[4096];int n;while((n=in.read(b))!=-1){if(out.size()+n>65536)throw new IOException("响应过大");out.write(b,0,n);}}JSONObject result=new JSONObject(out.toString("UTF-8"));if("/api/auth".equals(path))setName(origin,result.optString("cookieName",null));return result; }finally{c.disconnect();} } diff --git a/mobile/android/src/io/github/try2love/codexbridge/MainActivity.java b/mobile/android/src/io/github/try2love/codexbridge/MainActivity.java index 1a2aa7a0..01a3cf3d 100644 --- a/mobile/android/src/io/github/try2love/codexbridge/MainActivity.java +++ b/mobile/android/src/io/github/try2love/codexbridge/MainActivity.java @@ -30,7 +30,7 @@ public final class MainActivity extends Activity { private FrameLayout screen;private DownloadCard downloadCard;private ArtifactDownload.Task downloadTask,choosingDownload;private UpdateInstallSession updateSession;private boolean savingDownload,openingDownload,restoringUpdate,autoInstallUpdate; private String choosingUpdateFile;private Bundle pendingSaveResult; private GatewayUpdate preparingUpdate; - private ServiceConnection monitor;private ValueCallback files;private WebView fileView;private int fileGeneration;private int generation; + private ServiceConnection monitor;private ValueCallback files;private WebView fileView;private int fileGeneration;private int generation;private boolean authLossAttempted; String L(String value){return MobileStrings.text(this,value);} int dp(int n){return (int)(getResources().getDisplayMetrics().density*n);} TextView text(String value,int size){TextView t=new TextView(this);t.setText(value);t.setTextSize(size);t.setTextColor(0xff202124);t.setPadding(0,dp(5),0,dp(5));return t;} @@ -43,37 +43,69 @@ public final class MainActivity extends Activity { void header(boolean chat){bar.setVisibility(chat?View.GONE:View.VISIBLE);if(chat)return;bar.removeAllViews();ImageView logo=new ImageView(this);logo.setImageResource(getResources().getIdentifier("ic_bridge","drawable",getPackageName()));LinearLayout.LayoutParams lp=new LinearLayout.LayoutParams(dp(30),dp(30));lp.setMarginEnd(dp(8));bar.addView(logo,lp);TextView title=text("Codex Bridge",18);title.setTypeface(null,android.graphics.Typeface.BOLD);bar.addView(title,new LinearLayout.LayoutParams(0,-2,1));bar.addView(icon("ic_bell",L("通知收件箱"),this::inbox),new LinearLayout.LayoutParams(dp(48),dp(48)));bar.addView(icon("ic_settings",L("手机设置"),this::settings),new LinearLayout.LayoutParams(dp(48),dp(48)));} void webAction(String selector){if(web!=null&&GatewayURL.sameOrigin(web.getUrl(),origin))web.evaluateJavascript("document.querySelector("+JSONObject.quote(selector)+")?.click()",null);} void rememberConnection(){ - final String address=origin,binding=EventClient.sessionCookie(CookieManager.getInstance().getCookie(origin+"/"));final int ticket=generation; - worker.execute(()->{try{JSONObject auth=EventClient.request(address,"/api/auth",null,null),data=ConnectionDiscovery.descriptor(auth,binding); - runOnUiThread(()->{if(ticket!=generation||!binding.equals(EventClient.sessionCookie(CookieManager.getInstance().getCookie(address+"/"))))return;if(data!=null)prefs.edit().putString("connection:"+address,data.toString()).apply();else prefs.edit().remove("connection:"+address).apply();}); + final String address=origin;final int ticket=generation;final String snapshot=CookieManager.getInstance().getCookie(address+"/"); + worker.execute(()->{try{JSONObject auth=EventClient.request(address,"/api/auth",null,null);String name=auth.optString("cookieName","codex_mobile_session");String binding=BindingSelection.cookie(CookieManager.getInstance().getCookie(address+"/"),name);JSONObject data=ConnectionDiscovery.descriptor(auth,binding); + runOnUiThread(()->{String current=BindingSelection.match(snapshot,CookieManager.getInstance().getCookie(address+"/"),name); + if(ticket!=generation||!saved().contains(address)||current.isEmpty()||!binding.equals(current))return; + if(auth.optBoolean("authenticated"))prefs.edit().putString("cookieName:"+address,name).apply(); + if(auth.optBoolean("authenticated")&&auth.optBoolean("trustedDevice")&&DeviceBinding.name(name))try{DeviceBinding.save(this,address,name,EventClient.tokenValue(current));}catch(Exception ignored){} + else if(!auth.optBoolean("authenticated"))DeviceBinding.clear(this,address); + if(data!=null)prefs.edit().putString("connection:"+address,data.toString()).apply();else prefs.edit().remove("connection:"+address).apply();}); }catch(Exception ignored){}}); } + void loadWithRecovery(String address,String url,WebView current,int ticket){ + final String before=EventClient.sessionCookie(CookieManager.getInstance().getCookie(address+"/"),address); + worker.execute(()->{ + DeviceBinding.Credential saved=DeviceBinding.load(this,address); + boolean recover=false; + if(saved!=null&&!saved.cookie().equals(before)){ + Boolean existing=before.isEmpty()?false:DeviceBinding.verify(address,new DeviceBinding.Credential(before.substring(0,before.indexOf('=')),before.substring(before.indexOf('=')+1))); + if(Boolean.FALSE.equals(existing)){ + Boolean valid=DeviceBinding.verify(address,saved); + if(Boolean.TRUE.equals(valid))recover=true; + else if(Boolean.FALSE.equals(valid)){DeviceBinding.Credential latest=DeviceBinding.load(this,address);if(latest!=null&&latest.cookie().equals(saved.cookie()))DeviceBinding.clear(this,address);} + } + } + final boolean restore=recover;runOnUiThread(()->{ + if(ticket!=generation||web!=current||!address.equals(origin)||!saved().contains(address))return; + DeviceBinding.Credential latest=DeviceBinding.load(this,address); + if(!restore||latest==null||!latest.cookie().equals(saved.cookie())||!before.equals(EventClient.sessionCookie(CookieManager.getInstance().getCookie(address+"/"),address))){current.loadUrl(url);return;} + EventClient.setName(address,saved.name); + CookieManager.getInstance().setCookie(address+"/",saved.cookie()+"; Path="+GatewayURL.cookiePath(address)+"; HttpOnly; SameSite=Strict; Max-Age=34560000"+(address.startsWith("https://")?"; Secure":""),ok->{ + DeviceBinding.Credential retained=DeviceBinding.load(this,address); + if(ticket!=generation||web!=current||!address.equals(origin)||!saved().contains(address)||retained==null||!retained.cookie().equals(saved.cookie())){if(ok&&CookieManager.getInstance().getCookie(address+"/")!=null&&CookieManager.getInstance().getCookie(address+"/").contains(saved.cookie()))CookieManager.getInstance().setCookie(address+"/",saved.name+"=; Path="+GatewayURL.cookiePath(address)+"; Max-Age=0");return;} + if(ok){prefs.edit().putString("cookieName:"+address,saved.name).apply();CookieManager.getInstance().flush();MonitorService.bindingsChanged();} + current.loadUrl(url); + }); + }); + }); + } void recoverConnection(String requested){ if(routeAttempted||!resumed||web==null)return;routeAttempted=true; - final String address=origin,encoded=prefs.getString("connection:"+address,null),cookie=EventClient.sessionCookie(CookieManager.getInstance().getCookie(address+"/"));final int ticket=generation; + final String address=origin,encoded=prefs.getString("connection:"+address,null),cookie=EventClient.sessionCookie(CookieManager.getInstance().getCookie(address+"/"),address);final int ticket=generation; if(encoded==null||cookie.isEmpty())return; final ConnectionDiscovery resolver=new ConnectionDiscovery();routeDiscovery=resolver; worker.execute(()->{try{String next=resolver.resolve(address,new JSONObject(encoded),cookie);runOnUiThread(()->{ - if(ticket!=generation||!resumed||routeDiscovery!=resolver||next.equals(address)||!saved().contains(address)||!cookie.equals(EventClient.sessionCookie(CookieManager.getInstance().getCookie(address+"/"))))return; + if(ticket!=generation||!resumed||routeDiscovery!=resolver||next.equals(address)||!saved().contains(address)||!cookie.equals(EventClient.sessionCookie(CookieManager.getInstance().getCookie(address+"/"),address)))return; final String[] snapshot={null}; - Runnable relocate=()->{String existing=EventClient.sessionCookie(CookieManager.getInstance().getCookie(next+"/"));if(!existing.isEmpty()&&!existing.equals(cookie))return;if(ticket!=generation||!resumed||routeDiscovery!=resolver||!saved().contains(address)||!cookie.equals(EventClient.sessionCookie(CookieManager.getInstance().getCookie(address+"/"))))return; - CookieManager.getInstance().setCookie(next+"/",cookie+"; Path=/; Secure; HttpOnly; SameSite=Strict; Max-Age=34560000",ok->{if(!ok||ticket!=generation||!resumed||routeDiscovery!=resolver||!saved().contains(address)||!cookie.equals(EventClient.sessionCookie(CookieManager.getInstance().getCookie(address+"/"))))return; + Runnable relocate=()->{String existing=EventClient.sessionCookie(CookieManager.getInstance().getCookie(next+"/"),next);if(!existing.isEmpty()&&!existing.equals(cookie))return;if(ticket!=generation||!resumed||routeDiscovery!=resolver||!saved().contains(address)||!cookie.equals(EventClient.sessionCookie(CookieManager.getInstance().getCookie(address+"/"),address)))return; + CookieManager.getInstance().setCookie(next+"/",cookie+"; Path=/; Secure; HttpOnly; SameSite=Strict; Max-Age=34560000",ok->{if(!ok||ticket!=generation||!resumed||routeDiscovery!=resolver||!saved().contains(address)||!cookie.equals(EventClient.sessionCookie(CookieManager.getInstance().getCookie(address+"/"),address)))return; Set values=saved();values.remove(address);values.add(next);android.content.SharedPreferences.Editor edit=prefs.edit().putStringSet("origins",values).putString("connection:"+next,encoded).remove("connection:"+address); - for(String prefix:new String[]{"name:","computer-name:","cursor:","stream:","last-opened:"}){Object value=prefs.getAll().get(prefix+address);if(value instanceof String)edit.putString(prefix+next,(String)value);else if(value instanceof Long)edit.putLong(prefix+next,(Long)value);edit.remove(prefix+address);}edit.apply(); - EventClient.forget(address);connect(next+requested.substring(address.length()));pendingStorage=snapshot[0];routeAttempted=true; + for(String prefix:new String[]{"name:","computer-name:","cursor:","stream:","last-opened:","cookieName:"}){Object value=prefs.getAll().get(prefix+address);if(value instanceof String)edit.putString(prefix+next,(String)value);else if(value instanceof Long)edit.putLong(prefix+next,(Long)value);edit.remove(prefix+address);}edit.apply(); + DeviceBinding.Credential backup=DeviceBinding.load(MainActivity.this,address);if(backup!=null&&backup.cookie().equals(cookie)&&DeviceBinding.load(MainActivity.this,next)==null)try{DeviceBinding.save(MainActivity.this,next,backup.name,backup.token);DeviceBinding.clear(MainActivity.this,address);}catch(Exception ignored){}EventClient.setName(next,EventClient.cookieName(address));EventClient.forget(address);connect(next+requested.substring(address.length()));pendingStorage=snapshot[0];routeAttempted=true; });}; if(web!=null&&GatewayURL.sameOrigin(web.getUrl(),address))web.evaluateJavascript("(()=>{if(typeof saveDraft==='function')saveDraft();return JSON.stringify(Object.fromEntries(Object.entries(localStorage)));})()",value->{if(ticket!=generation)return;try{Object parsed=new org.json.JSONTokener(value).nextValue();if(parsed instanceof String&&((String)parsed).length()<524288){JSONObject data=new JSONObject((String)parsed);snapshot[0]=data.toString();}}catch(Exception ignored){}relocate.run();});else relocate.run(); });}catch(Exception ignored){}}); } void webAccounts(){if(web!=null&&GatewayURL.sameOrigin(web.getUrl(),origin))web.evaluateJavascript("(()=>{if(typeof window.BridgeNavigation?.accounts==='function')return window.BridgeNavigation.accounts();document.getElementById('accounts-button')?.click();return true;})()",null);} void gatewayMenu(View anchor){PopupMenu menu=new PopupMenu(this,anchor);menu.getMenu().add(L("返回电脑列表")).setOnMenuItemClickListener(i->{home();return true;});menu.getMenu().add(L("账号与接入")).setOnMenuItemClickListener(i->{webAccounts();return true;});menu.getMenu().add(L("通知收件箱")).setOnMenuItemClickListener(i->{inbox();return true;});menu.getMenu().add(L("外观与显示")).setOnMenuItemClickListener(i->{webAction("[data-open-appearance]");return true;});menu.getMenu().add(L("手机设置")).setOnMenuItemClickListener(i->{settings();return true;});menu.getMenu().add(L("刷新页面")).setOnMenuItemClickListener(i->{if(web!=null){routeAttempted=false;web.reload();}return true;});for(int n=0;n{if(web==current&&generation==ticket)webAction("#client-switch-toggle");return true;}); - if(current!=null&&GatewayURL.sameOrigin(current.getUrl(),address))current.evaluateJavascript("(()=>{const button=document.getElementById('client-switch-toggle');return document.getElementById('app')?.hidden===false&&button&&!button.hidden?button.getAttribute('aria-expanded')==='false':null;})()",value->{if(web!=current||generation!=ticket||!GatewayURL.sameOrigin(current.getUrl(),address))return;if("true".equals(value)||"false".equals(value)){appIcons.setTitle(L("true".equals(value)?"显示APP":"隐藏APP"));appIcons.setVisible(true);}}); + final android.view.MenuItem appIcons=menu.getMenu().add(L("隐藏底部 APP 图标")).setIcon(android.R.drawable.ic_menu_view).setVisible(false);appIcons.setOnMenuItemClickListener(i->{if(web==current&&generation==ticket)webAction("#client-switch-toggle");return true;}); + if(current!=null&&GatewayURL.sameOrigin(current.getUrl(),address))current.evaluateJavascript("(()=>{const button=document.getElementById('client-switch-toggle');return document.getElementById('app')?.hidden===false&&button&&!button.hidden?button.getAttribute('aria-expanded')==='false':null;})()",value->{if(web!=current||generation!=ticket||!GatewayURL.sameOrigin(current.getUrl(),address))return;if("true".equals(value)||"false".equals(value)){appIcons.setTitle(L("true".equals(value)?"显示底部 APP 图标":"隐藏底部 APP 图标"));appIcons.setVisible(true);}}); if(Build.VERSION.SDK_INT>=29)menu.setForceShowIcon(true);menu.show();} void message(String value){resetExitGesture();new AlertDialog.Builder(this).setMessage(L(value)).setPositiveButton(L("好"),null).show();} Set saved(){return new LinkedHashSet<>(prefs.getStringSet("origins",new HashSet<>()));} - @Override public void onCreate(Bundle state){super.onCreate(state);prefs=getSharedPreferences("bridge",0);root=new LinearLayout(this);root.setOrientation(1);root.setBackgroundColor(0xfff5f5f3);screen=new FrameLayout(this);screen.addView(root,new FrameLayout.LayoutParams(-1,-1));setContentView(screen);systemBars=new SystemBars(this,screen,()->{if(downloadCard!=null)downloadCard.post(downloadCard::place);}); + @Override public void onCreate(Bundle state){super.onCreate(state);prefs=getSharedPreferences("bridge",0);for(String address:prefs.getStringSet("origins",java.util.Collections.emptySet()))EventClient.setName(address,prefs.getString("cookieName:"+address,null));root=new LinearLayout(this);root.setOrientation(1);root.setBackgroundColor(0xfff5f5f3);screen=new FrameLayout(this);screen.addView(root,new FrameLayout.LayoutParams(-1,-1));setContentView(screen);systemBars=new SystemBars(this,screen,()->{if(downloadCard!=null)downloadCard.post(downloadCard::place);}); if(state!=null){choosingUpdateFile=state.getString("choosingUpdateFile");pendingSaveResult=state.getBundle("pendingSaveResult");} if(Build.VERSION.SDK_INT>=33)getOnBackInvokedDispatcher().registerOnBackInvokedCallback(android.window.OnBackInvokedDispatcher.PRIORITY_DEFAULT,this::handleBack); bar=new LinearLayout(this);bar.setGravity(Gravity.CENTER_VERTICAL);bar.setPadding(dp(20),dp(4),dp(12),dp(4));root.addView(bar);header(false); @@ -151,7 +183,15 @@ void checkComputers(){ if(view==web&&GatewayURL.sameOrigin(url,origin)&&GatewayURL.sameOrigin(view.getUrl(),origin))result.confirm(computerName(origin));else result.cancel();return true; } if(prompt.equals("codexbridge-session:"+clipboardToken)){ - if(view==web&&GatewayURL.sameOrigin(url,origin)&&GatewayURL.sameOrigin(view.getUrl(),origin)){CookieManager.getInstance().flush();MonitorService.bindingsChanged();result.confirm("saved");}else result.cancel();return true; + if(view==web&&GatewayURL.sameOrigin(url,origin)&&GatewayURL.sameOrigin(view.getUrl(),origin)){authLossAttempted=false;CookieManager.getInstance().flush();MonitorService.bindingsChanged();rememberConnection();result.confirm("saved");}else result.cancel();return true; + } + if(prompt.equals("codexbridge-auth-lost:"+clipboardToken)){ + if(view==web&&view.hasWindowFocus()&&GatewayURL.sameOrigin(url,origin)&&GatewayURL.sameOrigin(view.getUrl(),origin)&&!authLossAttempted){authLossAttempted=true;loadWithRecovery(origin,origin+"/",view,generation);result.confirm("recovering");}else result.cancel();return true; + } + if(prompt.equals("codexbridge-logout:"+clipboardToken)){ + if(view==web&&view.hasWindowFocus()&&GatewayURL.sameOrigin(url,origin)&&GatewayURL.sameOrigin(view.getUrl(),origin)){ + DeviceBinding.clear(MainActivity.this,origin);EventClient.forget(origin);MonitorService.bindingsChanged();result.confirm("cleared"); + }else result.cancel();return true; } if(prompt.equals("codexbridge-language:"+clipboardToken)){ if(view==web&&GatewayURL.sameOrigin(url,origin)&&GatewayURL.sameOrigin(view.getUrl(),origin)&&("en".equals(value)||"zh".equals(value))){prefs.edit().putString("language",value).apply();menu.setContentDescription(L("电脑与聊天选项"));renderDownload();result.confirm("saved");}else result.cancel();return true; @@ -170,7 +210,7 @@ public boolean onShowFileChooser(WebView view,ValueCallback callback,File }}); web.setDownloadListener((target,agent,disposition,mime,length)->downloadArtifact(target)); - web.loadUrl(url);monitor(); + loadWithRecovery(base,url,web,generation);monitor(); }catch(Exception e){message(e.getMessage());}} void renderDownload(){downloadCard.render(downloadTask,savingDownload||choosingDownload!=null,openingDownload,updateSession!=null);} void downloadArtifact(String target){ @@ -246,11 +286,11 @@ Dialog panel(String title,LinearLayout content){ dialog.setContentView(body);dialog.show();dialog.getWindow().setBackgroundDrawableResource(android.R.color.transparent);dialog.getWindow().setLayout(getResources().getDisplayMetrics().widthPixels-dp(24),getResources().getDisplayMetrics().heightPixels-dp(100));return dialog; } void clearInbox(String base,JSONObject data,Runnable done){ - final int ticket=generation;final String binding=EventClient.sessionCookie(CookieManager.getInstance().getCookie(base+"/"));worker.execute(()->{try{ + final int ticket=generation;final String binding=EventClient.sessionCookie(CookieManager.getInstance().getCookie(base+"/"),base);worker.execute(()->{try{ JSONObject auth=EventClient.request(base,"/api/auth",null,null); - if(!binding.equals(EventClient.sessionCookie(CookieManager.getInstance().getCookie(base+"/"))))return; + if(!binding.equals(EventClient.sessionCookie(CookieManager.getInstance().getCookie(base+"/"),base)))return; JSONObject snapshot=EventClient.request(base,"/api/mobile/events/read",new JSONObject().put("provider","all").put("streamId",data.getString("streamId")).put("through",data.getLong("cursor")),auth.getString("csrf")); - runOnUiThread(()->{if(ticket!=generation||!binding.equals(EventClient.sessionCookie(CookieManager.getInstance().getCookie(base+"/"))))return;ComputerNotifications.update(base,binding,snapshot);done.run();}); + runOnUiThread(()->{if(ticket!=generation||!binding.equals(EventClient.sessionCookie(CookieManager.getInstance().getCookie(base+"/"),base)))return;ComputerNotifications.update(base,binding,snapshot);done.run();}); }catch(Exception error){runOnUiThread(()->{if(ticket==generation)message(error.getMessage());});}}); } void inbox(){ @@ -409,7 +449,7 @@ void requestStartupNotifications(){ void toggleNotifications(){if(prefs.getBoolean("alerts",false)){prefs.edit().putBoolean("alerts",false).apply();return;}if(Build.VERSION.SDK_INT>=33&&checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS)!=PackageManager.PERMISSION_GRANTED){requestPermissions(new String[]{Manifest.permission.POST_NOTIFICATIONS},22);return;}if(!((NotificationManager)getSystemService(NOTIFICATION_SERVICE)).areNotificationsEnabled()){message(L("请在系统设置中允许通知。"));return;}prefs.edit().putBoolean("alerts",true).apply();monitor();} void testNotification(){if(Build.VERSION.SDK_INT>=33&&checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS)!=PackageManager.PERMISSION_GRANTED){requestPermissions(new String[]{Manifest.permission.POST_NOTIFICATIONS},23);return;}String base=origin;new Handler().postDelayed(()->{try{MonitorService.notifyEvent(this,base,new JSONObject().put("id","test").put("title",L("手机通知测试")).put("body",L("本机通知已开启")));}catch(Exception ignored){}},10000);message(L("10 秒后显示本地测试通知,可以先返回桌面。此测试不代表远程推送已经接通。"));} void confirmRemove(){confirmRemove(origin);} - void confirmRemove(String target){new AlertDialog.Builder(this).setTitle(L("移除这台电脑?")).setMessage(target+"\n\n"+L("清除本机保存的连接和登录状态,电脑上的聊天不受影响。")).setNegativeButton(L("取消"),null).setPositiveButton(L("移除"),(d,w)->{if(downloadTask!=null&&downloadTask.origin.equals(target))cancelDownload();final int ticket=generation;worker.execute(()->{try{JSONObject auth=EventClient.request(target,"/api/auth",null,null);if(auth.optBoolean("authenticated"))EventClient.request(target,"/api/logout",new JSONObject(),auth.getString("csrf"));}catch(Exception ignored){/* Offline removal is local; server session expiry still applies. */}runOnUiThread(()->{MonitorService.bindingsChanged();Set values=saved();values.remove(target);EventClient.forget(target);android.content.SharedPreferences.Editor edit=prefs.edit().putStringSet("origins",values).remove("cursor:"+target).remove("stream:"+target).remove("connection:"+target).remove("name:"+target).remove("last-opened:"+target);if(target.equals(prefs.getString("active","")))edit.remove("active");edit.apply();if(target.equals(origin))origin="";if(ticket==generation)home();});});}).show();} + void confirmRemove(String target){new AlertDialog.Builder(this).setTitle(L("移除这台电脑?")).setMessage(target+"\n\n"+L("清除本机保存的连接和登录状态,电脑上的聊天不受影响。")).setNegativeButton(L("取消"),null).setPositiveButton(L("移除"),(d,w)->{if(downloadTask!=null&&downloadTask.origin.equals(target))cancelDownload();final int ticket=generation;worker.execute(()->{try{JSONObject auth=EventClient.request(target,"/api/auth",null,null);if(auth.optBoolean("authenticated"))EventClient.request(target,"/api/logout",new JSONObject(),auth.getString("csrf"));}catch(Exception ignored){/* Offline removal is local; server session expiry still applies. */}DeviceBinding.clear(this,target);runOnUiThread(()->{MonitorService.bindingsChanged();Set values=saved();values.remove(target);EventClient.forget(target);android.content.SharedPreferences.Editor edit=prefs.edit().putStringSet("origins",values).remove("cursor:"+target).remove("stream:"+target).remove("connection:"+target).remove("name:"+target).remove("last-opened:"+target).remove("cookieName:"+target);if(target.equals(prefs.getString("active","")))edit.remove("active");edit.apply();if(target.equals(origin))origin="";if(ticket==generation)home();});});}).show();} void monitor(){if(monitor==null){monitor=new ServiceConnection(){public void onServiceConnected(ComponentName n,IBinder b){}public void onServiceDisconnected(ComponentName n){}};getApplicationContext().bindService(new Intent(this,MonitorService.class),monitor,BIND_AUTO_CREATE);}} void scan(){if(checkSelfPermission(Manifest.permission.CAMERA)!=PackageManager.PERMISSION_GRANTED){requestPermissions(new String[]{Manifest.permission.CAMERA},21);return;}startActivityForResult(new Intent(this,ScanActivity.class),20);} @Override public void onRequestPermissionsResult(int code,String[] names,int[] results){super.onRequestPermissionsResult(code,names,results);boolean allowed=results.length>0&&results[0]==PackageManager.PERMISSION_GRANTED;if(code==25){prefs.edit().putBoolean("alerts",allowed).apply();if(allowed)monitor();return;}if(!allowed){message(L(code==21?"未获得权限,可继续粘贴地址使用。":"请在系统设置中允许通知。"));return;}if(code==24)enableNativePush();if(code==21)scan();if(code==23)testNotification();if(code==22){prefs.edit().putBoolean("alerts",true).apply();monitor();}} diff --git a/mobile/android/src/io/github/try2love/codexbridge/MobileStrings.java b/mobile/android/src/io/github/try2love/codexbridge/MobileStrings.java index 2321811a..502dc2bb 100644 --- a/mobile/android/src/io/github/try2love/codexbridge/MobileStrings.java +++ b/mobile/android/src/io/github/try2love/codexbridge/MobileStrings.java @@ -126,8 +126,8 @@ final class MobileStrings { values.put("返回电脑列表","Back to computers"); values.put("账号与接入","Accounts and connections"); values.put("外观与显示","Appearance"); - values.put("隐藏APP","Hide apps"); - values.put("显示APP","Show apps"); + values.put("隐藏底部 APP 图标","Hide bottom app icons"); + values.put("显示底部 APP 图标","Show bottom app icons"); values.put("刷新页面","Reload page"); values.put("好","OK"); values.put("电脑上的工作,\n带在身边。","Your computer’s work,\nalways with you."); @@ -183,7 +183,7 @@ final class MobileStrings { values.put("当前电脑","Current computer"); values.put("尚未选择电脑","No computer selected"); values.put("返回首页扫码或输入网关地址。","Return home to scan or enter a gateway address."); - values.put("移除这台电脑","Remove this computer"); + values.put("移除这台电脑","Remove"); values.put("此预览尚未配置系统推送项目。当前仅同步收件箱;配置 Firebase 或厂商推送后才能启用后台任务提醒。","System push is not configured. Background task alerts require Firebase or a device vendor’s push service."); values.put("手机通知测试","Mobile notification test"); values.put("本机通知已开启","Local notifications are enabled"); diff --git a/mobile/android/src/io/github/try2love/codexbridge/MonitorService.java b/mobile/android/src/io/github/try2love/codexbridge/MonitorService.java index 7e384562..ff211247 100644 --- a/mobile/android/src/io/github/try2love/codexbridge/MonitorService.java +++ b/mobile/android/src/io/github/try2love/codexbridge/MonitorService.java @@ -29,11 +29,12 @@ private void check(){ SharedPreferences p=getSharedPreferences("bridge",0);final long ticket;final EventClient.Read pending=new EventClient.Read(); synchronized(foregroundLock){if(!visible||activeRead!=null)return;ticket=foregroundEpoch;activeRead=pending;if(epoch!=ticket){epoch=ticket;baselined.clear();}} try{for(String origin:new java.util.HashSet<>(p.getStringSet("origins",java.util.Collections.emptySet()))){ + EventClient.setName(origin,p.getString("cookieName:"+origin,null)); synchronized(foregroundLock){if(!current(pending,ticket))return;if(!p.getStringSet("origins",java.util.Collections.emptySet()).contains(origin))continue;} - String binding=EventClient.sessionCookie(android.webkit.CookieManager.getInstance().getCookie(origin+"/")); + String binding=EventClient.sessionCookie(android.webkit.CookieManager.getInstance().getCookie(origin+"/"),origin); try{JSONObject data=EventClient.read(origin,Math.max(0,p.getLong("cursor:"+origin,0)),pending); synchronized(foregroundLock){if(!current(pending,ticket))return;if(!p.getStringSet("origins",java.util.Collections.emptySet()).contains(origin))continue; - synchronized(EventClient.class){if(!binding.equals(EventClient.sessionCookie(android.webkit.CookieManager.getInstance().getCookie(origin+"/"))))continue; + synchronized(EventClient.class){if(!binding.equals(EventClient.sessionCookie(android.webkit.CookieManager.getInstance().getCookie(origin+"/"),origin)))continue; ComputerNotifications.update(origin,binding,data);if(!data.optBoolean("enabled"))continue; String key="cursor:"+origin,stream=data.getString("streamId");long previous=p.getLong(key,-1); boolean ready=baselined.contains(origin)&&stream.equals(p.getString("stream:"+origin,"")); diff --git a/mobile/ios/BridgeActivity/Info.plist b/mobile/ios/BridgeActivity/Info.plist index 41e83738..d295d670 100644 --- a/mobile/ios/BridgeActivity/Info.plist +++ b/mobile/ios/BridgeActivity/Info.plist @@ -13,9 +13,9 @@ CFBundlePackageType XPC! CFBundleShortVersionString - 2.0.2 + 2.0.3 CFBundleVersion - 29 + 30 NSExtension NSExtensionPointIdentifier diff --git a/mobile/ios/BridgeNotifications/Info.plist b/mobile/ios/BridgeNotifications/Info.plist index cd50c580..6c49dba5 100644 --- a/mobile/ios/BridgeNotifications/Info.plist +++ b/mobile/ios/BridgeNotifications/Info.plist @@ -6,8 +6,8 @@ CFBundleName$(PRODUCT_NAME) CFBundleDisplayNameBridge Notifications CFBundlePackageTypeXPC! -CFBundleShortVersionString2.0.2 -CFBundleVersion29 +CFBundleShortVersionString2.0.3 +CFBundleVersion30 NSExtension NSExtensionPointIdentifiercom.apple.usernotifications.service NSExtensionPrincipalClass$(PRODUCT_MODULE_NAME).NotificationService diff --git a/mobile/ios/BridgePreview/App.swift b/mobile/ios/BridgePreview/App.swift index 2d0ea910..07aed054 100644 --- a/mobile/ios/BridgePreview/App.swift +++ b/mobile/ios/BridgePreview/App.swift @@ -3,6 +3,49 @@ import UIKit import AVFoundation import UserNotifications import UniformTypeIdentifiers +import Security + +private enum DeviceBinding { + struct Credential: Codable { let name: String; let token: String; var cookie: String { name + "=" + token } } + static func valid(_ name: String) -> Bool { name == "codex_mobile_session" || name.range(of: #"^codex_mobile_session_[a-f0-9]{24}$"#, options: .regularExpression) != nil } + static func query(_ address: String) -> [String: Any] { [kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: "codex-bridge-device-binding-v1", kSecAttrAccount as String: address] } + static func load(_ address: String) -> Credential? { + var request = query(address); request[kSecReturnData as String] = true; request[kSecMatchLimit as String] = kSecMatchLimitOne + var item: CFTypeRef?; guard SecItemCopyMatching(request as CFDictionary, &item) == errSecSuccess, + let data = item as? Data, let value = try? JSONDecoder().decode(Credential.self, from: data), valid(value.name), + value.token.range(of: #"^[A-Za-z0-9_-]{32,128}$"#, options: .regularExpression) != nil else { return nil } + return value + } + @discardableResult static func save(_ address: String, _ credential: Credential) -> Bool { + guard valid(credential.name), credential.token.range(of: #"^[A-Za-z0-9_-]{32,128}$"#, options: .regularExpression) != nil, + let data = try? JSONEncoder().encode(credential) else { return false } + var request = query(address); request[kSecValueData as String] = data + request[kSecAttrAccessible as String] = kSecAttrAccessibleWhenUnlockedThisDeviceOnly + let status = SecItemAdd(request as CFDictionary, nil) + if status == errSecDuplicateItem { return SecItemUpdate(query(address) as CFDictionary, [kSecValueData as String: data] as CFDictionary) == errSecSuccess } + return status == errSecSuccess + } + static func clear(_ address: String) { SecItemDelete(query(address) as CFDictionary) } + /// nil is a transport error; false is a server-confirmed rejected binding. + static func verify(_ address: String, _ credential: Credential) async -> Bool? { + guard let url = URL(string: address + "/api/auth") else { return false } + let config = URLSessionConfiguration.ephemeral; config.httpShouldSetCookies = false; config.httpCookieStorage = nil; config.urlCredentialStorage = nil + config.timeoutIntervalForRequest = 4; config.timeoutIntervalForResource = 5 + let session = URLSession(configuration: config, delegate: NoRedirect(), delegateQueue: nil) + defer { session.invalidateAndCancel() } + var request = URLRequest(url: url); request.setValue(credential.cookie, forHTTPHeaderField: "Cookie") + request.setValue(GatewayURL.httpOrigin(address), forHTTPHeaderField: "Origin"); request.setValue("BridgeMobile/0.1-iOS", forHTTPHeaderField: "User-Agent") + do { + let (bytes, response) = try await session.bytes(for: request) + guard let http = response as? HTTPURLResponse, http.statusCode == 200, http.url.map({ GatewayURL.same($0, address) }) == true, response.expectedContentLength <= 65536 else { return nil } + var body = Data(); for try await byte in bytes { guard body.count < 65536 else { return nil }; body.append(byte) } + guard let auth = try JSONSerialization.jsonObject(with: body) as? [String: Any] else { return nil } + guard auth["authenticated"] as? Bool == true else { return false } + if let name = auth["cookieName"] as? String { return name == credential.name } + return credential.name == "codex_mobile_session" + } catch { return nil } + } +} final class BridgeNavigationController: UINavigationController { override var childForStatusBarStyle: UIViewController? { topViewController } @@ -93,9 +136,7 @@ final class BridgeController: UIViewController, WKNavigationDelegate, WKUIDelega WKWebsiteDataStore.default().httpCookieStore.getAllCookies { [weak self] cookies in guard let self, self.saved.contains(address) else { done(nil); return } let matching = cookies.filter { cookie in - cookie.name == "codex_mobile_session" && cookie.path == GatewayURL.cookiePath(address) && - cookie.domain.trimmingCharacters(in: CharacterSet(charactersIn: ".")) == target.host && - (!cookie.isSecure || target.scheme == "https") && (cookie.expiresDate == nil || cookie.expiresDate! > Date()) + self.cookieMatches(cookie, address: address) } done(matching.isEmpty ? nil : HTTPCookie.requestHeaderFields(with: matching)["Cookie"]) } @@ -112,7 +153,17 @@ final class BridgeController: UIViewController, WKNavigationDelegate, WKUIDelega private var pollGeneration = 0 private var pollTask: URLSessionDataTask? private var generation = 0 + private var authLossAttempted = false + private var bindingRevision = 0 private let defaults = UserDefaults.standard + private func cookieName(_ address: String) -> String { defaults.string(forKey: "cookieName:" + address) ?? "codex_mobile_session" } + private func cookieMatches(_ cookie: HTTPCookie, address: String, discover: Bool = false) -> Bool { + guard let target = URL(string: address), cookie.path == GatewayURL.cookiePath(address), + cookie.domain.trimmingCharacters(in: CharacterSet(charactersIn: ".")) == target.host, + (!cookie.isSecure || target.scheme == "https"), + cookie.expiresDate == nil || cookie.expiresDate! > Date() else { return false } + return cookie.name == cookieName(address) || (discover && cookie.name.range(of: #"^codex_mobile_session_[a-f0-9]{24}$"#, options: .regularExpression) != nil) + } private let page = UIStackView() private let topBackground = UIView() private var pageStatusBarStyle: UIStatusBarStyle = .default @@ -246,7 +297,7 @@ final class BridgeController: UIViewController, WKNavigationDelegate, WKUIDelega let ticket = self.generation, address = self.origin web.evaluateJavaScript("(()=>{const button=document.getElementById('client-switch-toggle');return document.getElementById('app')?.hidden===false&&button&&!button.hidden?button.getAttribute('aria-expanded')==='false':null;})()") { [weak self, weak web] value, _ in guard let self, let web, self.web === web, self.generation == ticket, let url = web.url, GatewayURL.same(url, address), let collapsed = value as? Bool else { completion([]); return } - let action = UIAction(title: MobileStrings.text(collapsed ? "显示APP" : "隐藏APP"), image: UIImage(systemName: "square.grid.3x1.below.line.grid.1x2")) { [weak self, weak web] _ in + let action = UIAction(title: MobileStrings.text(collapsed ? "显示底部 APP 图标" : "隐藏底部 APP 图标"), image: UIImage(systemName: "square.grid.3x1.below.line.grid.1x2")) { [weak self, weak web] _ in guard let self, let web, self.web === web, self.generation == ticket, let url = web.url, GatewayURL.same(url, address) else { return } web.evaluateJavaScript("document.getElementById('client-switch-toggle')?.click()") } @@ -400,21 +451,68 @@ final class BridgeController: UIViewController, WKNavigationDelegate, WKUIDelega func openSaved(_ address: String, provider: String? = nil) { let suffix = provider.flatMap { ["codex", "claude", "deepseek"].contains($0) ? "?client=" + $0 : nil } ?? ""; guard saved.contains(address), let url = URL(string: address + "/" + suffix) else { info(MobileStrings.text("请先扫码保存通知对应的电脑。")); return }; connect(url, address: address) } private func binding(_ address: String, done: @escaping (HTTPCookie?) -> Void) { WKWebsiteDataStore.default().httpCookieStore.getAllCookies { cookies in - done(cookies.first { $0.name == "codex_mobile_session" && $0.path == GatewayURL.cookiePath(address) && $0.domain == URL(string: address)?.host && ($0.expiresDate == nil || $0.expiresDate! > Date()) }) + done(cookies.first { self.cookieMatches($0, address: address) }) } } private func rememberConnection() { - let address = origin, ticket = generation - binding(address) { cookie in - guard let cookie, ticket == self.generation else { return } - self.fetch(address: address, route: "/api/auth") { result in - guard ticket == self.generation, case .success(let auth) = result else { return } + let address = origin, ticket = generation, revision = bindingRevision + WKWebsiteDataStore.default().httpCookieStore.getAllCookies { sentCookies in + let sent = sentCookies.filter { self.cookieMatches($0, address: address, discover: true) } + guard !sent.isEmpty else { return } + self.fetch(address: address, route: "/api/auth") { result in + guard ticket == self.generation, case .success(let auth) = result else { return } + self.binding(address) { cookie in + guard let cookie, ticket == self.generation else { return } self.binding(address) { current in - guard ticket == self.generation, current?.value == cookie.value else { return } + let previous = sent.first { $0.name == cookie.name } ?? (cookie.name == "codex_mobile_session" ? nil : sent.first { $0.name == "codex_mobile_session" }) + guard ticket == self.generation, revision == self.bindingRevision, self.saved.contains(address), current?.name == cookie.name, + current?.value == cookie.value, previous?.value == cookie.value else { return } + if auth["authenticated"] as? Bool == true, auth["trustedDevice"] as? Bool == true, + (auth["cookieName"] as? String ?? "codex_mobile_session") == cookie.name { + DeviceBinding.save(address, .init(name: cookie.name, token: cookie.value)) + } else if auth["authenticated"] as? Bool == false { DeviceBinding.clear(address) } self.defaults.set(ConnectionDiscovery.descriptor(auth, secret: cookie.value), forKey: "connection:" + address) } } } + } + } + private func loadWithRecovery(_ web: WKWebView, url: URL, address: String, ticket: Int) { + Task { @MainActor in + guard let backup = DeviceBinding.load(address) else { if ticket == generation && self.web === web { web.load(URLRequest(url: url)) }; return } + let store = WKWebsiteDataStore.default().httpCookieStore + let cookies = await withCheckedContinuation { continuation in store.getAllCookies { continuation.resume(returning: $0) } } + let before = cookies.first { cookieMatches($0, address: address) } + if before?.name == backup.name && before?.value == backup.token { + if ticket == generation && self.web === web { web.load(URLRequest(url: url)) }; return + } + if let before { + let current = await DeviceBinding.verify(address, .init(name: before.name, token: before.value)) + if current == true || current == nil { if ticket == generation && self.web === web { web.load(URLRequest(url: url)) }; return } + } + let valid = await DeviceBinding.verify(address, backup) + if valid == false, DeviceBinding.load(address)?.cookie == backup.cookie { DeviceBinding.clear(address) } + guard ticket == generation, self.web === web, saved.contains(address) else { return } + guard valid == true, DeviceBinding.load(address)?.cookie == backup.cookie, + let host = URL(string: address)?.host else { web.load(URLRequest(url: url)); return } + var properties: [HTTPCookiePropertyKey: Any] = [.name: backup.name, .value: backup.token, .domain: host, + .path: GatewayURL.cookiePath(address), .expires: Date().addingTimeInterval(400 * 86400), + HTTPCookiePropertyKey("HttpOnly"): "TRUE", HTTPCookiePropertyKey("SameSite"): "Strict"] + if address.hasPrefix("https://") { properties[.secure] = "TRUE" } + guard let restored = HTTPCookie(properties: properties) else { web.load(URLRequest(url: url)); return } + let latest = await withCheckedContinuation { continuation in store.getAllCookies { continuation.resume(returning: $0) } } + guard latest.first(where: { cookieMatches($0, address: address) })?.value == before?.value, + DeviceBinding.load(address)?.cookie == backup.cookie else { web.load(URLRequest(url: url)); return } + await withCheckedContinuation { continuation in store.setCookie(restored) { continuation.resume() } } + guard ticket == generation, self.web === web, saved.contains(address), + DeviceBinding.load(address)?.cookie == backup.cookie else { + let after = await withCheckedContinuation { continuation in store.getAllCookies { continuation.resume(returning: $0) } } + if after.contains(where: { $0.name == restored.name && $0.value == restored.value && $0.domain == restored.domain && $0.path == restored.path }) { await store.deleteCookie(restored) } + return + } + defaults.set(backup.name, forKey: "cookieName:" + address) + web.load(URLRequest(url: url)) + } } private func recoverConnection() { guard !routeAttempted, UIApplication.shared.applicationState == .active, let requested = requestedRoute, @@ -451,6 +549,9 @@ final class BridgeController: UIViewController, WKNavigationDelegate, WKUIDelega guard current() else { return } var all = self.saved.filter { $0 != address }; if !all.contains(next) { all.append(next) }; self.defaults.set(all, forKey: "origins") self.defaults.set(descriptor, forKey: "connection:" + next); self.defaults.removeObject(forKey: "connection:" + address) + if let name = self.defaults.string(forKey: "cookieName:" + address) { self.defaults.set(name, forKey: "cookieName:" + next) }; self.defaults.removeObject(forKey: "cookieName:" + address) + if let backup = DeviceBinding.load(address), backup.name == cookie.name, backup.token == cookie.value, + DeviceBinding.load(next) == nil, DeviceBinding.save(next, backup) { DeviceBinding.clear(address) } for prefix in ["name:", "computer-name:", "cursor:", "stream:", "last-opened:"] { if let value = self.defaults.object(forKey: prefix + address) { self.defaults.set(value, forKey: prefix + next) }; self.defaults.removeObject(forKey: prefix + address) } @@ -477,13 +578,13 @@ final class BridgeController: UIViewController, WKNavigationDelegate, WKUIDelega configuration.ignoresViewportScaleLimits = false configuration.userContentController.addUserScript(WKUserScript(source: WebScripts.fixedViewport, injectionTime: .atDocumentEnd, forMainFrameOnly: true)) clipboardToken = UUID().uuidString - let appearanceScript = scripts.appearance.replacingOccurrences(of: "__BRIDGE_CLIPBOARD_TOKEN__", with: clipboardToken) + let appearanceScript = scripts.appearance.replacingOccurrences(of: "__BRIDGE_CLIPBOARD_TOKEN__", with: clipboardToken) + "\ndocument.addEventListener('bridge-authenticated', () => window.prompt('codexbridge-session:" + clipboardToken + "', ''));document.addEventListener('bridge-logged-out', () => window.prompt('codexbridge-logout:" + clipboardToken + "', ''));document.addEventListener('bridge-auth-lost', () => window.prompt('codexbridge-auth-lost:" + clipboardToken + "', ''));" configuration.userContentController.addUserScript(WKUserScript(source: appearanceScript, injectionTime: .atDocumentEnd, forMainFrameOnly: true)) let clipboardScript = scripts.clipboard.replacingOccurrences(of: "__BRIDGE_CLIPBOARD_TOKEN__", with: clipboardToken) configuration.userContentController.addUserScript(WKUserScript(source: clipboardScript, injectionTime: .atDocumentEnd, forMainFrameOnly: true)) let systemBarsScript = scripts.systemBars.replacingOccurrences(of: "__BRIDGE_CLIPBOARD_TOKEN__", with: clipboardToken) configuration.userContentController.addUserScript(WKUserScript(source: systemBarsScript, injectionTime: .atDocumentEnd, forMainFrameOnly: true)) - let web = WKWebView(frame: .zero, configuration: configuration); self.web = web; web.scrollView.contentInsetAdjustmentBehavior = .never; web.navigationDelegate = self; web.uiDelegate = self; web.allowsBackForwardNavigationGestures = true; web.customUserAgent = nil; page.addArrangedSubview(web); gatewayMenuTop = gatewayMenu.topAnchor.constraint(equalTo: web.topAnchor, constant: 4); gatewayMenuTop?.isActive = true; web.load(URLRequest(url: url)) + let web = WKWebView(frame: .zero, configuration: configuration); self.web = web; web.scrollView.contentInsetAdjustmentBehavior = .never; web.navigationDelegate = self; web.uiDelegate = self; web.allowsBackForwardNavigationGestures = true; web.customUserAgent = nil; page.addArrangedSubview(web); gatewayMenuTop = gatewayMenu.topAnchor.constraint(equalTo: web.topAnchor, constant: 4); gatewayMenuTop?.isActive = true; loadWithRecovery(web, url: url, address: address, ticket: generation) web.scrollView.pinchGestureRecognizer?.isEnabled = false } @objc private func refresh() { routeAttempted = false; web?.reload() } @@ -531,6 +632,29 @@ final class BridgeController: UIViewController, WKNavigationDelegate, WKUIDelega let current = webView.url, GatewayURL.same(current, origin) else { completionHandler(nil); return } completionHandler(computerName(origin)); return } + if prompt == "codexbridge-logout:" + clipboardToken { + guard webView === web, UIApplication.shared.applicationState == .active, frame.isMainFrame, + let source = frame.request.url, GatewayURL.same(source, origin), + let current = webView.url, GatewayURL.same(current, origin) else { completionHandler(nil); return } + bindingRevision += 1 + DeviceBinding.clear(origin) + binding(origin) { cookie in if let cookie { WKWebsiteDataStore.default().httpCookieStore.delete(cookie) } } + completionHandler("cleared"); return + } + if prompt == "codexbridge-auth-lost:" + clipboardToken { + guard webView === web, UIApplication.shared.applicationState == .active, frame.isMainFrame, + let source = frame.request.url, GatewayURL.same(source, origin), + let current = webView.url, GatewayURL.same(current, origin), !authLossAttempted, + let target = URL(string: origin + "/") else { completionHandler(nil); return } + authLossAttempted = true; loadWithRecovery(webView, url: target, address: origin, ticket: generation) + completionHandler("recovering"); return + } + if prompt == "codexbridge-session:" + clipboardToken { + guard webView === web, UIApplication.shared.applicationState == .active, frame.isMainFrame, + let source = frame.request.url, GatewayURL.same(source, origin), + let current = webView.url, GatewayURL.same(current, origin) else { completionHandler(nil); return } + authLossAttempted = false; rememberConnection(); completionHandler("saved"); return + } if prompt == "codexbridge-language:" + clipboardToken { guard webView === web, frame.isMainFrame, let source = frame.request.url, GatewayURL.same(source, origin), let current = webView.url, GatewayURL.same(current, origin), let language = defaultText, ["zh", "en"].contains(language) else { completionHandler(nil); return } @@ -633,8 +757,7 @@ final class BridgeController: UIViewController, WKNavigationDelegate, WKUIDelega WKWebsiteDataStore.default().httpCookieStore.getAllCookies { cookies in guard valid() else { return } guard poll == nil || self.saved.contains(address) else { done(.failure(NSError(domain: "Bridge", code: 401))); return } - let host = target.host ?? "" - let matching = cookies.filter { cookie in cookie.name == "codex_mobile_session" && cookie.path == GatewayURL.cookiePath(address) && cookie.domain.trimmingCharacters(in: CharacterSet(charactersIn: ".")) == host && (!cookie.isSecure || target.scheme == "https") && (cookie.expiresDate == nil || cookie.expiresDate! > Date()) } + let matching = cookies.filter { cookie in self.cookieMatches(cookie, address: address, discover: route == "/api/auth") } guard !matching.isEmpty else { done(.failure(NSError(domain: "Bridge", code: 401, userInfo: [NSLocalizedDescriptionKey: MobileStrings.text("请先连接并登录电脑网关")]))); return } var request = URLRequest(url: target); request.setValue(GatewayURL.httpOrigin(address), forHTTPHeaderField: "Origin"); request.setValue(HTTPCookie.requestHeaderFields(with: matching)["Cookie"], forHTTPHeaderField: "Cookie"); request.setValue("BridgeMobile/0.1-iOS", forHTTPHeaderField: "User-Agent") if let body { request.httpMethod = "POST"; request.httpBody = try? JSONSerialization.data(withJSONObject: body); request.setValue("application/json", forHTTPHeaderField: "Content-Type"); request.setValue(csrf, forHTTPHeaderField: "X-CSRF-Token") } @@ -646,18 +769,27 @@ final class BridgeController: UIViewController, WKNavigationDelegate, WKUIDelega else { result = .failure(NSError(domain: "Bridge", code: code ?? 0, userInfo: [NSLocalizedDescriptionKey: code == 401 || code == 403 ? MobileStrings.text("登录已失效,请重新连接电脑") : MobileStrings.text("请使用配套电脑 Preview,并检查连接")])) } DispatchQueue.main.async { guard valid() else { return } + if route == "/api/auth", case .success(let auth) = result, auth["authenticated"] as? Bool == true, + let name = auth["cookieName"] as? String, + name.range(of: #"^codex_mobile_session_[a-f0-9]{24}$"#, options: .regularExpression) != nil { + self.defaults.set(name, forKey: "cookieName:" + address) + } if poll != nil { self.pollTask = nil } let response = response as? HTTPURLResponse let headers = response?.allHeaderFields as? [String: String] ?? [:] let renewed = code == 200 && response?.url.map({ GatewayURL.same($0, address) }) == true ? HTTPCookie.cookies(withResponseHeaderFields: headers, for: target).first(where: { cookie in - cookie.name == "codex_mobile_session" && cookie.path == GatewayURL.cookiePath(address) && cookie.domain.trimmingCharacters(in: CharacterSet(charactersIn: ".")) == host && cookie.path == GatewayURL.cookiePath(address) && matching.contains(where: { $0.name == cookie.name && $0.value == cookie.value }) + self.cookieMatches(cookie, address: address, discover: route == "/api/auth") && matching.contains(where: { $0.value == cookie.value }) }) : nil let store = WKWebsiteDataStore.default().httpCookieStore store.getAllCookies { current in guard valid() else { return } // A response from a removed/replaced login cannot renew its // cookie or commit that account's notification cursor. - let bindingCurrent = self.saved.contains(address) && matching.contains(where: { sent in current.contains(where: { $0.name == sent.name && $0.value == sent.value && $0.domain == sent.domain && $0.path == sent.path }) }) + let selectedName = (try? result.get())?["cookieName"] as? String ?? self.cookieName(address) + let bindingCurrent = self.saved.contains(address) && matching.contains(where: { sent in + let migrates = sent.name == "codex_mobile_session" && renewed.map { $0.value == sent.value && $0.name == selectedName } == true + return (sent.name == selectedName || migrates) && current.contains(where: { $0.name == sent.name && $0.value == sent.value && $0.domain == sent.domain && $0.path == sent.path }) + }) if !bindingCurrent { done(.failure(NSError(domain: "Bridge", code: 401))); return } guard bindingCurrent, let renewed else { done(result); return } store.setCookie(renewed) { @@ -954,12 +1086,13 @@ final class BridgeController: UIViewController, WKNavigationDelegate, WKUIDelega let revision = self.generation self.downloads.cancel(origin: target) self.revokeConnection(target) { + DeviceBinding.clear(target) if target == self.origin, #available(iOS 16.2, *) { Task { await LiveActivityController.shared.stop() } } self.defaults.set(self.saved.filter { $0 != target }, forKey: "origins"); self.computerSnapshots.removeValue(forKey: target) if self.defaults.string(forKey: "active") == target { self.defaults.removeObject(forKey: "active") } self.defaults.removeObject(forKey: "connection:" + target); self.defaults.removeObject(forKey: "cursor:" + target); self.defaults.removeObject(forKey: "stream:" + target); self.defaults.removeObject(forKey: "name:" + target); self.defaults.removeObject(forKey: "last-opened:" + target) WKWebsiteDataStore.default().httpCookieStore.getAllCookies { cookies in - for cookie in cookies where cookie.domain.trimmingCharacters(in: CharacterSet(charactersIn: ".")) == URL(string: target)?.host && cookie.name == "codex_mobile_session" && cookie.path == GatewayURL.cookiePath(target) { WKWebsiteDataStore.default().httpCookieStore.delete(cookie) } + for cookie in cookies where self.cookieMatches(cookie, address: target) { WKWebsiteDataStore.default().httpCookieStore.delete(cookie) } } if self.origin == target { self.origin = "" } if self.generation == revision { self.home() } @@ -1157,8 +1290,8 @@ enum MobileStrings { "返回电脑列表": "Back to computers", "外观与显示": "Appearance", - "隐藏APP": "Hide apps", - "显示APP": "Show apps", + "隐藏底部 APP 图标": "Hide bottom app icons", + "显示底部 APP 图标": "Show bottom app icons", "刷新页面": "Reload page", "好": "OK", "电脑上的工作,\n带在身边。": "Your computer’s work,\nalways with you.", @@ -1215,7 +1348,7 @@ enum MobileStrings { "当前电脑": "Current computer", "尚未选择电脑": "No computer selected", "返回首页扫码或输入网关地址。": "Return home to scan or enter a gateway address.", - "移除这台电脑": "Remove this computer", + "移除这台电脑": "Remove", "此预览尚未配置系统推送项目。当前仅同步收件箱;配置 Firebase 或厂商推送后才能启用后台任务提醒。": "System push is not configured. Background task alerts require Firebase or a device vendor’s push service.", "手机通知测试": "Mobile notification test", "本机通知已开启": "Local notifications are enabled", diff --git a/mobile/ios/BridgePreview/Info.plist b/mobile/ios/BridgePreview/Info.plist index 49a46a63..f9ba7617 100644 --- a/mobile/ios/BridgePreview/Info.plist +++ b/mobile/ios/BridgePreview/Info.plist @@ -13,9 +13,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 2.0.2 + 2.0.3 CFBundleVersion - 29 + 30 LSRequiresIPhoneOS NSSupportsLiveActivities @@ -56,6 +56,6 @@ BridgeReleaseVersion - 2.0.2 + 2.0.3 diff --git a/mobile/tests/EventClientTests.java b/mobile/tests/EventClientTests.java index f21bb3af..76388b46 100644 --- a/mobile/tests/EventClientTests.java +++ b/mobile/tests/EventClientTests.java @@ -18,7 +18,8 @@ public static void main(String[] args)throws Exception { if(mode.get().equals("replaced"))cookies.value="codex_mobile_session=newer-binding"; if(mode.get().equals("removed"))cookies.value=""; String attributes=mode.get().equals("domain")?"; Domain=127.0.0.1; Path=/":mode.get().equals("path")?"; Path=/other":"; Path=/"; - exchange.getResponseHeaders().set("Set-Cookie","codex_mobile_session=legacy-binding"+attributes+"; HttpOnly; SameSite=Strict; Max-Age=34560000"); + String name=mode.get().equals("migration")||mode.get().equals("scoped")?"codex_mobile_session_0123456789abcdef01234567":"codex_mobile_session"; + exchange.getResponseHeaders().set("Set-Cookie",name+"=legacy-binding"+attributes+"; HttpOnly; SameSite=Strict; Max-Age=34560000"); byte[] body="{}".getBytes(StandardCharsets.UTF_8);exchange.sendResponseHeaders(status,body.length);exchange.getResponseBody().write(body);exchange.close(); });server.start(); try{ @@ -29,6 +30,13 @@ public static void main(String[] args)throws Exception { check(cookies.lastSet.contains("Max-Age=34560000"),"Renewal reaches the WebView cookie store"); check(cookies.flushes==1,"Renewal is persisted before returning"); } + cookies.reset();EventClient.setName(origin,"codex_mobile_session");mode.set("migration"); + EventClient.read(origin,0L,new EventClient.Read()); + check(cookies.value.startsWith("codex_mobile_session_0123456789abcdef01234567="),"Valid legacy token migrates during pending read"); + cookies.reset();cookies.value="codex_mobile_session_0123456789abcdef01234567=legacy-binding";mode.set("scoped"); + EventClient.read(origin,0L,new EventClient.Read()); + check(cookies.lastSet.startsWith("codex_mobile_session_0123456789abcdef01234567="),"Scoped renewal preserves binding"); + EventClient.setName(origin,"codex_mobile_session"); for(String state:new String[]{"denied","replaced","removed","domain","path"}){ cookies.reset();mode.set(state); try{EventClient.read(origin);if(state.equals("denied"))throw new AssertionError("Unauthorized request accepted");}catch(java.io.IOException expected){if(!state.equals("denied"))throw expected;} diff --git a/mobile/tests/session.test.cjs b/mobile/tests/session.test.cjs index c5a36bd8..2d45073b 100644 --- a/mobile/tests/session.test.cjs +++ b/mobile/tests/session.test.cjs @@ -13,3 +13,11 @@ test('Android persists session immediately after login without exposing credenti test('an already authenticated page persists its renewed session; embedded frames cannot',()=>{ assert.equal(fixture({authenticated:true}).calls.length,1);const f=fixture({frame:true});assert.equal(f.calls.length,0);assert.equal(f.events['bridge-authenticated'],undefined); }); +test('successful logout asks the native shell to clear only its binding',()=>{ + const f=fixture();f.events['bridge-logged-out'](); + assert.deepEqual(f.calls,[['codexbridge-logout:__BRIDGE_CLIPBOARD_TOKEN__','']]); +}); +test('an authenticated page losing authorization asks for one guarded native recovery',()=>{ + const f=fixture({authenticated:true});f.events['bridge-auth-lost'](); + assert.deepEqual(f.calls.at(-1),['codexbridge-auth-lost:__BRIDGE_CLIPBOARD_TOKEN__','']); +}); diff --git a/package-lock.json b/package-lock.json index e1e1da1d..0d225aae 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codex-mobile-bridge-desktop", - "version": "2.0.2", + "version": "2.0.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codex-mobile-bridge-desktop", - "version": "2.0.2", + "version": "2.0.3", "license": "MIT", "dependencies": { "qrcode": "1.5.4" diff --git a/package.json b/package.json index 40aba66d..8c9398d7 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "codex-mobile-bridge-desktop", "desktopName": "codex-mobile-bridge.desktop", - "version": "2.0.2", + "version": "2.0.3", "description": "Codex App 手机网关桌面启动器", "author": "try2love", "license": "MIT", diff --git a/scripts/smoke-pairing.cjs b/scripts/smoke-pairing.cjs index ecec92a6..5edd9108 100644 --- a/scripts/smoke-pairing.cjs +++ b/scripts/smoke-pairing.cjs @@ -85,9 +85,10 @@ app.whenReady().then(async()=>{ nativePhone=openNative();await nativePhone.loadURL(fresh.url); const native=code=>nativePhone.webContents.executeJavaScript(code); await until(()=>native('document.getElementById("login").hidden&&!document.getElementById("app").hidden&&Boolean(csrf)'),platform+' QR sign-in'); - assert.equal(await native('fetch("/api/auth").then(r=>r.json()).then(v=>v.trustedDevice)'),true); + const auth=await native('fetch("/api/auth").then(r=>r.json())'); + assert.equal(auth.trustedDevice,true); const store=nativePhone.webContents.session.cookies; - const cookie=(await store.get({url:entry,name:'codex_mobile_session'}))[0]; + const cookie=(await store.get({url:entry,name:auth.cookieName}))[0]; assert.ok(cookie.httpOnly&&!cookie.session&&cookie.expirationDate>Date.now()/1000+399*86400); await store.flushStore();nativePhone.destroy();nativePhone=null; await worker('stop');await worker('start');await until(async()=>(await worker('snapshot')).runtime.running,'restarted gateway'); diff --git a/scripts/test-ios-binding-recovery.py b/scripts/test-ios-binding-recovery.py new file mode 100644 index 00000000..b5932dcf --- /dev/null +++ b/scripts/test-ios-binding-recovery.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +"""Compile production iOS recovery validation and exercise bounded local auth responses.""" +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +import subprocess +import threading + +ROOT = Path(__file__).resolve().parents[1] +WORK = ROOT / '.tmp/ios-binding-tests' +WORK.mkdir(parents=True, exist_ok=True) +NAME = 'codex_mobile_session_0123456789abcdef01234567' + + +class Handler(BaseHTTPRequestHandler): + mode = 'valid' + + def log_message(self, *_): + pass + + def do_GET(self): + if self.path.startswith('/control/'): + Handler.mode = self.path.split('/')[-1] + self.send_response(200); self.send_header('Content-Length', '0'); self.end_headers(); return + if Handler.mode == 'redirect': + self.send_response(302); self.send_header('Location', '/capture'); self.send_header('Content-Length', '0'); self.end_headers(); return + if self.path == '/capture': + raise AssertionError('Recovery followed a redirect') + relay = self.path.startswith('/d/fixture/') + legacy = Handler.mode == 'legacy' + selected = 'codex_mobile_session' if relay or legacy else NAME + valid = self.headers.get('Cookie') == selected + '=valid-device-binding' + if Handler.mode == 'revoked': valid = False + name = 'codex_mobile_session_ffffffffffffffffffffffff' if Handler.mode == 'wrong-name' else selected + import json + body = json.dumps({'authenticated': valid, 'cookieName': name} if not relay and not legacy else {'authenticated': valid, 'relay': relay, 'trustedDevice': True}).encode() + if Handler.mode == 'oversize': body += b' ' * 65537 + self.send_response(200); self.send_header('Content-Length', str(len(body))); self.end_headers(); self.wfile.write(body) + + +server = ThreadingHTTPServer(('127.0.0.1', 0), Handler) +threading.Thread(target=server.serve_forever, daemon=True).start() +try: + source = (ROOT / 'mobile/ios/BridgePreview/App.swift').read_text() + binding = source[source.index('private enum DeviceBinding'):source.index('final class BridgeNavigationController')] + redirect = source[source.index('final class NoRedirect:'):source.index('// Foreground-only')] + main = '''import Foundation +import Security +''' + binding + '\n' + redirect + ''' +let address = CommandLine.arguments[1] +private let scoped = DeviceBinding.Credential(name: "codex_mobile_session_0123456789abcdef01234567", token: "valid-device-binding") +func mode(_ value: String) async throws { + _ = try await URLSession.shared.data(from: URL(string: address + "/control/" + value)!) +} +private func check(_ address: String, _ credential: DeviceBinding.Credential, _ expected: Bool?) async { + let actual = await DeviceBinding.verify(address, credential); precondition(actual == expected) +} +Task { + await check(address, scoped, true) + try await mode("wrong-name") + await check(address, scoped, false) + try await mode("revoked") + await check(address, scoped, false) + try await mode("redirect") + await check(address, scoped, nil) + try await mode("oversize") + await check(address, scoped, nil) + try await mode("valid") + let relay = DeviceBinding.Credential(name: "codex_mobile_session", token: "valid-device-binding") + await check(address + "/d/fixture", relay, true) + try await mode("legacy") + await check(address, relay, true) + await check(address, scoped, false) + print("iOS binding validation passed") + exit(0) +} +dispatchMain() +''' + (WORK / 'main.swift').write_text(main) + subprocess.run(['xcrun', 'swiftc', '-module-cache-path', str(WORK / 'cache'), + str(ROOT / 'mobile/ios/BridgePreview/GatewayURL.swift'), str(WORK / 'main.swift'), + '-o', str(WORK / 'binding-tests')], check=True) + subprocess.run([str(WORK / 'binding-tests'), f'http://127.0.0.1:{server.server_port}'], check=True, timeout=30) +finally: + server.shutdown(); server.server_close() diff --git a/scripts/test-ios-events.py b/scripts/test-ios-events.py index 6cc84092..951592eb 100644 --- a/scripts/test-ios-events.py +++ b/scripts/test-ios-events.py @@ -127,6 +127,7 @@ def section(start, end): if 'private var pollGeneration' in source: fields += ' var pollGeneration = 0\n var pollTask: URLSessionDataTask?\n' fields += section(' private let redirect = NoRedirect()', ' override func viewDidLoad()') +fields += section(' private func cookieName(', ' private let page = UIStackView()').replace('private func', 'func') methods = ''' func checkComputers() {} func stopComputerChecks() {} diff --git a/scripts/test-mobile-session.py b/scripts/test-mobile-session.py index 3a9490df..dc61ce7e 100644 --- a/scripts/test-mobile-session.py +++ b/scripts/test-mobile-session.py @@ -23,7 +23,7 @@ }''', 'android/content/Context.java': '''package android.content; public abstract class Context {public abstract SharedPreferences getSharedPreferences(String name,int mode);}''', 'android/content/SharedPreferences.java': '''package android.content;public interface SharedPreferences {boolean getBoolean(String key,boolean fallback);String getString(String key,String fallback);Editor edit();interface Editor{Editor putString(String key,String value);Editor putBoolean(String key,boolean value);void apply();}}''', - 'org/json/JSONObject.java': '''package org.json; public class JSONObject {public JSONObject(){}public JSONObject(String value){}public boolean optBoolean(String key){return false;}public String getString(String key){return "";}public JSONObject put(String key,Object value){return this;}}''', + 'org/json/JSONObject.java': '''package org.json; public class JSONObject {public JSONObject(){}public JSONObject(String value){}public boolean optBoolean(String key){return false;}public String optString(String key,String fallback){return fallback;}public String getString(String key){return "";}public JSONObject put(String key,Object value){return this;}}''', } for name, source in stubs.items(): path = work / name diff --git a/scripts/test-native-binding-selection.py b/scripts/test-native-binding-selection.py new file mode 100644 index 00000000..4a5272c1 --- /dev/null +++ b/scripts/test-native-binding-selection.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python3 +"""Execute production cookie selection against first-login and stale-response cases.""" +from pathlib import Path +import os +import subprocess + +root = Path(__file__).resolve().parents[1] +work = root / '.tmp/native-binding-selection-tests' +work.mkdir(parents=True, exist_ok=True) +source = root / 'mobile/android/src/io/github/try2love/codexbridge/BindingSelection.java' +(work / 'BindingSelectionTests.java').write_text('''package io.github.try2love.codexbridge; +final class BindingSelectionTests { + static void eq(String expected,String actual){if(!expected.equals(actual))throw new AssertionError("expected "+expected+" got "+actual);} + public static void main(String[] args){ + String own="codex_mobile_session_0123456789abcdef01234567"; + String other="codex_mobile_session_ffffffffffffffffffffffff"; + String header=other+"=other-token; "+own+"=own-token"; + eq(own+"=own-token",BindingSelection.match(header,header,own)); + eq("",BindingSelection.match(other+"=other-token",header,own)); + eq(own+"=own-token",BindingSelection.match("codex_mobile_session=own-token",header,own)); + eq("",BindingSelection.match("codex_mobile_session=old-token",header,own)); + eq("",BindingSelection.match(header,header,"codex_mobile_session_bad")); + eq("codex_mobile_session=legacy-token",BindingSelection.match("codex_mobile_session=legacy-token", "codex_mobile_session=legacy-token", "codex_mobile_session")); + System.out.println("Native binding cookie selection passed"); + } +} +''') +java_home = Path(os.environ['JAVA_HOME']) if 'JAVA_HOME' in os.environ else None +def tool(name): return str(java_home / 'bin' / name) if java_home else name +subprocess.run([tool('javac'), '-d', str(work), str(source), str(work / 'BindingSelectionTests.java')], check=True) +subprocess.run([tool('java'), '-cp', str(work), 'io.github.try2love.codexbridge.BindingSelectionTests'], check=True) diff --git a/site/index.html b/site/index.html index 11894d2d..6130b86f 100644 --- a/site/index.html +++ b/site/index.html @@ -1,8 +1,8 @@ - + - + Codex Mobile Bridge|随身接力你的 AI 工作台 @@ -25,9 +25,9 @@ 下载 ↗
-

开源 · 自托管 · V2.0 正式版

+

开源 · 自托管 · v2.0.3 正式版

你的工作台,
不止在电脑前。

Codex、Claude Desktop、DeepSeek Harness 各司其职,你在手机上接着聊、查文件、看 Git、用终端。浏览器随开随用,Android 与 iOS App 随身接力。

代码开源,欢迎共建。觉得有用,欢迎前往 GitHub 点个 Star,也欢迎反馈问题、贡献代码。

任务仍在你的电脑或 SSH 主机上执行,执行端需保持在线。

2.0STABLE

从远程聊天,
到随身工作台。

WebAndroidiOS
-

01 / TRY IT

拿起这台手机,试试看。

不必安装,先走一遍你的工作流程。点击设备里的按钮,也可以直接选择下方场景。

模拟交互 · 无需登录
MOBILE WORKSPACE

    iOS APP · V2.0.0
    直接体验

    工作台与电脑网关使用 v2.0.0 产品界面和示例数据,手机首页为浏览器模拟。聊天回复、客户端状态、终端输出和 Git 操作均在本页模拟;不连接电脑、不调用模型、不读取或上传你的文件,刷新即重置。

    THREE CLIENTS / ONE BRIDGE

    三个应用,各自继续自己的工作。

    扫描电脑中已安装的客户端,只启用需要的应用。底部切换或左右滑动聊天列表,各自的会话与项目互不混淆。

    Codex

    Codex

    继续本机与 SSH 会话,保留模型、Skill、权限、计划和目标操作。官方账号与兼容 API 沿用原桌面工作环境。

    Claude

    Claude Desktop

    Code 与 Cowork 分组展示,继续消息并回应支持的授权。自动接入支持 macOS、Windows;首次连接可能需要解锁电脑完成准备。

    DeepSeek Harness

    DeepSeek Harness

    发现已安装的 Harness,沿用账号与工作目录,在同一个网关查看可用模型、推理选项及客户端提供的额度。

    02 / THE WORKBENCH

    不只是把聊天装进手机。

    v2.0.0 将聊天、文件与工具连成完整工作台。手机 Web、Android、iOS 和电脑浏览器共用会话,按屏幕宽度呈现。

    01 / FILES

    文件跟着项目走。

    打开目录、搜索文件,预览文本、Markdown 与图片。聊天附件可选择定位或下载,下载浮窗显示进度与速度;图片支持双指、滚轮缩放和拖动。

    聊天下载上限可即时调整;文件标签页的主动下载不受此上限限制。

    02 / TERMINAL

    不是单次命令,是连续终端。

    保留 cd 后的目录、环境变量和交互状态。采用用户实际登录 shell:macOS 通常为 zsh,Linux 通常为 bash,Windows 使用 COMSPEC。手机换行用于编写,点击发送才执行。

    切换标签保留会话,关闭终端标签结束会话。

    03 / GIT

    看见变更,也能完成提交。

    从增删差异到历史链路,在手机上审阅并暂存文件、填写提交说明。支持创建或切换本地分支、本地合并与中止合并。

    不提供远程推送、历史重写或强制丢弃。

    04 / SIDE CHAT

    Codex 侧边聊天,另开一条思路。

    继承创建时的主聊天上下文。模型、思考程度、Skill、权限和附件一个不少;支持普通/计划模式、排队和补充任务。同一网关的多台设备共享这条临时会话。

    结束或重启网关后清除;不进入原生侧边栏,暂不支持 SSH 工作区。

    05 / SPLIT VIEW

    宽屏,真正用起来。

    主聊天在左,文件、Git、终端或侧边聊天在右。拖动标签开启分屏,拖动分隔条调整比例;详情窗口可调节高度。窄屏和手机保持单栏。

    会话区域宽度至少 720 px 时提供分屏;以实际可用空间为准。

    06 / SUBAGENTS

    Codex 子智能体,进度看得见。

    在任务树里查看当前会话派生的子智能体,展开最近已保存的输入、回复和工具记录,不必离开主聊天。

    当前为只读记录,不提供创建、发消息或停止子智能体。

    看看 v2.0.0 的分屏界面
    真实 Web 界面 · 示例数据;上方可点击设备为交互示意。

    03 / MADE FOR YOUR PHONE

    一个 App,接上你的几台电脑。

    扫码保存 Mac、Windows 或 Ubuntu 网关,回到电脑列表随时切换。应用图标显示未读角标,点击直达对应应用。

    01

    连接与登录

    保存多台电脑及登录状态;电脑端可撤销设备。Web 支持记住登录,密码更新或凭证撤销后需重新验证。

    02

    真正顺手的操作

    点击直接复制,图片预览有关闭入口,附件调用系统保存。聊天列表支持下拉刷新,Android 首页连续两次返回可退出。

    03

    完整的输入区

    选择模型、思考程度、Skill 与权限,上传附件,普通/计划模式自由切换。Codex 主聊天保留目标模式,侧边聊天支持独立设置;其他客户端按自身能力提供选项。

    04

    外观、语言与更新

    中英文界面切换,聊天内容保持原样。设置里调整显示、图片缩略图,主动检查 App 更新;不会自动下载安装。

    下载手机 App ↗Android APK · iOS IPA(需自行签名)

    STAY IN THE LOOP

    收到提醒,也知道它的边界。

    App 前台可同步任务提醒,通知可清空,点击可回到对应电脑和聊天。外部 Bark、ntfy、PushPlus 通道继续保留,支持的通知链接可跳回已保存的电脑。

    iOS 灵动岛与锁屏实时活动

    查看执行、待回应和连接状态,点击回到聊天。不显示聊天正文;App 不活跃时可能显示最后已知状态。

    标准手机 App 不保证后台或锁屏通知送达,灵动岛也不等于后台实时推送。

    04 / GET THE APPS

    准备好,把演示变成你的工作台。

    v2.0.2 · 网关与手机 App 配套使用

    01

    先安装电脑网关

    macOS、Windows,以及实验性 Ubuntu x64 / ARM64。Web 工作台由网关提供,浏览器打开地址即可。

    电脑端下载 ↗
    02

    Android

    下载 APK,按系统提示安装。已有预览版可覆盖安装,保留已保存的电脑。

    下载 Android APK ↗

    手机 App 请搭配 v2.0.2 网关使用。升级前保留配置备份,相同端口只运行一个网关;iOS 仍需使用自己的 Apple 账号签名安装。

    基础操作录像 · v1.3.0 界面(展开观看)
    +

    01 / TRY IT

    拿起这台手机,试试看。

    不必安装,先走一遍你的工作流程。点击设备里的按钮,也可以直接选择下方场景。

    模拟交互 · 无需登录
    MOBILE WORKSPACE

      iOS APP · V2.0.0
      直接体验

      工作台与电脑网关使用 v2.0.0 产品界面和示例数据,手机首页为浏览器模拟。聊天回复、客户端状态、终端输出和 Git 操作均在本页模拟;不连接电脑、不调用模型、不读取或上传你的文件,刷新即重置。

      THREE CLIENTS / ONE BRIDGE

      三个应用,各自继续自己的工作。

      扫描电脑中已安装的客户端,只启用需要的应用。底部切换或左右滑动聊天列表,各自的会话与项目互不混淆。

      Codex

      Codex

      继续本机与 SSH 会话,保留模型、Skill、权限、计划和目标操作。官方账号与兼容 API 沿用原桌面工作环境。

      Claude

      Claude Desktop

      Code 与 Cowork 分组展示,继续消息并回应支持的授权。自动接入支持 macOS、Windows;首次连接可能需要解锁电脑完成准备。

      DeepSeek Harness

      DeepSeek Harness

      发现已安装的 Harness,沿用账号与工作目录,在同一个网关查看可用模型、推理选项及客户端提供的额度。

      02 / THE WORKBENCH

      不只是把聊天装进手机。

      v2.0.0 将聊天、文件与工具连成完整工作台。手机 Web、Android、iOS 和电脑浏览器共用会话,按屏幕宽度呈现。

      01 / FILES

      文件跟着项目走。

      打开目录、搜索文件,预览文本、Markdown 与图片。聊天附件可选择定位或下载,下载浮窗显示进度与速度;图片支持双指、滚轮缩放和拖动。

      聊天下载上限可即时调整;文件标签页的主动下载不受此上限限制。

      02 / TERMINAL

      不是单次命令,是连续终端。

      保留 cd 后的目录、环境变量和交互状态。采用用户实际登录 shell:macOS 通常为 zsh,Linux 通常为 bash,Windows 使用 COMSPEC。手机换行用于编写,点击发送才执行。

      切换标签保留会话,关闭终端标签结束会话。

      03 / GIT

      看见变更,也能完成提交。

      从增删差异到历史链路,在手机上审阅并暂存文件、填写提交说明。支持创建或切换本地分支、本地合并与中止合并。

      不提供远程推送、历史重写或强制丢弃。

      04 / SIDE CHAT

      Codex 侧边聊天,另开一条思路。

      继承创建时的主聊天上下文。模型、思考程度、Skill、权限和附件一个不少;支持普通/计划模式、排队和补充任务。同一网关的多台设备共享这条临时会话。

      结束或重启网关后清除;不进入原生侧边栏,暂不支持 SSH 工作区。

      05 / SPLIT VIEW

      宽屏,真正用起来。

      主聊天在左,文件、Git、终端或侧边聊天在右。拖动标签开启分屏,拖动分隔条调整比例;详情窗口可调节高度。窄屏和手机保持单栏。

      会话区域宽度至少 720 px 时提供分屏;以实际可用空间为准。

      06 / SUBAGENTS

      Codex 子智能体,进度看得见。

      在任务树里查看当前会话派生的子智能体,展开最近已保存的输入、回复和工具记录,不必离开主聊天。

      当前为只读记录,不提供创建、发消息或停止子智能体。

      看看 v2.0.0 的分屏界面
      真实 Web 界面 · 示例数据;上方可点击设备为交互示意。

      03 / MADE FOR YOUR PHONE

      一个 App,接上你的几台电脑。

      扫码保存 Mac、Windows 或 Ubuntu 网关,回到电脑列表随时切换。应用图标显示未读角标,点击直达对应应用。

      01

      连接与登录

      保存多台电脑及登录状态;电脑端可撤销设备。Web 支持记住登录,密码更新或凭证撤销后需重新验证。

      02

      真正顺手的操作

      点击直接复制,图片预览有关闭入口,附件调用系统保存。聊天列表支持下拉刷新,Android 首页连续两次返回可退出。

      03

      完整的输入区

      选择模型、思考程度、Skill 与权限,上传附件,普通/计划模式自由切换。Codex 主聊天保留目标模式,侧边聊天支持独立设置;其他客户端按自身能力提供选项。

      04

      外观、语言与更新

      中英文界面切换,聊天内容保持原样。设置里调整显示、图片缩略图,主动检查 App 更新;不会自动下载安装。

      下载手机 App ↗Android APK · iOS IPA(需自行签名)

      STAY IN THE LOOP

      收到提醒,也知道它的边界。

      App 前台可同步任务提醒。直连网关时,可在手机或 Web 的「第三方通知」选择 Bark(iPhone)、ntfy(Android)或 PushPlus(微信),配置并测试。

      iOS 灵动岛与锁屏实时活动

      查看执行、待回应和连接状态,点击回到聊天。不显示聊天正文;App 不活跃时可能显示最后已知状态。

      标准手机 App 不保证后台或锁屏通知送达,灵动岛也不等于后台实时推送。

      04 / GET THE APPS

      准备好,把演示变成你的工作台。

      v2.0.3 正式版 · 网关与手机 App 配套更新

      01

      先安装电脑网关

      macOS、Windows,以及实验性 Ubuntu x64 / ARM64。Web 工作台由网关提供,浏览器打开地址即可。

      电脑端下载 ↗
      02

      Android

      覆盖安装 APK 以保留已保存的电脑。v2.0.3 支持应用内更新,可选择手机下载或电脑下载,已下载的包可继续安装。

      下载 Android APK ↗

      手机 App 请搭配 v2.0.3 网关使用。升级前保留配置备份,相同端口只运行一个网关;iOS 仍需使用自己的 Apple 账号签名安装。

      基础操作录像 · v1.3.0 界面(展开观看)

      01 / WALKTHROUGH

      先看看,它是怎么用的。

      从启动网关到跨设备继续聊天,跟着实际界面走一遍。

      -

      Tailscale 配置图解 →查看正式版源码

      +

      Tailscale 配置图解 →查看正式版源码

      -

      06 / DOWNLOAD

      让手边的设备,接上工作。

      v2.0.2 · 当前正式版

      -

      v2.0.2 改善文件链接、手机文件打开、登录自启,以及聊天列表滚动与下拉刷新。

      +

      06 / DOWNLOAD

      让手边的设备,接上工作。

      v2.0.3 · 正式版

      +

      v2.0.3 新增 Android 应用内更新、手机与 Web 第三方通知配置,改善搜索筛选、横屏布局和连接恢复。

      安装包内置网关运行时。按电脑的系统与芯片下载。

      -

      从 v1.4.0 升级:先备份配置,在“应用与维护 → 应用更新”检查更新,或安装新版安装包;完成后刷新网页并更新手机 App。Ubuntu 使用安装包手动升级。

      +

      建议网关与手机 App 一起更新,网页重新加载。旧 Android 可先覆盖安装 APK,v2.0.3 起后续更新可在 App 内完成;iOS 仍需自行签名,Ubuntu 使用安装包手动升级。

      访问端需要登录同一个 OpenAI 账号吗? @@ -454,7 +454,7 @@

      如果它让你的 Codex

      账号与接入

      模型与推理强度

      应用到当前聊天,正在运行的任务从下一轮使用。

      新建聊天

      选择已有项目,或不选择项目创建独立聊天。电脑会打开这条聊天。

      -

      PushPlus 通知

      使用微信登录 PushPlus、关注服务号并完成实名认证,复制个人资料中的 Token。勾选启用并保存后发送测试通知,再到设置中选择全会话通知,或在聊天提醒中单独调整。

      PushPlus 需付费实名认证,最低 3.9 元;认证后可使用基础额度,无需另购会员。费用以官方页面为准。

      微信渠道普通实名用户每天 200 次请求、每分钟 5 次;相同内容每小时最多 3 条,失败请求也计入额度。超限可能暂停推送。

      PushPlus 官网与 Token ↗ · 实名认证与费用 ↗ · 额度与频率限制 ↗

      +

      第三方通知

      通用通知设置
      +

      修改聊天名称

      聊天通知设置

      选择 Skill

      选择后随下一条消息一起发送,最多 8 个。

      聊天详情

      @@ -26,7 +27,7 @@

      设置

      主页快捷入口 - +
      全会话通知 diff --git a/web/layouts/medium/client-navigation.css b/web/layouts/medium/client-navigation.css index 2e2fa768..8be20411 100644 --- a/web/layouts/medium/client-navigation.css +++ b/web/layouts/medium/client-navigation.css @@ -1,2 +1,2 @@ @media(min-width:721px){#sidebar{padding-top:0}} -@media(min-width:721px) and (max-width:1100px){.app.chat-open{grid-template-columns:minmax(0,1fr)}.app.chat-open>#sidebar{display:none}.desktop-sessions.ds-open{grid-template-columns:minmax(0,1fr)}.desktop-sessions.ds-open>.ds-list{display:none}#back{display:block}} +@media(min-width:721px) and (max-width:1100px){.app.chat-open{grid-template-columns:minmax(0,1fr)}.app.chat-open>#sidebar{display:none}.desktop-sessions:not(.ds-open){grid-template-columns:minmax(0,1fr)}.desktop-sessions:not(.ds-open)>.ds-main{display:none}.desktop-sessions.ds-open{grid-template-columns:minmax(0,1fr)}.desktop-sessions.ds-open>.ds-list{display:none}#back{display:block}} diff --git a/web/shared/i18n.js b/web/shared/i18n.js index 7fd8e168..9097d6e5 100644 --- a/web/shared/i18n.js +++ b/web/shared/i18n.js @@ -2,6 +2,41 @@ // UI messages only. Never translate chat content, commands, model IDs or user input. const BridgeI18n=(()=>{ const en={ +"第三方通知":"Third-party notifications", +"显示第三方通知入口":"Show notification shortcut", +"微信":"WeChat", +"返回":"Back", +"通用通知设置":"Common notification settings", +"保存通用设置":"Save common settings", +"重试读取":"Retry loading", +"读取中":"Loading", +"读取失败":"Could not load", +"未配置":"Not configured", +"已关闭":"Off", +"已开启":"On", +"通用通知设置已保存":"Common notification settings saved", +"安全提醒":"Security alerts", +"入口通知":"Gateway address notifications", +"网关名称":"Gateway name", +"包含聊天标题":"Include chat title", +"通知跳转地址":"Notification link URL", +"在 iPhone 的 Bark 中获取 Device Key,并填写服务地址。":"Get a Device Key in Bark on your iPhone and enter the server URL.", +"在 Android 的 ntfy 中订阅相同服务和主题。公共主题可以留空 Token。":"Subscribe to the same server and topic in ntfy on Android. Public topics can leave the token blank.", +"通过微信获取 PushPlus Token,保存后可以发送测试通知。":"Get a PushPlus token through WeChat. Save it before sending a test notification.", +"启用 Bark":"Enable Bark", +"启用 ntfy":"Enable ntfy", +"启用 PushPlus":"Enable PushPlus", +"清除已保存的 Token":"Clear saved token", +"填写 Bark Device Key":"Enter Bark Device Key", +"填写 ntfy Token":"Enter ntfy token", +"填写 PushPlus Token":"Enter PushPlus token", +"Bark 已接受测试通知,请在手机确认是否收到":"Bark accepted the test notification. Check your phone.", +"ntfy 已接受测试通知,请在手机确认是否收到":"ntfy accepted the test notification. Check your phone.", +"PushPlus 已接受测试通知,请在手机确认是否收到":"PushPlus accepted the test notification. Check your phone.", +"Bark 配置已保存":"Bark settings saved", +"ntfy 配置已保存":"ntfy settings saved", +"PushPlus 配置已保存":"PushPlus settings saved", +"尚未配置通知通道,请在第三方通知中配置。":"No notification channel configured. Set one up in Third-party notifications.", "手机访问地址(可选)":"Phone access URL (optional)", "留空使用服务器公网 IP":"Leave blank to use the server public IP", "使用域名时,需要自行将域名解析到服务器公网 IP,并配置 HTTPS 证书和反向代理;填写完整地址,例如 https://bridge.example.com。":"For a domain, point its DNS record to the server public IP and configure an HTTPS certificate and reverse proxy. Enter the full URL, such as https://bridge.example.com.", @@ -121,6 +156,9 @@ const en={ "这会立即结束电脑上的应用,可能中断正在运行的会话并丢失未保存内容。请确认仍要继续。":"This will immediately stop the desktop app. Running sessions may be interrupted and unsaved content may be lost. Confirm to continue.", "确认强制关闭":"Force quit now", "底部应用切换":"Bottom app switcher", +"显示底部 APP 图标":"Show icons", +"隐藏底部 APP 图标":"Hide icons", +"保存点击下载阈值":"Save click download limit", "隐藏图标不关闭应用,仍可在应用管理中打开聊天。":"Hiding an icon does not stop the app. Open its chats from App management.", "查看聊天":"View chats", "不选择项目":"No project", @@ -192,6 +230,10 @@ const en={ "选择需要接入的应用;Claude 完全退出或重新加载后需初始化连接,初始化会使用电脑前台和键盘焦点。":"Select apps to connect. After a complete quit or reload, Claude needs connection initialization, using the computer's foreground window and keyboard focus.", "需要初始化连接":"Connection initialization required", "初始化连接":"Initialize connection", +"尝试重连":"Try reconnecting", +"重新初始化连接":"Reinitialize connection", +"连接被其他网关占用":"Connection used by another gateway", +"Claude 桥接连接被其他网关占用,请先停止旧网关;旧版连接还需重新加载 Claude":"Another gateway is using Claude's bridge. Stop the old gateway first; legacy connections also require reloading Claude.", "开始初始化":"Start initialization", "会打开电脑上的 Claude 和开发者工具,并使用键盘焦点;请暂停电脑端操作,完成后恢复后台连接。":"This opens Claude and its developer tools on your computer and uses keyboard focus. Pause your work on the computer; the connection will return to the background when finished.", "通过 Claude 原生菜单退出,菜单可能短暂出现;如有任务或保存确认,请在电脑端处理。完全退出后需重新初始化连接,初始化会使用电脑前台和键盘焦点。":"Quit through Claude's native menu. The menu may appear briefly; handle any task or save confirmation on your computer. After quitting completely, the connection requires initialization again, using the computer's foreground window and keyboard focus.", @@ -469,7 +511,7 @@ const en={ "请求方式无效":"Invalid request method", "会话类型":"Conversation type", -"全部会话":"All conversations", +"全部会话":"All", "此应用的消息附件仅支持图片;其他文件请使用文件传输。":"This app accepts image attachments. Use file transfer for other files.", "没有匹配的聊天":"No matching conversations", "这条聊天还没有消息":"No messages yet", @@ -573,6 +615,7 @@ const en={ "未确认 Console 已提交,已停止自动操作;请检查 Console 和连接状态后重试":"Console submission could not be confirmed. Automatic setup stopped; check Console and the connection status before retrying.", "Claude 已连接,但未能关闭开发者工具窗口":"Claude connected, but the Developer Tools window could not be closed.", "Console 内容未完整到达,未执行连接脚本,请重试":"The complete connector did not reach Console and was not executed. Try again.", +"Console 无法核验完整内容,未执行连接脚本,请重试":"Could not verify the complete Console input. The connector was not executed. Try again.", "连接脚本格式无效":"Invalid connector script format", "Console 中已有未提交内容,已保留,请处理后重试连接":"Console contains an unfinished draft. It was preserved; finish it before reconnecting.", "Console 拒绝输入连接脚本":"Console refused the connector input", @@ -909,6 +952,7 @@ const en={ "‹ 会话":"‹ Sessions", "继续桌面会话…":"Continue this desktop session…", "已连接,桌面还没有会话":"Connected. No desktop sessions yet.", +"电脑上还没有聊天":"No chats on this computer yet.", "补充回答":"Additional answer", "新建桌面会话":"New desktop session", "应用到此会话":"Apply to this session", @@ -1773,7 +1817,7 @@ const en={ "执行错误": "Execution error", "把正在做的事,带到手机上": "Take your work with you", "拒绝": "Deny", - "按项目": "Group by project", + "按项目": "Project", "接收主题": "Topic", "推理强度": "Reasoning effort", "推送服务请使用 HTTPS;HTTP 仅用于本机测试": "Use HTTPS for the notification server. HTTP is only supported for local testing", @@ -2345,7 +2389,7 @@ const en={ "Language / 语言": "Language", "记住登录(闲置 7 天后过期)": "Remember me (expires after 7 days of inactivity)", "新建": "New", - "最近交互": "Recent activity", + "最近交互": "Recent", "权限": "Permissions", "发送新消息": "Send message", "完成后发送": "Send when done", diff --git a/web/shell/app.js b/web/shell/app.js index c190ac4a..e2cfe316 100644 --- a/web/shell/app.js +++ b/web/shell/app.js @@ -41,22 +41,23 @@ let listLoading=0; let modelCatalogGeneration=0;let catalogData={models:null,skills:null};let catalogResults={models:null,skills:null};let catalogTasks={models:null,skills:null};let selectedSkills=new Set(),invalidSkills=new Set(),skillIndex=new Map(),skillTotal=0,skillPages=[],skillQuery={q:'',offset:0,limit:200},skillSearchTimer,skillRequest=0; function el(tag,cls,text){const node=document.createElement(tag);if(cls)node.className=cls;if(text!==undefined)node.textContent=text;return node;} function toast(text){text=t(text);$('toast').textContent=text;$('toast').hidden=false;clearTimeout(toast.timer);toast.timer=setTimeout(()=>$('toast').hidden=true,4200);} -async function api(path,body,signal){const options={signal,credentials:'same-origin',cache:'no-store',headers:{}};if(body!==undefined){options.method='POST';options.headers={'Content-Type':'application/json','X-CSRF-Token':csrf};options.body=JSON.stringify(body);}const response=await fetch(hostUrl(path),options);const data=await response.json();if(!response.ok){if(response.status===401)showLogin();throw Object.assign(Error(data.error||t('请求失败')),{loginStatus:data.loginStatus,status:response.status});}return data;} -const workbench=window.BridgeWorkbench=new Workbench({chat:$('chat'),request:api,csrf:()=>csrf,notify:toast,onUnauthorized:()=>showLogin(),openChat}); -const desktopSessions=new DesktopSessionsView({root:$('app'),select:$('desktop-backend'),request:api,notify:toast,csrf:()=>csrf,onUnauthorized:()=>showLogin(),onSelect:provider=>{ +function authLost(){if(!$('app').hidden)document.dispatchEvent(new Event('bridge-auth-lost'));showLogin();} +async function api(path,body,signal){const options={signal,credentials:'same-origin',cache:'no-store',headers:{}};if(body!==undefined){options.method='POST';options.headers={'Content-Type':'application/json','X-CSRF-Token':csrf};options.body=JSON.stringify(body);}const response=await fetch(hostUrl(path),options);const data=await response.json();if(!response.ok){if(response.status===401)authLost();throw Object.assign(Error(data.error||t('请求失败')),{loginStatus:data.loginStatus,status:response.status});}return data;} +const workbench=window.BridgeWorkbench=new Workbench({chat:$('chat'),request:api,csrf:()=>csrf,notify:toast,onUnauthorized:authLost,openChat}); +const desktopSessions=new DesktopSessionsView({root:$('app'),select:$('desktop-backend'),request:api,notify:toast,csrf:()=>csrf,onUnauthorized:authLost,onSelect:provider=>{ if(provider!=='codex'){saveDraft();chatTimeline?.dispose();chatTimeline=null;currentId=null;state=null;workbench.reset();$('chat').hidden=true;$('welcome').hidden=false;$('app').classList.remove('chat-open');history.replaceState(null,'',location.pathname);} else if(clientNavigation.provider==='codex'&&!$('app').hidden)loadList(true).catch(error=>toast(error.message)); }}); const clientNavigation=new ClientNavigation({view:desktopSessions,request:api,notify:toast}); desktopSessions.onListEnter=()=>{clientNavigation.listReadDone=false;clientNavigation.listVisit=(clientNavigation.listVisit||0)+1;if(clientNavigation.provider!=='codex')desktopSessions.refresh();}; desktopSessions.onListReady=(provider,value)=>clientNavigation.acknowledgeList(provider,value); -async function uploadAttachment(key,id,file){const separator=key.indexOf('|'),host=key.slice(0,separator),thread=key.slice(separator+1);const response=await fetch(sessionUrl(thread,'uploads',host)+'&id='+encodeURIComponent(id)+'&name='+encodeURIComponent(file.name),{method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/octet-stream','X-CSRF-Token':csrf},body:file});const result=await response.json();if(!response.ok){if(response.status===401)showLogin();const error=Error(result.error||t('附件上传失败,请重试'));error.retryable=response.status>=500||response.status===429;throw error;}return result;} +async function uploadAttachment(key,id,file){const separator=key.indexOf('|'),host=key.slice(0,separator),thread=key.slice(separator+1);const response=await fetch(sessionUrl(thread,'uploads',host)+'&id='+encodeURIComponent(id)+'&name='+encodeURIComponent(file.name),{method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/octet-stream','X-CSRF-Token':csrf},body:file});const result=await response.json();if(!response.ok){if(response.status===401)authLost();const error=Error(result.error||t('附件上传失败,请重试'));error.retryable=response.status>=500||response.status===429;throw error;}return result;} async function uploadAttachmentWithRetry(key,id,file){if(window.BridgeSharedRelay)return uploadAttachment(key,id,file);let last;for(let attempt=0;attempt<3;attempt++){try{return await uploadAttachment(key,id,file);}catch(error){last=error;if(error.name==='AbortError'||error.retryable===false||attempt===2)throw error;await new Promise(resolve=>setTimeout(resolve,400*(attempt+1)));}}if(last instanceof TypeError)last.message=t('附件上传中断,请重试');throw last;} const attachments=new ChatAttachments({root:$('attachment-list'),button:$('attach-button'),input:$('attachment-input'),paste:$('message'),drop:$('composer'),preview:(key,id)=>{const at=key.indexOf('|'),host=key.slice(0,at),thread=key.slice(at+1);return hostUrl(sessionUrl(thread,'uploads/'+encodeURIComponent(id)+'/preview',host));},onChange:()=>{if(currentId)$('send').disabled=sending||!attachments.ready()||!state||state.loadingHistory||state.activating;},thumbnail:async(key,id,file)=>{ const separator=key.indexOf('|'),host=key.slice(0,separator),thread=key.slice(separator+1); const image=await attachments.thumbnailBlob(file); const response=await fetch(sessionUrl(thread,'uploads/'+encodeURIComponent(id)+'/thumb',host)+'&width='+image.width+'&height='+image.height,{method:'POST',credentials:'same-origin',headers:{'Content-Type':image.mime,'X-CSRF-Token':csrf},body:image.data}); - const result=await response.json();if(!response.ok){if(response.status===401)showLogin();throw Error(result.error||t('缩略图生成失败'));}return result; + const result=await response.json();if(!response.ok){if(response.status===401)authLost();throw Error(result.error||t('缩略图生成失败'));}return result; },upload:uploadAttachmentWithRetry}); function visibleChat(){return currentId&&!workbench.isFileVisible&&!document.hidden&&$('app').classList.contains('chat-open')?chatKey():null;} function renderActivity(){for(const button of $('sessions').querySelectorAll('.session')){ @@ -109,10 +110,11 @@ async function openHarness(){ $('harness-open').onclick=openHarness; async function enter(){desktopSessions.onListEnter();const chatLink=location.hash;$('desktop-backend').hidden=false;if(/(?:^|[?&])open=harness(?:&|$)/.test(location.search||'')){history.replaceState(null,'','/');await openHarness();}refreshComputer();$('login').hidden=true;$('app').hidden=false;$('bootstrap-status').hidden=true;window.BridgeBootstrap?.ready();document.dispatchEvent(new Event('bridge-authenticated'));await clientNavigation.refresh();accountsPanel?.refresh();const list=clientNavigation.provider==='codex'?loadList(true).catch(e=>toast(e.message)):Promise.resolve();await clientNavigation.openChatLink(chatLink,openChat);await list;} $('login-form').addEventListener('submit',async event=>{event.preventDefault();if(loginStatus.blocked)return;$('login-button').disabled=true;$('login-error').textContent='';try{const result=await api('/api/login',{username:$('username').value,password:$('password').value,remember:$('remember-login').checked});renderLoginStatus({attemptsRemaining:5,attemptLimit:5,blocked:false});csrf=result.csrf;$('password').value='';await enter();}catch(error){$('login-error').textContent=t(error.message);renderLoginStatus(error.loginStatus);}finally{$('login-button').disabled=loginStatus.blocked;}}); -$('logout').onclick=async()=>{await api('/api/logout',{});csrf='';state=null;currentId=null;$('messages').replaceChildren();$('approvals').replaceChildren();$('queued').replaceChildren();$('sessions').replaceChildren();$('message').value='';sessionStorage.clear();attachments.reset();sessionActivity.clear();recentInteractions.clear();showLogin();}; +$('logout').onclick=async()=>{await api('/api/logout',{});document.dispatchEvent(new Event('bridge-logged-out'));csrf='';state=null;currentId=null;$('messages').replaceChildren();$('approvals').replaceChildren();$('queued').replaceChildren();$('sessions').replaceChildren();$('message').value='';sessionStorage.clear();attachments.reset();sessionActivity.clear();recentInteractions.clear();showLogin();}; function dateText(value){if(!value)return '';return new Date(value*1000).toLocaleDateString(BridgeI18n.locale(),{month:'numeric',day:'numeric'});} $('list-mode').value=localStorage.getItem('list-mode')||'recent'; $('list-mode').onchange=()=>{localStorage.setItem('list-mode',$('list-mode').value);renderList();}; +if(typeof bindChatListToolbar==='function')bindChatListToolbar(document.querySelector('#sidebar .chat-list-toolbar')); function renderList(){ const stamp=JSON.stringify([$('list-mode').value,currentId,currentHost,BridgeI18n.locale(),listRows.map(row=>[row.id,row.host,row.title,row.projectKey,row.projectName,row.hostLabel,row.recency])]); if(stamp===listRenderStamp&&$('sessions').children.length){renderActivity();return;} @@ -155,7 +157,7 @@ async function loadList(reset=false){ listRows=[...rows.values()];listRows.sort((a,b)=>b.recency-a.recency); listOffset=offset+result.sessions.length;listHasMore=result.sessions.length===100; $('host-errors').textContent=(result.unavailableHosts||[]).map(h=>h.label+':'+h.error).join('\n'); - $('host-errors').hidden=!(result.unavailableHosts||[]).length;renderList();refreshActivity();clientNavigation.acknowledgeList('codex',result); + $('host-errors').hidden=!(result.unavailableHosts||[]).length;renderList();refreshActivity();clientNavigation.acknowledgeList('codex',result);listSync.acknowledgeSuccess(); } function loadMoreSessions(){ const root=$('sessions'); @@ -257,7 +259,7 @@ $('notify-button').onclick=async()=>{ $('notify-enabled').value=notificationState.requests||'inherit'; $('notify-completion').value=notificationState.completion||'inherit'; for(const id of ['notify-enabled','notify-completion','notify-save'])$(id).disabled=false; - $('notify-channel-status').textContent=t(notificationState.available?'通知通道已开启':'尚未配置通知通道,可在设置中配置 PushPlus,或在电脑端配置 Bark、ntfy。'); + $('notify-channel-status').textContent=t(notificationState.available?'通知通道已开启':'尚未配置通知通道,请在第三方通知中配置。'); }; $('notify-form').onsubmit=async event=>{ event.preventDefault(); @@ -600,34 +602,127 @@ $('rename-form').onsubmit=async event=>{ }catch(error){$('rename-error').textContent=t(error.message);} finally{renameBusy=false;$('rename-save').disabled=false;} }; -let pushplusBusy=false; -function renderPushplus(config){ - $('pushplus-enabled').checked=!!config.pushplusEnabled; +let notificationChannelBusy=false,selectedNotificationChannel='',notificationListRequest=0,notificationCommonLoaded=false; +const notificationChannelDetails={ + bark:{name:'Bark',enabled:'barkEnabled',server:'barkServer',secret:'barkKey',hasSecret:'hasBarkKey',clear:'clearBarkKey',secretLabel:'Device Key',help:'在 iPhone 的 Bark 中获取 Device Key,并填写服务地址。'}, + ntfy:{name:'ntfy',enabled:'enabled',server:'server',secret:'token',hasSecret:'hasToken',clear:'clearToken',secretLabel:'Token',help:'在 Android 的 ntfy 中订阅相同服务和主题。公共主题可以留空 Token。'}, + pushplus:{name:'PushPlus',enabled:'pushplusEnabled',secret:'pushplusToken',hasSecret:'hasPushplusToken',clear:'clearPushplusToken',secretLabel:'Token',help:'通过微信获取 PushPlus Token,保存后可以发送测试通知。'} +}; +function renderNotificationChannel(config){ + const channel=notificationChannelDetails[selectedNotificationChannel]; + $('pushplus-heading').textContent=channel.name; + $('notification-channel-help').textContent=t(channel.help); + $('pushplus-help').hidden=selectedNotificationChannel!=='pushplus'; + $('notification-channel-enable-label').textContent=t('启用 '+channel.name); + $('pushplus-enabled').checked=!!config[channel.enabled]; + $('notification-server-row').hidden=!channel.server; + $('notification-topic-row').hidden=selectedNotificationChannel!=='ntfy'; + if(channel.server){ + $('notification-server-label').textContent=t(channel.name+' 服务地址'); + $('notification-server').value=config[channel.server]||''; + } + if(selectedNotificationChannel==='ntfy')$('notification-topic').value=config.topic||''; + $('notification-secret-label').textContent=channel.secretLabel; + $('notification-clear-label').textContent=t('清除已保存的 '+channel.secretLabel); $('pushplus-token').value='';$('pushplus-clear').checked=false; - $('pushplus-token').placeholder=t(config.hasPushplusToken?'已保存,留空保留':'填写 PushPlus Token'); + $('pushplus-token').placeholder=t(config[channel.hasSecret]?'已保存,留空保留':'填写 '+channel.name+' '+channel.secretLabel); +} +function renderNotificationCommon(config){ + for(const id of ['security-enabled','address-enabled','include-title'])$('notification-'+id).checked=!!config[{'security-enabled':'securityEnabled','address-enabled':'addressEnabled','include-title':'includeTitle'}[id]]; + $('notification-address-name').value=config.addressName||''; + $('notification-click-base').value=config.clickBase||''; +} +function notificationCommonControls(enabled){ + for(const id of ['security-enabled','address-enabled','address-name','include-title','click-base','common-save'])$('notification-'+id).disabled=!enabled; +} +async function loadNotificationCommon(request){ + notificationCommonLoaded=false;notificationCommonControls(false); + $('notification-common-retry').hidden=true;$('notification-common-error').textContent=''; + try{ + const config=await api('/api/notifications/channels/common'); + if(request!==notificationListRequest||!$('notification-channels-dialog').open)return; + renderNotificationCommon(config);notificationCommonLoaded=true;notificationCommonControls(true); + }catch(error){ + if(request!==notificationListRequest||!$('notification-channels-dialog').open)return; + $('notification-common-error').textContent=t(error.message);$('notification-common-retry').hidden=false; + } +} +async function loadNotificationChannelStatuses(request){ + for(const name of Object.keys(notificationChannelDetails))$('notification-status-'+name).textContent=t('读取中'); + const responses=await Promise.allSettled(Object.keys(notificationChannelDetails).map(name=>api('/api/notifications/channels/'+name))); + if(request!==notificationListRequest||!$('notification-channels-dialog').open)return; + for(const [index,name] of Object.keys(notificationChannelDetails).entries()){ + const result=responses[index],channel=notificationChannelDetails[name]; + const status=result.status==='rejected'?'读取失败':(name==='ntfy'?!!result.value.topic:!!result.value[channel.hasSecret])?(result.value[channel.enabled]?'已开启':'已关闭'):'未配置'; + $('notification-status-'+name).textContent=t(status); + } } $('pushplus-settings').onclick=async()=>{ - if(pushplusBusy)return;pushplusBusy=true;$('pushplus-settings').disabled=true; - try{renderPushplus(await api('/api/notifications/pushplus'));$('pushplus-error').textContent='';$('pushplus-dialog').showModal();} - catch(error){toast(error.message);} - finally{pushplusBusy=false;$('pushplus-settings').disabled=false;} + if(notificationChannelBusy)return; + const request=++notificationListRequest; + $('notification-channels-dialog').showModal(); + await Promise.all([loadNotificationCommon(request),loadNotificationChannelStatuses(request)]); +}; +$('notification-channels-dialog').addEventListener('close',()=>{notificationListRequest++;notificationCommonLoaded=false;notificationCommonControls(false);}); +$('notification-common-retry').onclick=()=>loadNotificationCommon(notificationListRequest); +for(const button of document.querySelectorAll('[data-notification-channel]'))button.onclick=async()=>{ + if(notificationChannelBusy)return; + const channel=button.dataset.notificationChannel; + const request=notificationListRequest; + notificationChannelBusy=true;button.disabled=true; + try{ + const config=await api('/api/notifications/channels/'+channel); + if(request!==notificationListRequest||!$('notification-channels-dialog').open)return; + selectedNotificationChannel=channel; + renderNotificationChannel(config); + $('pushplus-error').textContent=''; + $('notification-channels-dialog').close(); + $('pushplus-dialog').showModal(); + }catch(error){if(request===notificationListRequest&&$('notification-channels-dialog').open)$('notification-common-error').textContent=t(error.message);} + finally{notificationChannelBusy=false;button.disabled=false;} +}; +$('notification-channel-back').onclick=()=>{$('pushplus-dialog').close();$('pushplus-settings').onclick();}; +$('notification-common-save').onclick=async()=>{ + if(notificationChannelBusy||!notificationCommonLoaded||!$('notification-channels-dialog').open)return; + notificationChannelBusy=true;$('notification-common-save').disabled=true;$('notification-common-error').textContent=''; + const request=notificationListRequest; + try{ + const config=await api('/api/notifications/channels/common',{ + securityEnabled:$('notification-security-enabled').checked, + addressEnabled:$('notification-address-enabled').checked, + addressName:$('notification-address-name').value.trim(), + includeTitle:$('notification-include-title').checked, + clickBase:$('notification-click-base').value.trim() + }); + if(request===notificationListRequest&&$('notification-channels-dialog').open){renderNotificationCommon(config);toast('通用通知设置已保存');} + }catch(error){if(request===notificationListRequest&&$('notification-channels-dialog').open)$('notification-common-error').textContent=t(error.message);} + finally{notificationChannelBusy=false;if(request===notificationListRequest&¬ificationCommonLoaded)notificationCommonControls(true);} }; -async function pushplusAction(test){ - if(pushplusBusy)return;pushplusBusy=true; - for(const id of ['pushplus-save','pushplus-test','pushplus-enabled','pushplus-token','pushplus-clear'])$(id).disabled=true; +async function notificationChannelAction(test){ + if(notificationChannelBusy||!selectedNotificationChannel)return; + notificationChannelBusy=true; + const ids=['pushplus-save','pushplus-test','pushplus-enabled','notification-server','notification-topic','pushplus-token','pushplus-clear']; + for(const id of ids)$(id).disabled=true; $('pushplus-error').textContent=''; + const channel=notificationChannelDetails[selectedNotificationChannel]; try{ + const url='/api/notifications/channels/'+selectedNotificationChannel; if(test){ - await api('/api/notifications/pushplus/test',{});toast('PushPlus 已接受测试通知,请在手机确认是否收到'); + await api(url+'/test',{}); + toast(channel.name+' 已接受测试通知,请在手机确认是否收到'); }else{ - renderPushplus(await api('/api/notifications/pushplus',{pushplusEnabled:$('pushplus-enabled').checked,pushplusToken:$('pushplus-token').value.trim(),clearPushplusToken:$('pushplus-clear').checked})); - toast('PushPlus 配置已保存');if(currentId)await loadNotificationState(currentId,currentHost); + const body={[channel.enabled]:$('pushplus-enabled').checked,[channel.secret]:$('pushplus-token').value.trim(),[channel.clear]:$('pushplus-clear').checked}; + if(channel.server)body[channel.server]=$('notification-server').value.trim(); + if(selectedNotificationChannel==='ntfy')body.topic=$('notification-topic').value.trim(); + renderNotificationChannel(await api(url,body)); + toast(channel.name+' 配置已保存'); + if(currentId)await loadNotificationState(currentId,currentHost); } }catch(error){$('pushplus-error').textContent=t(error.message);} - finally{pushplusBusy=false;for(const id of ['pushplus-save','pushplus-test','pushplus-enabled','pushplus-token','pushplus-clear'])$(id).disabled=false;} + finally{notificationChannelBusy=false;for(const id of ids)$(id).disabled=false;} } -$('pushplus-form').onsubmit=event=>{event.preventDefault();return pushplusAction(false);}; -$('pushplus-test').onclick=()=>pushplusAction(true); +$('pushplus-form').onsubmit=event=>{event.preventDefault();return notificationChannelAction(false);}; +$('pushplus-test').onclick=()=>notificationChannelAction(true); const accountsPanel=window.BridgeSharedRelay||typeof AccountsPanel==='undefined'?null:new AccountsPanel({root:$('accounts-content'),toolbar:$('accounts-heading'),read:()=>api('/api/accounts'),request:({action,...value})=>action==='ignoreSubmission'?ignoreSubmission(value):api(action==='account'?'/api/accounts/account':action==='details'?'/api/accounts/details':action==='reminders'?'/api/accounts/reminders':action==='checkDesktopUpdate'||action==='requestDesktopUpdate'?'/api/accounts/desktop-update':'/api/accounts/switch',action==='checkDesktopUpdate'||action==='requestDesktopUpdate'?{action,...value}:value),onReset:async()=>{$('account-details').open=true;await accountPanel.refresh();$('account-content').querySelector('.account-cards')?.scrollIntoView({block:'nearest'});},onChanged:()=>{accountPanel.clear();resetModelCatalog();},onUpdate:value=>{accountPanel.schedule();const official=value.current?.kind==='chatgpt';$('account-button').hidden=!official;if(!official)$('account-details').open=false;const label='账号管理';$('accounts-button').setAttribute('data-i18n',label);$('accounts-button').textContent=t(label);}}); function openCurrentAccounts(){ diff --git a/web/shell/content.css b/web/shell/content.css index 2fbb175e..d11f5986 100644 --- a/web/shell/content.css +++ b/web/shell/content.css @@ -32,6 +32,7 @@ .phone-header{flex:none;display:flex;align-items:center;justify-content:space-between;gap:12px;padding:max(8px,env(safe-area-inset-top)) max(16px,env(safe-area-inset-right)) 8px max(16px,env(safe-area-inset-left));border-bottom:1px solid var(--line)}.phone-header .wordmark{gap:7px;font-size:12px}.phone-header .mark{width:26px;height:26px}.phone-header .light{font-size:9px;letter-spacing:.5px}.phone-language{display:flex;align-items:center;flex:none;font-size:11px;color:var(--muted)}.phone-language select{font:inherit;width:auto;min-height:32px;margin:0;padding:5px;border:1px solid var(--line);border-radius:6px;background:transparent;color:inherit}html[lang=en] .compose-bottom{flex-wrap:wrap;gap:8px} .picker .notification-option{display:flex;align-items:center;gap:10px;cursor:pointer}.notification-option input{margin:0;accent-color:var(--green)} +.notification-channel-list{display:grid;gap:8px}.notification-channel-list button{display:grid;grid-template-columns:minmax(0,1fr) auto auto;align-items:center;gap:10px;width:100%;padding:14px;border:1px solid var(--line);border-radius:10px;background:var(--surface);color:var(--text);font:inherit;text-align:left;cursor:pointer}.notification-channel-list button span{color:var(--muted);font-size:12px}.notification-channel-list .notification-channel-status{min-width:48px;text-align:right}.notification-common{border:0;border-top:1px solid var(--line);margin:20px 0 0;padding:16px 0 0}.notification-common legend{font-weight:600}.notification-common label{margin:12px 0}#notification-channel-back{padding-left:0} .message-actions{display:flex;gap:4px;flex-wrap:wrap;margin-top:8px}.message-actions button{font-size:12px;min-height:32px;padding:5px 8px;color:var(--muted)}.message-actions button:hover{color:var(--accent)}.code-block{position:relative;min-width:0}.code-block>pre{margin-top:0;padding-top:38px}.code-copy{position:absolute;right:5px;top:4px;font-size:12px;padding:3px 8px;min-height:28px}.copy-dialog textarea,#message-action-text{width:100%;box-sizing:border-box;resize:vertical;min-height:180px;max-height:55dvh}.copy-dialog textarea{height:45dvh;font:inherit}.copy-dialog h2{margin-top:0}#message-action-form{display:grid;gap:12px}#message-action-error:empty{display:none}#fork-source{max-width:100%;text-align:left;overflow-wrap:anywhere}