Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Agent Note: Android 更新下载与安装交接

Status: implemented

## Problem

PR #32 把 Android 更新从浏览器下载改为 App 内下载并调用系统安装器。下载完成、离开 Activity 授予安装权限、返回 App、取消安装和保存 APK 之间需要保留同一个已校验文件,避免重复打开安装器或丢失任务。GitHub 不可达时,用户还需要明确的重试和版本页入口。

## Decision

- 下载仍复用 `ArtifactDownload.Task`。直接访问 GitHub 的更新请求只接受本项目固定 Release 资产地址与 GitHub HTTPS 重定向白名单,且不携带网关 Cookie。大小和 SHA-256 必须有效,完整校验通过后才能安装。后续增加的网关来源遵循 [网关协助更新记录](../feature/2026-10-12-gateway-assisted-android-update.md),安装交接约束继续适用。
- `UpdateInstallSession` 把已完成 APK 和安装权限交接状态保存在 App 私有目录。恢复及安装前重新校验文件;生命周期中断不代表文件损坏。下载完成事件只自动交接一次,用户取消安装后由「安装」按钮重试。
- `DownloadProvider` 完整复制后原子发布只读副本;发现旧副本长度不符时重新复制。系统保存器的返回结果等待原任务恢复,防止 Activity 重建后失去保存对象。
- 「保存」导出 APK,「安装」调用系统安装器。检查失败时提供 GitHub 版本页入口;下载失败卡片的「GitHub ↗」入口则在外部浏览器打开已检测到版本的 APK 直链,详见 [下载方式选择记录](../feature/2026-10-12-android-update-download-choice.md)。这些入口不能绕过网络对 GitHub 的限制。
- 重复更新演练使用 `.tmp/` 内的源码副本和独立 App 标识。局域网元数据、测试版本、同版本重装只存在于该副本;正式源码的版本选择仍严格要求更新版本。

## Alternatives considered

- **保留浏览器下载作为主流程**:实现简单,也方便用户使用浏览器的网络环境。但不能满足此次 App 内下载并直接安装的目标,因此仅作为失败兜底。
- **仅在 Activity 内保留安装对象**:没有落盘格式或恢复校验成本。但系统权限页和文件选择器可能触发 Activity 重建,导致文件已下载却无法继续操作,因此只将已完成文件及必要交接状态持久化。
- **正式源码提供任意更新服务器或强制更新开关**:测试方便,但会增加生产更新的信任入口。本次只在忽略提交的源码副本中替换测试输入。

## Testing

`scripts/test-mobile-downloads.py` 覆盖更新元数据缺失、摘要和大小不一致、重定向白名单、凭据隔离、重复交接、权限状态恢复、文件篡改及恢复被中断后的重试。原有普通附件下载和续传用例继续运行。Android 构建及隔离模拟器验证记录保存在 `.tmp/pr32-local/`;用户已报告实测通过,并授权仅合并源码。

## Consequences

已完成更新可以跨 Activity/进程恢复,用户仍控制系统安装。代价是维护少量私有状态并在恢复、安装时重新读取 APK 校验;下载中的跨进程续传不属于本次范围。版本已安装或用户关闭任务时清理待安装文件。

历史决策检索未发现已有 `.agents/notes/`、`docs/adr/` 或 `docs/decisions/` 对此交接的记录;手机安装说明位于 [mobile/README.md](../../../../mobile/README.md),无既有决定需要替代。
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Agent Note: Android 更新下载方式由用户选择

Status: implemented

## Problem

用户实测时,点击「下载并安装」后自动进入电脑下载,无法明确决定使用哪一端的网络。用户可能已知手机无法访问 GitHub,也可能希望仅使用手机网络,不应由一次请求失败代替其选择。

## Decision

点击「下载并安装」后显示两个操作:手机直接从 GitHub 下载、电脑从 GitHub 下载后传到手机。用户点选后才启动相应下载;取消不发起 APK 下载或网关准备。每次重新下载都可选择,不引入持久默认选项。

