From 638fa4b74c716c5e663440bf7b8c9be53f326811 Mon Sep 17 00:00:00 2001
From: try2love <1929706911@qq.com>
Date: Tue, 13 Oct 2026 00:22:49 +0800
Subject: [PATCH 01/31] fix(ui): group Claude list filters in the toolbar
---
web/features/clients/client-navigation.css | 6 ++++++
web/features/clients/desktop-sessions.js | 4 ++--
2 files changed, 8 insertions(+), 2 deletions(-)
diff --git a/web/features/clients/client-navigation.css b/web/features/clients/client-navigation.css
index 4c3efc78..4601042c 100644
--- a/web/features/clients/client-navigation.css
+++ b/web/features/clients/client-navigation.css
@@ -8,6 +8,12 @@
.chat-list-toolbar .list-mode select{height:36px;padding:6px 3px;width:auto;max-width:108px;font-size:12px}
.chat-list-toolbar .archive{display:flex;align-items:center;gap:3px;font-size:11px;min-height:36px}
.chat-list-toolbar .archive input{width:14px;height:14px;flex:none;margin:0}
+/* Keep search usable; filters wrap with the actual list pane width. */
+.ds-list .chat-list-toolbar{display:flex;flex-wrap:wrap}
+.ds-list .chat-list-toolbar>.search{flex:1 1 140px;min-width:140px}
+.ds-list .chat-list-toolbar>.list-filters{display:flex;flex:0 1 auto;flex-wrap:wrap;gap:6px;margin-left:auto}
+.ds-list .chat-list-toolbar .list-mode{flex:none}
+.ds-list .chat-list-toolbar .ds-surface{width:auto;height:36px;margin:0;padding:6px 3px}
.client-switch-dock{display:flex;align-items:center;border-top:1px solid var(--line);padding-bottom:max(4px,env(safe-area-inset-bottom))}
.client-switch-dock .client-switch{flex:1;min-width:0;border:0;padding:4px 0}
.client-functions>[hidden],.client-switch-dock[hidden]{display:none!important}
diff --git a/web/features/clients/desktop-sessions.js b/web/features/clients/desktop-sessions.js
index 97071a48..952b0ae6 100644
--- a/web/features/clients/desktop-sessions.js
+++ b/web/features/clients/desktop-sessions.js
@@ -9,10 +9,10 @@ class DesktopSessionsView {
this.search=this.node('input','search');this.search.placeholder=BridgeI18n.t('搜索聊天或项目');this.label(this.search,'搜索聊天或项目');this.search.oninput=()=>this.renderList();
const filters=this.node('div','list-filters');const display=this.node('label','list-mode');display.append(this.translated('span','显示方式'));this.listMode=this.node('select');this.label(this.listMode,'聊天列表显示方式');for(const [id,title] of [['recent','最近交互'],['project','按项目']])this.listMode.add(new Option(BridgeI18n.t(title),id));this.listMode.onchange=()=>this.renderList();display.append(this.listMode);
const archive=this.node('label','archive');this.archived=this.node('input');this.archived.type='checkbox';this.archived.onchange=()=>this.renderList();archive.append(this.archived,this.translated('span','已归档'));filters.append(display,archive);
- this.surface=this.node('select','ds-surface');this.label(this.surface,'会话类型');for(const [id,title] of [['all','全部会话'],['code','Code'],['cowork','Cowork']])this.surface.add(new Option(BridgeI18n.t(title),id));this.surface.onchange=()=>this.renderList();
+ this.surface=this.node('select','ds-surface');this.label(this.surface,'会话类型');for(const [id,title] of [['all','全部会话'],['code','Code'],['cowork','Cowork']])this.surface.add(new Option(BridgeI18n.t(title),id));this.surface.onchange=()=>this.renderList();filters.prepend(this.surface);
this.listStatus=this.node('p','muted ds-list-status');this.listStatus.setAttribute('role','status');this.connectionRetry=this.button('重试连接',async()=>{const provider=this.provider;if(this.retryingClient||!this.retryClient)return;this.retryingClient=provider;this.renderConnectionStatus();try{await this.retryClient(provider);}finally{this.retryingClient=null;this.renderConnectionStatus();}});this.connectionRetry.hidden=true;
this.connectionInitialize=this.button('初始化连接',async()=>{const provider=this.provider;if(this.initializingClient||!this.initializeClient)return;this.initializingClient=provider;this.renderConnectionStatus();try{await this.initializeClient(provider);}finally{this.initializingClient=null;this.renderConnectionStatus();}});this.connectionInitialize.hidden=true;
- this.connectionNotice=this.node('p','muted');this.connectionNotice.hidden=true;this.connectionRefresh=this.button('刷新状态',async()=>{if(this.refreshingClient||!this.refreshClients)return;this.refreshingClient=true;this.renderConnectionStatus();try{await this.refreshClients();}finally{this.refreshingClient=false;this.renderConnectionStatus();}});this.connectionRefresh.hidden=true;this.rowsRoot=this.node('div','sessions');const toolbar=this.node('div','chat-list-toolbar');toolbar.append(this.search,filters);this.list.append(toolbar,this.surface,this.listStatus,this.connectionNotice,this.connectionRetry,this.connectionInitialize,this.connectionRefresh,this.rowsRoot);
+ this.connectionNotice=this.node('p','muted');this.connectionNotice.hidden=true;this.connectionRefresh=this.button('刷新状态',async()=>{if(this.refreshingClient||!this.refreshClients)return;this.refreshingClient=true;this.renderConnectionStatus();try{await this.refreshClients();}finally{this.refreshingClient=false;this.renderConnectionStatus();}});this.connectionRefresh.hidden=true;this.rowsRoot=this.node('div','sessions');const toolbar=this.node('div','chat-list-toolbar');toolbar.append(this.search,filters);this.list.append(toolbar,this.listStatus,this.connectionNotice,this.connectionRetry,this.connectionInitialize,this.connectionRefresh,this.rowsRoot);
this.welcome=this.node('div','welcome ds-welcome');const logo=this.node('img','mark large');logo.src='/icon.png';logo.alt='';logo.width=72;logo.height=72;this.welcome.append(logo,this.translated('h1','从一条聊天继续'),this.translated('p','选择电脑上的聊天,查看进度或发送消息。','muted'));
this.conversation=this.node('section','ds-conversation chat');this.conversation.hidden=true;this.main.append(this.welcome,this.conversation);
this.back=this.iconButton('返回聊天列表','m15 5-7 7 7 7',()=>{if(!this.workbench.back())this.backToList();});this.back.classList.add('ds-back');
From 2cfb6e243ec2a63968b47ce30a7ab78effb26372 Mon Sep 17 00:00:00 2001
From: try2love <1929706911@qq.com>
Date: Tue, 13 Oct 2026 00:22:49 +0800
Subject: [PATCH 02/31] feat(ui): add shared pull refresh for Claude and DSH
lists
---
.../2026-10-12-shared-client-list-refresh.md | 31 +++++++++++++++++
tests/desktop-sessions-ui.test.cjs | 33 +++++++++++++++++--
tests/list-sync.test.cjs | 8 +++++
web/features/chat/list-sync.js | 17 +++++-----
web/features/clients/client-navigation.css | 2 +-
web/features/clients/desktop-sessions.js | 32 +++++++++++-------
web/features/workspace/workbench.css | 6 ++--
7 files changed, 104 insertions(+), 25 deletions(-)
create mode 100644 .agents/notes/implemented/feature/2026-10-12-shared-client-list-refresh.md
diff --git a/.agents/notes/implemented/feature/2026-10-12-shared-client-list-refresh.md b/.agents/notes/implemented/feature/2026-10-12-shared-client-list-refresh.md
new file mode 100644
index 00000000..04b3785f
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-10-12-shared-client-list-refresh.md
@@ -0,0 +1,31 @@
+# Agent Note: Claude 与 DSH 列表的手动下拉刷新
+
+Status: implemented
+
+## Problem
+
+Claude 与 DSH 只有自动轮询,用户无法像 Codex 一样通过下拉确认列表已更新。原刷新入口吞掉读取错误,不能直接作为手动刷新成功的判据。
+
+## Decision
+
+Claude 与 DSH 将共享 `ListSync` 接到实际 `.sessions` 滚动容器,复用顶部下拉阈值、横向取消及加载、成功、失败重试提示。提示位于独立列表区域,拉动内容不改变滚动区域高度,短列表和空列表也保留触摸区域。
+
+`DesktopSessionsView.refreshList()` 只读取列表,手动刷新不触发会话详情读取、恢复、消息或审批。它与自动轮询、前台恢复共用 `sharedRead` 及当前视图的列表处理 Promise;同一轮读取不追加重复请求。列表失败会向手动刷新传递错误,断开连接的响应不显示更新成功。原有自动轮询负责定时及前台恢复,桌面客户端的 `ListSync` 不再单独启动另一套定时器。
+
+客户端切换、账号读取失效、登出及页面隐藏重置手势与提示;刷新修订号保证旧请求不能修改新提示或解除新请求的忙碌状态。现有 generation、读取 epoch 继续约束数据结果。渲染使用最新搜索和筛选,保留所选会话、草稿及可保留的列表位置;列表中部优先按首个可见会话锚定,数据插入后仍保持该会话的相对位置,锚点消失时保留可用的滚动偏移。
+
+历史决策检索未发现活跃笔记中已有同一 ListSync/手动下拉刷新契约。本记录落实本地 v2 优化评审第 4 项,不引入连接管理或写操作的重试策略。
+
+## Alternatives considered
+
+- 各客户端独立实现手势:能单独调整体验,但会重复阈值、取消与提示逻辑,现有共享组件已覆盖这些行为。
+- 直接调用原轮询入口:能复用现有读取流程,但还会读取已选会话详情且吞掉错误,无法准确反馈手动列表刷新结果。
+- 整页重新加载:容易实现,但会丢失阅读状态并触发无关初始化,不采用。
+
+## Testing
+
+Node 回归覆盖并发读取合并、等待完成、失败与断开连接重试、筛选和草稿保留、客户端与账号失效、旧请求不得解锁新请求。隔离浏览器检查 390/844/1440px 下两个客户端的长列表、短列表、空列表、顶部手势、中部滚动、横向取消及失败重试;使用合成数据,不操作真实账号或任务。
+
+## Consequences
+
+三个客户端共享手势及结果反馈,手动确认不增加写操作或重复轮询。代价是共享组件需要维护可重置的请求修订号,列表读取与轮询编排分开。浏览器验证不代替 Android/iOS 真机 WebView 的回弹体验验收。
diff --git a/tests/desktop-sessions-ui.test.cjs b/tests/desktop-sessions-ui.test.cjs
index 10c38d3c..8981715c 100644
--- a/tests/desktop-sessions-ui.test.cjs
+++ b/tests/desktop-sessions-ui.test.cjs
@@ -5,6 +5,7 @@ function fixture(){
class Node {
constructor(tag='div'){this.tagName=tag.toUpperCase();this.children=[];this.parentElement=null;this.dataset={};this.attributes={};this.hidden=false;this.disabled=false;this.value='';this.className='';this._text='';this.style={setProperty(){}};this.scrollHeight=100;this.scrollTop=0;this.clientHeight=100;this.clientWidth=1000;this.open=false;this.options=this.children;this.classList={contains:n=>this.className.split(' ').includes(n),add:(...ns)=>this.className=[...new Set([...this.className.split(' ').filter(Boolean),...ns])].join(' '),remove:(...ns)=>this.className=this.className.split(' ').filter(n=>!ns.includes(n)).join(' '),toggle:(n,v)=>{v=v??!this.classList.contains(n);this.classList[v?'add':'remove'](n);return v;}};}
get textContent(){return this._text+this.children.map(n=>n.textContent).join('');}set textContent(v){this._text=String(v??'');this.children=[];this.options=this.children;}
+ getBoundingClientRect(){return {width:this.clientWidth,top:0,bottom:this.clientHeight};}
get firstElementChild(){return this.children[0];}get lastChild(){return this.children.at(-1);}get childNodes(){return this.children;}get isConnected(){return !!this.parentElement;}
append(...nodes){for(let n of nodes){if(typeof n==='string')n=document.createTextNode(n);n.remove();n.parentElement=this;this.children.push(n);}}
prepend(...nodes){for(const n of [...nodes].reverse()){n.remove();n.parentElement=this;this.children.unshift(n);}}
@@ -22,9 +23,9 @@ function fixture(){
}
document={body:new Node('body'),documentElement:new Node('html'),hidden:false,activeElement:null,createElement:tag=>new Node(tag),createElementNS:(_,tag)=>new Node(tag),createTextNode:text=>{const n=new Node('#text');n.textContent=text;return n;},querySelectorAll:s=>document.body.querySelectorAll(s),querySelector:s=>document.body.querySelector(s),getElementById:id=>document.body.querySelector('#'+id),addEventListener:(type,fn)=>listeners[type]=fn,dispatchEvent:event=>listeners[event.type]?.(event)};
const root=new Node('div');root.className='app';document.body.append(root);const select=new Node('select'),calls=[],notices=[];let uploadHandler=async()=>({ok:true,status:200,json:async()=>({image:true,size:16,name:'image.png'})}),handler=async(url,body)=>body?{status:'accepted'}:url.includes('/detail')?{session:{id:'a',title:'Chat',model:'model-a',effort:'high',status:'idle',capabilities:{}},messages:[]}:url.includes('/workspace')?{entries:[],total:0,project:'/project'}:{sessions:[]};
- const context=vm.createContext({document,window:{},navigator:{},location:{pathname:'/'},history:{replaceState(){}},localStorage:{getItem:k=>storage.get(k),setItem:(k,v)=>storage.set(k,v),removeItem:k=>storage.delete(k)},sessionStorage:{getItem:k=>storage.get(k),setItem:(k,v)=>storage.set(k,v),removeItem:k=>storage.delete(k)},crypto:{getRandomValues:array=>webcrypto.getRandomValues(array)},Uint8Array,TextEncoder,Option:class extends Node{constructor(text,value){super('option');this.textContent=text;this.value=value;}},ResizeObserver:class{observe(){}},MutationObserver:class{observe(){}},AbortController,requestAnimationFrame:fn=>fn(),getComputedStyle:()=>({getPropertyValue:()=>8}),setTimeout(){return 1;},clearTimeout(){},fetch:async(url,options)=>{calls.push({url,upload:options});return uploadHandler(url,options);},renderMarkdown:(node,text)=>node.textContent=text,CustomEvent:class{constructor(type,options){this.type=type;Object.assign(this,options);}}});
+ const context=vm.createContext({document,window:{addEventListener(){}},navigator:{},location:{pathname:'/'},history:{replaceState(){}},localStorage:{getItem:k=>storage.get(k),setItem:(k,v)=>storage.set(k,v),removeItem:k=>storage.delete(k)},sessionStorage:{getItem:k=>storage.get(k),setItem:(k,v)=>storage.set(k,v),removeItem:k=>storage.delete(k)},crypto:{getRandomValues:array=>webcrypto.getRandomValues(array)},Uint8Array,TextEncoder,Option:class extends Node{constructor(text,value){super('option');this.textContent=text;this.value=value;}},ResizeObserver:class{observe(){}},MutationObserver:class{observe(){}},AbortController,requestAnimationFrame:fn=>fn(),cancelAnimationFrame(){},getComputedStyle:()=>({getPropertyValue:()=>8}),setTimeout(){return 1;},clearTimeout(){},fetch:async(url,options)=>{calls.push({url,upload:options});return uploadHandler(url,options);},renderMarkdown:(node,text)=>node.textContent=text,CustomEvent:class{constructor(type,options){this.type=type;Object.assign(this,options);}}});
vm.runInContext(fs.readFileSync(path.join(__dirname,'../web/shell/app.js'),'utf8').match(/^function uuid\(\).*$/m)[0],context);
- for(const file of ['web/layouts/viewport.js','web/features/chat/activity.js','web/features/chat/attachments.js','web/shared/i18n.js','web/features/clients/client-lifecycle.js','web/features/chat/message-actions.js','web/features/chat/permissions.js','web/features/workspace/workbench.js','web/features/clients/desktop-sessions.js'])vm.runInContext(fs.readFileSync(path.join(__dirname,'..',file),'utf8'),context);
+ for(const file of ['web/layouts/viewport.js','web/features/chat/activity.js','web/features/chat/attachments.js','web/shared/i18n.js','web/features/clients/client-lifecycle.js','web/features/chat/message-actions.js','web/features/chat/permissions.js','web/features/workspace/workbench.js','web/features/chat/list-sync.js','web/features/clients/desktop-sessions.js'])vm.runInContext(fs.readFileSync(path.join(__dirname,'..',file),'utf8'),context);
const View=vm.runInContext('DesktopSessionsView',context),view=new View({root,select,request:(url,body)=>{calls.push({url,body});return handler(url,body);},notify:text=>notices.push(text),onSelect(){},csrf:()=>'csrf-test',onUnauthorized(){}});
return {view,root,document,calls,notices,setHandler:fn=>handler=fn,setUploadHandler:fn=>uploadHandler=fn,run:s=>vm.runInContext(s,context),text:n=>n.textContent};
}
@@ -328,3 +329,31 @@ test('context monitor remains discoverable and distinguishes unsupported from mi
apply(null,'unavailable');ui.view.contextDetails();dialog=ui.document.querySelector('dialog');assert.match(dialog.textContent,language==='en'?/update automatically when available/:/有可用数据时会自动更新/);assert.doesNotMatch(dialog.textContent,language==='en'?/does not expose/:/未提供上下文用量接口/);
}
});
+
+for(const provider of ['claude','deepseek'])test(`${provider} manual refresh joins polling and foreground reads and waits for the list result`,async()=>{
+ const ui=fixture();await ui.view.choose(provider);let finish;ui.setHandler(()=>new Promise(resolve=>finish=resolve));const before=ui.calls.length;
+ const polling=ui.view.refresh(),manual=ui.view.listSync.update(true);ui.document.dispatchEvent({type:'visibilitychange'});assert.equal(ui.calls.length-before,1);assert.match(ui.view.listHint.textContent,/正在刷新/);assert.equal(ui.view.listSync.busy,true);
+ finish({sessions:[{id:'new',title:'New result',backend:'code'}]});await Promise.all([polling,manual]);await new Promise(setImmediate);assert.equal(ui.calls.length-before,1,'no redundant trailing list read');assert.match(ui.view.rowsRoot.textContent,/New result/);assert.match(ui.view.listHint.textContent,/已更新/);assert.equal(ui.calls.some(c=>c.body),false);
+});
+test('manual refresh failure and disconnected responses remain retryable without fetching a selected chat',async()=>{
+ const ui=await opened(),before=ui.calls.length;
+ ui.setHandler(async()=>{throw Error('offline');});await ui.view.listSync.update(true);assert.match(ui.view.listHint.textContent,/点击重试/);assert.equal(ui.view.sid,'a');
+ ui.setHandler(async()=>({connected:false,sessions:[]}));await ui.view.listHint.click();assert.match(ui.view.listHint.textContent,/点击重试/);
+ ui.setHandler(async()=>({sessions:[{id:'a',title:'Recovered',backend:'code'}]}));await ui.view.listHint.click();assert.match(ui.view.listHint.textContent,/已更新/);assert.match(ui.view.rowsRoot.textContent,/Recovered/);
+ assert.ok(ui.calls.slice(before).every(c=>c.url.endsWith('/list')&&!c.body));
+});
+test('manual refresh retains current search, filters, selected chat, draft and list position',async()=>{
+ const ui=await opened();ui.view.input.value='draft';ui.view.search.value='needle';ui.view.surface.value='code';ui.view.listMode.value='project';ui.view.archived.checked=true;ui.view.rowsRoot.scrollTop=80;
+ let finish;ui.setHandler(()=>new Promise(resolve=>finish=resolve));const pending=ui.view.listSync.update(true);ui.view.search.value='changed';finish({sessions:[{id:'a',title:'changed result',backend:'code',cwd:'/project',archived:true},{id:'b',title:'needle old filter',backend:'cowork'}]});await pending;
+ assert.equal(ui.view.search.value,'changed');assert.equal(ui.view.surface.value,'code');assert.equal(ui.view.listMode.value,'project');assert.equal(ui.view.archived.checked,true);assert.equal(ui.view.sid,'a');assert.equal(ui.view.input.value,'draft');assert.equal(ui.view.rowsRoot.scrollTop,80);assert.match(ui.view.rowsRoot.textContent,/changed result/);assert.doesNotMatch(ui.view.rowsRoot.textContent,/old filter/);
+});
+for(const failure of [false,true])test(`a previous provider refresh ${failure?'failure':'success'} cannot affect a new provider refresh`,async()=>{
+ const ui=fixture();await ui.view.choose('claude');let resolveOld,rejectOld,resolveNew;ui.setHandler(url=>url.includes('/claude/')?new Promise((resolve,reject)=>{resolveOld=resolve;rejectOld=reject;}):new Promise(resolve=>resolveNew=resolve));
+ const old=ui.view.listSync.update(true),switching=ui.view.choose('deepseek');assert.equal(ui.view.listHint.hidden,true);const current=ui.view.listSync.update(true);assert.equal(ui.view.listSync.busy,true);
+ if(failure)rejectOld(Error('old failure'));else resolveOld({sessions:[{id:'old',title:'Old provider'}]});await old;assert.equal(ui.view.listSync.busy,true);assert.match(ui.view.listHint.textContent,/正在刷新/);assert.doesNotMatch(ui.view.rowsRoot.textContent,/Old provider/);
+ resolveNew({sessions:[{id:'new',title:'New provider'}]});await Promise.all([switching,current]);assert.equal(ui.view.provider,'deepseek');assert.match(ui.view.rowsRoot.textContent,/New provider/);assert.match(ui.view.listHint.textContent,/已更新/);
+});
+test('account invalidation cancels manual feedback and rejects old list contents',async()=>{
+ const ui=fixture();await ui.view.choose('claude');let finish;ui.setHandler(()=>new Promise(resolve=>finish=resolve));const old=ui.view.listSync.update(true);ui.view.invalidateReads('claude');assert.equal(ui.view.listHint.hidden,true);
+ finish({sessions:[{id:'old',title:'Old account',backend:'code'}]});await old;assert.equal(ui.view.listHint.hidden,true);assert.doesNotMatch(ui.view.rowsRoot.textContent,/Old account/);
+});
diff --git a/tests/list-sync.test.cjs b/tests/list-sync.test.cjs
index aa6a61f2..d2c87e72 100644
--- a/tests/list-sync.test.cjs
+++ b/tests/list-sync.test.cjs
@@ -59,3 +59,11 @@ test('busy, hidden and unavailable lists reject pull gestures and failure remain
ui.setRefresh(async()=>{throw Error('offline');});await ui.sync.update(true);ui.advance(5000);assert.equal(ui.status.hidden,false);assert.match(ui.status.textContent,/点击重试/);
ui.setRefresh(async()=>{});ui.status.onclick();await settled();assert.equal(ui.calls,3);ui.advance(800);assert.equal(ui.status.hidden,true);
});
+
+test('pulling in the middle of the list does not intercept normal scrolling',async()=>{
+ const ui=fixture();ui.root.scrollTop=120;ui.touch('touchstart',20);const event=ui.touch('touchmove',120);ui.touch('touchend',120);ui.frame();await settled();assert.equal(event.prevented,undefined);assert.equal(ui.calls,0);assert.equal(ui.status.hidden,true);
+});
+test('reset discards old feedback and keeps a newer refresh busy',async()=>{
+ const ui=fixture();let finishOld,finishNew;ui.setRefresh(()=>new Promise(resolve=>finishOld=resolve));const old=ui.sync.update(true);ui.sync.reset();assert.equal(ui.status.hidden,true);
+ ui.setRefresh(()=>new Promise(resolve=>finishNew=resolve));const current=ui.sync.update(true);finishOld();await old;assert.equal(ui.sync.busy,true);assert.match(ui.status.textContent,/正在刷新/);finishNew();await current;assert.equal(ui.sync.busy,false);assert.match(ui.status.textContent,/已更新/);
+});
diff --git a/web/features/chat/list-sync.js b/web/features/chat/list-sync.js
index 2c89db76..e8ebd14d 100644
--- a/web/features/chat/list-sync.js
+++ b/web/features/chat/list-sync.js
@@ -1,11 +1,11 @@
'use strict';
class ListSync {
- constructor({root,status,refresh,visible,ready}) {
- Object.assign(this,{root,status,refresh,visible,ready});this.busy=false;this.frame=0;
+ constructor({root,status,refresh,visible,ready,automatic=true}) {
+ Object.assign(this,{root,status,refresh,visible,ready});this.busy=false;this.frame=0;this.revision=0;
status.onclick=()=>this.update(true);
const resume=()=>{if(!document.hidden)this.update();};
- document.addEventListener('visibilitychange',resume);window.addEventListener('focus',resume);
+ if(automatic){document.addEventListener('visibilitychange',resume);window.addEventListener('focus',resume);}
root.addEventListener('touchstart',event=>{
if(this.start)this.endPull(true);
this.start=!this.busy&&!document.hidden&&visible()&&ready()&&root.scrollTop<=0&&event.touches.length===1?{x:event.touches[0].clientX,y:event.touches[0].clientY}:null;this.distance=0;
@@ -26,7 +26,7 @@ class ListSync {
},{passive:false});
root.addEventListener('touchend',()=>this.endPull());
root.addEventListener('touchcancel',()=>this.endPull(true));
- this.schedule();
+ if(automatic)this.schedule();
}
setPull(distance,dragging=false){this.root.classList.toggle('list-pulling',dragging);this.root.style.setProperty('--list-pull',distance+'px');}
show(text,pull=0){
@@ -44,16 +44,17 @@ class ListSync {
if(this.frame)cancelAnimationFrame(this.frame);this.frame=0;
if(refresh)this.update(true);else{this.setPull(0);this.hide();}
}
+ reset(){this.revision++;this.busy=false;this.endPull(true);this.setPull(0);clearTimeout(this.hideTimer);clearTimeout(this.dismissTimer);this.status.classList.remove('list-sync-visible');this.status.hidden=true;}
schedule(){clearTimeout(this.timer);this.timer=setTimeout(async()=>{await this.update();this.schedule();},15000);}
async update(explicit=false){
if(this.start||this.busy)return;
if(document.hidden||!this.visible()||!this.ready()||(!explicit&&this.root.scrollTop>24)){
if(explicit){this.setPull(0);this.hide();}return;
}
- this.busy=true;
+ this.busy=true;const revision=this.revision;
if(explicit){this.setPull(32);this.show('正在刷新聊天列表…',32);}
- try{await this.refresh();if(explicit){this.setPull(0);this.show('聊天列表已更新');this.hideTimer=setTimeout(()=>this.hide(),500);}}
- catch(e){this.setPull(0);this.show('列表暂未更新,点击重试');}
- finally{this.busy=false;}
+ try{const updated=await this.refresh();if(revision!==this.revision)return;if(updated===false){this.setPull(0);this.hide();return;}if(explicit){this.setPull(0);this.show('聊天列表已更新');this.hideTimer=setTimeout(()=>this.hide(),500);}}
+ catch(e){if(revision===this.revision){this.setPull(0);this.show('列表暂未更新,点击重试');}}
+ finally{if(revision===this.revision)this.busy=false;}
}
}
diff --git a/web/features/clients/client-navigation.css b/web/features/clients/client-navigation.css
index 4c3efc78..ee2ede0c 100644
--- a/web/features/clients/client-navigation.css
+++ b/web/features/clients/client-navigation.css
@@ -1,4 +1,4 @@
-.gateway-authenticated>.phone-header{display:none}.list-heading{display:grid;grid-template-columns:minmax(0,auto) minmax(0,1fr) auto auto;gap:6px;min-height:60px}.list-heading h2{display:flex;align-items:center;gap:7px;font-size:17px;white-space:nowrap}.client-badge{display:inline-flex;align-items:center;gap:4px;font-size:12px}.client-icon{display:inline-flex;align-items:center;justify-content:center;flex:none;width:23px;height:23px;font-size:20px;line-height:1}.client-icon img{width:100%;height:100%;object-fit:contain}.connected-computer{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:10px;padding:4px 0;color:var(--muted);min-width:0}.connected-computer[hidden]{display:block!important;visibility:hidden}.list-heading #new-chat{font-size:12px;white-space:nowrap;gap:3px;padding:6px 2px}.list-heading .list-more{font-size:13px;padding:6px 0}.client-navigation{margin-top:auto;flex:none;min-width:0}.client-functions{display:grid;grid-auto-flow:column;grid-auto-columns:minmax(0,1fr);align-items:center;gap:0;padding:7px 0;min-height:48px}.client-functions>*{min-width:0;flex:1!important;font-size:11px!important;padding:9px 2px!important;white-space:nowrap}.client-functions #computers-button{margin:0!important;justify-self:end;text-align:right}.navigation-hidden{display:none!important}.client-switch{display:flex;gap:4px;border-top:1px solid var(--line);padding:6px 0 max(7px,env(safe-area-inset-bottom))}.client-switch button{flex:1;display:flex;flex-direction:column;align-items:center;gap:5px;padding:8px 2px;border:0;background:transparent;border-radius:8px;font-size:11px;color:var(--muted)}.client-switch button.selected{background:var(--surface);color:var(--text);font-weight:600}.client-management-row{display:flex!important;align-items:center;gap:12px;padding:15px 0;border-bottom:1px solid var(--line)}.client-management-row>span:nth-child(2){flex:1}.client-management-row small{display:block;color:var(--muted);font-size:11px;margin-top:6px}.chat-head>.client-icon{margin-right:0}.chat-title-button,#chat-title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.no-client #sessions,.no-client #search,.no-client .list-filters{display:none}.no-client-message{padding:30px 5px}.ds-list{display:flex;flex-direction:column;padding:0 16px;overflow:hidden}.ds-list>.sessions{flex:1;min-height:0;overflow:auto}.ds-list>input{flex:none}.ds-list>p{margin:10px 0;font-size:12px}.ds-head>strong{white-space:nowrap;text-overflow:ellipsis;min-width:0}.ds-head>.client-icon{flex:none}.ds-conversation .wb-main-pane>.ds-messages{flex:1}.ds-conversation.workbench-active:not(.wb-split) .wb-main-pane{display:none}.ds-head .ds-back{display:block!important}.ds-conversation .wb-main-label{white-space:nowrap}.ds-conversation .wb-layout{flex:1}
+.gateway-authenticated>.phone-header{display:none}.list-heading{display:grid;grid-template-columns:minmax(0,auto) minmax(0,1fr) auto auto;gap:6px;min-height:60px}.list-heading h2{display:flex;align-items:center;gap:7px;font-size:17px;white-space:nowrap}.client-badge{display:inline-flex;align-items:center;gap:4px;font-size:12px}.client-icon{display:inline-flex;align-items:center;justify-content:center;flex:none;width:23px;height:23px;font-size:20px;line-height:1}.client-icon img{width:100%;height:100%;object-fit:contain}.connected-computer{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:10px;padding:4px 0;color:var(--muted);min-width:0}.connected-computer[hidden]{display:block!important;visibility:hidden}.list-heading #new-chat{font-size:12px;white-space:nowrap;gap:3px;padding:6px 2px}.list-heading .list-more{font-size:13px;padding:6px 0}.client-navigation{margin-top:auto;flex:none;min-width:0}.client-functions{display:grid;grid-auto-flow:column;grid-auto-columns:minmax(0,1fr);align-items:center;gap:0;padding:7px 0;min-height:48px}.client-functions>*{min-width:0;flex:1!important;font-size:11px!important;padding:9px 2px!important;white-space:nowrap}.client-functions #computers-button{margin:0!important;justify-self:end;text-align:right}.navigation-hidden{display:none!important}.client-switch{display:flex;gap:4px;border-top:1px solid var(--line);padding:6px 0 max(7px,env(safe-area-inset-bottom))}.client-switch button{flex:1;display:flex;flex-direction:column;align-items:center;gap:5px;padding:8px 2px;border:0;background:transparent;border-radius:8px;font-size:11px;color:var(--muted)}.client-switch button.selected{background:var(--surface);color:var(--text);font-weight:600}.client-management-row{display:flex!important;align-items:center;gap:12px;padding:15px 0;border-bottom:1px solid var(--line)}.client-management-row>span:nth-child(2){flex:1}.client-management-row small{display:block;color:var(--muted);font-size:11px;margin-top:6px}.chat-head>.client-icon{margin-right:0}.chat-title-button,#chat-title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.no-client #sessions,.no-client #search,.no-client .list-filters{display:none}.no-client-message{padding:30px 5px}.ds-list{display:flex;flex-direction:column;padding:0 16px;overflow:hidden}.ds-list>.session-list-region>.sessions{overflow:auto}.ds-list>input{flex:none}.ds-list>p{margin:10px 0;font-size:12px}.ds-head>strong{white-space:nowrap;text-overflow:ellipsis;min-width:0}.ds-head>.client-icon{flex:none}.ds-conversation .wb-main-pane>.ds-messages{flex:1}.ds-conversation.workbench-active:not(.wb-split) .wb-main-pane{display:none}.ds-head .ds-back{display:block!important}.ds-conversation .wb-main-label{white-space:nowrap}.ds-conversation .wb-layout{flex:1}
/* Shared by all providers; dimensions follow the list pane, not the host OS. */
.chat-list-toolbar{display:grid;grid-template-columns:minmax(0,1fr) auto auto;gap:6px;align-items:center;flex:none;margin:4px 0 8px;min-width:0}
.chat-list-toolbar>.search{width:100%;min-width:0;height:36px;padding:7px 8px;margin:0}
diff --git a/web/features/clients/desktop-sessions.js b/web/features/clients/desktop-sessions.js
index 97071a48..f9c1265d 100644
--- a/web/features/clients/desktop-sessions.js
+++ b/web/features/clients/desktop-sessions.js
@@ -30,8 +30,10 @@ class DesktopSessionsView {
this.attachments=new ChatAttachments({root:this.attachmentList,button:this.attachButton,input:this.attachmentInput,paste:this.input,drop:this.form,onChange:()=>this.updateComposer(),upload:(key,id,file)=>this.uploadAttachment(key,id,file),preview:(key,id)=>this.attachmentUrl(key,id,'preview'),thumbnail:async(key,id,file)=>{const image=await this.attachments.thumbnailBlob(file);return this.uploadBinary(this.attachmentUrl(key,id,'thumb')+'&width='+image.width+'&height='+image.height,image.data,image.mime);}});
const addAttachments=this.attachments.add.bind(this.attachments);this.attachments.add=files=>{if(!this.attachments.key||this.attachments.locked)return;if(this.overRequestLimit([...this.attachments.rows,...files])){this.attachments.error=this.requestLimitMessage();this.attachments.render();return;}if(files.some(file=>!file.type?.startsWith('image/'))){this.attachments.error='此应用的消息附件仅支持图片;其他文件请使用文件传输。';this.attachments.render();return;}addAttachments(files);};
this.conversation.append(head,this.notice,this.messages,this.requests,this.form);this.workbench=new Workbench({chat:this.conversation,request,csrf,notify,onUnauthorized});select.onchange=()=>this.choose(select.value);
- document.addEventListener('bridge-language',()=>this.relabel());document.addEventListener('visibilitychange',()=>{clearTimeout(this.timer);if(!document.hidden){this.prefetchLists();if(this.provider!=='codex')this.refresh();}});
+ document.addEventListener('bridge-language',()=>this.relabel());const resume=()=>{clearTimeout(this.timer);if(document.hidden)this.listSync.reset();else{this.prefetchLists();if(this.provider!=='codex')this.refresh();}};document.addEventListener('visibilitychange',resume);window.addEventListener('focus',resume);
this.appearanceObserver=new MutationObserver(()=>{this.renderMessages(this.lastMessages||[]);this.workbench.setThumbnails(document.documentElement.dataset.showFileThumbnails==='true');});this.appearanceObserver.observe(document.documentElement,{attributes:true,attributeFilter:['data-show-reasoning','data-show-process','data-show-file-thumbnails']});
+ const listRegion=this.node('div','session-list-region');this.listHint=this.button('',()=>{});this.listHint.className='list-sync';this.listHint.hidden=true;this.listHint.setAttribute('aria-live','polite');listRegion.append(this.rowsRoot,this.listHint);this.list.append(listRegion);
+ this.listSync=new ListSync({root:this.rowsRoot,status:this.listHint,refresh:()=>this.refreshList(),visible:()=>!this.root.hidden&&!this.container.hidden&&this.list.getBoundingClientRect().width>0,ready:()=>document.activeElement!==this.search,automatic:false});
this.updateComposer();
}
node(tag,cls='',text){const n=document.createElement(tag);n.className=cls;if(text!==undefined)n.textContent=text;return n;}
@@ -59,7 +61,7 @@ class DesktopSessionsView {
invalidateReads(provider){
this.providerReadEpochs.set(provider,(this.providerReadEpochs.get(provider)||0)+1);for(const key of this.readTasks.keys())if(JSON.parse(key)[0]===provider)this.readTasks.delete(key);
this.listPrefetchAt.delete(provider);const saved=this.providerViews.get(provider);if(saved)saved.cachedAt=0;
- if(provider===this.provider){this.freshDetail=false;this.detailRevision++;this.loading=null;this.refreshPending=null;}
+ if(provider===this.provider){this.freshDetail=false;this.detailRevision++;this.loading=null;this.listSync.reset();}
}
prefetchLists(){
if(document.hidden||this.root.hidden)return;
@@ -95,7 +97,7 @@ class DesktopSessionsView {
}
async choose(provider){
if(provider===this.provider)return;
- this.rememberView();clearTimeout(this.timer);this.generation++;this.detailRevision++;this.provider=provider;this.select.value=provider;this.workbench.provider=provider;this.workbench.current=null;this.workbench.panel.hidden=true;this.workbench.tabs.replaceChildren();this.conversation.classList.remove('workbench-active','wb-split');this.sid=null;this.state=null;this.freshDetail=false;const saved=this.providerViews.get(provider);this.rows=saved?.rows||[];this.listStamp='';this.search.value=saved?.query||'';this.surface.value=saved?.surface||'all';this.archived.checked=saved?.archived||false;if(saved?.listMode)this.listMode.value=saved.listMode;this.surface.hidden=provider!=='claude';this.showSelection(false);
+ this.rememberView();this.listSync.reset();clearTimeout(this.timer);this.generation++;this.detailRevision++;this.provider=provider;this.select.value=provider;this.workbench.provider=provider;this.workbench.current=null;this.workbench.panel.hidden=true;this.workbench.tabs.replaceChildren();this.conversation.classList.remove('workbench-active','wb-split');this.sid=null;this.state=null;this.freshDetail=false;const saved=this.providerViews.get(provider);this.rows=saved?.rows||[];this.listStamp='';this.search.value=saved?.query||'';this.surface.value=saved?.surface||'all';this.archived.checked=saved?.archived||false;if(saved?.listMode)this.listMode.value=saved.listMode;this.surface.hidden=provider!=='claude';this.showSelection(false);
this.container.hidden=provider==='codex';this.root.classList.toggle('desktop-mode',provider!=='codex');this.clientIcon.replaceChildren();if(provider!=='codex'){const img=this.node('img');img.src='/client-icons/'+provider+'.png';img.alt='';this.clientIcon.append(img);}this.clientIcon.className='client-icon client-'+provider;this.listStatus.textContent=BridgeI18n.t('正在读取聊天…');this.renderList();this.rowsRoot.scrollTop=saved?.scroll||0;this.updateComposer();if(saved?.sid)this.prepareChat(saved.sid);this.onSelect(provider);
this.renderConnectionStatus();if(provider!=='codex')await this.refresh();
}
@@ -116,16 +118,24 @@ class DesktopSessionsView {
if(provider!==this.provider)return;
this.rows=[];this.listStamp='';this.lastMessages=[];this.requestStamp='';this.messageNodes.clear();this.messages.replaceChildren();this.requests.replaceChildren();this.showSelection(false);this.renderList();this.updateComposer();await this.refresh();
}
- clear(){this.readEpoch++;this.readTasks.clear();this.providerReadEpochs.clear();this.listPrefetchAt.clear();this.detailCacheSizes.clear();this.detailCacheBytes=0;this.clientStates=[];this.workbench.reset();clearTimeout(this.timer);this.generation++;this.detailRevision++;this.provider='codex';this.select.value='codex';this.select.hidden=true;this.container.hidden=true;this.root.classList.remove('desktop-mode');this.sid=null;this.state=null;this.showSelection(false);this.drafts.clear();this.accountDrafts.clear();this.skillSelections.clear();this.providerViews.clear();this.detailCache.clear();this.activity.clear();this.freshDetail=false;this.rows=[];this.listStamp='';this.rowsRoot.replaceChildren();this.lastMessages=[];this.requestStamp='';this.pending.clear();this.attachments.reset();this.messages.replaceChildren();this.requests.replaceChildren();this.messageNodes.clear();}
+ clear(){this.listSync.reset();this.readEpoch++;this.readTasks.clear();this.providerReadEpochs.clear();this.listPrefetchAt.clear();this.detailCacheSizes.clear();this.detailCacheBytes=0;this.clientStates=[];this.workbench.reset();clearTimeout(this.timer);this.generation++;this.detailRevision++;this.provider='codex';this.select.value='codex';this.select.hidden=true;this.container.hidden=true;this.root.classList.remove('desktop-mode');this.sid=null;this.state=null;this.showSelection(false);this.drafts.clear();this.accountDrafts.clear();this.skillSelections.clear();this.providerViews.clear();this.detailCache.clear();this.activity.clear();this.freshDetail=false;this.rows=[];this.listStamp='';this.rowsRoot.replaceChildren();this.lastMessages=[];this.requestStamp='';this.pending.clear();this.attachments.reset();this.messages.replaceChildren();this.requests.replaceChildren();this.messageNodes.clear();}
async refresh(){
clearTimeout(this.timer);const generation=this.generation,provider=this.provider;if(provider==='codex'||document.hidden||this.root.hidden)return;
if(this.renderConnectionStatus()){this.updateActivity(this.rows,false);this.renderIndicators();this.freshDetail=false;this.updateComposer();return;}
- if(this.loading?.generation===generation){this.refreshPending=generation;return;}const loading={generation};this.loading=loading;
+ if(this.loading?.generation===generation)return this.loading.promise;const loading={generation};this.loading=loading;
if(this.sid)this.detail(generation).catch(error=>{if(generation===this.generation){this.error.textContent=BridgeI18n.t(error.message);this.status.textContent=BridgeI18n.t('连接失败');}});
- const task=this.sharedRead(provider,'list');
- try{const value=await task.promise;if(!task.current()||generation!==this.generation)return;this.rows=value.sessions||[];this.updateActivity(this.rows,value.connected!==false);this.listStatus.textContent=BridgeI18n.t(value.connected===false?'尚未连接':this.rows.length?'已连接桌面':'已连接,桌面还没有会话');this.renderConnectionStatus();this.renderList();this.rememberView();this.onListReady?.(provider,value);}
- catch(error){if(task.current()&&generation===this.generation){this.updateActivity(this.rows,false);this.renderIndicators();if(!this.renderConnectionStatus())this.listStatus.textContent=BridgeI18n.t(error.message);if(this.sid&&!this.state)this.error.textContent=this.listStatus.textContent;}}
- finally{if(this.loading===loading){this.loading=null;if(generation===this.generation){if(this.refreshPending===generation){this.refreshPending=null;this.refresh();}else this.timer=setTimeout(()=>this.refresh(),this.sid||ClientLifecycle.connection(this.clientStates?.find(client=>client.id===provider)).waiting?2000:5000);}}}
+ loading.promise=this.refreshList().catch(()=>{}).finally(()=>{if(this.loading===loading){this.loading=null;if(generation===this.generation)this.timer=setTimeout(()=>this.refresh(),this.sid||ClientLifecycle.connection(this.clientStates?.find(client=>client.id===provider)).waiting?2000:5000);}});
+ return loading.promise;
+ }
+ refreshList(){
+ const generation=this.generation,provider=this.provider;if(provider==='codex'||document.hidden||this.root.hidden||this.renderConnectionStatus())return Promise.resolve(false);
+ const task=this.sharedRead(provider,'list');if(this.listRead?.task===task&&this.listRead.generation===generation)return this.listRead.promise;
+ const reading={task,generation};this.listRead=reading;
+ reading.promise=(async()=>{
+ try{const value=await task.promise;if(!task.current()||generation!==this.generation)return false;this.rows=value.sessions||[];this.updateActivity(this.rows,value.connected!==false);this.listStatus.textContent=BridgeI18n.t(value.connected===false?'尚未连接':this.rows.length?'已连接桌面':'已连接,桌面还没有会话');this.renderConnectionStatus();this.renderList();this.rememberView();this.onListReady?.(provider,value);if(value.connected===false)throw Error(BridgeI18n.t('尚未连接'));return true;}
+ catch(error){if(!task.current()||generation!==this.generation)return false;this.updateActivity(this.rows,false);this.renderIndicators();if(!this.renderConnectionStatus())this.listStatus.textContent=BridgeI18n.t(error.message);if(this.sid&&!this.state)this.error.textContent=this.listStatus.textContent;throw error;}
+ finally{if(this.listRead===reading)this.listRead=null;}
+ })();return reading.promise;
}
visibleActivity(){return this.isChatOpen&&!document.hidden&&!this.workbench.isFileVisible?this.provider+'|'+this.sid:null;}
updateActivity(rows,connected=true){this.activity.update(rows.map(row=>({id:row.id,host:this.provider,connected,status:row.status,turnId:row.turnId,turnStatus:row.turnStatus})),this.visibleActivity());}
@@ -133,13 +143,13 @@ class DesktopSessionsView {
const rows=new Map(this.rows.map(row=>[row.id,row]));for(const button of this.rowsRoot.querySelectorAll('.session')){button.querySelector('.session-indicator')?.remove();const row=this.freshDetail&&button.dataset.id===this.sid?this.state:rows.get(button.dataset.id),indicator=row?.requests?.length?(this.activity.rows[this.provider+'|'+button.dataset.id]?.offline?{kind:'unknown',label:'运行状态暂不可用'}:{kind:'awaiting',label:'等待回应'}):this.activity.indicator(this.provider+'|'+button.dataset.id);if(indicator){const dot=this.node('span','session-indicator '+indicator.kind);dot.title=BridgeI18n.t(indicator.label);dot.setAttribute('aria-label',BridgeI18n.t(indicator.label));button.querySelector('strong')?.prepend(dot);}}
}
renderList(){
- const query=this.search.value.trim().toLowerCase(),surface=this.provider==='claude'?this.surface.value:'all';const stamp=JSON.stringify([this.provider,this.sid,query,surface,this.archived.checked,this.listMode.value,this.rows,BridgeI18n.language()]);if(stamp===this.listStamp){this.renderIndicators();return;}this.listStamp=stamp;this.rowsRoot.replaceChildren();
+ const query=this.search.value.trim().toLowerCase(),surface=this.provider==='claude'?this.surface.value:'all';const stamp=JSON.stringify([this.provider,this.sid,query,surface,this.archived.checked,this.listMode.value,this.rows,BridgeI18n.language()]);if(stamp===this.listStamp){this.renderIndicators();return;}this.listStamp=stamp;const scroll=this.rowsRoot.scrollTop,top=this.rowsRoot.getBoundingClientRect().top,anchor=scroll>0?[...this.rowsRoot.querySelectorAll('.session')].find(node=>node.getBoundingClientRect().bottom>top):null,offset=anchor?anchor.getBoundingClientRect().top-top:0;this.rowsRoot.replaceChildren();
const rows=(this.rows||[]).filter(row=>(this.archived.checked||!row.archived)&&(surface==='all'||row.backend===surface)&&((row.title||'')+' '+(row.cwd||'')).toLowerCase().includes(query)).sort((a,b)=>Number(b.updatedAt||0)-Number(a.updatedAt||0));
const groups=new Map();for(const row of rows){const key=[this.provider==='claude'?(row.backend==='cowork'?'Cowork':'Code'):'',this.listMode.value==='project'?row.cwd||BridgeI18n.t('电脑上的聊天'):''].filter(Boolean).join(' · ');if(!groups.has(key))groups.set(key,[]);groups.get(key).push(row);}
for(const [name,items] of groups){let target=this.rowsRoot;if(name){const group=this.node('details','project-group ds-session-group'),key=JSON.stringify([this.provider,this.listMode.value,name]);group.open=!this.collapsedGroups.has(key);group.ontoggle=()=>{if(!group.isConnected)return;if(group.open)this.collapsedGroups.delete(key);else this.collapsedGroups.add(key);try{localStorage.setItem('bridge-desktop-session-groups',JSON.stringify([...this.collapsedGroups]));}catch{}};group.append(this.node('summary','',name));this.rowsRoot.append(group);target=group;}
for(const row of items){const button=this.button('',()=>this.open(row.id));button.className='session'+(row.id===this.sid?' selected':'');button.dataset.id=row.id;button.dataset.provider=this.provider;const meta=this.node('small'),location=this.node('span','',row.cwd||''),time=this.node('time');if(row.updatedAt){const raw=Number(row.updatedAt);const date=new Date(raw<1e12?raw*1000:raw);if(Number.isFinite(date.getTime()))time.textContent=date.toLocaleDateString(BridgeI18n.locale(),{month:'numeric',day:'numeric'});}meta.append(location,time);button.append(this.node('strong','',row.title||BridgeI18n.t('新会话')),meta);target.append(button);}
}
- if(!rows.length&&this.rows.length)this.rowsRoot.append(this.translated('p','没有匹配的聊天','muted ds-list-empty'));this.renderIndicators();
+ if(!rows.length&&this.rows.length)this.rowsRoot.append(this.translated('p','没有匹配的聊天','muted ds-list-empty'));this.rowsRoot.scrollTop=scroll;if(anchor){const next=[...this.rowsRoot.querySelectorAll('.session')].find(node=>node.dataset.id===anchor.dataset.id);if(next)this.rowsRoot.scrollTop+=next.getBoundingClientRect().top-this.rowsRoot.getBoundingClientRect().top-offset;}this.renderIndicators();
}
prepareChat(sid){
this.saveDraft();this.sid=sid;this.state=null;this.freshDetail=false;this.detailRevision++;const row=this.rows.find(r=>r.id===sid);this.title.textContent=row?.title||BridgeI18n.t('正在读取聊天…');this.status.textContent=BridgeI18n.t('正在连接…');this.input.value=this.drafts.get(this.provider+':'+sid)||'';this.requestStamp='';this.error.textContent='';this.notice.hidden=true;this.messages.replaceChildren(this.translated('p','正在读取聊天…','muted'));this.messageNodes.clear();this.lastMessages=[];this.requests.replaceChildren();this.showSelection(true);this.attachments.open(JSON.stringify([this.provider,sid]));this.workbench.open(sid,'local');const cached=this.detailCache.get(JSON.stringify([this.provider,sid]));if(cached){this.applyDetail(cached,true);this.messages.scrollTop=this.workbench.current.scroll||0;}this.activity.read(this.provider+'|'+sid);this.updateComposer();this.renderList();this.resizeInput();this.rememberView();
diff --git a/web/features/workspace/workbench.css b/web/features/workspace/workbench.css
index 7c696b2a..0b61b216 100644
--- a/web/features/workspace/workbench.css
+++ b/web/features/workspace/workbench.css
@@ -84,11 +84,11 @@
@media(max-height:500px){.wb-terminal-help{display:none}.wb-terminal-head{padding:4px 10px}.wb-terminal-form{padding:4px 10px}.wb-terminal-form textarea{height:40px}}
.wb-agents{position:relative;isolation:isolate;height:100%;display:flex;flex-direction:column;min-height:0;overflow:hidden}.wb-agents>.wb-git-head strong{flex:1}.wb-agents-list{flex:1;min-height:0;overflow:auto}.wb-agent-card{display:grid;width:100%;gap:6px;text-align:left;border:0;border-bottom:1px solid var(--line);padding:14px 12px;background:transparent;color:var(--text)}.wb-agent-card:hover{background:var(--surface)}.wb-agent-card strong{font-size:14px}.wb-agent-card span{font-size:12px}.wb-agent-card small{font-size:11px;color:var(--muted);overflow-wrap:anywhere}.wb-agent-detail{flex:1;min-height:0;overflow:auto;padding:0 12px 8px}.wb-agent-message{padding:12px 0;border-bottom:1px solid var(--line);font-size:13px;overflow-wrap:anywhere}.wb-agent-message>small{color:var(--muted);font-size:11px}.wb-agent-message pre{white-space:pre-wrap;overflow-wrap:anywhere;font-size:11px}.wb-agent-message .message-body{font-size:13px}
.session-list-region{position:relative;flex:1;min-height:0;overflow:hidden;margin:0 -5px}
-.session-list-region>#sessions{height:100%;margin:0;overscroll-behavior-y:contain;transform:translate3d(0,var(--list-pull,0px),0);transition:transform 180ms ease-out}
-.session-list-region>#sessions.list-pulling{transition:none;will-change:transform}
+.session-list-region>.sessions{height:100%;margin:0;overscroll-behavior-y:contain;transform:translate3d(0,var(--list-pull,0px),0);transition:transform 180ms ease-out}
+.session-list-region>.sessions.list-pulling{transition:none;will-change:transform}
.list-sync{position:absolute;z-index:1;top:0;left:50%;max-width:calc(100% - 16px);white-space:nowrap;font-size:11px;min-height:28px;padding:4px 12px;color:var(--muted);background:var(--surface);border:1px solid var(--line);border-radius:999px;opacity:0;pointer-events:none;transform:translate(-50%,-8px);transition:opacity 160ms ease-out,transform 160ms ease-out}
.list-sync.list-sync-visible{opacity:1;pointer-events:auto;transform:translate(-50%,var(--list-hint-y,4px))}
-@media(prefers-reduced-motion:reduce){.session-list-region>#sessions,.list-sync{transition:none}}
+@media(prefers-reduced-motion:reduce){.session-list-region>.sessions,.list-sync{transition:none}}
.wb-pty .wb-terminal-head strong{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.wb-terminal-screen{flex:1;min-height:80px;overflow:hidden;padding:8px 10px 0}.wb-terminal-screen .xterm{height:100%}.wb-terminal-screen .xterm-viewport{overscroll-behavior:contain}.wb-terminal-screen .xterm-helper-textarea{font-size:16px!important}.wb-terminal-keys{display:flex;gap:6px;overflow-x:auto;flex:none;padding:6px 10px;border-top:1px solid #303947}.wb-terminal-keys button{flex:none;min-width:38px;min-height:36px;padding:4px 8px;border:1px solid #384353;border-radius:6px;background:#1b212b;color:#cdd7e5;font:12px ui-monospace,monospace}.wb-terminal-input{display:none;gap:8px;padding:4px 10px;flex:none}.wb-terminal-touch .wb-terminal-input{display:flex}.wb-terminal-input textarea{flex:1;min-width:0;max-height:22dvh;resize:vertical;font:16px/1.45 ui-monospace,monospace;background:#1b212b;color:#e2e8f0;border:1px solid #384353;border-radius:6px;padding:8px}.wb-terminal-input textarea::placeholder{color:#a3afbf}.wb-terminal-input button{flex:none;width:auto;margin:0;padding:8px;border:1px solid #384353;border-radius:6px;background:#1b212b;color:#a7eed0;font-size:12px}.wb-pty .wb-status{font-size:10px}
.wb-side-content{display:flex;flex-direction:column;min-height:0;height:100%;overflow:hidden}.wb-side-head{display:flex;align-items:center;gap:8px;padding:10px 14px;border-bottom:1px solid var(--line);flex-wrap:wrap;flex:none}.wb-side-head strong{margin-right:auto;font-size:13px}.wb-side-head button{min-height:40px;padding:8px 12px;border:1px solid var(--line);border-radius:10px;background:var(--surface);color:var(--text)}.wb-side-help{margin:10px 14px 0;color:var(--muted);font-size:12px;line-height:1.6;flex:none}.wb-side-content>.wb-status{margin:8px 14px;flex:none}.wb-side-messages{flex:1;min-height:0;overflow:auto;overscroll-behavior:contain;padding:8px 14px}.wb-side-message{margin:0 0 18px;overflow-wrap:anywhere}.wb-side-message>small{display:block;color:var(--muted);font-size:11px;margin-bottom:6px}.wb-side-message.user{background:var(--soft);border-radius:12px;padding:10px 12px}.wb-side-message pre{white-space:pre-wrap;overflow-wrap:anywhere}
From f81bfd4621646b03ca5b5be7abbaaabc31e42039 Mon Sep 17 00:00:00 2001
From: try2love <1929706911@qq.com>
Date: Tue, 13 Oct 2026 00:22:50 +0800
Subject: [PATCH 03/31] fix(workbench): keep split when closing an active tool
tab
---
tests/desktop-sessions-ui.test.cjs | 26 ++++++++++++++++++++++++++
web/features/workspace/workbench.js | 8 +++++---
2 files changed, 31 insertions(+), 3 deletions(-)
diff --git a/tests/desktop-sessions-ui.test.cjs b/tests/desktop-sessions-ui.test.cjs
index 10c38d3c..11fb440a 100644
--- a/tests/desktop-sessions-ui.test.cjs
+++ b/tests/desktop-sessions-ui.test.cjs
@@ -74,6 +74,32 @@ test('Claude lists distinguish Code and Cowork and retain archive/project filter
test('workspace tabs use the real Workbench provider routes and split behavior',async()=>{
const ui=await opened();assert.equal(ui.view.workbench.current.provider,'claude');ui.view.workbench.files();await Promise.resolve();assert.ok(ui.calls.some(c=>c.url.includes('/api/desktop-sessions/claude/workspace?sessionId=a')));ui.view.workbench.split('files');assert.equal(ui.view.workbench.splitMode,true);assert.ok(ui.view.conversation.classList.contains('wb-split'));assert.equal(ui.view.workbench.current.splitTab,'files');
});
+
+for(const [closed,next] of [['files','terminal'],['terminal','file:preview'],['file:preview','terminal']])test(`closing active split ${closed} selects adjacent ${next} and preserves the main chat`,async()=>{
+ const ui=await opened(),wb=ui.view.workbench,session=wb.current,disposed=[];
+ session.files=['files','terminal','file:preview'].map(id=>({id,name:id,body:wb.node('div'),terminal:id==='terminal'?{dispose:()=>disposed.push(id)}:undefined}));
+ ui.view.input.value='unsent draft';ui.view.messages.scrollTop=42;wb.split(closed);session.splitRatio=.6;
+ await wb.close(closed);assert.equal(session.splitTab,next);assert.equal(session.active,next);assert.equal(wb.splitMode,true);assert.equal(wb.panel.firstElementChild,session.files.find(t=>t.id===next).body);assert.equal(ui.view.input.value,'unsent draft');assert.equal(ui.view.messages.scrollTop,42);assert.equal(session.splitRatio,.6);assert.deepEqual(disposed,closed==='terminal'?['terminal']:[]);
+});
+test('inactive closes and consecutive closes preserve split until the final tool is closed',async()=>{
+ const ui=await opened(),wb=ui.view.workbench,s=wb.current;
+ s.files=['files','terminal','file:preview'].map(id=>({id,name:id,body:wb.node('div')}));wb.split('terminal');
+ await wb.close('files');assert.equal(s.splitTab,'terminal');await wb.close('terminal');assert.equal(s.splitTab,'file:preview');assert.equal(wb.splitMode,true);
+ await wb.close('file:preview');assert.equal(s.splitTab,null);assert.equal(s.active,'chat');assert.equal(wb.splitMode,false);assert.equal(wb.panel.hidden,true);
+});
+test('closing a split tool while narrow preserves the adjacent tool when widened',async()=>{
+ const ui=await opened(),wb=ui.view.workbench,s=wb.current;
+ s.files=['files','file:preview'].map(id=>({id,name:id,body:wb.node('div')}));wb.split('file:preview');ui.view.conversation.clientWidth=390;wb.paint();
+ await wb.close('file:preview');assert.equal(s.splitTab,'files');assert.equal(wb.splitMode,false);ui.view.conversation.clientWidth=1000;wb.paint();assert.equal(wb.splitMode,true);assert.equal(wb.panel.firstElementChild,s.files[0].body);
+ wb.unsplit();await wb.close('files');assert.equal(s.active,'chat');assert.equal(s.splitTab,null);
+});
+test('a pending side-chat close updates only its original session and respects cancellation',async()=>{
+ const ui=await opened(),wb=ui.view.workbench,s=wb.current;let finish;
+ s.files=['files','sidechat'].map(id=>({id,name:id,body:wb.node('div')}));s.files[1].sideChat={end:()=>new Promise(resolve=>finish=resolve),dispose(){}};wb.split('sidechat');
+ const cancelled=wb.close('sidechat');finish(false);await cancelled;assert.equal(s.splitTab,'sidechat');assert.equal(s.files.length,2);
+ const closing=wb.close('sidechat');wb.open('other','local');const other=wb.current;finish(true);await closing;assert.equal(wb.current,other);assert.equal(s.splitTab,'files');assert.equal(s.active,'files');assert.equal(other.active,'chat');
+});
+
test('model picker preserves current effort only for compatible models',async()=>{
const ui=await opened();ui.setHandler(async()=>({models:[{id:'model-a',efforts:['high','low']},{id:'model-b',efforts:['low'],defaultEffort:'low'}],currentModel:'model-a',currentEffort:'high'}));await ui.view.models();const dialog=ui.document.querySelector('dialog'),selects=dialog.querySelectorAll('select');assert.equal(selects[1].value,'high');selects[0].value='model-b';selects[0].onchange();assert.equal(selects[1].value,'low');
});
diff --git a/web/features/workspace/workbench.js b/web/features/workspace/workbench.js
index fb55c523..c199efcb 100644
--- a/web/features/workspace/workbench.js
+++ b/web/features/workspace/workbench.js
@@ -195,9 +195,11 @@ class Workbench {
if (session.files[index].sideChat && !await session.files[index].sideChat.end()) return;
if (!session.files[index] || session.files[index].id !== id) return;
session.files[index].controller?.abort(); session.files[index].git?.dispose(); session.files[index].terminal?.dispose(); session.files[index].agents?.dispose(); session.files[index].sideChat?.dispose(); session.files.splice(index, 1);
- if(session.splitTab===id)session.splitTab=null;
- if (this.current !== session) { if (session.active === id) session.active = 'chat'; return; }
- if (session.active === id) this.select(session.files.find(t => t.id === 'files') ? 'files' : 'chat'); else this.paint();
+ if(session.splitTab===id)session.splitTab=(session.files[index]||session.files[index-1])?.id||null;
+ const wasActive=session.active===id;
+ if(wasActive)session.active=session.splitTab||(session.files.some(t=>t.id==='files')?'files':'chat');
+ if (this.current !== session) return;
+ if (wasActive) this.select(session.active); else this.paint();
}
files() {
const session = this.current; if (!session) return;
From 3e5ce02bf1f15a4ddc67a88ebb78912d9215f424 Mon Sep 17 00:00:00 2001
From: try2love <1929706911@qq.com>
Date: Tue, 13 Oct 2026 02:14:56 +0800
Subject: [PATCH 04/31] Compact client list controls and native remove labels
---
.../try2love/codexbridge/MobileStrings.java | 2 +-
mobile/ios/BridgePreview/App.swift | 2 +-
web/features/clients/client-navigation.css | 23 +++++++++++++++----
web/features/clients/desktop-sessions.js | 2 +-
web/index.html | 2 +-
web/shared/i18n.js | 6 ++---
web/shell/app.js | 1 +
7 files changed, 26 insertions(+), 12 deletions(-)
diff --git a/mobile/android/src/io/github/try2love/codexbridge/MobileStrings.java b/mobile/android/src/io/github/try2love/codexbridge/MobileStrings.java
index 2321811a..542872b7 100644
--- a/mobile/android/src/io/github/try2love/codexbridge/MobileStrings.java
+++ b/mobile/android/src/io/github/try2love/codexbridge/MobileStrings.java
@@ -183,7 +183,7 @@ final class MobileStrings {
values.put("当前电脑","Current computer");
values.put("尚未选择电脑","No computer selected");
values.put("返回首页扫码或输入网关地址。","Return home to scan or enter a gateway address.");
- values.put("移除这台电脑","Remove this computer");
+ values.put("移除这台电脑","Remove");
values.put("此预览尚未配置系统推送项目。当前仅同步收件箱;配置 Firebase 或厂商推送后才能启用后台任务提醒。","System push is not configured. Background task alerts require Firebase or a device vendor’s push service.");
values.put("手机通知测试","Mobile notification test");
values.put("本机通知已开启","Local notifications are enabled");
diff --git a/mobile/ios/BridgePreview/App.swift b/mobile/ios/BridgePreview/App.swift
index 2d0ea910..075c01ef 100644
--- a/mobile/ios/BridgePreview/App.swift
+++ b/mobile/ios/BridgePreview/App.swift
@@ -1215,7 +1215,7 @@ enum MobileStrings {
"当前电脑": "Current computer",
"尚未选择电脑": "No computer selected",
"返回首页扫码或输入网关地址。": "Return home to scan or enter a gateway address.",
- "移除这台电脑": "Remove this computer",
+ "移除这台电脑": "Remove",
"此预览尚未配置系统推送项目。当前仅同步收件箱;配置 Firebase 或厂商推送后才能启用后台任务提醒。": "System push is not configured. Background task alerts require Firebase or a device vendor’s push service.",
"手机通知测试": "Mobile notification test",
"本机通知已开启": "Local notifications are enabled",
diff --git a/web/features/clients/client-navigation.css b/web/features/clients/client-navigation.css
index 35ce5f5a..561f9cca 100644
--- a/web/features/clients/client-navigation.css
+++ b/web/features/clients/client-navigation.css
@@ -1,4 +1,4 @@
-.gateway-authenticated>.phone-header{display:none}.list-heading{display:grid;grid-template-columns:minmax(0,auto) minmax(0,1fr) auto auto;gap:6px;min-height:60px}.list-heading h2{display:flex;align-items:center;gap:7px;font-size:17px;white-space:nowrap}.client-badge{display:inline-flex;align-items:center;gap:4px;font-size:12px}.client-icon{display:inline-flex;align-items:center;justify-content:center;flex:none;width:23px;height:23px;font-size:20px;line-height:1}.client-icon img{width:100%;height:100%;object-fit:contain}.connected-computer{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:10px;padding:4px 0;color:var(--muted);min-width:0}.connected-computer[hidden]{display:block!important;visibility:hidden}.list-heading #new-chat{font-size:12px;white-space:nowrap;gap:3px;padding:6px 2px}.list-heading .list-more{font-size:13px;padding:6px 0}.client-navigation{margin-top:auto;flex:none;min-width:0}.client-functions{display:grid;grid-auto-flow:column;grid-auto-columns:minmax(0,1fr);align-items:center;gap:0;padding:7px 0;min-height:48px}.client-functions>*{min-width:0;flex:1!important;font-size:11px!important;padding:9px 2px!important;white-space:nowrap}.client-functions #computers-button{margin:0!important;justify-self:end;text-align:right}.navigation-hidden{display:none!important}.client-switch{display:flex;gap:4px;border-top:1px solid var(--line);padding:6px 0 max(7px,env(safe-area-inset-bottom))}.client-switch button{flex:1;display:flex;flex-direction:column;align-items:center;gap:5px;padding:8px 2px;border:0;background:transparent;border-radius:8px;font-size:11px;color:var(--muted)}.client-switch button.selected{background:var(--surface);color:var(--text);font-weight:600}.client-management-row{display:flex!important;align-items:center;gap:12px;padding:15px 0;border-bottom:1px solid var(--line)}.client-management-row>span:nth-child(2){flex:1}.client-management-row small{display:block;color:var(--muted);font-size:11px;margin-top:6px}.chat-head>.client-icon{margin-right:0}.chat-title-button,#chat-title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.no-client #sessions,.no-client #search,.no-client .list-filters{display:none}.no-client-message{padding:30px 5px}.ds-list{display:flex;flex-direction:column;padding:0 16px;overflow:hidden}.ds-list>.session-list-region>.sessions{overflow:auto}.ds-list>input{flex:none}.ds-list>p{margin:10px 0;font-size:12px}.ds-head>strong{white-space:nowrap;text-overflow:ellipsis;min-width:0}.ds-head>.client-icon{flex:none}.ds-conversation .wb-main-pane>.ds-messages{flex:1}.ds-conversation.workbench-active:not(.wb-split) .wb-main-pane{display:none}.ds-head .ds-back{display:block!important}.ds-conversation .wb-main-label{white-space:nowrap}.ds-conversation .wb-layout{flex:1}
+.gateway-authenticated>.phone-header{display:none}.list-heading{display:grid;grid-template-columns:minmax(0,auto) minmax(0,1fr) auto auto;gap:6px;min-height:60px}.list-heading h2{display:flex;align-items:center;gap:7px;font-size:17px;white-space:nowrap}.client-badge{display:inline-flex;align-items:center;gap:4px;font-size:12px}.client-icon{display:inline-flex;align-items:center;justify-content:center;flex:none;width:23px;height:23px;font-size:20px;line-height:1}.client-icon img{width:100%;height:100%;object-fit:contain}.connected-computer{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:10px;padding:4px 0;color:var(--muted);min-width:0}.connected-computer[hidden]{display:block!important;visibility:hidden}.list-heading #new-chat{font-size:12px;white-space:nowrap;gap:3px;padding:6px 2px}.list-heading .list-more{font-size:13px;padding:6px 0}.client-navigation{margin-top:auto;flex:none;min-width:0}.client-functions{display:grid;grid-auto-flow:column;grid-auto-columns:minmax(0,1fr);align-items:center;gap:0;padding:7px 0;min-height:48px}.client-functions>*{min-width:0;flex:1!important;font-size:11px!important;padding:9px 2px!important;white-space:nowrap}.client-functions #computers-button{margin:0!important;justify-self:end;text-align:right}.navigation-hidden{display:none!important}.client-switch{display:flex;gap:4px;border-top:1px solid var(--line);padding:6px 0 max(7px,env(safe-area-inset-bottom))}.client-switch button{flex:1;display:flex;flex-direction:column;align-items:center;gap:5px;padding:8px 2px;border:0;background:transparent;border-radius:8px;font-size:11px;color:var(--muted)}.client-switch button.selected{background:var(--surface);color:var(--text);font-weight:600}.client-management-row{display:flex!important;align-items:center;gap:12px;padding:15px 0;border-bottom:1px solid var(--line)}.client-management-row>span:nth-child(2){flex:1}.client-management-row small{display:block;color:var(--muted);font-size:11px;margin-top:6px}.chat-head>.client-icon{margin-right:0}.chat-title-button,#chat-title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.no-client #sessions,.no-client #search,.no-client .list-filters{display:none}.no-client-message{padding:30px 5px}.ds-list{display:flex;flex-direction:column;padding:0 16px;overflow:hidden;container-type:inline-size}.ds-list>.session-list-region>.sessions{overflow:auto}.ds-list>input{flex:none}.ds-list>p{margin:10px 0;font-size:12px}.ds-head>strong{white-space:nowrap;text-overflow:ellipsis;min-width:0}.ds-head>.client-icon{flex:none}.ds-conversation .wb-main-pane>.ds-messages{flex:1}.ds-conversation.workbench-active:not(.wb-split) .wb-main-pane{display:none}.ds-head .ds-back{display:block!important}.ds-conversation .wb-main-label{white-space:nowrap}.ds-conversation .wb-layout{flex:1}
/* Shared by all providers; dimensions follow the list pane, not the host OS. */
.chat-list-toolbar{display:grid;grid-template-columns:minmax(0,1fr) auto auto;gap:6px;align-items:center;flex:none;margin:4px 0 8px;min-width:0}
.chat-list-toolbar>.search{width:100%;min-width:0;height:36px;padding:7px 8px;margin:0}
@@ -9,10 +9,9 @@
.chat-list-toolbar .archive{display:flex;align-items:center;gap:3px;font-size:11px;min-height:36px}
.chat-list-toolbar .archive input{width:14px;height:14px;flex:none;margin:0}
/* Keep search usable; filters wrap with the actual list pane width. */
-.ds-list .chat-list-toolbar{display:flex;flex-wrap:wrap}
-.ds-list .chat-list-toolbar>.search{flex:1 1 140px;min-width:140px}
-.ds-list .chat-list-toolbar>.list-filters{display:flex;flex:0 1 auto;flex-wrap:wrap;gap:6px;margin-left:auto}
-.ds-list .chat-list-toolbar .list-mode{flex:none}
+.ds-list .chat-list-toolbar{grid-template-columns:minmax(0,1fr) auto auto auto}
+.ds-list .chat-list-toolbar>.list-filters{display:contents}
+.ds-list .chat-list-toolbar .list-mode{min-width:0}
.ds-list .chat-list-toolbar .ds-surface{width:auto;height:36px;margin:0;padding:6px 3px}
.client-switch-dock{display:flex;align-items:center;border-top:1px solid var(--line);padding-bottom:max(4px,env(safe-area-inset-bottom))}
.client-switch-dock .client-switch{flex:1;min-width:0;border:0;padding:4px 0}
@@ -25,3 +24,17 @@
.client-swipe-rows{flex:1;min-height:0;overflow:hidden!important}
.client-swipe-caption{display:flex;align-items:center;gap:8px;flex:none;padding:10px 8px;font-size:12px;font-weight:600;color:var(--muted)}
.no-client .chat-list-toolbar{display:none}
+
+.list-search-toggle{display:none;align-items:center;justify-content:center;width:36px;height:36px;padding:0;border:1px solid var(--line);border-radius:8px;background:var(--raised);color:var(--text);font-size:24px;cursor:pointer}
+#sidebar{container-type:inline-size}
+@container (max-width:420px){
+ .chat-list-toolbar,.ds-list .chat-list-toolbar{display:flex;flex-wrap:nowrap;gap:5px}
+ .chat-list-toolbar .list-search-toggle{display:flex;flex:none}
+ .chat-list-toolbar>.search{display:none;min-width:0;flex:1}
+ .chat-list-toolbar>.list-filters,.ds-list .chat-list-toolbar>.list-filters{display:contents}
+ .chat-list-toolbar .list-mode{flex:1;min-width:0}
+ .chat-list-toolbar .list-mode select{width:100%;max-width:none;min-width:0}
+ .chat-list-toolbar .archive{flex:none}
+ .chat-list-toolbar.search-open>.search{display:block}
+ .chat-list-toolbar.search-open>.list-filters,.ds-list .chat-list-toolbar.search-open>.list-filters{display:none}
+}
diff --git a/web/features/clients/desktop-sessions.js b/web/features/clients/desktop-sessions.js
index 786022c2..3c799908 100644
--- a/web/features/clients/desktop-sessions.js
+++ b/web/features/clients/desktop-sessions.js
@@ -12,7 +12,7 @@ class DesktopSessionsView {
this.surface=this.node('select','ds-surface');this.label(this.surface,'会话类型');for(const [id,title] of [['all','全部会话'],['code','Code'],['cowork','Cowork']])this.surface.add(new Option(BridgeI18n.t(title),id));this.surface.onchange=()=>this.renderList();filters.prepend(this.surface);
this.listStatus=this.node('p','muted ds-list-status');this.listStatus.setAttribute('role','status');this.connectionRetry=this.button('重试连接',async()=>{const provider=this.provider;if(this.retryingClient||!this.retryClient)return;this.retryingClient=provider;this.renderConnectionStatus();try{await this.retryClient(provider);}finally{this.retryingClient=null;this.renderConnectionStatus();}});this.connectionRetry.hidden=true;
this.connectionInitialize=this.button('初始化连接',async()=>{const provider=this.provider;if(this.initializingClient||!this.initializeClient)return;this.initializingClient=provider;this.renderConnectionStatus();try{await this.initializeClient(provider);}finally{this.initializingClient=null;this.renderConnectionStatus();}});this.connectionInitialize.hidden=true;
- this.connectionNotice=this.node('p','muted');this.connectionNotice.hidden=true;this.connectionRefresh=this.button('刷新状态',async()=>{if(this.refreshingClient||!this.refreshClients)return;this.refreshingClient=true;this.renderConnectionStatus();try{await this.refreshClients();}finally{this.refreshingClient=false;this.renderConnectionStatus();}});this.connectionRefresh.hidden=true;this.rowsRoot=this.node('div','sessions');const toolbar=this.node('div','chat-list-toolbar');toolbar.append(this.search,filters);this.list.append(toolbar,this.listStatus,this.connectionNotice,this.connectionRetry,this.connectionInitialize,this.connectionRefresh,this.rowsRoot);
+ this.connectionNotice=this.node('p','muted');this.connectionNotice.hidden=true;this.connectionRefresh=this.button('刷新状态',async()=>{if(this.refreshingClient||!this.refreshClients)return;this.refreshingClient=true;this.renderConnectionStatus();try{await this.refreshClients();}finally{this.refreshingClient=false;this.renderConnectionStatus();}});this.connectionRefresh.hidden=true;this.rowsRoot=this.node('div','sessions');const toolbar=this.node('div','chat-list-toolbar');const searchToggle=this.node('button','list-search-toggle','⌕');searchToggle.type='button';this.label(searchToggle,'搜索聊天或项目');searchToggle.setAttribute('aria-expanded','false');searchToggle.onclick=()=>{const open=toolbar.classList.toggle('search-open');searchToggle.setAttribute('aria-expanded',String(open));if(open)this.search.focus();};toolbar.append(searchToggle,this.search,filters);this.list.append(toolbar,this.listStatus,this.connectionNotice,this.connectionRetry,this.connectionInitialize,this.connectionRefresh,this.rowsRoot);
this.welcome=this.node('div','welcome ds-welcome');const logo=this.node('img','mark large');logo.src='/icon.png';logo.alt='';logo.width=72;logo.height=72;this.welcome.append(logo,this.translated('h1','从一条聊天继续'),this.translated('p','选择电脑上的聊天,查看进度或发送消息。','muted'));
this.conversation=this.node('section','ds-conversation chat');this.conversation.hidden=true;this.main.append(this.welcome,this.conversation);
this.back=this.iconButton('返回聊天列表','m15 5-7 7 7 7',()=>{if(!this.workbench.back())this.backToList();});this.back.classList.add('ds-back');
diff --git a/web/index.html b/web/index.html
index c814b2bd..bd58f717 100644
--- a/web/index.html
+++ b/web/index.html
@@ -8,7 +8,7 @@
-
+

从一条聊天继续
选择电脑上的聊天,查看进度或发送消息。
diff --git a/web/shared/i18n.js b/web/shared/i18n.js
index 7fd8e168..77973224 100644
--- a/web/shared/i18n.js
+++ b/web/shared/i18n.js
@@ -469,7 +469,7 @@ const en={
"请求方式无效":"Invalid request method",
"会话类型":"Conversation type",
-"全部会话":"All conversations",
+"全部会话":"All",
"此应用的消息附件仅支持图片;其他文件请使用文件传输。":"This app accepts image attachments. Use file transfer for other files.",
"没有匹配的聊天":"No matching conversations",
"这条聊天还没有消息":"No messages yet",
@@ -1773,7 +1773,7 @@ const en={
"执行错误": "Execution error",
"把正在做的事,带到手机上": "Take your work with you",
"拒绝": "Deny",
- "按项目": "Group by project",
+ "按项目": "Project",
"接收主题": "Topic",
"推理强度": "Reasoning effort",
"推送服务请使用 HTTPS;HTTP 仅用于本机测试": "Use HTTPS for the notification server. HTTP is only supported for local testing",
@@ -2345,7 +2345,7 @@ const en={
"Language / 语言": "Language",
"记住登录(闲置 7 天后过期)": "Remember me (expires after 7 days of inactivity)",
"新建": "New",
- "最近交互": "Recent activity",
+ "最近交互": "Recent",
"权限": "Permissions",
"发送新消息": "Send message",
"完成后发送": "Send when done",
diff --git a/web/shell/app.js b/web/shell/app.js
index c190ac4a..c33b4e22 100644
--- a/web/shell/app.js
+++ b/web/shell/app.js
@@ -177,6 +177,7 @@ $('sessions').addEventListener('touchmove',event=>{
$('sessions').addEventListener('touchend',()=>{listTouch=null;},{passive:true});
$('sessions').addEventListener('touchcancel',()=>{listTouch=null;},{passive:true});
$('search').oninput=()=>{clearTimeout(loadList.timer);loadList.timer=setTimeout(()=>loadList(true).catch(e=>toast(e.message)),300);};$('refresh').onclick=()=>loadList(true).catch(e=>toast(e.message));
+for(const toggle of document.querySelectorAll('#sidebar .list-search-toggle'))toggle.onclick=()=>{const toolbar=toggle.closest('.chat-list-toolbar'),open=toolbar.classList.toggle('search-open');toggle.setAttribute('aria-expanded',String(open));if(open)$('search').focus();};
$('archived').onchange=()=>loadList(true).catch(e=>toast(e.message));
const listSync=window.BridgeListSync=new ListSync({root:$('sessions'),status:$('list-sync'),refresh:()=>loadList(true),visible:()=>!$('app').hidden&&$('sidebar').getBoundingClientRect().width>0,ready:()=>!listLoading&&document.activeElement!==$('search')});
const contextUsageControl=new ContextUsageControl();$('permissions-button').after(contextUsageControl.button);
From 3b5433b0a1fac4bba3917e71fc82c8d6e8420f1b Mon Sep 17 00:00:00 2001
From: try2love <1929706911@qq.com>
Date: Tue, 13 Oct 2026 02:15:49 +0800
Subject: [PATCH 05/31] Quiet routine desktop client list status
---
.../2026-10-13-quiet-client-list-status.md | 19 +++++++++++++++++++
tests/desktop-sessions-ui.test.cjs | 8 ++++----
web/features/clients/desktop-sessions.js | 6 +++---
web/shared/i18n.js | 1 +
4 files changed, 27 insertions(+), 7 deletions(-)
create mode 100644 .agents/notes/implemented/feature/2026-10-13-quiet-client-list-status.md
diff --git a/.agents/notes/implemented/feature/2026-10-13-quiet-client-list-status.md b/.agents/notes/implemented/feature/2026-10-13-quiet-client-list-status.md
new file mode 100644
index 00000000..17bce836
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-10-13-quiet-client-list-status.md
@@ -0,0 +1,19 @@
+# Agent Note: Claude 与 DSH 列表状态只提示需关注的结果
+
+Status: implemented
+
+## Problem
+
+客户端列表在每次轮询成功后显示“已连接桌面”,并在已有缓存会话时反复显示“正在读取聊天”。这两种正常状态占用列表空间,却没有给用户新的行动信息。
+
+## Decision
+
+有会话的正常列表读取完成后清空列表状态;已有缓存会话时,重新读取不显示等待文案。首次无缓存读取仍显示等待状态;空列表显示无聊天提示。连接初始化、断开、失败和可操作的重试提示继续由现有连接状态流程展示。
+
+## Alternatives considered
+
+保留“已连接桌面”并只减小字号可以继续提供连接确认,但它仍在每次成功轮询后常驻,无法让真正的异常更突出。
+
+## Consequences
+
+列表更安静,空列表和连接异常仍有明确解释。连接成功本身不再是列表中的持续文案;连接状态仍由客户端管理和会话详情各自展示。桌面会话 UI 回归覆盖正常、空列表、失败与初始化状态。
diff --git a/tests/desktop-sessions-ui.test.cjs b/tests/desktop-sessions-ui.test.cjs
index 33803f7f..b6a7052c 100644
--- a/tests/desktop-sessions-ui.test.cjs
+++ b/tests/desktop-sessions-ui.test.cjs
@@ -36,7 +36,7 @@ test('session list explains a pending connection and offers retry only after tim
ui.view.setClientStates([client],async id=>retries.push(id));ui.setHandler(async()=>{throw Error('客户端连接不可用,请在电脑端检查');});await ui.view.choose('deepseek');
assert.match(ui.view.listStatus.textContent,/正在启动/);assert.doesNotMatch(ui.view.listStatus.textContent,/请在电脑端检查/);assert.equal(ui.view.listStatus.attributes['aria-busy'],'true');assert.equal(ui.view.connectionRetry.hidden,true);assert.equal(ui.calls.length,0);
ui.view.setClientStates([{...client,connectionState:'timeout',retryable:true,reason:'Harness 连接超时,请检查桌面应用后重试接入'}],async id=>retries.push(id));await ui.view.refresh();assert.match(ui.view.listStatus.textContent,/连接超时/);assert.equal(ui.view.listStatus.attributes['aria-busy'],'false');assert.equal(ui.view.connectionRetry.hidden,false);await ui.view.connectionRetry.click();assert.deepEqual(retries,['deepseek']);
- ui.setHandler(async()=>({connected:true,sessions:[]}));await ui.view.setClientStates([{...client,connectionState:'connected',connected:true}]);assert.match(ui.view.listStatus.textContent,/已连接/);assert.equal(ui.view.connectionRetry.hidden,true);
+ ui.setHandler(async()=>({connected:true,sessions:[]}));await ui.view.setClientStates([{...client,connectionState:'connected',connected:true}]);assert.equal(ui.view.listStatus.textContent,'电脑上还没有聊天');assert.equal(ui.view.connectionRetry.hidden,true);
});
test('Claude session list exposes the confirmed initialization flow without running chat RPCs',async()=>{
@@ -49,7 +49,7 @@ test('Claude session list exposes the confirmed initialization flow without runn
test('unknown operations offer refresh only and a failed quit does not block connected chats',async()=>{
const ui=fixture(),client={id:'claude',enabled:true,installed:true,connected:false,initializationMode:'background',setupStatus:'needs-unlock',connectionState:'needs-initialization',operationFailure:{action:'initialize',message:'timeout',uncertain:true}},refreshes=[];
ui.view.setClientStates([client],()=>{throw Error('no replay');},()=>{throw Error('no initialization');},async()=>refreshes.push(true));await ui.view.choose('claude');assert.match(ui.view.listStatus.textContent,/电脑已锁定/);assert.match(ui.view.connectionNotice.textContent,/尚未确认/);assert.equal(ui.view.connectionInitialize.hidden,true);assert.equal(ui.view.connectionRetry.hidden,true);assert.equal(ui.view.connectionRefresh.hidden,false);await ui.view.connectionRefresh.click();assert.equal(refreshes.length,1);assert.equal(ui.calls.length,0);
- await ui.view.setClientStates([{...client,connected:true,connectionState:'connected',operationFailure:{action:'quit',message:'退出未完成',uncertain:false}}]);assert.ok(ui.calls.some(call=>call.url.includes('/list')));assert.match(ui.view.listStatus.textContent,/已连接/);assert.equal(ui.view.connectionNotice.textContent,'退出未完成');assert.equal(ui.view.connectionInitialize.hidden,true);
+ await ui.view.setClientStates([{...client,connected:true,connectionState:'connected',operationFailure:{action:'quit',message:'退出未完成',uncertain:false}}]);assert.ok(ui.calls.some(call=>call.url.includes('/list')));assert.equal(ui.view.listStatus.textContent,'电脑上还没有聊天');assert.equal(ui.view.connectionNotice.textContent,'退出未完成');assert.equal(ui.view.connectionInitialize.hidden,true);
});
test('session list surfaces genuine failures outside startup and never claims disconnected data is connected',async()=>{
@@ -245,7 +245,7 @@ test('history caching evicts older chats and does not retain an oversized transc
test('an invalidated list failure cannot replace the status of a newer successful read',async()=>{
const ui=await opened();let rejectOld,reads=0;ui.setHandler((url,body)=>body?Promise.resolve({status:'accepted'}):url.includes('/list')?++reads===1?new Promise((_,reject)=>rejectOld=reject):Promise.resolve({sessions:[{id:'a',title:'Fresh list',backend:'code'}]}):Promise.resolve({session:{id:'a',title:'Fresh',status:'idle'},messages:[]}));
const stale=ui.view.refresh();await new Promise(setImmediate);await ui.view.mutate('settings',{model:'new'});await new Promise(setImmediate);rejectOld(Error('obsolete list failure'));await stale;
- assert.match(ui.view.rowsRoot.textContent,/Fresh list/);assert.equal(ui.view.listStatus.textContent,'已连接桌面');
+ assert.match(ui.view.rowsRoot.textContent,/Fresh list/);assert.equal(ui.view.listStatus.textContent,'');
});
test('account changes, disabling and logout reject old background list and history replies',async()=>{
@@ -268,7 +268,7 @@ test('reads started before and during a write never replace its completion refre
test('an old account write cannot invalidate the new account list refresh',async()=>{
const ui=await opened();let accept,finishList;ui.setHandler((url,body)=>body?new Promise(resolve=>accept=resolve):Promise.resolve({sessions:[]}));const writing=ui.view.mutate('settings',{model:'new'});
ui.setHandler(()=>new Promise(resolve=>finishList=resolve));const changing=ui.view.accountChanged('claude',{activeId:'old'},{activeId:'new'});accept({status:'accepted'});await writing;
- finishList({sessions:[{id:'b',title:'New account list',backend:'code'}]});await changing;assert.match(ui.view.rowsRoot.textContent,/New account list/);assert.equal(ui.view.listStatus.textContent,'已连接桌面');
+ finishList({sessions:[{id:'b',title:'New account list',backend:'code'}]});await changing;assert.match(ui.view.rowsRoot.textContent,/New account list/);assert.equal(ui.view.listStatus.textContent,'');
});
test('provider return paints cached list and selected history before either network response',async()=>{
diff --git a/web/features/clients/desktop-sessions.js b/web/features/clients/desktop-sessions.js
index 3c799908..2608db7b 100644
--- a/web/features/clients/desktop-sessions.js
+++ b/web/features/clients/desktop-sessions.js
@@ -77,7 +77,7 @@ class DesktopSessionsView {
if(this.state&&!this.canMutate())this.staleDetail();else this.updateComposer();
const current=clients.find(client=>client.id===this.provider),wasWaiting=before&&ClientLifecycle.connection(before).waiting;
this.prefetchLists();
- if(!this.renderConnectionStatus()&&before&&(wasWaiting||(!before.connected&¤t?.connected))){this.listStatus.textContent=BridgeI18n.t('正在读取聊天…');return this.refresh();}
+ if(!this.renderConnectionStatus()&&before&&(wasWaiting||(!before.connected&¤t?.connected))){this.listStatus.textContent=this.rows.length?'':BridgeI18n.t('正在读取聊天…');return this.refresh();}
}
renderConnectionStatus(){
const client=this.clientStates?.find(client=>client.id===this.provider),state=client&&ClientLifecycle.connection(client);
@@ -98,7 +98,7 @@ class DesktopSessionsView {
async choose(provider){
if(provider===this.provider)return;
this.rememberView();this.listSync.reset();clearTimeout(this.timer);this.generation++;this.detailRevision++;this.provider=provider;this.select.value=provider;this.workbench.provider=provider;this.workbench.current=null;this.workbench.panel.hidden=true;this.workbench.tabs.replaceChildren();this.conversation.classList.remove('workbench-active','wb-split');this.sid=null;this.state=null;this.freshDetail=false;const saved=this.providerViews.get(provider);this.rows=saved?.rows||[];this.listStamp='';this.search.value=saved?.query||'';this.surface.value=saved?.surface||'all';this.archived.checked=saved?.archived||false;if(saved?.listMode)this.listMode.value=saved.listMode;this.surface.hidden=provider!=='claude';this.showSelection(false);
- this.container.hidden=provider==='codex';this.root.classList.toggle('desktop-mode',provider!=='codex');this.clientIcon.replaceChildren();if(provider!=='codex'){const img=this.node('img');img.src='/client-icons/'+provider+'.png';img.alt='';this.clientIcon.append(img);}this.clientIcon.className='client-icon client-'+provider;this.listStatus.textContent=BridgeI18n.t('正在读取聊天…');this.renderList();this.rowsRoot.scrollTop=saved?.scroll||0;this.updateComposer();if(saved?.sid)this.prepareChat(saved.sid);this.onSelect(provider);
+ this.container.hidden=provider==='codex';this.root.classList.toggle('desktop-mode',provider!=='codex');this.clientIcon.replaceChildren();if(provider!=='codex'){const img=this.node('img');img.src='/client-icons/'+provider+'.png';img.alt='';this.clientIcon.append(img);}this.clientIcon.className='client-icon client-'+provider;this.listStatus.textContent=this.rows.length?'':BridgeI18n.t('正在读取聊天…');this.renderList();this.rowsRoot.scrollTop=saved?.scroll||0;this.updateComposer();if(saved?.sid)this.prepareChat(saved.sid);this.onSelect(provider);
this.renderConnectionStatus();if(provider!=='codex')await this.refresh();
}
backToList(){if(!this.isChatOpen)return false;this.saveDraft();this.workbench.rememberScroll();this.sid=null;this.state=null;this.freshDetail=false;this.detailRevision++;this.showSelection(false);this.renderList();this.updateComposer();this.rememberView();this.onListEnter?.();return true;}
@@ -132,7 +132,7 @@ class DesktopSessionsView {
const task=this.sharedRead(provider,'list');if(this.listRead?.task===task&&this.listRead.generation===generation)return this.listRead.promise;
const reading={task,generation};this.listRead=reading;
reading.promise=(async()=>{
- try{const value=await task.promise;if(!task.current()||generation!==this.generation)return false;this.rows=value.sessions||[];this.updateActivity(this.rows,value.connected!==false);this.listStatus.textContent=BridgeI18n.t(value.connected===false?'尚未连接':this.rows.length?'已连接桌面':'已连接,桌面还没有会话');this.renderConnectionStatus();this.renderList();this.rememberView();this.onListReady?.(provider,value);if(value.connected===false)throw Error(BridgeI18n.t('尚未连接'));return true;}
+ try{const value=await task.promise;if(!task.current()||generation!==this.generation)return false;this.rows=value.sessions||[];this.updateActivity(this.rows,value.connected!==false);this.listStatus.textContent=value.connected===false?BridgeI18n.t('尚未连接'):this.rows.length?'':BridgeI18n.t('电脑上还没有聊天');this.renderConnectionStatus();this.renderList();this.rememberView();this.onListReady?.(provider,value);if(value.connected===false)throw Error(BridgeI18n.t('尚未连接'));return true;}
catch(error){if(!task.current()||generation!==this.generation)return false;this.updateActivity(this.rows,false);this.renderIndicators();if(!this.renderConnectionStatus())this.listStatus.textContent=BridgeI18n.t(error.message);if(this.sid&&!this.state)this.error.textContent=this.listStatus.textContent;throw error;}
finally{if(this.listRead===reading)this.listRead=null;}
})();return reading.promise;
diff --git a/web/shared/i18n.js b/web/shared/i18n.js
index 77973224..959c0424 100644
--- a/web/shared/i18n.js
+++ b/web/shared/i18n.js
@@ -909,6 +909,7 @@ const en={
"‹ 会话":"‹ Sessions",
"继续桌面会话…":"Continue this desktop session…",
"已连接,桌面还没有会话":"Connected. No desktop sessions yet.",
+"电脑上还没有聊天":"No chats on this computer yet.",
"补充回答":"Additional answer",
"新建桌面会话":"New desktop session",
"应用到此会话":"Apply to this session",
From ebd3fd602707be5ce78c3887f4f17c4e28b38cc4 Mon Sep 17 00:00:00 2001
From: try2love <1929706911@qq.com>
Date: Tue, 13 Oct 2026 02:16:12 +0800
Subject: [PATCH 06/31] Expand desktop client lists across medium widths
---
web/layouts/medium/client-navigation.css | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/web/layouts/medium/client-navigation.css b/web/layouts/medium/client-navigation.css
index 2e2fa768..8be20411 100644
--- a/web/layouts/medium/client-navigation.css
+++ b/web/layouts/medium/client-navigation.css
@@ -1,2 +1,2 @@
@media(min-width:721px){#sidebar{padding-top:0}}
-@media(min-width:721px) and (max-width:1100px){.app.chat-open{grid-template-columns:minmax(0,1fr)}.app.chat-open>#sidebar{display:none}.desktop-sessions.ds-open{grid-template-columns:minmax(0,1fr)}.desktop-sessions.ds-open>.ds-list{display:none}#back{display:block}}
+@media(min-width:721px) and (max-width:1100px){.app.chat-open{grid-template-columns:minmax(0,1fr)}.app.chat-open>#sidebar{display:none}.desktop-sessions:not(.ds-open){grid-template-columns:minmax(0,1fr)}.desktop-sessions:not(.ds-open)>.ds-main{display:none}.desktop-sessions.ds-open{grid-template-columns:minmax(0,1fr)}.desktop-sessions.ds-open>.ds-list{display:none}#back{display:block}}
From 8bc9232df5cfecaef0282b384758c451185ff0d7 Mon Sep 17 00:00:00 2001
From: try2love <1929706911@qq.com>
Date: Tue, 13 Oct 2026 02:17:48 +0800
Subject: [PATCH 07/31] Clarify app icons and file transfer settings
---
.../2026-10-13-file-transfer-settings-read.md | 19 +++++++++++++++++++
desktop/features/files/settings.js | 2 +-
.../try2love/codexbridge/MainActivity.java | 4 ++--
.../try2love/codexbridge/MobileStrings.java | 4 ++--
mobile/ios/BridgePreview/App.swift | 6 +++---
tests/desktop.test.cjs | 9 +++++----
web/features/clients/client-navigation.js | 4 ++--
web/features/workspace/file-actions.js | 10 +++++-----
web/shared/i18n.js | 3 +++
9 files changed, 42 insertions(+), 19 deletions(-)
create mode 100644 .agents/notes/implemented/bug-fix/2026-10-13-file-transfer-settings-read.md
diff --git a/.agents/notes/implemented/bug-fix/2026-10-13-file-transfer-settings-read.md b/.agents/notes/implemented/bug-fix/2026-10-13-file-transfer-settings-read.md
new file mode 100644
index 00000000..a5e3bdf1
--- /dev/null
+++ b/.agents/notes/implemented/bug-fix/2026-10-13-file-transfer-settings-read.md
@@ -0,0 +1,19 @@
+# Agent Note: 文件传输设置在可见且已登录时读取
+
+Status: implemented
+
+## Problem
+
+文件传输设置挂载时立即读取网关,登录前会把“请登录”留在外观面板。后续成功读取只更新输入值,没有清除这条旧错误。桌面高级面板也会在隐藏时提前请求设置。
+
+## Decision
+
+文件传输设置仅在面板可见、页面未隐藏且调用方已就绪时读取。Web 调用方通过已显示的登录后应用判定就绪;桌面本地调用方显式提供就绪条件。面板揭示、窗口聚焦及登录完成事件触发读取,不增加轮询。成功读取清除旧读取错误,正在编辑的值不被覆盖。
+
+## Alternatives considered
+
+在登录时自动重试而保留挂载时请求,触发次数更少,但仍会产生一次无意义的未授权请求,也不能覆盖桌面隐藏面板的场景。
+
+## Consequences
+
+未打开的设置面板不再触发读取。错误只在用户实际查看设置时出现,并会随下一次成功读取消失。独立保存按钮仍更新 clickDownloadMiB,不进入网关通用设置草稿。桌面设置测试验证隐藏挂载、揭示读取与独立保存。
diff --git a/desktop/features/files/settings.js b/desktop/features/files/settings.js
index 020327b3..69d63f07 100644
--- a/desktop/features/files/settings.js
+++ b/desktop/features/files/settings.js
@@ -1,2 +1,2 @@
'use strict';
-BridgeFileActions.mountSettings(document.querySelector('[data-panel=advanced]'),value=>window.bridgeDesktop.transferSettings(value)).id='file-transfer-settings';
+BridgeFileActions.mountSettings(document.querySelector('[data-panel=advanced]'),value=>window.bridgeDesktop.transferSettings(value),{ready:()=>true}).id='file-transfer-settings';
diff --git a/mobile/android/src/io/github/try2love/codexbridge/MainActivity.java b/mobile/android/src/io/github/try2love/codexbridge/MainActivity.java
index 1a2aa7a0..60fcdeb5 100644
--- a/mobile/android/src/io/github/try2love/codexbridge/MainActivity.java
+++ b/mobile/android/src/io/github/try2love/codexbridge/MainActivity.java
@@ -67,8 +67,8 @@ void recoverConnection(String requested){
}
void webAccounts(){if(web!=null&&GatewayURL.sameOrigin(web.getUrl(),origin))web.evaluateJavascript("(()=>{if(typeof window.BridgeNavigation?.accounts==='function')return window.BridgeNavigation.accounts();document.getElementById('accounts-button')?.click();return true;})()",null);}
void gatewayMenu(View anchor){PopupMenu menu=new PopupMenu(this,anchor);menu.getMenu().add(L("返回电脑列表")).setOnMenuItemClickListener(i->{home();return true;});menu.getMenu().add(L("账号与接入")).setOnMenuItemClickListener(i->{webAccounts();return true;});menu.getMenu().add(L("通知收件箱")).setOnMenuItemClickListener(i->{inbox();return true;});menu.getMenu().add(L("外观与显示")).setOnMenuItemClickListener(i->{webAction("[data-open-appearance]");return true;});menu.getMenu().add(L("手机设置")).setOnMenuItemClickListener(i->{settings();return true;});menu.getMenu().add(L("刷新页面")).setOnMenuItemClickListener(i->{if(web!=null){routeAttempted=false;web.reload();}return true;});for(int n=0;n{if(web==current&&generation==ticket)webAction("#client-switch-toggle");return true;});
- if(current!=null&&GatewayURL.sameOrigin(current.getUrl(),address))current.evaluateJavascript("(()=>{const button=document.getElementById('client-switch-toggle');return document.getElementById('app')?.hidden===false&&button&&!button.hidden?button.getAttribute('aria-expanded')==='false':null;})()",value->{if(web!=current||generation!=ticket||!GatewayURL.sameOrigin(current.getUrl(),address))return;if("true".equals(value)||"false".equals(value)){appIcons.setTitle(L("true".equals(value)?"显示APP":"隐藏APP"));appIcons.setVisible(true);}});
+ final android.view.MenuItem appIcons=menu.getMenu().add(L("隐藏底部 APP 图标")).setIcon(android.R.drawable.ic_menu_view).setVisible(false);appIcons.setOnMenuItemClickListener(i->{if(web==current&&generation==ticket)webAction("#client-switch-toggle");return true;});
+ if(current!=null&&GatewayURL.sameOrigin(current.getUrl(),address))current.evaluateJavascript("(()=>{const button=document.getElementById('client-switch-toggle');return document.getElementById('app')?.hidden===false&&button&&!button.hidden?button.getAttribute('aria-expanded')==='false':null;})()",value->{if(web!=current||generation!=ticket||!GatewayURL.sameOrigin(current.getUrl(),address))return;if("true".equals(value)||"false".equals(value)){appIcons.setTitle(L("true".equals(value)?"显示底部 APP 图标":"隐藏底部 APP 图标"));appIcons.setVisible(true);}});
if(Build.VERSION.SDK_INT>=29)menu.setForceShowIcon(true);menu.show();}
void message(String value){resetExitGesture();new AlertDialog.Builder(this).setMessage(L(value)).setPositiveButton(L("好"),null).show();}
diff --git a/mobile/android/src/io/github/try2love/codexbridge/MobileStrings.java b/mobile/android/src/io/github/try2love/codexbridge/MobileStrings.java
index 542872b7..502dc2bb 100644
--- a/mobile/android/src/io/github/try2love/codexbridge/MobileStrings.java
+++ b/mobile/android/src/io/github/try2love/codexbridge/MobileStrings.java
@@ -126,8 +126,8 @@ final class MobileStrings {
values.put("返回电脑列表","Back to computers");
values.put("账号与接入","Accounts and connections");
values.put("外观与显示","Appearance");
- values.put("隐藏APP","Hide apps");
- values.put("显示APP","Show apps");
+ values.put("隐藏底部 APP 图标","Hide bottom app icons");
+ values.put("显示底部 APP 图标","Show bottom app icons");
values.put("刷新页面","Reload page");
values.put("好","OK");
values.put("电脑上的工作,\n带在身边。","Your computer’s work,\nalways with you.");
diff --git a/mobile/ios/BridgePreview/App.swift b/mobile/ios/BridgePreview/App.swift
index 075c01ef..7122aa76 100644
--- a/mobile/ios/BridgePreview/App.swift
+++ b/mobile/ios/BridgePreview/App.swift
@@ -246,7 +246,7 @@ final class BridgeController: UIViewController, WKNavigationDelegate, WKUIDelega
let ticket = self.generation, address = self.origin
web.evaluateJavaScript("(()=>{const button=document.getElementById('client-switch-toggle');return document.getElementById('app')?.hidden===false&&button&&!button.hidden?button.getAttribute('aria-expanded')==='false':null;})()") { [weak self, weak web] value, _ in
guard let self, let web, self.web === web, self.generation == ticket, let url = web.url, GatewayURL.same(url, address), let collapsed = value as? Bool else { completion([]); return }
- let action = UIAction(title: MobileStrings.text(collapsed ? "显示APP" : "隐藏APP"), image: UIImage(systemName: "square.grid.3x1.below.line.grid.1x2")) { [weak self, weak web] _ in
+ let action = UIAction(title: MobileStrings.text(collapsed ? "显示底部 APP 图标" : "隐藏底部 APP 图标"), image: UIImage(systemName: "square.grid.3x1.below.line.grid.1x2")) { [weak self, weak web] _ in
guard let self, let web, self.web === web, self.generation == ticket, let url = web.url, GatewayURL.same(url, address) else { return }
web.evaluateJavaScript("document.getElementById('client-switch-toggle')?.click()")
}
@@ -1157,8 +1157,8 @@ enum MobileStrings {
"返回电脑列表": "Back to computers",
"外观与显示": "Appearance",
- "隐藏APP": "Hide apps",
- "显示APP": "Show apps",
+ "隐藏底部 APP 图标": "Hide bottom app icons",
+ "显示底部 APP 图标": "Show bottom app icons",
"刷新页面": "Reload page",
"好": "OK",
"电脑上的工作,\n带在身边。": "Your computer’s work,\nalways with you.",
diff --git a/tests/desktop.test.cjs b/tests/desktop.test.cjs
index 29dbaee1..e9f1c0ee 100644
--- a/tests/desktop.test.cjs
+++ b/tests/desktop.test.cjs
@@ -106,22 +106,23 @@ for(const transferFirst of [false,true])test(`independent transfer settings ${tr
element.validity={valid:true};element.reportValidity=()=>element.validity.valid;
return element;
};
- const panel=ui.context.document.createElement('section');panel.dataset.panel='advanced';
+ const panel=ui.context.document.createElement('section');panel.dataset.panel='advanced';panel.hidden=true;
const descendants=element=>[element,...(element.children||[]).flatMap(descendants)];
const autoStart=ui.nodes.get('auto-start');autoStart.id='auto-start';autoStart.type='checkbox';autoStart.validity={valid:true};autoStart.closest=selector=>selector==='[data-panel]'?panel:null;
ui.context.document.querySelector=selector=>selector==='[data-panel=advanced]'?panel:null;
ui.nodes.get('settings').querySelectorAll=()=>[autoStart,...descendants(panel).filter(element=>element.tagName==='INPUT')];
- ui.context.MutationObserver=class {observe(){}};ui.context.window.addEventListener=()=>{};
+ let reveal;ui.context.MutationObserver=class {constructor(callback){reveal=callback;}observe(){}};ui.context.window.addEventListener=()=>{};
let resolveRead;const writes=[];
ui.api.transferSettings=value=>value?(writes.push(value),Promise.resolve(value)):new Promise(resolve=>{resolveRead=resolve;});
vm.runInContext(fs.readFileSync(path.join(__dirname,'../web/features/workspace/file-actions.js'),'utf8'),ui.context);
ui.context.BridgeFileActions=ui.context.window.BridgeFileActions;
vm.runInContext(fs.readFileSync(path.join(__dirname,'../desktop/features/files/settings.js'),'utf8'),ui.context);
const input=descendants(panel).find(element=>element.tagName==='INPUT');
- if(transferFirst){resolveRead({clickDownloadMiB:100});await new Promise(setImmediate);}
+ assert.equal(resolveRead,undefined,'hidden settings must not read before reveal');
+ if(transferFirst){panel.hidden=false;reveal();resolveRead({clickDownloadMiB:100});await new Promise(setImmediate);}
ui.run('dirty=false;render(snapshot)');
assert.equal(ui.run('dirty'),false);
- if(!transferFirst){resolveRead({clickDownloadMiB:100});await new Promise(setImmediate);}
+ if(!transferFirst){panel.hidden=false;reveal();resolveRead({clickDownloadMiB:100});await new Promise(setImmediate);}
ui.run("applyLanguage('en')");
assert.equal(ui.run('dirty'),false,'independently loaded transfer limit is not a gateway draft');
await ui.start();assert.ok(ui.calls.includes('start'));
diff --git a/web/features/clients/client-navigation.js b/web/features/clients/client-navigation.js
index cfc94ff7..18eb1d0d 100644
--- a/web/features/clients/client-navigation.js
+++ b/web/features/clients/client-navigation.js
@@ -27,7 +27,7 @@ class ClientNavigation {
for(const [id,name] of [['codex','Codex'],['claude','Claude'],['deepseek','DeepSeek Harness']]){const row=document.createElement('label'),box=document.createElement('input'),caption=document.createElement('span');row.className='display-option';box.type='checkbox';box.id='appearance-switch-'+id;box.checked=this.preference('switch:'+id);box.onchange=()=>{this.preference('switch:'+id,box.checked);this.paint();};caption.textContent=name;row.append(caption,box);switchPreferences.append(row);}const switchHint=document.createElement('p');switchHint.className='muted';switchHint.dataset.i18n='隐藏图标不关闭应用,仍可在应用管理中打开聊天。';switchHint.textContent=BridgeI18n.t('隐藏图标不关闭应用,仍可在应用管理中打开聊天。');switchPreferences.append(switchHint);preferences.after(switchPreferences);
const swipeRow=document.createElement('label'),swipeBox=document.createElement('input'),swipeCaption=document.createElement('span');swipeRow.className='display-option';swipeBox.type='checkbox';swipeBox.id='appearance-swipe-clients';swipeBox.checked=this.preference('swipe-clients',undefined,true);swipeBox.onchange=()=>{this.preference('swipe-clients',swipeBox.checked);this.cancelListSwipe();};swipeCaption.dataset.i18n='左右滑动切换应用';swipeCaption.textContent=BridgeI18n.t('左右滑动切换应用');swipeRow.append(swipeCaption,swipeBox);switchPreferences.append(swipeRow);this.bindListGestures();
const language=document.getElementById('language');if(language)preferences.append(language.closest('label'));
- const applications=make('应用管理',()=>{settings.close();this.openManager();});settings.querySelector('.appearance-actions').insertBefore(applications,document.getElementById('appearance-reset').nextSibling);
+ const applications=make('应用管理',()=>{settings.close();this.openManager();});this.switchToggle.after(applications);
this.account=document.getElementById('accounts-button');this.notifications=document.getElementById('pushplus-settings');
// Older native shells move these controls after page load. Keep the new
// two-row navigation intact while preserving their native home action.
@@ -234,7 +234,7 @@ class ClientNavigation {
const target=provider&&provider!=='codex'?this.view.list:this.sidebar;target.prepend(this.heading);target.append(this.navigation);
this.account.classList.toggle('navigation-hidden',!provider||(provider==='codex'&&!!window.BridgeSharedRelay));this.notifications.classList.toggle('navigation-hidden',!provider);this.manage.hidden=!this.preference('clients');
this.account.textContent=BridgeI18n.t('账号管理');this.account.removeAttribute('data-i18n');this.notifications.textContent=BridgeI18n.t('通知管理');this.notifications.removeAttribute('data-i18n');
- this.switcher.replaceChildren();const enabled=this.clients.filter(c=>c.enabled),visible=this.visibleClients();const collapsed=this.preference('switch-collapsed',undefined,false);this.switchToggle.hidden=enabled.length<2||!visible.length;this.switchDock.hidden=this.switchToggle.hidden||collapsed;this.navigation.classList.toggle('switch-dock-hidden',this.switchDock.hidden);this.switchToggle.textContent=BridgeI18n.t(collapsed?'显示APP':'隐藏APP');this.switchToggle.setAttribute('aria-expanded',String(!collapsed));this.switchToggle.setAttribute('aria-label',BridgeI18n.t(collapsed?'展开应用图标':'收起应用图标'));this.switchToggle.title=this.switchToggle.getAttribute('aria-label');
+ this.switcher.replaceChildren();const enabled=this.clients.filter(c=>c.enabled),visible=this.visibleClients();const collapsed=this.preference('switch-collapsed',undefined,false);this.switchToggle.hidden=enabled.length<2||!visible.length;this.switchDock.hidden=this.switchToggle.hidden||collapsed;this.navigation.classList.toggle('switch-dock-hidden',this.switchDock.hidden);this.switchToggle.textContent=BridgeI18n.t(collapsed?'显示底部 APP 图标':'隐藏底部 APP 图标');this.switchToggle.setAttribute('aria-expanded',String(!collapsed));this.switchToggle.setAttribute('aria-label',BridgeI18n.t(collapsed?'展开应用图标':'收起应用图标'));this.switchToggle.title=this.switchToggle.getAttribute('aria-label');
for(const client of visible){const button=document.createElement('button');button.type='button';button.append(this.icon(client.id),document.createTextNode(client.name));button.classList.toggle('selected',client.id===provider);button.setAttribute('aria-pressed',String(client.id===provider));button.onclick=()=>this.choose(client.id).catch(e=>this.notify(e.message));this.switcher.append(button);}
document.getElementById('desktop-backend').hidden=true;
document.getElementById('new-chat').disabled=!provider;
diff --git a/web/features/workspace/file-actions.js b/web/features/workspace/file-actions.js
index 63815ef3..ce2196f9 100644
--- a/web/features/workspace/file-actions.js
+++ b/web/features/workspace/file-actions.js
@@ -46,18 +46,18 @@ window.BridgeFileActions = (() => {
const chat=anchor.closest('.chat'),workbench=chat&&workbenches.get(chat);if(!workbench)return;
event.preventDefault();event.stopImmediatePropagation();choose(anchor,workbench);
},true);
- function mountSettings(container,request){
+ function mountSettings(container,request,{ready=()=>document.getElementById('app')?.hidden===false}={}){
if(!container)return;
- const field=node('fieldset',undefined,'appearance-section'),legend=node('legend',t('文件传输')),label=node('label'),input=node('input'),save=node('button',t('保存配置'),'plain'),message=node('p','','muted');
- legend.dataset.i18n='文件传输';input.type='number';input.min='1';input.max='1048576';input.step='1';input.required=true;save.type='button';save.dataset.i18n='保存配置';
+ const field=node('fieldset',undefined,'appearance-section'),legend=node('legend',t('文件传输')),label=node('label'),input=node('input'),save=node('button',t('保存点击下载阈值'),'plain'),message=node('p','','muted');
+ legend.dataset.i18n='文件传输';input.type='number';input.min='1';input.max='1048576';input.step='1';input.required=true;save.type='button';save.dataset.i18n='保存点击下载阈值';
const caption=node('span',t('点击下载阈值(MiB)'));caption.dataset.i18n='点击下载阈值(MiB)';label.append(caption,input);
const hint=node('p',t('超过阈值仅显示定位;文件标签页中长按或右键下载不限此大小。'),'muted');hint.dataset.i18n='超过阈值仅显示定位;文件标签页中长按或右键下载不限此大小。';field.append(legend,label,hint,save,message);container.append(field);
let dirty=false,loading=false;input.oninput=e=>{e.stopPropagation();dirty=true;};input.onchange=e=>e.stopPropagation();
- const read=async()=>{if(dirty||loading||document.hidden)return;loading=true;try{const value=await request();if(!dirty)input.value=value.clickDownloadMiB;}catch(e){message.textContent=t(e.message);}finally{loading=false;}};
+ const read=async()=>{if(dirty||loading||document.hidden||container.hidden||('open' in container&&!container.open)||!ready())return;loading=true;try{const value=await request();if(!dirty){input.value=value.clickDownloadMiB;message.textContent='';}}catch(e){message.textContent=t(e.message);}finally{loading=false;}};
save.onclick=async()=>{if(!input.reportValidity())return;save.disabled=true;const value=Number(input.value);try{await request({clickDownloadMiB:value});if(Number(input.value)===value)dirty=false;message.textContent=t('已保存,立即生效');}catch(e){message.textContent=t(e.message);}finally{save.disabled=false;}};
// Fetch on reveal/focus, not on a timer. Every download reads the latest server policy.
new MutationObserver(()=>{if(!container.hidden&&(!('open' in container)||container.open))read();}).observe(container,{attributes:true,attributeFilter:['hidden','open']});
- window.addEventListener('focus',read);read();return field;
+ window.addEventListener('focus',read);document.addEventListener('bridge-authenticated',read);return field;
}
return {register:(chat,workbench)=>workbenches.set(chat,workbench),bindRow,menu,mountSettings};
})();
diff --git a/web/shared/i18n.js b/web/shared/i18n.js
index 959c0424..eeed39a1 100644
--- a/web/shared/i18n.js
+++ b/web/shared/i18n.js
@@ -121,6 +121,9 @@ const en={
"这会立即结束电脑上的应用,可能中断正在运行的会话并丢失未保存内容。请确认仍要继续。":"This will immediately stop the desktop app. Running sessions may be interrupted and unsaved content may be lost. Confirm to continue.",
"确认强制关闭":"Force quit now",
"底部应用切换":"Bottom app switcher",
+"显示底部 APP 图标":"Show bottom app icons",
+"隐藏底部 APP 图标":"Hide bottom app icons",
+"保存点击下载阈值":"Save click download limit",
"隐藏图标不关闭应用,仍可在应用管理中打开聊天。":"Hiding an icon does not stop the app. Open its chats from App management.",
"查看聊天":"View chats",
"不选择项目":"No project",
From 251c9be23b94239955721adaa15d18a86066fd29 Mon Sep 17 00:00:00 2001
From: try2love <1929706911@qq.com>
Date: Tue, 13 Oct 2026 02:18:12 +0800
Subject: [PATCH 08/31] Guard Claude renderer ownership and expose safe
reconnect
---
bridge/clients/claude/adapter.py | 11 ++++-
bridge/clients/claude/connector.js | 56 +++++++++++++++++++---
bridge/clients/manager.py | 3 +-
bridge/platforms/macos/claude-helper.swift | 9 ++++
bridge/platforms/windows/claude-helper.cs | 1 +
bridge/platforms/windows/claude.py | 3 ++
tests/claude-connector-owner.test.cjs | 54 +++++++++++++++++++++
tests/client-lifecycle-ui.test.cjs | 5 ++
tests/desktop-sessions-ui.test.cjs | 13 +++++
tests/test_claude_controls.py | 5 ++
tests/test_claude_session.py | 9 ++++
tests/test_claude_setup.py | 12 ++++-
web/features/clients/client-lifecycle.js | 7 +--
web/features/clients/client-navigation.js | 7 +--
web/features/clients/desktop-sessions.js | 11 +++--
web/shared/i18n.js | 4 ++
16 files changed, 189 insertions(+), 21 deletions(-)
create mode 100644 tests/claude-connector-owner.test.cjs
diff --git a/bridge/clients/claude/adapter.py b/bridge/clients/claude/adapter.py
index c4e6824c..e6a5657f 100644
--- a/bridge/clients/claude/adapter.py
+++ b/bridge/clients/claude/adapter.py
@@ -475,6 +475,7 @@ def _connect_native(self, restart, cancel_path, cancel=None, *, existing_only=Fa
self._setup_state('needs-retry', result.get('reason', 'Claude 自动连接未完成,请点击连接重试'))
return
deadline = time.monotonic() + (8 if sys.platform == 'win32' else 18)
+ inspect_at = time.monotonic() + 2
while not cancel.is_set() and time.monotonic() < deadline:
if self.desktop.connected:
if force_console:
@@ -493,6 +494,12 @@ def _connect_native(self, restart, cancel_path, cancel=None, *, existing_only=Fa
self._attempt_state(cancel, 'connected', 'Claude 已连接;开发者工具未自动关闭,请手动关闭' if pending else '桌面连接可用',
consoleCleanupPending=pending)
return
+ if time.monotonic() >= inspect_at:
+ inspection = native_action('inspect-error', pid=pid, executable=executable, cancelled=cancel)
+ if inspection.get('setupState') == 'owner-conflict':
+ self._attempt_state(cancel, 'owner-conflict', inspection['reason'])
+ return
+ inspect_at = time.monotonic() + 3
cancel.wait(.25)
else:
if not cancel.is_set():
@@ -503,8 +510,8 @@ def _connect_native(self, restart, cancel_path, cancel=None, *, existing_only=Fa
return
if self._await_desktop(result, cancel): continue
return
- self._setup_state('needs-trust' if result.get('setupState') == 'needs-trust' else 'failed',
- result.get('reason') if result.get('setupState') == 'needs-trust'
+ self._setup_state(result.get('setupState') if result.get('setupState') in ('needs-trust', 'owner-conflict') else 'failed',
+ result.get('reason') if result.get('setupState') in ('needs-trust', 'owner-conflict')
else '未收到 Claude 连接确认,请检查登录和目录信任后重试')
return
except Exception as exc:
diff --git a/bridge/clients/claude/connector.js b/bridge/clients/claude/connector.js
index e2f1cf37..f24b62c2 100644
--- a/bridge/clients/claude/connector.js
+++ b/bridge/clients/claude/connector.js
@@ -9,7 +9,42 @@
if (!api?.LocalSessions?.getAll || (config.transport !== 'cdp' && (!api.LocalSessions.readFileAtCwd || !api.LocalSessions.writeSessionFile))) {
throw new Error('当前 Claude Desktop 不提供所需的本地文件接口');
}
- window.__claudeMobileBridge?.stop();
+ const normalizedWorkspace = cwd => {
+ const value = cwd.replaceAll('\\', '/').replace(/\/$/, '');
+ return /^[A-Za-z]:\//.test(value) ? value.toLowerCase() : value;
+ };
+ const sameWorkspace = cwd => typeof cwd === 'string' &&
+ normalizedWorkspace(cwd) === normalizedWorkspace(config.cwd);
+ const existing = window.__claudeMobileBridge;
+ if (existing) {
+ let state, sameOwner = false;
+ try {
+ state = existing.status();
+ if (typeof existing.owns === 'function') sameOwner = existing.owns(config.cwd) === true;
+ else if (state?.transport === 'file' && state.sessionId) {
+ // Legacy connectors expose only a session ID. Check Desktop's live
+ // session list before deciding whether that renderer is ours.
+ const rows = await api.LocalSessions.getAll();
+ const sessions = Array.isArray(rows) ? rows : rows?.sessions;
+ const session = sessions?.find(row => row.sessionId === state.sessionId);
+ sameOwner = sameWorkspace(session?.cwd);
+ }
+ } catch { /* Unknown ownership is occupied, never permission to replace. */ }
+ if (!state || state.busy || state.writing || state.stopped !== true && !sameOwner) {
+ const message = sameOwner ? 'Claude 桥接仍有请求进行中,请稍后重试连接' :
+ 'Claude 桥接连接被其他网关占用,请先停止旧网关或重新加载 Claude';
+ window.__codingRemoteInjectionError = message;
+ console.error('Error: ' + message);
+ throw new Error(message);
+ }
+ }
+ function replaceExisting() {
+ if (!existing) return;
+ const state = existing.status();
+ if (window.__claudeMobileBridge !== existing || state.busy || state.writing)
+ throw new Error('Claude 桥接状态已变化,请刷新后重试连接');
+ existing.stop();
+ }
const encoder = new TextEncoder();
const key = await crypto.subtle.importKey('raw', encoder.encode(config.token),
{name: 'HMAC', hash: 'SHA-256'}, false, ['sign', 'verify']);
@@ -205,8 +240,11 @@
}
if (config.transport === 'cdp') {
let stopped = false, highWater = 0;
+ replaceExisting();
+ delete window.__codingRemoteInjectionError;
window.__claudeMobileBridge = {
stop() { stopped = true; for (const undo of restore) undo(); },
+ owns(cwd) { return sameWorkspace(cwd); },
status() { return {transport:'cdp', stopped, connectorRevision:config.connectorRevision, surfaces:capabilities}; },
async invoke(msg) {
if (stopped || msg?.token !== config.token || msg.generation !== config.generation)
@@ -253,7 +291,7 @@
if (!sessionId) throw new Error('无法创建文件桥接会话;请在 Code 页面确认目录信任');
let generation = initial.generation;
const generations = new Set([generation]);
- let stopped = false, suspended = initial.type === 'stop', busy = false, highWater = initial.seq, lastWrite = 0, dirty = true;
+ let stopped = false, suspended = initial.type === 'stop', busy = false, writing = false, highWater = initial.seq, lastWrite = 0, dirty = true;
let wake, wakeRequested=false;
const wakePoll=()=>{wakeRequested=true;wake?.();};
const pause=async()=>{
@@ -265,8 +303,11 @@
wakeRequested=false;
};
let state = {seq: 0, done: false};
+ replaceExisting();
+ delete window.__codingRemoteInjectionError;
window.__claudeMobileBridge = {stop() { stopped = true;wakePoll(); for (const undo of restore) undo(); },
- status() { return {transport: 'file', stopped, busy, sessionId, lastWrite, connectorRevision:config.connectorRevision}; }};
+ owns(cwd) { return sameWorkspace(cwd); },
+ status() { return {transport: 'file', stopped, busy, writing, sessionId, lastWrite, connectorRevision:config.connectorRevision}; }};
async function publish() {
let value = {...state, generation, connected: !stopped && !suspended,
timestamp: Date.now() / 1000, connectorRevision:config.connectorRevision, surfaces: capabilities};
@@ -275,9 +316,12 @@
state = {seq: state.seq, done: true, error: '会话内容超过 32 MiB,请在桌面查看'};
text = await pack({...value, ...state, result: undefined});
}
- const result = await api.LocalSessions.writeSessionFile(sessionId, config.response, text);
- if (!result?.hash) throw new Error('Claude 拒绝写入桥接文件,请检查目录信任和文件权限');
- lastWrite = Date.now();
+ writing = true;
+ try {
+ const result = await api.LocalSessions.writeSessionFile(sessionId, config.response, text);
+ if (!result?.hash) throw new Error('Claude 拒绝写入桥接文件,请检查目录信任和文件权限');
+ lastWrite = Date.now();
+ } finally { writing = false; }
}
console.info('Claude 文件桥接启动中;状态:window.__claudeMobileBridge.status()');
try {
diff --git a/bridge/clients/manager.py b/bridge/clients/manager.py
index ff15befa..7fa9207f 100644
--- a/bridge/clients/manager.py
+++ b/bridge/clients/manager.py
@@ -886,7 +886,8 @@ def clients(self, refresh=False):
row['initializationMode'] = 'background' if sys.platform == 'win32' else 'foreground'
row['reconnectSupported'] = self._claude_reconnect_supported()
row['canReconnect'] = bool(row['reconnectSupported'] and self.gateway_running and row['enabled'] and
- row['mainRunning'] and not native.get('unknown', True))
+ len(native.get('mainPids', [])) == 1 and not native.get('unknown', True) and
+ row['setupStatus'] != 'connecting')
row['canConfirmQuit'] = bool(sys.platform in ('darwin', 'win32') and row['mainRunning'])
if not self.gateway_running and row['enabled'] and row['setupStatus'] != 'recovery-required':
if row['id'] == 'claude' and sys.platform == 'darwin':
diff --git a/bridge/platforms/macos/claude-helper.swift b/bridge/platforms/macos/claude-helper.swift
index 87b16482..66829aae 100644
--- a/bridge/platforms/macos/claude-helper.swift
+++ b/bridge/platforms/macos/claude-helper.swift
@@ -329,6 +329,15 @@ final class Connector {
output("connected", "Claude 已连接")
}
func inspectError() {
+ let conflict = find(root, matching: {
+ guard text($0, kAXRoleAttribute) == "AXStaticText" else { return false }
+ let value = text($0, kAXValueAttribute).isEmpty ? text($0, kAXTitleAttribute) : text($0, kAXValueAttribute)
+ return value.contains("Claude 桥接连接被其他网关占用")
+ })
+ if conflict != nil {
+ output("owner-conflict", "Claude 桥接连接被其他网关占用,请先停止旧网关或重新加载 Claude")
+ return
+ }
let error = find(root, matching: {
guard text($0, kAXRoleAttribute) == "AXStaticText" else { return false }
let value = text($0, kAXValueAttribute).isEmpty ? text($0, kAXTitleAttribute) : text($0, kAXValueAttribute)
diff --git a/bridge/platforms/windows/claude-helper.cs b/bridge/platforms/windows/claude-helper.cs
index 03642e30..4494e116 100644
--- a/bridge/platforms/windows/claude-helper.cs
+++ b/bridge/platforms/windows/claude-helper.cs
@@ -1305,6 +1305,7 @@ [STAThread] static int Main(string[] args) {
var texts=AutomationElement.FromHandle(WaitAppWindow(process,false)).FindAll(TreeScope.Descendants,new PropertyCondition(AutomationElement.ControlTypeProperty,ControlType.Text));
foreach(AutomationElement item in texts) {
string value=item.Current.Name;
+ if(value.Contains("Claude 桥接连接被其他网关占用")) {Stage("OWNER_CONFLICT");return 0;}
if((value.StartsWith("Error:")||value.StartsWith("Uncaught"))&&(value.Contains("尚未信任目录")||value.Contains("WorkspaceTrustError")||value.Contains("trust_required"))) {Stage("NEEDS_TRUST");return 0;}
}
Stage("NO_TRUST_ERROR");return 0;
diff --git a/bridge/platforms/windows/claude.py b/bridge/platforms/windows/claude.py
index 172ac884..7a81606c 100644
--- a/bridge/platforms/windows/claude.py
+++ b/bridge/platforms/windows/claude.py
@@ -191,6 +191,9 @@ def desktop_blocked():
else 'needs-desktop' if '桌面不可用' in message else 'needs-retry')
return {'setupState': state, 'reason': message[:300]}
if action == 'inspect-error':
+ if 'OWNER_CONFLICT' in stdout:
+ return {'setupState': 'owner-conflict',
+ 'reason': 'Claude 桥接连接被其他网关占用,请先停止旧网关或重新加载 Claude'}
return {'setupState': 'needs-trust' if 'NEEDS_TRUST' in stdout else 'failed',
'reason': '请在 Claude Code 中确认连接目录信任,然后重新连接'}
return {'setupState': 'needs-developer-mode' if action == 'enable-devtools' else 'submitted',
diff --git a/tests/claude-connector-owner.test.cjs b/tests/claude-connector-owner.test.cjs
new file mode 100644
index 00000000..1630c0cd
--- /dev/null
+++ b/tests/claude-connector-owner.test.cjs
@@ -0,0 +1,54 @@
+'use strict';
+const test=require('node:test'),assert=require('node:assert/strict'),fs=require('node:fs'),vm=require('node:vm');
+const {webcrypto,createHmac}=require('node:crypto');
+const config={cwd:'/gateway-b',request:'request.json',response:'response.json',token:'fixture',generation:'generation-b',reconnect:true,connectorRevision:5};
+const source=fs.readFileSync('bridge/clients/claude/connector.js','utf8').replace('__BRIDGE_CONFIG__',JSON.stringify(config));
+function fixture(old,ownerCwd='/gateway-a'){
+ let stopped=0,writes=0,starts=0;
+ const payload=JSON.stringify({generation:config.generation,type:'idle',seq:0});
+ const envelope=JSON.stringify({payload,signature:createHmac('sha256',config.token).update(payload).digest('hex')});
+ const api={
+ async getAll(){return [{sessionId:'legacy-session',cwd:ownerCwd,title:'Codex Bridge Connector'}];},
+ async readFileAtCwd(){return {contents:envelope};},
+ async writeSessionFile(){writes++;return {hash:'fixture'};},
+ async start(){starts++;return {sessionId:'new-session'};}
+ };
+ const window={'claude.web':{LocalSessions:api},__claudeMobileBridge:{
+ status:()=>old,stop(){stopped++;},...(old?.owns?{owns:old.owns}:{})}};
+ const context={window,crypto:webcrypto,TextEncoder,Uint8Array,Date,setTimeout,clearTimeout,console:{info(){},error(){}}};
+ return {window,run:()=>vm.runInNewContext(source,context),counts:()=>({stopped,writes,starts})};
+}
+for(const [name,status] of [
+ ['live foreign legacy owner',{transport:'file',stopped:false,busy:false,sessionId:'legacy-session',lastWrite:Date.now()}],
+ ['stale foreign legacy owner',{transport:'file',stopped:false,busy:false,sessionId:'legacy-session',lastWrite:1}],
+ ['live foreign current owner',{transport:'file',stopped:false,busy:false,owns:()=>false,lastWrite:Date.now()}],
+ ['unknown legacy owner',{transport:'file',stopped:false,busy:false,sessionId:'missing',lastWrite:Date.now()}]
+])test(name+' remains intact and reports conflict',async()=>{
+ const ui=fixture(status);
+ await assert.rejects(ui.run(),/其他网关占用/);
+ assert.deepEqual(ui.counts(),{stopped:0,writes:0,starts:0});
+ assert.match(ui.window.__codingRemoteInjectionError,/其他网关占用/);
+});
+test('legacy same workspace may be replaced without exposing its token',async()=>{
+ const ui=fixture({transport:'file',stopped:false,busy:false,sessionId:'legacy-session',lastWrite:Date.now()},config.cwd);
+ const running=ui.run();
+ for(let i=0;i<50&&!ui.window.__claudeMobileBridge?.owns;i++)await new Promise(resolve=>setTimeout(resolve,10));
+ assert.equal(ui.counts().stopped,1);assert.equal(ui.window.__claudeMobileBridge.owns(config.cwd),true);
+ assert.equal(ui.window.__claudeMobileBridge.owns('/gateway-a'),false);
+ assert.ok(!JSON.stringify(ui.window.__claudeMobileBridge.status()).includes(config.token));
+ ui.window.__claudeMobileBridge.stop();await running;
+});
+test('pending request or response write blocks even same-owner replacement',async()=>{
+ for(const field of ['busy','writing']){
+ const ui=fixture({transport:'file',stopped:false,sessionId:'legacy-session',[field]:true},config.cwd);
+ await assert.rejects(ui.run(),/请求进行中/);
+ assert.equal(ui.counts().stopped,0);
+ }
+});
+test('explicitly stopped foreign connector can be replaced',async()=>{
+ const ui=fixture({transport:'file',stopped:true,busy:false,sessionId:'legacy-session'});
+ const running=ui.run();
+ for(let i=0;i<50&&!ui.window.__claudeMobileBridge?.owns;i++)await new Promise(resolve=>setTimeout(resolve,10));
+ assert.equal(ui.counts().stopped,1);
+ ui.window.__claudeMobileBridge.stop();await running;
+});
diff --git a/tests/client-lifecycle-ui.test.cjs b/tests/client-lifecycle-ui.test.cjs
index a7f7e3b6..04f6302d 100644
--- a/tests/client-lifecycle-ui.test.cjs
+++ b/tests/client-lifecycle-ui.test.cjs
@@ -109,6 +109,11 @@ test('connection labels distinguish startup, required action, failure and actual
assert.equal(connection({...base,setupStatus:'recovery-required',connectionState:'connecting',backgroundRunning:true,mainRunning:false}).label,'后台运行,桌面未打开');
assert.equal(connection({...base,pendingEnable:true,reason:'请手动连接 Claude'}).reason,'正在等待客户端连接,完成后会自动显示聊天。');
for(const setupStatus of ['needs-initialization','needs-retry','needs-developer-mode','needs-trust','needs-permission','failed'])assert.equal(connection({...base,id:'claude',setupStatus}).canInitialize,true);
+ const prepared={...base,id:'claude',setupStatus:'needs-initialization',reconnectSupported:true,canReconnect:true};
+ assert.equal(connection(prepared).canReconnect,true);assert.equal(connection(prepared).canInitialize,true);
+ assert.equal(connection({...prepared,connectionState:'connecting'}).canReconnect,false);
+ assert.equal(connection({...prepared,setupStatus:'owner-conflict'}).canReconnect,true);
+ assert.equal(connection({...prepared,canReconnect:false}).canReconnect,false);
for(const client of [{...base,id:'deepseek',setupStatus:'needs-initialization'},{...base,id:'claude',connected:true,setupStatus:'needs-initialization'},{...base,id:'claude',connectionState:'connecting',setupStatus:'needs-initialization'},{...base,id:'claude',setupStatus:'unsupported'}])assert.equal(connection(client).canInitialize,false);
assert.equal(connection({...base,setupStatus:'restart-required',retryable:true}).retryable,false);
});
diff --git a/tests/desktop-sessions-ui.test.cjs b/tests/desktop-sessions-ui.test.cjs
index 33803f7f..3b6f2664 100644
--- a/tests/desktop-sessions-ui.test.cjs
+++ b/tests/desktop-sessions-ui.test.cjs
@@ -46,6 +46,19 @@ test('Claude session list exposes the confirmed initialization flow without runn
ui.view.setClientStates([{...client,connectionState:'connecting'}]);assert.equal(ui.view.connectionInitialize.hidden,true);await ui.view.refresh();assert.equal(ui.calls.length,0);
});
+test('prepared Claude lists offer separate reconnect and reinitialization actions',async()=>{
+ const ui=fixture(),client={id:'claude',name:'Claude',enabled:true,installed:true,connected:false,setupStatus:'needs-initialization',reconnectSupported:true,canReconnect:true},reconnects=[],initializations=[];
+ ui.view.setClientStates([client],undefined,id=>initializations.push(id),undefined,id=>reconnects.push(id));await ui.view.choose('claude');
+ assert.equal(ui.view.connectionReconnect.hidden,false);assert.equal(ui.view.connectionInitialize.hidden,false);
+ assert.equal(ui.view.connectionInitialize.textContent,'重新初始化连接');
+ await ui.view.connectionReconnect.click();assert.deepEqual(reconnects,['claude']);assert.deepEqual(initializations,[]);
+ await ui.view.connectionInitialize.click();assert.deepEqual(initializations,['claude']);
+ ui.view.setClientStates([{...client,operationUncertain:true}],undefined,()=>{throw Error('unexpected initialize');},undefined,()=>{throw Error('unexpected reconnect');});
+ assert.equal(ui.view.connectionReconnect.hidden,true);assert.equal(ui.view.connectionInitialize.hidden,true);
+ ui.view.setClientStates([{...client,setupStatus:'owner-conflict',reason:'Claude 桥接连接被其他网关占用,请先停止旧网关或重新加载 Claude'}]);
+ assert.match(ui.view.listStatus.textContent,/其他网关占用/);assert.equal(ui.view.connectionReconnect.hidden,false);
+});
+
test('unknown operations offer refresh only and a failed quit does not block connected chats',async()=>{
const ui=fixture(),client={id:'claude',enabled:true,installed:true,connected:false,initializationMode:'background',setupStatus:'needs-unlock',connectionState:'needs-initialization',operationFailure:{action:'initialize',message:'timeout',uncertain:true}},refreshes=[];
ui.view.setClientStates([client],()=>{throw Error('no replay');},()=>{throw Error('no initialization');},async()=>refreshes.push(true));await ui.view.choose('claude');assert.match(ui.view.listStatus.textContent,/电脑已锁定/);assert.match(ui.view.connectionNotice.textContent,/尚未确认/);assert.equal(ui.view.connectionInitialize.hidden,true);assert.equal(ui.view.connectionRetry.hidden,true);assert.equal(ui.view.connectionRefresh.hidden,false);await ui.view.connectionRefresh.click();assert.equal(refreshes.length,1);assert.equal(ui.calls.length,0);
diff --git a/tests/test_claude_controls.py b/tests/test_claude_controls.py
index 3e76811f..ca453631 100644
--- a/tests/test_claude_controls.py
+++ b/tests/test_claude_controls.py
@@ -76,6 +76,11 @@ def test_reconnect_rejects_disabled_stopped_ambiguous_and_unprepared_clients(sel
with self.assertRaisesRegex(ValueError, '电脑端'): self.manager.reconnect_claude()
self.claude.connect.assert_not_called(); self.fixture.launch.assert_not_called()
+ def test_reconnect_can_retry_after_a_reported_other_gateway_conflict(self):
+ self.claude.status.return_value = {'connected': False, 'setupState': 'owner-conflict'}
+ self.manager.reconnect_claude()
+ self.claude.reconnect.assert_called_once_with(); self.claude.connect.assert_not_called()
+
def test_connected_reconnect_is_idempotent_even_when_tasks_are_busy(self):
self.claude.status.return_value = {'connected': True}
self.claude.call.side_effect = lambda *_: {'complete': True, 'sessions': [
diff --git a/tests/test_claude_session.py b/tests/test_claude_session.py
index 16c11cf2..fd3d8891 100644
--- a/tests/test_claude_session.py
+++ b/tests/test_claude_session.py
@@ -32,6 +32,15 @@ def setUp(self):
def locked(self):
self.snapshot.return_value = {'state': 'locked', 'interactive': False, 'reason': 'Windows 已锁定'}
+ def test_windows_owner_conflict_receipt_has_actionable_state(self):
+ process = Mock(returncode=0)
+ process.communicate.return_value = ('OWNER_CONFLICT', '')
+ with patch.object(claude_setup, 'helper_path', return_value=Path(__file__)), \
+ patch.object(claude_setup.subprocess, 'Popen', return_value=process):
+ result = claude_setup.native_action('inspect-error', pid=42)
+ self.assertEqual(result['setupState'], 'owner-conflict')
+ self.assertIn('旧网关', result['reason'])
+
def test_locked_check_and_connect_do_not_start_helper(self):
self.locked()
with patch('bridge.platforms.windows.claude.subprocess') as processes:
diff --git a/tests/test_claude_setup.py b/tests/test_claude_setup.py
index 4f4f4899..c0306afd 100644
--- a/tests/test_claude_setup.py
+++ b/tests/test_claude_setup.py
@@ -498,7 +498,7 @@ def developer(path):
self.adapter._connect_native(False, self.cancel_path)
profiles.assert_not_called()
- def run_injection(self, heartbeat, trust=False):
+ def run_injection(self, heartbeat, trust=False, conflict=False):
self.adapter.desktop = self.idle_desktop(connected=False)
actions = []
def native(action, **kwargs):
@@ -510,12 +510,14 @@ def native(action, **kwargs):
if action == 'close-devtools':
return {'setupState': 'connected'}
if action == 'inspect-error':
+ if conflict:
+ return {'setupState': 'owner-conflict', 'reason': 'Claude 桥接连接被其他网关占用,请先停止旧网关或重新加载 Claude'}
return {'setupState': 'needs-trust' if trust else 'failed', 'reason': 'trust required'}
return {'setupState': 'ready'}
with patch('bridge.clients.claude.adapter.native_action', side_effect=native), \
patch('bridge.clients.claude.adapter.running_app', return_value=42), \
patch('bridge.clients.claude.adapter.developer_mode_enabled', return_value=True), \
- patch('bridge.clients.claude.adapter.time.monotonic', side_effect=[0, 0 if heartbeat else 19]), \
+ patch('bridge.clients.claude.adapter.time.monotonic', side_effect=[0, 0, 3 if conflict else 0 if heartbeat else 19, 3]), \
patch.object(self.adapter, 'prepare', return_value={'consolePath': str(self.root/'script')}), \
patch.object(self.adapter.setup_cancel, 'wait', return_value=False), \
patch.object(self.adapter, '_setup_state', wraps=self.adapter._setup_state) as state:
@@ -534,6 +536,12 @@ def test_trust_failure_reports_native_user_action(self):
self.assertNotIn('connected', states)
self.assertEqual(self.adapter.status()['setupState'], 'needs-trust')
+ def test_owner_conflict_reports_without_waiting_for_full_connection_timeout(self):
+ actions, states = self.run_injection(False, conflict=True)
+ self.assertEqual(actions, ['check', 'connect', 'inspect-error'])
+ self.assertIn('owner-conflict', states)
+ self.assertEqual(self.adapter.status()['setupState'], 'owner-conflict')
+
def test_only_verified_heartbeat_allows_connected_and_console_cleanup(self):
actions, states = self.run_injection(True)
self.assertEqual(actions, ['check', 'connect', 'close-devtools'])
diff --git a/web/features/clients/client-lifecycle.js b/web/features/clients/client-lifecycle.js
index 7bd6a5bf..bba91731 100644
--- a/web/features/clients/client-lifecycle.js
+++ b/web/features/clients/client-lifecycle.js
@@ -14,13 +14,14 @@ const ClientLifecycle=(()=>{
(stage==='starting'?'正在启动应用…':'正在连接,请稍候…');pending=true;}
else{
label=({'needs-unlock':'电脑已锁定','needs-desktop':'电脑桌面暂不可用'})[setup]||({'error':'连接失败','timeout':'连接超时','needs-initialization':'需要初始化连接'})[stage]
- ||({'unsupported':'暂不支持自动接入','needs-initialization':'需要初始化连接','needs-first-launch':'需要完成首次设置','restart-required':'等待重启','needs-permission':'等待授权','needs-developer-mode':'等待开发者模式确认','needs-trust':'等待目录授权','cancelled':'已取消连接','failed':'连接失败','needs-retry':'连接失败'})[setup];
+ ||({'unsupported':'暂不支持自动接入','needs-initialization':'需要初始化连接','needs-first-launch':'需要完成首次设置','restart-required':'等待重启','needs-permission':'等待授权','needs-developer-mode':'等待开发者模式确认','needs-trust':'等待目录授权','owner-conflict':'连接被其他网关占用','cancelled':'已取消连接','failed':'连接失败','needs-retry':'连接失败'})[setup];
action=!!label;
label=label||(client.installed===false?'未安装':client.running===true?'应用运行中,尚未连接':client.running===false?'应用未运行':'尚未连接');
}
const canInitialize=client.id==='claude'&&client.enabled&&client.installed!==false&&!client.connected&&!pending&&(['needs-initialization','needs-retry','needs-developer-mode','needs-trust','needs-permission','needs-unlock','needs-desktop','cancelled','failed'].includes(setup)||stage==='needs-initialization');
- const retryable=client.retryable===true&&client.enabled&&!client.connected&&!pending&&!canInitialize&&!['recovery-required','restart-required'].includes(setup);
- return {label,reason,waiting:pending,action:action||setup==='recovery-required',retryable:!!retryable,canInitialize:!!canInitialize,error:!client.connected&&['error','timeout'].includes(stage)||!client.connected&&setup==='failed'};
+ const canReconnect=client.id==='claude'&&client.canReconnect===true&&client.reconnectSupported===true&&!client.connected&&!pending&&!['recovery-required','restart-required'].includes(setup);
+ const retryable=client.retryable===true&&client.enabled&&!client.connected&&!pending&&!canInitialize&&!canReconnect&&!['recovery-required','restart-required'].includes(setup);
+ return {label,reason,waiting:pending,action:action||setup==='recovery-required',retryable:!!retryable,canReconnect:!!canReconnect,canInitialize:!!canInitialize,error:!client.connected&&['error','timeout'].includes(stage)||!client.connected&&setup==='failed'};
}
function request(operation,{timeout=45000,uncertain=true,message='操作结果尚未确认,请刷新状态后再重试。'}={}){
const controller=typeof AbortController!=='undefined'?new AbortController():null;let timer;
diff --git a/web/features/clients/client-navigation.js b/web/features/clients/client-navigation.js
index cfc94ff7..2f3c92ee 100644
--- a/web/features/clients/client-navigation.js
+++ b/web/features/clients/client-navigation.js
@@ -165,7 +165,7 @@ class ClientNavigation {
}
connection(client){return ClientLifecycle.connection({...client,pendingEnable:this.gatewayRunning!==false&&this.pending.has(client.id)&&client.enabled});}
hasUncertain(){return [...(this.operationFailures?.values()||[])].some(value=>value.uncertain);}
- syncViewClients(){this.view.setClientStates?.(this.clients.map(client=>({...client,operationFailure:this.operationFailures?.get(client.id),operationUncertain:this.hasUncertain(),pendingEnable:this.gatewayRunning!==false&&this.pending.has(client.id)&&client.enabled})),id=>this.toggle(id,true),id=>this.initialize(id),()=>this.refresh());}
+ syncViewClients(){this.view.setClientStates?.(this.clients.map(client=>({...client,operationFailure:this.operationFailures?.get(client.id),operationUncertain:this.hasUncertain(),pendingEnable:this.gatewayRunning!==false&&this.pending.has(client.id)&&client.enabled})),id=>this.toggle(id,true),id=>this.initialize(id),()=>this.refresh(),id=>this.reconnect(id));}
retryOperation(id){const failure=this.operationFailures?.get(id);if(!failure||this.hasUncertain())return;return failure.action==='initialize'?this.initialize(id):this.toggle(id,failure.action==='enable');}
async choose(id){
this.cancelListSwipe();
@@ -223,7 +223,7 @@ class ClientNavigation {
finally{this.pending.delete(id);this.pendingQuitDesktop=false;this.pendingForceDesktop=false;++this.revision;this.paint();if(failed)await this.refresh();}
}
async reconnect(id){
- const client=this.clients.find(row=>row.id===id);if(this.pending.size||id!=='claude'||!client?.reconnectSupported||!client.canReconnect||client.setupStatus==='connecting')return;
+ const client=this.clients.find(row=>row.id===id);if(this.pending.size||this.hasUncertain()||id!=='claude'||!this.connection(client||{}).canReconnect)return;
this.pending.add(id);this.reconnecting=id;++this.revision;this.paint();
try{const data=await this.request('/api/clients/claude/reconnect',{});this.applyClients(data);const connected=data.clients.find(row=>row.id===id)?.connected;this.notify(connected?'Claude 已连接':'已发起重新连接');if(connected&&this.provider===id)await this.view.refresh();}
finally{this.pending.delete(id);this.reconnecting=null;++this.revision;this.paint();}
@@ -265,7 +265,8 @@ class ClientNavigation {
if(client.enabled){const open=document.createElement('button');open.type='button';open.textContent=BridgeI18n.t('查看聊天');open.onclick=()=>{manager.dialog.close();this.choose(client.id).catch(error=>this.notify(error.message));};control.append(open);}
if(failure){const retry=document.createElement('button');retry.type='button';retry.textContent=BridgeI18n.t(failure.uncertain?'刷新状态':ClientLifecycle.retryLabel(failure.action));retry.disabled=this.pending.size>0||!!this.choosing||!!this.loading||(!failure.uncertain&&this.hasUncertain());retry.onclick=()=>Promise.resolve(failure.uncertain?this.refresh():this.retryOperation(client.id)).catch(error=>this.notify(error.message));control.append(retry);}
if(!failure&&state.retryable&&this.gatewayRunning!==false){const retry=document.createElement('button');retry.type='button';retry.textContent=BridgeI18n.t('重试连接');retry.disabled=this.pending.size>0||!!this.choosing||this.hasUncertain();retry.onclick=()=>this.toggle(client.id,true).catch(error=>this.notify(error.message));control.append(retry);}
- if(!failure&&state.canInitialize&&this.gatewayRunning!==false){const initialize=document.createElement('button');initialize.type='button';initialize.textContent=BridgeI18n.t('初始化连接');initialize.disabled=this.pending.size>0||!!this.choosing||this.hasUncertain();initialize.onclick=()=>this.initialize(client.id).catch(error=>this.notify(error.message));control.append(initialize);}
+ if(!failure&&state.canReconnect&&this.gatewayRunning!==false){const reconnect=document.createElement('button');reconnect.type='button';reconnect.textContent=BridgeI18n.t('尝试重连');reconnect.disabled=this.pending.size>0||!!this.choosing||this.hasUncertain();reconnect.onclick=()=>this.reconnect(client.id).catch(error=>this.notify(error.message));control.append(reconnect);}
+ if(!failure&&state.canInitialize&&this.gatewayRunning!==false){const initialize=document.createElement('button');initialize.type='button';initialize.textContent=BridgeI18n.t(state.canReconnect?'重新初始化连接':'初始化连接');initialize.disabled=this.pending.size>0||!!this.choosing||this.hasUncertain();initialize.onclick=()=>this.initialize(client.id).catch(error=>this.notify(error.message));control.append(initialize);}
}
note.textContent=BridgeI18n.t(this.gatewayRunning===false?'网关未启动,开关仅保存下次启动时的选择,不会打开或退出应用。':'安装和登录请在电脑端完成。关闭接入可保留或退出电脑 App。macOS Claude 默认后台启动并尝试恢复已有连接;“初始化连接”经确认后使用电脑前台。Windows 优先尝试后台初始化。')+' '+BridgeI18n.t(ClientLifecycle.sessionNotice(this.windowsSession));
}
diff --git a/web/features/clients/desktop-sessions.js b/web/features/clients/desktop-sessions.js
index 786022c2..53323890 100644
--- a/web/features/clients/desktop-sessions.js
+++ b/web/features/clients/desktop-sessions.js
@@ -12,7 +12,8 @@ class DesktopSessionsView {
this.surface=this.node('select','ds-surface');this.label(this.surface,'会话类型');for(const [id,title] of [['all','全部会话'],['code','Code'],['cowork','Cowork']])this.surface.add(new Option(BridgeI18n.t(title),id));this.surface.onchange=()=>this.renderList();filters.prepend(this.surface);
this.listStatus=this.node('p','muted ds-list-status');this.listStatus.setAttribute('role','status');this.connectionRetry=this.button('重试连接',async()=>{const provider=this.provider;if(this.retryingClient||!this.retryClient)return;this.retryingClient=provider;this.renderConnectionStatus();try{await this.retryClient(provider);}finally{this.retryingClient=null;this.renderConnectionStatus();}});this.connectionRetry.hidden=true;
this.connectionInitialize=this.button('初始化连接',async()=>{const provider=this.provider;if(this.initializingClient||!this.initializeClient)return;this.initializingClient=provider;this.renderConnectionStatus();try{await this.initializeClient(provider);}finally{this.initializingClient=null;this.renderConnectionStatus();}});this.connectionInitialize.hidden=true;
- this.connectionNotice=this.node('p','muted');this.connectionNotice.hidden=true;this.connectionRefresh=this.button('刷新状态',async()=>{if(this.refreshingClient||!this.refreshClients)return;this.refreshingClient=true;this.renderConnectionStatus();try{await this.refreshClients();}finally{this.refreshingClient=false;this.renderConnectionStatus();}});this.connectionRefresh.hidden=true;this.rowsRoot=this.node('div','sessions');const toolbar=this.node('div','chat-list-toolbar');toolbar.append(this.search,filters);this.list.append(toolbar,this.listStatus,this.connectionNotice,this.connectionRetry,this.connectionInitialize,this.connectionRefresh,this.rowsRoot);
+ this.connectionReconnect=this.button('尝试重连',async()=>{const provider=this.provider;if(this.reconnectingClient||!this.reconnectClient)return;this.reconnectingClient=provider;this.renderConnectionStatus();try{await this.reconnectClient(provider);}finally{this.reconnectingClient=null;this.renderConnectionStatus();}});this.connectionReconnect.hidden=true;
+ this.connectionNotice=this.node('p','muted');this.connectionNotice.hidden=true;this.connectionRefresh=this.button('刷新状态',async()=>{if(this.refreshingClient||!this.refreshClients)return;this.refreshingClient=true;this.renderConnectionStatus();try{await this.refreshClients();}finally{this.refreshingClient=false;this.renderConnectionStatus();}});this.connectionRefresh.hidden=true;this.rowsRoot=this.node('div','sessions');const toolbar=this.node('div','chat-list-toolbar');toolbar.append(this.search,filters);this.list.append(toolbar,this.listStatus,this.connectionNotice,this.connectionRetry,this.connectionReconnect,this.connectionInitialize,this.connectionRefresh,this.rowsRoot);
this.welcome=this.node('div','welcome ds-welcome');const logo=this.node('img','mark large');logo.src='/icon.png';logo.alt='';logo.width=72;logo.height=72;this.welcome.append(logo,this.translated('h1','从一条聊天继续'),this.translated('p','选择电脑上的聊天,查看进度或发送消息。','muted'));
this.conversation=this.node('section','ds-conversation chat');this.conversation.hidden=true;this.main.append(this.welcome,this.conversation);
this.back=this.iconButton('返回聊天列表','m15 5-7 7 7 7',()=>{if(!this.workbench.back())this.backToList();});this.back.classList.add('ds-back');
@@ -71,9 +72,9 @@ class DesktopSessionsView {
this.listPrefetchAt.set(client.id,now);this.sharedRead(client.id,'list').promise.catch(()=>{});
}
}
- setClientStates(clients,retry,initialize,refresh){
+ setClientStates(clients,retry,initialize,refresh,reconnect){
for(const client of clients){const old=this.clientStates?.find(row=>row.id===client.id);if(old&&(old.enabled!==false&&client.enabled===false||old.connected===true&&client.connected===false))this.invalidateReads(client.id);}
- const before=this.clientStates?.find(client=>client.id===this.provider);this.clientStates=clients;this.retryClient=retry;this.initializeClient=initialize;this.refreshClients=refresh;
+ const before=this.clientStates?.find(client=>client.id===this.provider);this.clientStates=clients;this.retryClient=retry;this.initializeClient=initialize;this.refreshClients=refresh;this.reconnectClient=reconnect;
if(this.state&&!this.canMutate())this.staleDetail();else this.updateComposer();
const current=clients.find(client=>client.id===this.provider),wasWaiting=before&&ClientLifecycle.connection(before).waiting;
this.prefetchLists();
@@ -81,10 +82,12 @@ class DesktopSessionsView {
}
renderConnectionStatus(){
const client=this.clientStates?.find(client=>client.id===this.provider),state=client&&ClientLifecycle.connection(client);
- const failure=client?.operationFailure,uncertain=!!(client?.operationUncertain||failure?.uncertain),blocked=!!state&&!client.connected&&(state.waiting||state.action||state.retryable||!!failure);
+ const failure=client?.operationFailure,uncertain=!!(client?.operationUncertain||failure?.uncertain),blocked=!!state&&!client.connected&&(state.waiting||state.action||state.retryable||state.canReconnect||!!failure);
this.listStatus.setAttribute('aria-busy',String(!!state?.waiting));this.listStatus.classList.toggle('client-connection-waiting',!!state?.waiting);
this.connectionRetry.hidden=!blocked||!state.retryable||uncertain;this.connectionRetry.disabled=!!this.retryingClient;
this.connectionInitialize.hidden=!blocked||!state.canInitialize||uncertain;this.connectionInitialize.disabled=!!this.initializingClient;
+ this.connectionReconnect.hidden=!blocked||!state.canReconnect||uncertain;this.connectionReconnect.disabled=!!this.reconnectingClient;
+ this.connectionInitialize.textContent=BridgeI18n.t(state?.canReconnect?'重新初始化连接':'初始化连接');
this.connectionNotice.hidden=!failure;this.connectionNotice.textContent=failure?BridgeI18n.t(ClientLifecycle.failureMessage(failure)):'';this.connectionRefresh.hidden=!uncertain;this.connectionRefresh.disabled=!!this.refreshingClient;
if(blocked)this.listStatus.textContent=BridgeI18n.t(state.label)+(state.reason&&state.reason!==state.label?' · '+BridgeI18n.t(state.reason):'');
return blocked;
diff --git a/web/shared/i18n.js b/web/shared/i18n.js
index 7fd8e168..c7952083 100644
--- a/web/shared/i18n.js
+++ b/web/shared/i18n.js
@@ -192,6 +192,10 @@ const en={
"选择需要接入的应用;Claude 完全退出或重新加载后需初始化连接,初始化会使用电脑前台和键盘焦点。":"Select apps to connect. After a complete quit or reload, Claude needs connection initialization, using the computer's foreground window and keyboard focus.",
"需要初始化连接":"Connection initialization required",
"初始化连接":"Initialize connection",
+"尝试重连":"Try reconnecting",
+"重新初始化连接":"Reinitialize connection",
+"连接被其他网关占用":"Connection used by another gateway",
+"Claude 桥接连接被其他网关占用,请先停止旧网关或重新加载 Claude":"Another gateway is using Claude's bridge. Stop the old gateway or reload Claude first.",
"开始初始化":"Start initialization",
"会打开电脑上的 Claude 和开发者工具,并使用键盘焦点;请暂停电脑端操作,完成后恢复后台连接。":"This opens Claude and its developer tools on your computer and uses keyboard focus. Pause your work on the computer; the connection will return to the background when finished.",
"通过 Claude 原生菜单退出,菜单可能短暂出现;如有任务或保存确认,请在电脑端处理。完全退出后需重新初始化连接,初始化会使用电脑前台和键盘焦点。":"Quit through Claude's native menu. The menu may appear briefly; handle any task or save confirmation on your computer. After quitting completely, the connection requires initialization again, using the computer's foreground window and keyboard focus.",
From 99fa81f11a918f41a81e64a30a51ae853be0d7bc Mon Sep 17 00:00:00 2001
From: try2love <1929706911@qq.com>
Date: Tue, 13 Oct 2026 02:18:48 +0800
Subject: [PATCH 09/31] Isolate gateway login cookies by persistent namespace
---
.../2026-10-13-gateway-cookie-namespace.md | 21 ++++++++++
bridge/api/httpd.py | 12 ++++--
bridge/features/auth/auth.py | 10 ++++-
.../codexbridge/ComputerNotifications.java | 2 +-
.../codexbridge/ConnectionDiscovery.java | 2 +-
.../try2love/codexbridge/EventClient.java | 21 ++++++----
.../try2love/codexbridge/MainActivity.java | 28 ++++++-------
.../try2love/codexbridge/MonitorService.java | 5 ++-
mobile/ios/BridgePreview/App.swift | 41 +++++++++++++------
scripts/smoke-pairing.cjs | 5 ++-
scripts/test-ios-events.py | 1 +
scripts/test-mobile-session.py | 2 +-
tests/test_auth.py | 3 ++
tests/test_login_security.py | 14 +++++++
14 files changed, 121 insertions(+), 46 deletions(-)
create mode 100644 .agents/notes/implemented/bug-fix/2026-10-13-gateway-cookie-namespace.md
diff --git a/.agents/notes/implemented/bug-fix/2026-10-13-gateway-cookie-namespace.md b/.agents/notes/implemented/bug-fix/2026-10-13-gateway-cookie-namespace.md
new file mode 100644
index 00000000..90031c22
--- /dev/null
+++ b/.agents/notes/implemented/bug-fix/2026-10-13-gateway-cookie-namespace.md
@@ -0,0 +1,21 @@
+# Agent Note: 网关会话 Cookie 按实例隔离
+
+Status: implemented
+
+## Problem
+
+两个本地网关可以使用同一 IP 的不同端口,但浏览器和 WebView 的 Cookie 不按端口隔离。固定的 `codex_mobile_session` 名称会让后登录的网关覆盖前一个网关的登录状态。
+
+## Decision
+
+网关在自己的 `auth-sessions.json` 中持久保存随机 `cookieName`。它与每次启动变化的 `instanceId` 分离,也不随密码策略修改而变化。新登录只签发该名称;请求优先使用该名称,即使其值无效,也不回退到旧名称。只有旧 Cookie 的令牌仍在本机网关会话表中有效时才接受并通过下一次成功响应迁移。注销仅清除本机名称;另一个网关可能占有旧名称。
+
+`/api/auth` 返回 `cookieName` 供原生客户端按保存的连接地址选择 Cookie。Android 和 iOS 在认证成功后记录名称;旧网关和路径隔离的中继仍使用旧名称。通知、下载、连接发现和移除流程沿用该地址的名称,不把进程临时 ID 当作持久身份。
+
+## Alternatives considered
+
+直接把随机 `instanceId` 放入 Cookie 名称能立即区分并发进程,但网关重启就要求手机重新配对,违反持久登录。按端口设置 Cookie 不可行,因为 Cookie 协议不隔离端口。
+
+## Consequences
+
+同一主机的不同网关可以并存并保留各自登录;旧令牌在有效期内有条件迁移。新增公开的 Cookie 名称不是认证秘密。原生客户端必须维护按连接地址的名称映射,且真实设备的 Cookie 存储行为仍需平台构建验证。
diff --git a/bridge/api/httpd.py b/bridge/api/httpd.py
index f3c0599e..20435857 100644
--- a/bridge/api/httpd.py
+++ b/bridge/api/httpd.py
@@ -183,7 +183,7 @@ def cookie(self, token, clear=False):
secure = "; Secure" if self.headers.get("Host") in self.server.secure_hosts else ""
age = 0 if clear else self.server.auth.cookie_age(token)
lifetime = f"; Max-Age={age}" if age is not None else ""
- return f"{Auth.COOKIE}={token}; Path=/; HttpOnly; SameSite=Strict{lifetime}{secure}"
+ return f"{self.server.auth.cookie_name}={token}; Path=/; HttpOnly; SameSite=Strict{lifetime}{secure}"
def client(self):
return self.server.auth.client(self.client_address[0], self.headers, self.headers.get('Host') in self.server.secure_hosts)
@@ -195,7 +195,13 @@ def token(self):
cookie = SimpleCookie()
try:
cookie.load(self.headers.get("Cookie", ""))
- return cookie[Auth.COOKIE].value if Auth.COOKIE in cookie else ""
+ name = self.server.auth.cookie_name
+ if name in cookie:
+ return cookie[name].value
+ # Legacy cookies are accepted only while their token belongs to
+ # this gateway. A successful response migrates to the new name.
+ legacy = cookie[Auth.COOKIE].value if Auth.COOKIE in cookie else ""
+ return legacy if legacy and self.server.auth.get(legacy) else ""
except Exception:
return ""
@@ -294,7 +300,7 @@ def handle_method(self, write):
discovery = getattr(self.server, 'connection_discovery', None)
return self.output(200, {"uiLanguage": self.server.ui_language(), "authenticated": bool(session), "trustedDevice": bool(session and session.get("trustedDevice")), "csrf": session["csrf"] if session else None,
"loginStatus": self.server.auth.login_status(self.client()["ip"]),
- "instanceId": self.server.instance_id,
+ "instanceId": self.server.instance_id, "cookieName": self.server.auth.cookie_name,
**({"computer": {"name": socket.gethostname(), "platform": platform.system()}} if session else {}),
**({'connection': discovery.descriptor(self.token())} if session and discovery else {}),
"notifications": self.server.notifications is not None,
diff --git a/bridge/features/auth/auth.py b/bridge/features/auth/auth.py
index 3fb8229f..6bbb65c3 100644
--- a/bridge/features/auth/auth.py
+++ b/bridge/features/auth/auth.py
@@ -72,6 +72,9 @@ def __init__(self, config, data_dir=None):
self.identity = hashlib.sha256(json.dumps(identity, sort_keys=True).encode()).hexdigest()
legacy_identity = hashlib.sha256(json.dumps({**identity, 'sessionHours': self.hours}, sort_keys=True).encode()).hexdigest()
self.path = Path(data_dir) / 'auth-sessions.json' if data_dir is not None else None
+ # A cookie namespace belongs to the gateway data directory, not the
+ # process or its (possibly changed) login policy.
+ self.cookie_name = self.COOKIE + '_' + secrets.token_hex(12)
self.sessions = {}
self.policy = access_policy({})
self.failures = {}
@@ -79,6 +82,7 @@ def __init__(self, config, data_dir=None):
self.lock = threading.RLock()
if self.path and self.path.exists():
saved = json.loads(self.path.read_text(encoding='utf-8'))
+ self.cookie_name = saved.get('cookieName') or self.cookie_name
self.policy = access_policy(saved['policy'])
self.failures = saved.get('failures', {})
self.auto_blocks = saved.get('autoBlocks', {})
@@ -88,6 +92,10 @@ def __init__(self, config, data_dir=None):
self.persist()
else:
self.persist()
+ if not saved.get('cookieName'):
+ self.persist()
+ elif self.path:
+ self.persist()
@staticmethod
def valid(session):
@@ -103,7 +111,7 @@ def persist(self):
temporary = self.path.with_name(self.path.name + '.' + secrets.token_hex(8) + '.tmp')
try:
with os.fdopen(os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600), 'w', encoding='utf-8') as stream:
- json.dump({'identity': self.identity, 'policy': self.policy, 'sessions': self.sessions, 'failures': self.failures, 'autoBlocks': self.auto_blocks}, stream)
+ json.dump({'identity': self.identity, 'cookieName': self.cookie_name, 'policy': self.policy, 'sessions': self.sessions, 'failures': self.failures, 'autoBlocks': self.auto_blocks}, stream)
temporary.replace(self.path)
finally:
temporary.unlink(missing_ok=True)
diff --git a/mobile/android/src/io/github/try2love/codexbridge/ComputerNotifications.java b/mobile/android/src/io/github/try2love/codexbridge/ComputerNotifications.java
index 2873ea82..9a1bc012 100644
--- a/mobile/android/src/io/github/try2love/codexbridge/ComputerNotifications.java
+++ b/mobile/android/src/io/github/try2love/codexbridge/ComputerNotifications.java
@@ -17,7 +17,7 @@ private static final class Snapshot {final String binding;final JSONArray client
private static void changed(){Runnable callback=onChange;if(callback!=null)callback.run();}
static synchronized JSONArray clients(Context context,String origin){
Snapshot snapshot=snapshots.get(origin);SharedPreferences prefs=context.getSharedPreferences("bridge",0);
- if(snapshot==null||!prefs.getStringSet("origins",Collections.emptySet()).contains(origin)||!snapshot.binding.equals(EventClient.sessionCookie(CookieManager.getInstance().getCookie(origin+"/"))))return new JSONArray();
+ if(snapshot==null||!prefs.getStringSet("origins",Collections.emptySet()).contains(origin)||!snapshot.binding.equals(EventClient.sessionCookie(CookieManager.getInstance().getCookie(origin+"/"),origin)))return new JSONArray();
return snapshot.clients==null?new JSONArray():snapshot.clients;
}
static synchronized boolean fresh(String origin){Snapshot snapshot=snapshots.get(origin);return snapshot!=null&&snapshot.fresh;}
diff --git a/mobile/android/src/io/github/try2love/codexbridge/ConnectionDiscovery.java b/mobile/android/src/io/github/try2love/codexbridge/ConnectionDiscovery.java
index e13655fa..27aeef58 100644
--- a/mobile/android/src/io/github/try2love/codexbridge/ConnectionDiscovery.java
+++ b/mobile/android/src/io/github/try2love/codexbridge/ConnectionDiscovery.java
@@ -18,7 +18,7 @@ final class ConnectionDiscovery {
void cancel(){cancelled=true;HttpURLConnection c=active;if(c!=null)c.disconnect();}
static String hex(byte[] bytes){StringBuilder s=new StringBuilder();for(byte b:bytes)s.append(String.format("%02x",b&255));return s.toString();}
static String hash(String value)throws Exception{return hex(MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8)));}
- static String secret(String cookie){String value=EventClient.sessionCookie(cookie);return value.isEmpty()?"":value.substring(value.indexOf('=')+1);}
+ static String secret(String cookie){return cookie==null||!cookie.contains("=")?"":cookie.substring(cookie.indexOf('=')+1);}
static JSONObject descriptor(JSONObject auth,String cookie)throws Exception {
JSONObject d=auth.optJSONObject("connection");String secret=secret(cookie);
if(!auth.optBoolean("authenticated")||d==null||secret.isEmpty())return null;
diff --git a/mobile/android/src/io/github/try2love/codexbridge/EventClient.java b/mobile/android/src/io/github/try2love/codexbridge/EventClient.java
index eaeac1a4..72940a5b 100644
--- a/mobile/android/src/io/github/try2love/codexbridge/EventClient.java
+++ b/mobile/android/src/io/github/try2love/codexbridge/EventClient.java
@@ -7,6 +7,9 @@
final class EventClient {
static final String USER_AGENT="BridgeMobile/0.1-Android";
+ private static final java.util.Map names=new java.util.concurrent.ConcurrentHashMap<>();
+ static void setName(String origin,String name){if(name!=null&&name.matches("codex_mobile_session_[a-f0-9]{24}|codex_mobile_session"))names.put(origin,name);}
+ static String cookieName(String origin){return names.getOrDefault(origin,"codex_mobile_session");}
static final class Read {
private volatile boolean cancelled;
private HttpURLConnection connection;
@@ -16,16 +19,18 @@ static final class Read {
void cancel(){HttpURLConnection current;synchronized(this){cancelled=true;current=connection;connection=null;}if(current!=null)current.disconnect();}
}
static String sessionCookie(String header){if(header!=null)for(String part:header.split(";"))if(part.trim().startsWith("codex_mobile_session="))return part.trim();return "";}
- static synchronized void forget(String origin){CookieManager store=CookieManager.getInstance();store.setCookie(origin+"/","codex_mobile_session=; Path="+GatewayURL.cookiePath(origin)+"; Max-Age=0");store.flush();}
+ static String sessionCookie(String header,String origin){String wanted=cookieName(origin)+"=";if(header!=null)for(String part:header.split(";"))if(part.trim().startsWith(wanted))return part.trim();return "";}
+ static synchronized void forget(String origin){CookieManager store=CookieManager.getInstance();store.setCookie(origin+"/",cookieName(origin)+"=; Path="+GatewayURL.cookiePath(origin)+"; Max-Age=0");store.flush();names.remove(origin);}
static synchronized void renew(HttpURLConnection connection,String origin,String sent){
- CookieManager store=CookieManager.getInstance();String previous=sessionCookie(sent);
- if(previous.isEmpty()||!previous.equals(sessionCookie(store.getCookie(origin+"/"))))return;
+ CookieManager store=CookieManager.getInstance();String previous=sessionCookie(sent,origin);
+ if(previous.isEmpty()||!previous.equals(sessionCookie(store.getCookie(origin+"/"),origin)))return;
// Only renew the same authenticated binding. A late response must not
// restore a removed connection or overwrite a newer sign-in.
for(java.util.Map.Entry> field:connection.getHeaderFields().entrySet())if("Set-Cookie".equalsIgnoreCase(field.getKey()))for(String value:field.getValue()){
- if(!previous.equals(value.split(";",2)[0].trim()))continue;
+ String replacement=value.split(";",2)[0].trim();
+ if(!previous.equals(replacement)&&!(previous.startsWith("codex_mobile_session=")&&replacement.startsWith("codex_mobile_session_")&&replacement.endsWith(previous.substring(previous.indexOf('=')))))continue;
try{java.util.List parsed=HttpCookie.parse(value);if(parsed.size()!=1||parsed.get(0).getDomain()!=null||!GatewayURL.cookiePath(origin).equals(parsed.get(0).getPath()))continue;}catch(IllegalArgumentException invalid){continue;}
- store.setCookie(origin+"/",value);store.flush();return;
+ store.setCookie(origin+"/",value);store.flush();if(replacement.startsWith("codex_mobile_session_"))setName(origin,replacement.substring(0,replacement.indexOf('=')));return;
}
}
static JSONObject read(String origin)throws Exception {
@@ -33,14 +38,14 @@ static JSONObject read(String origin)throws Exception {
}
static JSONObject read(String origin,Long after,Read pending)throws Exception {
if(!GatewayURL.origin(origin).equals(origin))throw new IOException("连接地址无效");
- String cookie=CookieManager.getInstance().getCookie(origin+"/");if(cookie==null||cookie.isEmpty())throw new IOException("请先登录电脑网关");
+ String cookie=CookieManager.getInstance().getCookie(origin+"/");if(sessionCookie(cookie,origin).isEmpty())throw new IOException("请先登录电脑网关");
HttpURLConnection c=(HttpURLConnection)new URL(origin+"/api/mobile/events"+(after==null?"":"?after="+Math.max(0,after))).openConnection();
c.setInstanceFollowRedirects(false);c.setConnectTimeout(7000);c.setReadTimeout(10000);c.setRequestProperty("Cookie",cookie);c.setRequestProperty("Origin",GatewayURL.httpOrigin(origin));c.setRequestProperty("User-Agent",USER_AGENT);
try{if(pending!=null)pending.attach(c);int code=c.getResponseCode();if(code==401||code==403)throw new IOException("登录已失效,请重新连接电脑");if(code==404)throw new IOException("请使用配套的电脑端 Preview 网关");if(code!=200)throw new IOException("暂时无法读取提醒,请检查连接");
if(pending!=null)pending.check();
renew(c,origin,cookie);
ByteArrayOutputStream out=new ByteArrayOutputStream();try(InputStream in=c.getInputStream()){byte[] b=new byte[8192];int n;while((n=in.read(b))!=-1){if(pending!=null)pending.check();if(out.size()+n>512000)throw new IOException("通知数据过大");out.write(b,0,n);}}
- if(pending!=null){pending.check();if(!sessionCookie(cookie).equals(sessionCookie(CookieManager.getInstance().getCookie(origin+"/"))))throw new IOException("登录已失效,请重新连接电脑");}
+ if(pending!=null){pending.check();if(!sessionCookie(cookie,origin).equals(sessionCookie(CookieManager.getInstance().getCookie(origin+"/"),origin)))throw new IOException("登录已失效,请重新连接电脑");}
return new JSONObject(out.toString("UTF-8"));
}finally{if(pending!=null)pending.detach();c.disconnect();}
}
@@ -60,7 +65,7 @@ static JSONObject request(String origin,String path,JSONObject body,String csrf)
try{if(body!=null){c.setRequestMethod("POST");c.setDoOutput(true);c.setRequestProperty("Content-Type","application/json");c.setRequestProperty("X-CSRF-Token",csrf);try(OutputStream out=c.getOutputStream()){out.write(body.toString().getBytes(StandardCharsets.UTF_8));}}
if(c.getResponseCode()!=200)throw new IOException("电脑未配置系统推送,或登录已失效");
renew(c,origin,cookie);
- ByteArrayOutputStream out=new ByteArrayOutputStream();try(InputStream in=c.getInputStream()){byte[] b=new byte[4096];int n;while((n=in.read(b))!=-1){if(out.size()+n>65536)throw new IOException("响应过大");out.write(b,0,n);}}return new JSONObject(out.toString("UTF-8"));
+ ByteArrayOutputStream out=new ByteArrayOutputStream();try(InputStream in=c.getInputStream()){byte[] b=new byte[4096];int n;while((n=in.read(b))!=-1){if(out.size()+n>65536)throw new IOException("响应过大");out.write(b,0,n);}}JSONObject result=new JSONObject(out.toString("UTF-8"));if("/api/auth".equals(path))setName(origin,result.optString("cookieName",null));return result;
}finally{c.disconnect();}
}
diff --git a/mobile/android/src/io/github/try2love/codexbridge/MainActivity.java b/mobile/android/src/io/github/try2love/codexbridge/MainActivity.java
index 1a2aa7a0..cd450dda 100644
--- a/mobile/android/src/io/github/try2love/codexbridge/MainActivity.java
+++ b/mobile/android/src/io/github/try2love/codexbridge/MainActivity.java
@@ -43,24 +43,24 @@ public final class MainActivity extends Activity {
void header(boolean chat){bar.setVisibility(chat?View.GONE:View.VISIBLE);if(chat)return;bar.removeAllViews();ImageView logo=new ImageView(this);logo.setImageResource(getResources().getIdentifier("ic_bridge","drawable",getPackageName()));LinearLayout.LayoutParams lp=new LinearLayout.LayoutParams(dp(30),dp(30));lp.setMarginEnd(dp(8));bar.addView(logo,lp);TextView title=text("Codex Bridge",18);title.setTypeface(null,android.graphics.Typeface.BOLD);bar.addView(title,new LinearLayout.LayoutParams(0,-2,1));bar.addView(icon("ic_bell",L("通知收件箱"),this::inbox),new LinearLayout.LayoutParams(dp(48),dp(48)));bar.addView(icon("ic_settings",L("手机设置"),this::settings),new LinearLayout.LayoutParams(dp(48),dp(48)));}
void webAction(String selector){if(web!=null&&GatewayURL.sameOrigin(web.getUrl(),origin))web.evaluateJavascript("document.querySelector("+JSONObject.quote(selector)+")?.click()",null);}
void rememberConnection(){
- final String address=origin,binding=EventClient.sessionCookie(CookieManager.getInstance().getCookie(origin+"/"));final int ticket=generation;
- worker.execute(()->{try{JSONObject auth=EventClient.request(address,"/api/auth",null,null),data=ConnectionDiscovery.descriptor(auth,binding);
- runOnUiThread(()->{if(ticket!=generation||!binding.equals(EventClient.sessionCookie(CookieManager.getInstance().getCookie(address+"/"))))return;if(data!=null)prefs.edit().putString("connection:"+address,data.toString()).apply();else prefs.edit().remove("connection:"+address).apply();});
+ final String address=origin;final int ticket=generation;
+ worker.execute(()->{try{JSONObject auth=EventClient.request(address,"/api/auth",null,null);String binding=EventClient.sessionCookie(CookieManager.getInstance().getCookie(address+"/"),address);JSONObject data=ConnectionDiscovery.descriptor(auth,binding);
+ runOnUiThread(()->{if(ticket!=generation||!binding.equals(EventClient.sessionCookie(CookieManager.getInstance().getCookie(address+"/"),address)))return;if(auth.optBoolean("authenticated"))prefs.edit().putString("cookieName:"+address,EventClient.cookieName(address)).apply();if(data!=null)prefs.edit().putString("connection:"+address,data.toString()).apply();else prefs.edit().remove("connection:"+address).apply();});
}catch(Exception ignored){}});
}
void recoverConnection(String requested){
if(routeAttempted||!resumed||web==null)return;routeAttempted=true;
- final String address=origin,encoded=prefs.getString("connection:"+address,null),cookie=EventClient.sessionCookie(CookieManager.getInstance().getCookie(address+"/"));final int ticket=generation;
+ final String address=origin,encoded=prefs.getString("connection:"+address,null),cookie=EventClient.sessionCookie(CookieManager.getInstance().getCookie(address+"/"),address);final int ticket=generation;
if(encoded==null||cookie.isEmpty())return;
final ConnectionDiscovery resolver=new ConnectionDiscovery();routeDiscovery=resolver;
worker.execute(()->{try{String next=resolver.resolve(address,new JSONObject(encoded),cookie);runOnUiThread(()->{
- if(ticket!=generation||!resumed||routeDiscovery!=resolver||next.equals(address)||!saved().contains(address)||!cookie.equals(EventClient.sessionCookie(CookieManager.getInstance().getCookie(address+"/"))))return;
+ if(ticket!=generation||!resumed||routeDiscovery!=resolver||next.equals(address)||!saved().contains(address)||!cookie.equals(EventClient.sessionCookie(CookieManager.getInstance().getCookie(address+"/"),address)))return;
final String[] snapshot={null};
- Runnable relocate=()->{String existing=EventClient.sessionCookie(CookieManager.getInstance().getCookie(next+"/"));if(!existing.isEmpty()&&!existing.equals(cookie))return;if(ticket!=generation||!resumed||routeDiscovery!=resolver||!saved().contains(address)||!cookie.equals(EventClient.sessionCookie(CookieManager.getInstance().getCookie(address+"/"))))return;
- CookieManager.getInstance().setCookie(next+"/",cookie+"; Path=/; Secure; HttpOnly; SameSite=Strict; Max-Age=34560000",ok->{if(!ok||ticket!=generation||!resumed||routeDiscovery!=resolver||!saved().contains(address)||!cookie.equals(EventClient.sessionCookie(CookieManager.getInstance().getCookie(address+"/"))))return;
+ Runnable relocate=()->{String existing=EventClient.sessionCookie(CookieManager.getInstance().getCookie(next+"/"),next);if(!existing.isEmpty()&&!existing.equals(cookie))return;if(ticket!=generation||!resumed||routeDiscovery!=resolver||!saved().contains(address)||!cookie.equals(EventClient.sessionCookie(CookieManager.getInstance().getCookie(address+"/"),address)))return;
+ CookieManager.getInstance().setCookie(next+"/",cookie+"; Path=/; Secure; HttpOnly; SameSite=Strict; Max-Age=34560000",ok->{if(!ok||ticket!=generation||!resumed||routeDiscovery!=resolver||!saved().contains(address)||!cookie.equals(EventClient.sessionCookie(CookieManager.getInstance().getCookie(address+"/"),address)))return;
Set values=saved();values.remove(address);values.add(next);android.content.SharedPreferences.Editor edit=prefs.edit().putStringSet("origins",values).putString("connection:"+next,encoded).remove("connection:"+address);
- for(String prefix:new String[]{"name:","computer-name:","cursor:","stream:","last-opened:"}){Object value=prefs.getAll().get(prefix+address);if(value instanceof String)edit.putString(prefix+next,(String)value);else if(value instanceof Long)edit.putLong(prefix+next,(Long)value);edit.remove(prefix+address);}edit.apply();
- EventClient.forget(address);connect(next+requested.substring(address.length()));pendingStorage=snapshot[0];routeAttempted=true;
+ for(String prefix:new String[]{"name:","computer-name:","cursor:","stream:","last-opened:","cookieName:"}){Object value=prefs.getAll().get(prefix+address);if(value instanceof String)edit.putString(prefix+next,(String)value);else if(value instanceof Long)edit.putLong(prefix+next,(Long)value);edit.remove(prefix+address);}edit.apply();
+ EventClient.setName(next,EventClient.cookieName(address));EventClient.forget(address);connect(next+requested.substring(address.length()));pendingStorage=snapshot[0];routeAttempted=true;
});};
if(web!=null&&GatewayURL.sameOrigin(web.getUrl(),address))web.evaluateJavascript("(()=>{if(typeof saveDraft==='function')saveDraft();return JSON.stringify(Object.fromEntries(Object.entries(localStorage)));})()",value->{if(ticket!=generation)return;try{Object parsed=new org.json.JSONTokener(value).nextValue();if(parsed instanceof String&&((String)parsed).length()<524288){JSONObject data=new JSONObject((String)parsed);snapshot[0]=data.toString();}}catch(Exception ignored){}relocate.run();});else relocate.run();
});}catch(Exception ignored){}});
@@ -73,7 +73,7 @@ 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.addView(root,new FrameLayout.LayoutParams(-1,-1));setContentView(screen);systemBars=new SystemBars(this,screen,()->{if(downloadCard!=null)downloadCard.post(downloadCard::place);});
+ @Override public void onCreate(Bundle state){super.onCreate(state);prefs=getSharedPreferences("bridge",0);for(String address:prefs.getStringSet("origins",java.util.Collections.emptySet()))EventClient.setName(address,prefs.getString("cookieName:"+address,null));root=new LinearLayout(this);root.setOrientation(1);root.setBackgroundColor(0xfff5f5f3);screen=new FrameLayout(this);screen.addView(root,new FrameLayout.LayoutParams(-1,-1));setContentView(screen);systemBars=new SystemBars(this,screen,()->{if(downloadCard!=null)downloadCard.post(downloadCard::place);});
if(state!=null){choosingUpdateFile=state.getString("choosingUpdateFile");pendingSaveResult=state.getBundle("pendingSaveResult");}
if(Build.VERSION.SDK_INT>=33)getOnBackInvokedDispatcher().registerOnBackInvokedCallback(android.window.OnBackInvokedDispatcher.PRIORITY_DEFAULT,this::handleBack);
bar=new LinearLayout(this);bar.setGravity(Gravity.CENTER_VERTICAL);bar.setPadding(dp(20),dp(4),dp(12),dp(4));root.addView(bar);header(false);
@@ -246,11 +246,11 @@ Dialog panel(String title,LinearLayout content){
dialog.setContentView(body);dialog.show();dialog.getWindow().setBackgroundDrawableResource(android.R.color.transparent);dialog.getWindow().setLayout(getResources().getDisplayMetrics().widthPixels-dp(24),getResources().getDisplayMetrics().heightPixels-dp(100));return dialog;
}
void clearInbox(String base,JSONObject data,Runnable done){
- final int ticket=generation;final String binding=EventClient.sessionCookie(CookieManager.getInstance().getCookie(base+"/"));worker.execute(()->{try{
+ final int ticket=generation;final String binding=EventClient.sessionCookie(CookieManager.getInstance().getCookie(base+"/"),base);worker.execute(()->{try{
JSONObject auth=EventClient.request(base,"/api/auth",null,null);
- if(!binding.equals(EventClient.sessionCookie(CookieManager.getInstance().getCookie(base+"/"))))return;
+ if(!binding.equals(EventClient.sessionCookie(CookieManager.getInstance().getCookie(base+"/"),base)))return;
JSONObject snapshot=EventClient.request(base,"/api/mobile/events/read",new JSONObject().put("provider","all").put("streamId",data.getString("streamId")).put("through",data.getLong("cursor")),auth.getString("csrf"));
- runOnUiThread(()->{if(ticket!=generation||!binding.equals(EventClient.sessionCookie(CookieManager.getInstance().getCookie(base+"/"))))return;ComputerNotifications.update(base,binding,snapshot);done.run();});
+ runOnUiThread(()->{if(ticket!=generation||!binding.equals(EventClient.sessionCookie(CookieManager.getInstance().getCookie(base+"/"),base)))return;ComputerNotifications.update(base,binding,snapshot);done.run();});
}catch(Exception error){runOnUiThread(()->{if(ticket==generation)message(error.getMessage());});}});
}
void inbox(){
@@ -409,7 +409,7 @@ void requestStartupNotifications(){
void toggleNotifications(){if(prefs.getBoolean("alerts",false)){prefs.edit().putBoolean("alerts",false).apply();return;}if(Build.VERSION.SDK_INT>=33&&checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS)!=PackageManager.PERMISSION_GRANTED){requestPermissions(new String[]{Manifest.permission.POST_NOTIFICATIONS},22);return;}if(!((NotificationManager)getSystemService(NOTIFICATION_SERVICE)).areNotificationsEnabled()){message(L("请在系统设置中允许通知。"));return;}prefs.edit().putBoolean("alerts",true).apply();monitor();}
void testNotification(){if(Build.VERSION.SDK_INT>=33&&checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS)!=PackageManager.PERMISSION_GRANTED){requestPermissions(new String[]{Manifest.permission.POST_NOTIFICATIONS},23);return;}String base=origin;new Handler().postDelayed(()->{try{MonitorService.notifyEvent(this,base,new JSONObject().put("id","test").put("title",L("手机通知测试")).put("body",L("本机通知已开启")));}catch(Exception ignored){}},10000);message(L("10 秒后显示本地测试通知,可以先返回桌面。此测试不代表远程推送已经接通。"));}
void confirmRemove(){confirmRemove(origin);}
- void confirmRemove(String target){new AlertDialog.Builder(this).setTitle(L("移除这台电脑?")).setMessage(target+"\n\n"+L("清除本机保存的连接和登录状态,电脑上的聊天不受影响。")).setNegativeButton(L("取消"),null).setPositiveButton(L("移除"),(d,w)->{if(downloadTask!=null&&downloadTask.origin.equals(target))cancelDownload();final int ticket=generation;worker.execute(()->{try{JSONObject auth=EventClient.request(target,"/api/auth",null,null);if(auth.optBoolean("authenticated"))EventClient.request(target,"/api/logout",new JSONObject(),auth.getString("csrf"));}catch(Exception ignored){/* Offline removal is local; server session expiry still applies. */}runOnUiThread(()->{MonitorService.bindingsChanged();Set values=saved();values.remove(target);EventClient.forget(target);android.content.SharedPreferences.Editor edit=prefs.edit().putStringSet("origins",values).remove("cursor:"+target).remove("stream:"+target).remove("connection:"+target).remove("name:"+target).remove("last-opened:"+target);if(target.equals(prefs.getString("active","")))edit.remove("active");edit.apply();if(target.equals(origin))origin="";if(ticket==generation)home();});});}).show();}
+ void confirmRemove(String target){new AlertDialog.Builder(this).setTitle(L("移除这台电脑?")).setMessage(target+"\n\n"+L("清除本机保存的连接和登录状态,电脑上的聊天不受影响。")).setNegativeButton(L("取消"),null).setPositiveButton(L("移除"),(d,w)->{if(downloadTask!=null&&downloadTask.origin.equals(target))cancelDownload();final int ticket=generation;worker.execute(()->{try{JSONObject auth=EventClient.request(target,"/api/auth",null,null);if(auth.optBoolean("authenticated"))EventClient.request(target,"/api/logout",new JSONObject(),auth.getString("csrf"));}catch(Exception ignored){/* Offline removal is local; server session expiry still applies. */}runOnUiThread(()->{MonitorService.bindingsChanged();Set values=saved();values.remove(target);EventClient.forget(target);android.content.SharedPreferences.Editor edit=prefs.edit().putStringSet("origins",values).remove("cursor:"+target).remove("stream:"+target).remove("connection:"+target).remove("name:"+target).remove("last-opened:"+target).remove("cookieName:"+target);if(target.equals(prefs.getString("active","")))edit.remove("active");edit.apply();if(target.equals(origin))origin="";if(ticket==generation)home();});});}).show();}
void monitor(){if(monitor==null){monitor=new ServiceConnection(){public void onServiceConnected(ComponentName n,IBinder b){}public void onServiceDisconnected(ComponentName n){}};getApplicationContext().bindService(new Intent(this,MonitorService.class),monitor,BIND_AUTO_CREATE);}}
void scan(){if(checkSelfPermission(Manifest.permission.CAMERA)!=PackageManager.PERMISSION_GRANTED){requestPermissions(new String[]{Manifest.permission.CAMERA},21);return;}startActivityForResult(new Intent(this,ScanActivity.class),20);}
@Override public void onRequestPermissionsResult(int code,String[] names,int[] results){super.onRequestPermissionsResult(code,names,results);boolean allowed=results.length>0&&results[0]==PackageManager.PERMISSION_GRANTED;if(code==25){prefs.edit().putBoolean("alerts",allowed).apply();if(allowed)monitor();return;}if(!allowed){message(L(code==21?"未获得权限,可继续粘贴地址使用。":"请在系统设置中允许通知。"));return;}if(code==24)enableNativePush();if(code==21)scan();if(code==23)testNotification();if(code==22){prefs.edit().putBoolean("alerts",true).apply();monitor();}}
diff --git a/mobile/android/src/io/github/try2love/codexbridge/MonitorService.java b/mobile/android/src/io/github/try2love/codexbridge/MonitorService.java
index 7e384562..ff211247 100644
--- a/mobile/android/src/io/github/try2love/codexbridge/MonitorService.java
+++ b/mobile/android/src/io/github/try2love/codexbridge/MonitorService.java
@@ -29,11 +29,12 @@ private void check(){
SharedPreferences p=getSharedPreferences("bridge",0);final long ticket;final EventClient.Read pending=new EventClient.Read();
synchronized(foregroundLock){if(!visible||activeRead!=null)return;ticket=foregroundEpoch;activeRead=pending;if(epoch!=ticket){epoch=ticket;baselined.clear();}}
try{for(String origin:new java.util.HashSet<>(p.getStringSet("origins",java.util.Collections.emptySet()))){
+ EventClient.setName(origin,p.getString("cookieName:"+origin,null));
synchronized(foregroundLock){if(!current(pending,ticket))return;if(!p.getStringSet("origins",java.util.Collections.emptySet()).contains(origin))continue;}
- String binding=EventClient.sessionCookie(android.webkit.CookieManager.getInstance().getCookie(origin+"/"));
+ String binding=EventClient.sessionCookie(android.webkit.CookieManager.getInstance().getCookie(origin+"/"),origin);
try{JSONObject data=EventClient.read(origin,Math.max(0,p.getLong("cursor:"+origin,0)),pending);
synchronized(foregroundLock){if(!current(pending,ticket))return;if(!p.getStringSet("origins",java.util.Collections.emptySet()).contains(origin))continue;
- synchronized(EventClient.class){if(!binding.equals(EventClient.sessionCookie(android.webkit.CookieManager.getInstance().getCookie(origin+"/"))))continue;
+ synchronized(EventClient.class){if(!binding.equals(EventClient.sessionCookie(android.webkit.CookieManager.getInstance().getCookie(origin+"/"),origin)))continue;
ComputerNotifications.update(origin,binding,data);if(!data.optBoolean("enabled"))continue;
String key="cursor:"+origin,stream=data.getString("streamId");long previous=p.getLong(key,-1);
boolean ready=baselined.contains(origin)&&stream.equals(p.getString("stream:"+origin,""));
diff --git a/mobile/ios/BridgePreview/App.swift b/mobile/ios/BridgePreview/App.swift
index 2d0ea910..ec634b71 100644
--- a/mobile/ios/BridgePreview/App.swift
+++ b/mobile/ios/BridgePreview/App.swift
@@ -93,9 +93,7 @@ final class BridgeController: UIViewController, WKNavigationDelegate, WKUIDelega
WKWebsiteDataStore.default().httpCookieStore.getAllCookies { [weak self] cookies in
guard let self, self.saved.contains(address) else { done(nil); return }
let matching = cookies.filter { cookie in
- cookie.name == "codex_mobile_session" && cookie.path == GatewayURL.cookiePath(address) &&
- cookie.domain.trimmingCharacters(in: CharacterSet(charactersIn: ".")) == target.host &&
- (!cookie.isSecure || target.scheme == "https") && (cookie.expiresDate == nil || cookie.expiresDate! > Date())
+ self.cookieMatches(cookie, address: address)
}
done(matching.isEmpty ? nil : HTTPCookie.requestHeaderFields(with: matching)["Cookie"])
}
@@ -113,6 +111,14 @@ final class BridgeController: UIViewController, WKNavigationDelegate, WKUIDelega
private var pollTask: URLSessionDataTask?
private var generation = 0
private let defaults = UserDefaults.standard
+ private func cookieName(_ address: String) -> String { defaults.string(forKey: "cookieName:" + address) ?? "codex_mobile_session" }
+ private func cookieMatches(_ cookie: HTTPCookie, address: String, discover: Bool = false) -> Bool {
+ guard let target = URL(string: address), cookie.path == GatewayURL.cookiePath(address),
+ cookie.domain.trimmingCharacters(in: CharacterSet(charactersIn: ".")) == target.host,
+ (!cookie.isSecure || target.scheme == "https"),
+ cookie.expiresDate == nil || cookie.expiresDate! > Date() else { return false }
+ return cookie.name == cookieName(address) || (discover && cookie.name.range(of: #"^codex_mobile_session_[a-f0-9]{24}$"#, options: .regularExpression) != nil)
+ }
private let page = UIStackView()
private let topBackground = UIView()
private var pageStatusBarStyle: UIStatusBarStyle = .default
@@ -400,15 +406,15 @@ final class BridgeController: UIViewController, WKNavigationDelegate, WKUIDelega
func openSaved(_ address: String, provider: String? = nil) { let suffix = provider.flatMap { ["codex", "claude", "deepseek"].contains($0) ? "?client=" + $0 : nil } ?? ""; guard saved.contains(address), let url = URL(string: address + "/" + suffix) else { info(MobileStrings.text("请先扫码保存通知对应的电脑。")); return }; connect(url, address: address) }
private func binding(_ address: String, done: @escaping (HTTPCookie?) -> Void) {
WKWebsiteDataStore.default().httpCookieStore.getAllCookies { cookies in
- done(cookies.first { $0.name == "codex_mobile_session" && $0.path == GatewayURL.cookiePath(address) && $0.domain == URL(string: address)?.host && ($0.expiresDate == nil || $0.expiresDate! > Date()) })
+ done(cookies.first { self.cookieMatches($0, address: address) })
}
}
private func rememberConnection() {
let address = origin, ticket = generation
- binding(address) { cookie in
- guard let cookie, ticket == self.generation else { return }
- self.fetch(address: address, route: "/api/auth") { result in
- guard ticket == self.generation, case .success(let auth) = result else { return }
+ self.fetch(address: address, route: "/api/auth") { result in
+ guard ticket == self.generation, case .success(let auth) = result else { return }
+ self.binding(address) { cookie in
+ guard let cookie, ticket == self.generation else { return }
self.binding(address) { current in
guard ticket == self.generation, current?.value == cookie.value else { return }
self.defaults.set(ConnectionDiscovery.descriptor(auth, secret: cookie.value), forKey: "connection:" + address)
@@ -451,6 +457,7 @@ final class BridgeController: UIViewController, WKNavigationDelegate, WKUIDelega
guard current() else { return }
var all = self.saved.filter { $0 != address }; if !all.contains(next) { all.append(next) }; self.defaults.set(all, forKey: "origins")
self.defaults.set(descriptor, forKey: "connection:" + next); self.defaults.removeObject(forKey: "connection:" + address)
+ if let name = self.defaults.string(forKey: "cookieName:" + address) { self.defaults.set(name, forKey: "cookieName:" + next) }; self.defaults.removeObject(forKey: "cookieName:" + address)
for prefix in ["name:", "computer-name:", "cursor:", "stream:", "last-opened:"] {
if let value = self.defaults.object(forKey: prefix + address) { self.defaults.set(value, forKey: prefix + next) }; self.defaults.removeObject(forKey: prefix + address)
}
@@ -633,8 +640,7 @@ final class BridgeController: UIViewController, WKNavigationDelegate, WKUIDelega
WKWebsiteDataStore.default().httpCookieStore.getAllCookies { cookies in
guard valid() else { return }
guard poll == nil || self.saved.contains(address) else { done(.failure(NSError(domain: "Bridge", code: 401))); return }
- let host = target.host ?? ""
- let matching = cookies.filter { cookie in cookie.name == "codex_mobile_session" && cookie.path == GatewayURL.cookiePath(address) && cookie.domain.trimmingCharacters(in: CharacterSet(charactersIn: ".")) == host && (!cookie.isSecure || target.scheme == "https") && (cookie.expiresDate == nil || cookie.expiresDate! > Date()) }
+ let matching = cookies.filter { cookie in self.cookieMatches(cookie, address: address, discover: route == "/api/auth") }
guard !matching.isEmpty else { done(.failure(NSError(domain: "Bridge", code: 401, userInfo: [NSLocalizedDescriptionKey: MobileStrings.text("请先连接并登录电脑网关")]))); return }
var request = URLRequest(url: target); request.setValue(GatewayURL.httpOrigin(address), forHTTPHeaderField: "Origin"); request.setValue(HTTPCookie.requestHeaderFields(with: matching)["Cookie"], forHTTPHeaderField: "Cookie"); request.setValue("BridgeMobile/0.1-iOS", forHTTPHeaderField: "User-Agent")
if let body { request.httpMethod = "POST"; request.httpBody = try? JSONSerialization.data(withJSONObject: body); request.setValue("application/json", forHTTPHeaderField: "Content-Type"); request.setValue(csrf, forHTTPHeaderField: "X-CSRF-Token") }
@@ -646,18 +652,27 @@ final class BridgeController: UIViewController, WKNavigationDelegate, WKUIDelega
else { result = .failure(NSError(domain: "Bridge", code: code ?? 0, userInfo: [NSLocalizedDescriptionKey: code == 401 || code == 403 ? MobileStrings.text("登录已失效,请重新连接电脑") : MobileStrings.text("请使用配套电脑 Preview,并检查连接")])) }
DispatchQueue.main.async {
guard valid() else { return }
+ if route == "/api/auth", case .success(let auth) = result, auth["authenticated"] as? Bool == true,
+ let name = auth["cookieName"] as? String,
+ name.range(of: #"^codex_mobile_session_[a-f0-9]{24}$"#, options: .regularExpression) != nil {
+ self.defaults.set(name, forKey: "cookieName:" + address)
+ }
if poll != nil { self.pollTask = nil }
let response = response as? HTTPURLResponse
let headers = response?.allHeaderFields as? [String: String] ?? [:]
let renewed = code == 200 && response?.url.map({ GatewayURL.same($0, address) }) == true ? HTTPCookie.cookies(withResponseHeaderFields: headers, for: target).first(where: { cookie in
- cookie.name == "codex_mobile_session" && cookie.path == GatewayURL.cookiePath(address) && cookie.domain.trimmingCharacters(in: CharacterSet(charactersIn: ".")) == host && cookie.path == GatewayURL.cookiePath(address) && matching.contains(where: { $0.name == cookie.name && $0.value == cookie.value })
+ self.cookieMatches(cookie, address: address, discover: route == "/api/auth") && matching.contains(where: { $0.value == cookie.value })
}) : nil
let store = WKWebsiteDataStore.default().httpCookieStore
store.getAllCookies { current in
guard valid() else { return }
// A response from a removed/replaced login cannot renew its
// cookie or commit that account's notification cursor.
- let bindingCurrent = self.saved.contains(address) && matching.contains(where: { sent in current.contains(where: { $0.name == sent.name && $0.value == sent.value && $0.domain == sent.domain && $0.path == sent.path }) })
+ let selectedName = (try? result.get())?["cookieName"] as? String ?? self.cookieName(address)
+ let bindingCurrent = self.saved.contains(address) && matching.contains(where: { sent in
+ let migrates = sent.name == "codex_mobile_session" && renewed.map { $0.value == sent.value && $0.name == selectedName } == true
+ return (sent.name == selectedName || migrates) && current.contains(where: { $0.name == sent.name && $0.value == sent.value && $0.domain == sent.domain && $0.path == sent.path })
+ })
if !bindingCurrent { done(.failure(NSError(domain: "Bridge", code: 401))); return }
guard bindingCurrent, let renewed else { done(result); return }
store.setCookie(renewed) {
@@ -959,7 +974,7 @@ final class BridgeController: UIViewController, WKNavigationDelegate, WKUIDelega
if self.defaults.string(forKey: "active") == target { self.defaults.removeObject(forKey: "active") }
self.defaults.removeObject(forKey: "connection:" + target); self.defaults.removeObject(forKey: "cursor:" + target); self.defaults.removeObject(forKey: "stream:" + target); self.defaults.removeObject(forKey: "name:" + target); self.defaults.removeObject(forKey: "last-opened:" + target)
WKWebsiteDataStore.default().httpCookieStore.getAllCookies { cookies in
- for cookie in cookies where cookie.domain.trimmingCharacters(in: CharacterSet(charactersIn: ".")) == URL(string: target)?.host && cookie.name == "codex_mobile_session" && cookie.path == GatewayURL.cookiePath(target) { WKWebsiteDataStore.default().httpCookieStore.delete(cookie) }
+ for cookie in cookies where self.cookieMatches(cookie, address: target) { WKWebsiteDataStore.default().httpCookieStore.delete(cookie) }
}
if self.origin == target { self.origin = "" }
if self.generation == revision { self.home() }
diff --git a/scripts/smoke-pairing.cjs b/scripts/smoke-pairing.cjs
index ecec92a6..5edd9108 100644
--- a/scripts/smoke-pairing.cjs
+++ b/scripts/smoke-pairing.cjs
@@ -85,9 +85,10 @@ app.whenReady().then(async()=>{
nativePhone=openNative();await nativePhone.loadURL(fresh.url);
const native=code=>nativePhone.webContents.executeJavaScript(code);
await until(()=>native('document.getElementById("login").hidden&&!document.getElementById("app").hidden&&Boolean(csrf)'),platform+' QR sign-in');
- assert.equal(await native('fetch("/api/auth").then(r=>r.json()).then(v=>v.trustedDevice)'),true);
+ const auth=await native('fetch("/api/auth").then(r=>r.json())');
+ assert.equal(auth.trustedDevice,true);
const store=nativePhone.webContents.session.cookies;
- const cookie=(await store.get({url:entry,name:'codex_mobile_session'}))[0];
+ const cookie=(await store.get({url:entry,name:auth.cookieName}))[0];
assert.ok(cookie.httpOnly&&!cookie.session&&cookie.expirationDate>Date.now()/1000+399*86400);
await store.flushStore();nativePhone.destroy();nativePhone=null;
await worker('stop');await worker('start');await until(async()=>(await worker('snapshot')).runtime.running,'restarted gateway');
diff --git a/scripts/test-ios-events.py b/scripts/test-ios-events.py
index 6cc84092..951592eb 100644
--- a/scripts/test-ios-events.py
+++ b/scripts/test-ios-events.py
@@ -127,6 +127,7 @@ def section(start, end):
if 'private var pollGeneration' in source:
fields += ' var pollGeneration = 0\n var pollTask: URLSessionDataTask?\n'
fields += section(' private let redirect = NoRedirect()', ' override func viewDidLoad()')
+fields += section(' private func cookieName(', ' private let page = UIStackView()').replace('private func', 'func')
methods = '''
func checkComputers() {}
func stopComputerChecks() {}
diff --git a/scripts/test-mobile-session.py b/scripts/test-mobile-session.py
index 3a9490df..dc61ce7e 100644
--- a/scripts/test-mobile-session.py
+++ b/scripts/test-mobile-session.py
@@ -23,7 +23,7 @@
}''',
'android/content/Context.java': '''package android.content; public abstract class Context {public abstract SharedPreferences getSharedPreferences(String name,int mode);}''',
'android/content/SharedPreferences.java': '''package android.content;public interface SharedPreferences {boolean getBoolean(String key,boolean fallback);String getString(String key,String fallback);Editor edit();interface Editor{Editor putString(String key,String value);Editor putBoolean(String key,boolean value);void apply();}}''',
- 'org/json/JSONObject.java': '''package org.json; public class JSONObject {public JSONObject(){}public JSONObject(String value){}public boolean optBoolean(String key){return false;}public String getString(String key){return "";}public JSONObject put(String key,Object value){return this;}}''',
+ 'org/json/JSONObject.java': '''package org.json; public class JSONObject {public JSONObject(){}public JSONObject(String value){}public boolean optBoolean(String key){return false;}public String optString(String key,String fallback){return fallback;}public String getString(String key){return "";}public JSONObject put(String key,Object value){return this;}}''',
}
for name, source in stubs.items():
path = work / name
diff --git a/tests/test_auth.py b/tests/test_auth.py
index 789792be..53f977ac 100644
--- a/tests/test_auth.py
+++ b/tests/test_auth.py
@@ -30,10 +30,13 @@ def auth(self):
def test_permanent_login_survives_restart_without_storing_bearer(self):
auth = self.auth()
+ self.assertEqual(auth.cookie_name, self.auth().cookie_name)
+ self.assertRegex(auth.cookie_name, r'^codex_mobile_session_[0-9a-f]{24}$')
token, session = auth.new_session('192.0.2.7', 'iPhone Safari')
self.assertEqual(session['expires'], 0)
raw = (self.directory / 'auth-sessions.json').read_text()
self.assertNotIn(token, raw)
+ self.assertIn('cookieName', raw)
with patch('bridge.features.auth.auth.time.time', return_value=session['created'] + 50 * 365 * 86400):
restarted = self.auth()
self.assertEqual(restarted.get(token)['csrf'], session['csrf'])
diff --git a/tests/test_login_security.py b/tests/test_login_security.py
index 65eb0de6..331becf3 100644
--- a/tests/test_login_security.py
+++ b/tests/test_login_security.py
@@ -199,12 +199,26 @@ def test_cookie_modes_and_auth_refresh(self):
self.assertIn('HttpOnly', headers['Set-Cookie'])
self.assertEqual('Max-Age=' in headers['Set-Cookie'], remember)
cookie = headers['Set-Cookie'].split(';')[0]
+ self.assertTrue(cookie.startswith(self.server.auth.cookie_name + '='))
_, refreshed, auth = self.request('/api/auth', headers={'Cookie': cookie})
self.assertTrue(auth['authenticated'])
self.assertEqual('Max-Age=' in refreshed['Set-Cookie'], remember)
if remember:
self.assertIn('Max-Age=604800', headers['Set-Cookie'])
+ def test_legacy_cookie_migrates_only_if_owned_by_this_gateway(self):
+ token, _ = self.server.auth.new_session('127.0.0.1')
+ legacy = 'codex_mobile_session=' + token
+ _, headers, auth = self.request('/api/auth', headers={'Cookie': legacy})
+ self.assertTrue(auth['authenticated'])
+ self.assertEqual(auth['cookieName'], self.server.auth.cookie_name)
+ self.assertTrue(headers['Set-Cookie'].startswith(self.server.auth.cookie_name + '='))
+ _, _, foreign = self.request('/api/auth', headers={'Cookie': 'codex_mobile_session=foreign'})
+ self.assertFalse(foreign['authenticated'])
+ mixed = self.server.auth.cookie_name + '=invalid; ' + legacy
+ self.assertFalse(self.request('/api/auth', headers={'Cookie': mixed})[2]['authenticated'])
+ self.assertTrue(self.request('/api/auth', headers={'Cookie': legacy})[2]['authenticated'])
+
def test_mobile_http_stays_signed_in_and_success_clears_attempts(self):
headers = {'User-Agent': 'BridgeMobile/0.1-Android'}
self.assertFalse(self.request('/api/auth', headers=headers)[2]['authenticated'])
From e4dc2b9a60c8ee2821e84a56c9c3b1571ef8e1ea Mon Sep 17 00:00:00 2001
From: try2love <1929706911@qq.com>
Date: Tue, 13 Oct 2026 02:19:14 +0800
Subject: [PATCH 10/31] Bound automatic list failure feedback and clear stale
errors
---
.../2026-10-12-shared-client-list-refresh.md | 4 ++++
tests/desktop-sessions-ui.test.cjs | 6 +++++
tests/list-sync.test.cjs | 22 +++++++++++++++++--
web/features/chat/list-sync.js | 14 ++++++++----
web/features/clients/desktop-sessions.js | 4 ++--
web/shell/app.js | 2 +-
6 files changed, 43 insertions(+), 9 deletions(-)
diff --git a/.agents/notes/implemented/feature/2026-10-12-shared-client-list-refresh.md b/.agents/notes/implemented/feature/2026-10-12-shared-client-list-refresh.md
index 04b3785f..6eb23f6a 100644
--- a/.agents/notes/implemented/feature/2026-10-12-shared-client-list-refresh.md
+++ b/.agents/notes/implemented/feature/2026-10-12-shared-client-list-refresh.md
@@ -14,6 +14,8 @@ Claude 与 DSH 将共享 `ListSync` 接到实际 `.sessions` 滚动容器,复
客户端切换、账号读取失效、登出及页面隐藏重置手势与提示;刷新修订号保证旧请求不能修改新提示或解除新请求的忙碌状态。现有 generation、读取 epoch 继续约束数据结果。渲染使用最新搜索和筛选,保留所选会话、草稿及可保留的列表位置;列表中部优先按首个可见会话锚定,数据插入后仍保持该会话的相对位置,锚点消失时保留可用的滚动偏移。
+已有列表时,前两次自动读取失败保持旧列表且不弹出提示;连续第三次失败显示可点击重试。手动失败和没有旧列表的首次失败立即提示。当前身份与修订号核验通过的任何列表成功读取都会清除旧错误,包括手势之外的列表入口;过期结果不能清除它。认证失败交给登录流程,不作为列表重试提示。
+
历史决策检索未发现活跃笔记中已有同一 ListSync/手动下拉刷新契约。本记录落实本地 v2 优化评审第 4 项,不引入连接管理或写操作的重试策略。
## Alternatives considered
@@ -26,6 +28,8 @@ Claude 与 DSH 将共享 `ListSync` 接到实际 `.sessions` 滚动容器,复
Node 回归覆盖并发读取合并、等待完成、失败与断开连接重试、筛选和草稿保留、客户端与账号失效、旧请求不得解锁新请求。隔离浏览器检查 390/844/1440px 下两个客户端的长列表、短列表、空列表、顶部手势、中部滚动、横向取消及失败重试;使用合成数据,不操作真实账号或任务。
+另有 ListSync 回归覆盖短暂自动失败不打断旧列表、连续失败提示、其他入口成功清除错误、认证失败与过期结果保护。
+
## Consequences
三个客户端共享手势及结果反馈,手动确认不增加写操作或重复轮询。代价是共享组件需要维护可重置的请求修订号,列表读取与轮询编排分开。浏览器验证不代替 Android/iOS 真机 WebView 的回弹体验验收。
diff --git a/tests/desktop-sessions-ui.test.cjs b/tests/desktop-sessions-ui.test.cjs
index b6a7052c..f5c5b230 100644
--- a/tests/desktop-sessions-ui.test.cjs
+++ b/tests/desktop-sessions-ui.test.cjs
@@ -56,6 +56,12 @@ test('session list surfaces genuine failures outside startup and never claims di
const ui=fixture();ui.view.setClientStates([{id:'deepseek',enabled:true,connected:false,connectionState:'idle'}]);ui.setHandler(async()=>{throw Error('real failure');});await ui.view.choose('deepseek');assert.equal(ui.view.listStatus.textContent,'real failure');
ui.setHandler(async()=>({connected:false,sessions:[]}));await ui.view.refresh();assert.equal(ui.view.listStatus.textContent,'尚未连接');
});
+test('desktop client keeps cached rows through brief polling failures and clears sustained failure after recovery',async()=>{
+ const ui=fixture();ui.setHandler(async()=>({sessions:[{id:'a',title:'Saved chat',backend:'code'}]}));await ui.view.choose('claude');
+ ui.setHandler(async()=>{throw Error('network unavailable');});
+ for(let attempt=1;attempt<=3;attempt++){await assert.rejects(ui.view.refreshList(),/network unavailable/);assert.match(ui.view.rowsRoot.textContent,/Saved chat/);assert.equal(ui.view.listStatus.textContent,'');assert.equal(ui.view.listHint.hidden,attempt<3);}
+ ui.setHandler(async()=>({sessions:[{id:'a',title:'Current chat',backend:'code'}]}));await ui.view.refreshList();assert.match(ui.view.rowsRoot.textContent,/Current chat/);
+});
test('LAN HTTP send creates a secure id and retries the same unconfirmed operation',async()=>{
const ui=await opened();ui.run('uuid=undefined');let attempt=0;ui.setHandler(async(url,body)=>{if(body){if(++attempt===1)throw Error('connection lost');return {status:'accepted'};}return url.includes('/detail')?{session:{id:'a',title:'Chat',status:'idle'},messages:[]}:{sessions:[]};});
ui.view.input.value='hello';await ui.view.submit();assert.equal(ui.view.input.value,'hello');await ui.view.detail(ui.view.generation);await ui.view.submit();const writes=ui.calls.filter(c=>c.body);assert.equal(writes.length,2);assert.match(writes[0].body.id,/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);assert.equal(writes[0].body.id,writes[1].body.id);assert.equal(ui.view.input.value,'');
diff --git a/tests/list-sync.test.cjs b/tests/list-sync.test.cjs
index d2c87e72..f1354811 100644
--- a/tests/list-sync.test.cjs
+++ b/tests/list-sync.test.cjs
@@ -5,11 +5,11 @@ function fixture(){
let now=0,id=0,calls=0,ready=true,visible=true,refresh=async()=>{};
const timers=new Map(),frames=new Map(),events={};
const node=()=>({hidden:true,textContent:'',scrollTop:0,style:{setProperty(key,value){this[key]=value;}},classList:{values:new Set(),add(key){this.values.add(key);},remove(key){this.values.delete(key);},toggle(key,on){on?this.add(key):this.remove(key);}},setAttribute(key,value){this[key]=value;},addEventListener(type,fn){(events[type]??=[]).push(fn);}});
- const root=node(),status=node();let writes=0,text='';Object.defineProperty(status,'textContent',{get:()=>text,set:value=>{writes++;text=value;}});
+ const root=node(),status=node();root.children=[];let writes=0,text='';Object.defineProperty(status,'textContent',{get:()=>text,set:value=>{writes++;text=value;}});
const context=vm.createContext({BridgeI18n:{t:value=>value},document:{hidden:false,addEventListener(){}},window:{addEventListener(){}},
setTimeout:(fn,delay)=>{timers.set(++id,{fn,at:now+delay});return id;},clearTimeout:key=>timers.delete(key),requestAnimationFrame:fn=>{frames.set(++id,fn);return id;},cancelAnimationFrame:key=>frames.delete(key)});
vm.runInContext(fs.readFileSync(require.resolve('../web/features/chat/list-sync.js'),'utf8'),context);
- const sync=new (vm.runInContext('ListSync',context))({root,status,visible:()=>visible,ready:()=>ready,refresh:async()=>{calls++;await refresh();}});
+ const sync=new (vm.runInContext('ListSync',context))({root,status,visible:()=>visible,ready:()=>ready,refresh:async()=>{calls++;return await refresh();}});
const frame=()=>{const tasks=[...frames.values()];frames.clear();for(const fn of tasks)fn();};
const advance=ms=>{const end=now+ms;while(true){const next=[...timers].filter(([,timer])=>timer.at<=end).sort((a,b)=>a[1].at-b[1].at)[0];if(!next)break;now=next[1].at;timers.delete(next[0]);next[1].fn();}now=end;};
const touch=(type,y,x=30,count=1)=>{const event={touches:['touchend','touchcancel'].includes(type)?[]:Array.from({length:count},()=>({clientX:x,clientY:y})),preventDefault(){this.prevented=true;}};for(const fn of events[type]||[])fn(event);return event;};
@@ -67,3 +67,21 @@ test('reset discards old feedback and keeps a newer refresh busy',async()=>{
const ui=fixture();let finishOld,finishNew;ui.setRefresh(()=>new Promise(resolve=>finishOld=resolve));const old=ui.sync.update(true);ui.sync.reset();assert.equal(ui.status.hidden,true);
ui.setRefresh(()=>new Promise(resolve=>finishNew=resolve));const current=ui.sync.update(true);finishOld();await old;assert.equal(ui.sync.busy,true);assert.match(ui.status.textContent,/正在刷新/);finishNew();await current;assert.equal(ui.sync.busy,false);assert.match(ui.status.textContent,/已更新/);
});
+
+test('transient automatic failures preserve an old list quietly, then offer retry',async()=>{
+ const ui=fixture();ui.root.children.push({});ui.setRefresh(async()=>{throw Error('offline');});
+ await ui.sync.update();await ui.sync.update();assert.equal(ui.status.hidden,true);
+ await ui.sync.update();assert.equal(ui.status.hidden,false);assert.match(ui.status.textContent,/点击重试/);
+ ui.setRefresh(async()=>{});await ui.sync.update();ui.advance(200);assert.equal(ui.status.hidden,true);
+});
+
+test('manual failure clears on another authoritative success and auth failure uses login flow',async()=>{
+ const ui=fixture();ui.root.children.push({});ui.setRefresh(async()=>{throw Error('offline');});await ui.sync.update(true);assert.equal(ui.status.hidden,false);
+ ui.sync.acknowledgeSuccess();ui.advance(200);assert.equal(ui.status.hidden,true);
+ ui.setRefresh(async()=>{throw Object.assign(Error('login'),{status:401});});await ui.sync.update(true);assert.equal(ui.status.hidden,false);ui.advance(200);assert.equal(ui.status.hidden,true);
+});
+
+test('stale refresh results never dismiss current feedback or claim success',async()=>{
+ const ui=fixture();let finish;ui.setRefresh(()=>new Promise(resolve=>finish=resolve));const stale=ui.sync.update(true);ui.sync.reset();ui.setRefresh(async()=>{throw Error('new failure');});await ui.sync.update(true);finish(false);await stale;
+ assert.equal(ui.status.hidden,false);assert.match(ui.status.textContent,/点击重试/);
+});
diff --git a/web/features/chat/list-sync.js b/web/features/chat/list-sync.js
index e8ebd14d..1bfc89d7 100644
--- a/web/features/chat/list-sync.js
+++ b/web/features/chat/list-sync.js
@@ -2,7 +2,7 @@
class ListSync {
constructor({root,status,refresh,visible,ready,automatic=true}) {
- Object.assign(this,{root,status,refresh,visible,ready});this.busy=false;this.frame=0;this.revision=0;
+ Object.assign(this,{root,status,refresh,visible,ready});this.busy=false;this.frame=0;this.revision=0;this.automaticFailures=0;this.failureVisible=false;
status.onclick=()=>this.update(true);
const resume=()=>{if(!document.hidden)this.update();};
if(automatic){document.addEventListener('visibilitychange',resume);window.addEventListener('focus',resume);}
@@ -44,7 +44,13 @@ class ListSync {
if(this.frame)cancelAnimationFrame(this.frame);this.frame=0;
if(refresh)this.update(true);else{this.setPull(0);this.hide();}
}
- reset(){this.revision++;this.busy=false;this.endPull(true);this.setPull(0);clearTimeout(this.hideTimer);clearTimeout(this.dismissTimer);this.status.classList.remove('list-sync-visible');this.status.hidden=true;}
+ reset(){this.revision++;this.busy=false;this.automaticFailures=0;this.failureVisible=false;this.endPull(true);this.setPull(0);clearTimeout(this.hideTimer);clearTimeout(this.dismissTimer);this.status.classList.remove('list-sync-visible');this.status.hidden=true;}
+ acknowledgeSuccess(){this.automaticFailures=0;if(this.failureVisible){this.failureVisible=false;if(!this.busy)this.hide();}}
+ reportFailure(error,explicit=false){
+ if(error?.status===401){this.failureVisible=false;this.hide();return;}
+ this.automaticFailures++;
+ if(explicit||this.automaticFailures>=3||!this.root.children?.length){this.failureVisible=true;this.show('列表暂未更新,点击重试');}
+ }
schedule(){clearTimeout(this.timer);this.timer=setTimeout(async()=>{await this.update();this.schedule();},15000);}
async update(explicit=false){
if(this.start||this.busy)return;
@@ -53,8 +59,8 @@ class ListSync {
}
this.busy=true;const revision=this.revision;
if(explicit){this.setPull(32);this.show('正在刷新聊天列表…',32);}
- try{const updated=await this.refresh();if(revision!==this.revision)return;if(updated===false){this.setPull(0);this.hide();return;}if(explicit){this.setPull(0);this.show('聊天列表已更新');this.hideTimer=setTimeout(()=>this.hide(),500);}}
- catch(e){if(revision===this.revision){this.setPull(0);this.show('列表暂未更新,点击重试');}}
+ try{const updated=await this.refresh();if(revision!==this.revision)return;if(updated===false){this.setPull(0);this.hide();return;}const hadFailure=this.failureVisible;this.acknowledgeSuccess();if(explicit){this.setPull(0);this.show('聊天列表已更新');this.hideTimer=setTimeout(()=>this.hide(),500);}else if(hadFailure)this.hide();}
+ catch(e){if(revision===this.revision){this.setPull(0);this.reportFailure(e,explicit);}}
finally{if(revision===this.revision)this.busy=false;}
}
}
diff --git a/web/features/clients/desktop-sessions.js b/web/features/clients/desktop-sessions.js
index 2608db7b..7d9ebde3 100644
--- a/web/features/clients/desktop-sessions.js
+++ b/web/features/clients/desktop-sessions.js
@@ -132,8 +132,8 @@ class DesktopSessionsView {
const task=this.sharedRead(provider,'list');if(this.listRead?.task===task&&this.listRead.generation===generation)return this.listRead.promise;
const reading={task,generation};this.listRead=reading;
reading.promise=(async()=>{
- try{const value=await task.promise;if(!task.current()||generation!==this.generation)return false;this.rows=value.sessions||[];this.updateActivity(this.rows,value.connected!==false);this.listStatus.textContent=value.connected===false?BridgeI18n.t('尚未连接'):this.rows.length?'':BridgeI18n.t('电脑上还没有聊天');this.renderConnectionStatus();this.renderList();this.rememberView();this.onListReady?.(provider,value);if(value.connected===false)throw Error(BridgeI18n.t('尚未连接'));return true;}
- catch(error){if(!task.current()||generation!==this.generation)return false;this.updateActivity(this.rows,false);this.renderIndicators();if(!this.renderConnectionStatus())this.listStatus.textContent=BridgeI18n.t(error.message);if(this.sid&&!this.state)this.error.textContent=this.listStatus.textContent;throw error;}
+ try{const value=await task.promise;if(!task.current()||generation!==this.generation)return false;this.rows=value.sessions||[];this.updateActivity(this.rows,value.connected!==false);this.listStatus.textContent=value.connected===false?BridgeI18n.t('尚未连接'):this.rows.length?'':BridgeI18n.t('电脑上还没有聊天');this.renderConnectionStatus();this.renderList();this.rememberView();this.onListReady?.(provider,value);if(value.connected===false)throw Error(BridgeI18n.t('尚未连接'));this.listSync.acknowledgeSuccess();return true;}
+ catch(error){if(!task.current()||generation!==this.generation)return false;this.updateActivity(this.rows,false);this.renderIndicators();if(!this.renderConnectionStatus()){this.listStatus.textContent=this.rows.length?'':BridgeI18n.t(error.message);if(!this.listSync.busy)this.listSync.reportFailure(error);}if(this.sid&&!this.state)this.error.textContent=BridgeI18n.t(error.message);throw error;}
finally{if(this.listRead===reading)this.listRead=null;}
})();return reading.promise;
}
diff --git a/web/shell/app.js b/web/shell/app.js
index c33b4e22..b823cedc 100644
--- a/web/shell/app.js
+++ b/web/shell/app.js
@@ -155,7 +155,7 @@ async function loadList(reset=false){
listRows=[...rows.values()];listRows.sort((a,b)=>b.recency-a.recency);
listOffset=offset+result.sessions.length;listHasMore=result.sessions.length===100;
$('host-errors').textContent=(result.unavailableHosts||[]).map(h=>h.label+':'+h.error).join('\n');
- $('host-errors').hidden=!(result.unavailableHosts||[]).length;renderList();refreshActivity();clientNavigation.acknowledgeList('codex',result);
+ $('host-errors').hidden=!(result.unavailableHosts||[]).length;renderList();refreshActivity();clientNavigation.acknowledgeList('codex',result);listSync.acknowledgeSuccess();
}
function loadMoreSessions(){
const root=$('sessions');
From 7570cf36959a2c3cba81c477a2092622da599303 Mon Sep 17 00:00:00 2001
From: try2love <1929706911@qq.com>
Date: Tue, 13 Oct 2026 02:23:48 +0800
Subject: [PATCH 11/31] Preserve pending reads through cookie name migration
---
.../io/github/try2love/codexbridge/EventClient.java | 5 +++--
mobile/tests/EventClientTests.java | 10 +++++++++-
2 files changed, 12 insertions(+), 3 deletions(-)
diff --git a/mobile/android/src/io/github/try2love/codexbridge/EventClient.java b/mobile/android/src/io/github/try2love/codexbridge/EventClient.java
index 72940a5b..b00594a2 100644
--- a/mobile/android/src/io/github/try2love/codexbridge/EventClient.java
+++ b/mobile/android/src/io/github/try2love/codexbridge/EventClient.java
@@ -20,6 +20,7 @@ static final class Read {
}
static String sessionCookie(String header){if(header!=null)for(String part:header.split(";"))if(part.trim().startsWith("codex_mobile_session="))return part.trim();return "";}
static String sessionCookie(String header,String origin){String wanted=cookieName(origin)+"=";if(header!=null)for(String part:header.split(";"))if(part.trim().startsWith(wanted))return part.trim();return "";}
+ static String tokenValue(String cookie){return cookie.isEmpty()?"":cookie.substring(cookie.indexOf('=')+1);}
static synchronized void forget(String origin){CookieManager store=CookieManager.getInstance();store.setCookie(origin+"/",cookieName(origin)+"=; Path="+GatewayURL.cookiePath(origin)+"; Max-Age=0");store.flush();names.remove(origin);}
static synchronized void renew(HttpURLConnection connection,String origin,String sent){
CookieManager store=CookieManager.getInstance();String previous=sessionCookie(sent,origin);
@@ -38,14 +39,14 @@ static JSONObject read(String origin)throws Exception {
}
static JSONObject read(String origin,Long after,Read pending)throws Exception {
if(!GatewayURL.origin(origin).equals(origin))throw new IOException("连接地址无效");
- String cookie=CookieManager.getInstance().getCookie(origin+"/");if(sessionCookie(cookie,origin).isEmpty())throw new IOException("请先登录电脑网关");
+ String cookie=CookieManager.getInstance().getCookie(origin+"/"),binding=sessionCookie(cookie,origin);if(binding.isEmpty())throw new IOException("请先登录电脑网关");
HttpURLConnection c=(HttpURLConnection)new URL(origin+"/api/mobile/events"+(after==null?"":"?after="+Math.max(0,after))).openConnection();
c.setInstanceFollowRedirects(false);c.setConnectTimeout(7000);c.setReadTimeout(10000);c.setRequestProperty("Cookie",cookie);c.setRequestProperty("Origin",GatewayURL.httpOrigin(origin));c.setRequestProperty("User-Agent",USER_AGENT);
try{if(pending!=null)pending.attach(c);int code=c.getResponseCode();if(code==401||code==403)throw new IOException("登录已失效,请重新连接电脑");if(code==404)throw new IOException("请使用配套的电脑端 Preview 网关");if(code!=200)throw new IOException("暂时无法读取提醒,请检查连接");
if(pending!=null)pending.check();
renew(c,origin,cookie);
ByteArrayOutputStream out=new ByteArrayOutputStream();try(InputStream in=c.getInputStream()){byte[] b=new byte[8192];int n;while((n=in.read(b))!=-1){if(pending!=null)pending.check();if(out.size()+n>512000)throw new IOException("通知数据过大");out.write(b,0,n);}}
- if(pending!=null){pending.check();if(!sessionCookie(cookie,origin).equals(sessionCookie(CookieManager.getInstance().getCookie(origin+"/"),origin)))throw new IOException("登录已失效,请重新连接电脑");}
+ if(pending!=null){pending.check();if(!tokenValue(binding).equals(tokenValue(sessionCookie(CookieManager.getInstance().getCookie(origin+"/"),origin))))throw new IOException("登录已失效,请重新连接电脑");}
return new JSONObject(out.toString("UTF-8"));
}finally{if(pending!=null)pending.detach();c.disconnect();}
}
diff --git a/mobile/tests/EventClientTests.java b/mobile/tests/EventClientTests.java
index f21bb3af..76388b46 100644
--- a/mobile/tests/EventClientTests.java
+++ b/mobile/tests/EventClientTests.java
@@ -18,7 +18,8 @@ public static void main(String[] args)throws Exception {
if(mode.get().equals("replaced"))cookies.value="codex_mobile_session=newer-binding";
if(mode.get().equals("removed"))cookies.value="";
String attributes=mode.get().equals("domain")?"; Domain=127.0.0.1; Path=/":mode.get().equals("path")?"; Path=/other":"; Path=/";
- exchange.getResponseHeaders().set("Set-Cookie","codex_mobile_session=legacy-binding"+attributes+"; HttpOnly; SameSite=Strict; Max-Age=34560000");
+ String name=mode.get().equals("migration")||mode.get().equals("scoped")?"codex_mobile_session_0123456789abcdef01234567":"codex_mobile_session";
+ exchange.getResponseHeaders().set("Set-Cookie",name+"=legacy-binding"+attributes+"; HttpOnly; SameSite=Strict; Max-Age=34560000");
byte[] body="{}".getBytes(StandardCharsets.UTF_8);exchange.sendResponseHeaders(status,body.length);exchange.getResponseBody().write(body);exchange.close();
});server.start();
try{
@@ -29,6 +30,13 @@ public static void main(String[] args)throws Exception {
check(cookies.lastSet.contains("Max-Age=34560000"),"Renewal reaches the WebView cookie store");
check(cookies.flushes==1,"Renewal is persisted before returning");
}
+ cookies.reset();EventClient.setName(origin,"codex_mobile_session");mode.set("migration");
+ EventClient.read(origin,0L,new EventClient.Read());
+ check(cookies.value.startsWith("codex_mobile_session_0123456789abcdef01234567="),"Valid legacy token migrates during pending read");
+ cookies.reset();cookies.value="codex_mobile_session_0123456789abcdef01234567=legacy-binding";mode.set("scoped");
+ EventClient.read(origin,0L,new EventClient.Read());
+ check(cookies.lastSet.startsWith("codex_mobile_session_0123456789abcdef01234567="),"Scoped renewal preserves binding");
+ EventClient.setName(origin,"codex_mobile_session");
for(String state:new String[]{"denied","replaced","removed","domain","path"}){
cookies.reset();mode.set(state);
try{EventClient.read(origin);if(state.equals("denied"))throw new AssertionError("Unauthorized request accepted");}catch(java.io.IOException expected){if(!state.equals("denied"))throw expected;}
From 49bcc3542ecbe5643276cfed40f0e0ad77aee453 Mon Sep 17 00:00:00 2001
From: try2love <1929706911@qq.com>
Date: Tue, 13 Oct 2026 02:24:44 +0800
Subject: [PATCH 12/31] Dismiss recovered list error after nested success
---
tests/list-sync.test.cjs | 6 ++++++
web/features/chat/list-sync.js | 10 +++++-----
2 files changed, 11 insertions(+), 5 deletions(-)
diff --git a/tests/list-sync.test.cjs b/tests/list-sync.test.cjs
index f1354811..96f1ff27 100644
--- a/tests/list-sync.test.cjs
+++ b/tests/list-sync.test.cjs
@@ -81,6 +81,12 @@ test('manual failure clears on another authoritative success and auth failure us
ui.setRefresh(async()=>{throw Object.assign(Error('login'),{status:401});});await ui.sync.update(true);assert.equal(ui.status.hidden,false);ui.advance(200);assert.equal(ui.status.hidden,true);
});
+test('automatic success hides an old error when the list reader acknowledges success before returning',async()=>{
+ const ui=fixture();ui.root.children.push({});ui.setRefresh(async()=>{throw Error('offline');});await ui.sync.update(true);assert.equal(ui.status.hidden,false);
+ ui.setRefresh(async()=>{ui.sync.acknowledgeSuccess();});await ui.sync.update();ui.advance(200);
+ assert.equal(ui.status.hidden,true);
+});
+
test('stale refresh results never dismiss current feedback or claim success',async()=>{
const ui=fixture();let finish;ui.setRefresh(()=>new Promise(resolve=>finish=resolve));const stale=ui.sync.update(true);ui.sync.reset();ui.setRefresh(async()=>{throw Error('new failure');});await ui.sync.update(true);finish(false);await stale;
assert.equal(ui.status.hidden,false);assert.match(ui.status.textContent,/点击重试/);
diff --git a/web/features/chat/list-sync.js b/web/features/chat/list-sync.js
index 1bfc89d7..9e0563e0 100644
--- a/web/features/chat/list-sync.js
+++ b/web/features/chat/list-sync.js
@@ -44,8 +44,8 @@ class ListSync {
if(this.frame)cancelAnimationFrame(this.frame);this.frame=0;
if(refresh)this.update(true);else{this.setPull(0);this.hide();}
}
- reset(){this.revision++;this.busy=false;this.automaticFailures=0;this.failureVisible=false;this.endPull(true);this.setPull(0);clearTimeout(this.hideTimer);clearTimeout(this.dismissTimer);this.status.classList.remove('list-sync-visible');this.status.hidden=true;}
- acknowledgeSuccess(){this.automaticFailures=0;if(this.failureVisible){this.failureVisible=false;if(!this.busy)this.hide();}}
+ reset(){this.revision++;this.busy=false;this.automaticFailures=0;this.failureVisible=false;this.recoveredWhileBusy=false;this.endPull(true);this.setPull(0);clearTimeout(this.hideTimer);clearTimeout(this.dismissTimer);this.status.classList.remove('list-sync-visible');this.status.hidden=true;}
+ acknowledgeSuccess(){this.automaticFailures=0;if(this.failureVisible){this.failureVisible=false;if(this.busy)this.recoveredWhileBusy=true;else this.hide();}}
reportFailure(error,explicit=false){
if(error?.status===401){this.failureVisible=false;this.hide();return;}
this.automaticFailures++;
@@ -57,10 +57,10 @@ class ListSync {
if(document.hidden||!this.visible()||!this.ready()||(!explicit&&this.root.scrollTop>24)){
if(explicit){this.setPull(0);this.hide();}return;
}
- this.busy=true;const revision=this.revision;
+ this.busy=true;this.recoveredWhileBusy=false;const revision=this.revision;
if(explicit){this.setPull(32);this.show('正在刷新聊天列表…',32);}
- try{const updated=await this.refresh();if(revision!==this.revision)return;if(updated===false){this.setPull(0);this.hide();return;}const hadFailure=this.failureVisible;this.acknowledgeSuccess();if(explicit){this.setPull(0);this.show('聊天列表已更新');this.hideTimer=setTimeout(()=>this.hide(),500);}else if(hadFailure)this.hide();}
+ try{const updated=await this.refresh();if(revision!==this.revision)return;if(updated===false){this.setPull(0);this.hide();return;}const hadFailure=this.failureVisible;this.acknowledgeSuccess();if(explicit){this.setPull(0);this.show('聊天列表已更新');this.hideTimer=setTimeout(()=>this.hide(),500);}else if(hadFailure||this.recoveredWhileBusy)this.hide();}
catch(e){if(revision===this.revision){this.setPull(0);this.reportFailure(e,explicit);}}
- finally{if(revision===this.revision)this.busy=false;}
+ finally{if(revision===this.revision){this.busy=false;this.recoveredWhileBusy=false;}}
}
}
From 4dcd21695de5801adac8dc5709e499b25cf594b3 Mon Sep 17 00:00:00 2001
From: try2love <1929706911@qq.com>
Date: Tue, 13 Oct 2026 02:25:14 +0800
Subject: [PATCH 13/31] Honor signed Claude detach and guard async connector
handoff
---
bridge/clients/claude/adapter.py | 9 ++-
bridge/clients/claude/connector.js | 76 +++++++++++--------
bridge/platforms/macos/claude-helper.swift | 12 +--
bridge/platforms/macos/claude.py | 2 +
bridge/platforms/windows/claude-helper.cs | 2 +-
bridge/platforms/windows/claude.py | 4 +-
tests/claude-connector-lifecycle.test.cjs | 86 ++++++++++++++++++++++
tests/claude-connector-owner.test.cjs | 8 +-
tests/claude-connector.test.cjs | 2 +-
tests/desktop-sessions-ui.test.cjs | 2 +-
tests/test_claude_session.py | 11 ++-
tests/test_claude_setup.py | 12 ++-
web/shared/i18n.js | 2 +-
13 files changed, 176 insertions(+), 52 deletions(-)
create mode 100644 tests/claude-connector-lifecycle.test.cjs
diff --git a/bridge/clients/claude/adapter.py b/bridge/clients/claude/adapter.py
index e6a5657f..95b2975f 100644
--- a/bridge/clients/claude/adapter.py
+++ b/bridge/clients/claude/adapter.py
@@ -97,6 +97,7 @@ async def initialize():
asyncio.run_coroutine_threadsafe(initialize(), self.loop).result(timeout=5)
config = {'transport': 'file', 'cwd': str(self.directory), 'token': token,
'generation': self.desktop.generation, 'reconnect': True,
+ 'inspectionMarker': secrets.token_hex(8),
'connectorRevision': CONNECTOR_REVISION,
'request': 'request.json', 'response': 'response.json'}
source = Path(__file__).with_name('connector.js').read_text(encoding='utf-8')
@@ -107,7 +108,7 @@ async def initialize():
console.write_text(console_source(source, config), encoding='utf-8')
console.chmod(0o600)
return {'workspace': str(self.directory), 'scriptPath': str(target), 'script': script,
- 'consolePath': str(console)}
+ 'consolePath': str(console), 'inspectionMarker': config['inspectionMarker']}
def _setup_state(self, state, reason, **values):
with self.setup_lock:
@@ -495,7 +496,8 @@ def _connect_native(self, restart, cancel_path, cancel=None, *, existing_only=Fa
consoleCleanupPending=pending)
return
if time.monotonic() >= inspect_at:
- inspection = native_action('inspect-error', pid=pid, executable=executable, cancelled=cancel)
+ inspection = native_action('inspect-error', pid=pid, executable=executable,
+ script=prepared.get('inspectionMarker'), cancelled=cancel)
if inspection.get('setupState') == 'owner-conflict':
self._attempt_state(cancel, 'owner-conflict', inspection['reason'])
return
@@ -503,7 +505,8 @@ def _connect_native(self, restart, cancel_path, cancel=None, *, existing_only=Fa
cancel.wait(.25)
else:
if not cancel.is_set():
- result = native_action('inspect-error', pid=pid, executable=executable, cancelled=cancel)
+ result = native_action('inspect-error', pid=pid, executable=executable,
+ script=prepared.get('inspectionMarker'), cancelled=cancel)
if result.get('setupState') in DESKTOP_WAIT_STATES:
if sys.platform == 'win32':
self._attempt_state(cancel, result['setupState'], result.get('reason', 'Claude 连接未就绪'))
diff --git a/bridge/clients/claude/connector.js b/bridge/clients/claude/connector.js
index f24b62c2..12ba0ba3 100644
--- a/bridge/clients/claude/connector.js
+++ b/bridge/clients/claude/connector.js
@@ -16,8 +16,9 @@
const sameWorkspace = cwd => typeof cwd === 'string' &&
normalizedWorkspace(cwd) === normalizedWorkspace(config.cwd);
const existing = window.__claudeMobileBridge;
+ let sameOwner = false;
if (existing) {
- let state, sameOwner = false;
+ let state;
try {
state = existing.status();
if (typeof existing.owns === 'function') sameOwner = existing.owns(config.cwd) === true;
@@ -30,18 +31,19 @@
sameOwner = sameWorkspace(session?.cwd);
}
} catch { /* Unknown ownership is occupied, never permission to replace. */ }
- if (!state || state.busy || state.writing || state.stopped !== true && !sameOwner) {
+ if (!state || state.busy || state.writing || !sameOwner && state.suspended !== true) {
const message = sameOwner ? 'Claude 桥接仍有请求进行中,请稍后重试连接' :
- 'Claude 桥接连接被其他网关占用,请先停止旧网关或重新加载 Claude';
+ 'Claude 桥接连接被其他网关占用,请先停止旧网关;旧版连接还需重新加载 Claude';
window.__codingRemoteInjectionError = message;
- console.error('Error: ' + message);
+ console.error('Error: ' + message + ' [' + (config.inspectionMarker || config.generation) + ']');
throw new Error(message);
}
}
function replaceExisting() {
if (!existing) return;
const state = existing.status();
- if (window.__claudeMobileBridge !== existing || state.busy || state.writing)
+ if (!state || window.__claudeMobileBridge !== existing || state.busy || state.writing ||
+ !sameOwner && state.suspended !== true)
throw new Error('Claude 桥接状态已变化,请刷新后重试连接');
existing.stop();
}
@@ -83,13 +85,28 @@
capabilities[kind].push('mobileCatalog', 'mobileProgress', 'mobileAccount');
if(kind==='code')capabilities[kind].push('mobileList');
if(typeof api[surface]?.getSession==='function'&&typeof api[surface]?.getTranscript==='function')capabilities[kind].push('mobileDetail');
- const target = api[surface], original = target?.setAvailableCodeModels;
- if (typeof original === 'function') {
- const observer = function (models, ...rest) {
- if (Array.isArray(models)) offeredModels.set(target, models);
- return original.call(target, models, ...rest);
- };
- try { target.setAvailableCodeModels = observer; restore.push(() => { if (target.setAvailableCodeModels === observer) target.setAvailableCodeModels = original; }); } catch {}
+ }
+ function installObservers() {
+ for (const surface of Object.values(surfaces)) {
+ const target = api[surface], original = target?.setAvailableCodeModels;
+ if (typeof original === 'function') {
+ const observer = function (models, ...rest) {
+ if (Array.isArray(models)) offeredModels.set(target, models);
+ return original.call(target, models, ...rest);
+ };
+ try { target.setAvailableCodeModels = observer; restore.push(() => { if (target.setAvailableCodeModels === observer) target.setAvailableCodeModels = original; }); } catch {}
+ }
+ }
+ }
+ function activate(bridge) {
+ replaceExisting();
+ try {
+ installObservers();
+ window.__claudeMobileBridge = bridge;
+ delete window.__codingRemoteInjectionError;
+ } catch (error) {
+ for (const undo of restore) undo();
+ throw error;
}
}
async function mobileCatalog(target, kind, sessionId, includeSkills=true) {
@@ -239,19 +256,18 @@
return {contextUsage:null,contextUsageStatus:'unavailable'};
}
if (config.transport === 'cdp') {
- let stopped = false, highWater = 0;
- replaceExisting();
- delete window.__codingRemoteInjectionError;
- window.__claudeMobileBridge = {
+ let stopped = false, busy = false, highWater = 0;
+ activate({
stop() { stopped = true; for (const undo of restore) undo(); },
owns(cwd) { return sameWorkspace(cwd); },
- status() { return {transport:'cdp', stopped, connectorRevision:config.connectorRevision, surfaces:capabilities}; },
+ status() { return {transport:'cdp', stopped, busy, connectorRevision:config.connectorRevision, surfaces:capabilities}; },
async invoke(msg) {
if (stopped || msg?.token !== config.token || msg.generation !== config.generation)
return {error:'连接已过期'};
if (!Number.isSafeInteger(msg.seq) || msg.seq <= highWater) return {error:'请求已处理,不会重复执行'};
highWater = msg.seq;
if (typeof msg.expires !== 'number' || msg.expires < Date.now()/1000) return {error:'请求已过期'};
+ busy = true;
try {
const target = api[surfaces[msg.surface]];
if (!target || !Array.isArray(msg.args)) throw new Error('不支持的桌面操作');
@@ -263,8 +279,9 @@
if (!allowed.has(msg.method) || typeof target[msg.method] !== 'function') throw new Error('不支持的桌面操作');
return {result:sanitize(msg.method, await target[msg.method](...callArgs(msg.method,msg.args)))};
} catch (error) { return {error:String(error.message || error)}; }
+ finally { busy = false; }
}
- };
+ });
return;
}
const initial = await read();
@@ -303,21 +320,21 @@
wakeRequested=false;
};
let state = {seq: 0, done: false};
- replaceExisting();
- delete window.__codingRemoteInjectionError;
- window.__claudeMobileBridge = {stop() { stopped = true;wakePoll(); for (const undo of restore) undo(); },
+ activate({stop() { stopped = true;wakePoll(); for (const undo of restore) undo(); },
owns(cwd) { return sameWorkspace(cwd); },
- status() { return {transport: 'file', stopped, busy, writing, sessionId, lastWrite, connectorRevision:config.connectorRevision}; }};
+ status() { return {transport: 'file', stopped, suspended, busy, writing, sessionId, lastWrite, connectorRevision:config.connectorRevision}; }});
async function publish() {
- let value = {...state, generation, connected: !stopped && !suspended,
- timestamp: Date.now() / 1000, connectorRevision:config.connectorRevision, surfaces: capabilities};
- let text = await pack(value);
- if (encoder.encode(text).length > 32 * 1024 * 1024) {
- state = {seq: state.seq, done: true, error: '会话内容超过 32 MiB,请在桌面查看'};
- text = await pack({...value, ...state, result: undefined});
- }
writing = true;
try {
+ let value = {...state, generation, connected: !stopped && !suspended,
+ timestamp: Date.now() / 1000, connectorRevision:config.connectorRevision, surfaces: capabilities};
+ let text = await pack(value);
+ if (stopped) return;
+ if (encoder.encode(text).length > 32 * 1024 * 1024) {
+ state = {seq: state.seq, done: true, error: '会话内容超过 32 MiB,请在桌面查看'};
+ text = await pack({...value, ...state, result: undefined});
+ }
+ if (stopped) return;
const result = await api.LocalSessions.writeSessionFile(sessionId, config.response, text);
if (!result?.hash) throw new Error('Claude 拒绝写入桥接文件,请检查目录信任和文件权限');
lastWrite = Date.now();
@@ -333,6 +350,7 @@
await new Promise(resolve => setTimeout(resolve, 1000));
continue;
}
+ if (stopped) break;
if (msg && msg.generation !== generation) {
// A signed idle packet is the only restart handshake. Never replay a request.
if (!config.reconnect || generations.has(msg.generation) || msg.type !== 'idle' || msg.seq !== 0) {
diff --git a/bridge/platforms/macos/claude-helper.swift b/bridge/platforms/macos/claude-helper.swift
index 66829aae..75fb3784 100644
--- a/bridge/platforms/macos/claude-helper.swift
+++ b/bridge/platforms/macos/claude-helper.swift
@@ -328,14 +328,14 @@ final class Connector {
if find(root, matching: isPrompt) != nil { try fail("needs-attention", "Claude 已连接;开发者工具未自动关闭,请手动关闭") }
output("connected", "Claude 已连接")
}
- func inspectError() {
- let conflict = find(root, matching: {
+ func inspectError(marker: String?) {
+ let conflict = marker.flatMap { expected in find(root, matching: {
guard text($0, kAXRoleAttribute) == "AXStaticText" else { return false }
let value = text($0, kAXValueAttribute).isEmpty ? text($0, kAXTitleAttribute) : text($0, kAXValueAttribute)
- return value.contains("Claude 桥接连接被其他网关占用")
- })
+ return value.contains("Claude 桥接连接被其他网关占用") && value.contains("[\(expected)]")
+ }) }
if conflict != nil {
- output("owner-conflict", "Claude 桥接连接被其他网关占用,请先停止旧网关或重新加载 Claude")
+ output("owner-conflict", "Claude 桥接连接被其他网关占用,请先停止旧网关;旧版连接还需重新加载 Claude")
return
}
let error = find(root, matching: {
@@ -410,7 +410,7 @@ do {
if args[0] == "--enable-devtools" { try connector.developerMode() }
else if args[0] == "--connect" && args.count == 5 { try connector.inject(args[3]) }
else if args[0] == "--close-devtools" { try connector.closeConsole() }
- else if args[0] == "--inspect-error" { connector.inspectError() }
+ else if args[0] == "--inspect-error" { connector.inspectError(marker: args.count > 3 ? args[3] : nil) }
else { try fail("needs-attention", "连接组件参数无效") }
}
}
diff --git a/bridge/platforms/macos/claude.py b/bridge/platforms/macos/claude.py
index 55397111..3c67ad91 100644
--- a/bridge/platforms/macos/claude.py
+++ b/bridge/platforms/macos/claude.py
@@ -56,6 +56,8 @@ def helper_command(helper, action, pid, executable, script, cancel_path):
args += [str(pid), str(executable)]
if action == 'connect':
args += [str(script), str(cancel_path)]
+ elif action == 'inspect-error' and script:
+ args += [str(script)]
return args, {}
diff --git a/bridge/platforms/windows/claude-helper.cs b/bridge/platforms/windows/claude-helper.cs
index 4494e116..c8cada4a 100644
--- a/bridge/platforms/windows/claude-helper.cs
+++ b/bridge/platforms/windows/claude-helper.cs
@@ -1305,7 +1305,7 @@ [STAThread] static int Main(string[] args) {
var texts=AutomationElement.FromHandle(WaitAppWindow(process,false)).FindAll(TreeScope.Descendants,new PropertyCondition(AutomationElement.ControlTypeProperty,ControlType.Text));
foreach(AutomationElement item in texts) {
string value=item.Current.Name;
- if(value.Contains("Claude 桥接连接被其他网关占用")) {Stage("OWNER_CONFLICT");return 0;}
+ if(args.Length==4 && value.Contains("Claude 桥接连接被其他网关占用") && value.Contains("["+args[3]+"]")) {Stage("OWNER_CONFLICT");return 0;}
if((value.StartsWith("Error:")||value.StartsWith("Uncaught"))&&(value.Contains("尚未信任目录")||value.Contains("WorkspaceTrustError")||value.Contains("trust_required"))) {Stage("NEEDS_TRUST");return 0;}
}
Stage("NO_TRUST_ERROR");return 0;
diff --git a/bridge/platforms/windows/claude.py b/bridge/platforms/windows/claude.py
index 7a81606c..f9a7088f 100644
--- a/bridge/platforms/windows/claude.py
+++ b/bridge/platforms/windows/claude.py
@@ -101,6 +101,8 @@ def desktop_blocked():
return {'setupState': 'ready'}
argument = '--connect-background='+str(script) if background else str(script) if action == 'connect' else '--'+action
args = [str(helper), str(pid), argument, str(executable)]
+ if action == 'inspect-error' and script:
+ args.append(str(script))
if cancel_path:
args.append(str(cancel_path))
options = {'creationflags': getattr(subprocess, 'CREATE_NO_WINDOW', 0)}
@@ -193,7 +195,7 @@ def desktop_blocked():
if action == 'inspect-error':
if 'OWNER_CONFLICT' in stdout:
return {'setupState': 'owner-conflict',
- 'reason': 'Claude 桥接连接被其他网关占用,请先停止旧网关或重新加载 Claude'}
+ 'reason': 'Claude 桥接连接被其他网关占用,请先停止旧网关;旧版连接还需重新加载 Claude'}
return {'setupState': 'needs-trust' if 'NEEDS_TRUST' in stdout else 'failed',
'reason': '请在 Claude Code 中确认连接目录信任,然后重新连接'}
return {'setupState': 'needs-developer-mode' if action == 'enable-devtools' else 'submitted',
diff --git a/tests/claude-connector-lifecycle.test.cjs b/tests/claude-connector-lifecycle.test.cjs
new file mode 100644
index 00000000..1609d1c3
--- /dev/null
+++ b/tests/claude-connector-lifecycle.test.cjs
@@ -0,0 +1,86 @@
+'use strict';
+const test=require('node:test'),assert=require('node:assert/strict'),fs=require('node:fs'),vm=require('node:vm');
+const {webcrypto,createHmac}=require('node:crypto');
+const template=fs.readFileSync('bridge/clients/claude/connector.js','utf8');
+const config=(cwd,generation)=>({cwd,request:'request.json',response:'response.json',token:generation,generation,reconnect:true,connectorRevision:5,inspectionMarker:generation});
+const packet=(cfg,value)=>{const payload=JSON.stringify({generation:cfg.generation,...value});return JSON.stringify({payload,signature:createHmac('sha256',cfg.token).update(payload).digest('hex')});};
+const waitFor=async predicate=>{const until=Date.now()+3000;while(!predicate()){if(Date.now()>until)throw Error('timeout');await new Promise(resolve=>setTimeout(resolve,10));}};
+function runtime(configs){
+ const requests=new Map(configs.map(cfg=>[cfg.cwd,packet(cfg,{type:'idle',seq:0})]));
+ const writes=[],calls=[];let delayRead=false,readHeld=false,releaseRead,delayWrite=false,releaseWrite,delaySign=false,signHeld=false,releaseSign;
+ const original=function(models){return models;};
+ const api={setAvailableCodeModels:original,
+ async getAll(){return configs.map((cfg,i)=>({sessionId:'session-'+i,cwd:cfg.cwd,title:'Codex Bridge Connector'}));},
+ async readFileAtCwd(cwd){if(delayRead){delayRead=false;readHeld=true;return new Promise(resolve=>{releaseRead=contents=>resolve({contents:contents||requests.get(cwd)});});}return {contents:requests.get(cwd)};},
+ async writeSessionFile(id,path,text){if(delayWrite)return new Promise(resolve=>{releaseWrite=()=>{writes.push(JSON.parse(JSON.parse(text).payload));resolve({hash:'ok'});};});writes.push(JSON.parse(JSON.parse(text).payload));return {hash:'ok'};},
+ async sendMessage(...args){calls.push(args);}
+ };
+ const window={'claude.web':{LocalSessions:api}};
+ const crypto={subtle:{importKey:(...args)=>webcrypto.subtle.importKey(...args),verify:(...args)=>webcrypto.subtle.verify(...args),
+ sign:(...args)=>delaySign?(delaySign=false,signHeld=true,new Promise(resolve=>{releaseSign=()=>webcrypto.subtle.sign(...args).then(resolve);})):webcrypto.subtle.sign(...args)}};
+ const context={window,crypto,TextEncoder,Uint8Array,Date,setTimeout,clearTimeout,console:{info(){},error(){}}};
+ const inject=cfg=>vm.runInNewContext(template.replace('__BRIDGE_CONFIG__',JSON.stringify(cfg)),context);
+ return {window,api,writes,calls,requests,original,inject,delayNextRead(){delayRead=true;},readPending(){return readHeld;},releaseRead(contents){releaseRead(contents);},delayNextSign(){delaySign=true;},signPending(){return signHeld;},releaseSign(){releaseSign();},delayNextWrite(){delayWrite=true;},releaseWrite(){delayWrite=false;releaseWrite();}};
+}
+test('signed stop detaches one gateway and permits a different workspace to connect',async()=>{
+ const first=config('/gateway-a','first'),second=config('/gateway-b','second'),ui=runtime([first,second]);
+ const oldRunning=ui.inject(first);await waitFor(()=>ui.writes.some(row=>row.generation==='first'&&row.connected));
+ const old=ui.window.__claudeMobileBridge,oldObserver=ui.api.setAvailableCodeModels;
+ assert.notEqual(oldObserver,ui.original);
+ ui.requests.set(first.cwd,packet(first,{type:'stop',seq:1}));
+ await waitFor(()=>old.status().suspended&&!old.status().writing);
+ assert.equal(ui.writes.at(-1).connected,false);
+ const newRunning=ui.inject(second);await waitFor(()=>ui.window.__claudeMobileBridge?.owns(second.cwd));
+ assert.equal(old.status().stopped,true);assert.notEqual(ui.api.setAvailableCodeModels,oldObserver);
+ ui.window.__claudeMobileBridge.stop();await Promise.all([oldRunning,newRunning]);
+ assert.equal(ui.api.setAvailableCodeModels,ui.original,'observer restores Desktop original after transfer');
+});
+test('foreign conflict leaves the existing model observer untouched',async()=>{
+ const first=config('/gateway-a','first'),second=config('/gateway-b','second'),ui=runtime([first,second]);
+ const oldRunning=ui.inject(first);await waitFor(()=>ui.writes.length>0);
+ const old=ui.window.__claudeMobileBridge,observer=ui.api.setAvailableCodeModels;
+ await assert.rejects(ui.inject(second),/其他网关占用/);
+ assert.equal(ui.window.__claudeMobileBridge,old);assert.equal(ui.api.setAvailableCodeModels,observer);
+ old.stop();await oldRunning;assert.equal(ui.api.setAvailableCodeModels,ui.original);
+});
+test('replacement while old read is pending cannot dispatch its late request',async()=>{
+ const cfg=config('/same','same'),ui=runtime([cfg]);
+ const oldRunning=ui.inject(cfg);await waitFor(()=>ui.writes.length>0);
+ const old=ui.window.__claudeMobileBridge;
+ ui.delayNextRead();await waitFor(()=>ui.readPending());
+ const newRunning=ui.inject(cfg);await waitFor(()=>ui.window.__claudeMobileBridge!==old);
+ ui.releaseRead(packet(cfg,{type:'request',seq:1,surface:'code',method:'sendMessage',args:['session-0','late'],expires:Date.now()/1000+10}));await oldRunning;await new Promise(resolve=>setTimeout(resolve,30));
+ assert.equal(ui.calls.length,0);
+ ui.window.__claudeMobileBridge.stop();await newRunning;
+});
+test('a response write in progress blocks replacement and direct stop prevents a later write',async()=>{
+ const cfg=config('/same','same'),ui=runtime([cfg]);
+ const oldRunning=ui.inject(cfg);await waitFor(()=>ui.writes.length>0);
+ const old=ui.window.__claudeMobileBridge,baseline=ui.writes.length;
+ ui.delayNextWrite();ui.requests.set(cfg.cwd,packet(cfg,{type:'request',seq:1,surface:'code',method:'getAll',args:[],expires:0}));
+ await waitFor(()=>old.status().writing&&typeof ui.releaseWrite==='function');
+ await assert.rejects(ui.inject(cfg),/请求进行中/);
+ assert.equal(ui.window.__claudeMobileBridge,old);
+ old.stop();ui.releaseWrite();await oldRunning;
+ assert.equal(ui.writes.length,baseline+1,'an already submitted write completes, with no follow-up publish');
+});
+test('response signing is covered by the write guard and direct stop drops its result',async()=>{
+ const cfg=config('/same','same'),ui=runtime([cfg]);
+ const oldRunning=ui.inject(cfg);await waitFor(()=>ui.writes.length>0);
+ const old=ui.window.__claudeMobileBridge,baseline=ui.writes.length;
+ ui.delayNextSign();ui.requests.set(cfg.cwd,packet(cfg,{type:'request',seq:1,surface:'code',method:'getAll',args:[],expires:0}));
+ await waitFor(()=>ui.signPending()&&old.status().writing);
+ await assert.rejects(ui.inject(cfg),/请求进行中/);
+ old.stop();ui.releaseSign();await oldRunning;
+ assert.equal(ui.writes.length,baseline,'stopped connector must not submit a response after signing');
+});
+test('CDP invocation advertises pending work and blocks same-owner replacement',async()=>{
+ const cfg={...config('/same','same'),transport:'cdp'},ui=runtime([cfg]);
+ await ui.inject(cfg);const old=ui.window.__claudeMobileBridge;
+ let finish;ui.api.getAll=()=>new Promise(resolve=>{finish=()=>resolve([]);});
+ const pending=old.invoke({token:cfg.token,generation:cfg.generation,seq:1,expires:Date.now()/1000+10,surface:'code',method:'getAll',args:[]});
+ await waitFor(()=>old.status().busy);
+ await assert.rejects(ui.inject(cfg),/请求进行中/);
+ assert.equal(ui.window.__claudeMobileBridge,old);
+ finish();await pending;assert.equal(old.status().busy,false);old.stop();
+});
diff --git a/tests/claude-connector-owner.test.cjs b/tests/claude-connector-owner.test.cjs
index 1630c0cd..cfc885d7 100644
--- a/tests/claude-connector-owner.test.cjs
+++ b/tests/claude-connector-owner.test.cjs
@@ -45,10 +45,8 @@ test('pending request or response write blocks even same-owner replacement',asyn
assert.equal(ui.counts().stopped,0);
}
});
-test('explicitly stopped foreign connector can be replaced',async()=>{
+test('legacy stopped flag alone does not prove a signed release',async()=>{
const ui=fixture({transport:'file',stopped:true,busy:false,sessionId:'legacy-session'});
- const running=ui.run();
- for(let i=0;i<50&&!ui.window.__claudeMobileBridge?.owns;i++)await new Promise(resolve=>setTimeout(resolve,10));
- assert.equal(ui.counts().stopped,1);
- ui.window.__claudeMobileBridge.stop();await running;
+ await assert.rejects(ui.run(),/其他网关占用/);
+ assert.equal(ui.counts().stopped,0);
});
diff --git a/tests/claude-connector.test.cjs b/tests/claude-connector.test.cjs
index 9a74b26d..ef358ada 100644
--- a/tests/claude-connector.test.cjs
+++ b/tests/claude-connector.test.cjs
@@ -138,6 +138,6 @@ const waitFor = async predicate => {
await waitFor(()=>response.seq===seq&&response.done);
assert.equal(response.result.contextUsage,null);assert.equal(response.result.contextUsageStatus,'unsupported');
window.__claudeMobileBridge.stop(); await running;
- assert.equal(response.connected,false);
+ assert.equal(window.__claudeMobileBridge.status().stopped,true);
console.log('Desktop file connector: handshake, original session dispatch, deduplication, same-generation reinjection without replay, expiry, allowlist, redaction, restart, replay rejection, stop passed.');
})().catch(e=>{window.__claudeMobileBridge?.stop();console.error(e);process.exitCode=1;});
diff --git a/tests/desktop-sessions-ui.test.cjs b/tests/desktop-sessions-ui.test.cjs
index 3b6f2664..95dd8ca1 100644
--- a/tests/desktop-sessions-ui.test.cjs
+++ b/tests/desktop-sessions-ui.test.cjs
@@ -55,7 +55,7 @@ test('prepared Claude lists offer separate reconnect and reinitialization action
await ui.view.connectionInitialize.click();assert.deepEqual(initializations,['claude']);
ui.view.setClientStates([{...client,operationUncertain:true}],undefined,()=>{throw Error('unexpected initialize');},undefined,()=>{throw Error('unexpected reconnect');});
assert.equal(ui.view.connectionReconnect.hidden,true);assert.equal(ui.view.connectionInitialize.hidden,true);
- ui.view.setClientStates([{...client,setupStatus:'owner-conflict',reason:'Claude 桥接连接被其他网关占用,请先停止旧网关或重新加载 Claude'}]);
+ ui.view.setClientStates([{...client,setupStatus:'owner-conflict',reason:'Claude 桥接连接被其他网关占用,请先停止旧网关;旧版连接还需重新加载 Claude'}]);
assert.match(ui.view.listStatus.textContent,/其他网关占用/);assert.equal(ui.view.connectionReconnect.hidden,false);
});
diff --git a/tests/test_claude_session.py b/tests/test_claude_session.py
index fd3d8891..8802d48a 100644
--- a/tests/test_claude_session.py
+++ b/tests/test_claude_session.py
@@ -36,10 +36,17 @@ def test_windows_owner_conflict_receipt_has_actionable_state(self):
process = Mock(returncode=0)
process.communicate.return_value = ('OWNER_CONFLICT', '')
with patch.object(claude_setup, 'helper_path', return_value=Path(__file__)), \
- patch.object(claude_setup.subprocess, 'Popen', return_value=process):
- result = claude_setup.native_action('inspect-error', pid=42)
+ patch.object(claude_setup.subprocess, 'Popen', return_value=process) as popen:
+ result = claude_setup.native_action('inspect-error', pid=42, script='attempt-marker')
self.assertEqual(result['setupState'], 'owner-conflict')
self.assertIn('旧网关', result['reason'])
+ self.assertEqual(popen.call_args.args[0][-1], 'attempt-marker')
+
+ def test_macos_error_inspection_receives_only_the_current_attempt_marker(self):
+ from bridge.platforms.macos.claude import helper_command
+ command, _ = helper_command(Path('helper'), 'inspect-error', 42, Path('Claude'),
+ 'attempt-marker', None)
+ self.assertEqual(command[-1], 'attempt-marker')
def test_locked_check_and_connect_do_not_start_helper(self):
self.locked()
diff --git a/tests/test_claude_setup.py b/tests/test_claude_setup.py
index c0306afd..05de29cb 100644
--- a/tests/test_claude_setup.py
+++ b/tests/test_claude_setup.py
@@ -178,7 +178,8 @@ def legacy_open(path, mode='r', buffering=-1, encoding=None, errors=None, newlin
prepared = adapter.prepare()
source = (ROOT/'bridge/clients/claude/connector.js').read_text(encoding='utf-8')
config = {'transport': 'file', 'cwd': str(adapter.directory), 'token': adapter.desktop.token,
- 'generation': adapter.desktop.generation, 'reconnect': True, 'connectorRevision': 5,
+ 'generation': adapter.desktop.generation, 'reconnect': True,
+ 'inspectionMarker': prepared['inspectionMarker'], 'connectorRevision': 5,
'request': 'request.json', 'response': 'response.json'}
self.assertEqual(Path(prepared['scriptPath']).read_text(encoding='utf-8'),
source.replace('__BRIDGE_CONFIG__', json.dumps(config)))
@@ -511,7 +512,7 @@ def native(action, **kwargs):
return {'setupState': 'connected'}
if action == 'inspect-error':
if conflict:
- return {'setupState': 'owner-conflict', 'reason': 'Claude 桥接连接被其他网关占用,请先停止旧网关或重新加载 Claude'}
+ return {'setupState': 'owner-conflict', 'reason': 'Claude 桥接连接被其他网关占用,请先停止旧网关;旧版连接还需重新加载 Claude'}
return {'setupState': 'needs-trust' if trust else 'failed', 'reason': 'trust required'}
return {'setupState': 'ready'}
with patch('bridge.clients.claude.adapter.native_action', side_effect=native), \
@@ -794,6 +795,13 @@ def test_reset_preserves_token_but_rejects_previous_signed_generation(self):
self.assertNotIn('\n', source)
self.assertIn(self.adapter.desktop.generation, source)
+ def test_each_console_injection_has_a_fresh_error_inspection_marker(self):
+ self.addCleanup(self.adapter.close)
+ first = self.adapter.prepare()
+ second = self.adapter.prepare()
+ self.assertNotEqual(first['inspectionMarker'], second['inspectionMarker'])
+ self.assertIn(second['inspectionMarker'], Path(second['consolePath']).read_text(encoding='utf-8'))
+
def test_transformed_console_script_preserves_file_protocol_behavior(self):
source = (ROOT/'bridge/clients/claude/connector.js').read_text(encoding='utf-8')
config = {'cwd': 'D:/fixture', 'request': 'request.json', 'response': 'response.json',
diff --git a/web/shared/i18n.js b/web/shared/i18n.js
index c7952083..3ce9c012 100644
--- a/web/shared/i18n.js
+++ b/web/shared/i18n.js
@@ -195,7 +195,7 @@ const en={
"尝试重连":"Try reconnecting",
"重新初始化连接":"Reinitialize connection",
"连接被其他网关占用":"Connection used by another gateway",
-"Claude 桥接连接被其他网关占用,请先停止旧网关或重新加载 Claude":"Another gateway is using Claude's bridge. Stop the old gateway or reload Claude first.",
+"Claude 桥接连接被其他网关占用,请先停止旧网关;旧版连接还需重新加载 Claude":"Another gateway is using Claude's bridge. Stop the old gateway first; legacy connections also require reloading Claude.",
"开始初始化":"Start initialization",
"会打开电脑上的 Claude 和开发者工具,并使用键盘焦点;请暂停电脑端操作,完成后恢复后台连接。":"This opens Claude and its developer tools on your computer and uses keyboard focus. Pause your work on the computer; the connection will return to the background when finished.",
"通过 Claude 原生菜单退出,菜单可能短暂出现;如有任务或保存确认,请在电脑端处理。完全退出后需重新初始化连接,初始化会使用电脑前台和键盘焦点。":"Quit through Claude's native menu. The menu may appear briefly; handle any task or save confirmation on your computer. After quitting completely, the connection requires initialization again, using the computer's foreground window and keyboard focus.",
From 35e6d05ab60a2498840212ddf51d968831802a32 Mon Sep 17 00:00:00 2001
From: try2love <1929706911@qq.com>
Date: Tue, 13 Oct 2026 02:26:52 +0800
Subject: [PATCH 14/31] Test same-host gateway cookie and logout isolation
---
tests/test_login_security.py | 20 ++++++++++++++++++++
1 file changed, 20 insertions(+)
diff --git a/tests/test_login_security.py b/tests/test_login_security.py
index 331becf3..aae1c9c0 100644
--- a/tests/test_login_security.py
+++ b/tests/test_login_security.py
@@ -219,6 +219,26 @@ def test_legacy_cookie_migrates_only_if_owned_by_this_gateway(self):
self.assertFalse(self.request('/api/auth', headers={'Cookie': mixed})[2]['authenticated'])
self.assertTrue(self.request('/api/auth', headers={'Cookie': legacy})[2]['authenticated'])
+ def test_two_same_host_gateways_keep_separate_cookies_and_logout(self):
+ first = self.server.auth
+ with tempfile.TemporaryDirectory(dir=ROOT / '.tmp') as directory:
+ second = Auth(CONFIG, directory)
+ self.assertNotEqual(first.cookie_name, second.cookie_name)
+ one, _ = first.new_session('127.0.0.1')
+ two, _ = second.new_session('127.0.0.1')
+ jar = f'{first.cookie_name}={one}; {second.cookie_name}={two}'
+ _, _, auth_one = self.request('/api/auth', headers={'Cookie': jar})
+ self.assertTrue(auth_one['authenticated'])
+ self.server.auth = second
+ _, _, auth_two = self.request('/api/auth', headers={'Cookie': jar})
+ self.assertTrue(auth_two['authenticated'])
+ self.server.auth = first
+ _, headers, _ = self.request('/api/logout', {}, {'Cookie': jar, 'X-CSRF-Token': auth_one['csrf']})
+ self.assertTrue(headers['Set-Cookie'].startswith(first.cookie_name + '=;'))
+ self.assertFalse(self.request('/api/auth', headers={'Cookie': jar})[2]['authenticated'])
+ self.server.auth = second
+ self.assertTrue(self.request('/api/auth', headers={'Cookie': jar})[2]['authenticated'])
+
def test_mobile_http_stays_signed_in_and_success_clears_attempts(self):
headers = {'User-Agent': 'BridgeMobile/0.1-Android'}
self.assertFalse(self.request('/api/auth', headers=headers)[2]['authenticated'])
From bd85e3021354bcaacbb90ce98dd29d182304f394 Mon Sep 17 00:00:00 2001
From: try2love <1929706911@qq.com>
Date: Tue, 13 Oct 2026 02:27:42 +0800
Subject: [PATCH 15/31] Keep Claude ownership while native calls remain
unsettled
---
bridge/clients/claude/connector.js | 21 +++++++-----
tests/claude-connector-lifecycle.test.cjs | 42 +++++++++++++++++++----
2 files changed, 48 insertions(+), 15 deletions(-)
diff --git a/bridge/clients/claude/connector.js b/bridge/clients/claude/connector.js
index 12ba0ba3..8d155f4f 100644
--- a/bridge/clients/claude/connector.js
+++ b/bridge/clients/claude/connector.js
@@ -256,18 +256,18 @@
return {contextUsage:null,contextUsageStatus:'unavailable'};
}
if (config.transport === 'cdp') {
- let stopped = false, busy = false, highWater = 0;
+ let stopped = false, inFlight = 0, highWater = 0;
activate({
stop() { stopped = true; for (const undo of restore) undo(); },
owns(cwd) { return sameWorkspace(cwd); },
- status() { return {transport:'cdp', stopped, busy, connectorRevision:config.connectorRevision, surfaces:capabilities}; },
+ status() { return {transport:'cdp', stopped, busy:inFlight > 0, connectorRevision:config.connectorRevision, surfaces:capabilities}; },
async invoke(msg) {
if (stopped || msg?.token !== config.token || msg.generation !== config.generation)
return {error:'连接已过期'};
if (!Number.isSafeInteger(msg.seq) || msg.seq <= highWater) return {error:'请求已处理,不会重复执行'};
highWater = msg.seq;
if (typeof msg.expires !== 'number' || msg.expires < Date.now()/1000) return {error:'请求已过期'};
- busy = true;
+ inFlight++;
try {
const target = api[surfaces[msg.surface]];
if (!target || !Array.isArray(msg.args)) throw new Error('不支持的桌面操作');
@@ -279,7 +279,7 @@
if (!allowed.has(msg.method) || typeof target[msg.method] !== 'function') throw new Error('不支持的桌面操作');
return {result:sanitize(msg.method, await target[msg.method](...callArgs(msg.method,msg.args)))};
} catch (error) { return {error:String(error.message || error)}; }
- finally { busy = false; }
+ finally { inFlight--; }
}
});
return;
@@ -308,7 +308,7 @@
if (!sessionId) throw new Error('无法创建文件桥接会话;请在 Code 页面确认目录信任');
let generation = initial.generation;
const generations = new Set([generation]);
- let stopped = false, suspended = initial.type === 'stop', busy = false, writing = false, highWater = initial.seq, lastWrite = 0, dirty = true;
+ let stopped = false, suspended = initial.type === 'stop', busy = false, inFlight = 0, writing = 0, highWater = initial.seq, lastWrite = 0, dirty = true;
let wake, wakeRequested=false;
const wakePoll=()=>{wakeRequested=true;wake?.();};
const pause=async()=>{
@@ -322,9 +322,10 @@
let state = {seq: 0, done: false};
activate({stop() { stopped = true;wakePoll(); for (const undo of restore) undo(); },
owns(cwd) { return sameWorkspace(cwd); },
- status() { return {transport: 'file', stopped, suspended, busy, writing, sessionId, lastWrite, connectorRevision:config.connectorRevision}; }});
+ status() { return {transport: 'file', stopped, suspended, busy:busy || inFlight > 0,
+ writing:writing > 0, sessionId, lastWrite, connectorRevision:config.connectorRevision}; }});
async function publish() {
- writing = true;
+ writing++;
try {
let value = {...state, generation, connected: !stopped && !suspended,
timestamp: Date.now() / 1000, connectorRevision:config.connectorRevision, surfaces: capabilities};
@@ -338,7 +339,7 @@
const result = await api.LocalSessions.writeSessionFile(sessionId, config.response, text);
if (!result?.hash) throw new Error('Claude 拒绝写入桥接文件,请检查目录信任和文件权限');
lastWrite = Date.now();
- } finally { writing = false; }
+ } finally { writing--; }
}
console.info('Claude 文件桥接启动中;状态:window.__claudeMobileBridge.status()');
try {
@@ -384,6 +385,10 @@
}
return sanitize(msg.method, await target[msg.method](...callArgs(msg.method,msg.args)));
});
+ // The deadline settles the gateway reply, not the Desktop call.
+ // Retain ownership until the underlying call itself settles.
+ inFlight++;
+ operation.then(() => { inFlight--; }, () => { inFlight--; });
bounded(operation,Math.max(100,Math.min(90000,(msg.expires-Date.now()/1000)*1000)),'桌面操作超时,结果待核对;不会重复执行').then(result => { if (generation === dispatchedGeneration) state = {seq: msg.seq, done: true, result}; },
error => { if (generation === dispatchedGeneration) state = {seq: msg.seq, done: true, error: String(error.message || error)}; })
.finally(() => { if (generation === dispatchedGeneration) { busy = false; dirty = true;wakePoll(); } });
diff --git a/tests/claude-connector-lifecycle.test.cjs b/tests/claude-connector-lifecycle.test.cjs
index 1609d1c3..afa85824 100644
--- a/tests/claude-connector-lifecycle.test.cjs
+++ b/tests/claude-connector-lifecycle.test.cjs
@@ -5,7 +5,7 @@ const template=fs.readFileSync('bridge/clients/claude/connector.js','utf8');
const config=(cwd,generation)=>({cwd,request:'request.json',response:'response.json',token:generation,generation,reconnect:true,connectorRevision:5,inspectionMarker:generation});
const packet=(cfg,value)=>{const payload=JSON.stringify({generation:cfg.generation,...value});return JSON.stringify({payload,signature:createHmac('sha256',cfg.token).update(payload).digest('hex')});};
const waitFor=async predicate=>{const until=Date.now()+3000;while(!predicate()){if(Date.now()>until)throw Error('timeout');await new Promise(resolve=>setTimeout(resolve,10));}};
-function runtime(configs){
+function runtime(configs,{fastTimeouts=false}={}){
const requests=new Map(configs.map(cfg=>[cfg.cwd,packet(cfg,{type:'idle',seq:0})]));
const writes=[],calls=[];let delayRead=false,readHeld=false,releaseRead,delayWrite=false,releaseWrite,delaySign=false,signHeld=false,releaseSign;
const original=function(models){return models;};
@@ -18,9 +18,10 @@ function runtime(configs){
const window={'claude.web':{LocalSessions:api}};
const crypto={subtle:{importKey:(...args)=>webcrypto.subtle.importKey(...args),verify:(...args)=>webcrypto.subtle.verify(...args),
sign:(...args)=>delaySign?(delaySign=false,signHeld=true,new Promise(resolve=>{releaseSign=()=>webcrypto.subtle.sign(...args).then(resolve);})):webcrypto.subtle.sign(...args)}};
- const context={window,crypto,TextEncoder,Uint8Array,Date,setTimeout,clearTimeout,console:{info(){},error(){}}};
+ const context={window,crypto,TextEncoder,Uint8Array,Date,
+ setTimeout:(fn,ms)=>setTimeout(fn,fastTimeouts&&ms===4000?30:ms),clearTimeout,console:{info(){},error(){}}};
const inject=cfg=>vm.runInNewContext(template.replace('__BRIDGE_CONFIG__',JSON.stringify(cfg)),context);
- return {window,api,writes,calls,requests,original,inject,delayNextRead(){delayRead=true;},readPending(){return readHeld;},releaseRead(contents){releaseRead(contents);},delayNextSign(){delaySign=true;},signPending(){return signHeld;},releaseSign(){releaseSign();},delayNextWrite(){delayWrite=true;},releaseWrite(){delayWrite=false;releaseWrite();}};
+ return {window,api,writes,calls,requests,original,inject,delayNextRead(){delayRead=true;},readPending(){return readHeld;},releaseRead(contents){releaseRead(contents);},delayNextSign(){delaySign=true;},signPending(){return signHeld;},releaseSign(){releaseSign();},delayNextWrite(){delayWrite=true;},writePending(){return !!releaseWrite;},releaseWrite(){delayWrite=false;releaseWrite();}};
}
test('signed stop detaches one gateway and permits a different workspace to connect',async()=>{
const first=config('/gateway-a','first'),second=config('/gateway-b','second'),ui=runtime([first,second]);
@@ -58,7 +59,7 @@ test('a response write in progress blocks replacement and direct stop prevents a
const oldRunning=ui.inject(cfg);await waitFor(()=>ui.writes.length>0);
const old=ui.window.__claudeMobileBridge,baseline=ui.writes.length;
ui.delayNextWrite();ui.requests.set(cfg.cwd,packet(cfg,{type:'request',seq:1,surface:'code',method:'getAll',args:[],expires:0}));
- await waitFor(()=>old.status().writing&&typeof ui.releaseWrite==='function');
+ await waitFor(()=>old.status().writing&&ui.writePending());
await assert.rejects(ui.inject(cfg),/请求进行中/);
assert.equal(ui.window.__claudeMobileBridge,old);
old.stop();ui.releaseWrite();await oldRunning;
@@ -77,10 +78,37 @@ test('response signing is covered by the write guard and direct stop drops its r
test('CDP invocation advertises pending work and blocks same-owner replacement',async()=>{
const cfg={...config('/same','same'),transport:'cdp'},ui=runtime([cfg]);
await ui.inject(cfg);const old=ui.window.__claudeMobileBridge;
- let finish;ui.api.getAll=()=>new Promise(resolve=>{finish=()=>resolve([]);});
- const pending=old.invoke({token:cfg.token,generation:cfg.generation,seq:1,expires:Date.now()/1000+10,surface:'code',method:'getAll',args:[]});
+ const finishes=[];ui.api.getAll=()=>new Promise(resolve=>{finishes.push(()=>resolve([]));});
+ const message=seq=>({token:cfg.token,generation:cfg.generation,seq,expires:Date.now()/1000+10,surface:'code',method:'getAll',args:[]});
+ const first=old.invoke(message(1)),second=old.invoke(message(2));
await waitFor(()=>old.status().busy);
await assert.rejects(ui.inject(cfg),/请求进行中/);
assert.equal(ui.window.__claudeMobileBridge,old);
- finish();await pending;assert.equal(old.status().busy,false);old.stop();
+ await waitFor(()=>finishes.length===2);finishes[0]();await first;
+ assert.equal(old.status().busy,true,'one completed invoke cannot clear the other');
+ finishes[1]();await second;assert.equal(old.status().busy,false);old.stop();
+});
+test('file operation remains owned after its display deadline until Desktop settles',async()=>{
+ const cfg=config('/same','same'),ui=runtime([cfg]);
+ const running=ui.inject(cfg);await waitFor(()=>ui.writes.length>0);
+ const old=ui.window.__claudeMobileBridge;let finish;
+ ui.api.getAll=()=>new Promise(resolve=>{finish=()=>resolve([]);});
+ ui.requests.set(cfg.cwd,packet(cfg,{type:'request',seq:1,surface:'code',method:'getAll',args:[],expires:Date.now()/1000+.8}));
+ await waitFor(()=>ui.writes.some(row=>row.seq===1&&row.done&&/超时/.test(row.error)));
+ assert.equal(old.status().busy,true,'timeout must not hide an unsettled native call');
+ await assert.rejects(ui.inject(cfg),/请求进行中/);
+ finish();await waitFor(()=>old.status().busy===false);
+ old.stop();await running;
+});
+test('overlapping timed-out publishes keep the write guard until both settle',async()=>{
+ const cfg=config('/same','same'),ui=runtime([cfg],{fastTimeouts:true});
+ const running=ui.inject(cfg);await waitFor(()=>ui.writes.length>0);
+ const old=ui.window.__claudeMobileBridge,finishes=[];
+ ui.api.writeSessionFile=()=>new Promise(resolve=>{finishes.push(()=>resolve({hash:'ok'}));});
+ ui.requests.set(cfg.cwd,packet(cfg,{type:'request',seq:1,surface:'code',method:'getAll',args:[],expires:0}));
+ await waitFor(()=>finishes.length===2);
+ finishes[0]();await new Promise(resolve=>setTimeout(resolve,10));
+ assert.equal(old.status().writing,true,'first completion cannot clear a later pending write');
+ await assert.rejects(ui.inject(cfg),/请求进行中/);
+ old.stop();finishes[1]();await running;
});
From 01b5d7b4595eb64f5f85e92459f936d29d8a5db8 Mon Sep 17 00:00:00 2001
From: try2love <1929706911@qq.com>
Date: Tue, 13 Oct 2026 02:34:55 +0800
Subject: [PATCH 16/31] Recover native gateway bindings from secure device
storage
---
.../2026-10-13-native-binding-recovery.md | 23 ++++
mobile/android/assets/mobile-session.js | 2 +
.../codexbridge/BindingSelection.java | 16 +++
.../try2love/codexbridge/DeviceBinding.java | 76 +++++++++++
.../try2love/codexbridge/MainActivity.java | 56 ++++++--
mobile/ios/BridgePreview/App.swift | 126 +++++++++++++++++-
mobile/tests/session.test.cjs | 8 ++
scripts/test-ios-binding-recovery.py | 84 ++++++++++++
scripts/test-native-binding-selection.py | 31 +++++
web/shell/app.js | 13 +-
10 files changed, 417 insertions(+), 18 deletions(-)
create mode 100644 .agents/notes/implemented/feature/2026-10-13-native-binding-recovery.md
create mode 100644 mobile/android/src/io/github/try2love/codexbridge/BindingSelection.java
create mode 100644 mobile/android/src/io/github/try2love/codexbridge/DeviceBinding.java
create mode 100644 scripts/test-ios-binding-recovery.py
create mode 100644 scripts/test-native-binding-selection.py
diff --git a/.agents/notes/implemented/feature/2026-10-13-native-binding-recovery.md b/.agents/notes/implemented/feature/2026-10-13-native-binding-recovery.md
new file mode 100644
index 00000000..108eed46
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-10-13-native-binding-recovery.md
@@ -0,0 +1,23 @@
+# Agent Note: 原生设备绑定恢复
+
+Status: implemented
+
+## Problem
+
+手机原生壳的 WebView Cookie 可能在应用升级或系统清理后丢失。服务器仍保留已认证、可撤销的设备会话,但原生壳没有独立的安全副本,用户因此需要重新登录或扫码。
+
+## Decision
+
+两端只备份已通过 `/api/auth` 确认的 `trustedDevice` 会话令牌,按保存的电脑地址索引。Android 使用 AndroidKeyStore 的 AES-GCM 密钥加密副本,密文保存在应用私有偏好设置;iOS 使用 `WhenUnlockedThisDeviceOnly` Keychain 项。令牌仍由网关或中继的现有会话表撤销,不新增密码、二维码或不可撤销的第二套凭据。
+
+WebView 加载前,如 Cookie 缺失或与安全副本不符,原生端用只读、同源、禁止重定向、限时限量的 `/api/auth` 检查当前 Cookie 和安全副本。首次登录时先记录完整 Cookie 快照,待网关返回 `cookieName` 后才选择对应令牌;旧名迁移只接受同一令牌,不因同主机其他网关的 Cookie 误存。当前 Cookie 有效则保留并在正常认证回调中更新副本;安全副本被服务器确认有效且其 Cookie 名称属于该网关时才恢复。无 `cookieName` 的旧直连网关及中继仅接受旧 Cookie 名。明确无效时删除副本,网络错误时保留以供下次尝试。已打开页面收到 401 时,每次失效周期只触发一次原生校验和只读页面重新加载;恢复不重放消息或写请求。异步校验完成时再次检查设备凭据、页面代际和当前 Cookie,避免退出或移除后的迟到结果复活会话。显式退出与移除删除该地址的安全副本及对应 Cookie。
+
+## Alternatives considered
+
+重新保存密码或可复用扫码载荷能够再次登录,但扩大了长期秘密和配对能力。另设恢复令牌及 HTTP 端点可以与登录令牌分离,却需要新的服务器存储、撤销和中继协议;现有原生设备会话已经持久且可撤销,复用它更小。
+
+## Consequences
+
+Cookie 丢失后,仍有效的绑定可在加载登录界面前或已打开页面遇到 401 时恢复;服务器撤销后不能恢复。首次连接会增加一次至多五秒的只读校验,离线时仍进入原有页面错误路径。Android KeyStore 或 iOS Keychain 不可用时不会把明文令牌写入普通偏好设置。真实设备上的存储与系统清理行为仍需设备验证。
+
+相关 Cookie 名称和旧版兼容规则见[网关会话 Cookie 隔离记录](../bug-fix/2026-10-13-gateway-cookie-namespace.md)。历史笔记只有该记录部分重叠;它规定网关命名,本篇规定原生安全副本与恢复时序。
diff --git a/mobile/android/assets/mobile-session.js b/mobile/android/assets/mobile-session.js
index 6839367d..95d965c8 100644
--- a/mobile/android/assets/mobile-session.js
+++ b/mobile/android/assets/mobile-session.js
@@ -2,6 +2,8 @@
if (window !== window.top) return;
const persist = () => window.prompt('codexbridge-session:__BRIDGE_CLIPBOARD_TOKEN__', '');
document.addEventListener('bridge-authenticated', persist);
+ document.addEventListener('bridge-logged-out', () => window.prompt('codexbridge-logout:__BRIDGE_CLIPBOARD_TOKEN__', ''));
+ document.addEventListener('bridge-auth-lost', () => window.prompt('codexbridge-auth-lost:__BRIDGE_CLIPBOARD_TOKEN__', ''));
const app = document.getElementById('app');
if (app && !app.hidden) persist();
})();
diff --git a/mobile/android/src/io/github/try2love/codexbridge/BindingSelection.java b/mobile/android/src/io/github/try2love/codexbridge/BindingSelection.java
new file mode 100644
index 00000000..5dddae9f
--- /dev/null
+++ b/mobile/android/src/io/github/try2love/codexbridge/BindingSelection.java
@@ -0,0 +1,16 @@
+package io.github.try2love.codexbridge;
+
+/** Select only the cookie name confirmed by this gateway's auth response. */
+final class BindingSelection {
+ static boolean name(String value){return value!=null&&value.matches("codex_mobile_session_[a-f0-9]{24}|codex_mobile_session");}
+ static String cookie(String header,String name){
+ if(header!=null&&name(name))for(String part:header.split(";")){String value=part.trim();if(value.startsWith(name+"="))return value;}
+ return "";
+ }
+ static String match(String sent,String current,String name){
+ String selected=cookie(current,name),previous=cookie(sent,name);
+ if(previous.isEmpty()&&!"codex_mobile_session".equals(name))previous=cookie(sent,"codex_mobile_session");
+ if(selected.isEmpty()||previous.isEmpty()||!selected.substring(selected.indexOf('=')+1).equals(previous.substring(previous.indexOf('=')+1)))return "";
+ return selected;
+ }
+}
diff --git a/mobile/android/src/io/github/try2love/codexbridge/DeviceBinding.java b/mobile/android/src/io/github/try2love/codexbridge/DeviceBinding.java
new file mode 100644
index 00000000..a51144b8
--- /dev/null
+++ b/mobile/android/src/io/github/try2love/codexbridge/DeviceBinding.java
@@ -0,0 +1,76 @@
+package io.github.try2love.codexbridge;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.security.keystore.KeyGenParameterSpec;
+import android.security.keystore.KeyProperties;
+import android.util.Base64;
+import java.io.ByteArrayOutputStream;
+import java.io.InputStream;
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+import java.security.KeyStore;
+import javax.crypto.Cipher;
+import javax.crypto.KeyGenerator;
+import javax.crypto.SecretKey;
+import javax.crypto.spec.GCMParameterSpec;
+import org.json.JSONObject;
+
+/** Device-only encrypted copy of a server-revocable native session. */
+final class DeviceBinding {
+ private static final String ALIAS="codex-bridge-device-binding-v1";
+ static final class Credential {
+ final String name,token;
+ Credential(String name,String token){this.name=name;this.token=token;}
+ String cookie(){return name+"="+token;}
+ }
+ static boolean name(String value){return value!=null&&value.matches("codex_mobile_session_[a-f0-9]{24}|codex_mobile_session");}
+ static SharedPreferences prefs(Context context){return context.getSharedPreferences("bridge-bindings",0);}
+ private static SecretKey key()throws Exception {
+ KeyStore store=KeyStore.getInstance("AndroidKeyStore");store.load(null);
+ SecretKey key=(SecretKey)store.getKey(ALIAS,null);
+ if(key!=null)return key;
+ KeyGenerator generator=KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES,"AndroidKeyStore");
+ generator.init(new KeyGenParameterSpec.Builder(ALIAS,KeyProperties.PURPOSE_ENCRYPT|KeyProperties.PURPOSE_DECRYPT)
+ .setBlockModes(KeyProperties.BLOCK_MODE_GCM).setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE).setKeySize(256).build());
+ return generator.generateKey();
+ }
+ static void save(Context context,String origin,String name,String token)throws Exception {
+ if(!GatewayURL.origin(origin).equals(origin)||!name(name)||token==null||!token.matches("[A-Za-z0-9_-]{32,128}"))return;
+ Cipher cipher=Cipher.getInstance("AES/GCM/NoPadding");cipher.init(Cipher.ENCRYPT_MODE,key());
+ byte[] payload=new JSONObject().put("name",name).put("token",token).toString().getBytes(StandardCharsets.UTF_8);
+ byte[] iv=cipher.getIV(),encrypted=cipher.doFinal(payload),combined=new byte[iv.length+encrypted.length];
+ System.arraycopy(iv,0,combined,0,iv.length);System.arraycopy(encrypted,0,combined,iv.length,encrypted.length);
+ prefs(context).edit().putString(origin,Base64.encodeToString(combined,Base64.NO_WRAP)).apply();
+ }
+ static Credential load(Context context,String origin){
+ String encoded=prefs(context).getString(origin,null);if(encoded==null)return null;
+ try{
+ byte[] bytes=Base64.decode(encoded,Base64.NO_WRAP);if(bytes.length<29||bytes.length>1024)return null;
+ Cipher cipher=Cipher.getInstance("AES/GCM/NoPadding");cipher.init(Cipher.DECRYPT_MODE,key(),new GCMParameterSpec(128,bytes,0,12));
+ JSONObject value=new JSONObject(new String(cipher.doFinal(bytes,12,bytes.length-12),StandardCharsets.UTF_8));
+ String name=value.getString("name"),token=value.getString("token");
+ return name(name)&&token.matches("[A-Za-z0-9_-]{32,128}")?new Credential(name,token):null;
+ }catch(Exception invalid){return null;}
+ }
+ static void clear(Context context,String origin){prefs(context).edit().remove(origin).apply();}
+ /** Returns null for a network/transport error, false for server-confirmed invalidity. */
+ static Boolean verify(String origin,Credential credential){
+ HttpURLConnection connection=null;
+ try{
+ if(!GatewayURL.origin(origin).equals(origin))return false;
+ connection=(HttpURLConnection)new URL(origin+"/api/auth").openConnection();
+ connection.setInstanceFollowRedirects(false);connection.setConnectTimeout(4000);connection.setReadTimeout(4000);
+ connection.setRequestProperty("Cookie",credential.cookie());connection.setRequestProperty("Origin",GatewayURL.httpOrigin(origin));connection.setRequestProperty("User-Agent",EventClient.USER_AGENT);
+ if(connection.getResponseCode()!=200||!GatewayURL.sameOrigin(connection.getURL().toString(),origin))return null;
+ ByteArrayOutputStream out=new ByteArrayOutputStream();try(InputStream in=connection.getInputStream()){
+ byte[] bytes=new byte[4096];int n;while((n=in.read(bytes))!=-1){if(out.size()+n>65536)return null;out.write(bytes,0,n);}
+ }
+ JSONObject auth=new JSONObject(out.toString("UTF-8"));
+ if(!auth.optBoolean("authenticated"))return false;
+ String returned=auth.optString("cookieName","");
+ return returned.isEmpty()?credential.name.equals("codex_mobile_session"):returned.equals(credential.name);
+ }catch(Exception unavailable){return null;}finally{if(connection!=null)connection.disconnect();}
+ }
+}
diff --git a/mobile/android/src/io/github/try2love/codexbridge/MainActivity.java b/mobile/android/src/io/github/try2love/codexbridge/MainActivity.java
index cd450dda..e5a32bdf 100644
--- a/mobile/android/src/io/github/try2love/codexbridge/MainActivity.java
+++ b/mobile/android/src/io/github/try2love/codexbridge/MainActivity.java
@@ -30,7 +30,7 @@ public final class MainActivity extends Activity {
private FrameLayout screen;private DownloadCard downloadCard;private ArtifactDownload.Task downloadTask,choosingDownload;private UpdateInstallSession updateSession;private boolean savingDownload,openingDownload,restoringUpdate,autoInstallUpdate;
private String choosingUpdateFile;private Bundle pendingSaveResult;
private GatewayUpdate preparingUpdate;
- private ServiceConnection monitor;private ValueCallback files;private WebView fileView;private int fileGeneration;private int generation;
+ private ServiceConnection monitor;private ValueCallback files;private WebView fileView;private int fileGeneration;private int generation;private boolean authLossAttempted;
String L(String value){return MobileStrings.text(this,value);}
int dp(int n){return (int)(getResources().getDisplayMetrics().density*n);}
TextView text(String value,int size){TextView t=new TextView(this);t.setText(value);t.setTextSize(size);t.setTextColor(0xff202124);t.setPadding(0,dp(5),0,dp(5));return t;}
@@ -43,11 +43,43 @@ public final class MainActivity extends Activity {
void header(boolean chat){bar.setVisibility(chat?View.GONE:View.VISIBLE);if(chat)return;bar.removeAllViews();ImageView logo=new ImageView(this);logo.setImageResource(getResources().getIdentifier("ic_bridge","drawable",getPackageName()));LinearLayout.LayoutParams lp=new LinearLayout.LayoutParams(dp(30),dp(30));lp.setMarginEnd(dp(8));bar.addView(logo,lp);TextView title=text("Codex Bridge",18);title.setTypeface(null,android.graphics.Typeface.BOLD);bar.addView(title,new LinearLayout.LayoutParams(0,-2,1));bar.addView(icon("ic_bell",L("通知收件箱"),this::inbox),new LinearLayout.LayoutParams(dp(48),dp(48)));bar.addView(icon("ic_settings",L("手机设置"),this::settings),new LinearLayout.LayoutParams(dp(48),dp(48)));}
void webAction(String selector){if(web!=null&&GatewayURL.sameOrigin(web.getUrl(),origin))web.evaluateJavascript("document.querySelector("+JSONObject.quote(selector)+")?.click()",null);}
void rememberConnection(){
- final String address=origin;final int ticket=generation;
- worker.execute(()->{try{JSONObject auth=EventClient.request(address,"/api/auth",null,null);String binding=EventClient.sessionCookie(CookieManager.getInstance().getCookie(address+"/"),address);JSONObject data=ConnectionDiscovery.descriptor(auth,binding);
- runOnUiThread(()->{if(ticket!=generation||!binding.equals(EventClient.sessionCookie(CookieManager.getInstance().getCookie(address+"/"),address)))return;if(auth.optBoolean("authenticated"))prefs.edit().putString("cookieName:"+address,EventClient.cookieName(address)).apply();if(data!=null)prefs.edit().putString("connection:"+address,data.toString()).apply();else prefs.edit().remove("connection:"+address).apply();});
+ final String address=origin;final int ticket=generation;final String snapshot=CookieManager.getInstance().getCookie(address+"/");
+ worker.execute(()->{try{JSONObject auth=EventClient.request(address,"/api/auth",null,null);String name=auth.optString("cookieName","codex_mobile_session");String binding=BindingSelection.cookie(CookieManager.getInstance().getCookie(address+"/"),name);JSONObject data=ConnectionDiscovery.descriptor(auth,binding);
+ runOnUiThread(()->{String current=BindingSelection.match(snapshot,CookieManager.getInstance().getCookie(address+"/"),name);
+ if(ticket!=generation||!saved().contains(address)||current.isEmpty()||!binding.equals(current))return;
+ if(auth.optBoolean("authenticated"))prefs.edit().putString("cookieName:"+address,name).apply();
+ if(auth.optBoolean("authenticated")&&auth.optBoolean("trustedDevice")&&DeviceBinding.name(name))try{DeviceBinding.save(this,address,name,EventClient.tokenValue(current));}catch(Exception ignored){}
+ else if(!auth.optBoolean("authenticated"))DeviceBinding.clear(this,address);
+ if(data!=null)prefs.edit().putString("connection:"+address,data.toString()).apply();else prefs.edit().remove("connection:"+address).apply();});
}catch(Exception ignored){}});
}
+ void loadWithRecovery(String address,String url,WebView current,int ticket){
+ final String before=EventClient.sessionCookie(CookieManager.getInstance().getCookie(address+"/"),address);
+ worker.execute(()->{
+ DeviceBinding.Credential saved=DeviceBinding.load(this,address);
+ boolean recover=false;
+ if(saved!=null&&!saved.cookie().equals(before)){
+ Boolean existing=before.isEmpty()?false:DeviceBinding.verify(address,new DeviceBinding.Credential(before.substring(0,before.indexOf('=')),before.substring(before.indexOf('=')+1)));
+ if(Boolean.FALSE.equals(existing)){
+ Boolean valid=DeviceBinding.verify(address,saved);
+ if(Boolean.TRUE.equals(valid))recover=true;
+ else if(Boolean.FALSE.equals(valid)){DeviceBinding.Credential latest=DeviceBinding.load(this,address);if(latest!=null&&latest.cookie().equals(saved.cookie()))DeviceBinding.clear(this,address);}
+ }
+ }
+ final boolean restore=recover;runOnUiThread(()->{
+ if(ticket!=generation||web!=current||!address.equals(origin)||!saved().contains(address))return;
+ DeviceBinding.Credential latest=DeviceBinding.load(this,address);
+ if(!restore||latest==null||!latest.cookie().equals(saved.cookie())||!before.equals(EventClient.sessionCookie(CookieManager.getInstance().getCookie(address+"/"),address))){current.loadUrl(url);return;}
+ EventClient.setName(address,saved.name);
+ CookieManager.getInstance().setCookie(address+"/",saved.cookie()+"; Path="+GatewayURL.cookiePath(address)+"; HttpOnly; SameSite=Strict; Max-Age=34560000"+(address.startsWith("https://")?"; Secure":""),ok->{
+ DeviceBinding.Credential retained=DeviceBinding.load(this,address);
+ if(ticket!=generation||web!=current||!address.equals(origin)||!saved().contains(address)||retained==null||!retained.cookie().equals(saved.cookie())){if(ok&&CookieManager.getInstance().getCookie(address+"/")!=null&&CookieManager.getInstance().getCookie(address+"/").contains(saved.cookie()))CookieManager.getInstance().setCookie(address+"/",saved.name+"=; Path="+GatewayURL.cookiePath(address)+"; Max-Age=0");return;}
+ if(ok){prefs.edit().putString("cookieName:"+address,saved.name).apply();CookieManager.getInstance().flush();MonitorService.bindingsChanged();}
+ current.loadUrl(url);
+ });
+ });
+ });
+ }
void recoverConnection(String requested){
if(routeAttempted||!resumed||web==null)return;routeAttempted=true;
final String address=origin,encoded=prefs.getString("connection:"+address,null),cookie=EventClient.sessionCookie(CookieManager.getInstance().getCookie(address+"/"),address);final int ticket=generation;
@@ -60,7 +92,7 @@ void recoverConnection(String requested){
CookieManager.getInstance().setCookie(next+"/",cookie+"; Path=/; Secure; HttpOnly; SameSite=Strict; Max-Age=34560000",ok->{if(!ok||ticket!=generation||!resumed||routeDiscovery!=resolver||!saved().contains(address)||!cookie.equals(EventClient.sessionCookie(CookieManager.getInstance().getCookie(address+"/"),address)))return;
Set values=saved();values.remove(address);values.add(next);android.content.SharedPreferences.Editor edit=prefs.edit().putStringSet("origins",values).putString("connection:"+next,encoded).remove("connection:"+address);
for(String prefix:new String[]{"name:","computer-name:","cursor:","stream:","last-opened:","cookieName:"}){Object value=prefs.getAll().get(prefix+address);if(value instanceof String)edit.putString(prefix+next,(String)value);else if(value instanceof Long)edit.putLong(prefix+next,(Long)value);edit.remove(prefix+address);}edit.apply();
- EventClient.setName(next,EventClient.cookieName(address));EventClient.forget(address);connect(next+requested.substring(address.length()));pendingStorage=snapshot[0];routeAttempted=true;
+ DeviceBinding.Credential backup=DeviceBinding.load(MainActivity.this,address);if(backup!=null&&backup.cookie().equals(cookie)&&DeviceBinding.load(MainActivity.this,next)==null)try{DeviceBinding.save(MainActivity.this,next,backup.name,backup.token);DeviceBinding.clear(MainActivity.this,address);}catch(Exception ignored){}EventClient.setName(next,EventClient.cookieName(address));EventClient.forget(address);connect(next+requested.substring(address.length()));pendingStorage=snapshot[0];routeAttempted=true;
});};
if(web!=null&&GatewayURL.sameOrigin(web.getUrl(),address))web.evaluateJavascript("(()=>{if(typeof saveDraft==='function')saveDraft();return JSON.stringify(Object.fromEntries(Object.entries(localStorage)));})()",value->{if(ticket!=generation)return;try{Object parsed=new org.json.JSONTokener(value).nextValue();if(parsed instanceof String&&((String)parsed).length()<524288){JSONObject data=new JSONObject((String)parsed);snapshot[0]=data.toString();}}catch(Exception ignored){}relocate.run();});else relocate.run();
});}catch(Exception ignored){}});
@@ -151,7 +183,15 @@ void checkComputers(){
if(view==web&&GatewayURL.sameOrigin(url,origin)&&GatewayURL.sameOrigin(view.getUrl(),origin))result.confirm(computerName(origin));else result.cancel();return true;
}
if(prompt.equals("codexbridge-session:"+clipboardToken)){
- if(view==web&&GatewayURL.sameOrigin(url,origin)&&GatewayURL.sameOrigin(view.getUrl(),origin)){CookieManager.getInstance().flush();MonitorService.bindingsChanged();result.confirm("saved");}else result.cancel();return true;
+ if(view==web&&GatewayURL.sameOrigin(url,origin)&&GatewayURL.sameOrigin(view.getUrl(),origin)){authLossAttempted=false;CookieManager.getInstance().flush();MonitorService.bindingsChanged();rememberConnection();result.confirm("saved");}else result.cancel();return true;
+ }
+ if(prompt.equals("codexbridge-auth-lost:"+clipboardToken)){
+ if(view==web&&view.hasWindowFocus()&&GatewayURL.sameOrigin(url,origin)&&GatewayURL.sameOrigin(view.getUrl(),origin)&&!authLossAttempted){authLossAttempted=true;loadWithRecovery(origin,origin+"/",view,generation);result.confirm("recovering");}else result.cancel();return true;
+ }
+ if(prompt.equals("codexbridge-logout:"+clipboardToken)){
+ if(view==web&&view.hasWindowFocus()&&GatewayURL.sameOrigin(url,origin)&&GatewayURL.sameOrigin(view.getUrl(),origin)){
+ DeviceBinding.clear(MainActivity.this,origin);EventClient.forget(origin);MonitorService.bindingsChanged();result.confirm("cleared");
+ }else result.cancel();return true;
}
if(prompt.equals("codexbridge-language:"+clipboardToken)){
if(view==web&&GatewayURL.sameOrigin(url,origin)&&GatewayURL.sameOrigin(view.getUrl(),origin)&&("en".equals(value)||"zh".equals(value))){prefs.edit().putString("language",value).apply();menu.setContentDescription(L("电脑与聊天选项"));renderDownload();result.confirm("saved");}else result.cancel();return true;
@@ -170,7 +210,7 @@ public boolean onShowFileChooser(WebView view,ValueCallback callback,File
}});
web.setDownloadListener((target,agent,disposition,mime,length)->downloadArtifact(target));
- web.loadUrl(url);monitor();
+ loadWithRecovery(base,url,web,generation);monitor();
}catch(Exception e){message(e.getMessage());}}
void renderDownload(){downloadCard.render(downloadTask,savingDownload||choosingDownload!=null,openingDownload,updateSession!=null);}
void downloadArtifact(String target){
@@ -409,7 +449,7 @@ void requestStartupNotifications(){
void toggleNotifications(){if(prefs.getBoolean("alerts",false)){prefs.edit().putBoolean("alerts",false).apply();return;}if(Build.VERSION.SDK_INT>=33&&checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS)!=PackageManager.PERMISSION_GRANTED){requestPermissions(new String[]{Manifest.permission.POST_NOTIFICATIONS},22);return;}if(!((NotificationManager)getSystemService(NOTIFICATION_SERVICE)).areNotificationsEnabled()){message(L("请在系统设置中允许通知。"));return;}prefs.edit().putBoolean("alerts",true).apply();monitor();}
void testNotification(){if(Build.VERSION.SDK_INT>=33&&checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS)!=PackageManager.PERMISSION_GRANTED){requestPermissions(new String[]{Manifest.permission.POST_NOTIFICATIONS},23);return;}String base=origin;new Handler().postDelayed(()->{try{MonitorService.notifyEvent(this,base,new JSONObject().put("id","test").put("title",L("手机通知测试")).put("body",L("本机通知已开启")));}catch(Exception ignored){}},10000);message(L("10 秒后显示本地测试通知,可以先返回桌面。此测试不代表远程推送已经接通。"));}
void confirmRemove(){confirmRemove(origin);}
- void confirmRemove(String target){new AlertDialog.Builder(this).setTitle(L("移除这台电脑?")).setMessage(target+"\n\n"+L("清除本机保存的连接和登录状态,电脑上的聊天不受影响。")).setNegativeButton(L("取消"),null).setPositiveButton(L("移除"),(d,w)->{if(downloadTask!=null&&downloadTask.origin.equals(target))cancelDownload();final int ticket=generation;worker.execute(()->{try{JSONObject auth=EventClient.request(target,"/api/auth",null,null);if(auth.optBoolean("authenticated"))EventClient.request(target,"/api/logout",new JSONObject(),auth.getString("csrf"));}catch(Exception ignored){/* Offline removal is local; server session expiry still applies. */}runOnUiThread(()->{MonitorService.bindingsChanged();Set values=saved();values.remove(target);EventClient.forget(target);android.content.SharedPreferences.Editor edit=prefs.edit().putStringSet("origins",values).remove("cursor:"+target).remove("stream:"+target).remove("connection:"+target).remove("name:"+target).remove("last-opened:"+target).remove("cookieName:"+target);if(target.equals(prefs.getString("active","")))edit.remove("active");edit.apply();if(target.equals(origin))origin="";if(ticket==generation)home();});});}).show();}
+ void confirmRemove(String target){new AlertDialog.Builder(this).setTitle(L("移除这台电脑?")).setMessage(target+"\n\n"+L("清除本机保存的连接和登录状态,电脑上的聊天不受影响。")).setNegativeButton(L("取消"),null).setPositiveButton(L("移除"),(d,w)->{if(downloadTask!=null&&downloadTask.origin.equals(target))cancelDownload();final int ticket=generation;worker.execute(()->{try{JSONObject auth=EventClient.request(target,"/api/auth",null,null);if(auth.optBoolean("authenticated"))EventClient.request(target,"/api/logout",new JSONObject(),auth.getString("csrf"));}catch(Exception ignored){/* Offline removal is local; server session expiry still applies. */}DeviceBinding.clear(this,target);runOnUiThread(()->{MonitorService.bindingsChanged();Set values=saved();values.remove(target);EventClient.forget(target);android.content.SharedPreferences.Editor edit=prefs.edit().putStringSet("origins",values).remove("cursor:"+target).remove("stream:"+target).remove("connection:"+target).remove("name:"+target).remove("last-opened:"+target).remove("cookieName:"+target);if(target.equals(prefs.getString("active","")))edit.remove("active");edit.apply();if(target.equals(origin))origin="";if(ticket==generation)home();});});}).show();}
void monitor(){if(monitor==null){monitor=new ServiceConnection(){public void onServiceConnected(ComponentName n,IBinder b){}public void onServiceDisconnected(ComponentName n){}};getApplicationContext().bindService(new Intent(this,MonitorService.class),monitor,BIND_AUTO_CREATE);}}
void scan(){if(checkSelfPermission(Manifest.permission.CAMERA)!=PackageManager.PERMISSION_GRANTED){requestPermissions(new String[]{Manifest.permission.CAMERA},21);return;}startActivityForResult(new Intent(this,ScanActivity.class),20);}
@Override public void onRequestPermissionsResult(int code,String[] names,int[] results){super.onRequestPermissionsResult(code,names,results);boolean allowed=results.length>0&&results[0]==PackageManager.PERMISSION_GRANTED;if(code==25){prefs.edit().putBoolean("alerts",allowed).apply();if(allowed)monitor();return;}if(!allowed){message(L(code==21?"未获得权限,可继续粘贴地址使用。":"请在系统设置中允许通知。"));return;}if(code==24)enableNativePush();if(code==21)scan();if(code==23)testNotification();if(code==22){prefs.edit().putBoolean("alerts",true).apply();monitor();}}
diff --git a/mobile/ios/BridgePreview/App.swift b/mobile/ios/BridgePreview/App.swift
index ec634b71..077265e8 100644
--- a/mobile/ios/BridgePreview/App.swift
+++ b/mobile/ios/BridgePreview/App.swift
@@ -3,6 +3,49 @@ import UIKit
import AVFoundation
import UserNotifications
import UniformTypeIdentifiers
+import Security
+
+private enum DeviceBinding {
+ struct Credential: Codable { let name: String; let token: String; var cookie: String { name + "=" + token } }
+ static func valid(_ name: String) -> Bool { name == "codex_mobile_session" || name.range(of: #"^codex_mobile_session_[a-f0-9]{24}$"#, options: .regularExpression) != nil }
+ static func query(_ address: String) -> [String: Any] { [kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: "codex-bridge-device-binding-v1", kSecAttrAccount as String: address] }
+ static func load(_ address: String) -> Credential? {
+ var request = query(address); request[kSecReturnData as String] = true; request[kSecMatchLimit as String] = kSecMatchLimitOne
+ var item: CFTypeRef?; guard SecItemCopyMatching(request as CFDictionary, &item) == errSecSuccess,
+ let data = item as? Data, let value = try? JSONDecoder().decode(Credential.self, from: data), valid(value.name),
+ value.token.range(of: #"^[A-Za-z0-9_-]{32,128}$"#, options: .regularExpression) != nil else { return nil }
+ return value
+ }
+ @discardableResult static func save(_ address: String, _ credential: Credential) -> Bool {
+ guard valid(credential.name), credential.token.range(of: #"^[A-Za-z0-9_-]{32,128}$"#, options: .regularExpression) != nil,
+ let data = try? JSONEncoder().encode(credential) else { return false }
+ var request = query(address); request[kSecValueData as String] = data
+ request[kSecAttrAccessible as String] = kSecAttrAccessibleWhenUnlockedThisDeviceOnly
+ let status = SecItemAdd(request as CFDictionary, nil)
+ if status == errSecDuplicateItem { return SecItemUpdate(query(address) as CFDictionary, [kSecValueData as String: data] as CFDictionary) == errSecSuccess }
+ return status == errSecSuccess
+ }
+ static func clear(_ address: String) { SecItemDelete(query(address) as CFDictionary) }
+ /// nil is a transport error; false is a server-confirmed rejected binding.
+ static func verify(_ address: String, _ credential: Credential) async -> Bool? {
+ guard let url = URL(string: address + "/api/auth") else { return false }
+ let config = URLSessionConfiguration.ephemeral; config.httpShouldSetCookies = false; config.httpCookieStorage = nil; config.urlCredentialStorage = nil
+ config.timeoutIntervalForRequest = 4; config.timeoutIntervalForResource = 5
+ let session = URLSession(configuration: config, delegate: NoRedirect(), delegateQueue: nil)
+ defer { session.invalidateAndCancel() }
+ var request = URLRequest(url: url); request.setValue(credential.cookie, forHTTPHeaderField: "Cookie")
+ request.setValue(GatewayURL.httpOrigin(address), forHTTPHeaderField: "Origin"); request.setValue("BridgeMobile/0.1-iOS", forHTTPHeaderField: "User-Agent")
+ do {
+ let (bytes, response) = try await session.bytes(for: request)
+ guard let http = response as? HTTPURLResponse, http.statusCode == 200, http.url.map({ GatewayURL.same($0, address) }) == true, response.expectedContentLength <= 65536 else { return nil }
+ var body = Data(); for try await byte in bytes { guard body.count < 65536 else { return nil }; body.append(byte) }
+ guard let auth = try JSONSerialization.jsonObject(with: body) as? [String: Any] else { return nil }
+ guard auth["authenticated"] as? Bool == true else { return false }
+ if let name = auth["cookieName"] as? String { return name == credential.name }
+ return credential.name == "codex_mobile_session"
+ } catch { return nil }
+ }
+}
final class BridgeNavigationController: UINavigationController {
override var childForStatusBarStyle: UIViewController? { topViewController }
@@ -110,6 +153,8 @@ final class BridgeController: UIViewController, WKNavigationDelegate, WKUIDelega
private var pollGeneration = 0
private var pollTask: URLSessionDataTask?
private var generation = 0
+ private var authLossAttempted = false
+ private var bindingRevision = 0
private let defaults = UserDefaults.standard
private func cookieName(_ address: String) -> String { defaults.string(forKey: "cookieName:" + address) ?? "codex_mobile_session" }
private func cookieMatches(_ cookie: HTTPCookie, address: String, discover: Bool = false) -> Bool {
@@ -410,17 +455,64 @@ final class BridgeController: UIViewController, WKNavigationDelegate, WKUIDelega
}
}
private func rememberConnection() {
- let address = origin, ticket = generation
+ let address = origin, ticket = generation, revision = bindingRevision
+ WKWebsiteDataStore.default().httpCookieStore.getAllCookies { sentCookies in
+ let sent = sentCookies.filter { self.cookieMatches($0, address: address, discover: true) }
+ guard !sent.isEmpty else { return }
self.fetch(address: address, route: "/api/auth") { result in
guard ticket == self.generation, case .success(let auth) = result else { return }
self.binding(address) { cookie in
guard let cookie, ticket == self.generation else { return }
self.binding(address) { current in
- guard ticket == self.generation, current?.value == cookie.value else { return }
+ let previous = sent.first { $0.name == cookie.name } ?? (cookie.name == "codex_mobile_session" ? nil : sent.first { $0.name == "codex_mobile_session" })
+ guard ticket == self.generation, revision == self.bindingRevision, self.saved.contains(address), current?.name == cookie.name,
+ current?.value == cookie.value, previous?.value == cookie.value else { return }
+ if auth["authenticated"] as? Bool == true, auth["trustedDevice"] as? Bool == true,
+ (auth["cookieName"] as? String ?? "codex_mobile_session") == cookie.name {
+ DeviceBinding.save(address, .init(name: cookie.name, token: cookie.value))
+ } else if auth["authenticated"] as? Bool == false { DeviceBinding.clear(address) }
self.defaults.set(ConnectionDiscovery.descriptor(auth, secret: cookie.value), forKey: "connection:" + address)
}
}
}
+ }
+ }
+ private func loadWithRecovery(_ web: WKWebView, url: URL, address: String, ticket: Int) {
+ Task { @MainActor in
+ guard let backup = DeviceBinding.load(address) else { if ticket == generation && self.web === web { web.load(URLRequest(url: url)) }; return }
+ let store = WKWebsiteDataStore.default().httpCookieStore
+ let cookies = await withCheckedContinuation { continuation in store.getAllCookies { continuation.resume(returning: $0) } }
+ let before = cookies.first { cookieMatches($0, address: address) }
+ if before?.name == backup.name && before?.value == backup.token {
+ if ticket == generation && self.web === web { web.load(URLRequest(url: url)) }; return
+ }
+ if let before {
+ let current = await DeviceBinding.verify(address, .init(name: before.name, token: before.value))
+ if current == true || current == nil { if ticket == generation && self.web === web { web.load(URLRequest(url: url)) }; return }
+ }
+ let valid = await DeviceBinding.verify(address, backup)
+ if valid == false, DeviceBinding.load(address)?.cookie == backup.cookie { DeviceBinding.clear(address) }
+ guard ticket == generation, self.web === web, saved.contains(address) else { return }
+ guard valid == true, DeviceBinding.load(address)?.cookie == backup.cookie,
+ let host = URL(string: address)?.host else { web.load(URLRequest(url: url)); return }
+ var properties: [HTTPCookiePropertyKey: Any] = [.name: backup.name, .value: backup.token, .domain: host,
+ .path: GatewayURL.cookiePath(address), .expires: Date().addingTimeInterval(400 * 86400),
+ HTTPCookiePropertyKey("HttpOnly"): "TRUE", HTTPCookiePropertyKey("SameSite"): "Strict"]
+ if address.hasPrefix("https://") { properties[.secure] = "TRUE" }
+ guard let restored = HTTPCookie(properties: properties) else { web.load(URLRequest(url: url)); return }
+ let latest = await withCheckedContinuation { continuation in store.getAllCookies { continuation.resume(returning: $0) } }
+ guard latest.first(where: { cookieMatches($0, address: address) })?.value == before?.value,
+ DeviceBinding.load(address)?.cookie == backup.cookie else { web.load(URLRequest(url: url)); return }
+ await withCheckedContinuation { continuation in store.setCookie(restored) { continuation.resume() } }
+ guard ticket == generation, self.web === web, saved.contains(address),
+ DeviceBinding.load(address)?.cookie == backup.cookie else {
+ let after = await withCheckedContinuation { continuation in store.getAllCookies { continuation.resume(returning: $0) } }
+ if after.contains(where: { $0.name == restored.name && $0.value == restored.value && $0.domain == restored.domain && $0.path == restored.path }) { await store.deleteCookie(restored) }
+ return
+ }
+ defaults.set(backup.name, forKey: "cookieName:" + address)
+ web.load(URLRequest(url: url))
+ }
}
private func recoverConnection() {
guard !routeAttempted, UIApplication.shared.applicationState == .active, let requested = requestedRoute,
@@ -458,6 +550,8 @@ final class BridgeController: UIViewController, WKNavigationDelegate, WKUIDelega
var all = self.saved.filter { $0 != address }; if !all.contains(next) { all.append(next) }; self.defaults.set(all, forKey: "origins")
self.defaults.set(descriptor, forKey: "connection:" + next); self.defaults.removeObject(forKey: "connection:" + address)
if let name = self.defaults.string(forKey: "cookieName:" + address) { self.defaults.set(name, forKey: "cookieName:" + next) }; self.defaults.removeObject(forKey: "cookieName:" + address)
+ if let backup = DeviceBinding.load(address), backup.name == cookie.name, backup.token == cookie.value,
+ DeviceBinding.load(next) == nil, DeviceBinding.save(next, backup) { DeviceBinding.clear(address) }
for prefix in ["name:", "computer-name:", "cursor:", "stream:", "last-opened:"] {
if let value = self.defaults.object(forKey: prefix + address) { self.defaults.set(value, forKey: prefix + next) }; self.defaults.removeObject(forKey: prefix + address)
}
@@ -484,13 +578,13 @@ final class BridgeController: UIViewController, WKNavigationDelegate, WKUIDelega
configuration.ignoresViewportScaleLimits = false
configuration.userContentController.addUserScript(WKUserScript(source: WebScripts.fixedViewport, injectionTime: .atDocumentEnd, forMainFrameOnly: true))
clipboardToken = UUID().uuidString
- let appearanceScript = scripts.appearance.replacingOccurrences(of: "__BRIDGE_CLIPBOARD_TOKEN__", with: clipboardToken)
+ let appearanceScript = scripts.appearance.replacingOccurrences(of: "__BRIDGE_CLIPBOARD_TOKEN__", with: clipboardToken) + "\ndocument.addEventListener('bridge-authenticated', () => window.prompt('codexbridge-session:" + clipboardToken + "', ''));document.addEventListener('bridge-logged-out', () => window.prompt('codexbridge-logout:" + clipboardToken + "', ''));document.addEventListener('bridge-auth-lost', () => window.prompt('codexbridge-auth-lost:" + clipboardToken + "', ''));"
configuration.userContentController.addUserScript(WKUserScript(source: appearanceScript, injectionTime: .atDocumentEnd, forMainFrameOnly: true))
let clipboardScript = scripts.clipboard.replacingOccurrences(of: "__BRIDGE_CLIPBOARD_TOKEN__", with: clipboardToken)
configuration.userContentController.addUserScript(WKUserScript(source: clipboardScript, injectionTime: .atDocumentEnd, forMainFrameOnly: true))
let systemBarsScript = scripts.systemBars.replacingOccurrences(of: "__BRIDGE_CLIPBOARD_TOKEN__", with: clipboardToken)
configuration.userContentController.addUserScript(WKUserScript(source: systemBarsScript, injectionTime: .atDocumentEnd, forMainFrameOnly: true))
- let web = WKWebView(frame: .zero, configuration: configuration); self.web = web; web.scrollView.contentInsetAdjustmentBehavior = .never; web.navigationDelegate = self; web.uiDelegate = self; web.allowsBackForwardNavigationGestures = true; web.customUserAgent = nil; page.addArrangedSubview(web); gatewayMenuTop = gatewayMenu.topAnchor.constraint(equalTo: web.topAnchor, constant: 4); gatewayMenuTop?.isActive = true; web.load(URLRequest(url: url))
+ let web = WKWebView(frame: .zero, configuration: configuration); self.web = web; web.scrollView.contentInsetAdjustmentBehavior = .never; web.navigationDelegate = self; web.uiDelegate = self; web.allowsBackForwardNavigationGestures = true; web.customUserAgent = nil; page.addArrangedSubview(web); gatewayMenuTop = gatewayMenu.topAnchor.constraint(equalTo: web.topAnchor, constant: 4); gatewayMenuTop?.isActive = true; loadWithRecovery(web, url: url, address: address, ticket: generation)
web.scrollView.pinchGestureRecognizer?.isEnabled = false
}
@objc private func refresh() { routeAttempted = false; web?.reload() }
@@ -538,6 +632,29 @@ final class BridgeController: UIViewController, WKNavigationDelegate, WKUIDelega
let current = webView.url, GatewayURL.same(current, origin) else { completionHandler(nil); return }
completionHandler(computerName(origin)); return
}
+ if prompt == "codexbridge-logout:" + clipboardToken {
+ guard webView === web, UIApplication.shared.applicationState == .active, frame.isMainFrame,
+ let source = frame.request.url, GatewayURL.same(source, origin),
+ let current = webView.url, GatewayURL.same(current, origin) else { completionHandler(nil); return }
+ bindingRevision += 1
+ DeviceBinding.clear(origin)
+ binding(origin) { cookie in if let cookie { WKWebsiteDataStore.default().httpCookieStore.delete(cookie) } }
+ completionHandler("cleared"); return
+ }
+ if prompt == "codexbridge-auth-lost:" + clipboardToken {
+ guard webView === web, UIApplication.shared.applicationState == .active, frame.isMainFrame,
+ let source = frame.request.url, GatewayURL.same(source, origin),
+ let current = webView.url, GatewayURL.same(current, origin), !authLossAttempted,
+ let target = URL(string: origin + "/") else { completionHandler(nil); return }
+ authLossAttempted = true; loadWithRecovery(webView, url: target, address: origin, ticket: generation)
+ completionHandler("recovering"); return
+ }
+ if prompt == "codexbridge-session:" + clipboardToken {
+ guard webView === web, UIApplication.shared.applicationState == .active, frame.isMainFrame,
+ let source = frame.request.url, GatewayURL.same(source, origin),
+ let current = webView.url, GatewayURL.same(current, origin) else { completionHandler(nil); return }
+ authLossAttempted = false; rememberConnection(); completionHandler("saved"); return
+ }
if prompt == "codexbridge-language:" + clipboardToken {
guard webView === web, frame.isMainFrame, let source = frame.request.url, GatewayURL.same(source, origin),
let current = webView.url, GatewayURL.same(current, origin), let language = defaultText, ["zh", "en"].contains(language) else { completionHandler(nil); return }
@@ -969,6 +1086,7 @@ final class BridgeController: UIViewController, WKNavigationDelegate, WKUIDelega
let revision = self.generation
self.downloads.cancel(origin: target)
self.revokeConnection(target) {
+ DeviceBinding.clear(target)
if target == self.origin, #available(iOS 16.2, *) { Task { await LiveActivityController.shared.stop() } }
self.defaults.set(self.saved.filter { $0 != target }, forKey: "origins"); self.computerSnapshots.removeValue(forKey: target)
if self.defaults.string(forKey: "active") == target { self.defaults.removeObject(forKey: "active") }
diff --git a/mobile/tests/session.test.cjs b/mobile/tests/session.test.cjs
index c5a36bd8..2d45073b 100644
--- a/mobile/tests/session.test.cjs
+++ b/mobile/tests/session.test.cjs
@@ -13,3 +13,11 @@ test('Android persists session immediately after login without exposing credenti
test('an already authenticated page persists its renewed session; embedded frames cannot',()=>{
assert.equal(fixture({authenticated:true}).calls.length,1);const f=fixture({frame:true});assert.equal(f.calls.length,0);assert.equal(f.events['bridge-authenticated'],undefined);
});
+test('successful logout asks the native shell to clear only its binding',()=>{
+ const f=fixture();f.events['bridge-logged-out']();
+ assert.deepEqual(f.calls,[['codexbridge-logout:__BRIDGE_CLIPBOARD_TOKEN__','']]);
+});
+test('an authenticated page losing authorization asks for one guarded native recovery',()=>{
+ const f=fixture({authenticated:true});f.events['bridge-auth-lost']();
+ assert.deepEqual(f.calls.at(-1),['codexbridge-auth-lost:__BRIDGE_CLIPBOARD_TOKEN__','']);
+});
diff --git a/scripts/test-ios-binding-recovery.py b/scripts/test-ios-binding-recovery.py
new file mode 100644
index 00000000..b5932dcf
--- /dev/null
+++ b/scripts/test-ios-binding-recovery.py
@@ -0,0 +1,84 @@
+#!/usr/bin/env python3
+"""Compile production iOS recovery validation and exercise bounded local auth responses."""
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+from pathlib import Path
+import subprocess
+import threading
+
+ROOT = Path(__file__).resolve().parents[1]
+WORK = ROOT / '.tmp/ios-binding-tests'
+WORK.mkdir(parents=True, exist_ok=True)
+NAME = 'codex_mobile_session_0123456789abcdef01234567'
+
+
+class Handler(BaseHTTPRequestHandler):
+ mode = 'valid'
+
+ def log_message(self, *_):
+ pass
+
+ def do_GET(self):
+ if self.path.startswith('/control/'):
+ Handler.mode = self.path.split('/')[-1]
+ self.send_response(200); self.send_header('Content-Length', '0'); self.end_headers(); return
+ if Handler.mode == 'redirect':
+ self.send_response(302); self.send_header('Location', '/capture'); self.send_header('Content-Length', '0'); self.end_headers(); return
+ if self.path == '/capture':
+ raise AssertionError('Recovery followed a redirect')
+ relay = self.path.startswith('/d/fixture/')
+ legacy = Handler.mode == 'legacy'
+ selected = 'codex_mobile_session' if relay or legacy else NAME
+ valid = self.headers.get('Cookie') == selected + '=valid-device-binding'
+ if Handler.mode == 'revoked': valid = False
+ name = 'codex_mobile_session_ffffffffffffffffffffffff' if Handler.mode == 'wrong-name' else selected
+ import json
+ body = json.dumps({'authenticated': valid, 'cookieName': name} if not relay and not legacy else {'authenticated': valid, 'relay': relay, 'trustedDevice': True}).encode()
+ if Handler.mode == 'oversize': body += b' ' * 65537
+ self.send_response(200); self.send_header('Content-Length', str(len(body))); self.end_headers(); self.wfile.write(body)
+
+
+server = ThreadingHTTPServer(('127.0.0.1', 0), Handler)
+threading.Thread(target=server.serve_forever, daemon=True).start()
+try:
+ source = (ROOT / 'mobile/ios/BridgePreview/App.swift').read_text()
+ binding = source[source.index('private enum DeviceBinding'):source.index('final class BridgeNavigationController')]
+ redirect = source[source.index('final class NoRedirect:'):source.index('// Foreground-only')]
+ main = '''import Foundation
+import Security
+''' + binding + '\n' + redirect + '''
+let address = CommandLine.arguments[1]
+private let scoped = DeviceBinding.Credential(name: "codex_mobile_session_0123456789abcdef01234567", token: "valid-device-binding")
+func mode(_ value: String) async throws {
+ _ = try await URLSession.shared.data(from: URL(string: address + "/control/" + value)!)
+}
+private func check(_ address: String, _ credential: DeviceBinding.Credential, _ expected: Bool?) async {
+ let actual = await DeviceBinding.verify(address, credential); precondition(actual == expected)
+}
+Task {
+ await check(address, scoped, true)
+ try await mode("wrong-name")
+ await check(address, scoped, false)
+ try await mode("revoked")
+ await check(address, scoped, false)
+ try await mode("redirect")
+ await check(address, scoped, nil)
+ try await mode("oversize")
+ await check(address, scoped, nil)
+ try await mode("valid")
+ let relay = DeviceBinding.Credential(name: "codex_mobile_session", token: "valid-device-binding")
+ await check(address + "/d/fixture", relay, true)
+ try await mode("legacy")
+ await check(address, relay, true)
+ await check(address, scoped, false)
+ print("iOS binding validation passed")
+ exit(0)
+}
+dispatchMain()
+'''
+ (WORK / 'main.swift').write_text(main)
+ subprocess.run(['xcrun', 'swiftc', '-module-cache-path', str(WORK / 'cache'),
+ str(ROOT / 'mobile/ios/BridgePreview/GatewayURL.swift'), str(WORK / 'main.swift'),
+ '-o', str(WORK / 'binding-tests')], check=True)
+ subprocess.run([str(WORK / 'binding-tests'), f'http://127.0.0.1:{server.server_port}'], check=True, timeout=30)
+finally:
+ server.shutdown(); server.server_close()
diff --git a/scripts/test-native-binding-selection.py b/scripts/test-native-binding-selection.py
new file mode 100644
index 00000000..4a5272c1
--- /dev/null
+++ b/scripts/test-native-binding-selection.py
@@ -0,0 +1,31 @@
+#!/usr/bin/env python3
+"""Execute production cookie selection against first-login and stale-response cases."""
+from pathlib import Path
+import os
+import subprocess
+
+root = Path(__file__).resolve().parents[1]
+work = root / '.tmp/native-binding-selection-tests'
+work.mkdir(parents=True, exist_ok=True)
+source = root / 'mobile/android/src/io/github/try2love/codexbridge/BindingSelection.java'
+(work / 'BindingSelectionTests.java').write_text('''package io.github.try2love.codexbridge;
+final class BindingSelectionTests {
+ static void eq(String expected,String actual){if(!expected.equals(actual))throw new AssertionError("expected "+expected+" got "+actual);}
+ public static void main(String[] args){
+ String own="codex_mobile_session_0123456789abcdef01234567";
+ String other="codex_mobile_session_ffffffffffffffffffffffff";
+ String header=other+"=other-token; "+own+"=own-token";
+ eq(own+"=own-token",BindingSelection.match(header,header,own));
+ eq("",BindingSelection.match(other+"=other-token",header,own));
+ eq(own+"=own-token",BindingSelection.match("codex_mobile_session=own-token",header,own));
+ eq("",BindingSelection.match("codex_mobile_session=old-token",header,own));
+ eq("",BindingSelection.match(header,header,"codex_mobile_session_bad"));
+ eq("codex_mobile_session=legacy-token",BindingSelection.match("codex_mobile_session=legacy-token", "codex_mobile_session=legacy-token", "codex_mobile_session"));
+ System.out.println("Native binding cookie selection passed");
+ }
+}
+''')
+java_home = Path(os.environ['JAVA_HOME']) if 'JAVA_HOME' in os.environ else None
+def tool(name): return str(java_home / 'bin' / name) if java_home else name
+subprocess.run([tool('javac'), '-d', str(work), str(source), str(work / 'BindingSelectionTests.java')], check=True)
+subprocess.run([tool('java'), '-cp', str(work), 'io.github.try2love.codexbridge.BindingSelectionTests'], check=True)
diff --git a/web/shell/app.js b/web/shell/app.js
index c190ac4a..fa990a17 100644
--- a/web/shell/app.js
+++ b/web/shell/app.js
@@ -41,22 +41,23 @@ let listLoading=0;
let modelCatalogGeneration=0;let catalogData={models:null,skills:null};let catalogResults={models:null,skills:null};let catalogTasks={models:null,skills:null};let selectedSkills=new Set(),invalidSkills=new Set(),skillIndex=new Map(),skillTotal=0,skillPages=[],skillQuery={q:'',offset:0,limit:200},skillSearchTimer,skillRequest=0;
function el(tag,cls,text){const node=document.createElement(tag);if(cls)node.className=cls;if(text!==undefined)node.textContent=text;return node;}
function toast(text){text=t(text);$('toast').textContent=text;$('toast').hidden=false;clearTimeout(toast.timer);toast.timer=setTimeout(()=>$('toast').hidden=true,4200);}
-async function api(path,body,signal){const options={signal,credentials:'same-origin',cache:'no-store',headers:{}};if(body!==undefined){options.method='POST';options.headers={'Content-Type':'application/json','X-CSRF-Token':csrf};options.body=JSON.stringify(body);}const response=await fetch(hostUrl(path),options);const data=await response.json();if(!response.ok){if(response.status===401)showLogin();throw Object.assign(Error(data.error||t('请求失败')),{loginStatus:data.loginStatus,status:response.status});}return data;}
-const workbench=window.BridgeWorkbench=new Workbench({chat:$('chat'),request:api,csrf:()=>csrf,notify:toast,onUnauthorized:()=>showLogin(),openChat});
-const desktopSessions=new DesktopSessionsView({root:$('app'),select:$('desktop-backend'),request:api,notify:toast,csrf:()=>csrf,onUnauthorized:()=>showLogin(),onSelect:provider=>{
+function authLost(){if(!$('app').hidden)document.dispatchEvent(new Event('bridge-auth-lost'));showLogin();}
+async function api(path,body,signal){const options={signal,credentials:'same-origin',cache:'no-store',headers:{}};if(body!==undefined){options.method='POST';options.headers={'Content-Type':'application/json','X-CSRF-Token':csrf};options.body=JSON.stringify(body);}const response=await fetch(hostUrl(path),options);const data=await response.json();if(!response.ok){if(response.status===401)authLost();throw Object.assign(Error(data.error||t('请求失败')),{loginStatus:data.loginStatus,status:response.status});}return data;}
+const workbench=window.BridgeWorkbench=new Workbench({chat:$('chat'),request:api,csrf:()=>csrf,notify:toast,onUnauthorized:authLost,openChat});
+const desktopSessions=new DesktopSessionsView({root:$('app'),select:$('desktop-backend'),request:api,notify:toast,csrf:()=>csrf,onUnauthorized:authLost,onSelect:provider=>{
if(provider!=='codex'){saveDraft();chatTimeline?.dispose();chatTimeline=null;currentId=null;state=null;workbench.reset();$('chat').hidden=true;$('welcome').hidden=false;$('app').classList.remove('chat-open');history.replaceState(null,'',location.pathname);}
else if(clientNavigation.provider==='codex'&&!$('app').hidden)loadList(true).catch(error=>toast(error.message));
}});
const clientNavigation=new ClientNavigation({view:desktopSessions,request:api,notify:toast});
desktopSessions.onListEnter=()=>{clientNavigation.listReadDone=false;clientNavigation.listVisit=(clientNavigation.listVisit||0)+1;if(clientNavigation.provider!=='codex')desktopSessions.refresh();};
desktopSessions.onListReady=(provider,value)=>clientNavigation.acknowledgeList(provider,value);
-async function uploadAttachment(key,id,file){const separator=key.indexOf('|'),host=key.slice(0,separator),thread=key.slice(separator+1);const response=await fetch(sessionUrl(thread,'uploads',host)+'&id='+encodeURIComponent(id)+'&name='+encodeURIComponent(file.name),{method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/octet-stream','X-CSRF-Token':csrf},body:file});const result=await response.json();if(!response.ok){if(response.status===401)showLogin();const error=Error(result.error||t('附件上传失败,请重试'));error.retryable=response.status>=500||response.status===429;throw error;}return result;}
+async function uploadAttachment(key,id,file){const separator=key.indexOf('|'),host=key.slice(0,separator),thread=key.slice(separator+1);const response=await fetch(sessionUrl(thread,'uploads',host)+'&id='+encodeURIComponent(id)+'&name='+encodeURIComponent(file.name),{method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/octet-stream','X-CSRF-Token':csrf},body:file});const result=await response.json();if(!response.ok){if(response.status===401)authLost();const error=Error(result.error||t('附件上传失败,请重试'));error.retryable=response.status>=500||response.status===429;throw error;}return result;}
async function uploadAttachmentWithRetry(key,id,file){if(window.BridgeSharedRelay)return uploadAttachment(key,id,file);let last;for(let attempt=0;attempt<3;attempt++){try{return await uploadAttachment(key,id,file);}catch(error){last=error;if(error.name==='AbortError'||error.retryable===false||attempt===2)throw error;await new Promise(resolve=>setTimeout(resolve,400*(attempt+1)));}}if(last instanceof TypeError)last.message=t('附件上传中断,请重试');throw last;}
const attachments=new ChatAttachments({root:$('attachment-list'),button:$('attach-button'),input:$('attachment-input'),paste:$('message'),drop:$('composer'),preview:(key,id)=>{const at=key.indexOf('|'),host=key.slice(0,at),thread=key.slice(at+1);return hostUrl(sessionUrl(thread,'uploads/'+encodeURIComponent(id)+'/preview',host));},onChange:()=>{if(currentId)$('send').disabled=sending||!attachments.ready()||!state||state.loadingHistory||state.activating;},thumbnail:async(key,id,file)=>{
const separator=key.indexOf('|'),host=key.slice(0,separator),thread=key.slice(separator+1);
const image=await attachments.thumbnailBlob(file);
const response=await fetch(sessionUrl(thread,'uploads/'+encodeURIComponent(id)+'/thumb',host)+'&width='+image.width+'&height='+image.height,{method:'POST',credentials:'same-origin',headers:{'Content-Type':image.mime,'X-CSRF-Token':csrf},body:image.data});
- const result=await response.json();if(!response.ok){if(response.status===401)showLogin();throw Error(result.error||t('缩略图生成失败'));}return result;
+ const result=await response.json();if(!response.ok){if(response.status===401)authLost();throw Error(result.error||t('缩略图生成失败'));}return result;
},upload:uploadAttachmentWithRetry});
function visibleChat(){return currentId&&!workbench.isFileVisible&&!document.hidden&&$('app').classList.contains('chat-open')?chatKey():null;}
function renderActivity(){for(const button of $('sessions').querySelectorAll('.session')){
@@ -109,7 +110,7 @@ async function openHarness(){
$('harness-open').onclick=openHarness;
async function enter(){desktopSessions.onListEnter();const chatLink=location.hash;$('desktop-backend').hidden=false;if(/(?:^|[?&])open=harness(?:&|$)/.test(location.search||'')){history.replaceState(null,'','/');await openHarness();}refreshComputer();$('login').hidden=true;$('app').hidden=false;$('bootstrap-status').hidden=true;window.BridgeBootstrap?.ready();document.dispatchEvent(new Event('bridge-authenticated'));await clientNavigation.refresh();accountsPanel?.refresh();const list=clientNavigation.provider==='codex'?loadList(true).catch(e=>toast(e.message)):Promise.resolve();await clientNavigation.openChatLink(chatLink,openChat);await list;}
$('login-form').addEventListener('submit',async event=>{event.preventDefault();if(loginStatus.blocked)return;$('login-button').disabled=true;$('login-error').textContent='';try{const result=await api('/api/login',{username:$('username').value,password:$('password').value,remember:$('remember-login').checked});renderLoginStatus({attemptsRemaining:5,attemptLimit:5,blocked:false});csrf=result.csrf;$('password').value='';await enter();}catch(error){$('login-error').textContent=t(error.message);renderLoginStatus(error.loginStatus);}finally{$('login-button').disabled=loginStatus.blocked;}});
-$('logout').onclick=async()=>{await api('/api/logout',{});csrf='';state=null;currentId=null;$('messages').replaceChildren();$('approvals').replaceChildren();$('queued').replaceChildren();$('sessions').replaceChildren();$('message').value='';sessionStorage.clear();attachments.reset();sessionActivity.clear();recentInteractions.clear();showLogin();};
+$('logout').onclick=async()=>{await api('/api/logout',{});document.dispatchEvent(new Event('bridge-logged-out'));csrf='';state=null;currentId=null;$('messages').replaceChildren();$('approvals').replaceChildren();$('queued').replaceChildren();$('sessions').replaceChildren();$('message').value='';sessionStorage.clear();attachments.reset();sessionActivity.clear();recentInteractions.clear();showLogin();};
function dateText(value){if(!value)return '';return new Date(value*1000).toLocaleDateString(BridgeI18n.locale(),{month:'numeric',day:'numeric'});}
$('list-mode').value=localStorage.getItem('list-mode')||'recent';
$('list-mode').onchange=()=>{localStorage.setItem('list-mode',$('list-mode').value);renderList();};
From ccb2c5c6906fb85d4ccd1477a15f59b6a889c968 Mon Sep 17 00:00:00 2001
From: try2love <1929706911@qq.com>
Date: Tue, 13 Oct 2026 02:38:55 +0800
Subject: [PATCH 17/31] Correct binding recovery timing note
---
.../implemented/feature/2026-10-13-native-binding-recovery.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.agents/notes/implemented/feature/2026-10-13-native-binding-recovery.md b/.agents/notes/implemented/feature/2026-10-13-native-binding-recovery.md
index 108eed46..50334f22 100644
--- a/.agents/notes/implemented/feature/2026-10-13-native-binding-recovery.md
+++ b/.agents/notes/implemented/feature/2026-10-13-native-binding-recovery.md
@@ -18,6 +18,6 @@ WebView 加载前,如 Cookie 缺失或与安全副本不符,原生端用只
## Consequences
-Cookie 丢失后,仍有效的绑定可在加载登录界面前或已打开页面遇到 401 时恢复;服务器撤销后不能恢复。首次连接会增加一次至多五秒的只读校验,离线时仍进入原有页面错误路径。Android KeyStore 或 iOS Keychain 不可用时不会把明文令牌写入普通偏好设置。真实设备上的存储与系统清理行为仍需设备验证。
+Cookie 丢失后,仍有效的绑定可在加载登录界面前或已打开页面遇到 401 时恢复;服务器撤销后不能恢复。需要恢复时会增加有限时的只读校验,离线时仍进入原有页面错误路径。Android KeyStore 或 iOS Keychain 不可用时不会把明文令牌写入普通偏好设置。真实设备上的存储与系统清理行为仍需设备验证。
相关 Cookie 名称和旧版兼容规则见[网关会话 Cookie 隔离记录](../bug-fix/2026-10-13-gateway-cookie-namespace.md)。历史笔记只有该记录部分重叠;它规定网关命名,本篇规定原生安全副本与恢复时序。
From 7ea126892cd00a538655dae2e599559c2ca274ef Mon Sep 17 00:00:00 2001
From: try2love <1929706911@qq.com>
Date: Tue, 13 Oct 2026 03:12:35 +0800
Subject: [PATCH 18/31] Keep client search visible with content-sized filters
---
tests/chat-list-toolbar.test.cjs | 16 +++++++++++
web/features/clients/client-navigation.css | 32 +++++-----------------
web/features/clients/client-navigation.js | 22 +++++++++++++++
web/features/clients/desktop-sessions.js | 2 +-
web/index.html | 2 +-
web/shell/app.js | 2 +-
6 files changed, 48 insertions(+), 28 deletions(-)
create mode 100644 tests/chat-list-toolbar.test.cjs
diff --git a/tests/chat-list-toolbar.test.cjs b/tests/chat-list-toolbar.test.cjs
new file mode 100644
index 00000000..da46f951
--- /dev/null
+++ b/tests/chat-list-toolbar.test.cjs
@@ -0,0 +1,16 @@
+'use strict';
+const test=require('node:test'),assert=require('node:assert/strict'),fs=require('node:fs'),vm=require('node:vm');
+
+test('list filters follow the selected label and language',()=>{
+ const events={},documentEvents={};
+ const measure={style:{},textContent:'',getBoundingClientRect(){return {width:this.textContent.length*8};}};
+ const select=text=>({style:{},selectedOptions:[{textContent:text}],getBoundingClientRect(){return {width:parseInt(this.style.width,10)}}});
+ const surface=select('All'),mode=select('Recent');
+ const toolbar={querySelectorAll:()=>[surface,mode],addEventListener(name,fn){events[name]=fn;}};
+ const context=vm.createContext({document:{createElement:()=>measure,body:{append(){}},addEventListener(name,fn){documentEvents[name]=fn;}},window:{addEventListener(){}},getComputedStyle:()=>({font:'12px sans-serif',letterSpacing:'normal'}),requestAnimationFrame:fn=>fn()});
+ vm.runInContext(fs.readFileSync('web/features/clients/client-navigation.js','utf8'),context);
+ vm.runInContext('bindChatListToolbar(toolbar)',Object.assign(context,{toolbar}));
+ assert.equal(mode.style.width,'84px');assert.equal(surface.style.width,'60px');
+ mode.selectedOptions[0].textContent='Project';events.change();assert.equal(mode.style.width,'92px');
+ mode.selectedOptions[0].textContent='按项目';documentEvents['bridge-language']();assert.equal(mode.style.width,'60px');
+});
diff --git a/web/features/clients/client-navigation.css b/web/features/clients/client-navigation.css
index 561f9cca..2319aa0a 100644
--- a/web/features/clients/client-navigation.css
+++ b/web/features/clients/client-navigation.css
@@ -1,18 +1,14 @@
.gateway-authenticated>.phone-header{display:none}.list-heading{display:grid;grid-template-columns:minmax(0,auto) minmax(0,1fr) auto auto;gap:6px;min-height:60px}.list-heading h2{display:flex;align-items:center;gap:7px;font-size:17px;white-space:nowrap}.client-badge{display:inline-flex;align-items:center;gap:4px;font-size:12px}.client-icon{display:inline-flex;align-items:center;justify-content:center;flex:none;width:23px;height:23px;font-size:20px;line-height:1}.client-icon img{width:100%;height:100%;object-fit:contain}.connected-computer{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:10px;padding:4px 0;color:var(--muted);min-width:0}.connected-computer[hidden]{display:block!important;visibility:hidden}.list-heading #new-chat{font-size:12px;white-space:nowrap;gap:3px;padding:6px 2px}.list-heading .list-more{font-size:13px;padding:6px 0}.client-navigation{margin-top:auto;flex:none;min-width:0}.client-functions{display:grid;grid-auto-flow:column;grid-auto-columns:minmax(0,1fr);align-items:center;gap:0;padding:7px 0;min-height:48px}.client-functions>*{min-width:0;flex:1!important;font-size:11px!important;padding:9px 2px!important;white-space:nowrap}.client-functions #computers-button{margin:0!important;justify-self:end;text-align:right}.navigation-hidden{display:none!important}.client-switch{display:flex;gap:4px;border-top:1px solid var(--line);padding:6px 0 max(7px,env(safe-area-inset-bottom))}.client-switch button{flex:1;display:flex;flex-direction:column;align-items:center;gap:5px;padding:8px 2px;border:0;background:transparent;border-radius:8px;font-size:11px;color:var(--muted)}.client-switch button.selected{background:var(--surface);color:var(--text);font-weight:600}.client-management-row{display:flex!important;align-items:center;gap:12px;padding:15px 0;border-bottom:1px solid var(--line)}.client-management-row>span:nth-child(2){flex:1}.client-management-row small{display:block;color:var(--muted);font-size:11px;margin-top:6px}.chat-head>.client-icon{margin-right:0}.chat-title-button,#chat-title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.no-client #sessions,.no-client #search,.no-client .list-filters{display:none}.no-client-message{padding:30px 5px}.ds-list{display:flex;flex-direction:column;padding:0 16px;overflow:hidden;container-type:inline-size}.ds-list>.session-list-region>.sessions{overflow:auto}.ds-list>input{flex:none}.ds-list>p{margin:10px 0;font-size:12px}.ds-head>strong{white-space:nowrap;text-overflow:ellipsis;min-width:0}.ds-head>.client-icon{flex:none}.ds-conversation .wb-main-pane>.ds-messages{flex:1}.ds-conversation.workbench-active:not(.wb-split) .wb-main-pane{display:none}.ds-head .ds-back{display:block!important}.ds-conversation .wb-main-label{white-space:nowrap}.ds-conversation .wb-layout{flex:1}
/* Shared by all providers; dimensions follow the list pane, not the host OS. */
-.chat-list-toolbar{display:grid;grid-template-columns:minmax(0,1fr) auto auto;gap:6px;align-items:center;flex:none;margin:4px 0 8px;min-width:0}
-.chat-list-toolbar>.search{width:100%;min-width:0;height:36px;padding:7px 8px;margin:0}
-.chat-list-toolbar>.list-filters{display:contents}
-.chat-list-toolbar .list-mode{padding:0;min-width:0}
+.chat-list-toolbar{display:flex;flex-wrap:wrap;gap:6px;align-items:center;flex:none;margin:4px 0 8px;min-width:0}
+.chat-list-toolbar>.search{flex:1 1 96px;width:100%;min-width:96px;height:36px;padding:7px 8px;margin:0}
+.chat-list-toolbar>.list-filters{display:flex;flex:none;flex-wrap:wrap;gap:6px;max-width:100%;min-width:0}
+.chat-list-toolbar .list-mode{flex:none;padding:0;min-width:0}
.chat-list-toolbar .list-mode>span{display:none}
-.chat-list-toolbar .list-mode select{height:36px;padding:6px 3px;width:auto;max-width:108px;font-size:12px}
-.chat-list-toolbar .archive{display:flex;align-items:center;gap:3px;font-size:11px;min-height:36px}
+.chat-list-toolbar .list-mode select{height:36px;padding:6px 3px;font-size:12px}
+.chat-list-toolbar .archive{display:flex;align-items:center;gap:3px;flex:none;font-size:11px;min-height:36px}
.chat-list-toolbar .archive input{width:14px;height:14px;flex:none;margin:0}
-/* Keep search usable; filters wrap with the actual list pane width. */
-.ds-list .chat-list-toolbar{grid-template-columns:minmax(0,1fr) auto auto auto}
-.ds-list .chat-list-toolbar>.list-filters{display:contents}
-.ds-list .chat-list-toolbar .list-mode{min-width:0}
-.ds-list .chat-list-toolbar .ds-surface{width:auto;height:36px;margin:0;padding:6px 3px}
+.ds-list .chat-list-toolbar .ds-surface{flex:none;height:36px;margin:0;padding:6px 3px}
.client-switch-dock{display:flex;align-items:center;border-top:1px solid var(--line);padding-bottom:max(4px,env(safe-area-inset-bottom))}
.client-switch-dock .client-switch{flex:1;min-width:0;border:0;padding:4px 0}
.client-functions>[hidden],.client-switch-dock[hidden]{display:none!important}
@@ -24,17 +20,3 @@
.client-swipe-rows{flex:1;min-height:0;overflow:hidden!important}
.client-swipe-caption{display:flex;align-items:center;gap:8px;flex:none;padding:10px 8px;font-size:12px;font-weight:600;color:var(--muted)}
.no-client .chat-list-toolbar{display:none}
-
-.list-search-toggle{display:none;align-items:center;justify-content:center;width:36px;height:36px;padding:0;border:1px solid var(--line);border-radius:8px;background:var(--raised);color:var(--text);font-size:24px;cursor:pointer}
-#sidebar{container-type:inline-size}
-@container (max-width:420px){
- .chat-list-toolbar,.ds-list .chat-list-toolbar{display:flex;flex-wrap:nowrap;gap:5px}
- .chat-list-toolbar .list-search-toggle{display:flex;flex:none}
- .chat-list-toolbar>.search{display:none;min-width:0;flex:1}
- .chat-list-toolbar>.list-filters,.ds-list .chat-list-toolbar>.list-filters{display:contents}
- .chat-list-toolbar .list-mode{flex:1;min-width:0}
- .chat-list-toolbar .list-mode select{width:100%;max-width:none;min-width:0}
- .chat-list-toolbar .archive{flex:none}
- .chat-list-toolbar.search-open>.search{display:block}
- .chat-list-toolbar.search-open>.list-filters,.ds-list .chat-list-toolbar.search-open>.list-filters{display:none}
-}
diff --git a/web/features/clients/client-navigation.js b/web/features/clients/client-navigation.js
index 757e1bf6..3f5e2506 100644
--- a/web/features/clients/client-navigation.js
+++ b/web/features/clients/client-navigation.js
@@ -1,4 +1,26 @@
'use strict';
+function bindChatListToolbar(toolbar){
+ const selects=[...toolbar.querySelectorAll('select')];
+ const measure=document.createElement('span');
+ measure.style.cssText='position:absolute;visibility:hidden;white-space:pre;pointer-events:none';
+ document.body.append(measure);
+ const update=()=>{
+ for(const select of selects){
+ const style=getComputedStyle(select);
+ measure.style.font=style.font;
+ measure.style.letterSpacing=style.letterSpacing;
+ measure.textContent=select.selectedOptions[0]?.textContent||'';
+ // Reserve the native arrow and its spacing; only the selected label determines width.
+ select.style.width=Math.ceil(measure.getBoundingClientRect().width+36)+'px';
+ }
+ };
+ const later=()=>requestAnimationFrame(update);
+ toolbar.addEventListener('change',update);
+ document.addEventListener('bridge-language',later);
+ window.addEventListener('resize',update);
+ if(typeof ResizeObserver!=='undefined')new ResizeObserver(update).observe(toolbar);
+ later();
+}
class ClientNavigation {
constructor({view,request,notify}) {
Object.assign(this,{view,request,notify});this.clients=[];this.revision=0;this.pending=new Set();this.provider=localStorage.getItem('bridge-client')||'codex';const requested=new URLSearchParams(location.search).get('client');if(['codex','claude','deepseek'].includes(requested))this.provider=requested;
diff --git a/web/features/clients/desktop-sessions.js b/web/features/clients/desktop-sessions.js
index e10fbe6d..e1d1de5a 100644
--- a/web/features/clients/desktop-sessions.js
+++ b/web/features/clients/desktop-sessions.js
@@ -13,7 +13,7 @@ class DesktopSessionsView {
this.listStatus=this.node('p','muted ds-list-status');this.listStatus.setAttribute('role','status');this.connectionRetry=this.button('重试连接',async()=>{const provider=this.provider;if(this.retryingClient||!this.retryClient)return;this.retryingClient=provider;this.renderConnectionStatus();try{await this.retryClient(provider);}finally{this.retryingClient=null;this.renderConnectionStatus();}});this.connectionRetry.hidden=true;
this.connectionInitialize=this.button('初始化连接',async()=>{const provider=this.provider;if(this.initializingClient||!this.initializeClient)return;this.initializingClient=provider;this.renderConnectionStatus();try{await this.initializeClient(provider);}finally{this.initializingClient=null;this.renderConnectionStatus();}});this.connectionInitialize.hidden=true;
this.connectionReconnect=this.button('尝试重连',async()=>{const provider=this.provider;if(this.reconnectingClient||!this.reconnectClient)return;this.reconnectingClient=provider;this.renderConnectionStatus();try{await this.reconnectClient(provider);}finally{this.reconnectingClient=null;this.renderConnectionStatus();}});this.connectionReconnect.hidden=true;
- this.connectionNotice=this.node('p','muted');this.connectionNotice.hidden=true;this.connectionRefresh=this.button('刷新状态',async()=>{if(this.refreshingClient||!this.refreshClients)return;this.refreshingClient=true;this.renderConnectionStatus();try{await this.refreshClients();}finally{this.refreshingClient=false;this.renderConnectionStatus();}});this.connectionRefresh.hidden=true;this.rowsRoot=this.node('div','sessions');const toolbar=this.node('div','chat-list-toolbar');const searchToggle=this.node('button','list-search-toggle','⌕');searchToggle.type='button';this.label(searchToggle,'搜索聊天或项目');searchToggle.setAttribute('aria-expanded','false');searchToggle.onclick=()=>{const open=toolbar.classList.toggle('search-open');searchToggle.setAttribute('aria-expanded',String(open));if(open)this.search.focus();};toolbar.append(searchToggle,this.search,filters);this.list.append(toolbar,this.listStatus,this.connectionNotice,this.connectionRetry,this.connectionReconnect,this.connectionInitialize,this.connectionRefresh,this.rowsRoot);
+ this.connectionNotice=this.node('p','muted');this.connectionNotice.hidden=true;this.connectionRefresh=this.button('刷新状态',async()=>{if(this.refreshingClient||!this.refreshClients)return;this.refreshingClient=true;this.renderConnectionStatus();try{await this.refreshClients();}finally{this.refreshingClient=false;this.renderConnectionStatus();}});this.connectionRefresh.hidden=true;this.rowsRoot=this.node('div','sessions');const toolbar=this.node('div','chat-list-toolbar');toolbar.append(this.search,filters);this.list.append(toolbar,this.listStatus,this.connectionNotice,this.connectionRetry,this.connectionReconnect,this.connectionInitialize,this.connectionRefresh,this.rowsRoot);if(typeof bindChatListToolbar==='function')bindChatListToolbar(toolbar);
this.welcome=this.node('div','welcome ds-welcome');const logo=this.node('img','mark large');logo.src='/icon.png';logo.alt='';logo.width=72;logo.height=72;this.welcome.append(logo,this.translated('h1','从一条聊天继续'),this.translated('p','选择电脑上的聊天,查看进度或发送消息。','muted'));
this.conversation=this.node('section','ds-conversation chat');this.conversation.hidden=true;this.main.append(this.welcome,this.conversation);
this.back=this.iconButton('返回聊天列表','m15 5-7 7 7 7',()=>{if(!this.workbench.back())this.backToList();});this.back.classList.add('ds-back');
diff --git a/web/index.html b/web/index.html
index bd58f717..c814b2bd 100644
--- a/web/index.html
+++ b/web/index.html
@@ -8,7 +8,7 @@
-
+

从一条聊天继续
选择电脑上的聊天,查看进度或发送消息。
diff --git a/web/shell/app.js b/web/shell/app.js
index 83dfd4b4..807b0356 100644
--- a/web/shell/app.js
+++ b/web/shell/app.js
@@ -114,6 +114,7 @@ $('logout').onclick=async()=>{await api('/api/logout',{});document.dispatchEvent
function dateText(value){if(!value)return '';return new Date(value*1000).toLocaleDateString(BridgeI18n.locale(),{month:'numeric',day:'numeric'});}
$('list-mode').value=localStorage.getItem('list-mode')||'recent';
$('list-mode').onchange=()=>{localStorage.setItem('list-mode',$('list-mode').value);renderList();};
+if(typeof bindChatListToolbar==='function')bindChatListToolbar(document.querySelector('#sidebar .chat-list-toolbar'));
function renderList(){
const stamp=JSON.stringify([$('list-mode').value,currentId,currentHost,BridgeI18n.locale(),listRows.map(row=>[row.id,row.host,row.title,row.projectKey,row.projectName,row.hostLabel,row.recency])]);
if(stamp===listRenderStamp&&$('sessions').children.length){renderActivity();return;}
@@ -178,7 +179,6 @@ $('sessions').addEventListener('touchmove',event=>{
$('sessions').addEventListener('touchend',()=>{listTouch=null;},{passive:true});
$('sessions').addEventListener('touchcancel',()=>{listTouch=null;},{passive:true});
$('search').oninput=()=>{clearTimeout(loadList.timer);loadList.timer=setTimeout(()=>loadList(true).catch(e=>toast(e.message)),300);};$('refresh').onclick=()=>loadList(true).catch(e=>toast(e.message));
-for(const toggle of document.querySelectorAll('#sidebar .list-search-toggle'))toggle.onclick=()=>{const toolbar=toggle.closest('.chat-list-toolbar'),open=toolbar.classList.toggle('search-open');toggle.setAttribute('aria-expanded',String(open));if(open)$('search').focus();};
$('archived').onchange=()=>loadList(true).catch(e=>toast(e.message));
const listSync=window.BridgeListSync=new ListSync({root:$('sessions'),status:$('list-sync'),refresh:()=>loadList(true),visible:()=>!$('app').hidden&&$('sidebar').getBoundingClientRect().width>0,ready:()=>!listLoading&&document.activeElement!==$('search')});
const contextUsageControl=new ContextUsageControl();$('permissions-button').after(contextUsageControl.button);
From 999b530e7d4b3626a0d829a524f289b18bc92611 Mon Sep 17 00:00:00 2001
From: try2love <1929706911@qq.com>
Date: Tue, 13 Oct 2026 03:11:27 +0800
Subject: [PATCH 19/31] Add third-party notification settings to mobile UI
---
...10-13-third-party-notification-settings.md | 23 ++++
README.md | 3 +-
bridge/api/httpd.py | 44 ++++--
mobile/README.md | 4 +-
tests/notifications-ui.test.cjs | 85 ++++++++++--
tests/test_bridge.py | 33 +++++
web/features/clients/client-navigation.js | 4 +-
web/features/clients/desktop-sessions.js | 2 +-
web/index.html | 7 +-
web/shared/i18n.js | 35 +++++
web/shell/app.js | 129 +++++++++++++++---
web/shell/content.css | 1 +
12 files changed, 326 insertions(+), 44 deletions(-)
create mode 100644 .agents/notes/implemented/feature/2026-10-13-third-party-notification-settings.md
diff --git a/.agents/notes/implemented/feature/2026-10-13-third-party-notification-settings.md b/.agents/notes/implemented/feature/2026-10-13-third-party-notification-settings.md
new file mode 100644
index 00000000..142f556e
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-10-13-third-party-notification-settings.md
@@ -0,0 +1,23 @@
+# Agent Note: 手机端第三方通知配置
+
+Status: implemented
+
+## Problem
+
+手机端原先只能配置 PushPlus,Bark 和 ntfy 需要回到电脑端。三个通道实际共用电脑网关的通知配置与发送器,因此手机端缺少入口会让同一份设置出现不同的操作能力。
+
+## Decision
+
+手机网页和 App 的「第三方通知」列出 Bark、ntfy、PushPlus,各通道详情只编辑自己的开关、服务地址、主题或密钥。通用区编辑 `includeTitle`、`clickBase`、`securityEnabled`、`addressEnabled` 和 `addressName`;全会话请求与完成通知继续由现有设置控制,原生手机通知权限与收件箱保持独立。
+
+HTTP 在已认证与 CSRF 校验之后,通过 `/api/notifications/channels/{bark,ntfy,pushplus}` 和 `/api/notifications/channels/common` 分别限制可写字段。它复用 `bridge/features/notifications/channels.py` 的持久化、校验和发送器,读取密钥只返回是否已保存。留空密钥沿用原配置;更换 Bark 或 ntfy 服务地址时清除旧密钥。`/test` 只发送该通道已经保存的配置,只有用户按下测试按钮才会发送。旧 `/api/notifications/pushplus` 路由继续可用。
+
+通用区读取成功之前不能保存;关闭、重新打开或进入通道详情后,旧读取结果不得回填当前表单。服务列表显示未配置、已关闭、已开启或读取失败,便于判断下一步操作。
+
+## Alternatives considered
+
+可以在手机端维护另一份通知配置,但会产生与桌面网关状态冲突的密钥和开关。也可以直接开放桌面设置快照,然而其中包含更多与手机端无关的网关选项,扩大可写范围。按通道与通用字段分开限制,更贴近现有权限边界。
+
+## Consequences
+
+手机端与电脑端修改同一份网关配置;所有已登录设备可修改这些通道。通道测试使用现有外部发送器,页面确认服务接受请求不等于手机已收到。自动入口通知仍依赖网关运行和原有入口发现逻辑。回归测试用本地模拟响应和受控 loopback HTTP,未发送真实第三方通知。
diff --git a/README.md b/README.md
index ad4e6b4e..0ffcd74f 100644
--- a/README.md
+++ b/README.md
@@ -818,6 +818,7 @@ PushPlus 通过微信接收通知,**接入前需要付费实名认证,最低
- 在网页 **设置** 或聊天列表底部点击 **PushPlus 通知**,填写在 [PushPlus 官网](https://www.pushplus.plus/) 获取的 Token,勾选启用并保存,然后点击 **测试已保存的配置**。桌面启动器的「手机通知」中也可以配置和测试。
- PushPlus 配置由整个网关共享,更换 Token 会改变所有已关注聊天的 PushPlus 接收目标。所有已登录设备均可修改;开启免密访问时,能够访问网关的设备也拥有此权限。
- **设置 → 全会话通知** 控制所有聊天的请求处理与运行完毕通知;每个聊天的 **提醒** 可独立覆盖。网关持续运行时,关闭网页仍会发送通知。PushPlus 可与 Bark、ntfy 同时使用。
-- **设置 → 主页快捷入口** 可分别隐藏 PushPlus 和账户管理入口;功能仍可从设置打开,显示偏好仅保存在当前浏览器。通知策略保存在网关,各设备共享。
+- **第三方通知** 可在手机网页或 App 中分别配置 Bark、ntfy、PushPlus 的启用状态、服务地址与密钥,并测试已保存的单个通道;通用通知设置集中管理安全提醒、入口通知、网关名称、聊天标题和跳转地址。密钥读取时只显示是否已保存,留空保存保留原值,更换 Bark/ntfy 服务地址会清除原密钥。
+- **设置 → 主页快捷入口** 可分别隐藏第三方通知和账户管理入口;功能仍可从设置打开,显示偏好仅保存在当前浏览器。通知策略保存在网关,各设备共享。
- Token 保存到本机通知配置文件;电脑 App 默认遮罩显示并支持查看,网页不回传已保存的 Token。留空保留原 Token,关闭通道后可勾选清除。
- 点击聊天顶部标题,在聊天详情中选择 **修改聊天名称**,输入新名称并保存(最多 120 个字符)。名称写入该聊天所在主机的 Codex;支持本机和 SSH 聊天,网页列表与标题同步更新。
diff --git a/bridge/api/httpd.py b/bridge/api/httpd.py
index 20435857..9e8a2a9d 100644
--- a/bridge/api/httpd.py
+++ b/bridge/api/httpd.py
@@ -19,7 +19,7 @@
from bridge.features.workspace.workspace import MAX_TRANSFER, Workspace
from bridge.features.workspace.preferences import transfer_settings
from bridge.features.workspace.files import workspace_relative_path
-from bridge.features.notifications.channels import settings as notification_settings, save_settings as save_notification_settings, publish_pushplus
+from bridge.features.notifications.channels import settings as notification_settings, save_settings as save_notification_settings, publish, publish_bark, publish_pushplus
from socketserver import TCPServer
from urllib.parse import parse_qs, urlsplit, quote
@@ -560,10 +560,32 @@ def handle_method(self, write):
if self.server.notifications is None:
raise ValueError('当前网关未启用通知服务')
return self.output(200, self.server.notifications.defaults(self.read_json() if write else None))
- if path in ('/api/notifications/pushplus', '/api/notifications/pushplus/test'):
+ if path == '/api/notifications/channels/common':
manager = self.server.notifications
if manager is None:
raise ValueError('当前网关未启用通知服务')
+ fields = ('includeTitle', 'clickBase', 'securityEnabled', 'addressEnabled', 'addressName')
+ with manager.lock:
+ if write:
+ body = self.read_json()
+ if not isinstance(body, dict) or set(body) - set(fields):
+ raise ValueError('通知配置格式不正确')
+ config = save_notification_settings(manager.data_dir, body)
+ else:
+ config = notification_settings(manager.data_dir)
+ return self.output(200, {key: config[key] for key in fields})
+ channel_parts = path.removeprefix('/api/notifications/channels/').split('/') if path.startswith('/api/notifications/channels/') else []
+ channel = channel_parts[0] if channel_parts and channel_parts[0] in ('ntfy', 'bark', 'pushplus') and channel_parts[1:] in ([], ['test']) else None
+ if channel or path in ('/api/notifications/pushplus', '/api/notifications/pushplus/test'):
+ manager = self.server.notifications
+ if manager is None:
+ raise ValueError('当前网关未启用通知服务')
+ selected = channel or 'pushplus'
+ fields = {'ntfy': ('enabled', 'server', 'topic', 'token', 'clearToken'),
+ 'bark': ('barkEnabled', 'barkServer', 'barkKey', 'clearBarkKey'),
+ 'pushplus': ('pushplusEnabled', 'pushplusToken', 'clearPushplusToken')}[selected]
+ secret, marker = {'ntfy': ('token', 'hasToken'), 'bark': ('barkKey', 'hasBarkKey'),
+ 'pushplus': ('pushplusToken', 'hasPushplusToken')}[selected]
with manager.lock:
config = notification_settings(manager.data_dir)
if path.endswith('/test'):
@@ -571,17 +593,23 @@ def handle_method(self, write):
return self.output(405, {'error': '需要 POST 请求'})
self.read_json()
try:
- publish_pushplus(config, 'Codex 手机通知测试', '收到这条消息表示 PushPlus 通道已连通。')
+ sender = {'ntfy': publish, 'bark': publish_bark, 'pushplus': publish_pushplus}[selected]
+ name = {'ntfy': 'ntfy', 'bark': 'Bark', 'pushplus': 'PushPlus'}[selected]
+ sender(config, 'Codex 手机通知测试', f'收到这条消息表示 {name} 通道已连通。')
except Exception:
- raise ValueError('PushPlus 测试失败,请检查 Token 和网络') from None
+ message = 'PushPlus 测试失败,请检查 Token 和网络' if not channel else f'{name} 测试失败,请检查服务地址、认证和网络'
+ raise ValueError(message) from None
return self.output(200, {'ok': True})
if write:
body = self.read_json()
- if set(body) - {'pushplusEnabled', 'pushplusToken', 'clearPushplusToken'}:
- raise ValueError('PushPlus 配置格式不正确')
+ allowed = set(fields)
+ if not isinstance(body, dict) or set(body) - allowed or any(
+ key in body and not isinstance(body[key], bool) for key in allowed if key.startswith('clear')):
+ raise ValueError('通知配置格式不正确' if channel else 'PushPlus 配置格式不正确')
config = save_notification_settings(manager.data_dir, body)
- return self.output(200, {'pushplusEnabled': config['pushplusEnabled'],
- 'hasPushplusToken': bool(config['pushplusToken'])})
+ visible = {key: config[key] for key in fields if key != secret and not key.startswith('clear')}
+ visible[marker] = bool(config[secret])
+ return self.output(200, visible)
if desktop_workspace:
if self.server.desktop_sessions is None:
return self.output(503, {'error': '桌面会话接入不可用'})
diff --git a/mobile/README.md b/mobile/README.md
index 4d4240e2..87104ba8 100644
--- a/mobile/README.md
+++ b/mobile/README.md
@@ -43,7 +43,7 @@ Android 手机无法查询 GitHub 时,会尝试通过当前已连接并登录
## 聊天列表与应用切换
- 当前应用图标和名称紧邻「聊天」,右侧新建与更多设置遵循原生菜单留白。搜索、最近交互/按项目和归档筛选放在同一行。
-- 功能栏位于应用切换栏上方,账号管理、通知管理、应用管理可选择展示,「返回电脑列表」位于右侧;普通 Web 不显示返回电脑列表。
+- 功能栏位于应用切换栏上方,账号管理、第三方通知、应用管理可选择展示,「返回电脑列表」位于右侧;普通 Web 不显示返回电脑列表。
- 仅一个应用启用时隐藏底部切换栏;更多设置可选择隐藏图标或整栏。隐藏只影响显示,不关闭电脑应用。
- 左右滑动切换应用默认开启,有拖动预览;可在显示设置关闭。此前主动关闭的选择保留,不拦截纵向滚动、输入或边缘返回手势。
- Claude Code 与 Cowork 分组可折叠;返回从当前会话回到该应用列表。模型、权限、上下文用量与账号入口均随当前应用变化,缺失能力不伪装成 Codex 的功能。
@@ -64,7 +64,7 @@ Android 手机无法查询 GitHub 时,会尝试通过当前已连接并登录
3. App 在前台同步已保存电脑的新事件,切入后台取消巡检。首次同步、回前台或事件流重置不会集中弹出旧提醒,历史仍在收件箱。
4. 点击通知回到相应电脑和聊天。手机设置的十秒测试只检验本机显示,不证明远程推送已经接通。
-**标准手机包不保证离开 App、锁屏或被系统终止后的通知送达。** 可使用电脑端 Bark、ntfy、PushPlus;启用「通知链接优先打开手机 App」后,支持该链接的通道可跳转至已保存的电脑。
+**标准手机包不保证离开 App、锁屏或被系统终止后的通知送达。** 可在「第三方通知」配置 Bark、ntfy、PushPlus;启用「通知链接优先打开手机 App」后,支持该链接的通道可跳转至已保存的电脑。
Android 通知大图标显示来源应用,状态栏遵守单色图标要求;iOS 使用来源名称和图片附件,系统小图标仍属于 Bridge,不能逐条替换成其他应用图标。图片由 App 自带,不从远端下载。
diff --git a/tests/notifications-ui.test.cjs b/tests/notifications-ui.test.cjs
index 26129a0e..74c5479a 100644
--- a/tests/notifications-ui.test.cjs
+++ b/tests/notifications-ui.test.cjs
@@ -7,16 +7,17 @@ async function fixture(){
const nodes=new Map(),saved=new Map(),writes=[],listRequests=[];
const node=(tag='')=>({tagName:tag.toUpperCase(),children:[],value:'',checked:false,disabled:false,hidden:false,open:false,textContent:'',dataset:{},options:[],style:{setProperty(k,v){this[k]=v;}},scrollTop:0,parentElement:{},listeners:{},
setAttribute(k,v){this[k]=v;},after(child){this.nextSibling=child;},getBoundingClientRect(){return {width:390,height:844};},classList:{add(){},remove(){},toggle(){},contains(){return false;}},addEventListener(type,listener){(this.listeners[type]??=[]).push(listener);},dispatchEvent(event){for(const listener of this.listeners[event.type]||[])listener(event);},replaceChildren(...values){this.children=values;},append(...values){this.children.push(...values);},querySelectorAll(){return [];},querySelector(){return {disabled:false};},
- showModal(){this.open=true;},close(){this.open=false;}});
+ showModal(){this.open=true;},close(){this.open=false;this.dispatchEvent({type:'close'});}});
const html=fs.readFileSync(path.join(__dirname,'../web/index.html'),'utf8');
for(const match of html.matchAll(/\bid="([^"]+)"/g))nodes.set(match[1],node());
+ const channelButtons=['bark','ntfy','pushplus'].map(channel=>Object.assign(node('button'),{dataset:{notificationChannel:channel}}));
const storage=()=>({getItem(){return null;},setItem(){},removeItem(){}});
const archiveToggles=[];
const archiveFilter={classList:{toggle:(name,enabled)=>archiveToggles.push([name,enabled])}};
- let nextPost;
+ let nextPost,nextGet;
const response=(data,status=200)=>({ok:status===200,status,json:async()=>data});
let listResponse=()=>response({sessions:[],unavailableHosts:[]});
- const context=vm.createContext({ClientNavigation:class{constructor(){this.provider="codex";}async refresh(){}acknowledgeList(){}},DesktopSessionsView:class{clear(){}},Workbench:class{open(){}reset(){}setThumbnails(){}relabel(){}},navigator:{userAgent:'test'},document:{addEventListener(){},documentElement:{},getElementById:id=>nodes.get(id),querySelector:selector=>selector==='.archive-filter'?archiveFilter:{disabled:false},querySelectorAll:()=>[],createElement:node},
+ const context=vm.createContext({ClientNavigation:class{constructor(){this.provider="codex";}async refresh(){}acknowledgeList(){}},DesktopSessionsView:class{clear(){}},Workbench:class{open(){}reset(){}setThumbnails(){}relabel(){}},navigator:{userAgent:'test'},document:{addEventListener(){},documentElement:{},getElementById:id=>nodes.get(id),querySelector:selector=>selector==='.archive-filter'?archiveFilter:{disabled:false},querySelectorAll:selector=>selector==='[data-notification-channel]'?channelButtons:[],createElement:node},
renderMarkdown(){},window:{addEventListener(){}},localStorage:storage(),sessionStorage:storage(),
location:{hash:'',pathname:'/',search:''},history:{replaceState(){}},setTimeout(){},clearTimeout(){},setInterval(){},
ChatTimeline:class{constructor(){this.abort=new AbortController();}async start(){}dispose(){this.abort.abort();}relabel(){}},
@@ -26,15 +27,21 @@ async function fixture(){
if(url.includes('/reconnect'))return response({ok:true});
if(url.startsWith('/api/sessions?')){listRequests.push(url);return listResponse(url);}
if(url==='/api/activity')return response({sessions:[]});
- if(url.startsWith('/api/notifications/pushplus')||url.includes('/rename?')){
+ if(url.startsWith('/api/notifications/channels/')||url.includes('/rename?')){
+ if(options.method!=='POST'&&nextGet){const handle=nextGet;nextGet=null;return handle(url);}
if(options.method==='POST'){
const body=JSON.parse(options.body);writes.push({url,body});
if(nextPost){const handle=nextPost;nextPost=null;return handle(body);}
if(url.includes('/rename?'))return response({title:body.title});
if(url.endsWith('/test'))return response({ok:true});
- saved.set(url,{pushplusEnabled:body.pushplusEnabled,hasPushplusToken:!!body.pushplusToken||!body.clearPushplusToken&&!!saved.get(url)?.hasPushplusToken});
+ if(url.endsWith('/common'))saved.set(url,body);
+ else {
+ const secret=url.endsWith('/bark')?'barkKey':url.endsWith('/ntfy')?'token':'pushplusToken';
+ const marker=url.endsWith('/bark')?'hasBarkKey':url.endsWith('/ntfy')?'hasToken':'hasPushplusToken';
+ saved.set(url,{...body,[secret]:undefined,[marker]:!!body[secret]||!body['clear'+secret[0].toUpperCase()+secret.slice(1)]&&!!saved.get(url)?.[marker]});
+ }
}
- return response(saved.get(url)||{pushplusEnabled:false,hasPushplusToken:false});
+ return response(saved.get(url)||{pushplusEnabled:false,hasPushplusToken:false,barkServer:'https://api.day.app',server:'https://ntfy.sh'});
}
assert.match(url,/\/notifications\?/);
if(options.method==='POST'){
@@ -47,7 +54,7 @@ async function fixture(){
for(const file of ['web/hosts/environment.js','web/shared/i18n.js','web/features/chat/list-sync.js','web/features/accounts/account.js','web/features/chat/modes.js','web/features/chat/attachments.js','web/features/chat/activity.js','web/features/chat/fast-mode.js','web/features/chat/message-actions.js','web/features/chat/permissions.js','web/shell/app.js'])vm.runInContext(fs.readFileSync(path.join(__dirname,'..',file),'utf8'),context);
await new Promise(setImmediate);
const run=code=>vm.runInContext(code,context);
- return {nodes,writes,saved,run,html,response,archiveToggles,listRequests,setList:handle=>{listResponse=handle;},setPost:handle=>{nextPost=handle;},
+ return {nodes,channelButtons,writes,saved,run,html,response,archiveToggles,listRequests,setList:handle=>{listResponse=handle;},setPost:handle=>{nextPost=handle;},setGet:handle=>{nextGet=handle;},
async open(id='11111111-1111-4111-8111-111111111111',host='local'){
await run(`openChat(${JSON.stringify(id)},${JSON.stringify(host)})`);await new Promise(setImmediate);
},
@@ -124,16 +131,24 @@ test('notification controls have English translations and language changes prese
});
-test('PushPlus saves and tests settings without redisplaying its token',async()=>{
+test('third-party notification channels save independently, mask secrets and test the selected saved channel',async()=>{
const ui=await fixture();await ui.open();
await ui.nodes.get('pushplus-settings').onclick();
+ assert.equal(ui.nodes.get('notification-channels-dialog').open,true);
+ await ui.channelButtons[2].onclick();
assert.equal(ui.nodes.get('pushplus-dialog').open,true);
ui.nodes.get('pushplus-enabled').checked=true;ui.nodes.get('pushplus-token').value='secret';
await ui.nodes.get('pushplus-form').onsubmit({preventDefault(){}});
assert.deepEqual(ui.writes[0].body,{pushplusEnabled:true,pushplusToken:'secret',clearPushplusToken:false});
assert.equal(ui.nodes.get('pushplus-token').value,'');
assert.equal(ui.nodes.get('pushplus-token').placeholder,'已保存,留空保留');
- await ui.nodes.get('pushplus-test').onclick();assert.equal(ui.writes[1].url,'/api/notifications/pushplus/test');
+ await ui.nodes.get('pushplus-test').onclick();assert.equal(ui.writes[1].url,'/api/notifications/channels/pushplus/test');
+ ui.nodes.get('notification-channel-back').onclick();await ui.channelButtons[0].onclick();
+ assert.equal(ui.nodes.get('notification-server').value,'https://api.day.app');
+ ui.nodes.get('pushplus-enabled').checked=true;ui.nodes.get('pushplus-token').value='bark-secret';
+ await ui.nodes.get('pushplus-form').onsubmit({preventDefault(){}});
+ assert.deepEqual(ui.writes[2].body,{barkEnabled:true,barkKey:'bark-secret',clearBarkKey:false,barkServer:'https://api.day.app'});
+ await ui.nodes.get('pushplus-test').onclick();assert.equal(ui.writes[3].url,'/api/notifications/channels/bark/test');
ui.nodes.get('pushplus-token').value='retry-token';
ui.setPost(()=>ui.response({error:'Network failure'},400));
await ui.nodes.get('pushplus-form').onsubmit({preventDefault(){}});
@@ -141,6 +156,58 @@ test('PushPlus saves and tests settings without redisplaying its token',async()=
assert.equal(ui.nodes.get('pushplus-error').textContent,'Network failure');
});
+test('common notification controls save once outside provider detail',async()=>{
+ const ui=await fixture();await ui.nodes.get('pushplus-settings').onclick();
+ ui.nodes.get('notification-security-enabled').checked=true;
+ ui.nodes.get('notification-address-enabled').checked=false;
+ ui.nodes.get('notification-address-name').value='Home gateway';
+ ui.nodes.get('notification-include-title').checked=true;
+ ui.nodes.get('notification-click-base').value='https://bridge.example';
+ await ui.nodes.get('notification-common-save').onclick();
+ assert.deepEqual(ui.writes[0].body,{securityEnabled:true,addressEnabled:false,addressName:'Home gateway',includeTitle:true,clickBase:'https://bridge.example'});
+ assert.equal(ui.nodes.get('notification-channels-dialog').open,true);
+});
+
+test('provider list reports configured and enabled states',async()=>{
+ const ui=await fixture();
+ ui.saved.set('/api/notifications/channels/bark',{barkEnabled:true,hasBarkKey:true});
+ ui.saved.set('/api/notifications/channels/ntfy',{enabled:false,topic:'saved-topic'});
+ await ui.nodes.get('pushplus-settings').onclick();
+ assert.equal(ui.nodes.get('notification-status-bark').textContent,'已开启');
+ assert.equal(ui.nodes.get('notification-status-ntfy').textContent,'已关闭');
+ assert.equal(ui.nodes.get('notification-status-pushplus').textContent,'未配置');
+});
+
+test('common settings wait for a valid read and ignore late responses after close',async()=>{
+ const ui=await fixture();let finish;
+ ui.setGet(url=>url.endsWith('/common')?new Promise(resolve=>{finish=resolve;}):ui.response({}));
+ const opening=ui.nodes.get('pushplus-settings').onclick();
+ assert.equal(ui.nodes.get('notification-common-save').disabled,true);
+ await ui.nodes.get('notification-common-save').onclick();
+ assert.equal(ui.writes.length,0);
+ ui.nodes.get('notification-channels-dialog').close();
+ finish(ui.response({securityEnabled:false,addressEnabled:false,addressName:'old',includeTitle:false,clickBase:''}));
+ await opening;
+ assert.equal(ui.nodes.get('notification-common-save').disabled,true);
+ ui.saved.set('/api/notifications/channels/common',{securityEnabled:true,addressEnabled:true,addressName:'new',includeTitle:true,clickBase:''});
+ await ui.nodes.get('pushplus-settings').onclick();
+ assert.equal(ui.nodes.get('notification-address-name').value,'new');
+ assert.equal(ui.nodes.get('notification-common-save').disabled,false);
+});
+
+test('failed common read blocks save and permits explicit retry',async()=>{
+ const ui=await fixture();
+ ui.setGet(()=>ui.response({error:'Read failed'},500));
+ await ui.nodes.get('pushplus-settings').onclick();
+ assert.equal(ui.nodes.get('notification-common-save').disabled,true);
+ assert.equal(ui.nodes.get('notification-common-retry').hidden,false);
+ assert.equal(ui.nodes.get('notification-common-error').textContent,'Read failed');
+ await ui.nodes.get('notification-common-save').onclick();
+ assert.equal(ui.writes.length,0);
+ await ui.nodes.get('notification-common-retry').onclick();
+ assert.equal(ui.nodes.get('notification-common-save').disabled,false);
+});
+
test('rename stays scoped to its original host and cannot overwrite a newly opened chat',async()=>{
const ui=await fixture();await ui.open(undefined,'remote:test');
ui.nodes.get('chat-title').textContent='Old';ui.nodes.get('rename-chat').onclick();
diff --git a/tests/test_bridge.py b/tests/test_bridge.py
index 4917d7e5..c9a13ecb 100644
--- a/tests/test_bridge.py
+++ b/tests/test_bridge.py
@@ -863,6 +863,39 @@ def test_pushplus_configuration_requires_login_and_csrf_and_hides_token(self):
send.assert_called_once()
self.assertEqual(self.request('POST', path, {'server':'https://other.example'}, headers)[0], 400)
+ def test_third_party_channels_are_scoped_and_secrets_remain_masked(self):
+ from bridge.features.notifications.channels import Notifications, settings
+ from unittest.mock import patch
+ with tempfile.TemporaryDirectory(dir=ROOT/'.tmp') as directory:
+ self.server.notifications = Notifications(None, directory)
+ headers = self.login()
+ bark = '/api/notifications/channels/bark'
+ ntfy = '/api/notifications/channels/ntfy'
+ common = '/api/notifications/channels/common'
+ self.assertEqual(self.request('GET', bark)[0], 401)
+ self.assertEqual(self.request('POST', bark, {'barkEnabled': True}, {'Cookie': headers['Cookie']})[0], 403)
+ body = {'barkEnabled': True, 'barkServer': 'https://api.day.app', 'barkKey': 'private-bark'}
+ self.assertEqual(self.request('POST', bark, body, headers)[2]['hasBarkKey'], True)
+ self.assertEqual(self.request('POST', ntfy, {'enabled': True, 'topic': 'test-topic'}, headers)[0], 200)
+ self.assertEqual(self.request('POST', common, {'includeTitle': True, 'securityEnabled': False}, headers)[0], 200)
+ self.assertEqual(settings(directory)['barkKey'], 'private-bark')
+ self.assertTrue(settings(directory)['includeTitle'])
+ self.assertFalse(settings(directory)['securityEnabled'])
+ self.assertNotIn('private-bark', json.dumps(self.request('GET', bark, headers=headers)))
+ self.assertEqual(self.request('POST', ntfy, {'barkKey': 'leak'}, headers)[0], 400)
+ self.assertEqual(self.request('POST', common, {'pushplusToken': 'leak'}, headers)[0], 400)
+ self.assertEqual(self.request('POST', bark, {'clearBarkKey': 'false'}, headers)[0], 400)
+ self.assertEqual(self.request('POST', ntfy, {'clearToken': 1}, headers)[0], 400)
+ self.assertEqual(settings(directory)['barkKey'], 'private-bark')
+ with patch('bridge.api.httpd.publish_bark') as send_bark, patch('bridge.api.httpd.publish') as send_ntfy:
+ self.assertEqual(self.request('POST', bark + '/test', {}, headers)[0], 200)
+ send_bark.assert_called_once()
+ send_ntfy.assert_not_called()
+ self.assertEqual(self.request('POST', ntfy + '/test', {}, headers)[0], 200)
+ send_ntfy.assert_called_once()
+ self.assertEqual(self.request('POST', bark, {'barkServer': 'https://other.example', 'barkEnabled': False}, headers)[2]['hasBarkKey'], False)
+ self.assertEqual(settings(directory)['barkKey'], '')
+
def test_rename_route_requires_csrf_and_routes_to_selected_host(self):
from unittest.mock import Mock
headers = self.login()
diff --git a/web/features/clients/client-navigation.js b/web/features/clients/client-navigation.js
index 757e1bf6..04f35080 100644
--- a/web/features/clients/client-navigation.js
+++ b/web/features/clients/client-navigation.js
@@ -20,7 +20,7 @@ class ClientNavigation {
const preferences=document.getElementById('appearance-activity').closest('fieldset');
for(const [key,label] of [['clients','应用管理']]){const row=document.createElement('label'),box=document.createElement('input');row.className='display-option';box.type='checkbox';box.id='appearance-'+key;box.checked=this.preference(key);box.onchange=()=>{this.preference(key,box.checked);this.paint();};const caption=document.createElement('span');caption.dataset.i18n=label;caption.textContent=BridgeI18n.t(label);row.append(caption,box);preferences.append(row);}
const oldShortcuts=document.getElementById('appearance-accounts').closest('fieldset');
- for(const [id,label] of [['appearance-accounts','账号管理'],['appearance-pushplus','通知管理']]){const row=document.getElementById(id).closest('label'),caption=row.querySelector('span');caption.dataset.i18n=label;caption.textContent=BridgeI18n.t(label);preferences.append(row);}
+ for(const [id,label] of [['appearance-accounts','账号管理'],['appearance-pushplus','显示第三方通知入口']]){const row=document.getElementById(id).closest('label'),caption=row.querySelector('span');caption.dataset.i18n=label;caption.textContent=BridgeI18n.t(label);preferences.append(row);}
oldShortcuts.hidden=true;
const settings=document.getElementById('appearance-dialog');settings.querySelector('.picker-head').after(this.switchToggle);
const switchPreferences=document.createElement('fieldset');switchPreferences.className='appearance-section';const switchTitle=document.createElement('legend');switchTitle.dataset.i18n='底部应用切换';switchTitle.textContent=BridgeI18n.t('底部应用切换');switchPreferences.append(switchTitle);
@@ -233,7 +233,7 @@ class ClientNavigation {
const provider=this.provider,row=this.clients.find(c=>c.id===provider);this.badge.replaceChildren();if(provider)this.badge.append(this.icon(provider),document.createTextNode(row?.name||'Codex'));
const target=provider&&provider!=='codex'?this.view.list:this.sidebar;target.prepend(this.heading);target.append(this.navigation);
this.account.classList.toggle('navigation-hidden',!provider||(provider==='codex'&&!!window.BridgeSharedRelay));this.notifications.classList.toggle('navigation-hidden',!provider);this.manage.hidden=!this.preference('clients');
- this.account.textContent=BridgeI18n.t('账号管理');this.account.removeAttribute('data-i18n');this.notifications.textContent=BridgeI18n.t('通知管理');this.notifications.removeAttribute('data-i18n');
+ this.account.textContent=BridgeI18n.t('账号管理');this.account.removeAttribute('data-i18n');this.notifications.textContent=BridgeI18n.t('第三方通知');this.notifications.removeAttribute('data-i18n');
this.switcher.replaceChildren();const enabled=this.clients.filter(c=>c.enabled),visible=this.visibleClients();const collapsed=this.preference('switch-collapsed',undefined,false);this.switchToggle.hidden=enabled.length<2||!visible.length;this.switchDock.hidden=this.switchToggle.hidden||collapsed;this.navigation.classList.toggle('switch-dock-hidden',this.switchDock.hidden);this.switchToggle.textContent=BridgeI18n.t(collapsed?'显示底部 APP 图标':'隐藏底部 APP 图标');this.switchToggle.setAttribute('aria-expanded',String(!collapsed));this.switchToggle.setAttribute('aria-label',BridgeI18n.t(collapsed?'展开应用图标':'收起应用图标'));this.switchToggle.title=this.switchToggle.getAttribute('aria-label');
for(const client of visible){const button=document.createElement('button');button.type='button';button.append(this.icon(client.id),document.createTextNode(client.name));button.classList.toggle('selected',client.id===provider);button.setAttribute('aria-pressed',String(client.id===provider));button.onclick=()=>this.choose(client.id).catch(e=>this.notify(e.message));this.switcher.append(button);}
document.getElementById('desktop-backend').hidden=true;
diff --git a/web/features/clients/desktop-sessions.js b/web/features/clients/desktop-sessions.js
index e10fbe6d..b677f57e 100644
--- a/web/features/clients/desktop-sessions.js
+++ b/web/features/clients/desktop-sessions.js
@@ -272,7 +272,7 @@ class DesktopSessionsView {
for(const [id,text] of [['inherit','跟随全局设置'],['on','开启'],['off','未开启']]){const option=this.translated('option',text);option.value=id;select.add(option);}select.value=value[key]||'inherit';fields[key]=select;form.append(label);
}
const error=this.node('p','error');error.setAttribute('role','alert');const save=this.button('保存配置',()=>{});save.type='submit';save.className='primary';
- form.append(this.translated('p',value.available?'通知通道已开启':'尚未配置通知通道,可在设置中配置 PushPlus,或在电脑端配置 Bark、ntfy。','muted'),error,save);
+ form.append(this.translated('p',value.available?'通知通道已开启':'尚未配置通知通道,请在第三方通知中配置。','muted'),error,save);
form.onsubmit=async event=>{event.preventDefault();if(!current())return dialog.close();if(save.disabled)return;save.disabled=true;error.textContent='';try{await this.request(this.path('notifications',sid,provider),{requests:fields.requests.value,completion:fields.completion.value});if(current()){dialog.close();this.notify(BridgeI18n.t('聊天提醒设置已保存'));}}catch(failure){if(current())error.textContent=BridgeI18n.t(failure.message);}finally{save.disabled=false;}};
dialog.append(form);
}catch(error){if(current()){loading.textContent=BridgeI18n.t(error.message);loading.className='error';}}
diff --git a/web/index.html b/web/index.html
index bd58f717..e335c438 100644
--- a/web/index.html
+++ b/web/index.html
@@ -8,7 +8,7 @@
-
+

从一条聊天继续
选择电脑上的聊天,查看进度或发送消息。
-
+
+
@@ -26,7 +27,7 @@
设置