Appearance
功能规格说明:机器身份
创建日期:2026-10-13(2026-10-30 从 ui/remote-control.md 拆出)
本文只讲这台机器在远控里的身份:hostId 从哪来、多进程并存时谁在服务、手机上怎么在多台电脑之间切换。通道本身见 remote-channel.md。
用户场景与测试 (必填)
用户故事:机器身份稳定——同一台机器只有一行登记(优先级:P1)
作为在同一台电脑上反复开关远程控制(CLI / 插件端 / 桌面端任一入口)的用户,我希望这台机器在中继上始终是同一行、同一个 hostId,而不是每丢一次本地状态就多出一条同名记录,并连带换掉页面地址与主屏图标。
为什么是这个优先级:首次使用感受不到它(首次登记本来就要建一行)。它决定的是长期整洁与地址稳定:hostId 今天是中继在建行那一刻随机生成的,本机只是把结果记在 ~/.wave/remote-control.json 里——那个文件一丢(重装、清 home、只读 home、换 OS 用户),下次开启就会在同一个账号下再建一行,手机上机器列表里出现多条同名记录,页面地址与主屏图标也一起失效。把 id 的来源从「中继随机」换成「本机按机器标识派生」之后,同一台机器算出的 id 恒定,登记因此变成幂等:本地状态丢了也只是把同一行认回来。跨仓:这需要中继支持「按本机提交的 id 幂等登记」,本机侧只是换 id 的来源。
独立测试:在同一台机器上删掉 ~/.wave/remote-control.json 后重新运行 wave remote-control on,中继的机器列表行数不变、hostId 与删除前相同;CLI 与桌面端分别开启得到同一个 hostId;屏蔽机器标识读取后开启仍成功(回退到中继生成的 id)。
验收场景:
- 假设 本机没有身份(
~/.wave/remote-control.json里没有 hostId),当 开启远程控制时,则 登记请求带上按机器标识派生的 id(「固定命名空间 + 机器标识 + 本机登录账号 id」派生出的 UUID,v5 / RFC 4122),中继按「本账号 + 该 id」查行——命中则复用那一行(不新增行)并重新签发hostToken,未命中则以这个 id 建行;响应里的host.id与hostToken一起写进本地状态。 - 假设 本地状态文件被删除、损坏或写不进去(重装、清 home、半写),当 用户在同一台机器、同一账号下再次开启时,则 中继上不新增行:派生出的 id 与删除前相同,登记认回原来那一行,页面地址与主屏图标不变。
- 假设 同一台机器上先后从 CLI(含 TUI 斜杠命令)、插件端、桌面端开启,则 三个入口共用同一份本地状态与同一个 hostId(入口不同不产生额外行)——这一点与今天一致,本故事改的是「本地状态是唯一载体」这件事本身。
- 假设 本机已经有一行(老机器:本地存着历史上由中继随机生成的 hostId),则 继续用它、不做迁移:本地状态在就一直复用,而迁移会让每台老机器多出一行垃圾。派生只在本地没有身份时才参与。
- 假设 本机读不到机器标识(平台不支持、权限不足、取值失败),当 首次登记时,则 登记照常进行:请求不带 id,由中继生成随机 id(今天的行为),响应里的 id 写进本地状态;读不到标识不导致开启失败。
- 假设 同一台机器上换成另一个账号登录(另一个 OS 用户、或登出后再登入别的账号),则 派生的输入不同 ⇒ 另一个 hostId、中继上另一行——行的归属本来就是「某个账号的一台机器」,与中继
relay_hosts.user_id的口径一致。 - 假设 派生出的 id 在中继上已经被另一个账号占用(同一份机器标识被两台机器共享:克隆的虚拟机、镜像还原),则 中继不覆盖他人的行,改为生成一个随机 id 建行并把实际 id 返回;本机以响应里的 id 为准。已知代价:这类机器每次丢本地状态都会再建一行(幂等在这条路径上不成立)——根因是它们本就自称同一台机器。
- 假设 机器标识要送往中继,则 送的是不可逆的派生值(派生摘要)而不是原文:标识只在本机读取、只参与派生,中继与手机上看到的永远只是 id。
- 假设 用户在中继侧吊销了这台机器的行(
DELETE /api/relay/hosts/:id),当 本机带着同一个派生 id 重新登记时,则 中继复用那一行、把它翻回 active 并重新签发令牌——吊销的语义是「立刻失效、断开连接、从列表消失」,不是「永久拉黑这台机器」。⚠️ 因此本机侧的实际效果是「踢掉当前连接 + 从列表消失,直到本机下一次连接」:本地状态还在并不会让吊销更彻底,因为连接被4003 invalid_host_token拒绝时本机会自愈重登记(连接生命周期故事场景 4–6)、把同一行认回来 —— 一次都不用在~/.wave/里手工挪文件。真要表达「删掉就不许回来」,得先有中继侧的拉黑能力(本规格不做);在那之前,吊销只能表达「现在断开、列表里看不见」。 - 假设 本地状态丢失的同时有多个入口并发首次开启,则 它们提交同一个 id、都命中同一行,但各自拿到一份新令牌,后到的那份让先到的失效(一台机器一份令牌)——表现是先开的那个可能连不上,重新开启即恢复。已知取舍:中继只存令牌摘要,而「本地状态丢失后要能拿回令牌」只能靠重新签发,这个窄窗口不额外加锁。
用户故事:一个通道一个服务方——后开的入口接管,先开的自动停(优先级:P1)
作为在同一台机器上从不同入口(CLI / 插件端 / 桌面端,甚至桌面端的不同分屏)开远程控制的用户,我希望最后开启的那一个成为手机看到的那一个、先前那个自动退下来,而不是两个进程每两秒互相顶一次、手机跟着反复断流。
为什么是这个优先级:通道键是设备级的(一台机器一个 hostId),而「谁持有会话谁服务」意味着同一时刻只能有一个进程在服务。中继侧已经就是这样:同一通道上后到的主机上线时按「换代接管」处理——先把登记换成新的,再以 4001 关闭旧连接(跨中继实例经总线广播同样的接管),并且不惊动订阅者(不给手机推 peer_disconnected、不清在线键),所以手机侧看到的是一次无缝替换。缺的是本机侧的另一半:被顶掉的那个进程把 4001 当成瞬态错误、两秒后重连就又把对方顶掉,于是两个进程每两秒互踢一次、永不收敛(每次都能连上,而每次连上都会清空重试预算,谁都用不完预算)。本故事只补这一半:被接管 = 这个进程不再服务,不再抢回来。
独立测试:在同一台机器上先从一个入口开启远控、再从另一个入口开启;第二个的页面地址能连上;第一个的状态里出现「已被另一个入口接管」的说明、且不再拨号(观察两分钟无重连);把第一个关掉再开启,它能重新接管回来。
验收场景:
- 假设 同一台机器上已经有一个进程在服务(daemon / GUI 子进程 / 前台 TUI),当 用户在另一个入口开启远程控制时,则 由后开的那个接管:中继把登记换成它、并以
4001(reason: "replaced")关闭旧连接,手机侧不出现断开提示(不推peer_disconnected、在线状态不翻)。 - 假设 一个进程的连接是被「换代接管」关掉的,当 本机收到那次关闭时,则 它按被接管处理:不再重连,状态明确写出「已被这台机器上的另一个入口接管」,不留一句笼统的「中继连接断开」。依据:中继用的是同一个
4001,光靠关闭码分不出「我被顶替了」与「这台机器没上线」;区分靠 close 帧的reason(中继在接管时下发replaced)。 - 假设 用户在被接管的那个入口里重新开启,则 它重新拨号并接管回来(后开启者为准)——接管关系不粘在进程上,
off/on始终是各入口自己的动作,谁最后连上谁服务。 - 假设 用户在某个入口里关闭(
off/ 弹窗断开),则 关掉的只是那个进程那条连接(既有口径不变):别的进程若在服务,手机不受影响;没有任何进程在服务时,手机侧按4001重连(页面走既有的「等待上线」自愈路径)。 - 假设 桌面端的两个分屏各自开启远程控制,则 规则同上——后开的那个成为手机读到的会话集合,先开的那条连接停止(每个分屏一个
--stdio子进程,各自都是一次「开启」)。 - 假设 一台远端机器上的 daemon 正开着远程控制(无人值守场景),当 有人在那台机器上跑一次前台 TUI 并开启时,则 前台进程接管、daemon 那条连接停止并如实显示被接管。这是刻意的取舍:接管只看「谁最后开启」,不按入口类型分优先级——无人值守要守的是「别在那台机器上再开一次」,而不是给 daemon 特权。
- 假设 先开的进程后来被重启(
wave daemon restart等),则 它启动后按自己的开关状态重新拨号——那是一次新的「开启」,于是它又接管回来(同场景 3 的规则)。
用户故事:机器列表与切换——一个页面管所有机器(优先级:P1)
作为有不止一台电脑的用户,我希望加到主屏幕的那一个图标就能列出并切换我账号下开启了远程控制的所有机器,而不是每台机器一个图标、各自登录一次、各自扫一次码。
为什么是这个优先级:单机器用户感受不到它(带 host= 的地址行为不变);它是多机器用户「N 个图标 N 次登录」痛点的解药,也是「新机器开启远程控制后不必重新扫码」的前提——机器自己登记即出现在列表里,页面刷新即可见。收端的列表接口(GET /api/relay/hosts)已就绪,本故事是纯页面侧的活,收端零配合。本故事做的是选择、切换,以及清理不再使用的老机器:删除入口只对离线机器开放,删掉的是那一行登记,而不是「永久拉黑这台机器」——那台电脑重新开启远程控制后仍会回到列表,这正是我们要的口径(2026-11-01 定);重命名等其余机器管理仍不做(收端有接口,页面不接)。
独立测试:打开不带 host= 的页面地址(已登录),页面列出账号下的机器(名称 / 平台 / 在线状态);选中一台进入会话视图且地址被改写成带 host=;再次打开直接重连上次选中的那台;「换机器」入口能回到列表切换另一台,且列表读取中 / 取不到的提示只出现在弹窗里,关掉弹窗后页面与点击前一致;删掉一台离线机器后列表随即少一行,而它重新开启远程控制后再次出现。
验收场景:
- 假设 地址里带
host=,则 页面行为与今天逐字节一致:直接连那台机器,不请求机器列表——这是既有链接与二维码的兼容面,也是单机器用户的主路径。 - 假设 地址不带
host=且本地记住了上次选中的机器、那台仍在列表里,则 页面直接重连它(不展示列表),并把选择写回地址(history.replaceState补上host=)——主屏图标点开仍是同一台;同一账号下新增一台机器不改变这一点。 - 假设 地址不带
host=且没有本地记录、或记录的那台已不在列表里,当 页面取到机器列表时,则 展示机器列表(每台给名称、平台、在线状态),选中即写地址并连接。在线状态来自接口的online字段(心跳续期,断连后 ≤60 秒翻为 false),除了决定删除入口是否可用(场景 10)之外只作展示判据——选中一台离线机器是允许的,交给「等待上线」的自愈路径(见断线自愈故事),不让离线状态把入口关死。 - 假设 列表为空,则 页面给明确状态(还没有机器开启远程控制 + 在电脑侧开启的指引),不留白屏。
- 假设 用户想换一台机器,则 「换机器」入口是页面侧的一个常驻入口,但收在「更多」菜单里(菜单项文案「切换电脑」,排在菜单第一项;形态归页面侧壳,不改共享聊天 UI 的交互语义),电脑连不上或断开时也照常可用(状态条是流内一行、不压顶栏,见「状态只在一条流内状态条上播报」场景 1)。依据:换机器是低频动作(多机器用户才用得上),占着顶栏一枚常驻图标与「新建对话 / 会话 / 更多」抢位置不划算;菜单里的一整行既保住了可达性,也让顶栏只留高频的三个入口。切换 = 改通道键 + 丢弃地址里的
session=(那是上一台机器的会话)+ 重走完整连接(快照、忙闲、待审批、权限档全部按冷启动口径重读)。 - 假设 页面要取机器列表,则 它对同源的
GET /api/relay/hosts发起请求(凭证就是页面已在用的企业端登录令牌,接口按「登录用户 == 主机属主」返回该账号的机器,无服务端配对步骤),因此 CSP 的connect-src必须从wss:放宽到wss: 'self'。依据:这张 CSP 是页面初版定的,当时页面没有任何同源出站请求;后来加的登录态静默续期(POST /api/auth/refresh)同样是同源 fetch,却没放宽 CSP——线上那一步会被静默拦掉、退化成跳登录页,而 jsdom 不执行 CSP,测试咬不到。机器列表是第二个同源 fetch,本故事把这张网一起补上。 - 假设 列表请求失败(网络不可达、接口异常),则 页面给明确状态与重试入口,不白屏、也不静默渲染成空列表。
- 假设 用户点「换机器」入口(「更多」菜单里的「切换电脑」一项),则 这次读取的两种中间状态都由弹窗自己承载:读取期间弹窗里显示「正在读取机器列表…」,取不到时弹窗里显示原因与「重试」(重试就地重取、刷新同一个弹窗)——而页面顶部那条横幅在弹窗开合前后逐字不变,关闭弹窗(取消)后页面与点击前一致。依据:那条横幅是连接层的状态面(「电脑侧不在线,等待上线…」这一句,外加关闭键),列表读取则是过渡;把过渡写进状态面有两个后果——① 用错误色的横幅呈现「正在读取」,用户读到的是「出事了」(2026-10-15 实测反馈);② 覆盖之后再无恢复路径,用户在等待上线时点一次「换机器」再取消,连「正在重连」那句一起丢掉。冷启动那次取列表(地址不带
host=、页面此刻还没有别的东西可说)仍用横幅占位,但取中性色:它说的是「正在做一件事」,不是「某件事失败了」。 - 假设 列表里某台机器处于离线状态(
online为 false),当 用户点它那一行的删除并确认,则 页面调用同源的DELETE /api/relay/hosts/:id(凭证同机器列表;登录态过期就就地续期一次,续不上按既有阶梯跳登录页),成功后弹窗留在原地、那一行随即消失。删除的语义是吊销那一行(中继把status翻成revoked,GET /api/relay/hosts不再返回它),不是拉黑:那台电脑重新开启远程控制时会以同一个 hostId 重新登记并回到列表(中继对已吊销的行是复用 + 翻回 active)。请求失败时弹窗里给原因与「重试」,不静默把一行抹掉。依据:收端DELETE /api/relay/hosts/:id已就绪(成功 204、不是本账号的行 404、对已吊销的行幂等仍 204),本故事仍然收端零配合。 - 假设 那台机器在线(
online为 true,即最近 60 秒有心跳),则 它那一行的删除入口不可用,并说明原因(「这台电脑正在使用远程控制,请先在那台机器上关闭后再删除」)。依据:吊销会让中继把该 hostId 上的电脑侧与所有手机侧连接一并以 4003 关掉;不让删在线机器,等于把「删除动作把用户自己正在看的连接踢掉」这条交互分支挡在门口。代价:刚在那台电脑上关闭远程控制后,列表要等心跳过期(≤60 秒)才允许删——这个延迟可接受,不另加判据。 - 假设 被删掉的正是本页上次选中的机器(本地记住了它的 hostId),则 页面同时清掉这份本地记录;否则下次冷启动仍会拿这个 id 去连,订阅者鉴权按「行不存在」回
unknown_host(4003),页面退化成一条「中继拒绝连接」的错误。
边界情况
一台机器只有一个服务方:通道键既然是设备级的,同一台机器上的多个 wave 进程就只能共用同一个通道键(本机只有一份 ~/.wave/remote-control.json)。于是「谁持有会话谁服务」在多进程并存时必须收敛成一个答案——手机指向的那台机器,同一时刻只由其中一个进程提供服务,不允许两个进程都以为自己在线(前台 CLI 故事第 8 条)。
机器身份取自机器标识,不取自本地文件、也不取自主机名:id 由本机按「机器标识 + 本机登录账号」派生(见「机器身份稳定」故事),同一台机器上的多个入口、多个进程、多个工作目录在任何时刻都算出同一个 id;~/.wave/remote-control.json 不再是身份本身,只是「已知身份 + 令牌」的缓存。机器标识按平台取系统自带的那个(Linux /etc/machine-id、macOS IOPlatformUUID、Windows MachineGuid),拿不到就回退到今天的行为(中继生成随机 id);不取主机名——主机名可改、可重复,把它当身份只会制造「改个名就多一行」的假象,而它作为展示标签(name)已经够用。派生值一旦定型就不再改:换命名空间或换派生输入等于换身份、会让所有机器重新登记一行,所以它只在本机没有已存身份时才参与(故事场景 4),既有的行不追着算法迁移。
本地身份是缓存,凭据类 4003 触发一次自愈:~/.wave/remote-control.json 里那对 hostId + hostToken 会失效(行被吊销、令牌 90 天到期、或半写导致两者不配对),失效后的表现是中继用 4003 + auth_error: invalid_host_token / unknown_host 把连接挡回来。不修的话只剩「手工挪走那个文件」这一条路,而用户根本不知道它存在(2026-10-16 实测:清空测试环境列表后,Mac 上只能靠人肉 mv 恢复)——所以本机按 auth_error 分类处置:凭据类 ⇒ 作废本地身份、重新登记一次(派生 id 让这次登记幂等,认回同一行)、写回、重连;其余永久类(版本 / 组织 / 账号)不自愈,因为重新登记改变不了它们、只会盖掉真实原因。代价是吊销的语义被摊薄:「删掉这台机器」从「永久消失」变成「踢掉 + 从列表消失,直到本机下一次连接自己回来」,这是本规格明确的取舍(机器身份稳定故事场景 9)。不做拉黑也是刻意的:拉黑需要中继侧的持久拒绝能力,本规格不做;在没有它的时候假装「吊销 = 拉黑」,代价由用户以一个他无从诊断的坏文件来承担。
--stdio 子进程与 daemon 都可能是服务方:同一台机器上「谁持有会话谁服务」,手机看到的就是那个进程的会话集合;GUI 三端各自拉子进程 ⇒ 各自可能同时持有一条出站连接,这是设计而不是重复。
未落地与待决
- **同一台机器两个服务方(2026-10-16 拍板为「后开接管」):通道键是设备级的(本机只有一份
~/.wave/remote-control.json),所以「daemon / GUI 子进程」与「前台 TUI」同时开启时,会出现同一个 hostId 的多条出站连接。中继侧的实际行为已核对:同一通道上后到的主机上线时按换代接管处理(换登记 → 以4001+reason: "replaced"关闭旧连接,跨中继实例经总线广播同样的接管),且不给订阅者推peer_disconnected、不清在线键。此前未定的取舍已拍为后开接管、先开自动停(见「一个通道一个服务方」故事);留给收端的固化项 = 把接管写成契约(关闭码与 reason 稳定、订阅者不受影响),因为本机侧靠 reason 区分「被接管」与「这台机器没上线」。 - **机器列表与切换 / PWA 主屏元信息 / 断线自愈(#2432,2026-10-15 规格确认并实现):三条新故事(机器列表与切换、PWA 主屏元信息、断线自愈)已按规格落地——机器列表与切换(
/api/relay/hosts+ 机器选择浮层 + 「更多」菜单里的「切换电脑」入口)、PWA 主屏元信息(manifest + 真实 PNG 图标 + 两处引导文案)、断线自愈(瞬态断开无限退避重试、永久失败即停、回前台试连)。收端零配合的前提已对收端源码核实:GET /api/relay/hosts就绪(含online字段)、运营平台上传白名单已含png/json且 MIME 齐全。页面侧的两处 CSP 放宽(connect-src 'self'——这一处同时修复静默续期被拦的既有缺陷——与manifest-src 'self')分别钉在机器列表故事场景 6 与 PWA 故事场景 2。 - **机器身份稳定(2026-10-16 规格确认,两侧均未实现):新增「机器身份稳定」故事(10 条场景),把 hostId 的来源从「中继随机生成」改成「本机按机器标识 + 登录账号派生、随登记请求提交」。收端(codechat)要给的配合:
POST /api/relay/hosts接受可选id(UUID、36 字符,与relay_hosts.id CHAR(36)同形),按「user_id+id」幂等处理——命中已有行则复用该行(更新name/platform/last_seen_at,把status翻回active,重新签发hostToken与token_expires_at),未命中则以该 id 建行;id已属于另一个账号时不覆盖他人的行,改由中继生成随机 id 建行;响应形状({host, hostToken})不变,host.id始终是最终身份;请求不带id时行为逐字不变(老版本客户端不受影响)。本机侧:登记请求多带一个由「机器标识 + 登录账号 id」派生的 UUIDv5,派生只在本地状态没有 hostId 时发生,机器标识读取失败即回退到今天的路径;~/.wave/remote-control.json的字段与语义不变。仍未做的:两侧实现,以及一次「删掉本地状态后重新开启、中继列表行数不变且 hostId 与删除前一致」的验证(先测测试环境)。 - **删除离线机器(2026-11-01 规格确认并实现):机器列表加了「删除」——只对离线机器开放,删的是中继上那一行登记,不是永久拉黑,那台电脑重新开启远程控制后会以同一个 hostId 回来(见「机器列表与切换」故事场景 9–11)。落地位置(全在
packages/webview/remote/shim.js):machineDeleteButton是入口与置灰理由(判据取接口的online === false;字段缺失不算离线,宁可置灰也不误删一台正在用的机器),askDeleteMachine是行内二次确认(不叠第二层浮层),deleteMachine走withRenewal+relayRest打同源DELETE /api/relay/hosts/:id(凭证与机器列表同一份、登录态过期就地续期一次、续不上按既有阶梯跳登录页),204 与 404 都让那一行消失(404 = 这一行已经不在这台账号下了,重试永远不会成功,所以不给「重试」),其余失败在弹窗里给原因与「重试」,不静默抹掉行;dropMachine负责清本地记录、必要时连地址里的host=一起去掉(场景 11)。场景 10 的「说明原因」落在弹窗副标题上,不只靠title——这一页的主读者是手机,hover 文案不出现。用例见packages/webview/tests/remote/shim.test.ts(离线可删 / 在线置灰 / 状态缺失也置灰 / 续期一次后重试成功 / 续不上跳登录页 / 404 视为已不在 / 5xx 留弹窗给原因与重试 / 删的正是上次选中的机器则清记录并去掉地址里的host=/ 删别的机器不动记录)。收端零配合:DELETE /api/relay/hosts/:id早已就绪,页面本来就有同源 CSP 与续期阶梯。仍未做的:真机(真中继 + 真手机)验一遍——删掉一台后,那台电脑重新开启远程控制确实以同一 hostId 回到列表。同一轮顺带确认、但未纳入本规格的两件:① 列表行是否显示「最近活跃」时间(接口已带lastSeenAt,心跳里每 5 分钟最多回写一次,可用来识别老机器)——未拍板;② 「删了就不许这台再回来」需要中继侧的持久拉黑能力(登记时不再把revoked行翻回active),本规格明确不做。