手机直下载失败后不自动使用电脑;下载卡片提供「重新选择」以及「GitHub ↗」入口。后者用外部浏览器打开当前已检测到版本的官方 APK 直链,由浏览器负责下载;即使原任务来自电脑,也不向浏览器传递网关地址或凭据。重选同一路径时可复用可续传任务,换路径时建立对应的新任务。暂停后的继续仍沿用已选路径。选择电脑时使用当前保存的电脑连接,继续核对登录、版本、大小和摘要。

版本查询仍允许直接查询失败后由电脑查询,避免手机无法发现更新。查询成功与哪一端负责下载是两个独立决定。网关来源限制、缓存、认证、中继及安装恢复沿用 [网关协助更新记录](2026-10-12-gateway-assisted-android-update.md) 和 [安装交接记录](../bug-fix/2026-10-12-android-update-handoff.md)。

## Alternatives considered

- **保留自动回退**:操作步骤少,网络正常时用户不必关心来源。但不能满足用户明确选择网络路径的要求,也会让已知手机网络不通的用户先等一次失败。
- **选择手机后仍自动回退到电脑**:失败时能省一次操作,但所选路径的含义不清晰。失败后由用户重新选择,保留重试与浏览器入口。

## Testing

用独立局域网测试包检查选择前无 APK 请求、取消无下载、手机失败不触发电脑 APK 请求、电脑选择直接请求网关,以及下载失败后的重新选择。补充验证下载失败卡片的「GitHub ↗」由外部 Chrome 打开 APK 直链,确认后下载的文件摘要与测试包一致;该局域网结果不代表真实 GitHub 网络已验证。安装包和模拟器验证证据存于 `.tmp/pr32-gateway/`。用户已报告实测通过,授权合并源码;本次不编译 App 或发布新版本。

## Consequences

用户可以直接选择可用网络,代价是下载前增加一次选择。电脑下载仍要求当前网关支持该功能且已登录;两端都不能访问 GitHub 时仍可能失败。此改动限于 Android 下载交互,不变更网关接口。

历史检索命中两篇相关记录,均为部分重叠:网关记录的下载自动回退部分被替代并互链,其来源、认证和缓存约束保留;安装交接记录的文件校验与恢复约束继续适用。无完全吸收或过时提案需要归档。
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Agent Note: 网关协助 Android 查询和下载安装包

Status: implemented

## Problem

手机无法访问 GitHub 时,应用内检查版本或下载 APK 都可能失败;浏览器入口不保证改变网络可达性。电脑网关可能有可用网络,手机也已建立对该电脑的认证连接。

## Decision

Android 查询版本保留直接访问 GitHub、失败后通过当前已连接电脑重试的路径。初版下载也采用此顺序,理由是 API 不通不代表 APK 下载地址不通;这一下载自动回退决定已由 [下载方式选择记录](2026-10-12-android-update-download-choice.md) 部分取代,查询备用路径和下面的网关契约继续适用。手机选中的版本、大小、摘要必须与网关从固定官方仓库读取的资产记录相同;网关只接受版本与资产身份,不接受任意 URL。安装交接继续使用 [Android 更新交接记录](../bug-fix/2026-10-12-android-update-handoff.md) 的已完成文件恢复机制。

网关先完整下载到私有缓存并校验,再通过已认证下载接口传给手机。准备请求使用 CSRF,进度查询不占用长请求。下载缓存只保留于当前运行周期,闲置超过 24 小时按需清理,最多两个任务/缓存,每次一个后台下载,每包最多 512 MiB。手机取消等待保留电脑后台任务,其他已登录手机可复用;手机端没有跨进程保留未完成下载的承诺。

现有附件传输的范围协商和中继流式协议继续复用。中继、连接器和网关都限制准确的更新路径与方法,网关认证信息只发往选定地址,GitHub 请求不携带这些信息。手机再次核对完整 APK 的大小和摘要,最终交给系统安装器确认。SHA-256 用于检测损坏,不被描述为独立的发布者签名。

## Alternatives considered

