diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 0a2a844f..4cee6f99 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -30,7 +30,9 @@ HTTP 阅读请求先返回,保存历史和实时连接在后台加载,通过
SSH 参数包含非交互认证、严格主机指纹检查和禁用额外转发。远端模型/Skill 目录由远端 Codex 运行时读取,不复用 本机文件路径。
-本地与远端列表在合并后按最近交互时间排序、分页。项目优先采用显式归属,其次按最长工作目录前缀匹配。项目键带主机 ID,避免同名项目相互混淆。
+本地与远端列表在合并后按最近交互时间排序、分页。Codex 聊天列表每批读取 100 条,接近底部时自动续载;失败保留已加载内容,再次向下滚动可重试。项目优先采用显式归属,其次按最长工作目录前缀匹配。项目键带主机 ID,避免同名项目相互混淆。
+
+Codex 列表下拉刷新使用浮层提示与按动画帧更新的位移,不改变滚动区域高度;拖动期间暂停自动刷新。成功提示停留 500 毫秒后淡出,失败保留点击重试。列表展示内容未变化时保留已有聊天节点。
## 新建聊天
@@ -98,7 +100,7 @@ Windows 自动发现用户目录中的 App 运行时、常见安装目录、MSIX
桌面控制器通过白名单 Electron IPC 调用 `desktop.py` 的独立 JSON 工作进程;网关启停、更新和网络配置管理不监听网络。PushPlus 全局配置与测试允许通过手机 HTTP 操作,必须经过 Host、Origin、登录及写入 CSRF 校验,仅接受 PushPlus 字段,不返回 Token。该配置影响整个网关的关注聊天,不按登录设备隔离。打包版本执行随包提供的 PyInstaller 运行时,网关以独立后台进程运行,退出控制器不会停止网关。Windows 使用 NSIS 安装包或完整 ZIP 目录,避免临时自解压目录随控制器退出被删除而破坏后台运行时。
-Windows `desktop/shell/tray.cjs` 管理托盘状态、手机地址和两种退出动作;仅显式停止才调用网关的协作停止协议。主窗口关闭时隐藏,第二次启动恢复原窗口;退出前销毁托盘并允许窗口关闭。状态请求合并,菜单操作避免重复提交;停止失败保留控制器并显示错误。macOS 不创建此托盘,保留原窗口生命周期。
+Windows `desktop/shell/tray.cjs` 管理托盘状态、手机地址和两种退出动作;仅显式停止才调用网关的协作停止协议。主窗口关闭时隐藏,第二次启动恢复原窗口;退出前销毁托盘并允许窗口关闭。状态请求合并,菜单操作避免重复提交;停止失败保留控制器并显示错误。macOS 使用同一菜单提供菜单栏入口,关闭控制面板后仍可从菜单栏或 Dock 重新打开。登录自启动由用户在高级设置中启用:Windows/macOS 使用系统登录项,Linux 已安装的桌面包使用用户 XDG autostart;AppImage 注册原始文件路径,不注册临时挂载目录。Windows/macOS 登录启动隐藏控制面板,Linux 保持窗口可见。启用登录自启动同时启用网关自动启动;关闭登录项保留后者。
网关停止使用带随机实例令牌的本地控制文件,由服务主动调用 `shutdown`,再关闭隧道、IPC 和 HTTP。`stop.py --config` 与启动配置对应,不依赖 Unix 信号或 Windows PID 强制终止。文件统一使用 UTF-8;Windows 权限继承目录 ACL。
diff --git a/README.md b/README.md
index 176f8d15..ad4e6b4e 100644
--- a/README.md
+++ b/README.md
@@ -5,11 +5,11 @@
# Codex Mobile Bridge · 多客户端远程工作台
-> **v2.0.0 正式版**:[发布与下载](https://github.com/try2love/codex-mobile-bridge/releases/tag/v2.0.0) · [完整更新说明](RELEASE_NOTES.md) · [手机安装说明](mobile/README.md)。从 v1.4.0 的 Codex 手机网关,扩展为 Codex、Claude Desktop 与 DeepSeek Harness 的统一远程入口。
+> **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 的统一远程入口。
[简体中文](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.0)**
+**[双语介绍与使用演示 ↗](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)
@@ -47,18 +47,18 @@
## 下载与快速开始(推荐)
-日常使用直接下载桌面 App,**无需 Agent 帮忙部署,也无需安装 Python、Node.js 或打开终端**。当前版本为 **v2.0.0 正式版**。
+日常使用直接下载桌面 App,**无需 Agent 帮忙部署,也无需安装 Python、Node.js 或打开终端**。当前版本为 **v2.0.2 正式版**。
| 系统 | 下载 | 打开方式 |
| --- | --- | --- |
-| Windows x64(推荐安装包) | [下载 Setup.exe](https://github.com/try2love/codex-mobile-bridge/releases/download/v2.0.0/Codex-Mobile-Bridge-2.0.0-Windows-x64-Setup.exe) | 运行安装包,从快捷方式打开 |
-| Windows x64(免安装) | [下载完整 ZIP](https://github.com/try2love/codex-mobile-bridge/releases/download/v2.0.0/Codex-Mobile-Bridge-2.0.0-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.0/Codex-Mobile-Bridge-2.0.0-macOS-arm64.dmg) | 打开 DMG,将 App 拖入“应用程序” |
-| macOS Intel(英特尔) | [下载 x64 DMG](https://github.com/try2love/codex-mobile-bridge/releases/download/v2.0.0/Codex-Mobile-Bridge-2.0.0-macOS-x64.dmg) | 打开 DMG,将 App 拖入“应用程序” |
-| Ubuntu x64(实验性) | [下载 .deb](https://github.com/try2love/codex-mobile-bridge/releases/download/v2.0.0/Codex-Mobile-Bridge-2.0.0-Linux-amd64.deb) | Ubuntu 22.04;安装后以普通用户启动 |
-| Ubuntu ARM64(实验性) | [下载 .deb](https://github.com/try2love/codex-mobile-bridge/releases/download/v2.0.0/Codex-Mobile-Bridge-2.0.0-Linux-arm64.deb) | Ubuntu 22.04 ARM64;不含 32 位 ARM |
+| 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 |
-[查看所有版本与更新说明](https://github.com/try2love/codex-mobile-bridge/releases) · [下载 SHA256 校验文件](https://github.com/try2love/codex-mobile-bridge/releases/download/v2.0.0/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.2/SHA256SUMS.txt)。两种 Mac 同时提供 ZIP,供应用内更新或手动替换使用。Windows ARM 暂无专用安装包。Mac 版采用本地完整性签名但未公证,首次打开可能需要手动允许;Windows 版未做证书签名。详见下方 macOS 首次打开说明。
1. 安装并登录要使用的桌面客户端,打开 **Codex Mobile Bridge**。在 **客户端与接入** 检查扫描结果,完成需要的授权,并启用配置就绪的客户端。
2. 在 **网络访问** 保留局域网访问或添加 HTTPS 入口,保存后从概览或客户端页点击 **启动网关**。已有配置时沿用原端口。
@@ -67,7 +67,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.0 Release](https://github.com/try2love/codex-mobile-bridge/releases/tag/v2.0.0)。Android 覆盖安装以保留数据;iOS 需自行签名,暂无 App Store/TestFlight 分发。源码运行与自定义部署见后面的[部署 Agent 执行说明](#agent-deployment)。
+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)。
@@ -77,7 +77,7 @@ Android APK、iOS 未签名 IPA 与 Xcode 源码包见同一 [v2.0.0 Release](ht
从 `v0.2.0-beta.5` 起,可在桌面 App 的「应用更新」中检查新版、查看说明并点击「更新并重启」。下载与校验完成后短暂重启网关,保留登录、网络、通知和关注聊天配置;失败时尝试恢复原版本。此前版本需要先手动安装一次支持更新的版本。使用临时 HTTPS 时,重启后请打开最新地址。发布与恢复说明见 [桌面更新文档](docs/desktop-updates.md)。
-**Windows beta.7 / 1.0.0 用户:**旧更新器可能因目录占用报 `WinError 32` 并回退。请先在托盘选择“停止网关并退出”,再用 **2.0.0 Setup.exe** 安装到原位置;无需卸载或删除数据。1.1.0 修复后续更新的目录占用问题。
+**Windows beta.7 / 1.0.0 用户:**旧更新器可能因目录占用报 `WinError 32` 并回退。请先在托盘选择“停止网关并退出”,再用 **2.0.2 Setup.exe** 安装到原位置;无需卸载或删除数据。1.1.0 修复后续更新的目录占用问题。
### 手机阅读与显示设置
@@ -120,10 +120,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.0 的 M 系列 Mac DMG 为例,其他文件请替换文件名:
+1. 从本仓库的 Release 下载对应芯片的 DMG 或 ZIP,以及 `SHA256SUMS.txt`。计算下载文件的 SHA-256,与校验文件中同名文件的一行比较;不一致时重新下载,不要放行。以下以 2.0.2 的 M 系列 Mac DMG 为例,其他文件请替换文件名:
```sh
- shasum -a 256 "$HOME/Downloads/Codex-Mobile-Bridge-2.0.0-macOS-arm64.dmg"
+ shasum -a 256 "$HOME/Downloads/Codex-Mobile-Bridge-2.0.2-macOS-arm64.dmg"
```
2. 打开 DMG,将 `Codex Mobile Bridge.app` 拖入“应用程序”,再推出磁盘映像;ZIP 则先解压并移动 App。尝试从“应用程序”打开后,前往 **系统设置 → 隐私与安全性 → 仍要打开**,按系统提示确认。
diff --git a/README_EN.md b/README_EN.md
index ccf4cc19..a7911e58 100644
--- a/README_EN.md
+++ b/README_EN.md
@@ -5,11 +5,11 @@
# Codex Mobile Bridge · A remote workbench for desktop agents
-> **v2.0.0 stable**: [Release and downloads](https://github.com/try2love/codex-mobile-bridge/releases/tag/v2.0.0) · [Full release notes](RELEASE_NOTES.md) · [Mobile installation](mobile/README.md). The v1.4.0 Codex gateway grows into one remote entry for Codex, Claude Desktop and DeepSeek Harness.
+> **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.
[简体中文](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.0)**
+**[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)**
[](https://try2love.github.io/codex-mobile-bridge/?lang=en)
@@ -47,18 +47,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.0 (stable)**.
+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)**.
| Platform | Download | Open |
| --- | --- | --- |
-| Windows x64 (recommended installer) | [Download Setup.exe](https://github.com/try2love/codex-mobile-bridge/releases/download/v2.0.0/Codex-Mobile-Bridge-2.0.0-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.0/Codex-Mobile-Bridge-2.0.0-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.0/Codex-Mobile-Bridge-2.0.0-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.0/Codex-Mobile-Bridge-2.0.0-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.0/Codex-Mobile-Bridge-2.0.0-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.0/Codex-Mobile-Bridge-2.0.0-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.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 |
-[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.0/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.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.
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 +67,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.0 Release](https://github.com/try2love/codex-mobile-bridge/releases/tag/v2.0.0). 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.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).
### In-app updates
@@ -75,7 +75,7 @@ Android APK, unsigned iOS IPA and Xcode source downloads are in the same [v2.0.0
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.0 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.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.
### Phone reading and display settings
@@ -115,10 +115,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.0 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.2 Apple Silicon DMG; replace the filename for other downloads.
```sh
- shasum -a 256 "$HOME/Downloads/Codex-Mobile-Bridge-2.0.0-macOS-arm64.dmg"
+ shasum -a 256 "$HOME/Downloads/Codex-Mobile-Bridge-2.0.2-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.
diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md
index 8c69d096..58e1cbdf 100644
--- a/RELEASE_NOTES.md
+++ b/RELEASE_NOTES.md
@@ -1,3 +1,27 @@
+## v2.0.2 · 文件兼容与聊天列表体验优化
+
+- **聊天列表更连贯**:Codex 列表滚动接近底部时自动加载后续聊天,移除常驻“加载更多”按钮。下拉刷新提示不再挤压列表,手势跟随与回弹更顺滑,成功提示更快收起;内容未变化时保留已有列表节点和阅读位置。
+- **Windows 文件链接更可靠**:兼容盘符、反斜杠、file URL 和长路径前缀;同一文件的不同引用共用下载入口。继续使用现有工作区授权与可配置下载上限。
+- **手机下载后直接打开**:Android 和 iOS 可通过系统支持的应用打开已下载文件,同时保留保存入口;系统区域颜色随当前页面变化。
+- **跨平台登录自启**:Windows、macOS 和已安装的 Linux 桌面包可在高级设置中启用。Windows/macOS 登录后收起控制面板,macOS 可从菜单栏或 Dock 恢复;Linux 保持窗口可见,AppImage 使用原文件路径。
+- 保留 v2.0.1 的 macOS 异常应用 plist 扫描修复。
+
+感谢 [@heiqiu47](https://github.com/heiqiu47) 在 [PR #24](https://github.com/try2love/codex-mobile-bridge/pull/24) 中贡献的文件兼容、手机文件打开、系统栏与登录自启改进。本版按现有 main 的文件与认证边界整合,并扩展跨平台适配。
+
+**升级**:建议网关与手机 App 一起更新至 v2.0.2,以使用完整改进。聊天列表优化由网关提供,升级后重新加载页面即可。Android 覆盖安装以保留已保存电脑与登录;iOS IPA 仍需自行签名,另附 Xcode 源码包。Linux 仍为实验性支持,请保留数据目录后手动安装。下载文件可用 `SHA256SUMS.txt` 校验。
+
+### English
+
+- Load more Codex chats automatically near the bottom, remove the persistent load-more button, and make pull-to-refresh smoother with a shorter success notice. Keep unchanged list nodes and reading position.
+- Improve Windows drive paths, backslashes, file URLs and long-path aliases while retaining workspace authorization and configurable download limits.
+- Open downloaded files through supported system apps on Android and iOS, retain Save, and match system-area colors to the current page.
+- Add opt-in login startup for Windows, macOS and installed Linux desktop packages. Windows/macOS hide the control panel on login; Linux keeps it visible. macOS provides a menu-bar entry, and AppImage startup uses its original file path.
+- Retain the v2.0.1 macOS malformed-plist discovery fix.
+
+Thanks to [@heiqiu47](https://github.com/heiqiu47) for [PR #24](https://github.com/try2love/codex-mobile-bridge/pull/24). Update both the gateway and mobile app for all improvements. Install Android over the existing app; the unsigned iOS IPA requires your own signing. Linux remains experimental. Verify downloads with `SHA256SUMS.txt`.
+
+---
+
## v2.0.1 · 修复 macOS 客户端扫描中断
- 修复 macOS 扫描应用时,某个应用的 `Info.plist` 存在 XML 格式错误,导致整轮客户端发现中断、已启用客户端无法继续自动接入的问题。
diff --git a/bridge/api/httpd.py b/bridge/api/httpd.py
index 8295f5aa..8e3ac82f 100644
--- a/bridge/api/httpd.py
+++ b/bridge/api/httpd.py
@@ -18,6 +18,7 @@
from bridge.features.workspace.uploads import MAX_FILE
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 socketserver import TCPServer
from urllib.parse import parse_qs, urlsplit, quote
@@ -790,9 +791,8 @@ def download(self, bridge, thread_id, artifact_id, info=False):
if info:
result = workspace.info(relative)
cwd = bridge.store.get(thread_id).get('cwd')
- root = Path(cwd).resolve() if cwd else None
- result['locatable'] = root is not None and root in path.parents
- result['path'] = path.relative_to(root).as_posix() if result['locatable'] else None
+ result['path'] = workspace_relative_path(path, cwd)
+ result['locatable'] = result['path'] is not None
return self.output(200, {**result, **policy})
# Walk the authorized absolute path without following replaced parent
# symlinks. Inline images retain their independent preview size bound.
diff --git a/bridge/app/service.py b/bridge/app/service.py
index e1a95972..de571b87 100644
--- a/bridge/app/service.py
+++ b/bridge/app/service.py
@@ -1198,7 +1198,7 @@ def view(self, thread_id, attach=True, background=False, force=False):
view["goalActivationIds"] = sorted(self._goal_activation_ids(thread_id))
with session.condition:
artifacts = artifact_paths(session.state or {}, self.store.home) if self.host == "local" else {}
- view["files"] = [{"id": k, "name": v["name"], "reference": v["reference"], "image": v["image"]} for k, v in artifacts.items()]
+ view["files"] = [{"id": k, "name": v["name"], "reference": v["reference"], "references": v["references"], "image": v["image"]} for k, v in artifacts.items()]
self._overlay_native_goal(session, thread_id, view)
self._submission_meta(session, view)
view["forkedFrom"] = self._fork_origin(thread_id)
@@ -1504,14 +1504,14 @@ def timeline_read(self, thread_id, mode='page', **options):
cwd = (session.state or {}).get('cwd')
media_items = [item for turn in ordered_turns(session.state or {})
for item in items_array(turn.get('items')) if item.get('type') in ('ImageView', 'imageView')]
- # Resolve links in the delivered page, plus ImageView evidence needed by
- # embedded model screenshots that can live outside workspace roots.
+ # Resolve links in the delivered page, plus runtime ImageView evidence
+ # retained separately from the paged message text.
file_state = {'cwd': cwd, 'turns': [
{'items': [{'type': 'agentMessage', 'text': text} for text in texts]},
{'items': media_items},
]}
artifacts = artifact_paths(file_state, self.store.home) if self.host == 'local' else {}
- result['files'] = [{'id': k, 'name': v['name'], 'reference': v['reference'], 'image': v['image']} for k, v in artifacts.items()]
+ result['files'] = [{'id': k, 'name': v['name'], 'reference': v['reference'], 'references': v['references'], 'image': v['image']} for k, v in artifacts.items()]
if mode != 'detail':
self._submission_meta(session, result['meta'])
result['meta']['forkedFrom'] = self._fork_origin(thread_id)
diff --git a/bridge/features/workspace/files.py b/bridge/features/workspace/files.py
index 17648bb2..94ee602b 100644
--- a/bridge/features/workspace/files.py
+++ b/bridge/features/workspace/files.py
@@ -19,15 +19,36 @@ def path_identity(value):
"""Return the host-normalized identity used to link duplicate references."""
path = value if isinstance(value, Path) else Path(value)
try:
- return Path(os.path.normcase(str(path.resolve(strict=False))))
+ return Path(os.path.normcase(_without_windows_prefix(str(path.resolve(strict=False)))))
except (OSError, RuntimeError):
- return Path(os.path.normcase(os.path.abspath(str(path))))
+ return Path(os.path.normcase(_without_windows_prefix(os.path.abspath(str(path)))))
+
+
+def _without_windows_prefix(value):
+ if os.name == 'nt':
+ if value.startswith('\\\\?\\UNC\\'):
+ return '\\\\' + value[8:]
+ if value.startswith('\\\\?\\') and re.match(r'[A-Za-z]:\\', value[4:]):
+ return value[4:]
+ return value
+
+
+def workspace_relative_path(path, cwd):
+ """Locate an artifact inside the resolved workspace, including Windows aliases."""
+ if not cwd:
+ return None
+ path = Path(_without_windows_prefix(str(Path(path).resolve())))
+ root = Path(_without_windows_prefix(str(Path(cwd).resolve())))
+ return path.relative_to(root).as_posix() if root in path.parents else None
def reference_path(value, allow_relative=False):
"""Resolve a local file reference without allowing remote URL access."""
if not isinstance(value, str) or not value:
return None
+ # Chat links may prefix Windows drive paths with a URL-style slash.
+ if os.name == 'nt' and re.fullmatch(r'/[A-Za-z]:[\\/].*', value):
+ value = value[1:]
try:
parsed = urlsplit(value)
if parsed.scheme == 'file':
@@ -122,7 +143,7 @@ def artifact_paths(state, codex_home):
candidates.add(value)
result = {}
model_images, trusted_views = _model_image_references(state)
- for raw in candidates:
+ for raw in sorted(candidates):
# Model-authored Markdown and response attachments remain bound to the
# workspace/visualization roots even when they name an image suffix.
value = re.sub(r':\d+$', '', raw)
@@ -131,14 +152,15 @@ def artifact_paths(state, codex_home):
continue
try:
path = ((Path(state['cwd']) / path) if not path.is_absolute() and state.get('cwd') else path).resolve(strict=True)
- if not any(root in path.parents for root in roots) or not (path.is_file() or path.is_dir()):
+ if not any(workspace_relative_path(path, root) is not None for root in roots) or not (path.is_file() or path.is_dir()):
continue
except OSError:
continue
key = hashlib.sha256(str(path_identity(path)).encode()).hexdigest()
- result[key] = {'path': path, 'reference': raw, 'name': path.name,
+ artifact = result.setdefault(key, {'path': path, 'reference': raw, 'references': [], 'name': path.name,
'kind': 'directory' if path.is_dir() else 'file',
- 'image': path.is_file() and path.suffix.lower() in IMAGE_SUFFIXES}
+ 'image': path.is_file() and path.suffix.lower() in IMAGE_SUFFIXES})
+ artifact['references'].append(raw)
# A path can be shown once as ImageView and embedded later with another text
# form (usually a plain absolute path versus file://). Only the runtime view
# grants the outside-workspace exception; collect all references so Markdown
@@ -158,7 +180,8 @@ def artifact_paths(state, codex_home):
file_refs = sorted(ref for ref in references if urlsplit(ref).scheme == 'file')
reference = plain[0] if plain else (file_refs[0] if file_refs else next(iter(references)))
key = hashlib.sha256(str(path).encode()).hexdigest()
- result[key] = {'path': path, 'reference': reference, 'name': path.name,
+ references.update(result.get(key, {}).get('references', []))
+ result[key] = {'path': path, 'reference': reference, 'references': sorted(references), 'name': path.name,
'kind': 'directory' if path.is_dir() else 'file',
'image': path.is_file() and path.suffix.lower() in IMAGE_SUFFIXES}
return result
@@ -171,5 +194,6 @@ def workspace_references(messages, cwd):
for row in messages if row.get('role') == 'assistant']} ]}
root = Path(cwd).resolve()
return [{'id': key, 'name': value['name'], 'reference': value['reference'],
- 'path': value['path'].relative_to(root).as_posix(), 'kind': value['kind'], 'image': False}
+ 'references': value['references'], 'path': workspace_relative_path(value['path'], root),
+ 'kind': value['kind'], 'image': False}
for key, value in artifact_paths(state, None).items()]
diff --git a/desktop/index.html b/desktop/index.html
index c6a674d9..d429029c 100644
--- a/desktop/index.html
+++ b/desktop/index.html
@@ -35,7 +35,7 @@
手动填写的跳转地址优先;留空自动选择网关入口。局域网链接需要手机在同一网络。临时域名变化后,旧通知的链接可能失效。
已关注聊天
在手机端添加监控;在这里管理完成通知或删除监控。删除监控不会删除会话。
-启动与数据目录 打开 App 时自动启动网关
打开目录 选择已有网关目录
目录内保存网关配置、通知 Token、登录凭据和运行日志。
Cloudflare 组件 桌面 App 已内置 cloudflared,临时和固定隧道均可直接使用。添加连接请前往“网络与登录”。
高级:自定义程序与故障修复 cloudflared 可执行文件 选择
一键下载并安装 检测已安装程序 官方安装教程 ↗
从 Cloudflare 官方 GitHub 下载适配本机的程序并核对 SHA-256,安装在网关数据目录。无需管理员权限,不修改系统 PATH。下载通常需要几十秒,最长等待 3 分钟。
下载或连接失败? 无法访问 GitHub:检查电脑网络或系统代理,稍后重试;也可打开官方教程,下载对应系统与架构的程序。Mac 解压后选择 cloudflared,Windows 选择 cloudflared.exe,再检测并保存。
程序已就绪但隧道失败:查看“运行日志”中的 Cloudflare 部分。网络需允许向外连接 TCP 7844;网关启动后局域网可独立使用,无需等待临时 HTTPS。
高级路径设置(通常无需修改) Codex 程序与 IPC 地址会自动发现;仅在自动发现失败或使用自定义环境时修改。
Codex 数据目录 选择
桌面 IPC 地址 Codex 可执行文件 选择
网络和运行路径在网关停止后可修改。模型、提供商和 Skill 沿用原 Codex 会话设置。
配置已保存 保存配置
+配置已保存 保存配置
登录设备 刷新设备列表 每条记录代表一次浏览器登录。撤销登录会要求重新验证;封禁 IP 会同时阻止使用该 IP 的所有设备,包括重新登录。
自动封禁 IP 同一 IP 连续输错密码 5 次后永久封禁,重启不会清除。解除后恢复 5 次尝试;白名单不会绕过自动封禁。
网页无法读取网卡 MAC。手机可能使用私有 MAC,IP 也会随网络变化;共用公网 IP 的设备会一起受到 IP 规则影响。
IP 访问规则
diff --git a/desktop/login-startup.cjs b/desktop/login-startup.cjs
new file mode 100644
index 00000000..17e64f98
--- /dev/null
+++ b/desktop/login-startup.cjs
@@ -0,0 +1,78 @@
+'use strict';
+const path=require('node:path');
+const fs=require('node:fs');
+const os=require('node:os');
+const developmentConfig=root=>path.join(root,'.local','desktop-login-startup.json');
+const argument='--login-startup';
+function restoreDevelopmentEnvironment(app,argv=process.argv,env=process.env,platform=process.platform,root=path.resolve(__dirname,'..')){
+ if(platform!=='win32'||app.isPackaged||!argv.includes(argument))return;
+ if(fs.existsSync(developmentConfig(root))){
+ const saved=JSON.parse(fs.readFileSync(developmentConfig(root),'utf8'));
+ for(const key of ['CMB_DATA_DIR','CMB_PYTHON'])if(typeof saved[key]==='string'&&saved[key])env[key]=saved[key];
+ }
+ env.PYTHONUTF8='1';
+}
+function createLoginStartup(app,{platform=process.platform,executable=process.execPath,root=path.resolve(__dirname,'..'),env=process.env,home=os.homedir(),cwd=process.cwd()}={}){
+ const supported=platform==='win32'||(['darwin','linux'].includes(platform)&&app.isPackaged);
+ // Electron development launches need the project entry and the same gateway environment.
+ const args=app.isPackaged?[argument]:[root,argument];
+ const options={path:executable,args,name:app.isPackaged?'Codex Mobile Bridge':'Codex Mobile Bridge Development'};
+ const config=env.XDG_CONFIG_HOME&&path.isAbsolute(env.XDG_CONFIG_HOME)?env.XDG_CONFIG_HOME:path.join(home,'.config');
+ const desktopFile=path.join(config,'autostart','codex-mobile-bridge.desktop');
+ const linuxExecutable=env.APPIMAGE||executable;
+ function linuxCommand(){
+ if(!path.isAbsolute(linuxExecutable)||/[\r\n\0=]/.test(linuxExecutable)||(!env.APPIMAGE&&linuxExecutable.includes('/.mount_')))
+ throw Error('请将应用安装到固定目录后设置登录自启。');
+ // Desktop Entry string escaping is applied before Exec argument unquoting.
+ const quoted='"'+linuxExecutable.replace(/["`$\\]/g,'\\$&').replace(/%/g,'%%')+'"';
+ return quoted.replace(/\\/g,'\\\\')+' '+argument;
+ }
+ function status(){
+ if(!supported)return {supported:false,enabled:false};
+ if(platform==='darwin'){
+ const settings=app.getLoginItemSettings();
+ return {supported:true,enabled:!!settings.openAtLogin,requiresApproval:settings.status==='requires-approval'};
+ }
+ if(platform==='linux'){
+ try{
+ const entry=fs.readFileSync(desktopFile,'utf8').split('[Desktop Entry]')[1]?.split(/\n\[/)[0]||'';
+ const values=Object.fromEntries(entry.split(/\r?\n/).filter(line=>line.includes('=')).map(line=>[line.slice(0,line.indexOf('=')).trim(),line.slice(line.indexOf('=')+1).trim()]));
+ return {supported:true,enabled:values.Type==='Application'&&values.Hidden!=='true'&&values['X-GNOME-Autostart-enabled']!=='false'&&values.Exec===linuxCommand()};
+ }catch(error){if(error.code==='ENOENT')return {supported:true,enabled:false};throw error;}
+ }
+ let settings=app.getLoginItemSettings(options);
+ // Electron 44's Windows lookup misses quoted executable paths containing spaces.
+ if(executable.includes(' ')&&!settings.launchItems?.some(item=>item.name===options.name)){
+ settings=app.getLoginItemSettings({...options,path:'"'+executable+'"'});
+ }
+ // openAtLogin queries Electron's default entry name, even when set uses a custom name.
+ const enabled=settings.launchItems?.some(item=>item.name===options.name&&item.scope==='user'&&item.enabled);
+ return {supported:true,enabled:!!enabled};
+ }
+ function validate(enabled){
+ if(typeof enabled!=='boolean')throw Error('登录自启开关格式错误');
+ if(!supported)throw Error('请使用已安装的桌面应用设置登录自启。');
+ if(platform==='linux')linuxCommand();
+ }
+ function set(enabled){
+ validate(enabled);
+ if(enabled&&!app.isPackaged){
+ // Keep Python and data paths out of Windows Run's 260-character command limit.
+ const paths=platform==='win32'?path.win32:path;
+ const saved={};for(const key of ['CMB_DATA_DIR','CMB_PYTHON'])if(env[key]){
+ // A bare interpreter name still resolves through PATH; relative paths must not depend on login cwd.
+ saved[key]=key==='CMB_PYTHON'&&!/[\\/]/.test(env[key])?env[key]:paths.isAbsolute(env[key])?env[key]:paths.resolve(cwd,env[key]);
+ }
+ fs.mkdirSync(path.dirname(developmentConfig(root)),{recursive:true});
+ fs.writeFileSync(developmentConfig(root),JSON.stringify(saved),{mode:0o600});
+ }
+ if(platform==='linux'){
+ fs.mkdirSync(path.dirname(desktopFile),{recursive:true,mode:0o700});
+ fs.writeFileSync(desktopFile,'[Desktop Entry]\nType=Application\nName=Codex Mobile Bridge\nExec='+linuxCommand()+'\nTerminal=false\nHidden='+!enabled+'\n',{mode:0o600});
+ }else app.setLoginItemSettings(platform==='darwin'?{openAtLogin:enabled}:{...options,openAtLogin:enabled});
+ const current=status();
+ if(current.enabled!==enabled&&!(enabled&¤t.requiresApproval))throw Error('登录自启设置保存失败,请重试。');
+ }
+ return {status,validate,set,hidden:(argv,initial=false)=>supported&&platform!=='linux'&&(argv.includes(argument)||(initial&&platform==='darwin'&&!!app.getLoginItemSettings().wasOpenedAtLogin))};
+}
+module.exports={createLoginStartup,restoreDevelopmentEnvironment};
diff --git a/desktop/main.cjs b/desktop/main.cjs
index 1e7aac07..ba73ee0a 100644
--- a/desktop/main.cjs
+++ b/desktop/main.cjs
@@ -1,5 +1,5 @@
'use strict';
-const {app,BrowserWindow,ipcMain,dialog,shell,clipboard,Tray,Menu,net}=require('electron');
+const {app,BrowserWindow,ipcMain,dialog,shell,clipboard,Tray,Menu,net,nativeImage}=require('electron');
const path=require('node:path');
const fs=require('node:fs');
const {pathToFileURL}=require('node:url');
@@ -10,6 +10,9 @@ const {normalize,translate}=require('./shared/i18n.js');
const {publishLanguage}=require('./shared/gateway-language.cjs');
const cloudflared=require('./features/connections/cloudflared.cjs');
const {Updater,allowedMirrorUrl}=require('./features/updates/updater.cjs');
+const {createLoginStartup,restoreDevelopmentEnvironment}=require('./login-startup.cjs');
+restoreDevelopmentEnvironment(app);
+const loginStartup=createLoginStartup(app);
const {spawn}=require('node:child_process');
const {randomUUID}=require('node:crypto');
const {interfaces}=require('./features/connections/network.cjs');
@@ -52,7 +55,7 @@ function worker(action,payload){
if(writes)workerWrites++;
if(action==='snapshot'&&snapshotPending)return snapshotPending;
const options=workerFor({packaged:app.isPackaged,resources:process.resourcesPath,root,dataDir});
- const result=(action==='snapshot'&&!updater?.busy?snapshotWorker.read(options):['accounts','account','desktop-sessions','start','stop','save'].includes(action)?managementWorker.call(options,action,payload):runWorker(options,action,payload)).then(value=>['snapshot','save'].includes(action)?{...value,networkInterfaces:interfaces(),cloudflaredInstall:installStatus,update:updater?.status(),updateResult:updateResult(),updateManaged:updateManaged()}:value).finally(()=>{if(writes)workerWrites--;});
+ const result=(action==='snapshot'&&!updater?.busy?snapshotWorker.read(options):['accounts','account','desktop-sessions','start','stop','save'].includes(action)?managementWorker.call(options,action,payload):runWorker(options,action,payload)).then(value=>['snapshot','save'].includes(action)?{...value,networkInterfaces:interfaces(),cloudflaredInstall:installStatus,update:updater?.status(),updateResult:updateResult(),updateManaged:updateManaged(),loginStartup:loginStartup.status()}:value).finally(()=>{if(writes)workerWrites--;});
if(action==='snapshot')snapshotPending=result.then(value=>{lastSnapshot=value;return value;}).finally(()=>{snapshotPending=null;});
return action==='snapshot'?snapshotPending:result;
}
@@ -130,7 +133,15 @@ function register(){
}
if(action==='desktop-sessions'&&payload?.action==='copy-claude-workspace'){const value=await worker(action,{action:'status'});clipboard.writeText(value.claudeWorkspace);return {copied:true};}
if(action==='desktop-sessions'&&payload?.action==='prepare-claude'){const value=await worker(action,payload);clipboard.writeText(value.script);delete value.script;return value;}
- try{return await worker(action,payload);}catch(error){if(error.validation)return {validationError:{message:error.message,...error.validation}};throw error;}
+ if(action==='save'&&payload?.loginStartup!==undefined){
+ loginStartup.validate(payload.loginStartup);
+ if(payload.loginStartup)payload={...payload,preferences:{...payload.preferences,autoStart:true}};
+ }
+ try{
+ const value=await worker(action,payload);
+ if(action==='save'&&payload?.loginStartup!==undefined){loginStartup.set(payload.loginStartup);value.loginStartup=loginStartup.status();}
+ return value;
+ }catch(error){if(error.validation)return {validationError:{message:error.message,...error.validation}};throw error;}
});
ipcMain.handle('bridge:tailscale-authorize',async(event,value)=>{
authorize(event);
@@ -270,8 +281,8 @@ function register(){
clipboard.writeText(target);
});
}
-function createWindow(){
- window=new BrowserWindow({width:1100,height:850,minWidth:820,minHeight:640,title:t('Codex 手机网关'),icon:path.join(__dirname,'assets/icon.png'),backgroundColor:'#f6f7f9',webPreferences:{preload:path.join(__dirname,'preload.cjs'),nodeIntegration:false,contextIsolation:true,sandbox:true}});
+function createWindow(hidden=false){
+ window=new BrowserWindow({show:!hidden,width:1100,height:850,minWidth:820,minHeight:640,title:t('Codex 手机网关'),icon:path.join(__dirname,'assets/icon.png'),backgroundColor:'#f6f7f9',webPreferences:{preload:path.join(__dirname,'preload.cjs'),nodeIntegration:false,contextIsolation:true,sandbox:true}});
// The native title is owned by the main process, including locale changes.
window.on('page-title-updated',event=>event.preventDefault());
window.webContents.setWindowOpenHandler(()=>({action:'deny'}));
@@ -290,18 +301,19 @@ function createWindow(){
}
if(!app.requestSingleInstanceLock())app.quit();
else{
- app.on('second-instance',showWindow);
+ app.on('second-instance',(_event,argv)=>{if(!loginStartup.hidden(argv))showWindow();});
app.whenReady().then(()=>{
language=normalize(app.getLocale());
try{language=normalize(JSON.parse(fs.readFileSync(path.join(app.getPath('userData'),'language.json'),'utf8')).language);}catch{}
dataDir=loadDataDir();publishLanguage(dataDir,language);setupUpdater();register();
- if(process.platform==='win32'){
- app.setAppUserModelId('io.github.try2love.codexmobilebridge');
- tray=createTray({Tray,Menu,icon:path.join(__dirname,'assets/icon.ico'),show:showWindow,worker,t,
+ if(process.platform==='win32')app.setAppUserModelId('io.github.try2love.codexmobilebridge');
+ if(['win32','darwin'].includes(process.platform)){
+ const icon=process.platform==='darwin'?nativeImage.createFromPath(path.join(__dirname,'assets/icon.png')).resize({width:18,height:18}):path.join(__dirname,'assets/icon.ico');
+ tray=createTray({Tray,Menu,icon,show:showWindow,worker,t,
open:url=>shell.openExternal(url),copy:url=>clipboard.writeText(url),quit:()=>app.quit(),
onError:error=>{showWindow();dialog.showErrorBox(t('网关操作未完成'),t(error.message));}});
}
- createWindow();
+ createWindow(!!tray&&loginStartup.hidden(process.argv,true));
});
app.on('before-quit',releaseTray);
app.on('activate',showWindow);
diff --git a/desktop/shell/renderer.js b/desktop/shell/renderer.js
index 7ab7911d..a087a9a4 100644
--- a/desktop/shell/renderer.js
+++ b/desktop/shell/renderer.js
@@ -87,6 +87,7 @@ function renderLan(){
if(!rows.length){const note=document.createElement('p');note.className='hint';note.textContent=t('未发现可用的局域网 IPv4 地址');$('lan-addresses').append(note);}
$('lan-scope').disabled=!!snapshot?.runtime.running||!$('lan').checked;
}
+$('login-startup').onchange=()=>{if($('login-startup').checked)$('auto-start').checked=true;$('auto-start').disabled=$('login-startup').checked;updateDirty();};
$('lan').onchange=()=>{renderLan();updateDirty();};
$('lan-scope').onchange=()=>{renderLan();updateDirty();};
function input(id,value){$(id).value=value??'';}
@@ -97,6 +98,8 @@ function renderGatewayActions(){
if(snapshot)window.GatewayLayout?.update(snapshot,{starting,stopping:stopPending});
}
function render(value,watchRevision=watchPanel.revision){
+ $('login-startup-setting').hidden=$('login-startup-hint').hidden=!value.loginStartup?.supported;
+ $('login-startup-hint').textContent=t(value.loginStartup?.requiresApproval?'请在系统设置的登录项中允许此应用。':'开启后,登录电脑时自动启动网关。');
if(snapshot&&(snapshot.runtime.instanceId!==value.runtime.instanceId||snapshot.dataDir!==value.dataDir))resetPairing();
if(snapshot&&snapshot.dataDir!==value.dataDir){resetSecretFields();harnessPanel?.clear();if(activeTab==='accounts')harnessPanel?.refresh();watchPanel.clear();watchRevision=watchPanel.revision;devicesState=null;devicesDirty=false;$('device-list').replaceChildren();$('auto-blocks').replaceChildren();}
if(snapshot&&(snapshot.runtime.instanceId!==value.runtime.instanceId||snapshot.dataDir!==value.dataDir))accountPanel.clear();
@@ -139,7 +142,7 @@ function render(value,watchRevision=watchPanel.revision){
if(!dirty){
const p=value.preferences,n=value.notifications;
for(const [id,key] of [['port','port'],['cloudflared','cloudflared'],['codex-home','codexHome'],['ipc-path','ipcPath'],['codex-bin','codexBin']])input(id,p[key]);
- $('auto-start').checked=p.autoStart;$('lan').checked=p.lan;
+ $('login-startup').checked=!!(value.loginStartup?.enabled||value.loginStartup?.requiresApproval);$('auto-start').checked=p.autoStart;$('auto-start').disabled=!!(value.loginStartup?.enabled||value.loginStartup?.requiresApproval);$('lan').checked=p.lan;
$('local-access').checked=p.localAccess!==false;input('lan-scope',p.lanAddresses==null?'all':'selected');
lanDraft=p.lanAddresses==null?(value.networkInterfaces||[]).map(row=>row.address):[...p.lanAddresses];savedLan=JSON.stringify(lanDraft);
if(!document.activeElement?.closest('#connections')||JSON.stringify(p.connections||[])!==savedConnections){connectionDraft=JSON.parse(JSON.stringify(p.connections||[]));savedConnections=JSON.stringify(connectionDraft);renderConnections();}
@@ -167,8 +170,8 @@ function render(value,watchRevision=watchPanel.revision){
loadSavedSecrets();
}
let renderedSnapshot='',renderedDirty=false,renderedRevision=-1;
-async function refresh(){if(loading||document.hidden)return;loading=true;try{const revision=watchPanel.revision,value=await api.snapshot(),signature=JSON.stringify(value);if(signature!==renderedSnapshot||renderedDirty!==dirty||renderedRevision!==revision||startingUntil){render(value,revision);renderedSnapshot=signature;renderedDirty=dirty;renderedRevision=revision;}}catch(e){feedback(e.message,true);}finally{loading=false;}}
-function collect(){return {preferences:{autoStart:$('auto-start').checked,port:Number($('port').value),lan:$('lan').checked,lanAddresses:$('lan-scope').value==='all'?null:[...lanDraft],localAccess:$('local-access').checked,connections:connectionDraft,cloudflared:$('cloudflared').value.trim(),codexHome:$('codex-home').value.trim(),ipcPath:$('ipc-path').value.trim(),codexBin:$('codex-bin').value.trim()},auth:{sessionHours:Number($('session-hours').value),mode:$('auth-mode').value,username:$('username').value.trim(),password:submittedSecret('password')},origins:$('origins').value.split('\n').map(s=>s.trim()).filter(Boolean),notifications:{mobileEnabled:$('mobile-enabled').checked,mobileAppLinks:$('mobile-app-links').checked,securityEnabled:$('security-enabled').checked,addressEnabled:$('address-enabled').checked,addressName:$('address-name').value.trim(),pushplusEnabled:$('pushplus-enabled').checked,pushplusToken:submittedSecret('pushplus-token'),clearPushplusToken:$('clear-pushplus-token').checked,enabled:$('ntfy-enabled').checked,server:$('ntfy-server').value.trim(),topic:$('ntfy-topic').value.trim(),token:submittedSecret('ntfy-token'),clearToken:$('clear-token').checked,barkEnabled:$('bark-enabled').checked,barkServer:$('bark-server').value.trim(),barkKey:submittedSecret('bark-key'),clearBarkKey:$('clear-bark-key').checked,clickBase:$('click-base').value.trim(),includeTitle:$('include-title').checked}};}
+async function refresh(force=false){if(loading||(document.hidden&&force!==true))return;loading=true;try{const revision=watchPanel.revision,value=await api.snapshot(),signature=JSON.stringify(value);if(signature!==renderedSnapshot||renderedDirty!==dirty||renderedRevision!==revision||startingUntil){render(value,revision);renderedSnapshot=signature;renderedDirty=dirty;renderedRevision=revision;}}catch(e){feedback(e.message,true);}finally{loading=false;}}
+function collect(){return {...(snapshot?.loginStartup?.supported?{loginStartup:$('login-startup').checked}:{}),preferences:{autoStart:$('auto-start').checked,port:Number($('port').value),lan:$('lan').checked,lanAddresses:$('lan-scope').value==='all'?null:[...lanDraft],localAccess:$('local-access').checked,connections:connectionDraft,cloudflared:$('cloudflared').value.trim(),codexHome:$('codex-home').value.trim(),ipcPath:$('ipc-path').value.trim(),codexBin:$('codex-bin').value.trim()},auth:{sessionHours:Number($('session-hours').value),mode:$('auth-mode').value,username:$('username').value.trim(),password:submittedSecret('password')},origins:$('origins').value.split('\n').map(s=>s.trim()).filter(Boolean),notifications:{mobileEnabled:$('mobile-enabled').checked,mobileAppLinks:$('mobile-app-links').checked,securityEnabled:$('security-enabled').checked,addressEnabled:$('address-enabled').checked,addressName:$('address-name').value.trim(),pushplusEnabled:$('pushplus-enabled').checked,pushplusToken:submittedSecret('pushplus-token'),clearPushplusToken:$('clear-pushplus-token').checked,enabled:$('ntfy-enabled').checked,server:$('ntfy-server').value.trim(),topic:$('ntfy-topic').value.trim(),token:submittedSecret('ntfy-token'),clearToken:$('clear-token').checked,barkEnabled:$('bark-enabled').checked,barkServer:$('bark-server').value.trim(),barkKey:submittedSecret('bark-key'),clearBarkKey:$('clear-bark-key').checked,clickBase:$('click-base').value.trim(),includeTitle:$('include-title').checked}};}
$('settings').onsubmit=async event=>{
event.preventDefault();if(saving||busyActions.size)return;const invalid=[...$('settings').querySelectorAll('input,textarea,select')].find(node=>!node.closest('#file-transfer-settings')&&!node.disabled&&!node.validity.valid&&(!node.closest('.connection-card')||connectionDraft.find(row=>row.id===node.closest('.connection-card').dataset.connectionId)?.enabled));if(invalid){focusField(invalid);feedback(invalid.validationMessage,true);return;}saving=true;$('save').disabled=true;const submitted=fieldValues(),submittedConnections=JSON.stringify(connectionDraft),submittedLan=JSON.stringify(lanDraft);
try{
@@ -235,7 +238,7 @@ $('cloudflared').oninput=()=>{renderCloudflared();updateDirty();};
async function loadLogs(){try{$('log-output').textContent=(await api.logs()).text||t('暂无运行日志');$('log-output').scrollTop=0;}catch(e){feedback(e.message,true);}}
$('refresh-logs').onclick=loadLogs;
for(const id of ['password','ntfy-token','bark-key','pushplus-token'])secretControl($(id));
-api.language().then(applyLanguage).catch(error=>feedback(error.message,true)).then(()=>refresh()).then(()=>desktopConnections?.scan()).then(()=>{if(snapshot?.preferences.autoStart&&!snapshot.updateManaged&&!snapshot.runtime.running&&!snapshot.runtime.portOccupied)$('start').click();});
+api.language().then(applyLanguage).catch(error=>feedback(error.message,true)).then(()=>refresh(true)).then(()=>desktopConnections?.scan()).then(()=>{if(snapshot?.preferences.autoStart&&!snapshot.updateManaged&&!snapshot.runtime.running&&!snapshot.runtime.portOccupied)$('start').click();});
let refreshTimer=setInterval(refresh,3000);
document.addEventListener('visibilitychange',()=>{clearInterval(refreshTimer);refreshTimer=null;if(!document.hidden){refresh();refreshTimer=setInterval(refresh,3000);}});
diff --git a/docs/desktop-updates.md b/docs/desktop-updates.md
index aa5d6821..ed28729e 100644
--- a/docs/desktop-updates.md
+++ b/docs/desktop-updates.md
@@ -4,7 +4,7 @@
从 v0.2.0-beta.5 起,桌面 App 启动后及每 6 小时检查一次 GitHub Release。
在「应用更新」中检查新版、阅读说明,点击「更新并重启」。Beta 用户可接收后续 Beta、RC 和正式版;正式版只接收正式版。
-旧版首次升级需要手动安装支持更新的版本。v2.0.0 提供 Mac Apple Silicon(arm64)、Mac Intel(x64)和 Windows x64 安装包。Windows ARM 暂无发布包。Ubuntu x64/ARM64 提供实验性 `.deb` / AppImage,暂不支持应用内更新;升级前停止网关并退出 App,保留数据目录后手动安装。
+旧版首次升级需要手动安装支持更新的版本。v2.0.2 提供 Mac Apple Silicon(arm64)、Mac Intel(x64)和 Windows x64 安装包。Windows ARM 暂无发布包。Ubuntu x64/ARM64 提供实验性 `.deb` / AppImage,暂不支持应用内更新;升级前停止网关并退出 App,保留数据目录后手动安装。
下载阶段网关保持在线。签名、哈希、解压路径和包版本检查通过后,App 才退出并停止网关。
应用在原位置替换,原本运行的网关随后恢复;原本停止的网关保持停止。
diff --git a/mobile/README.md b/mobile/README.md
index ab7c3874..2e4567ff 100644
--- a/mobile/README.md
+++ b/mobile/README.md
@@ -2,11 +2,11 @@
手机扫描网关二维码后连接 Codex、Claude Desktop 和 DeepSeek Harness,电脑继续执行任务。手机 App 保存多台电脑,提供原生扫码、剪贴板、文件下载和通知;浏览器入口同时保留。
-**[v2.0.0 下载](https://github.com/try2love/codex-mobile-bridge/releases/tag/v2.0.0)** · [完整更新说明](../RELEASE_NOTES.md) · [桌面客户端接入](../docs/deepseek-harness.md)
+**[v2.0.2 下载](https://github.com/try2love/codex-mobile-bridge/releases/tag/v2.0.2)** · [完整更新说明](../RELEASE_NOTES.md) · [桌面客户端接入](../docs/deepseek-harness.md)
## 安装与升级
-- **Android 8.0 及以上**:从 Release 下载 `Codex-Mobile-Bridge-2.0.0-Android.apk`,按系统提示安装。升级时覆盖安装,不要先卸载,以保留电脑列表和登录。相机权限仅供扫码,拒绝后仍可粘贴地址登录。
+- **Android 8.0 及以上**:从 Release 下载 `Codex-Mobile-Bridge-2.0.2-Android.apk`,按系统提示安装。升级时覆盖安装,不要先卸载,以保留电脑列表和登录。相机权限仅供扫码,拒绝后仍可粘贴地址登录。
- **iPhone / iPad**:Release 提供未签名 IPA 和 Xcode 源码包,需使用自己的 Apple 账号签名;不能直接点击 IPA 安装,暂无 App Store / TestFlight 分发。免费签名可能需要定期重新安装。
- 使用 Release 中的 `SHA256SUMS.txt` 校验下载。编译通过不代表已验证所有机型、系统版本和网络环境。
diff --git a/mobile/android-push/AndroidManifest.xml b/mobile/android-push/AndroidManifest.xml
index 272b42ed..cac63f13 100644
--- a/mobile/android-push/AndroidManifest.xml
+++ b/mobile/android-push/AndroidManifest.xml
@@ -1,12 +1,14 @@
-
+
-
+
+
+
diff --git a/mobile/android-push/build.gradle b/mobile/android-push/build.gradle
index 54bc3f49..63394858 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 28; versionName '2.0.1' }
+ defaultConfig { applicationId 'io.github.try2love.codexbridge.preview'; minSdk 26; targetSdk 35; versionCode 29; versionName '2.0.2' }
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 0788c842..0d4fa2a0 100644
--- a/mobile/android/AndroidManifest.xml
+++ b/mobile/android/AndroidManifest.xml
@@ -1,5 +1,6 @@
-
+
+
@@ -7,6 +8,7 @@
+
diff --git a/mobile/android/src/io/github/try2love/codexbridge/DownloadCard.java b/mobile/android/src/io/github/try2love/codexbridge/DownloadCard.java
index 68a4fded..3719701a 100644
--- a/mobile/android/src/io/github/try2love/codexbridge/DownloadCard.java
+++ b/mobile/android/src/io/github/try2love/codexbridge/DownloadCard.java
@@ -11,9 +11,9 @@
final class DownloadCard extends LinearLayout {
private final MainActivity activity;private final FrameLayout parent;
private final TextView title,details;private final ProgressBar progress;private final LinearLayout actions;
- private final Button primary,cancel,toggle;
+ private final Button primary,open,cancel,toggle;
private boolean collapsed,dragged;private float downX,downY,startX,startY;private boolean moving;
- DownloadCard(MainActivity activity,FrameLayout parent,Runnable action,Runnable dismiss){
+ DownloadCard(MainActivity activity,FrameLayout parent,Runnable action,Runnable openFile,Runnable dismiss){
super(activity);this.activity=activity;this.parent=parent;
setOrientation(VERTICAL);setPadding(dp(12),dp(8),dp(12),dp(10));
android.graphics.drawable.GradientDrawable face=activity.background(0xfff6f9fe,16);face.setStroke(dp(1),0xffd7e1ef);setBackground(face);setElevation(dp(8));
@@ -29,7 +29,8 @@ final class DownloadCard extends LinearLayout {
}});
progress=new ProgressBar(activity,null,android.R.attr.progressBarStyleHorizontal);progress.setMax(1000);progress.setProgressTintList(ColorStateList.valueOf(0xff477ccc));progress.setProgressBackgroundTintList(ColorStateList.valueOf(0xffdce6f4));addView(progress,new LinearLayout.LayoutParams(-1,dp(4)));
details=activity.text("",12);details.setTextColor(0xff63738a);details.setPadding(0,dp(8),0,dp(4));addView(details,new LinearLayout.LayoutParams(-1,-2));
- actions=new LinearLayout(activity);primary=control("",action);primary.setTextColor(0xff3268b5);cancel=control("",dismiss);LinearLayout.LayoutParams first=new LinearLayout.LayoutParams(0,dp(40),1);first.setMarginEnd(dp(8));actions.addView(primary,first);actions.addView(cancel,new LinearLayout.LayoutParams(0,dp(40),1));addView(actions);
+ actions=new LinearLayout(activity);open=control(L("打开"),openFile);open.setVisibility(GONE);LinearLayout.LayoutParams opening=new LinearLayout.LayoutParams(0,dp(40),1);opening.setMarginEnd(dp(8));actions.addView(open,opening);
+ primary=control("",action);primary.setTextColor(0xff3268b5);cancel=control("",dismiss);LinearLayout.LayoutParams first=new LinearLayout.LayoutParams(0,dp(40),1);first.setMarginEnd(dp(8));actions.addView(primary,first);actions.addView(cancel,new LinearLayout.LayoutParams(0,dp(40),1));addView(actions);
parent.addView(this,new FrameLayout.LayoutParams(dp(320),-2,Gravity.TOP|Gravity.LEFT));setVisibility(GONE);
parent.addOnLayoutChangeListener((v,l,t,r,b,ol,ot,or,ob)->place());addOnLayoutChangeListener((v,l,t,r,b,ol,ot,or,ob)->place());
}
@@ -49,20 +50,23 @@ void place(){
float bottom=Math.max(top,parent.getHeight()-parent.getPaddingBottom()-getHeight()-dp(88));
setX(dragged?Math.max(left,Math.min(right,getX())):right);setY(dragged?Math.max(top,Math.min(bottom,getY())):bottom);
}
- void render(ArtifactDownload.Task task,boolean saving){
+ void render(ArtifactDownload.Task task,boolean saving,boolean opening){
if(task==null){setVisibility(GONE);return;}
if(getVisibility()!=VISIBLE)showTask();
ArtifactDownload.State state=task.state;boolean active=state==ArtifactDownload.State.RUNNING||state==ArtifactDownload.State.READY||state==ArtifactDownload.State.PAUSING;
String size=bytes(task.downloaded)+(task.total>=0?" / "+bytes(task.total):"");
- String phase=state==ArtifactDownload.State.COMPLETE?L("下载完成"):state==ArtifactDownload.State.PAUSED?L("已暂停"):state==ArtifactDownload.State.FAILED?L("下载中断"):state==ArtifactDownload.State.PAUSING?L("正在暂停…"):L("正在下载…");
+ String phase=state==ArtifactDownload.State.COMPLETE?L("下载完成,可打开或保存"):state==ArtifactDownload.State.PAUSED?L("已暂停"):state==ArtifactDownload.State.FAILED?L("下载中断"):state==ArtifactDownload.State.PAUSING?L("正在暂停…"):L("正在下载…");
+ if(opening)phase=L("正在打开…");
+ boolean busy=saving||opening;
if(saving)phase=L("正在保存…");
String percent=task.total>0?String.format(Locale.ROOT,"%d%%",Math.min(100,task.downloaded*100/task.total)):state==ArtifactDownload.State.COMPLETE?"100%":"";
title.setText((collapsed&&!percent.isEmpty()?percent+" · ":"")+task.name);
title.setContentDescription(task.name+" · "+phase+" · "+size);
progress.setIndeterminate(active&&task.total<0);progress.setProgress(task.total>0?(int)Math.min(1000,task.downloaded*1000/task.total):state==ArtifactDownload.State.COMPLETE?1000:0);
details.setText(phase+" · "+size+(state==ArtifactDownload.State.RUNNING?" · "+bytes((long)task.bytesPerSecond)+"/s":"")+(state==ArtifactDownload.State.FAILED&&!task.error.isEmpty()?"\n"+L(task.error):""));
- primary.setText(L(state==ArtifactDownload.State.COMPLETE?"保存":active?"暂停":task.resumable()?"继续":"重新下载"));primary.setEnabled(!saving&&state!=ArtifactDownload.State.PAUSING&&state!=ArtifactDownload.State.READY);
- cancel.setText(L(state==ArtifactDownload.State.COMPLETE?"关闭":"取消"));cancel.setEnabled(!saving);
+ primary.setText(L(state==ArtifactDownload.State.COMPLETE?"保存":active?"暂停":task.resumable()?"继续":"重新下载"));primary.setEnabled(!busy&&state!=ArtifactDownload.State.PAUSING&&state!=ArtifactDownload.State.READY);
+ cancel.setText(L(state==ArtifactDownload.State.COMPLETE?"关闭":"取消"));cancel.setEnabled(!busy);
+ open.setText(L("打开"));open.setVisibility(state==ArtifactDownload.State.COMPLETE?VISIBLE:GONE);open.setEnabled(!busy);
setCollapsed(collapsed);
}
private static String bytes(long value){if(value<1024)return value+" B";if(value<1024*1024)return String.format(Locale.ROOT,"%.1f KB",value/1024d);return String.format(Locale.ROOT,"%.1f MB",value/(1024d*1024));}
diff --git a/mobile/android/src/io/github/try2love/codexbridge/DownloadProvider.java b/mobile/android/src/io/github/try2love/codexbridge/DownloadProvider.java
new file mode 100644
index 00000000..9c55ae3f
--- /dev/null
+++ b/mobile/android/src/io/github/try2love/codexbridge/DownloadProvider.java
@@ -0,0 +1,60 @@
+package io.github.try2love.codexbridge;
+
+import android.content.*;
+import android.database.Cursor;
+import android.database.MatrixCursor;
+import android.net.Uri;
+import android.os.ParcelFileDescriptor;
+import android.provider.OpenableColumns;
+import android.webkit.MimeTypeMap;
+import java.io.*;
+import java.nio.file.Files;
+import java.util.*;
+
+/** Read-only copies keep external viewers independent of the active download. */
+public final class DownloadProvider extends ContentProvider {
+ private static final long RETENTION=24L*60*60*1000;
+ static Uri prepare(Context context,ArtifactDownload.Result result)throws IOException {
+ File root=new File(context.getCacheDir(),"opened-downloads");
+ if(!root.isDirectory()&&!root.mkdirs())throw new IOException();
+ File[] folders=root.listFiles();
+ if(folders!=null)for(File folder:folders){
+ if(folder.lastModified() path=uri.getPathSegments();
+ if(!"content".equals(uri.getScheme())||!(getContext().getPackageName()+".downloads").equals(uri.getAuthority())||path.size()!=2||!path.get(0).matches("[a-f0-9-]{36}"))throw new FileNotFoundException();
+ try{
+ File folder=new File(getContext().getCacheDir(),"opened-downloads/"+path.get(0)).getCanonicalFile();
+ File file=new File(folder,path.get(1)).getCanonicalFile();
+ if(!folder.equals(file.getParentFile())||!file.isFile())throw new FileNotFoundException();return file;
+ }catch(IOException e){throw new FileNotFoundException();}
+ }
+ @Override public boolean onCreate(){return true;}
+ @Override public String getType(Uri uri){
+ try{String name=file(uri).getName();String extension=name.substring(name.lastIndexOf('.')+1).toLowerCase(Locale.ROOT);
+ String type=MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension);return type==null?"application/octet-stream":type;
+ }catch(FileNotFoundException e){return "application/octet-stream";}
+ }
+ @Override public Cursor query(Uri uri,String[] projection,String selection,String[] args,String order){
+ try{File file=file(uri);String[] columns=projection==null?new String[]{OpenableColumns.DISPLAY_NAME,OpenableColumns.SIZE}:projection;
+ MatrixCursor cursor=new MatrixCursor(columns,1);Object[] row=new Object[columns.length];
+ for(int i=0;icheckComputers();
private long homeBackAt;private Toast exitToast;
private final ExecutorService downloads=Executors.newSingleThreadExecutor();
- private FrameLayout screen;private DownloadCard downloadCard;private ArtifactDownload.Task downloadTask,choosingDownload;private boolean savingDownload;
+ private FrameLayout screen;private DownloadCard downloadCard;private ArtifactDownload.Task downloadTask,choosingDownload;private boolean savingDownload,openingDownload;
private ServiceConnection monitor;private ValueCallback files;private WebView fileView;private int fileGeneration;private int generation;
String L(String value){return MobileStrings.text(this,value);}
int dp(int n){return (int)(getResources().getDisplayMetrics().density*n);}
@@ -71,15 +71,14 @@ void recoverConnection(String requested){
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.setFitsSystemWindows(Build.VERSION.SDK_INT<30);screen.addView(root,new FrameLayout.LayoutParams(-1,-1));setContentView(screen);
+ @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);});
if(Build.VERSION.SDK_INT>=33)getOnBackInvokedDispatcher().registerOnBackInvokedCallback(android.window.OnBackInvokedDispatcher.PRIORITY_DEFAULT,this::handleBack);
- if(Build.VERSION.SDK_INT>=30){getWindow().setDecorFitsSystemWindows(false);screen.setOnApplyWindowInsetsListener((v,insets)->{android.graphics.Insets i=insets.getInsets(WindowInsets.Type.systemBars()|WindowInsets.Type.displayCutout()|WindowInsets.Type.ime());v.setPadding(i.left,i.top,i.right,i.bottom);if(downloadCard!=null)downloadCard.post(downloadCard::place);return WindowInsets.CONSUMED;});}
bar=new LinearLayout(this);bar.setGravity(Gravity.CENTER_VERTICAL);bar.setPadding(dp(20),dp(4),dp(12),dp(4));root.addView(bar);header(false);
- downloadCard=new DownloadCard(this,screen,this::downloadAction,this::cancelDownload);
+ downloadCard=new DownloadCard(this,screen,this::downloadAction,this::openDownload,this::cancelDownload);
status=text("",12);status.setTextColor(0xff6b7075);status.setGravity(Gravity.CENTER);root.addView(status);
if(!openIntent(getIntent())){String active=prefs.getString("active","");if(saved().contains(active))connect(active+"/");else home();}monitor();root.post(this::requestStartupNotifications);
}
- void clear(){pendingStorage=null;if(routeDiscovery!=null){routeDiscovery.cancel();routeDiscovery=null;}if(files!=null){files.onReceiveValue(null);files=null;fileView=null;}stopComputerChecks();homeStates.clear();homeNotifications.clear();resetExitGesture();generation++;if(web!=null){web.stopLoading();web.destroy();web=null;}while(root.getChildCount()>2)root.removeViewAt(2);}
+ void clear(){pendingStorage=null;if(routeDiscovery!=null){routeDiscovery.cancel();routeDiscovery=null;}if(files!=null){files.onReceiveValue(null);files=null;fileView=null;}stopComputerChecks();homeStates.clear();homeNotifications.clear();resetExitGesture();generation++;if(web!=null){web.stopLoading();web.destroy();web=null;}systemBars.update(0xfff5f5f3,0xfff5f5f3);while(root.getChildCount()>2)root.removeViewAt(2);}
void home(){clear();header(false);status.setVisibility(View.GONE);ScrollView scroll=new ScrollView(this);scroll.setFillViewport(true);LinearLayout content=column();content.setPadding(dp(20),dp(16),dp(20),dp(24));scroll.addView(content);root.addView(scroll,new LinearLayout.LayoutParams(-1,0,1));
ImageView icon=new ImageView(this);icon.setImageResource(getResources().getIdentifier("ic_computer","drawable",getPackageName()));icon.setScaleType(ImageView.ScaleType.FIT_CENTER);LinearLayout.LayoutParams ip=new LinearLayout.LayoutParams(dp(64),dp(58));ip.gravity=Gravity.CENTER;ip.bottomMargin=dp(24);content.addView(icon,ip);
TextView headline=text(L("电脑上的工作,\n带在身边。"),30);headline.setTypeface(null,android.graphics.Typeface.BOLD);headline.setGravity(Gravity.CENTER);add(headline,content,10);
@@ -141,6 +140,10 @@ void checkComputers(){
});
web.setWebChromeClient(new WebChromeClient(){
@Override public boolean onJsPrompt(WebView view,String url,String prompt,String value,JsPromptResult result){
+ if(prompt.equals("codexbridge-system-bars:"+clipboardToken)){
+ if(view==web&&view.hasWindowFocus()&&GatewayURL.sameOrigin(url,origin)&&GatewayURL.sameOrigin(view.getUrl(),origin)&&value!=null&&value.length()<100){try{JSONObject colors=new JSONObject(value);String top=colors.getString("top"),bottom=colors.getString("bottom");if(top.matches("#[0-9a-fA-F]{6}")&&bottom.matches("#[0-9a-fA-F]{6}")){systemBars.update(android.graphics.Color.parseColor(top),android.graphics.Color.parseColor(bottom));result.confirm("saved");return true;}}catch(Exception ignored){}}
+ result.cancel();return true;
+ }
if(prompt.equals("codexbridge-computer:"+clipboardToken)){
if(view==web&&GatewayURL.sameOrigin(url,origin)&&GatewayURL.sameOrigin(view.getUrl(),origin))result.confirm(computerName(origin));else result.cancel();return true;
}
@@ -166,7 +169,7 @@ public boolean onShowFileChooser(WebView view,ValueCallback callback,File
web.setDownloadListener((target,agent,disposition,mime,length)->downloadArtifact(target));
web.loadUrl(url);monitor();
}catch(Exception e){message(e.getMessage());}}
- void renderDownload(){downloadCard.render(downloadTask,savingDownload||choosingDownload!=null);}
+ void renderDownload(){downloadCard.render(downloadTask,savingDownload||choosingDownload!=null,openingDownload);}
void downloadArtifact(String target){
if(downloadTask!=null){downloadCard.showTask();renderDownload();message(L("已有下载任务,请先完成或取消"));return;}
if(web==null||!GatewayURL.sameOrigin(web.getUrl(),origin)||!ArtifactDownload.accepts(target,origin)){message(L("此下载不是当前电脑的附件,请在浏览器中打开"));return;}
@@ -179,7 +182,7 @@ void runDownload(ArtifactDownload.Task task){
task.state=ArtifactDownload.State.READY;renderDownload();downloads.execute(()->task.run(()->CookieManager.getInstance().getCookie(task.url),value->runOnUiThread(()->{if(downloadTask==value&&!isFinishing()&&!isDestroyed())renderDownload();})));
}
void downloadAction(){
- ArtifactDownload.Task task=downloadTask;if(task==null||savingDownload||choosingDownload!=null)return;
+ ArtifactDownload.Task task=downloadTask;if(task==null||savingDownload||choosingDownload!=null||openingDownload)return;
if(task.state==ArtifactDownload.State.RUNNING){task.pause();renderDownload();}
else if(task.state==ArtifactDownload.State.PAUSED||task.state==ArtifactDownload.State.FAILED){
if(task.resumable())runDownload(task);else{String target=task.url,base=task.origin;cancelDownload();startDownload(target,base);}
@@ -188,6 +191,19 @@ else if(task.state==ArtifactDownload.State.PAUSED||task.state==ArtifactDownload.
try{startActivityForResult(save,32);}catch(Exception e){choosingDownload=null;renderDownload();message(L("没有可用的文件保存器"));}
}
}
+ void openDownload(){
+ ArtifactDownload.Task task=downloadTask;if(task==null||task.state!=ArtifactDownload.State.COMPLETE||savingDownload||choosingDownload!=null||openingDownload)return;
+ openingDownload=true;renderDownload();downloads.execute(()->{
+ Uri uri=null;try{uri=DownloadProvider.prepare(this,task.result());}catch(java.io.IOException ignored){}
+ final Uri target=uri;runOnUiThread(()->{
+ openingDownload=false;if(isFinishing()||isDestroyed())return;renderDownload();if(task!=downloadTask)return;
+ if(target==null){message(L("打开失败,请重试或保存文件"));return;}
+ Intent view=new Intent(Intent.ACTION_VIEW).setDataAndType(target,getContentResolver().getType(target)).addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
+ view.setClipData(ClipData.newRawUri("",target));
+ try{startActivity(view);}catch(ActivityNotFoundException e){message(L("暂无可打开此文件的应用"));}catch(Exception e){message(L("打开失败,请重试或保存文件"));}
+ });
+ });
+ }
void cancelDownload(){if(downloadTask!=null){downloadTask.cancel();downloadTask=null;}renderDownload();}
void saveDownload(int result,Intent data){
ArtifactDownload.Task task=choosingDownload;choosingDownload=null;
diff --git a/mobile/android/src/io/github/try2love/codexbridge/MobileStrings.java b/mobile/android/src/io/github/try2love/codexbridge/MobileStrings.java
index e1013d51..b53ee2a5 100644
--- a/mobile/android/src/io/github/try2love/codexbridge/MobileStrings.java
+++ b/mobile/android/src/io/github/try2love/codexbridge/MobileStrings.java
@@ -8,6 +8,10 @@ final class MobileStrings {
values.put("最近通知","Recent notifications");
values.put("需要你处理","Action requested");
values.put("已运行完毕","Completed");
+ values.put("正在打开…","Opening…");
+ values.put("下载完成,可打开或保存","Downloaded · Open or save");
+ values.put("打开失败,请重试或保存文件","Could not open the file. Retry or save it.");
+ values.put("暂无可打开此文件的应用","No app is available to open this file.");
values.put("未收到所选文件,请重新选择。","No selected file was received. Choose the file again.");
values.put("无法读取所选文件,请从系统文件选择器重新选择。","Cannot read the selected file. Select it again using the system file picker.");
diff --git a/mobile/android/src/io/github/try2love/codexbridge/MobileWebScripts.java b/mobile/android/src/io/github/try2love/codexbridge/MobileWebScripts.java
index 6bc08aa1..de0ffe4e 100644
--- a/mobile/android/src/io/github/try2love/codexbridge/MobileWebScripts.java
+++ b/mobile/android/src/io/github/try2love/codexbridge/MobileWebScripts.java
@@ -2,5 +2,5 @@
// Asset loading only; WebView navigation and prompt validation stay in MainActivity.
final class MobileWebScripts {
- static String appearance(android.content.res.AssetManager assets){try(java.io.ByteArrayOutputStream out=new java.io.ByteArrayOutputStream()){for(String asset:new String[]{"mobile-ui.js","mobile-clipboard.js","mobile-session.js"}){try(java.io.InputStream in=assets.open(asset)){byte[] data=new byte[4096];int size;while((size=in.read(data))!=-1)out.write(data,0,size);}}return out.toString("UTF-8");}catch(java.io.IOException e){throw new IllegalStateException("Missing mobile layout",e);}}
+ static String appearance(android.content.res.AssetManager assets){try(java.io.ByteArrayOutputStream out=new java.io.ByteArrayOutputStream()){for(String asset:new String[]{"mobile-ui.js","mobile-clipboard.js","mobile-system-bars.js","mobile-session.js"}){try(java.io.InputStream in=assets.open(asset)){byte[] data=new byte[4096];int size;while((size=in.read(data))!=-1)out.write(data,0,size);}}return out.toString("UTF-8");}catch(java.io.IOException e){throw new IllegalStateException("Missing mobile layout",e);}}
}
diff --git a/mobile/android/src/io/github/try2love/codexbridge/SystemBars.java b/mobile/android/src/io/github/try2love/codexbridge/SystemBars.java
new file mode 100644
index 00000000..067f7bd0
--- /dev/null
+++ b/mobile/android/src/io/github/try2love/codexbridge/SystemBars.java
@@ -0,0 +1,54 @@
+package io.github.try2love.codexbridge;
+
+import android.app.Activity;
+import android.graphics.*;
+import android.graphics.drawable.Drawable;
+import android.os.Build;
+import android.view.*;
+
+final class SystemBars {
+ private final Window window;
+ private final BarBackground background=new BarBackground();
+ SystemBars(Activity activity,View screen,Runnable insetsChanged){
+ this.window=activity.getWindow();
+ window.clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS|WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION);
+ window.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS);
+ window.setStatusBarColor(Color.TRANSPARENT);window.setNavigationBarColor(Color.TRANSPARENT);
+ if(Build.VERSION.SDK_INT>=28)window.setNavigationBarDividerColor(Color.TRANSPARENT);
+ if(Build.VERSION.SDK_INT>=29){window.setStatusBarContrastEnforced(false);window.setNavigationBarContrastEnforced(false);}
+ if(Build.VERSION.SDK_INT>=30)window.setDecorFitsSystemWindows(false);
+ else window.getDecorView().setSystemUiVisibility(View.SYSTEM_UI_FLAG_LAYOUT_STABLE|View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN|View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION);
+ screen.setOnApplyWindowInsetsListener((v,insets)->{
+ if(Build.VERSION.SDK_INT>=30){
+ Insets i=insets.getInsets(WindowInsets.Type.systemBars()|WindowInsets.Type.displayCutout()|WindowInsets.Type.ime());
+ v.setPadding(i.left,i.top,i.right,i.bottom);
+ insetsChanged.run();
+ return WindowInsets.CONSUMED;
+ }
+ int left=insets.getSystemWindowInsetLeft(),top=insets.getSystemWindowInsetTop(),right=insets.getSystemWindowInsetRight(),bottom=insets.getSystemWindowInsetBottom();
+ if(Build.VERSION.SDK_INT>=28&&insets.getDisplayCutout()!=null){DisplayCutout cutout=insets.getDisplayCutout();left=Math.max(left,cutout.getSafeInsetLeft());top=Math.max(top,cutout.getSafeInsetTop());right=Math.max(right,cutout.getSafeInsetRight());bottom=Math.max(bottom,cutout.getSafeInsetBottom());}
+ v.setPadding(left,top,right,bottom);
+ insetsChanged.run();
+ return insets.consumeSystemWindowInsets();
+ });
+ // Insets are consumed here, so the native background fills the space outside the WebView.
+ screen.setBackground(background);
+ update(0xfff5f5f3,0xfff5f5f3);
+ screen.requestApplyInsets();
+ }
+ void update(int top,int bottom){
+ background.top=top;background.bottom=bottom;background.invalidateSelf();
+ boolean lightTop=light(top),lightBottom=light(bottom);
+ if(Build.VERSION.SDK_INT>=30){WindowInsetsController controller=window.getInsetsController();if(controller!=null)controller.setSystemBarsAppearance((lightTop?WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS:0)|(lightBottom?WindowInsetsController.APPEARANCE_LIGHT_NAVIGATION_BARS:0),WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS|WindowInsetsController.APPEARANCE_LIGHT_NAVIGATION_BARS);}
+ else {View decor=window.getDecorView();int flags=decor.getSystemUiVisibility()&~(View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR|View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR);decor.setSystemUiVisibility(flags|(lightTop?View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR:0)|(lightBottom?View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR:0));}
+ }
+ private static final class BarBackground extends Drawable {
+ private final Paint paint=new Paint();
+ private int top,bottom,alpha=255;
+ @Override public void draw(Canvas canvas){Rect b=getBounds();int middle=b.top+b.height()/2;paint.setColor(top);paint.setAlpha(alpha);canvas.drawRect(b.left,b.top,b.right,middle,paint);paint.setColor(bottom);paint.setAlpha(alpha);canvas.drawRect(b.left,middle,b.right,b.bottom,paint);}
+ @Override public void setAlpha(int alpha){this.alpha=alpha;invalidateSelf();}
+ @Override public void setColorFilter(ColorFilter filter){paint.setColorFilter(filter);invalidateSelf();}
+ @Override public int getOpacity(){return alpha==255?PixelFormat.OPAQUE:PixelFormat.TRANSLUCENT;}
+ }
+ private static boolean light(int color){return Color.red(color)*.299+Color.green(color)*.587+Color.blue(color)*.114>=128;}
+}
diff --git a/mobile/ios/BridgeActivity/Info.plist b/mobile/ios/BridgeActivity/Info.plist
index 04835c38..41e83738 100644
--- a/mobile/ios/BridgeActivity/Info.plist
+++ b/mobile/ios/BridgeActivity/Info.plist
@@ -13,9 +13,9 @@
CFBundlePackageType
XPC!
CFBundleShortVersionString
- 2.0.1
+ 2.0.2
CFBundleVersion
- 28
+ 29
NSExtension
NSExtensionPointIdentifier
diff --git a/mobile/ios/BridgeNotifications/Info.plist b/mobile/ios/BridgeNotifications/Info.plist
index 2f1f3388..cd50c580 100644
--- a/mobile/ios/BridgeNotifications/Info.plist
+++ b/mobile/ios/BridgeNotifications/Info.plist
@@ -6,8 +6,8 @@
CFBundleName $(PRODUCT_NAME)
CFBundleDisplayName Bridge Notifications
CFBundlePackageType XPC!
-CFBundleShortVersionString 2.0.1
-CFBundleVersion 28
+CFBundleShortVersionString 2.0.2
+CFBundleVersion 29
NSExtension
NSExtensionPointIdentifier com.apple.usernotifications.service
NSExtensionPrincipalClass $(PRODUCT_MODULE_NAME).NotificationService
diff --git a/mobile/ios/BridgePreview.xcodeproj/project.pbxproj b/mobile/ios/BridgePreview.xcodeproj/project.pbxproj
index f931fd9f..d855ed15 100644
--- a/mobile/ios/BridgePreview.xcodeproj/project.pbxproj
+++ b/mobile/ios/BridgePreview.xcodeproj/project.pbxproj
@@ -18,6 +18,7 @@ B00000000000000000000017 = {isa = PBXCopyFilesBuildPhase; buildActionMask = 2147
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXBuildFile section */
+E00000000000000000000009 /* mobile-system-bars.js in Resources */ = {isa = PBXBuildFile; fileRef = E00000000000000000000008; };
F30000000000000000000001 = {isa = PBXBuildFile; fileRef = F30000000000000000000002; };
E00000000000000000000002 /* WebScripts.swift in Sources */ = {isa = PBXBuildFile; fileRef = E00000000000000000000001; };
E00000000000000000000004 /* mobile-ui.js in Resources */ = {isa = PBXBuildFile; fileRef = E00000000000000000000003; };
@@ -50,6 +51,7 @@ F40000000000000000000011 = {isa = PBXBuildFile; fileRef = F100000000000000000000
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
+E00000000000000000000008 /* mobile-system-bars.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; path = ../shared/web/mobile-system-bars.js; sourceTree = SOURCE_ROOT; };
F30000000000000000000002 = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionDiscovery.swift; sourceTree = ""; };
E00000000000000000000001 /* WebScripts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebScripts.swift; sourceTree = ""; };
E00000000000000000000003 /* mobile-ui.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; path = ../shared/web/mobile-ui.js; sourceTree = SOURCE_ROOT; };
@@ -130,7 +132,7 @@ F30000000000000000000002, F40000000000000000000001, F40000000000000000000002,
sourceTree = "";
};
B00000000000000000000014 = {isa = PBXGroup; children = (B00000000000000000000003,); path = BridgeActivity; sourceTree = ""; };
-E00000000000000000000007 /* Shared Web */ = {isa = PBXGroup; children = (E00000000000000000000003, E00000000000000000000005,); name = "Shared Web"; sourceTree = ""; };
+E00000000000000000000007 /* Shared Web */ = {isa = PBXGroup; children = (E00000000000000000000003, E00000000000000000000005, E00000000000000000000008,); name = "Shared Web"; sourceTree = ""; };
F40000000000000000000014 = {isa = PBXGroup; children = (F40000000000000000000003,); path = BridgeNotifications; sourceTree = ""; };
/* End PBXGroup section */
@@ -200,6 +202,7 @@ F20000000000000000000001,
F20000000000000000000000,
E00000000000000000000004 /* mobile-ui.js in Resources */,
E00000000000000000000006 /* mobile-clipboard.js in Resources */,
+E00000000000000000000009 /* mobile-system-bars.js in Resources */,
A00000000000000000000053 /* AppIcon76x76@2x.png in Resources */,
A00000000000000000000052 /* AppIcon76x76.png in Resources */,
A00000000000000000000051 /* AppIcon60x60@3x.png in Resources */,
diff --git a/mobile/ios/BridgePreview/App.swift b/mobile/ios/BridgePreview/App.swift
index 49eace33..2d0ea910 100644
--- a/mobile/ios/BridgePreview/App.swift
+++ b/mobile/ios/BridgePreview/App.swift
@@ -4,13 +4,17 @@ import AVFoundation
import UserNotifications
import UniformTypeIdentifiers
+final class BridgeNavigationController: UINavigationController {
+ override var childForStatusBarStyle: UIViewController? { topViewController }
+}
+
@main
final class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterDelegate {
var window: UIWindow?
let controller = BridgeController()
func application(_ application: UIApplication, didFinishLaunchingWithOptions options: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
UNUserNotificationCenter.current().delegate = self
- let window = UIWindow(frame: UIScreen.main.bounds); window.rootViewController = UINavigationController(rootViewController: controller); window.makeKeyAndVisible(); self.window = window
+ let window = UIWindow(frame: UIScreen.main.bounds); window.rootViewController = BridgeNavigationController(rootViewController: controller); window.makeKeyAndVisible(); self.window = window
if let url = options?[.url] as? URL { DispatchQueue.main.async { self.controller.openLink(url) } }
return true
}
@@ -65,7 +69,7 @@ final class GatewayReachability: NSObject, URLSessionDataDelegate {
}
}
-final class BridgeController: UIViewController, WKNavigationDelegate, WKUIDelegate, UIDocumentPickerDelegate {
+final class BridgeController: UIViewController, WKNavigationDelegate, WKUIDelegate, UIDocumentPickerDelegate, UIDocumentInteractionControllerDelegate {
private var web: WKWebView?
private var origin = ""
private var routeDiscovery: ConnectionDiscovery?
@@ -76,6 +80,7 @@ final class BridgeController: UIViewController, WKNavigationDelegate, WKUIDelega
private var downloadState = DownloadManager.Snapshot()
private let downloadPanel = DownloadPanel()
private var exportPicker: UIDocumentPickerViewController?
+ private var openDocument: UIDocumentInteractionController?
private var downloadBottom: NSLayoutConstraint?
private lazy var downloads: DownloadManager = {
let manager = DownloadManager()
@@ -109,6 +114,9 @@ final class BridgeController: UIViewController, WKNavigationDelegate, WKUIDelega
private var generation = 0
private let defaults = UserDefaults.standard
private let page = UIStackView()
+ private let topBackground = UIView()
+ private var pageStatusBarStyle: UIStatusBarStyle = .default
+ override var preferredStatusBarStyle: UIStatusBarStyle { pageStatusBarStyle }
private let subtitle = UILabel()
private let gatewayMenu = UIButton(type: .system)
private var gatewayMenuTop: NSLayoutConstraint?
@@ -134,6 +142,9 @@ final class BridgeController: UIViewController, WKNavigationDelegate, WKUIDelega
navigationController?.navigationBar.scrollEdgeAppearance = appearance
navigationController?.navigationBar.prefersLargeTitles = true
page.axis = .vertical; page.spacing = 0; page.translatesAutoresizingMaskIntoConstraints = false; view.addSubview(page)
+ topBackground.translatesAutoresizingMaskIntoConstraints = false; topBackground.isUserInteractionEnabled = false
+ topBackground.backgroundColor = .systemGroupedBackground; view.insertSubview(topBackground, belowSubview: page)
+ NSLayoutConstraint.activate([topBackground.topAnchor.constraint(equalTo: view.topAnchor), topBackground.bottomAnchor.constraint(equalTo: page.topAnchor), topBackground.leadingAnchor.constraint(equalTo: view.leadingAnchor), topBackground.trailingAnchor.constraint(equalTo: view.trailingAnchor)])
pageBottom = page.bottomAnchor.constraint(equalTo: view.bottomAnchor)
NSLayoutConstraint.activate([page.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), pageBottom!, page.leadingAnchor.constraint(equalTo: view.leadingAnchor), page.trailingAnchor.constraint(equalTo: view.trailingAnchor)])
subtitle.font = .preferredFont(forTextStyle: .caption1); subtitle.textColor = .secondaryLabel; subtitle.textAlignment = .center; subtitle.numberOfLines = 2; page.addArrangedSubview(subtitle)
@@ -151,6 +162,7 @@ final class BridgeController: UIViewController, WKNavigationDelegate, WKUIDelega
}
downloadPanel.onCancel = { [weak self] in self?.downloads.cancel() }
downloadPanel.onSave = { [weak self] in self?.saveDownload() }
+ downloadPanel.onOpen = { [weak self] in self?.openDownload() }
downloadBottom = downloadPanel.bottomAnchor.constraint(equalTo: page.bottomAnchor, constant: -16)
let downloadWidth = downloadPanel.widthAnchor.constraint(equalToConstant: 360); downloadWidth.priority = .defaultHigh
NSLayoutConstraint.activate([downloadBottom!, downloadWidth,
@@ -268,7 +280,7 @@ final class BridgeController: UIViewController, WKNavigationDelegate, WKUIDelega
let inbox = UIBarButtonItem(image: UIImage(systemName: "bell"), style: .plain, target: self, action: #selector(self.inbox)); inbox.accessibilityLabel = MobileStrings.text("通知")
navigationItem.rightBarButtonItems = [settings, inbox]
}
- private func clear() { pendingStorage = nil; routeDiscovery?.cancel(); routeDiscovery = nil; cancelSync(); finishUpload(nil); stopComputerChecks(); computerStates.removeAll(); computerNotifications.removeAll(); gatewayMenuTop?.isActive = false; gatewayMenuTop = nil; generation += 1; web?.stopLoading(); web?.navigationDelegate = nil; web?.uiDelegate = nil; web = nil; for v in page.arrangedSubviews where v !== subtitle { page.removeArrangedSubview(v); v.removeFromSuperview() } }
+ private func clear() { updateSystemBars(reset: true); pendingStorage = nil; routeDiscovery?.cancel(); routeDiscovery = nil; cancelSync(); finishUpload(nil); stopComputerChecks(); computerStates.removeAll(); computerNotifications.removeAll(); gatewayMenuTop?.isActive = false; gatewayMenuTop = nil; generation += 1; web?.stopLoading(); web?.navigationDelegate = nil; web?.uiDelegate = nil; web = nil; for v in page.arrangedSubviews where v !== subtitle { page.removeArrangedSubview(v); v.removeFromSuperview() } }
@objc func home() {
clear(); navigation(home: true); subtitle.isHidden = true
let container = UIView(); page.addArrangedSubview(container); let content = scrollContent(in: container)
@@ -469,6 +481,8 @@ final class BridgeController: UIViewController, WKNavigationDelegate, WKUIDelega
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))
web.scrollView.pinchGestureRecognizer?.isEnabled = false
}
@@ -503,6 +517,15 @@ final class BridgeController: UIViewController, WKNavigationDelegate, WKUIDelega
func webView(_ webView: WKWebView, runJavaScriptAlertPanelWithMessage message: String, initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping () -> Void) { let a = UIAlertController(title: nil, message: message, preferredStyle: .alert); a.addAction(UIAlertAction(title: MobileStrings.text("好"), style: .default) { _ in completionHandler() }); present(a, animated: true) }
func webView(_ webView: WKWebView, runJavaScriptConfirmPanelWithMessage message: String, initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping (Bool) -> Void) { let a = UIAlertController(title: nil, message: message, preferredStyle: .alert); a.addAction(UIAlertAction(title: MobileStrings.text("取消"), style: .cancel) { _ in completionHandler(false) }); a.addAction(UIAlertAction(title: MobileStrings.text("确认"), style: .default) { _ in completionHandler(true) }); present(a, animated: true) }
func webView(_ webView: WKWebView, runJavaScriptTextInputPanelWithPrompt prompt: String, defaultText: String?, initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping (String?) -> Void) {
+ if prompt == "codexbridge-system-bars:" + 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),
+ let text = defaultText, text.utf16.count < 100, let data = text.data(using: .utf8),
+ let colors = try? JSONSerialization.jsonObject(with: data) as? [String: String],
+ let top = Self.barColor(colors["top"]), let bottom = Self.barColor(colors["bottom"]) else { completionHandler(nil); return }
+ updateSystemBars(top: top, bottom: bottom); completionHandler("saved"); return
+ }
if prompt == "codexbridge-computer:" + clipboardToken {
guard webView === web, frame.isMainFrame, let source = frame.request.url, GatewayURL.same(source, origin),
let current = webView.url, GatewayURL.same(current, origin) else { completionHandler(nil); return }
@@ -521,6 +544,22 @@ final class BridgeController: UIViewController, WKNavigationDelegate, WKUIDelega
UIPasteboard.general.string = text
completionHandler("copied")
}
+ private static func barColor(_ value: String?) -> UIColor? {
+ guard let value, value.range(of: "^#[0-9a-fA-F]{6}$", options: .regularExpression) != nil,
+ let rgb = UInt32(value.dropFirst(), radix: 16) else { return nil }
+ return UIColor(red: CGFloat((rgb >> 16) & 255) / 255, green: CGFloat((rgb >> 8) & 255) / 255, blue: CGFloat(rgb & 255) / 255, alpha: 1)
+ }
+ private func updateSystemBars(top: UIColor = .systemGroupedBackground, bottom: UIColor = .systemGroupedBackground, reset: Bool = false) {
+ view.backgroundColor = bottom; topBackground.backgroundColor = top
+ let appearance = UINavigationBarAppearance(); appearance.configureWithOpaqueBackground()
+ appearance.backgroundColor = top; appearance.shadowColor = .clear
+ navigationController?.navigationBar.standardAppearance = appearance
+ navigationController?.navigationBar.scrollEdgeAppearance = appearance
+ var red: CGFloat = 0, green: CGFloat = 0, blue: CGFloat = 0, alpha: CGFloat = 0
+ top.resolvedColor(with: traitCollection).getRed(&red, green: &green, blue: &blue, alpha: &alpha)
+ pageStatusBarStyle = reset ? .default : (red * 0.299 + green * 0.587 + blue * 0.114 >= 0.5 ? .darkContent : .lightContent)
+ setNeedsStatusBarAppearanceUpdate(); navigationController?.setNeedsStatusBarAppearanceUpdate()
+ }
private func isArtifact(_ url: URL) -> Bool {
DownloadManager.allows(url, origin: origin)
}
@@ -529,6 +568,17 @@ final class BridgeController: UIViewController, WKNavigationDelegate, WKUIDelega
guard let url = request.url, isArtifact(url) else { return }
downloadPanel.expand(); downloads.start(url, origin: origin)
}
+ private func openDownload() {
+ guard downloadState.phase == .complete, let file = downloadState.file, presentedViewController == nil else { return }
+ let document = UIDocumentInteractionController(url: file)
+ document.delegate = self; openDocument = document
+ // The menu transfers the document to the receiving app; the active task retains its source.
+ if !document.presentOpenInMenu(from: downloadPanel.bounds, in: downloadPanel, animated: true),
+ !document.presentPreview(animated: true) {
+ openDocument = nil; info(MobileStrings.text("暂无可打开此文件的应用"))
+ }
+ }
+ func documentInteractionControllerViewControllerForPreview(_ controller: UIDocumentInteractionController) -> UIViewController { self }
private func saveDownload() {
guard downloadState.phase == .complete, let file = downloadState.file, presentedViewController == nil else { return }
let picker = UIDocumentPickerViewController(forExporting: [file], asCopy: true); picker.delegate = self
@@ -925,6 +975,7 @@ final class DownloadPanel: UIView {
var onToggle: (() -> Void)?
var onCancel: (() -> Void)?
var onSave: (() -> Void)?
+ var onOpen: (() -> Void)?
private let title = UILabel()
private let detail = UILabel()
private let status = UILabel()
@@ -932,6 +983,7 @@ final class DownloadPanel: UIView {
private let content = UIStackView()
private let toggle = UIButton(type: .system)
private let save = UIButton(type: .system)
+ private let open = UIButton(type: .system)
private let cancel = UIButton(type: .system)
private let collapse = UIButton(type: .system)
private var collapsed = false
@@ -957,7 +1009,7 @@ final class DownloadPanel: UIView {
progress.progressTintColor = tintColor; progress.trackTintColor = .tertiarySystemFill
content.addArrangedSubview(detail); content.addArrangedSubview(progress); content.addArrangedSubview(status)
let actions = UIStackView(); actions.axis = .horizontal; actions.spacing = 8; actions.distribution = .fillEqually; content.addArrangedSubview(actions)
- for button in [toggle, save, cancel] {
+ for button in [toggle, open, save, cancel] {
var config = UIButton.Configuration.tinted(); config.cornerStyle = .medium
config.baseForegroundColor = tintColor; config.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { incoming in var output = incoming; output.font = .systemFont(ofSize: 14, weight: .medium); return output }
button.configuration = config; button.heightAnchor.constraint(greaterThanOrEqualToConstant: 40).isActive = true; actions.addArrangedSubview(button)
@@ -965,13 +1017,14 @@ final class DownloadPanel: UIView {
toggle.addAction(UIAction { [weak self] _ in self?.onToggle?() }, for: .touchUpInside)
cancel.addAction(UIAction { [weak self] _ in self?.onCancel?() }, for: .touchUpInside)
save.addAction(UIAction { [weak self] _ in self?.onSave?() }, for: .touchUpInside)
+ open.addAction(UIAction { [weak self] _ in self?.onOpen?() }, for: .touchUpInside)
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
func expand() { collapsed = false; render(state) }
func render(_ state: DownloadManager.Snapshot) {
self.state = state; isHidden = !state.visible; content.isHidden = collapsed
let percentage = state.total.map { $0 == 0 ? 100 : min(100, Int(Double(state.received) / Double($0) * 100)) }
- let phases: [DownloadManager.Phase: String] = [.connecting: "正在连接…", .downloading: "正在下载…", .paused: "已暂停", .failed: "下载未完成", .complete: "下载完成,待保存", .saved: "已保存", .idle: "", .cancelled: "已取消"]
+ let phases: [DownloadManager.Phase: String] = [.connecting: "正在连接…", .downloading: "正在下载…", .paused: "已暂停", .failed: "下载未完成", .complete: "下载完成,可打开或保存", .saved: "已保存", .idle: "", .cancelled: "已取消"]
let phase = MobileStrings.text(phases[state.phase] ?? "")
title.text = state.name + (collapsed ? " · " + (state.phase == .downloading ? percentage.map { "\($0)%" } ?? phase : phase) : "")
title.accessibilityLabel = state.name
@@ -986,7 +1039,8 @@ final class DownloadPanel: UIView {
toggle.isHidden = ![.connecting, .downloading, .paused, .failed].contains(state.phase)
toggle.configuration?.title = MobileStrings.text([.connecting, .downloading].contains(state.phase) ? "暂停" : state.canResume ? "继续下载" : "重新下载")
save.isHidden = state.phase != .complete; save.configuration?.title = MobileStrings.text("保存到文件")
- cancel.configuration?.title = MobileStrings.text(state.phase == .saved ? "关闭" : "取消下载")
+ open.isHidden = state.phase != .complete; open.configuration?.title = MobileStrings.text("打开")
+ cancel.configuration?.title = MobileStrings.text([.complete, .saved].contains(state.phase) ? "关闭" : "取消下载")
}
}
@@ -1023,7 +1077,8 @@ enum MobileStrings {
"正在连接…": "Connecting…",
"已暂停": "Paused",
"下载未完成": "Download incomplete",
- "下载完成,待保存": "Downloaded · ready to save",
+ "下载完成,可打开或保存": "Downloaded · open or save",
+ "暂无可打开此文件的应用": "No app is available to open this file.",
"已保存": "Saved",
"已取消": "Cancelled",
"展开下载": "Expand download",
diff --git a/mobile/ios/BridgePreview/Info.plist b/mobile/ios/BridgePreview/Info.plist
index 0348c85f..49a46a63 100644
--- a/mobile/ios/BridgePreview/Info.plist
+++ b/mobile/ios/BridgePreview/Info.plist
@@ -13,9 +13,9 @@
CFBundlePackageType
APPL
CFBundleShortVersionString
- 2.0.1
+ 2.0.2
CFBundleVersion
- 28
+ 29
LSRequiresIPhoneOS
NSSupportsLiveActivities
@@ -56,6 +56,6 @@
BridgeReleaseVersion
- 2.0.1
+ 2.0.2
diff --git a/mobile/ios/BridgePreview/WebScripts.swift b/mobile/ios/BridgePreview/WebScripts.swift
index bbe57a5c..c0c5eda5 100644
--- a/mobile/ios/BridgePreview/WebScripts.swift
+++ b/mobile/ios/BridgePreview/WebScripts.swift
@@ -4,10 +4,12 @@ import Foundation
struct WebScripts {
let appearance: String
let clipboard: String
+ let systemBars: String
init(bundle: Bundle = .main) throws {
appearance = try Self.source("mobile-ui", bundle: bundle)
clipboard = try Self.source("mobile-clipboard", bundle: bundle)
+ systemBars = try Self.source("mobile-system-bars", bundle: bundle)
}
private static func source(_ name: String, bundle: Bundle) throws -> String {
diff --git a/mobile/shared/AGENTS.md b/mobile/shared/AGENTS.md
index cc884e66..e3b687ac 100644
--- a/mobile/shared/AGENTS.md
+++ b/mobile/shared/AGENTS.md
@@ -1,6 +1,6 @@
# 两端共用的 WebView 脚本
-- `web/mobile-ui.js` 和 `web/mobile-clipboard.js` 是 Android/iOS 的唯一源,按 UI、剪贴板顺序加载。不要在平台代码生成或复制同一份脚本。
+- `web/mobile-ui.js`、`web/mobile-clipboard.js` 和 `web/mobile-system-bars.js` 是 Android/iOS 的唯一源,按 UI、剪贴板、系统区域颜色顺序加载。不要在平台代码生成或复制同一份脚本。
- 脚本只补充手机原生壳的页面适配;共享聊天功能与布局仍在根目录 `web/`。
- 保留 token 占位符、剪贴板真实点击/单次消费与主框架保护。原生权限和 origin 校验留在各平台原生桥,不能靠页面自行声明可信。
- 新增或重命名资源必须同时更新 Android 普通构建、FCM Gradle 和 Xcode 主目标资源清单,并运行 `mobile/tests/resources.test.cjs`。不得把 Android 专有 `mobile-session.js` 纳入 iOS。
diff --git a/mobile/shared/web/mobile-system-bars.js b/mobile/shared/web/mobile-system-bars.js
new file mode 100644
index 00000000..c6e7c234
--- /dev/null
+++ b/mobile/shared/web/mobile-system-bars.js
@@ -0,0 +1,48 @@
+(() => {
+ if (window !== window.top || window.BridgeNativeSystemBars) return;
+ window.BridgeNativeSystemBars = true;
+ let pending = false, previous = '';
+ function opaqueColor(value) {
+ const match = /^rgba?\(\s*(\d+)[, ]+\s*(\d+)[, ]+\s*(\d+)(?:\s*[,/]\s*([\d.]+))?\s*\)$/.exec(value);
+ if (!match || match[4] !== undefined && Number(match[4]) !== 1) return null;
+ return '#' + match.slice(1, 4).map(v => Number(v).toString(16).padStart(2, '0')).join('');
+ }
+ function colorAt(y) {
+ let element = document.elementFromPoint(innerWidth / 2, y);
+ while (element) {
+ const color = opaqueColor(getComputedStyle(element).backgroundColor);
+ if (color) return color;
+ element = element.parentElement;
+ }
+ return opaqueColor(getComputedStyle(document.documentElement).backgroundColor) || '#ffffff';
+ }
+ function refresh() {
+ if (pending) return;
+ pending = true;
+ requestAnimationFrame(() => {
+ pending = false;
+ const colors = JSON.stringify({ top: colorAt(1), bottom: colorAt(Math.max(1, innerHeight - 1)) });
+ if (colors === previous) return;
+ if (window.prompt('codexbridge-system-bars:__BRIDGE_CLIPBOARD_TOKEN__', colors) === 'saved') previous = colors;
+ });
+ }
+ const watchDialog = dialog => observer.observe(dialog, { attributes: true, attributeFilter: ['open'] });
+ const observer = new MutationObserver(records => {
+ for (const record of records) {
+ if (record.type === 'childList') {
+ for (const node of record.addedNodes) if (node.nodeName === 'DIALOG') watchDialog(node);
+ }
+ }
+ refresh();
+ });
+ // Watch only page containers and dialog state, keeping streamed message mutations out of this observer.
+ for (const element of [document.documentElement, document.body, document.getElementById('app')]) {
+ if (element) observer.observe(element, { attributes: true, childList: element === document.body, attributeFilter: ['class', 'style', 'data-theme', 'hidden'] });
+ }
+ document.querySelectorAll('dialog').forEach(watchDialog);
+ window.addEventListener('resize', refresh);
+ window.addEventListener('focus', refresh);
+ document.addEventListener('visibilitychange', refresh);
+ window.visualViewport?.addEventListener('resize', refresh);
+ refresh();
+})();
diff --git a/mobile/tests/resources.test.cjs b/mobile/tests/resources.test.cjs
index 0db46e9a..56d7ad1e 100644
--- a/mobile/tests/resources.test.cjs
+++ b/mobile/tests/resources.test.cjs
@@ -5,9 +5,16 @@ const fs = require('node:fs');
const path = require('node:path');
const root = path.resolve(__dirname, '../..');
const read = value => fs.readFileSync(path.join(root, value), 'utf8');
-const shared = ['mobile-ui.js', 'mobile-clipboard.js'];
+const shared = ['mobile-ui.js', 'mobile-clipboard.js', 'mobile-system-bars.js'];
const android = [...shared, 'mobile-session.js'];
+test('both Android variants register the downloaded-file viewer provider', () => {
+ for (const manifest of ['mobile/android/AndroidManifest.xml', 'mobile/android-push/AndroidManifest.xml']) {
+ const source = read(manifest);
+ assert.match(source, /]*android:name="io\.github\.try2love\.codexbridge\.DownloadProvider"[^>]*android:exported="false"[^>]*android:grantUriPermissions="true"/);
+ }
+});
+
function xcodeObjects() {
const source = read('mobile/ios/BridgePreview.xcodeproj/project.pbxproj');
return new Map([...source.matchAll(/([A-F0-9]{24})(?: \/\*[^*]*\*\/)? = \{([\s\S]*?)\};/g)]
@@ -70,7 +77,7 @@ test('iOS missing resources stop connection visibly before replacing the current
const connect = app.slice(app.indexOf('private func connect('), app.indexOf('@objc private func refresh()'));
assert.match(connect, /do \{ scripts = try WebScripts\(\) \}\s+catch \{ info\(error\.localizedDescription\); return \}/);
assert.ok(connect.indexOf('try WebScripts()') < connect.indexOf('clear()'));
- assert.equal([...connect.matchAll(/injectionTime: \.atDocumentEnd, forMainFrameOnly: true/g)].length, 3);
+ assert.equal([...connect.matchAll(/injectionTime: \.atDocumentEnd, forMainFrameOnly: true/g)].length, 4);
assert.match(connect, /scripts\.appearance\.replacingOccurrences\(of: "__BRIDGE_CLIPBOARD_TOKEN__", with: clipboardToken\)/);
assert.match(connect, /scripts\.clipboard\.replacingOccurrences\(of: "__BRIDGE_CLIPBOARD_TOKEN__", with: clipboardToken\)/);
assert.doesNotMatch(app, /private static let web(Appearance|Clipboard) =/);
diff --git a/mobile/tests/system-bars.test.cjs b/mobile/tests/system-bars.test.cjs
new file mode 100644
index 00000000..b23e0ea1
--- /dev/null
+++ b/mobile/tests/system-bars.test.cjs
@@ -0,0 +1,28 @@
+'use strict';
+const test=require('node:test'),assert=require('node:assert/strict'),fs=require('node:fs'),path=require('node:path'),vm=require('node:vm');
+const source=fs.readFileSync(path.join(__dirname,'../shared/web/mobile-system-bars.js'),'utf8');
+function page(){
+ const events={},frames=[],calls=[],observed=[];
+ const html={color:'rgb(255, 255, 255)'},body={color:'rgb(10, 20, 30)',parentElement:html};
+ const top={color:'rgb(30, 40, 50)',parentElement:body},bottom={color:'rgb(240, 241, 242)',parentElement:body};
+ let accepted=true,observer;
+ const document={documentElement:html,body,getElementById:()=>body,querySelectorAll:()=>[],elementFromPoint:(_x,y)=>y===1?top:bottom,addEventListener:(name,callback)=>events[name]=callback};
+ const window={addEventListener:(name,callback)=>events[name]=callback,prompt:(name,value)=>{calls.push([name,JSON.parse(value)]);return accepted?'saved':null;}};window.top=window;
+ const context=vm.createContext({window,document,innerWidth:390,innerHeight:800,getComputedStyle:element=>({backgroundColor:element.color}),requestAnimationFrame:callback=>frames.push(callback),MutationObserver:class{constructor(callback){observer=callback;}observe(node,options){observed.push({node,options});}}});
+ vm.runInContext(source,context);
+ return {calls,events,top,bottom,observed,flush:()=>{while(frames.length)frames.shift()();},accept:value=>{accepted=value;},change:()=>observer([])};
+}
+test('shared theme sampler sends top and bottom colors once until they change',()=>{
+ const ui=page();ui.flush();assert.deepEqual(ui.calls[0][1],{top:'#1e2832',bottom:'#f0f1f2'});
+ ui.events.resize();ui.change();ui.flush();assert.equal(ui.calls.length,1);
+ ui.bottom.color='rgb(0, 0, 0)';ui.events.resize();ui.flush();assert.equal(ui.calls[1][1].bottom,'#000000');
+ assert.ok(ui.observed.every(value=>!value.options.subtree));
+});
+test('theme sampler retries after a native rejection and foreground return',()=>{
+ const ui=page();ui.accept(false);ui.flush();assert.equal(ui.calls.length,1);
+ ui.accept(true);ui.events.focus();ui.events.visibilitychange();ui.flush();assert.equal(ui.calls.length,2);
+ ui.events.focus();ui.flush();assert.equal(ui.calls.length,2);
+});
+test('transparent elements use their opaque parent background',()=>{
+ const ui=page();ui.top.color='rgba(255, 255, 255, 0)';ui.flush();assert.equal(ui.calls[0][1].top,'#0a141e');
+});
diff --git a/package-lock.json b/package-lock.json
index aecb3755..e1e1da1d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "codex-mobile-bridge-desktop",
- "version": "2.0.1",
+ "version": "2.0.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "codex-mobile-bridge-desktop",
- "version": "2.0.1",
+ "version": "2.0.2",
"license": "MIT",
"dependencies": {
"qrcode": "1.5.4"
diff --git a/package.json b/package.json
index 590cb18a..40aba66d 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "codex-mobile-bridge-desktop",
"desktopName": "codex-mobile-bridge.desktop",
- "version": "2.0.1",
+ "version": "2.0.2",
"description": "Codex App 手机网关桌面启动器",
"author": "try2love",
"license": "MIT",
diff --git a/scripts/build-mobile-android.py b/scripts/build-mobile-android.py
index a533db3d..83ce186e 100644
--- a/scripts/build-mobile-android.py
+++ b/scripts/build-mobile-android.py
@@ -1,6 +1,7 @@
#!/usr/bin/env python3
"""Build a local debug APK using project-local JDK/SDK; never installs tools."""
import hashlib
+import argparse
import json
import os
from pathlib import Path
@@ -9,6 +10,9 @@
import zipfile
ROOT = Path(__file__).resolve().parents[1]
+parser = argparse.ArgumentParser(description=__doc__)
+parser.add_argument('--output-dir', type=Path, default=ROOT/'dist/mobile-preview')
+options = parser.parse_args()
tools = ROOT/'.tmp/mobile-tools'
jdks = list((tools/'jdk').glob('*/Contents/Home'))
if not jdks:
@@ -51,7 +55,7 @@ def run(*args):
if not key.exists():
run(jdk/'bin/keytool','-genkeypair','-keystore',key,'-storepass','android','-keypass','android','-alias','preview','-keyalg','RSA','-keysize','2048','-validity','3650','-dname','CN=Bridge Local Preview')
run(build_tools/'zipalign','-f','4',work/'unsigned.apk',work/'aligned.apk')
-out = ROOT/'dist/mobile-preview'
+out = options.output_dir.resolve()
out.mkdir(parents=True,exist_ok=True)
version = json.loads((ROOT/'package.json').read_text())['version']
apk = out/('Codex-Mobile-Bridge-'+version+'-Android.apk')
diff --git a/scripts/build-mobile-ios.py b/scripts/build-mobile-ios.py
index b0b9af88..d0a85f7a 100644
--- a/scripts/build-mobile-ios.py
+++ b/scripts/build-mobile-ios.py
@@ -18,6 +18,7 @@
REQUIRED_SOURCES = ('mobile/ios/BridgePreview.xcodeproj/project.pbxproj',
'mobile/ios/BridgePreview/Info.plist',
'mobile/shared/web/mobile-ui.js', 'mobile/shared/web/mobile-clipboard.js',
+ 'mobile/shared/web/mobile-system-bars.js',
'web/client-icons/codex.png', 'web/client-icons/claude.png',
'web/client-icons/deepseek.png', 'mobile/README.md', 'LICENSE')
@@ -56,7 +57,7 @@ def verify_app(app, version, build):
if (bundle/'embedded.mobileprovision').exists() or (bundle/'_CodeSignature').exists():
raise RuntimeError('Expected an unsigned iOS bundle: '+str(bundle))
subprocess.run(['xcrun', 'lipo', str(executable), '-verify_arch', 'arm64'], check=True)
- for name in ('mobile-ui.js', 'mobile-clipboard.js', 'codex.png', 'claude.png', 'deepseek.png'):
+ for name in ('mobile-ui.js', 'mobile-clipboard.js', 'mobile-system-bars.js', 'codex.png', 'claude.png', 'deepseek.png'):
if not (app/name).is_file():
raise RuntimeError('Missing iOS resource: '+name)
info = plistlib.loads((app/'Info.plist').read_bytes())
diff --git a/scripts/smoke-windows.cjs b/scripts/smoke-windows.cjs
index 51317c24..f36a9a11 100644
--- a/scripts/smoke-windows.cjs
+++ b/scripts/smoke-windows.cjs
@@ -50,6 +50,9 @@ async function main(){
const port=await freePort(),debugPort=await freePort();
const env={...process.env,CMB_DATA_DIR:data,PATH:path.join(process.env.SystemRoot,'System32')};
for(const key of ['CMB_PYTHON','PYTHONPATH','PYTHONHOME','ELECTRON_RUN_AS_NODE'])delete env[key];
+ const powershell=path.join(process.env.SystemRoot,'System32/WindowsPowerShell/v1.0/powershell.exe');
+ const startupKey='HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Run';
+ const originalStartup=JSON.parse((await execute(powershell,['-NoProfile','-NonInteractive','-Command',`ConvertTo-Json -Compress -InputObject (Get-Item -LiteralPath '${startupKey}').GetValue('Codex Mobile Bridge')`],{env,windowsHide:true,timeout:15000})).stdout.trim()||'null');
async function worker(action,payload){
if(payload===undefined){const {stdout}=await execute(runtime,[action,'--data-dir',data],{env,windowsHide:true,timeout:30000,maxBuffer:4*1024*1024});const value=JSON.parse(stdout);assert.equal(value.ok,true,value.error);return value.result;}
return new Promise((resolve,reject)=>{
@@ -76,8 +79,8 @@ async function main(){
assert.equal((await fs.readFile(exported.path)).subarray(0,2).toString(),'PK');
}
let stderr='',client,started=false,child,exited;
- function launch(){
- child=spawn(executable,['--remote-debugging-address=127.0.0.1','--remote-debugging-port='+debugPort],{env,stdio:['ignore','ignore','pipe']});
+ function launch(extra=[]){
+ child=spawn(executable,['--remote-debugging-address=127.0.0.1','--remote-debugging-port='+debugPort,...extra],{env,stdio:['ignore','ignore','pipe']});
child.stderr.on('data',value=>stderr+=value);
exited=new Promise(resolve=>{child.on('exit',resolve);child.on('error',resolve);});
}
@@ -185,12 +188,34 @@ async function main(){
assert.equal(await client.evaluate('document.documentElement.lang'),'en');
assert.equal(await client.evaluate("document.getElementById('language').value"),'en');
assert.equal(await client.evaluate("document.getElementById('start').textContent"),'Start gateway');
- console.log(JSON.stringify({ok:true,executable,data,screenshots,checks:['bundled runtime without Python or Node on PATH','Unicode data path','renderer isolation','start and stop','private HTTP routes','local assets','parallel connection settings','server and NAS deployment ZIPs','11 mobile bilingual browser checks','notification settings saved without publishing','close to tray keeps gateway online','second launch restores window','bilingual switch preserves drafts','language survives app restart','English layout at 820x640','no horizontal overflow']}));
+ assert.equal(await client.evaluate('snapshot.loginStartup.supported'),true);
+ assert.equal(await client.evaluate("$('login-startup-setting').hidden"),false);
+ const previousStartup=await client.evaluate('snapshot.loginStartup.enabled');
+ try{
+ await client.evaluate("$('login-startup').checked=true;$('login-startup').onchange();$('settings').requestSubmit()");
+ await until(()=>client.evaluate('!saving&&!dirty&&snapshot.loginStartup.enabled'),'Login startup save');
+ assert.equal(await client.evaluate('snapshot.preferences.autoStart'),true);
+ client.close();child.kill();await exited;launch(['--login-startup']);
+ // Hidden renderer evaluation can pause; gateway readiness is checked independently.
+ await until(async()=>{const value=await worker('snapshot');return value.runtime.running;},'Hidden login gateway startup',60000);started=true;
+ const visibility=await execute(path.join(process.env.SystemRoot,'System32/WindowsPowerShell/v1.0/powershell.exe'),['-NoProfile','-NonInteractive','-Command',`(Get-Process -Id ${child.pid}).MainWindowHandle`],{env,windowsHide:true,timeout:15000});
+ assert.equal(visibility.stdout.trim(),'0','Login launch must stay in tray');
+ await execute(executable,[],{env,timeout:15000});await connectApp();
+ await until(()=>client.evaluate("document.visibilityState==='visible'"),'Manual restore after login launch');
+ await client.evaluate('stopGateway()');started=false;
+ }finally{
+ if(client&&child.exitCode===null){
+ await client.evaluate(`$('login-startup').checked=${previousStartup};$('login-startup').onchange();$('settings').requestSubmit()`);
+ await until(()=>client.evaluate(`!saving&&!dirty&&snapshot.loginStartup.enabled===${previousStartup}`),'Restore login startup preference');
+ }
+ }
+ console.log(JSON.stringify({ok:true,executable,data,screenshots,checks:['login startup save and readback','hidden login launch starts gateway','manual restore after login launch','bundled runtime without Python or Node on PATH','Unicode data path','renderer isolation','start and stop','private HTTP routes','local assets','parallel connection settings','server and NAS deployment ZIPs','11 mobile bilingual browser checks','notification settings saved without publishing','close to tray keeps gateway online','second launch restores window','bilingual switch preserves drafts','language survives app restart','English layout at 820x640','no horizontal overflow']}));
}finally{
if(started){try{await worker('stop');}catch(error){console.error('Test cleanup:',error.message);}}
client?.close();
if(child.exitCode===null)child.kill();
await exited;
+ await execute(powershell,['-NoProfile','-NonInteractive','-Command',`if($env:CMB_TEST_STARTUP_EXISTS -eq '1'){Set-ItemProperty -LiteralPath '${startupKey}' -Name 'Codex Mobile Bridge' -Value $env:CMB_TEST_STARTUP_VALUE}elseif($null -ne (Get-Item -LiteralPath '${startupKey}').GetValue('Codex Mobile Bridge')){Remove-ItemProperty -LiteralPath '${startupKey}' -Name 'Codex Mobile Bridge' -ErrorAction Stop}`],{env:{...env,CMB_TEST_STARTUP_EXISTS:originalStartup===null?'0':'1',CMB_TEST_STARTUP_VALUE:originalStartup||''},windowsHide:true,timeout:15000});
}
}
main().catch(error=>{console.error(error);process.exitCode=1;});
diff --git a/site/index.html b/site/index.html
index d1b46685..11894d2d 100644
--- a/site/index.html
+++ b/site/index.html
@@ -27,7 +27,7 @@
开源 · 自托管 · V2.0 正式版
你的工作台, 不止在电脑前。 Codex、Claude Desktop、DeepSeek Harness 各司其职,你在手机上接着聊、查文件、看 Git、用终端。浏览器随开随用,Android 与 iOS App 随身接力。
代码开源,欢迎共建。觉得有用,欢迎前往 GitHub 点个 Star,也欢迎反馈问题、贡献代码。
任务仍在你的电脑或 SSH 主机上执行,执行端需保持在线。
2.0 STABLE 从远程聊天, 到随身工作台。
Web Android iOS
-不必安装,先走一遍你的工作流程。点击设备里的按钮,也可以直接选择下方场景。
iOS App Android App 手机 Web 电脑 Web 电脑网关 App
模拟交互 · 无需登录 直接体验 聊天 文件与预览 Git 历史与提交 连续终端 侧边聊天 子智能体
工作台与电脑网关使用 v2.0.0 产品界面和示例数据,手机首页为浏览器模拟。聊天回复、客户端状态、终端输出和 Git 操作均在本页模拟;不连接电脑、不调用模型、不读取或上传你的文件,刷新即重置。
交互演示需要启用 JavaScript。下方仍可阅读完整功能与安装说明。 / Enable JavaScript for the demo; features and installation instructions are below.
THREE CLIENTS / ONE BRIDGE
三个应用,各自继续自己的工作。 扫描电脑中已安装的客户端,只启用需要的应用。底部切换或左右滑动聊天列表,各自的会话与项目互不混淆。
Codex 继续本机与 SSH 会话,保留模型、Skill、权限、计划和目标操作。官方账号与兼容 API 沿用原桌面工作环境。
Claude Desktop Code 与 Cowork 分组展示,继续消息并回应支持的授权。自动接入支持 macOS、Windows;首次连接可能需要解锁电脑完成准备。
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 更新;不会自动下载安装。
STAY IN THE LOOP
收到提醒,也知道它的边界。 App 前台可同步任务提醒,通知可清空,点击可回到对应电脑和聊天。外部 Bark、ntfy、PushPlus 通道继续保留,支持的通知链接可跳回已保存的电脑。
iOS 灵动岛与锁屏实时活动 查看执行、待回应和连接状态,点击回到聊天。不显示聊天正文;App 不活跃时可能显示最后已知状态。
标准手机 App 不保证后台或锁屏通知送达,灵动岛也不等于后台实时推送。 04 / GET THE APPS
准备好,把演示变成你的工作台。 v2.0.0 · 网关与手机 App 配套使用
01 先安装电脑网关 macOS、Windows,以及实验性 Ubuntu x64 / ARM64。Web 工作台由网关提供,浏览器打开地址即可。
电脑端下载 ↗ 02 Android 下载 APK,按系统提示安装。已有预览版可覆盖安装,保留已保存的电脑。
下载 Android APK ↗ 03 iPhone IPA 需要用户自行签名后安装,也可下载 Xcode 工程,使用自己的 Apple 账号签名安装。尚无 App Store/TestFlight 版本,下载 IPA 不会直接安装。
手机 App 请搭配 v2.0.0 网关使用。升级前保留配置备份,相同端口只运行一个网关;iOS 仍需使用自己的 Apple 账号签名安装。
基础操作录像 · v1.3.0 界面(展开观看)
+不必安装,先走一遍你的工作流程。点击设备里的按钮,也可以直接选择下方场景。
iOS App Android App 手机 Web 电脑 Web 电脑网关 App
模拟交互 · 无需登录 直接体验 聊天 文件与预览 Git 历史与提交 连续终端 侧边聊天 子智能体
工作台与电脑网关使用 v2.0.0 产品界面和示例数据,手机首页为浏览器模拟。聊天回复、客户端状态、终端输出和 Git 操作均在本页模拟;不连接电脑、不调用模型、不读取或上传你的文件,刷新即重置。
交互演示需要启用 JavaScript。下方仍可阅读完整功能与安装说明。 / Enable JavaScript for the demo; features and installation instructions are below.
THREE CLIENTS / ONE BRIDGE
三个应用,各自继续自己的工作。 扫描电脑中已安装的客户端,只启用需要的应用。底部切换或左右滑动聊天列表,各自的会话与项目互不混淆。
Codex 继续本机与 SSH 会话,保留模型、Skill、权限、计划和目标操作。官方账号与兼容 API 沿用原桌面工作环境。
Claude Desktop Code 与 Cowork 分组展示,继续消息并回应支持的授权。自动接入支持 macOS、Windows;首次连接可能需要解锁电脑完成准备。
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 更新;不会自动下载安装。
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 ↗ 03 iPhone IPA 需要用户自行签名后安装,也可下载 Xcode 工程,使用自己的 Apple 账号签名安装。尚无 App Store/TestFlight 版本,下载 IPA 不会直接安装。
手机 App 请搭配 v2.0.2 网关使用。升级前保留配置备份,相同端口只运行一个网关;iOS 仍需使用自己的 Apple 账号签名安装。
基础操作录像 · v1.3.0 界面(展开观看)
01 / WALKTHROUGH
先看看,它是怎么用的。 从启动网关到跨设备继续聊天,跟着实际界面走一遍。
直接打开视频
@@ -321,7 +321,7 @@ 桌面管理
保留设备身份和名称时,正常重启后地址不变。电脑需保持唤醒,网关需保持运行。Funnel 存在带宽限制,请用实际 Wi-Fi 和蜂窝网络验证,不能保证中国境内的低延迟。
只在私有网络内访问可选择 Serve,手机也需接入同一 Tailscale 网络;Funnel 提供公网 HTTPS 入口,无需手机安装 Tailscale。
-Tailscale 配置图解 → 查看正式版源码
+Tailscale 配置图解 → 查看正式版源码
手机浏览器 自己的固定域名
→ Cloudflare 固定隧道 · HTTPS
→ 电脑网关 内置 cloudflared
→ 桌面客户端 电脑保持在线
Cloudflare Tunnel 的访问链路可能经过境外节点,中国大陆直连可能较慢或超时;稳定的代理线路可能改善访问体验,实际速度以当前网络为准。
只有域名,没有公网服务器,也能使用固定 HTTPS 地址。 域名在原注册商续费,DNS 托管到 Cloudflare;电脑、Codex 和网关需要保持在线。try2love.com 和 https://codex.try2love.com 仅用于配置演示,不代表可访问的演示站点,请替换成自己的域名。
首次配置:从域名到手机访问 1 添加自己的域名 在 Cloudflare 添加 try2love.com,复制分配的两条名称服务器(NS)。已有网站和邮箱时,先核对并保留原 DNS 记录。
2 修改域名的 DNS 服务器 在阿里云等注册商的域名管理中,把 DNS 服务器改为这两条 NS,等待 Cloudflare 显示 Active。域名仍在原注册商续费;后续解析在 Cloudflare 管理。
3 创建隧道,取得 Token 在 Cloudflare 的 Tunnels 中创建 Cloudflared 隧道,从连接器安装命令中复制 Tunnel Token。App 已内置程序,无需再执行安装命令;只复制 Token,不是整条命令或 API Key。
4 填写 App 并启动网关 添加“固定域名 · Cloudflare Tunnel”,填写 https://codex.try2love.com 和 Token,启用、保存并启动网关。回到 Cloudflare 确认连接器已连接。
5 把域名指向电脑网关 在隧道 Routes 中添加 Published application / 公开主机名:子域名 codex,域名 try2love.com,路径留空。服务类型选 HTTP,地址填 localhost:8787(端口与 App 一致);保存后自动创建 DNS 记录。
6 开启 HTTP → HTTPS 跳转 在 Cloudflare 为网关子域名添加 HTTPS 重定向规则。打开 HTTP 地址应先跳到 HTTPS 再登录;具体字段见下方完整图解。
7 用手机完成最后验证 点击 App 的“检测固定入口”,再用电脑浏览器和手机蜂窝网络分别访问域名,验证登录、读取聊天和发送消息。Healthy 只代表隧道已连接,网页还需要实际验证。
找不到域名:检查 NS、Active 状态与 DNS 缓存。1033:检查隧道和 Token。502:检查本机网关、HTTP 服务类型和端口。无需把域名指向电脑 IP,也不需要 SSH 密码。
固定隧道完整配置图解 →
@@ -376,28 +376,28 @@
桌面管理
扫码登录不会自动打通网络。固定 HTTPS 和 NAS 需要首次配置;使用期间保持电脑唤醒、所选客户端与网关运行。
-06 / DOWNLOAD
让手边的设备,接上工作。 v2.0.0 · 当前正式版
-从 v1.4.0 到 v2.0.0:三客户端接入、完整文件与 Git 工作台、连续终端、分屏、手机 App、通知角标、图片缩放和 Tailscale 固定 HTTPS。
+06 / DOWNLOAD
让手边的设备,接上工作。 v2.0.2 · 当前正式版
+v2.0.2 改善文件链接、手机文件打开、登录自启,以及聊天列表滚动与下拉刷新。