Просмотр исходного кода

merge: 合并远程 v0.4.74 与桌面登录更新

Samien 1 месяц назад
Родитель
Сommit
09a3133f17
54 измененных файлов с 739 добавлено и 110 удалено
  1. 6 6
      README.md
  2. 45 0
      design-qa.md
  3. 0 0
      desktop/boot-icons.svg
  4. 56 0
      desktop/boot.css
  5. 10 5
      desktop/boot.html
  6. 50 11
      desktop/boot.js
  7. 0 3
      desktop/core-lifecycle.test.mjs
  8. 40 0
      desktop/login-sessions.mjs
  9. 28 0
      desktop/login-sessions.test.mjs
  10. 79 11
      desktop/main.mjs
  11. 8 8
      desktop/mode-switch.test.mjs
  12. 9 7
      desktop/onboarding-flow.test.mjs
  13. 27 0
      desktop/onboarding-renderer.test.mjs
  14. 4 2
      desktop/onboarding.mjs
  15. 12 0
      desktop/onboarding.test.mjs
  16. 1 0
      desktop/preload.cjs
  17. 20 1
      desktop/remote-login.mjs
  18. 20 1
      desktop/remote-login.test.mjs
  19. 9 0
      desktop/service-manager.mjs
  20. 10 6
      desktop/test-support/onboarding.mjs
  21. 4 3
      desktop/windows-smoke.test.mjs
  22. 1 1
      docker.env.example
  23. 29 0
      docs/design/desktop-login-steps/README.md
  24. BIN
      docs/design/desktop-login-steps/approved-design.png
  25. BIN
      docs/design/desktop-login-steps/choice-compact.png
  26. BIN
      docs/design/desktop-login-steps/choice.png
  27. BIN
      docs/design/desktop-login-steps/connect-compact.png
  28. BIN
      docs/design/desktop-login-steps/connect.png
  29. BIN
      docs/design/desktop-login-steps/local-complete.png
  30. BIN
      docs/design/desktop-login-steps/login-compact.png
  31. BIN
      docs/design/desktop-login-steps/login.png
  32. BIN
      docs/design/desktop-login-steps/narrow-dark.png
  33. 2 0
      docs/design/desktop-onboarding/README.md
  34. 7 7
      docs/docker-install.md
  35. 51 0
      docs/releases/v0.4.74.md
  36. 1 1
      docs/windows-client.md
  37. 2 2
      package-lock.json
  38. 1 1
      package.json
  39. 3 3
      release.json
  40. 7 4
      scripts/preview-desktop-onboarding.mjs
  41. 6 5
      src/client/components/app/AccountSecurityPanel.vue
  42. 1 0
      src/client/env.d.ts
  43. 3 0
      src/client/stores/auth.ts
  44. 1 0
      src/client/utils/normalize.ts
  45. 4 1
      src/server/index.ts
  46. 44 9
      src/server/localAuth.ts
  47. 5 1
      src/server/routes.ts
  48. 9 2
      src/server/serviceInstance.ts
  49. 1 0
      src/shared/types.ts
  50. 47 4
      tests/client/authDesktopAuthorization.test.ts
  51. 4 3
      tests/server/hermesMediaRoutes.test.ts
  52. 51 1
      tests/server/localAuth.test.ts
  53. 19 0
      tests/server/serviceInstance.test.ts
  54. 2 1
      tests/server/sessionDeletion.test.ts

+ 6 - 6
README.md

@@ -1,6 +1,6 @@
 # 夭夭 AI
 
-当前 Web 与 macOS 桌面发布版本:**v0.4.73**。桌面版提供 Apple Silicon(arm64)正式 DMG,并附 Windows x64 未签名测试安装包,详见 [发布说明](docs/releases/v0.4.73.md)。
+当前 Web 与 macOS 桌面发布版本:**v0.4.74**。桌面版提供 Apple Silicon(arm64)正式 DMG,并附 Windows x64 未签名测试安装包,详见 [发布说明](docs/releases/v0.4.74.md)。
 
 夭夭 AI 为网页、iOS 和安卓提供统一的 Agent、群聊、文件库及语音配置服务。Hermes 通过标准 9119 HTTP/WebSocket 接口执行任务。
 