- **只保留手机直连和浏览器兜底**:单端实现、维护简单,但手机网络完全不可达时,两条路径都可能失败,不能利用已连接电脑的网络。
- **只代理 APK 文件**:新增接口较少,但手机可能无法获取版本信息,根本到不了下载阶段,因此查询也需要网关备用路径。
- **电脑边下载边直接转发一个长响应**:可以减少首次传输等待,但下载和手机断线耦合,重试与长时间无数据返回的处理更复杂。先缓存使完整性、进度和重试边界明确。

## Testing

`tests/test_mobile_updates.py` 覆盖发布通道、固定来源、资产身份、并发复用、失败重试、完整校验、过期、认证、CSRF 和范围响应;`tests/test_shared_relay.py` 通过真实本机连接器验证新接口。`GatewayUpdateDownloadTests.java` 验证带设备前缀的认证下载、摘要、禁止重定向和缓存失效后的重新准备。构建与模拟器证据保存在忽略提交的 `.tmp/pr32-gateway/`。

独立测试包使用局域网固定夹具模拟手机查询和下载均失败、仅下载失败;两种场景均经打包后的 macOS 网关完成 Android 模拟器内安装。安装权限页面期间终止 App 后台进程后可恢复安装;同版本测试更新可再次触发并完成。最终从模拟器取出的已安装 APK 摘要与提供的目标包一致。这些模拟结果不代表真实 GitHub 网络已验证;用户随后报告实测通过,并授权仅合并源码。

## Consequences

手机可以利用电脑的网络完成更新,电脑也可以复用已下载包。代价是网关存储、传输流量和跨端协议维护;共享中继必须同时升级白名单。电脑同样无法访问 GitHub 且没有缓存时,功能不能解决网络问题。旧网关不影响手机原有直连路径。

历史检索只命中前述安装交接记录,属于部分重叠:原先 GitHub 下载及安装恢复约束继续适用,本篇增加经网关传输的来源类型与认证契约,无其他提案需要归档。
1 change: 1 addition & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ jobs:
distribution: temurin
java-version: '17'
- run: python3 scripts/test-mobile-releases.py
- run: python3 scripts/test-mobile-downloads.py
test:
strategy:
fail-fast: false
Expand Down
8 changes: 8 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,14 @@ Electron 主进程使用 qrcode 本地生成 PNG,渲染器接收图片、授

`bridge/features/updates/gateway.py` 负责原安装目录旁的准备、替换与恢复。准备阶段拒绝路径穿越和危险链接,核对包版本、平台、macOS 签名和内置运行时。独立复制的旧运行时等控制面板退出、协作停止原网关后替换目录,再等待新面板确认加载并恢复此前运行的网关。失败时尝试回滚;配置目录保持独立。发布签名身份和事务恢复操作见 [桌面更新文档](docs/desktop-updates.md)。

## Android 更新协助

Android 优先直接检查本项目 GitHub Release,查询失败时可通过当前已登录网关查询版本。APK 下载由用户在「下载并安装」后选择手机直连或电脑下载再传送;取消选择不启动下载,下载失败不自动切换路径。下载卡片允许重新选择,暂停后的继续仍沿用原路径。交互取舍见 [下载方式选择记录](.agents/notes/implemented/feature/2026-10-12-android-update-download-choice.md)。`bridge/features/updates/mobile.py` 只接受固定仓库、版本对应的 APK、有效大小和 SHA-256。手机准备请求的版本、大小和摘要须与网关从官方源取得的记录一致;不接受手机指定外部 URL 或磁盘路径。

`GET /api/mobile/update?current=<版本>` 查询版本;带登录和 CSRF 的 `POST /api/mobile/update/prepare` 接受 `{version,size,sha256}`,启动或复用一个后台下载;`GET /api/mobile/update/status/<id>` 返回进度;`GET /api/mobile/update/packages/<id>` 只传送已完成校验的缓存包,并支持 Range/ETag。所有入口要求密码保护的登录会话。共享中继与连接器只放行这些准确的方法和路径,共用同一网关缓存。

网关单次最多下载 512 MiB,只并行下载一个包,最多保留两个任务/缓存;闲置超过 24 小时的缓存按需清理。缓存只属于当前网关运行周期。手机取消准备仅停止等待,电脑任务仍可供已登录手机复用。手机和网关各自校验完整 APK;手机安装恢复复用私有已校验文件,不依赖网关持续在线。网关 Cookie 只发往选定网关,不跟随重定向或发送给 GitHub。详细取舍见 [决策记录](.agents/notes/implemented/feature/2026-10-12-gateway-assisted-android-update.md)。


## PR8 验收反馈调整

Expand Down
32 changes: 32 additions & 0 deletions bridge/api/httpd.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
from bridge.clients.deepseek.legacy_proxy import proxy as harness_proxy
from bridge.features.auth.pairing import Pairing
from bridge.features.auth.auth import Auth, LoginRejected
from bridge.features.updates.mobile import MobileUpdates
from bridge.clients.codex.ipc import IPCError
from bridge.features.sessions.goal import GoalError
from bridge.clients.codex.catalog import CatalogError
Expand All @@ -55,6 +56,8 @@ class GatewayServer(ThreadingHTTPServer):

def __init__(self, address, bridge, config, web_dir, data_dir=None, shared=None):
self.transfer_directory = data_dir if shared is None else shared.transfer_directory
self.mobile_updates_owner = shared is None and data_dir is not None
self.mobile_updates = MobileUpdates(data_dir) if self.mobile_updates_owner else getattr(shared, 'mobile_updates', None)
self.local_access = config.get('localAccess', True)
if shared is None:
self.bridge = bridge
Expand Down Expand Up @@ -93,6 +96,11 @@ def ui_language(self):
except (OSError, ValueError, AttributeError):
return 'zh'

def server_close(self):
if self.mobile_updates_owner:
self.mobile_updates.close()
super().server_close()

def server_bind(self):
# HTTPServer resolves the listening IP with getfqdn(), which can stall
# startup on machines without working reverse DNS. Origins are explicit.
Expand Down Expand Up @@ -322,6 +330,30 @@ def handle_method(self, write):
auth = self.authorized(write)
if not auth:
return
mobile_update = re.fullmatch(r'/api/mobile/update(?:/(prepare|status/([a-f0-9]{64})|packages/([a-f0-9]{64})))?', path)
if mobile_update:
if self.server.auth.config.get('mode') == 'none':
raise PermissionError('请启用密码保护后使用电脑协助更新')
updates = self.server.mobile_updates
if updates is None:
return self.output(503, {'error': '此网关暂不支持协助更新'})
operation, identifier, package = mobile_update.groups()
if write:
if operation != 'prepare' or query:
return self.output(405, {'error': '请求方式无效'})
return self.output(200, updates.prepare(self.read_json()))
if operation is None:
if set(query) != {'current'} or len(query['current']) != 1:
raise ValueError('更新请求无效')
return self.output(200, updates.check(query['current'][0]))
if query:
raise ValueError('更新请求无效')
if identifier:
return self.output(200, updates.status(identifier))
if package:
with updates.download(package, **self.download_request()) as (metadata, stream):
return self.download_response(metadata, stream, 'application/vnd.android.package-archive')
return self.output(405, {'error': '请求方式无效'})
if path == '/api/clients/claude/reconnect':
if not write:
return self.output(405, {'error': '请求方式无效'})
Expand Down
1 change: 1 addition & 0 deletions bridge/features/network/shared_relay.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ def __init__(self, directory, gateway, test_http=False):
self.server = GatewayServer(('127.0.0.1', 0), gateway.bridge, config, gateway.web_dir)
# Share the live file policy while keeping relay authentication private.
self.server.transfer_directory = getattr(gateway, 'transfer_directory', None)
self.server.mobile_updates = getattr(gateway, 'mobile_updates', None)
self.server.notifications = gateway.notifications
self.server.desktop_sessions = getattr(gateway, 'desktop_sessions', None)
self.local_origin = 'http://127.0.0.1:' + str(self.server.server_port)
Expand Down
Loading
Loading