@@ -40,7 +40,7 @@ Web 数据默认保存在 `~/.yaoyao`,可用 `YAOYAO_HOME` 指定(兼容 `HE
 
 ## macOS 桌面版
 
-在 [v0.4.73 发布页](https://github.com/Lsamien/yaoyao/releases/tag/v0.4.73) 下载 `Yaoyao-0.4.73-arm64.dmg`,将“夭夭”拖到应用程序后打开。首次启动会核对并同步配套本机 Web 服务;普通退出保留后台运行,“停止后台服务并退出”可完整停止本应用管理的后台。详细说明见 [macOS App](docs/macos-app.md)。
+在 [v0.4.74 发布页](https://github.com/Lsamien/yaoyao/releases/tag/v0.4.74) 下载 `Yaoyao-0.4.74-arm64.dmg`,将“夭夭”拖到应用程序后打开。首次启动会核对并同步配套本机 Web 服务;普通退出保留后台运行,“停止后台服务并退出”可完整停止本应用管理的后台。详细说明见 [macOS App](docs/macos-app.md)。
 
 桌面安装包的签名、公证状态及自动升级可用范围见对应版本的 GitHub 发布说明。
 
@@ -50,11 +50,11 @@ Web 数据默认保存在 `~/.yaoyao`,可用 `YAOYAO_HOME` 指定(兼容 `HE
 
 | 镜像 | 架构 |
 | --- | --- |
-| `samienluo/yaoyao:v0.4.73-amd` | AMD64 |
-| `samienluo/yaoyao:v0.4.73` | 自动匹配 AMD64 / ARM64 |
-| `samienluo/yaoyao:latest` | 最新稳定版通用镜像,当前与 `v0.4.73` 相同 |
+| `samienluo/yaoyao:v0.4.74-amd` | AMD64 |
+| `samienluo/yaoyao:v0.4.74` | 自动匹配 AMD64 / ARM64 |
+| `samienluo/yaoyao:latest` | 最新稳定版通用镜像,当前与 `v0.4.74` 相同 |
 
-准备好 Docker Engine/Compose 和容器可访问的 Hermes 9119 服务,在仓库目录将 `docker.env.example` 复制为 `docker.env`。配置上游地址,并设置 `HERMES_YAOYAO_IMAGE=samienluo/yaoyao:v0.4.73`(或 `samienluo/yaoyao:latest`),即可使用远程镜像:
+准备好 Docker Engine/Compose 和容器可访问的 Hermes 9119 服务,在仓库目录将 `docker.env.example` 复制为 `docker.env`。配置上游地址,并设置 `HERMES_YAOYAO_IMAGE=samienluo/yaoyao:v0.4.74`(或 `samienluo/yaoyao:latest`),即可使用远程镜像:
 
 ```sh
 docker compose --env-file docker.env pull web

+ 45 - 0
design-qa.md

@@ -1,3 +1,48 @@
+# 2026-09-28:桌面分步登录 · 方案 1
+
+final result: passed
+
+## Findings
+
+- 当前无未解决的 P0/P1/P2 问题。选择、连接、登录每次只显示一页;本机准备结束后直接进入工作区。
+- 有意适配:本机路径取消账号页;连接页允许修改「记住本次选择」;表单最大宽度为 440 px。正常桌面窗口增加外部留白,不拉伸表单。
+
+## Source / evidence / normalization
+
+- source visual truth:`docs/design/desktop-login-steps/approved-design.png`,1942 × 809 px,用户实际选择的第 1 张设计图。
+- 单个白色应用画面约 615 × 605 px;比较时排除画板标题、边距和底部说明。实现以 615 × 605 CSS px、1x 截图核对,未缩放整幅设计画板。
+- implementation screenshots:同目录 `choice-compact.png`、`connect-compact.png`、`login-compact.png`,均为 615 × 605 px。浅色,远程方式选中、记住选中、示例地址已填写;登录图为空表单并显示实际输入焦点,参考图的示例密码没有照填。
+- 同一个视觉比较输入同时打开源图及这三张最终截图,检查全页层级、表单、进度、返回入口和页底操作。各控件在原尺寸截图中可直接阅读,已逐项检查,不另做焦点区域裁切。
+- 补充证据:960 × 940 的 `choice.png`、`connect.png`、`login.png`;390 × 844 的 `narrow-dark.png`、`local-complete.png`。CSS 尺寸与截图像素一致,1x。
+- 本地入口:`http://127.0.0.1:4388`。实际生产页面与控制器,服务行为由预览适配器模拟。
+
+## Required fidelity surfaces
+
+- 字体:系统中文字体栈,常规桌面标题 32 px / 650,正文 15 px,字段 14 px,辅助文字 13 px;窄屏标题 28 px。标签、地址和长错误信息可换行,不截断操作。
+- 间距与布局:居中的编号进度、标题、纵向选择卡及全宽主按钮。短窗口压缩上部留白,615 × 605 下页底记住/注册/帮助可见;390 px 下标签移到输入框上方,可纵向滚动,无横向溢出。
+- 颜色:白底、近黑主按钮、中性灰选中背景;已连接状态使用浅绿色背景和带文字的反馈。深色使用现有语义变量,并提供可保存的主题切换;错误配合文本和 alert,不仅依赖颜色。
+- 图像与图标:复用项目品牌资源及已安装的 Tabler 图标;新主题图标由图标库导出。没有替代性自绘品牌、额外装饰位图或模糊缩放。
+- 文案:使用产品名「夭夭」,保留所选方案的核心标题与主按钮。本机解释为直接管理员访问;保存的是授权而非密码。连接中、错误、重试、登录和注册使用各自真实状态。
+
+## Comparison history
+
+1. 初次预览缺少平台参数,流程无法初始化;显式传入预览平台后,三页可正常进入并完成模拟流程。最终三页截图确认修复。
+2. P2:主题按钮缺少对应图标 symbol。补齐 Tabler Moon/Sun,浅/深色截图均有清晰按钮图形。
+3. P2:连接检测期间保留旧的地址编辑提示,提交按钮更新不及时。改为真实检测/提交状态,浏览器确认显示「正在检测服务器连接…」并禁用相关操作。
+4. P2:615 × 605 窗口的「记住本次选择」落在首屏下方。短窗口降低顶部留白和选项行高,将堆叠字段的断点收至 540 px。重新捕获三个 `*-compact.png` 并与源图一起核对,页底操作完整可见。
+
+## Interaction / implementation checklist
+
+- [x] 逐页前进、返回保留地址和记住状态、修改服务器、登录错误、密码显隐、深色切换。
+- [x] 本机准备完成直接进入;恢复有效会话期间隐藏选择和登录;远程授权失效直接进入登录。
+- [x] 浏览器控制台无 error;常规、短窗口及窄窗口视觉核对。
+- [x] 相关测试共 65 项通过,覆盖加密保存、旧会话升级、500 天后重启、退出和改密撤销、本机管理员及私有入口隔离;类型检查、完整 Web/服务端构建通过。
+- [x] 本次未连接真实用户服务器;原生 Electron 全量集成测试和 macOS/Windows 安装包尚未运行,未替换用户已安装 App。
+
+---
+
+以下为历史验收记录。
+
 # 桌面单页安装与登录设计核对
 
 final result: passed

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
desktop/boot-icons.svg


+ 56 - 0
desktop/boot.css

@@ -598,3 +598,59 @@ footer {
 .local-ready .installation {
   margin-top:6px
 }
+
+/* One page per step — approved minimal wizard. */
+.page{width:min(960px,calc(100% - 64px));margin:32px auto;position:relative}
+.brand strong{font-size:25px}.brand>div{gap:10px}
+.theme-button{display:grid;place-items:center;width:44px;height:44px;border:0;border-radius:12px;background:transparent;color:var(--text)}
+.theme-button:hover{background:var(--soft)}
+.wizard{width:min(440px,100%);margin:72px auto 0}
+.steps{margin:0;gap:0}
+.steps li{font-size:0;gap:0}
+.steps li>span:not(.step-dot){font-size:0}
+.steps li+li:before{width:48px;margin:0 12px}
+.step-dot{width:28px;height:28px;font-size:14px;border:1px solid var(--line);background:var(--canvas)}
+.steps .complete .step-dot{background:var(--muted);border-color:var(--muted);color:var(--canvas)}
+.steps [aria-current=step] .step-dot{border-color:var(--solid)}
+.step-caption{text-align:center;color:var(--muted);font-size:13px;margin:16px 0 0}
+.intro{margin:34px 0 30px;text-align:center}
+.intro h1{font-size:32px;line-height:1.3;letter-spacing:-.03em;font-weight:650;outline:0}
+.intro p{margin-top:12px;line-height:1.6;font-size:15px}
+.section{padding:0;border:0}.section>h2,.section>#choose-hint{position:absolute;clip-path:inset(50%);width:1px;height:1px;overflow:hidden}
+.inset{margin-left:0}
+.mode-options{display:flex;flex-direction:column;gap:0;border:1px solid var(--line);border-radius:13px;overflow:hidden;margin:0}
+.mode-option{border:0;border-radius:0;min-height:92px;padding:20px;gap:18px}
+.mode-option+.mode-option{border-top:1px solid var(--line)}
+.mode-option:has(:checked){box-shadow:none;background:var(--soft)}
+.mode-option:has(:focus-visible){outline-offset:-3px}
+.mode-option strong{font-size:16px}.mode-option small{font-size:13px;line-height:1.5}
+.mode-icon{width:28px;height:28px}
+.primary{min-height:52px;border-radius:12px;font-size:15px;margin-top:24px}
+.back{position:absolute;top:96px;left:0;display:flex;align-items:center;gap:8px;text-decoration:none;font-size:14px;padding:8px 12px}
+.back svg{transform:rotate(180deg);width:18px}
+label[for]{font-size:14px}
+input:not([type=checkbox],[type=radio]){height:52px;border-radius:12px;font-size:15px;padding:0 15px}
+.connection-row{margin-top:8px;display:block}
+#connection-form{margin-top:0}.field-help{margin-top:10px;font-size:13px}
+#detect{margin-top:30px}#account-hint{display:none}
+.connected-server{display:flex;align-items:center;gap:10px;padding:2px 14px;margin-bottom:24px;border-radius:10px;background:color-mix(in srgb,var(--success) 8%,var(--canvas));color:var(--success);font-size:13px;min-height:44px}
+.connected-server>svg{width:16px;height:16px}
+.connected-server>span{min-width:0;overflow-wrap:anywhere;flex:1}
+.connected-server .text-button{color:var(--success);padding-left:8px;flex:none;text-decoration:none}
+#login-form{margin-top:0}#credentials{gap:14px}
+.field{grid-template-columns:54px minmax(0,1fr);gap:12px}.field label{font-size:14px}
+.password-input input{padding-right:48px}.eye-button{height:52px;width:48px}
+#submit{margin-top:24px}.registration-notice{margin-top:12px}
+footer{flex-wrap:wrap;gap:4px 16px;margin-top:12px;font-size:13px}
+footer .remember{margin-right:auto}#register-toggle{margin:0 auto}.text-button{font-size:13px}
+.page[data-step="2"] footer .remember{display:none}
+.installation{margin-top:20px;padding:12px 18px}.install-steps li{padding:14px 0}
+.status{line-height:1.6;margin-top:14px}
+.help{line-height:1.7}button,input{transition:background-color 150ms,border-color 150ms,opacity 150ms}
+:root[data-theme="light"]{color-scheme:light;--canvas:#fff;--soft:#f2f2ef;--text:#1f1f1d;--muted:#656560;--line:#ddddda;--solid:#20201e;--on-solid:#fff;--success:#377a54;--danger:#b33d33;--focus:rgba(31,31,29,.16)}
+:root[data-theme="dark"]{color-scheme:dark;--canvas:#181817;--soft:#282827;--text:#efefec;--muted:#b2b2ad;--line:#41413e;--solid:#efefec;--on-solid:#151514;--success:#77b88e;--danger:#ed786f;--focus:rgba(255,255,255,.12)}
+:root[data-theme="light"] .brand img,:root[data-theme="light"] .session-loading img{filter:none}
+:root[data-theme="dark"] .brand img,:root[data-theme="dark"] .session-loading img{filter:invert(1)}
+@media(max-width:540px){.page{width:calc(100% - 40px);margin:20px auto}.wizard{margin-top:68px}.back{top:58px;left:-10px}.intro{margin-top:28px}.intro h1{font-size:28px}.mode-option{gap:14px;padding:18px}.field{grid-template-columns:1fr;gap:8px}}
+@media(max-height:680px) and (min-width:541px){.page{margin-top:20px}.wizard{margin-top:24px}.back{top:68px}.step-caption{margin-top:12px}.intro{margin-top:20px;margin-bottom:22px}.mode-option{min-height:86px;padding:16px 20px}}
+@media(prefers-reduced-motion:reduce){button,input{transition:none}}

+ 10 - 5
desktop/boot.html

@@ -11,25 +11,28 @@
   <p id="session-loading-message">正在打开夭夭…</p>
 </main>
 <main id="onboarding" class="page" hidden>
-  <header class="brand"><div><img src="icon.png" alt="" width="40" height="40"><strong>夭夭</strong></div><span>服务器与账号</span></header>
-  <div class="intro"><h1>开始使用夭夭</h1><p>选择服务器,完成准备后即可开始使用。</p></div>
+  <header class="brand"><div><img src="icon.png" alt="" width="40" height="40"><strong>夭夭</strong></div><button id="theme-toggle" class="theme-button" type="button" aria-label="切换深色主题"><svg><use href="boot-icons.svg#moon"/></svg></button></header>
+  <div class="wizard"><button id="back" class="back text-button" type="button" hidden><svg><use href="boot-icons.svg#arrow"/></svg>返回</button>
   <ol class="steps" aria-label="设置进度">
     <li id="step-choice" aria-current="step"><span class="step-dot"><b>1</b><svg><use href="boot-icons.svg#check"/></svg></span>选择服务器</li>
     <li id="step-prepare"><span class="step-dot"><b>2</b><svg><use href="boot-icons.svg#check"/></svg></span>准备服务</li>
     <li id="step-account"><span class="step-dot"><b>3</b><svg><use href="boot-icons.svg#check"/></svg></span><span id="account-step-title">登录账号</span></li>
   </ol>
+  <p id="step-caption" class="step-caption"></p>
+  <div class="intro"><h1 id="page-title" tabindex="-1">开始使用夭夭</h1><p id="page-description">选择适合你的使用方式。</p></div>
   <section id="choice-section" class="section choice-section" aria-labelledby="choice-title">
     <h2 id="choice-title"><span>01</span>选择服务器</h2>
     <fieldset id="mode-options" class="mode-options"><legend class="sr-only">服务器运行方式</legend>
       <label class="mode-option"><input type="radio" name="mode" value="local"><svg class="mode-icon"><use href="boot-icons.svg#monitor"/></svg><span><strong>本机运行</strong><small>在这台电脑准备并运行服务</small></span></label>
       <label class="mode-option"><input type="radio" name="mode" value="remote"><svg class="mode-icon"><use href="boot-icons.svg#link"/></svg><span><strong>连接远程服务器</strong><small>使用已有服务器,本机无需安装</small></span></label>
     </fieldset>
+    <button id="continue" class="primary" type="button" disabled><span>继续</span><svg><use href="boot-icons.svg#arrow"/></svg></button>
   </section>
   <section id="prepare-section" class="section pending" aria-labelledby="prepare-title">
     <h2 id="prepare-title"><span>02</span><span id="prepare-heading">准备服务</span></h2>
     <p id="choose-hint" class="muted inset">请先选择本机运行或连接远程服务器。</p>
     <div id="local-panel" hidden>
-      <p class="muted inset">检测已有环境,按顺序准备缺少的组件。</p>
+      <p class="muted inset">自动准备本机服务,完成后以管理员身份进入。</p>
       <div class="installation">
         <ol class="install-steps">
           <li data-stage="checking"><svg><use href="boot-icons.svg#circle"/></svg><span>检查运行环境</span><small>等待中</small></li>
@@ -42,7 +45,7 @@
     </div>
     <form id="connection-form" class="inset" hidden>
       <label for="server">服务器地址</label>
-      <div class="connection-row"><input id="server" name="server" type="text" inputmode="url" autocomplete="url" spellcheck="false" placeholder="192.168.1.10:15300" required><button id="detect" class="secondary" type="submit">检测连接</button></div>
+      <div class="connection-row"><input id="server" name="server" type="text" inputmode="url" autocomplete="url" spellcheck="false" placeholder="https://yaoyao.example.com" required></div><p class="muted field-help">输入管理员提供的服务器地址。</p><button id="detect" class="primary" type="submit">检测并继续</button>
     </form>
     <p id="status" class="status inset" role="status" aria-live="polite"></p>
     <div id="actions" class="actions inset" hidden><button id="retry" class="secondary" type="button">重试</button><button id="force-sync" class="secondary" type="button" hidden>使用当前 App 覆盖同版本 Web</button></div>
@@ -51,6 +54,7 @@
   <section id="account-section" class="section pending" aria-labelledby="account-title">
     <h2 id="account-title"><span>03</span><span id="account-heading">登录账号</span></h2>
     <p id="account-hint" class="muted inset">服务准备完成后,在此登录账号。</p>
+    <div id="connected-server" class="connected-server"><svg><use href="boot-icons.svg#check"/></svg><span id="server-summary"></span><button id="change-server" class="text-button" type="button">修改</button></div>
     <form id="login-form" hidden>
       <div id="credentials" class="inset">
         <div class="field"><label for="username">账号</label><input id="username" name="username" autocomplete="username" spellcheck="false" placeholder="输入用户名" required maxlength="320"></div>
@@ -64,7 +68,8 @@
     <button id="waiting" class="primary" type="button" disabled hidden>正在准备服务,请稍候</button>
   </section>
   <footer><label class="remember"><input id="remember" type="checkbox">记住本次选择</label><button id="register-toggle" class="text-button" type="button" hidden>没有账号?注册子账号</button><button id="help-button" class="text-button" type="button" aria-expanded="false" aria-controls="help">帮助</button></footer>
-  <div id="help" class="help" hidden><p><strong>本机运行:</strong>复用已有服务,或准备本机服务后创建管理员。机器人运行还需要本机的 Hermes 环境。</p><p><strong>远程连接:</strong>填写夭夭服务器地址,检测成功后使用该服务器的账号登录。管理员登录后会尝试将本机注册为可控制的电脑。</p><p>更换服务器不会迁移原服务器上的数据。遇到安装问题,可展开安装详情查看日志。</p></div>
+  <div id="help" class="help" hidden><p><strong>本机运行:</strong>自动准备服务并以管理员身份进入,无需创建账号或登录。</p><p><strong>远程连接:</strong>填写夭夭服务器地址,检测成功后使用该服务器的账号登录。登录授权保存在此设备,主动退出或密码修改后需要重新登录。</p><p>更换服务器不会迁移原服务器上的数据。遇到安装问题,可展开安装详情查看日志。</p></div>
+</div>
 </main>
 <script src="boot.js"></script>
 </body></html>

+ 50 - 11
desktop/boot.js

@@ -2,10 +2,20 @@ const api = window.yaoyaoDesktop
 const $ = id => document.getElementById(id)
 let registering = false
 let state, initialized = false, pending = false, serverDirty = false, generation = 0, actionError = ''
+let draftMode = '', previousStep
+let theme = window.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
+try { theme = localStorage.getItem('yaoyao-desktop-theme') || theme } catch { /* Storage can be unavailable in embedded previews. */ }
+function applyTheme() {
+  document.documentElement.dataset.theme = theme
+  $('theme-toggle').setAttribute('aria-label', theme === 'dark' ? '切换浅色主题' : '切换深色主题')
+  $('theme-toggle').querySelector('use').setAttribute('href', `boot-icons.svg#${theme === 'dark' ? 'sun' : 'moon'}`)
+}
+applyTheme()
+$('theme-toggle').onclick = () => { theme = theme === 'dark' ? 'light' : 'dark'; try { localStorage.setItem('yaoyao-desktop-theme', theme) } catch {} applyTheme() }
 
 function display(next) {
   state = next
-  const restoring = state.restoring === true || state.phase === 'complete'
+  const restoring = state.restoring === true || state.phase === 'complete' || state.mode === 'local' && state.phase === 'entering'
   $('onboarding').hidden = restoring
   $('session-loading').hidden = !restoring
   document.title = restoring ? '夭夭' : '开始使用夭夭'
@@ -16,8 +26,8 @@ function display(next) {
     initialized = true
   }
   const busy = pending || state.busy
+  if (state.mode) draftMode = state.mode
   const clientOnly = state.supportedModes && !state.supportedModes.includes('server')
-  $('choice-section').hidden = Boolean(clientOnly)
   $('step-choice').hidden = Boolean(clientOnly)
   if (clientOnly) {
     $('prepare-title').firstElementChild.textContent = '01'
@@ -28,7 +38,7 @@ function display(next) {
     $('help').lastElementChild.textContent = '更换服务器不会迁移原服务器上的数据。连接失败时请检查服务器地址和网络。'
   }
   const local = state.mode === 'local'
-  const ready = ['ready', 'authenticating', 'entering'].includes(state.phase) && !serverDirty
+  const ready = !local && ['ready', 'authenticating', 'entering'].includes(state.phase) && !serverDirty
   const setup = ready && state.setupRequired
   const confirm = setup || registering
   const accountTitle = setup ? '创建管理员' : registering ? '注册子账号' : '登录账号'
@@ -38,7 +48,8 @@ function display(next) {
   $('registration-notice').textContent = state.registrationNotice || ''
   $('registration-notice').hidden = !ready || !state.registrationNotice
   $('mode-options').disabled = busy
-  for (const input of document.querySelectorAll('[name=mode]')) input.checked = input.value === state.mode
+  for (const input of document.querySelectorAll('[name=mode]')) input.checked = input.value === (state.mode || draftMode)
+  $('continue').disabled = busy || !draftMode
   $('remember').disabled = busy
   $('prepare-section').classList.toggle('pending', !state.mode)
   $('prepare-section').classList.toggle('local-ready', local && ready)
@@ -48,15 +59,15 @@ function display(next) {
   $('connection-form').hidden = state.mode !== 'remote'
   $('server').disabled = busy
   $('detect').disabled = busy || !$('server').value.trim()
-  $('detect').textContent = busy && !ready ? '正在检测…' : ready ? '重新检测' : '检测连接'
+  $('detect').textContent = busy && !ready ? '正在检测…' : '检测并继续'
   $('prepare-local').hidden = !local || state.phase !== 'idle'
   $('prepare-local').disabled = busy
-  $('actions').hidden = !local || state.phase !== 'error'
+  $('actions').hidden = !local || !(state.phase === 'error' || state.error)
   $('retry').disabled = busy
   $('force-sync').hidden = !state.canForceSync
   $('force-sync').disabled = busy
   const preparationError = !ready && !serverDirty && (actionError || state.error)
-  $('status').textContent = preparationError || (serverDirty ? '服务器地址已更改,请重新检测。' : state.message)
+  $('status').textContent = preparationError || (busy && !ready ? local ? state.message : '正在检测服务器连接…' : serverDirty ? '' : state.message)
   $('status').setAttribute('role', preparationError ? 'alert' : 'status')
   $('status').classList.toggle('success', ready && !preparationError)
   $('prepare-section').setAttribute('aria-busy', String(busy && !ready))
@@ -64,7 +75,7 @@ function display(next) {
   $('account-step-title').textContent = accountTitle
   $('account-section').classList.toggle('pending', !ready)
   $('account-section').setAttribute('aria-busy', String(busy && ready))
-  $('account-hint').textContent = !ready ? '服务准备完成后,在此创建账号或登录。'
+  $('account-hint').textContent = !ready ? ''
     : state.authenticated ? `已登录${state.username ? `:${state.username}` : ''},可以继续进入夭夭。`
     : setup ? '首次使用,请设置管理员账号和密码。密码至少 8 个字符。' : registering ? '子账号仅可使用 Bot 模式,注册后需管理员分配机器人并开通。' : '登录所选服务器的夭夭账号。'
   $('login-form').hidden = !ready
@@ -78,13 +89,29 @@ function display(next) {
   $('password').autocomplete = confirm ? 'new-password' : 'current-password'
   $('toggle-password').disabled = busy
   $('submit').disabled = busy
-  $('submit').querySelector('span').textContent = state.phase === 'authenticating' ? (confirm ? '正在提交…' : '正在登录…')
+  $('submit').querySelector('span').textContent = state.phase === 'authenticating' || pending && ready ? (confirm ? '正在提交…' : '正在登录…')
     : state.phase === 'entering' ? '正在进入…' : state.authenticated ? '进入夭夭' : setup ? '创建并进入夭夭' : registering ? '注册子账号' : '登录并进入夭夭'
   $('login-error').textContent = ready ? actionError || state.error : ''
   $('login-error').hidden = !$('login-error').textContent
   $('waiting').hidden = !busy || ready
   $('waiting').textContent = local ? '正在准备服务,请稍候' : '正在检测连接,请稍候'
   const activeStep = ready ? 2 : state.mode ? 1 : 0
+  $('choice-section').hidden = activeStep !== 0 || Boolean(clientOnly)
+  $('prepare-section').hidden = activeStep !== 1
+  $('account-section').hidden = activeStep !== 2
+  $('step-account').hidden = local
+  $('back').hidden = activeStep === 0 || Boolean(clientOnly && activeStep === 1)
+  $('back').disabled = busy
+  $('change-server').disabled = busy
+  let serverLabel = state.serverURL || ''
+  try { serverLabel = new URL(serverLabel).host } catch { /* Editing an unvalidated saved address is still possible. */ }
+  $('server-summary').textContent = `已连接 · ${serverLabel}`
+  $('page-title').textContent = activeStep === 0 ? '开始使用夭夭' : activeStep === 1 ? local ? '准备本机服务' : '连接你的服务器' : registering ? '注册子账号' : '登录夭夭'
+  $('page-description').textContent = activeStep === 0 ? '选择适合你的使用方式。' : activeStep === 1 ? local ? '准备完成后,以管理员身份直接进入。' : '输入服务器地址,确认连接后继续。' : registering ? '提交后需管理员分配机器人并开通,才能登录使用。' : '使用此服务器的账号继续。'
+  const stepCount = clientOnly || local ? 2 : 3
+  $('step-caption').textContent = `第 ${activeStep + (clientOnly ? 0 : 1)} 步,共 ${stepCount} 步`
+  $('onboarding').dataset.step = String(activeStep)
+  if (previousStep !== activeStep) { previousStep = activeStep; $('help').hidden = true; $('help-button').setAttribute('aria-expanded', 'false'); $('page-title').focus() }
   for (const [index, id] of ['step-choice', 'step-prepare', 'step-account'].entries()) {
     $(id).classList.toggle('complete', index < activeStep)
     if (index === activeStep) $(id).setAttribute('aria-current', 'step')
@@ -126,12 +153,24 @@ async function action(run, { resetServer = false, focusAccount = false } = {}) {
   }
 }
 for (const input of document.querySelectorAll('[name=mode]')) input.addEventListener('change', () => {
+  draftMode = input.value
+  display(state)
+})
+$('continue').onclick = () => {
   $('login-form').reset(); $('password').type = 'password'
   $('toggle-password').setAttribute('aria-pressed', 'false'); $('toggle-password').setAttribute('aria-label', '显示密码')
   $('password').removeAttribute('aria-invalid'); $('confirmation').removeAttribute('aria-invalid')
   serverDirty = false; registering = false
-  void action(() => api.selectServer(input.value))
-})
+  void action(() => api.selectServer(draftMode))
+}
+function back() {
+  if (pending || state.busy) return
+  const mode = ['ready', 'authenticating', 'entering'].includes(state.phase) ? state.mode : null
+  $('password').value = ''; $('confirmation').value = ''; registering = false; serverDirty = false
+  void action(() => api.selectServer(mode))
+}
+$('back').onclick = back
+$('change-server').onclick = back
 $('server').addEventListener('input', () => {
   serverDirty = true; registering = false; actionError = ''
   // Credentials belong to the checked server, never carry them to a new address.

+ 0 - 3
desktop/core-lifecycle.test.mjs

@@ -20,9 +20,6 @@ test('packaged core service starts, preserves window-close work, recovers, and s
     await until(()=>localJSON(`http://127.0.0.1:${upstreamPort}/api/status`),Boolean)
     app=await electron.launch({...(process.env.DESKTOP_TEST_EXECUTABLE?{executablePath:process.env.DESKTOP_TEST_EXECUTABLE}:{}),args:process.env.DESKTOP_TEST_EXECUTABLE?[]:[root],cwd:root,env:{...process.env,HERMES_YAOYAO_DESKTOP_TEST_HOME:home,HERMES_YAOYAO_DESKTOP_PORT:String(port),HERMES_YAOYAO_UPSTREAM:`http://127.0.0.1:${upstreamPort}`},timeout:30000})
     const page=await app.firstWindow()
-    await page.locator('#login-form').waitFor()
-    const prepared=JSON.parse(await readFile(join(home,'service-instance.json'),'utf8'))
-    assert.equal((await localJSON(`http://127.0.0.1:${port}/desktop/service`,{'x-yaoyao-desktop-token':prepared.token})).activationRequired,true)
     await enterLocal(page);await page.waitForURL(`http://127.0.0.1:${port}/**`)
     const initial=JSON.parse(await readFile(join(home,'service-instance.json'),'utf8'));pid=initial.pid
     const identity=await localJSON(`http://127.0.0.1:${port}/desktop/service`,{'x-yaoyao-desktop-token':initial.token})

+ 40 - 0
desktop/login-sessions.mjs

@@ -0,0 +1,40 @@
+import { createHash, randomUUID } from 'node:crypto'
+import { mkdir, readFile, writeFile, rename, rm } from 'node:fs/promises'
+import { join } from 'node:path'
+
+/** Native-only vault. Passwords are never retained; each origin owns one revocable session. */
+export class DesktopLoginSessions {
+  constructor({ home, encrypt, decrypt }) {
+    this.root = join(home, 'desktop-login-sessions')
+    this.encrypt = encrypt; this.decrypt = decrypt
+    this.pending = Promise.resolve()
+  }
+  path(origin) { return join(this.root, createHash('sha256').update(new URL(origin).origin).digest('hex') + '.enc') }
+  serial(run) {
+    const next = this.pending.then(run)
+    this.pending = next.catch(() => {})
+    return next
+  }
+  read(origin) {
+    return this.serial(async () => {
+      let bytes
+      try { bytes = await readFile(this.path(origin)) } catch (error) { if (error.code === 'ENOENT') return; throw error }
+      const value = JSON.parse(await this.decrypt(bytes))
+      if (value.origin !== new URL(origin).origin || value.cookie?.name !== 'hermes_yaoyao_session' || typeof value.cookie.value !== 'string' || !value.cookie.value)
+        throw new Error('保存的登录授权无效,请重新登录')
+      return value
+    })
+  }
+  save(origin, cookies) {
+    return this.serial(async () => {
+      const cookie = cookies.find(value => value.name === 'hermes_yaoyao_session' && value.value)
+      if (!cookie) throw new Error('服务器没有返回登录授权')
+      const bytes = await this.encrypt(JSON.stringify({ origin: new URL(origin).origin, cookie }))
+      await mkdir(this.root, { recursive: true, mode: 0o700 })
+      const path = this.path(origin), temporary = path + '.' + randomUUID()
+      try { await writeFile(temporary, bytes, { mode: 0o600, flag: 'wx' }); await rename(temporary, path) }
+      finally { await rm(temporary, { force: true }) }
+    })
+  }
+  forget(origin) { return this.serial(() => rm(this.path(origin), { force: true })) }
+}

+ 28 - 0
desktop/login-sessions.test.mjs

@@ -0,0 +1,28 @@
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+import { mkdtemp, readFile, rm, stat } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto'
+import { DesktopLoginSessions } from './login-sessions.mjs'
+
+test('native login vault encrypts only session authorization, restores by origin, and durably forgets', async () => {
+  const home = await mkdtemp(join(tmpdir(), 'yaoyao-login-vault-')), key = randomBytes(32)
+  const options = { home,
+    encrypt: async value => { const iv = randomBytes(12), cipher = createCipheriv('aes-256-gcm', key, iv); const bytes = Buffer.concat([cipher.update(value),cipher.final()]); return Buffer.concat([iv,cipher.getAuthTag(),bytes]) },
+    decrypt: async bytes => { const cipher = createDecipheriv('aes-256-gcm',key,bytes.subarray(0,12));cipher.setAuthTag(bytes.subarray(12,28));return Buffer.concat([cipher.update(bytes.subarray(28)),cipher.final()]).toString() },
+  }
+  try {
+    let vault = new DesktopLoginSessions(options)
+    const origin = 'https://server.example', cookie = {name:'hermes_yaoyao_session',value:'opaque-revocable-token',httpOnly:true,path:'/',sameSite:'strict'}
+    await vault.save(origin,[cookie,{name:'csrf',value:'temporary'}])
+    assert.ok(!(await readFile(vault.path(origin))).includes(Buffer.from(cookie.value)))
+    assert.equal((await stat(vault.path(origin))).mode & 0o777, 0o600)
+    vault = new DesktopLoginSessions(options)
+    assert.deepEqual(await vault.read(origin),{origin,cookie})
+    assert.equal(await vault.read('https://other.example'),undefined)
+    const saving = vault.save(origin,[cookie]); const forgetting = vault.forget(origin)
+    await Promise.all([saving,forgetting])
+    assert.equal(await new DesktopLoginSessions(options).read(origin),undefined)
+  } finally {await rm(home,{recursive:true,force:true})}
+})

+ 79 - 11
desktop/main.mjs

@@ -11,7 +11,8 @@ import { synchronizeLocalService, stopLocalService, migrateLocalData } from './s
 import { resolveDataHome } from './data-home.mjs'
 import { DesktopEnvironmentHost, DesktopHostCore } from './environment-host.mjs'
 import { DesktopHostManager } from './host-manager.mjs'
-import { remoteRegistration, remoteSession, enrollDesktopHost, inspectServer } from './remote-login.mjs'
+import { remoteRegistration, remoteSession, enrollDesktopHost, inspectServer, persistRemoteSession, sessionCookieDetails } from './remote-login.mjs'
+import { DesktopLoginSessions } from './login-sessions.mjs'
 import { DesktopOnboarding } from './onboarding.mjs'
 import { DesktopUpdateManager } from './update-manager.mjs'
 import { DesktopAutoUpdateManager } from './auto-update-manager.mjs'
@@ -34,6 +35,7 @@ else {
   let remoteMode = false, remoteURL = '', serverModeActive = false, switchingMode = false
   let recoveringService = false
   let resumeLocalAfterUpdate = false
+  let checkRemoteAuthorization = async () => {}
   const serviceURL = () => remoteMode ? remoteURL : manager?.state.url
   // Use the same Chromium networking as server detection/navigation. Auth owns
   // its cookie jar: do not send or overwrite another account's browser cookies.
@@ -251,7 +253,7 @@ else {
             }
           }
           if (manager) stateChanged(manager.state)
-          timer=setInterval(()=>{if(serverModeActive)void manager.check()},10000);timer.unref()
+          timer=setInterval(()=>{if(serverModeActive)void manager.check();else void checkRemoteAuthorization()},10000);timer.unref()
           updater?.startChecking?.()
           show()
         }
@@ -284,7 +286,7 @@ else {
       }
     }
     clearInterval(timer)
-    timer = setInterval(() => { if (serverModeActive) void manager?.check() }, 10000); timer.unref()
+    timer = setInterval(() => { if (serverModeActive) void manager?.check(); else void checkRemoteAuthorization() }, 10000); timer.unref()
     window?.show(); updateWindow?.show()
   }
   app.on('before-quit', event => {
@@ -313,6 +315,46 @@ else {
     }
     app.setAboutPanelOptions({applicationName:'夭夭',applicationVersion:packageVersion,version:`${String(buildInfo.commit).slice(0,12)}${buildInfo.dirty?' · 工作区快照':''}`})
     const credentials=new DesktopCredentials({home,platform:platform.platform,safeStorage,helper:join(root,app.isPackaged?'keychain-helper':'keychain-helper-dev'),legacyDecrypt:async bytes=>(await safeStorage.decryptStringAsync(bytes)).result})
+    const loginSessions = new DesktopLoginSessions({ home, encrypt: value => credentials.encrypt(value), decrypt: value => credentials.decrypt(value) })
+    async function installSessionCookies(server, cookies) {
+      for (const cookie of cookies) await electronSession.defaultSession.cookies.set({ url: server, ...cookie,
+        httpOnly: true, secure: cookie.secure || server.startsWith('https://') })
+      await electronSession.defaultSession.cookies.flushStore()
+    }
+    // Serialize vault + Chromium changes so an in-flight restore cannot undo logout.
+    let loginOperation = Promise.resolve()
+    function synchronizeLogin(run) {
+      const next = loginOperation.then(run)
+      loginOperation = next.catch(() => {})
+      return next
+    }
+    async function clearLogin(server) {
+      await loginSessions.forget(server)
+      await electronSession.defaultSession.cookies.remove(server, 'hermes_yaoyao_session')
+      await electronSession.defaultSession.cookies.flushStore()
+    }
+    function forgetLogin(server) { return synchronizeLogin(() => clearLogin(server)) }
+    async function inspectRemote(server) {
+      return synchronizeLogin(async () => {
+        const saved = await loginSessions.read(server)
+        const info = await inspectServer(server, saved
+          ? (url, options) => authFetch(url, { ...options, headers: { ...options.headers, cookie: `${saved.cookie.name}=${saved.cookie.value}` } })
+          : (...args) => net.fetch(...args))
+        if (!info.authenticated) {
+          if (saved) await clearLogin(server)
+          return info
+        }
+        if (saved) {
+          await installSessionCookies(server, [{ ...saved.cookie, expirationDate: Date.now() / 1000 + 400 * 86400 }])
+        } else {
+          const cookies = await electronSession.defaultSession.cookies.get({ url: server })
+          const persistent = await persistRemoteSession(server, info, cookies, authFetch)
+          await loginSessions.save(server, persistent)
+          await installSessionCookies(server, persistent)
+        }
+        return info
+      })
+    }
     if (platform.server) {
     manager = new DesktopServiceManager({ home, port, version: packageVersion, log, onState: stateChanged,
       prepareHome: !fixtureHome ? onProgress => migrateLocalData({ home, port, root, onProgress }) : undefined,
@@ -426,7 +468,12 @@ else {
     onboarding = new DesktopOnboarding({
       platform: platform.platform, supportedModes: platform.supportedModes,
       remoteServer: () => preferences.value.remoteServer,
-      inspect: server => inspectServer(server, (...args) => net.fetch(...args)),
+      inspect: inspectRemote,
+      localSession: async server => {
+        const session = await manager.adminSession()
+        await installSessionCookies(server, sessionCookieDetails(session.setCookies))
+        return { authenticated: true, user: session.user }
+      },
       prepareLocal: async ({ force }) => {
         if (!platform.server) throw new Error('此客户端不支持在本机运行服务器')
         serverModeActive = true; remoteMode = false; remoteURL = ''
@@ -441,11 +488,8 @@ else {
       authenticate: async ({ mode, serverURL, setup, username, password }) => {
         authorizedOnboardingServer = ''
         const auth = await remoteSession(serverURL, { setup, username, password }, authFetch)
-        for (const cookie of auth.cookieDetails) {
-          await electronSession.defaultSession.cookies.set({ url: serverURL, ...cookie,
-            httpOnly: true, secure: cookie.secure || serverURL.startsWith('https://') })
-        }
-        await electronSession.defaultSession.cookies.flushStore()
+        if (mode === 'remote') await loginSessions.save(serverURL, auth.cookieDetails)
+        await installSessionCookies(serverURL, auth.cookieDetails)
         let warning
         if (mode === 'remote' && auth.user.role === 'admin') {
           try { await authorizeComputer(serverURL, auth); authorizedOnboardingServer = serverURL }
@@ -516,6 +560,30 @@ else {
       // A session expiry returns to the same main-page flow for the active server.
       return openOnboarding(remoteMode ? 'remote' : 'local', { autoPrepare: true })
     })
+    ipcMain.handle('desktop:forget-login', async event => {
+      requireModePage(event)
+      if (remoteMode && remoteURL) {
+        await forgetLogin(remoteURL)
+        await hostManager.stop()
+      }
+    })
+    let checkingAuthorization = false
+    checkRemoteAuthorization = async () => {
+      if (checkingAuthorization || closing || quitting || !remoteMode || onboarding.state.active) return
+      checkingAuthorization = true
+      const server = remoteURL
+      try {
+        const info = await inspectServer(server, (...args) => net.fetch(...args))
+        if (!info.authenticated && remoteURL === server && !onboarding.state.active && !closing && !quitting) {
+          // Chromium may expire its copy; the native authorization remains authoritative.
+          if ((await inspectRemote(server)).authenticated) return
+          await forgetLogin(server)
+          await hostManager.stop()
+          await openOnboarding('remote', { autoPrepare: true })
+        }
+      } catch { /* Network loss does not revoke a saved login. */ }
+      finally { checkingAuthorization = false }
+    }
     function syncModeMenu(){
       const menu=Menu.getApplicationMenu()
       const useRemote=menu?.getMenuItemById('desktop-host-use-remote'),useLocal=menu?.getMenuItemById('desktop-host-use-local'),ask=menu?.getMenuItemById('desktop-host-ask')
@@ -597,8 +665,8 @@ else {
       remember: preferences.value.startupChoice !== 'ask', restoring: restoreSession })
     // Finish the initial navigation before a remembered session enters its server.
     await createWindow()
-    powerMonitor.on('resume', () => { if (!closing && !quitting && serverModeActive) void manager.check() })
-    timer = setInterval(() => { if (serverModeActive) void manager.check() }, 10_000); timer.unref()
+    powerMonitor.on('resume', () => { if (!closing && !quitting) { if (serverModeActive) void manager.check(); else void checkRemoteAuthorization() } })
+    timer = setInterval(() => { if (serverModeActive) void manager.check(); else void checkRemoteAuthorization() }, 10_000); timer.unref()
     syncModeMenu()
     if (restoreSession)
       await onboarding.prepare({ autoEnter: true })

+ 8 - 8
desktop/mode-switch.test.mjs

@@ -15,6 +15,7 @@ test('signed-in desktop pages can switch both ways; other windows cannot control
   const identityReady=new Promise(resolve=>{releaseIdentity=resolve})
   const local=createServer(async(req,res)=>{
     if(req.url==='/desktop/service'){await identityReady;res.setHeader('Content-Type','application/json');res.end(JSON.stringify(identity))}
+    else if(req.url==='/desktop/service/session'){res.setHeader('Content-Type','application/json');res.end(JSON.stringify({user:{username:'admin',role:'admin'},setCookies:['hermes_yaoyao_session=native; Path=/; HttpOnly']}))}
     else if(req.url.split('?')[0]==='/api/app/bootstrap'){res.setHeader('Content-Type','application/json');res.end(JSON.stringify({authenticated:true,csrfToken:'fixture'}))}
     else {res.setHeader('Content-Type','text/html; charset=utf-8');res.end('<title>已登录的本机页面</title>')}
   })
@@ -23,14 +24,15 @@ test('signed-in desktop pages can switch both ways; other windows cannot control
   const remote=createServer((_req,res)=>{
     if(_req.url.split('?')[0]==='/api/app/bootstrap'){
       res.setHeader('Content-Type','application/json')
-      res.setHeader('Set-Cookie',['session=remembered; Path=/; HttpOnly','csrf=fixture; Path=/; HttpOnly'])
+      res.setHeader('Set-Cookie',['hermes_yaoyao_session=remembered; Path=/; HttpOnly','csrf=fixture; Path=/; HttpOnly'])
       res.end(JSON.stringify({authenticated:true,csrfToken:'fixture',user:{id:'admin',role:'admin',username:'管理员'}}));return
     }
+    if(_req.url==='/api/app/desktop-session'){res.statusCode=404;res.end();return}
     if(_req.url==='/api/app/admin/desktop-hosts'&&_req.method==='POST'){
       enrollments++
       assert.equal(_req.headers.origin,remoteURL)
       assert.equal(_req.headers['x-csrf-token'],'fixture')
-      assert.match(_req.headers.cookie,/session=remembered/)
+      assert.match(_req.headers.cookie,/hermes_yaoyao_session=remembered/)
       res.setHeader('Content-Type','application/json')
       if(enrollmentFails){res.statusCode=503;res.end(JSON.stringify({error:'fixture enrollment unavailable'}));return}
       res.statusCode=201;res.end(JSON.stringify({host:{id:deviceId},token:deviceToken}));return
@@ -84,17 +86,14 @@ test('signed-in desktop pages can switch both ways; other windows cannot control
     await page.evaluate(()=>{void window.yaoyaoDesktop.openRemoteLogin()})
     await page.waitForURL('**/boot.html')
     assert.equal(app.windows().length,1)
+    await page.locator('#back').click()
     await page.getByRole('radio',{name:/本机运行/}).check()
+    await page.locator('#continue').click()
     await page.locator('#prepare-local').click()
-    await page.locator('#submit').click()
     await page.waitForURL(origin+'/**')
     enrollmentFails = false
     await app.evaluate(async (_electron, credentialsModule) => {
-      // Fixture credentials stay in this temporary directory; do not access Keychain.
       const module = process.getBuiltinModule('module')
-      const { DesktopCredentials } = module.createRequire(credentialsModule)(credentialsModule)
-      DesktopCredentials.prototype.encrypt = async value => Buffer.from(value)
-      DesktopCredentials.prototype.decrypt = async value => value.toString('utf8')
       globalThis.fetch = async () => { throw new Error('fixture Node transport is unavailable') }
       const http = process.getBuiltinModule('http')
       const originalRequest = http.request
@@ -111,9 +110,10 @@ test('signed-in desktop pages can switch both ways; other windows cannot control
     await page.evaluate(()=>{void window.yaoyaoDesktop.openRemoteLogin()})
     await page.waitForURL('**/boot.html')
     assert.equal(app.windows().length,1)
+    await page.locator('#back').click()
     await page.getByRole('radio',{name:/本机运行/}).check()
+    await page.locator('#continue').click()
     await page.locator('#prepare-local').click()
-    await page.locator('#submit').click()
     await page.waitForURL(origin+'/**')
     await expect.poll(()=>page.evaluate(()=>window.yaoyaoDesktop.modeState())).toEqual({mode:'server',serverURL:origin,switching:false,platform:'darwin',supportedModes:['client','server']})
   }finally{

+ 9 - 7
desktop/onboarding-flow.test.mjs

@@ -17,7 +17,7 @@ test('first launch, failed connection and remote login all stay in the only main
     const json = (value, status = 200) => { res.statusCode = status; res.setHeader('content-type', 'application/json'); res.end(JSON.stringify(value)) }
     if (req.url.split('?')[0] === '/api/app/bootstrap') {
       res.setHeader('set-cookie', 'csrf=fixture; Path=/; HttpOnly')
-      const reply = () => json({ authenticated: sessionValid && String(req.headers.cookie).includes('session=fixture'), setupRequired: false, registrationAvailable: true, csrfToken: 'fixture', serverKind: 'yaoyao-web' })
+      const reply = () => json({ authenticated: sessionValid && String(req.headers.cookie).includes('hermes_yaoyao_session=fixture'), setupRequired: false, registrationAvailable: true, csrfToken: 'fixture', serverKind: 'yaoyao-web' })
       if (holdBootstrap) { replyBootstrap = reply; return }
       return reply()
     }
@@ -38,7 +38,7 @@ test('first launch, failed connection and remote login all stay in the only main
         assert.ok(!String(req.headers.cookie).includes('session='))
         if (body.username !== 'user' || body.password !== 'correct-password') return json({ error: 'wrong' }, 401)
         if (req.headers['x-csrf-token'] !== 'fixture' || !String(req.headers.cookie).includes('csrf=fixture')) return json({ error: 'csrf' }, 403)
-        res.setHeader('set-cookie', 'session=fixture; Path=/; HttpOnly; Max-Age=3600; SameSite=Strict')
+        res.setHeader('set-cookie', 'hermes_yaoyao_session=fixture; Path=/; HttpOnly; Max-Age=3600; SameSite=Strict')
         json({ user: { id: 'fixture-user', username: 'user', role: loginRole }, csrfToken: 'fixture' })
       }); return
     }
@@ -46,7 +46,7 @@ test('first launch, failed connection and remote login all stay in the only main
       enrollmentCount++
       assert.equal(req.headers.origin, url)
       assert.equal(req.headers['x-csrf-token'], 'fixture')
-      assert.ok(String(req.headers.cookie).includes('session=fixture'))
+      assert.ok(String(req.headers.cookie).includes('hermes_yaoyao_session=fixture'))
       // Exercise authorization without provisioning a real computer in the fixture.
       return json({ error: 'enrollment unavailable' }, 503)
     }
@@ -63,13 +63,15 @@ test('first launch, failed connection and remote login all stay in the only main
     // are unavailable (for example, after a macOS local-network permission change).
     await app.evaluate(({ session }, origin) => {
       globalThis.fetch = async () => { throw new TypeError('fetch failed', { cause: { code: 'EHOSTUNREACH' } }) }
-      return session.defaultSession.cookies.set({ url: origin, name: 'session', value: 'previous-account', httpOnly: true })
+      return session.defaultSession.cookies.set({ url: origin, name: 'hermes_yaoyao_session', value: 'previous-account', httpOnly: true })
     }, url)
     const errors = []; page.on('pageerror', error => errors.push(error.message))
     await expect(page.getByRole('heading', { name: '开始使用夭夭' })).toBeVisible()
     assert.equal(app.windows().length, 1)
     assert.equal((await page.evaluate(() => window.yaoyaoDesktop.status())).mode, null)
     await page.getByRole('radio', { name: /连接远程服务器/ }).check()
+    await page.locator('#remember').check()
+    await page.locator('#continue').click()
     await page.locator('#server').fill('http://127.0.0.1:1')
     await page.locator('#detect').click()
     await expect(page.locator('#status[role=alert]')).toBeVisible()
@@ -89,7 +91,7 @@ test('first launch, failed connection and remote login all stay in the only main
     assert.equal(registrationCount, 1)
     assert.equal(loginCount, 0, 'registration never signs in or authorizes this computer')
     assert.equal(await app.evaluate(async ({ session }, origin) =>
-      (await session.defaultSession.cookies.get({ url: origin, name: 'session' }))[0]?.value, url),
+      (await session.defaultSession.cookies.get({ url: origin, name: 'hermes_yaoyao_session' }))[0]?.value, url),
     'previous-account', 'registration does not replace the active browser session')
     assert.match(page.url(), /boot.html$/)
     await page.locator('#username').fill('user')
@@ -98,6 +100,7 @@ test('first launch, failed connection and remote login all stay in the only main
     await expect(page.locator('#login-error')).toContainText('用户名或密码不正确')
     assert.equal(await page.locator('#password').inputValue(), '')
     // Editing the address invalidates the checked target and hides credentials.
+    await page.locator('#change-server').click()
     await page.locator('#server').fill('http://another-server.test')
     await expect(page.locator('#login-form')).toBeHidden()
     await page.locator('#server').fill(url); await page.locator('#detect').click()
@@ -107,7 +110,6 @@ test('first launch, failed connection and remote login all stay in the only main
     await page.locator('#toggle-password').click()
     assert.equal(await page.locator('#password').getAttribute('type'), 'text')
     await page.locator('#toggle-password').click()
-    await page.locator('#remember').check()
     await mkdir(join(root, 'docs/design/desktop-onboarding'), { recursive: true })
     await page.emulateMedia({ colorScheme: 'light' })
     assert.equal(await page.evaluate(() => document.querySelector('footer').getBoundingClientRect().bottom <= innerHeight), true, 'default window shows its footer without scrolling')
@@ -157,7 +159,7 @@ test('first launch, failed connection and remote login all stay in the only main
       console.log('Fixture cookie metadata:', await app.evaluate(async ({ session }, url) => (await session.defaultSession.cookies.get({ url })).map(({ name, expirationDate, sameSite, session }) => ({ name, expirationDate, sameSite, session })), url))
       throw error
     }
-    assert.equal(loginCount, 2, 'a remembered session keeps the server-provided cookie expiry and is reused')
+    assert.equal(loginCount, 2, 'a remembered authorization is restored without another password login')
     assert.equal(guideFlashed, false, 'restoring a valid session never displays the guide')
     // An expired session still returns to the same page with usable login fields.
     sessionValid = false

+ 27 - 0
desktop/onboarding-renderer.test.mjs

@@ -31,3 +31,30 @@ test('the first paint and every restoring snapshot hide the entire login guide',
     assert.equal(window.document.getElementById('login-form').hidden, false)
   } finally { window.close() }
 })
+
+test('only the active page is shown; back retains server and remembered choice; expired sessions open login directly', async () => {
+  const { DesktopOnboarding } = await import('./onboarding.mjs')
+  const html = await readFile(new URL('./boot.html', import.meta.url), 'utf8')
+  const script = await readFile(new URL('./boot.js', import.meta.url), 'utf8')
+  const dom = new JSDOM(html, {runScripts:'outside-only',url:'https://preview.test'})
+  const w=dom.window, $=id=>w.document.getElementById(id)
+  const flow = new DesktopOnboarding({ remoteServer:()=>'',inspect:async()=>({authenticated:false}) })
+  w.yaoyaoDesktop={status:async()=>flow.snapshot(),selectServer:async mode=>flow.select(mode),prepareServer:input=>flow.prepare(input)}
+  w.setInterval=()=>1
+  const tick=()=>new Promise(resolve=>setImmediate(resolve))
+  const visible=()=>['choice-section','prepare-section','account-section'].filter(id=>!$(id).hidden)
+  try {
+    w.eval(script);await tick()
+    assert.deepEqual(visible(),['choice-section'])
+    w.document.querySelector('[value=remote]').click();$('remember').checked=true;$('continue').click();await tick()
+    assert.deepEqual(visible(),['prepare-section'])
+    $('server').value='https://server.test';$('server').dispatchEvent(new w.Event('input'))
+    $('connection-form').dispatchEvent(new w.Event('submit',{cancelable:true}));await tick()
+    assert.deepEqual(visible(),['account-section'])
+    $('change-server').click();await tick()
+    assert.deepEqual(visible(),['prepare-section'])
+    assert.equal($('server').value,'https://server.test');assert.equal($('remember').checked,true)
+    $('back').click();await tick()
+    assert.deepEqual(visible(),['choice-section'])
+  } finally {w.close()}
+})

+ 4 - 2
desktop/onboarding.mjs

@@ -20,6 +20,7 @@ export class DesktopOnboarding {
     return this.snapshot()
   }
   select(mode) {
+    if (mode === null) return this.open({ remember: this.state.remember })
     if (!['local', 'remote'].includes(mode)) throw new Error('请选择运行方式')
     return this.open({ mode, serverURL: this.options.remoteServer(), remember: this.state.remember })
   }
@@ -53,12 +54,13 @@ export class DesktopOnboarding {
     try {
       const target = mode === 'local' ? await this.options.prepareLocal({ force }) : normalizeServerURL(serverURL)
       this.state.serverURL = target
-      const info = await this.options.inspect(target)
+      const info = mode === 'local' && this.options.localSession
+        ? await this.options.localSession(target) : await this.options.inspect(target)
       if (mode === 'remote' && info.setupRequired) throw new Error('远程服务器尚未初始化,请先在服务器本机创建管理员,再重新检测。')
       Object.assign(this.state, { phase: 'ready', stage: 'ready', setupRequired: info.setupRequired === true, registrationAvailable: info.registrationAvailable === true,
         authenticated: info.authenticated === true && !this.state.forceLogin, username: info.user?.username || '',
         message: mode === 'local' ? '本机服务已就绪' : '连接成功,服务器已就绪' })
-      if (autoEnter && this.state.authenticated) await this.enter()
+      if ((autoEnter || mode === 'local' && this.options.localSession) && this.state.authenticated) await this.enter()
     } catch (error) {
       this.state.phase = 'error'
       this.state.error = error.message || '准备未完成,请重试'

+ 12 - 0
desktop/onboarding.test.mjs

@@ -119,3 +119,15 @@ test('registration stays anonymous and does not enter or enroll a computer', asy
   await flow.prepare({ serverURL: 'http://another-server.test' })
   assert.equal(flow.snapshot().registrationNotice, '', 'registration belongs to the checked server')
 })
+
+test('local desktop entry uses native administrator authorization and skips all account forms', async () => {
+  const { flow, calls } = fixture({
+    localSession: async () => ({ authenticated: true, user: { username: 'admin', role: 'admin' } }),
+    inspect: async () => { throw new Error('local mode must not use remote login') },
+    authenticate: async () => { throw new Error('must not request a password') },
+  })
+  flow.select('local'); await flow.prepare({ remember: true })
+  assert.equal(flow.snapshot().phase, 'complete')
+  assert.equal(flow.snapshot().active, false)
+  assert.deepEqual(calls, ['prepare', {mode:'local',remember:true}, 'http://127.0.0.1:15300'])
+})

+ 1 - 0
desktop/preload.cjs

@@ -5,6 +5,7 @@ contextBridge.exposeInMainWorld('yaoyaoDesktop', Object.freeze({
   prepareServer: input => ipcRenderer.invoke('desktop:onboarding-prepare', input),
   login: input => ipcRenderer.invoke('desktop:onboarding-submit', input),
   openLogin: () => ipcRenderer.invoke('desktop:open-login'),
+  forgetLogin: () => ipcRenderer.invoke('desktop:forget-login'),
   retry: () => ipcRenderer.invoke('desktop:retry'),
   forceSync: () => ipcRenderer.invoke('desktop:force-sync'),
   logs: () => ipcRenderer.invoke('desktop:logs'),

+ 20 - 1
desktop/remote-login.mjs

@@ -60,6 +60,25 @@ export async function inspectServer(serverURL,fetchImpl=globalThis.fetch){
   return info
 }
 
+export function sessionCookieDetails(setCookies) {
+  const jar = new CookieJar()
+  jar.absorb({ headers: { getSetCookie: () => Array.isArray(setCookies) ? setCookies : [setCookies].filter(Boolean) } })
+  return [...jar.details.values()]
+}
+
+export async function persistRemoteSession(serverURL, info, cookies, fetchImpl=globalThis.fetch) {
+  const base = normalizeServerURL(serverURL)
+  const response = await requestServer(fetchImpl, new URL('/api/app/desktop-session', base), {
+    method: 'POST', redirect: 'error', signal: AbortSignal.timeout(10000),
+    headers: { 'content-type': 'application/json', origin: base, 'x-csrf-token': info.csrfToken,
+      cookie: cookies.map(({name,value}) => `${name}=${value}`).join('; ') }, body: '{}',
+  })
+  // Older servers retain their original session lifetime until upgraded.
+  if (response.status === 404) return cookies
+  if (!response.ok) throw new Error('无法保存桌面登录授权,请重新登录')
+  return sessionCookieDetails(response.headers.getSetCookie())
+}
+
 export async function remoteSession(serverURL,{username,password,setup=false},fetchImpl=globalThis.fetch){
   const base=normalizeServerURL(serverURL)
   const user=String(username??'').trim(),pass=String(password??'')
@@ -73,7 +92,7 @@ export async function remoteSession(serverURL,{username,password,setup=false},fe
   const response=await requestServer(fetchImpl,new URL(setup?'/api/app/setup':'/api/app/login',base),{
     method:'POST',redirect:'error',signal:AbortSignal.timeout(15000),
     headers:{accept:'application/json','content-type':'application/json',cookie:jar.header(),'x-csrf-token':info.csrfToken,origin:base},
-    body:JSON.stringify({username:user,password:pass}),
+    body:JSON.stringify({username:user,password:pass,desktop:true}),
   })
   jar.absorb(response)
   if(!setup&&(response.status===401||response.status===403)) {

+ 20 - 1
desktop/remote-login.test.mjs

@@ -2,7 +2,7 @@ import {test} from 'node:test'
 import assert from 'node:assert/strict'
 import {createServer} from 'node:http'
 import {randomUUID,randomBytes} from 'node:crypto'
-import {remoteSession,enrollDesktopHost,normalizeServerURL,inspectServer} from './remote-login.mjs'
+import {remoteSession,enrollDesktopHost,normalizeServerURL,inspectServer,persistRemoteSession} from './remote-login.mjs'
 
 test('server inspection requests local login metadata without binding Hermes', async () => {
   const calls = []
@@ -67,6 +67,7 @@ test('logs in with username and password and enrolls this Mac as a computer',asy
     assert.equal(bootstrap.url,'/api/app/bootstrap')
     assert.equal(login.origin,fake.url)
     assert.equal(login.csrf,fake.csrf)
+    assert.equal(JSON.parse(login.body).desktop,true)
     assert.equal(enroll.csrf,fake.csrf+'-rotated')
     assert.ok(enroll.cookie.includes(`session=${fake.session}`))
     assert.deepEqual(JSON.parse(enroll.body),{name:'书房 iMac',installId,previousHostId})
@@ -75,6 +76,24 @@ test('logs in with username and password and enrolls this Mac as a computer',asy
   }
 })
 
+test('existing desktop sessions upgrade with CSRF and retain only server-issued cookie policy', async () => {
+  const cookies = [{name:'hermes_yaoyao_session',value:'old'},{name:'csrf',value:'csrf-cookie'}]
+  const info = {csrfToken:'csrf-token'}
+  const upgraded = await persistRemoteSession('https://server.test', info, cookies, async (url, options) => {
+    assert.equal(url.pathname,'/api/app/desktop-session')
+    assert.equal(options.method,'POST')
+    assert.equal(options.headers.origin,'https://server.test')
+    assert.equal(options.headers['x-csrf-token'],'csrf-token')
+    assert.equal(options.headers.cookie,'hermes_yaoyao_session=old; csrf=csrf-cookie')
+    return Response.json({}, {headers:{'set-cookie':'hermes_yaoyao_session=new; Path=/; Max-Age=34560000; HttpOnly; Secure; SameSite=Strict'}})
+  })
+  assert.equal(upgraded[0].value,'new')
+  assert.equal(upgraded[0].httpOnly,true)
+  assert.equal(upgraded[0].sameSite,'strict')
+  assert.deepEqual(await persistRemoteSession('https://server.test', info, cookies, async () => new Response(null,{status:404})),cookies)
+  await assert.rejects(() => persistRemoteSession('https://server.test', info, cookies, async () => new Response(null,{status:401})),/重新登录/)
+})
+
 test('wrong credentials and non-admin accounts surface clear errors',async()=>{
   const fake=await fakeServer()
   try{

+ 9 - 0
desktop/service-manager.mjs

@@ -99,6 +99,15 @@ export class DesktopServiceManager {
     if (this.stopping || generation !== this.generation) throw new Error('本机服务状态已改变,请重试')
     this.activationRequested = true
   }
+  async adminSession() {
+    const generation = this.generation, record = await this.readRecord()
+    if (!record) throw new Error('请更新本机 Web 服务以启用管理员免登录')
+    const service = await this.verify(record)
+    if (this.stopping || generation !== this.generation) throw new Error('本机服务状态已改变,请重试')
+    const session = await localJSON(new URL('/desktop/service/session', service.url), { 'x-yaoyao-desktop-token': record.token }, 'POST')
+    if (this.stopping || generation !== this.generation) throw new Error('本机服务状态已改变,请重试')
+    return session
+  }
   async activateRecord(record, service) {
     if (service.activationRequired)
       await localJSON(new URL('/desktop/service/activate', service.url), { 'x-yaoyao-desktop-token': record.token }, 'POST')

+ 10 - 6
desktop/test-support/onboarding.mjs

@@ -1,8 +1,12 @@
-/** Complete the real native first-run form; never bypass authentication. */
+/** Enter through the native local-admin flow, then set isolated fixture credentials. */
 export async function enterLocal(page, { username = 'desktop-fixture', password = 'desktop-fixture-password' } = {}) {
-  await page.locator('#login-form').waitFor({ timeout: 90000 })
-  await page.locator('#username').fill(username)
-  await page.locator('#password').fill(password)
-  if (await page.locator('#confirmation-field').isVisible()) await page.locator('#confirmation').fill(password)
-  await page.locator('#submit').click()
+  await page.waitForURL(url => url.protocol === 'http:' || url.protocol === 'https:', { timeout: 90000 })
+  await page.evaluate(async ({username,password}) => {
+    const bootstrap = await (await fetch('/api/app/bootstrap')).json()
+    if (!bootstrap.user?.localDesktop) throw new Error('Native administrator session was not established')
+    const response = await fetch('/api/app/account/credentials', { method: 'PUT',
+      headers: {'Content-Type':'application/json','X-CSRF-Token':bootstrap.csrfToken},
+      body: JSON.stringify({username,currentPassword:'',newPassword:password}) })
+    if (!response.ok) throw new Error('Fixture account configuration failed')
+  }, {username,password})
 }

+ 4 - 3
desktop/windows-smoke.test.mjs

@@ -14,10 +14,10 @@ test('packaged Windows client logs in, encrypts pairing, restores its session an
     const json = (body, status = 200) => { res.statusCode = status; res.setHeader('content-type', 'application/json'); res.end(JSON.stringify(body)) }
     if (req.url.split('?')[0] === '/api/app/bootstrap') {
       res.setHeader('set-cookie', 'csrf=fixture; Path=/; HttpOnly')
-      return json({ authenticated: String(req.headers.cookie).includes('session=fixture'), setupRequired: false, csrfToken: 'fixture', serverKind: 'yaoyao-web' })
+      return json({ authenticated: String(req.headers.cookie).includes('hermes_yaoyao_session=fixture'), setupRequired: false, csrfToken: 'fixture', serverKind: 'yaoyao-web' })
     }
     if (req.url === '/api/app/login') {
-      req.resume(); res.setHeader('set-cookie', 'session=fixture; Path=/; HttpOnly; Max-Age=3600')
+      req.resume(); res.setHeader('set-cookie', 'hermes_yaoyao_session=fixture; Path=/; HttpOnly; Max-Age=3600')
       return json({ user: { id: 'fixture', username: 'admin', role: 'admin' }, csrfToken: 'fixture' })
     }
     if (req.url === '/api/app/admin/desktop-hosts') { req.resume(); return json({ host: { id: hostId }, token }) }
@@ -41,9 +41,10 @@ test('packaged Windows client logs in, encrypts pairing, restores its session an
     await expect(page.locator('#choice-section')).toBeHidden()
     assert.deepEqual((await page.evaluate(() => window.yaoyaoDesktop.status())).supportedModes, ['client'])
     assert.equal(await page.evaluate(async () => { try { await window.yaoyaoDesktop.selectServer('local'); return false } catch { return true } }), true)
+    await page.locator('#remember').check()
     await page.locator('#server').fill(url); await page.locator('#detect').click()
     await expect(page.locator('#login-form')).toBeVisible()
-    await page.locator('#username').fill('admin'); await page.locator('#password').fill('fixture-password'); await page.locator('#remember').check(); await page.locator('#submit').click()
+    await page.locator('#username').fill('admin'); await page.locator('#password').fill('fixture-password'); await page.locator('#submit').click()
     await page.waitForURL(url + '/**')
     const state = await page.evaluate(() => window.yaoyaoDesktop.modeState())
     assert.equal(state.mode, 'client'); assert.deepEqual(state.supportedModes, ['client'])

+ 1 - 1
docker.env.example

@@ -3,7 +3,7 @@ HERMES_YAOYAO_BIND_ADDRESS=127.0.0.1
 HERMES_YAOYAO_PUBLISHED_PORT=15300
 # 远程通用镜像支持 AMD64 / ARM64;每次发布更新 latest 并保留旧版本标签。
 # HERMES_YAOYAO_IMAGE=samienluo/yaoyao:latest
-# 固定版本:samienluo/yaoyao:v0.4.73;仅 AMD64:samienluo/yaoyao:v0.4.73-amd。
+# 固定版本:samienluo/yaoyao:v0.4.74;仅 AMD64:samienluo/yaoyao:v0.4.74-amd。
 # 留空时使用本地构建名称 yaoyao:local。
 # YAOYAO_DESKTOP_IMAGE=hermes-yaoyao-desktop:0.4.0
 # Cursor Universal 兼容桌面的本地构建名称;基础镜像在 deploy/computer/Dockerfile.cursor 中固定。

+ 29 - 0
docs/design/desktop-login-steps/README.md

@@ -0,0 +1,29 @@
+# 桌面分步登录 · 方案 1
+
+用户选择 `approved-design.png` 的极简向导:每次只显示当前一步,远程流程为「选择方式 → 连接服务器 → 登录账号」。返回保留选择、服务器地址和用户名;修改服务器地址会清除凭据,并要求重新检测。
+
+## 启动与授权
+
+- 勾选「记住本次选择」后,启动时只显示中性加载状态。记住的是本机/远程方式和服务器地址,有效授权会直接进入工作区。
+- 远程桌面登录授权独立加密保存在本机,使用现有 macOS Keychain / Windows DPAPI 凭据设施;不保存账号密码。服务端为桌面会话保存可撤销授权,不按普通网页的 30 天期限失效。
+- Chromium Cookie 保留 400 天,并可从本机加密授权恢复。服务端仍检查账号有效性和密码版本;退出、改密、账号禁用或权限撤销都会使旧授权失效。
+- 主动退出会清除本机加密授权和 Cookie。即使服务器离线,也不会在下次启动恢复该授权。保存/恢复/清除操作按顺序执行,避免恢复请求覆盖退出结果。
+- 修改密码后,远程桌面回到该服务器的登录页,不重新选择运行方式。其他桌面通过请求鉴权或最多约 10 秒的在线检查发现撤销;断网不会自行删除授权,恢复连接后再检查。
+- 新客户端连接旧服务端时仍可登录,旧服务端会话沿用旧有效期。完整的持久授权需要同步更新服务端。
+
+## 本机管理员
+
+本机流程为「选择本机 → 准备服务 → 工作区」,无需填写账号、密码或创建管理员。已有安装复用原管理员身份;新安装创建随机不可猜测密码的管理员,不提供公共默认密码。
+
+免登录入口只允许桌面主进程使用本地私有服务凭据访问:校验真实回环连接、私有令牌及无 Origin 的原生请求。普通浏览器和局域网请求仍需登录。设置页允许本机管理员直接设置供其他设备登录的账号和密码,修改后本机继续使用管理员身份。
+
+## 预览与验证
+
+运行 `node scripts/preview-desktop-onboarding.mjs` 后打开 `http://127.0.0.1:4388`。预览使用正式 HTML/CSS/JS 和流程控制器,但服务、登录和注册为模拟行为;不会连接输入的服务器。密码填 `error` 可查看错误状态。
+
+- `choice-compact.png`、`connect-compact.png`、`login-compact.png`:615 × 605,按设计画板的单页尺寸核对。
+- `choice.png`、`connect.png`、`login.png`:960 × 940,常规桌面窗口。
+- `narrow-dark.png`、`local-complete.png`:390 × 844,深色及本机无需登录的补充状态。
+- 设计验收见仓库根目录 `design-qa.md`。本次完成浏览器交互验证、相关单元测试、类型检查和 Web/服务端构建;未运行原生 Electron 完整集成测试、制作安装包或替换已安装 App。
+
+旧单页方案资料保留于 `../desktop-onboarding/`,当前行为以本文为准。

BIN
docs/design/desktop-login-steps/approved-design.png


BIN
docs/design/desktop-login-steps/choice-compact.png


BIN
docs/design/desktop-login-steps/choice.png


BIN
docs/design/desktop-login-steps/connect-compact.png


BIN
docs/design/desktop-login-steps/connect.png


BIN
docs/design/desktop-login-steps/local-complete.png


BIN
docs/design/desktop-login-steps/login-compact.png


BIN
docs/design/desktop-login-steps/login.png


BIN
docs/design/desktop-login-steps/narrow-dark.png


+ 2 - 0
docs/design/desktop-onboarding/README.md

@@ -1,5 +1,7 @@
 # 单页安装与登录
 
+> 历史方案:已由 [桌面分步登录 · 方案 1](../desktop-login-steps/README.md) 取代。以下说明与截图保留用于回溯,不代表当前实现。
+
 已确认的设计见 `approved-design.png`。桌面 App 的服务器选择、服务准备、创建管理员和登录在主窗口同一个 `boot.html` 页面中完成。
 
 - 首次启动选择本机运行或远程连接;记住选择后再次启动自动检测。

+ 7 - 7
docs/docker-install.md

@@ -12,14 +12,14 @@
 
 | 标签 | 架构 |
 | --- | --- |
-| `v0.4.73-amd` | `linux/amd64` |
-| `v0.4.73` | `linux/amd64`、`linux/arm64`,拉取时自动匹配 |
-| `latest` | 最新稳定通用镜像,当前指向与 `v0.4.73` 相同的镜像 |
+| `v0.4.74-amd` | `linux/amd64` |
+| `v0.4.74` | `linux/amd64`、`linux/arm64`,拉取时自动匹配 |
+| `latest` | 最新稳定通用镜像,当前指向与 `v0.4.74` 相同的镜像 |
 
 ```sh
-docker pull samienluo/yaoyao:v0.4.73
+docker pull samienluo/yaoyao:v0.4.74
 # 需要固定 AMD64 时:
-docker pull samienluo/yaoyao:v0.4.73-amd
+docker pull samienluo/yaoyao:v0.4.74-amd
 ```
 
 每次新版本发布都会更新 `latest`,旧版本标签继续保留。需要固定部署版本时使用具体版本标签。
@@ -34,7 +34,7 @@ cp docker.env.example docker.env
 
 | 配置 | 用途 |
 | --- | --- |
-| `HERMES_YAOYAO_IMAGE` | 远程镜像可设为 `samienluo/yaoyao:v0.4.73` 或 `samienluo/yaoyao:latest`;未设置时使用本地构建名称 |
+| `HERMES_YAOYAO_IMAGE` | 远程镜像可设为 `samienluo/yaoyao:v0.4.74` 或 `samienluo/yaoyao:latest`;未设置时使用本地构建名称 |
 | `HERMES_YAOYAO_UPSTREAM` | Hermes 上游地址,默认 `http://host.docker.internal:9119` |
 | `HERMES_YAOYAO_BIND_ADDRESS` | Web 的宿主机发布地址,默认 `127.0.0.1`;局域网访问可设为 `0.0.0.0` |
 | `HERMES_YAOYAO_PUBLISHED_PORT` | 宿主机 Web 端口,默认 `15300` |
@@ -211,7 +211,7 @@ curl --fail http://127.0.0.1:15300/healthz
 使用 Docker Hub 发布镜像时,在 `docker.env` 设置:
 
 ```dotenv
-HERMES_YAOYAO_IMAGE=samienluo/yaoyao:v0.4.73
+HERMES_YAOYAO_IMAGE=samienluo/yaoyao:v0.4.74
 YAOYAO_CURSOR_DESKTOP_IMAGE=samienluo/yaoyao-desktop:v0.4.12-cursor-amd
 ```
 

+ 51 - 0
docs/releases/v0.4.74.md

@@ -0,0 +1,51 @@
+# 夭夭 AI v0.4.74
+
+本次发布 Web、macOS Apple Silicon 正式桌面版、Windows x64 未签名测试客户端和 Docker 双架构镜像,不提供 APK。
+
+## 更新内容
+
+- 修复上游认证异常、迟到请求和桌面 Cookie 合并造成的反复登录及登录状态提前失效。
+- 统一运行环境与系统服务授权,分别设置默认执行位置和允许使用的环境;支持弹窗浏览器完成账号授权,并保留文件导入方式。
+- 支持在 Bot 对话中创建属于当前 Bot 的定时任务,完善任务归属与权限检查。
+- 增加虚拟机全局代理与独立网络网关,准确报告代理就绪失败,避免失败时绕过代理直连。
+- 正常完成任务后按空闲策略保留虚拟机;服务启动时恢复已配置的本机虚拟机。
+- 重建虚拟机时保存并迁移用户授权资料,先正常关闭桌面应用以写入最新凭据,并在迁移前核对网络身份。
+- 完善托管浏览器拖拽操作和人工连续拖动,支持拖动取消与接管交还。
+
+## 升级说明
+
+- 默认执行位置与允许使用的环境分别配置;托管浏览器的配套 Chromium 首次使用时下载。浏览器和虚拟机保持独立的登录资料。
+- 请同时更新服务端、执行节点与桌面镜像中的工具桥接程序;使用虚拟机全局代理时同步更新 Compose 配置并构建配套网络网关镜像。更新前请结束正在运行的任务。
+- 升级前备份完整 `.yaoyao` 数据目录,建议先更新服务端,再更新桌面客户端。
+- macOS 桌面使用 Developer ID Application 正式分发签名及 Apple 公证,提供完整自动更新附件;已安装正式签名版的用户可使用“下载更新 → 重启更新”,历史开发签名版请先手动安装正式 DMG。
+- Windows 为未签名测试客户端,可能提示未知发布者;安装到当前用户目录,连接已有服务器,不托管本机服务。
+- Windows 测试更新清单随独立元数据压缩包提供,不写入正式自动更新源。Windows Server CI 验证结果与尚未完成的 Windows 10/11 真机项目分别列出,详见 [客户端说明](https://github.com/Lsamien/yaoyao/blob/v0.4.74/docs/windows-client.md)。
+- 本次不构建、不上传 APK,旧版本标签和附件继续保留。
+- 主项目采用 Apache-2.0,第三方组件保留各自许可与署名。
+
+## 下载
+
+- `Yaoyao-0.4.74-arm64.dmg`:macOS Apple Silicon 首次安装或手动更新。
+- `Yaoyao-0.4.74-arm64.zip`、DMG 与 ZIP 对应的 `.blockmap`、`latest-mac.yml`:macOS 自动更新载荷与清单。
+- `Yaoyao-0.4.74-win-x64-setup.exe`:Windows x64 未签名测试安装包。
+- `Yaoyao-0.4.74-windows-test-metadata.zip`:Windows 测试更新清单、差分文件、构建身份、说明及校验值。
+- `Yaoyao-0.4.74-source.tar.gz`:Web/桌面源码及部署配置。
+- `SHA256SUMS.txt`:发布附件的 SHA-256 校验值。
+
+## Docker Hub
+
+| 镜像 | 架构 |
+| --- | --- |
+| `samienluo/yaoyao:v0.4.74-amd` | `linux/amd64` |
+| `samienluo/yaoyao:v0.4.74` | `linux/amd64`、`linux/arm64` |
+| `samienluo/yaoyao:latest` | 验证后更新为同一通用镜像 |
+
+```sh
+docker pull samienluo/yaoyao:v0.4.74
+docker compose --env-file docker.env pull web
+docker compose --env-file docker.env up -d --no-build web
+```
+
+Compose 中设置 `HERMES_YAOYAO_IMAGE=samienluo/yaoyao:v0.4.74`。镜像发布到 Docker Hub,GitHub 提供安装及更新附件、源码和校验文件。
+
+附件摘要、镜像摘要与实际验证结果见 [GitHub 发布页](https://github.com/Lsamien/yaoyao/releases/tag/v0.4.74)。

+ 1 - 1
docs/windows-client.md

@@ -15,7 +15,7 @@ Windows 首版支持 Windows 10/11 x64,安装后连接已有夭夭服务器。
 
 ## 构建与安装包
 
-v0.4.73 发布页提供未签名测试 EXE 与独立的测试更新元数据压缩包。元数据包包含 `latest.yml`、blockmap、构建身份与校验值,解压后与 EXE 放在同一目录使用;GitHub Release 根目录不提供 Windows `latest.yml`,测试包不进入正式自动更新源。
+v0.4.74 发布页提供未签名测试 EXE 与独立的测试更新元数据压缩包。元数据包包含 `latest.yml`、blockmap、构建身份与校验值,解压后与 EXE 放在同一目录使用;GitHub Release 根目录不提供 Windows `latest.yml`,测试包不进入正式自动更新源。
 
 日常可以在 Mac 修改共享代码;原生助手和 EXE 安装包在 Windows x64 上构建。需要 Node 24、CMake、Visual Studio 2022 C++ 工具链和 Windows SDK;GitHub Actions `windows-2022` 提供该构建环境。
 

+ 2 - 2
package-lock.json

@@ -1,12 +1,12 @@
 {
   "name": "yaoyao",
-  "version": "0.4.73",
+  "version": "0.4.74",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "yaoyao",
-      "version": "0.4.73",
+      "version": "0.4.74",
       "license": "Apache-2.0",
       "dependencies": {
         "@koa/bodyparser": "^5.0.0",

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "yaoyao",
-  "version": "0.4.73",
+  "version": "0.4.74",
   "private": true,
   "license": "Apache-2.0",
   "description": "夭夭 AI:聊天、多 Agent 团队、文件库与电脑工作空间",

+ 3 - 3
release.json

@@ -1,6 +1,6 @@
 {
   "schemaVersion": 1,
-  "releaseVersion": "0.4.73",
-  "webVersion": "0.4.73",
-  "gitTag": "v0.4.73"
+  "releaseVersion": "0.4.74",
+  "webVersion": "0.4.74",
+  "gitTag": "v0.4.74"
 }

+ 7 - 4
scripts/preview-desktop-onboarding.mjs

@@ -8,8 +8,11 @@ const bridge = `
 const flowReady = import('/desktop/onboarding.mjs').then(({DesktopOnboarding}) => {
   const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
   const flow = new DesktopOnboarding({
+    platform: 'darwin',
     remoteServer: () => 'http://192.168.1.10:15300',
-    inspect: async () => { await delay(400); return {authenticated:false,setupRequired:flow.state.mode==='local'}; },
+    inspect: async () => { await delay(400); return {authenticated:false,registrationAvailable:true}; },
+    localSession: async () => ({authenticated:true,user:{username:'admin',role:'admin'}}),
+    register: async () => { await delay(400); },
     prepareLocal: async () => {
       for (const [stage,message] of [['checking','正在检查运行环境…'],['installing','正在准备服务组件…'],['starting','正在启动并检查服务…']]) {
         flow.serviceChanged({phase:'starting',stage,message}); await delay(stage==='installing'?3000:1000);
@@ -18,13 +21,13 @@ const flowReady = import('/desktop/onboarding.mjs').then(({DesktopOnboarding}) =
     },
     authenticate: async ({username,password}) => { await delay(600); if(password==='error')throw new Error('用户名或密码不正确'); return {username}; },
     activate: async () => {},
-    navigate: async () => { document.querySelector('.intro h1').textContent='设置完成'; document.querySelector('.intro p').textContent='交互预览已完成,正式应用将在这里进入工作区。'; },
+    navigate: async () => { document.querySelector('#session-loading-message').textContent='预览完成:正式应用将在这里进入工作区。'; },
   });
-  flow.open({mode:'remote',serverURL:'http://192.168.1.10:15300'});
+  flow.open();
   return flow;
 });
 window.yaoyaoDesktop={status:()=>flowReady.then(f=>f.snapshot()),selectServer:mode=>flowReady.then(f=>f.select(mode)),prepareServer:input=>flowReady.then(f=>f.prepare(input)),login:input=>flowReady.then(f=>f.submit(input)),forceSync:()=>flowReady.then(f=>f.prepare()),logs:async()=>{document.getElementById('install-log').textContent+='\\n交互预览使用模拟服务,无本机安装日志。';}};
-window.addEventListener('DOMContentLoaded',()=>{document.querySelector('.brand>span').textContent='交互预览 · 模拟服务';});
+window.addEventListener('DOMContentLoaded',()=>{document.querySelector('#session-loading-message').textContent='交互预览 · 模拟服务';});
 `
 const types = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript', '.mjs': 'text/javascript', '.css': 'text/css', '.png': 'image/png', '.svg': 'image/svg+xml' }
 const server = createServer(async (req, res) => {

+ 6 - 5
src/client/components/app/AccountSecurityPanel.vue

@@ -53,7 +53,7 @@ const dirty = computed(() => (
 ))
 const canSave = computed(() => (
   !busy.value
-  && Boolean(currentPassword.value)
+  && Boolean(auth.user?.localDesktop || currentPassword.value)
   && newPassword.value.length >= 8
   && passwordsMatch.value
   && (!isAdmin.value || Boolean(username.value.trim()))
@@ -74,7 +74,7 @@ function reset() {
 async function save() {
   const normalizedUsername = username.value.trim()
   if (isAdmin.value && !normalizedUsername) { error.value = '管理员用户名不能为空'; return }
-  if (!currentPassword.value) { error.value = '请输入当前密码'; return }
+  if (!auth.user?.localDesktop && !currentPassword.value) { error.value = '请输入当前密码'; return }
   if (newPassword.value.length < 8) { error.value = '新密码至少需要 8 个字符'; return }
   if (!passwordsMatch.value) { error.value = '两次输入的新密码不一致'; return }
 
@@ -202,7 +202,8 @@ watch(canSave, value => emit('can-save-change', value), { immediate: true, flush
       </label>
 
       <h3 class="security-section-title security-section-title--password">修改密码</h3>
-      <label class="field">
+      <p v-if="auth.user?.localDesktop" class="muted">本机以管理员身份使用,无需登录。这里设置的密码用于其他设备连接此服务器。</p>
+      <label v-if="!auth.user?.localDesktop" class="field">
         <span>当前密码</span>
         <input v-model="currentPassword" name="current-password" type="password" autocomplete="current-password" :disabled="busy" />
         <small>用于确认是你本人;密码不会被回显或保存在浏览器中。</small>
@@ -238,10 +239,10 @@ watch(canSave, value => emit('can-save-change', value), { immediate: true, flush
       </footer>
     </form>
 
-    <section v-if="section !== 'profile'" class="logout-card" aria-label="退出登录">
+    <section v-if="section !== 'profile' && !auth.user?.localDesktop" class="logout-card" aria-label="退出登录">
       <div>
         <strong>退出当前账号</strong>
-        <p>只退出这个浏览器中的当前 Web 会话,不会停止服务,也不会影响其他设备。</p>
+        <p>退出当前设备的登录授权,不会停止服务,也不会影响其他设备。</p>
       </div>
       <button class="logout-button" type="button" :disabled="busy" @click="emit('logout')">
         <AppIcon name="logout" :size="15" />退出当前账号

+ 1 - 0
src/client/env.d.ts

@@ -12,6 +12,7 @@ interface ImportMeta {
 interface Window {
   yaoyaoDesktop?: {
     openLogin?(): Promise<unknown>
+    forgetLogin?(): Promise<unknown>
     openUpdates?(): Promise<void>
     modeState?(): Promise<{ mode: 'client' | 'server'; serverURL: string; switching: boolean; platform?: string; supportedModes?: ('client' | 'server')[] }>
     switchMode?(mode: 'client' | 'server'): Promise<{ ok: boolean; error?: string; pendingLogin?: boolean }>

+ 3 - 0
src/client/stores/auth.ts

@@ -136,6 +136,7 @@ export const useAuthStore = defineStore('auth', () => {
 
   async function logout(): Promise<void> {
     try { await authApi.logout() } catch { /* local state still signs out */ }
+    await window.yaoyaoDesktop?.forgetLogin?.()
     status.value = 'anonymous'
     user.value = undefined
     serverIdentity.value = undefined; identityRequest++
@@ -173,7 +174,9 @@ export const useAuthStore = defineStore('auth', () => {
   }
 
   async function changeCredentials(input: { currentPassword: string; newPassword: string; username?: string }): Promise<void> {
+    const localDesktop = user.value?.localDesktop
     user.value = await authApi.changeCredentials(input)
+    if (window.yaoyaoDesktop && !localDesktop) await window.yaoyaoDesktop.forgetLogin?.()
     await bootstrap()
   }
   async function updateAccountAvatar(avatar: string | null): Promise<void> {

+ 1 - 0
src/client/utils/normalize.ts

@@ -73,6 +73,7 @@ export function normalizeUser(value: unknown): CurrentUser {
     role: string(source.role) || undefined,
     enabled: source.enabled === undefined ? undefined : bool(source.enabled),
     mustChangePassword: bool(pick(source, 'mustChangePassword', 'must_change_password')),
+    ...(source.localDesktop === true ? { localDesktop: true } : {}),
   }
 }
 

+ 4 - 1
src/server/index.ts

@@ -40,7 +40,10 @@ let closeFrontend = async (): Promise<void> => undefined
 // Native update admission runs before routes (including streamed requests).
 // The capability is loopback-only and never shared with the Web renderer.
 runtime.app.middleware.unshift(instance.middleware(shutdown, () => runtime.realtime.broker.idleForUpdate
-  && runtime.runners.idleForUpdate && runtime.workspaceRuntime.idleForUpdate && runtime.desktopEnvironments.idleForUpdate && runtime.hermesBridge.idleForUpdate, ctx=>runtime.desktopEnvironments.bridge(ctx), desktopActivation))
+  && runtime.runners.idleForUpdate && runtime.workspaceRuntime.idleForUpdate && runtime.desktopEnvironments.idleForUpdate && runtime.hermesBridge.idleForUpdate, ctx=>runtime.desktopEnvironments.bridge(ctx), desktopActivation, ctx => {
+    const user = runtime.auth.localDesktopSession(ctx)
+    ctx.body = { user, setCookies: ctx.response.get('set-cookie') }
+  }))
 
 if (runtime.config.production) {
   const dist = resolve(process.env.HERMES_YAOYAO_STATIC_DIR || resolve(process.cwd(), 'dist'))

+ 44 - 9
src/server/localAuth.ts

@@ -25,6 +25,7 @@ import { allowsLocalAuthorization, isLocalAuthorizationTarget, localSessionToken
 
 const SESSION_COOKIE = 'hermes_yaoyao_session'
 const SESSION_TTL_SECONDS = 60 * 60 * 24 * 30
+const DESKTOP_COOKIE_TTL_SECONDS = 60 * 60 * 24 * 400
 const USERNAME_PATTERN = /^[^\u0000-\u001f\u007f\s/@\\]{1,100}$/u
 
 export type LocalRole = 'admin' | 'user'
@@ -50,6 +51,8 @@ interface StoredUser {
 interface StoredUsers { version: 1; users: StoredUser[] }
 
 interface SessionRecord {
+  desktop?: boolean
+  localDesktop?: boolean
   userID: string
   authVersion: number
   expiresAt: number
@@ -57,6 +60,7 @@ interface SessionRecord {
 interface StoredSessions { version: 1; sessions: Array<SessionRecord & { tokenHash: string }> }
 
 export interface LocalUser {
+  localDesktop?: boolean
   registrationStatus?: RegistrationStatus
   id: string
   username: string
@@ -152,7 +156,7 @@ export class LocalAuthStore {
     return this.#issueSession(ctx, admin)
   }
 
-  login(ctx: Koa.Context, usernameValue: string, password: string): LocalUser {
+  login(ctx: Koa.Context, usernameValue: string, password: string, desktop = false): LocalUser {
     const normalized = usernameValue.trim().toLocaleLowerCase('en-US')
     const user = this.#users.find(candidate => candidate.normalizedUsername === normalized)
     if (!user || !this.#passwordMatches(user, password)) {
@@ -160,7 +164,27 @@ export class LocalAuthStore {
     }
     if (user.registrationStatus === 'pending') throw new HttpError(403, '账号尚未开通,请等待管理员分配机器人并开通。', 'account_pending_approval')
     if (!user.enabled) throw new HttpError(401, '用户名或密码错误', 'login_failed')
-    return this.#issueSession(ctx, user)
+    return this.#issueSession(ctx, user, desktop)
+  }
+
+  /** Called only behind the native service capability, never from public auth routes. */
+  localDesktopSession(ctx: Koa.Context): LocalUser {
+    let admin = this.#users.find(user => user.role === 'admin' && user.enabled)
+    if (!admin) {
+      if (!this.setupRequired) throw new HttpError(403, '本机管理员不可用', 'local_admin_unavailable')
+      admin = this.#newUser('admin', randomBytes(48).toString('base64url'), 'admin', false, Date.now())
+      this.#users.push(admin)
+      this.#saveUsers()
+    }
+    return this.#issueSession(ctx, admin, false, true)
+  }
+
+  persistDesktopSession(ctx: Koa.Context): LocalUser {
+    const current = this.require(ctx, true)
+    const user = this.#users.find(user => user.id === current.id)!
+    const previous = this.#token(ctx)
+    if (previous) this.#sessions.delete(this.#sessionKey(previous))
+    return this.#issueSession(ctx, user, true)
   }
 
   issueSession(ctx: Koa.Context, userID: string): LocalUser {
@@ -171,12 +195,14 @@ export class LocalAuthStore {
     return this.#issueSession(ctx, user)
   }
 
-  #issueSession(ctx: Koa.Context, user: StoredUser): LocalUser {
+  #issueSession(ctx: Koa.Context, user: StoredUser, desktop = false, localDesktop = false): LocalUser {
     const token = randomBytes(32).toString('base64url')
     this.#sessions.set(this.#sessionKey(token), {
       userID: user.id,
       authVersion: user.authVersion,
-      expiresAt: Date.now() + SESSION_TTL_SECONDS * 1_000,
+      expiresAt: desktop ? Number.MAX_SAFE_INTEGER : Date.now() + SESSION_TTL_SECONDS * 1_000,
+      ...(desktop ? { desktop: true } : {}),
+      ...(localDesktop ? { localDesktop: true } : {}),
     })
     this.#saveSessions()
     appendSetCookies(ctx, [serialize(SESSION_COOKIE, token, {
@@ -184,9 +210,9 @@ export class LocalAuthStore {
       secure: this.#secureCookie,
       sameSite: 'strict',
       path: '/',
-      maxAge: SESSION_TTL_SECONDS,
+      maxAge: desktop ? DESKTOP_COOKIE_TTL_SECONDS : SESSION_TTL_SECONDS,
     })])
-    const published = publicUser(user)
+    const published = { ...publicUser(user), ...(localDesktop ? { localDesktop: true, mustChangePassword: false } : {}) }
     ctx.state.localUser = published
     return published
   }
@@ -223,7 +249,7 @@ export class LocalAuthStore {
       this.#saveSessions()
       return undefined
     }
-    return publicUser(user)
+    return { ...publicUser(user), ...(session.localDesktop ? { localDesktop: true, mustChangePassword: false } : {}) }
   }
 
   require(ctx: Koa.Context, allowPasswordChange = false): LocalUser {
@@ -349,7 +375,7 @@ export class LocalAuthStore {
   ): LocalUser {
     const current = this.require(ctx, true)
     const user = this.#users.find(candidate => candidate.id === current.id)!
-    if (!this.#passwordMatches(user, currentPassword)) {
+    if (!current.localDesktop && !this.#passwordMatches(user, currentPassword)) {
       throw new HttpError(401, '当前密码错误', 'invalid_current_password')
     }
     const username = newUsername === undefined ? user.username : canonicalUsername(newUsername)
@@ -363,7 +389,14 @@ export class LocalAuthStore {
     user.mustChangePassword = false
     user.updatedAt = Date.now()
     this.#saveUsers()
+    const session = this.#sessions.get(this.#sessionKey(this.#token(ctx) ?? ''))
     this.#revokeUser(user.id)
+    if (current.localDesktop) return this.#issueSession(ctx, user, false, true)
+    if (session?.desktop) {
+      this.logout(ctx)
+      delete ctx.state.localUser
+      return publicUser(user)
+    }
     return this.login(ctx, username, newPassword)
   }
 
@@ -377,7 +410,7 @@ export class LocalAuthStore {
     else delete user.avatar
     user.updatedAt = Date.now()
     this.#saveUsers()
-    const published = publicUser(user)
+    const published = { ...publicUser(user), ...(current.localDesktop ? { localDesktop: true } : {}) }
     ctx.state.localUser = published
     return published
   }
@@ -494,6 +527,8 @@ export class LocalAuthStore {
         if (session.tokenHash && session.expiresAt > now) {
           this.#sessions.set(session.tokenHash, {
             userID: session.userID, authVersion: session.authVersion, expiresAt: session.expiresAt,
+            ...(session.desktop === true ? { desktop: true } : {}),
+            ...(session.localDesktop === true ? { localDesktop: true } : {}),
           })
         }
       }

+ 5 - 1
src/server/routes.ts

@@ -882,7 +882,7 @@ async function login(ctx: Koa.Context, dependencies: RouteDependencies): Promise
     throw new HttpError(400, 'Username and password are required', 'invalid_credentials')
   }
 
-  dependencies.auth.login(ctx, username, password)
+  dependencies.auth.login(ctx, username, password, request.desktop === true)
   await bootstrap(ctx, dependencies, true)
 }
 
@@ -1716,6 +1716,10 @@ export function createApiRouter(dependencies: RouteDependencies): Router {
   router.post('/api/app/login', async (ctx) => {
     await login(ctx, dependencies)
   })
+  router.post('/api/app/desktop-session', async ctx => {
+    dependencies.auth.persistDesktopSession(ctx)
+    await bootstrap(ctx, dependencies, true, true)
+  })
   router.post('/api/app/register', ctx => {
     const retry = registrations.take(ctx.req.socket.remoteAddress ?? 'unknown')
     if (retry) {

+ 9 - 2
src/server/serviceInstance.ts

@@ -60,9 +60,9 @@ export function acquireServiceInstance(home: string, version: string, desktopOwn
       } catch { /* Never remove a lock that can no longer be identified. */ }
       finally { lock.close() }
     },
-    middleware(shutdown: () => Promise<void>, idle: () => boolean = () => true, environment?: (ctx:Koa.Context)=>Promise<void>, activation?: {readonly required:boolean;activate():void|Promise<void>}): Koa.Middleware {
+    middleware(shutdown: () => Promise<void>, idle: () => boolean = () => true, environment?: (ctx:Koa.Context)=>Promise<void>, activation?: {readonly required:boolean;activate():void|Promise<void>}, desktopSession?: (ctx: Koa.Context) => void): Koa.Middleware {
       return async (ctx, next) => {
-        const control = ctx.path === '/desktop/environment' || ctx.path === '/desktop/service' || ctx.path === '/desktop/service/quiesce' || ctx.path === '/desktop/service/activate'
+        const control = ctx.path === '/desktop/environment' || ctx.path === '/desktop/service' || ctx.path === '/desktop/service/quiesce' || ctx.path === '/desktop/service/activate' || ctx.path === '/desktop/service/session'
         if (!control) {
           if(stopping){ctx.status=503;ctx.body={error:'后台服务正在停止',code:'service_stopping'};return}
           if (quiesced() && !['/healthz', '/readyz', '/api/status'].includes(ctx.path)) {
@@ -79,6 +79,13 @@ export function acquireServiceInstance(home: string, version: string, desktopOwn
         const local = ['127.0.0.1', '::1', '::ffff:127.0.0.1'].includes(ctx.req.socket.remoteAddress ?? '')
         if (!local || ctx.get('origin') || !timingSafeEqual(received, expected)) { ctx.status = 403; ctx.body = { error: '桌面服务授权无效' }; return }
         ctx.set('Cache-Control', 'no-store')
+        if (ctx.path === '/desktop/service/session') {
+          if (ctx.method !== 'POST' || !desktopSession) { ctx.status = 405; return }
+          if (stopping || quiesced()) { ctx.status = 503; return }
+          try { desktopSession(ctx) }
+          catch (error) { ctx.status = (error as any).status ?? 500; ctx.body = { error: '本机管理员会话未就绪' } }
+          return
+        }
         if(ctx.path==='/desktop/environment'){if(environment){try{await environment(ctx)}catch(error){ctx.status=(error as any).status??500;ctx.body={error:ctx.status<500?(error as Error).message:'桌面连接请求失败',code:(error as any).code??'desktop_error'}}}else ctx.status=404;return}
         if (ctx.path === '/desktop/service/activate') {
           if (ctx.method !== 'POST' || !activation) { ctx.status = 405; return }

+ 1 - 0
src/shared/types.ts

@@ -10,6 +10,7 @@ export type AuthStatus =
   | 'error'
 
 export interface CurrentUser {
+  localDesktop?: boolean
   id: string
   username: string
   avatar?: string

+ 47 - 4
tests/client/authDesktopAuthorization.test.ts

@@ -5,16 +5,20 @@ const mocks = vi.hoisted(() => ({
   login: vi.fn(),
   setup: vi.fn(),
   authorizeComputer: vi.fn(),
+  bootstrap: vi.fn(),
+  logout: vi.fn(),
+  changeCredentials: vi.fn(),
+  forgetLogin: vi.fn(),
 }))
 
 vi.mock('@/api/auth', () => ({
   login: mocks.login,
   setup: mocks.setup,
-  bootstrap: vi.fn(),
-  logout: vi.fn(),
+  bootstrap: mocks.bootstrap,
+  logout: mocks.logout,
   fetchProfiles: vi.fn(),
   fetchProfileIdentities: vi.fn(),
-  changeCredentials: vi.fn(),
+  changeCredentials: mocks.changeCredentials,
   updateAccountAvatar: vi.fn(),
 }))
 vi.mock('@/api/serverIdentity', () => ({ fetchServerIdentity: vi.fn(), onServerIdentity: () => () => {} }))
@@ -38,7 +42,11 @@ beforeEach(() => {
   mocks.login.mockReset()
   mocks.setup.mockReset()
   mocks.authorizeComputer.mockReset()
-  Object.defineProperty(window, 'yaoyaoDesktop', { configurable: true, value: { authorizeComputer: mocks.authorizeComputer } })
+  mocks.bootstrap.mockReset()
+  mocks.logout.mockReset()
+  mocks.changeCredentials.mockReset()
+  mocks.forgetLogin.mockReset()
+  Object.defineProperty(window, 'yaoyaoDesktop', { configurable: true, value: { authorizeComputer: mocks.authorizeComputer, forgetLogin: mocks.forgetLogin } })
 })
 
 it('re-authorizes the computer only after an explicit administrator login', async () => {
@@ -65,3 +73,38 @@ it('keeps the administrator signed in when computer authorization fails', async
   await expect(auth.login({ username: 'admin', password: 'secret' })).resolves.toBeUndefined()
   expect(auth.status).toBe('authenticated')
 })
+
+it('forgets native authorization on explicit logout even when the server is offline', async () => {
+  mocks.login.mockResolvedValue(response('user'))
+  const auth = useAuthStore()
+  await auth.login({ username: 'member', password: 'secret' })
+  mocks.logout.mockRejectedValue(new Error('offline'))
+  mocks.bootstrap.mockRejectedValue(new Error('offline'))
+  await auth.logout()
+  expect(mocks.forgetLogin).toHaveBeenCalledOnce()
+  expect(auth.status).toBe('anonymous')
+  expect(auth.user).toBeUndefined()
+})
+
+it('returns a remote desktop to login after changing its password', async () => {
+  mocks.login.mockResolvedValue(response('user'))
+  const auth = useAuthStore()
+  await auth.login({ username: 'member', password: 'secret' })
+  mocks.changeCredentials.mockResolvedValue(response('user').user)
+  mocks.bootstrap.mockResolvedValue({ authRequired: true, profiles: [], csrfToken: 'new-csrf' })
+  await auth.changeCredentials({ currentPassword: 'secret', newPassword: 'new-secret' })
+  expect(mocks.forgetLogin).toHaveBeenCalledOnce()
+  expect(auth.status).toBe('anonymous')
+})
+
+it('keeps the native local administrator active after setting a remote password', async () => {
+  const local = { ...response('admin'), user: { ...response('admin').user, localDesktop: true } }
+  mocks.bootstrap.mockResolvedValue(local)
+  const auth = useAuthStore()
+  await auth.bootstrap()
+  mocks.changeCredentials.mockResolvedValue(local.user)
+  await auth.changeCredentials({ currentPassword: '', newPassword: 'remote-password' })
+  expect(mocks.forgetLogin).not.toHaveBeenCalled()
+  expect(auth.status).toBe('authenticated')
+  expect(auth.user?.localDesktop).toBe(true)
+})

+ 4 - 3
tests/server/hermesMediaRoutes.test.ts

@@ -169,14 +169,15 @@ describe('historical Hermes media routes', () => {
     expect(fixture.calls).toHaveLength(0)
   })
 
-  it.each([401, 403, 404])('preserves upstream %s without a broader file-read fallback', async status => {
+  it.each([401, 403, 404])('handles upstream %s without expiring local login or broadening file access', async status => {
     const fixture = gateway(() => Response.json({ detail: `upstream-${status}` }, { status }))
     const runtime = runtimeFor(fixture)
     const response = await request(runtime.app.callback())
       .get('/Users/samien/.hermes/profiles/yaoer/cache/images/result.png')
-      .set('Host', HOST).expect(status)
+      .set('Host', HOST).expect(status === 401 ? 502 : status)
 
-    expect(response.body).toEqual({ detail: `upstream-${status}` })
+    if (status === 401) expect(response.body).toMatchObject({ code: 'upstream_auth_unavailable' })
+    else expect(response.body).toEqual({ detail: `upstream-${status}` })
     expect(fixture.downloads().length).toBeGreaterThan(0)
     expect(fixture.calls.some(call => call.url.pathname === '/api/fs/read-data-url' || call.url.pathname === '/api/media')).toBe(false)
   })

+ 51 - 1
tests/server/localAuth.test.ts

@@ -2,7 +2,7 @@ import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import type Koa from 'koa'
-import { afterEach, describe, expect, it } from 'vitest'
+import { afterEach, describe, expect, it, vi } from 'vitest'
 import { LocalAuthStore } from '../../src/server/localAuth.js'
 
 const homes: string[] = []
@@ -24,6 +24,7 @@ function issuedCookie(ctx: Koa.Context): string {
 }
 
 afterEach(() => {
+  vi.restoreAllMocks()
   for (const home of homes.splice(0)) rmSync(home, { recursive: true, force: true })
 })
 
@@ -82,3 +83,52 @@ describe('15300 local users', () => {
     expect(readFileSync(join(home, 'upstream-credentials.enc'), 'utf8')).not.toContain('very-secret')
   })
 })
+
+it('desktop authorizations survive time and server restart, but logout and password changes revoke them', () => {
+  const home = mkdtempSync(join(tmpdir(), 'yaoyao-desktop-auth-')); homes.push(home)
+  let store = new LocalAuthStore(home)
+  store.setupAdmin(context(), 'admin', 'initial-password')
+  const login = context(); store.login(login, 'admin', 'initial-password', true)
+  const cookie = issuedCookie(login)
+  const sessions = JSON.parse(readFileSync(join(home, 'sessions.json'), 'utf8'))
+  expect(sessions.sessions.some((s: { desktop?: boolean; expiresAt: number }) => s.desktop && s.expiresAt === Number.MAX_SAFE_INTEGER)).toBe(true)
+  const later = Date.now() + 500 * 86400 * 1000
+  vi.spyOn(Date, 'now').mockReturnValue(later)
+  store = new LocalAuthStore(home)
+  expect(store.require(context(cookie)).role).toBe('admin')
+  const change = context(cookie)
+  store.changeCredentials(change, 'initial-password', 'updated-password')
+  expect(store.current(context(cookie))).toBeUndefined()
+  expect(store.current(change)).toBeUndefined()
+  const next = context(); store.login(next, 'admin', 'updated-password', true)
+  const nextCookie = issuedCookie(next)
+  store.logout(context(nextCookie))
+  expect(new LocalAuthStore(home).current(context(nextCookie))).toBeUndefined()
+})
+
+it('upgrades an existing browser session and invalidates the replaced token', () => {
+  const home = mkdtempSync(join(tmpdir(), 'yaoyao-upgrade-auth-')); homes.push(home)
+  const store = new LocalAuthStore(home), setup = context()
+  store.setupAdmin(setup, 'owner', 'initial-password')
+  const oldCookie = issuedCookie(setup), upgrade = context(oldCookie)
+  store.persistDesktopSession(upgrade)
+  expect(store.current(context(oldCookie))).toBeUndefined()
+  expect(new LocalAuthStore(home).require(context(issuedCookie(upgrade))).username).toBe('owner')
+  expect(() => store.persistDesktopSession(context())).toThrow(/请先登录/)
+})
+
+it('local desktop creates or reuses the real administrator, without a public default password', () => {
+  const home = mkdtempSync(join(tmpdir(), 'yaoyao-native-admin-')); homes.push(home)
+  let store = new LocalAuthStore(home)
+  const native = context(), admin = store.localDesktopSession(native)
+  expect(admin).toMatchObject({ username: 'admin', role: 'admin', localDesktop: true, mustChangePassword: false })
+  expect(store.current(context())).toBeUndefined()
+  expect(() => store.login(context(), 'admin', 'admin')).toThrow()
+  const active = context(issuedCookie(native))
+  store.changeCredentials(active, '', 'remote-password')
+  store = new LocalAuthStore(home)
+  expect(store.localDesktopSession(context()).id).toBe(admin.id)
+  const remote = context(); store.login(remote, 'admin', 'remote-password', true)
+  expect(store.require(context(issuedCookie(remote))).localDesktop).toBeUndefined()
+  expect(() => store.changeCredentials(context(issuedCookie(remote)), '', 'another-password')).toThrow('当前密码错误')
+})

+ 19 - 0
tests/server/serviceInstance.test.ts

@@ -107,3 +107,22 @@ describe('service ownership', () => {
     } finally { clock.mockRestore() }
   })
 })
+
+it('native administrator sessions require the private capability and reject web origins, remote callers and maintenance', async () => {
+  const instance = acquire(false), session = vi.fn(ctx => { ctx.body = { user: { role: 'admin' } } })
+  const app = new Koa().use(instance.middleware(async () => {}, () => true, undefined, undefined, session))
+  const api = request(app.callback()), auth = { 'x-yaoyao-desktop-token': instance.record.token }
+  await api.post('/desktop/service/session').expect(403)
+  await api.post('/desktop/service/session').set(auth).set('Origin', 'http://localhost').expect(403)
+  await api.post('/desktop/service/session').set('x-yaoyao-desktop-token', 'wrong').expect(403)
+  await api.get('/desktop/service/session').set(auth).expect(405)
+  expect(session).not.toHaveBeenCalled()
+  await api.post('/desktop/service/session').set(auth).expect(200)
+  expect(session).toHaveBeenCalledTimes(1)
+  await api.post('/desktop/service/quiesce').set(auth).expect(200)
+  await api.post('/desktop/service/session').set(auth).expect(503)
+  expect(session).toHaveBeenCalledTimes(1)
+  const remote = { path: '/desktop/service/session', method: 'POST', req: { socket: { remoteAddress: '192.168.1.20' } }, get: (key: string) => key === 'x-yaoyao-desktop-token' ? instance.record.token : '', set: () => {} } as any
+  await instance.middleware(async () => {}, () => true, undefined, undefined, session)(remote, async () => {})
+  expect(remote.status).toBe(403)
+})

+ 2 - 1
tests/server/sessionDeletion.test.ts

@@ -117,7 +117,8 @@ describe('deleting sessions retained only in cache', () => {
     [401, 'Session not found'], [403, 'Session not found'], [500, 'Session not found'],
   ])('preserves cache on lookup failure %i: %s', async (status, error) => {
     const f = await fixture(() => Response.json({ error }, { status }))
-    await f.remove().expect(status)
+    const response = await f.remove().expect(status === 401 ? 502 : status)
+    if (status === 401) expect(response.body).toMatchObject({ code: 'upstream_auth_unavailable' })
     expect(f.store.localDetail(f.owner, 'default', sessionID)).toBeDefined()
     expect(f.store.messagePage(f.owner, 'default', sessionID, 0, 100)).toBeDefined()
     f.assertOtherCopies()

Некоторые файлы не были показаны из-за большого количества измененных файлов