Forráskód Böngészése

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

Samien 1 hónapja
szülő
commit
09a3133f17
54 módosított fájl, 739 hozzáadás és 110 törlés
  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
 # 夭夭 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 接口执行任务。
 夭夭 AI 为网页、iOS 和安卓提供统一的 Agent、群聊、文件库及语音配置服务。Hermes 通过标准 9119 HTTP/WebSocket 接口执行任务。
 
 
@@ -40,7 +40,7 @@ Web 数据默认保存在 `~/.yaoyao`,可用 `YAOYAO_HOME` 指定(兼容 `HE
 
 
 ## macOS 桌面版
 ## 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 发布说明。
 桌面安装包的签名、公证状态及自动升级可用范围见对应版本的 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
 ```sh
 docker compose --env-file docker.env pull web
 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
 final result: passed

A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 0 - 0
desktop/boot-icons.svg


+ 56 - 0
desktop/boot.css

@@ -598,3 +598,59 @@ footer {
 .local-ready .installation {
 .local-ready .installation {
   margin-top:6px
   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>
   <p id="session-loading-message">正在打开夭夭…</p>
 </main>
 </main>
 <main id="onboarding" class="page" hidden>
 <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="设置进度">
   <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-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-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>
     <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>
   </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">
   <section id="choice-section" class="section choice-section" aria-labelledby="choice-title">
     <h2 id="choice-title"><span>01</span>选择服务器</h2>
     <h2 id="choice-title"><span>01</span>选择服务器</h2>
     <fieldset id="mode-options" class="mode-options"><legend class="sr-only">服务器运行方式</legend>
     <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="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>
       <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>
     </fieldset>
+    <button id="continue" class="primary" type="button" disabled><span>继续</span><svg><use href="boot-icons.svg#arrow"/></svg></button>
   </section>
   </section>
   <section id="prepare-section" class="section pending" aria-labelledby="prepare-title">
   <section id="prepare-section" class="section pending" aria-labelledby="prepare-title">
     <h2 id="prepare-title"><span>02</span><span id="prepare-heading">准备服务</span></h2>
     <h2 id="prepare-title"><span>02</span><span id="prepare-heading">准备服务</span></h2>
     <p id="choose-hint" class="muted inset">请先选择本机运行或连接远程服务器。</p>
     <p id="choose-hint" class="muted inset">请先选择本机运行或连接远程服务器。</p>
     <div id="local-panel" hidden>
     <div id="local-panel" hidden>
-      <p class="muted inset">检测已有环境,按顺序准备缺少的组件。</p>
+      <p class="muted inset">自动准备本机服务,完成后以管理员身份进入。</p>
       <div class="installation">
       <div class="installation">
         <ol class="install-steps">
         <ol class="install-steps">
           <li data-stage="checking"><svg><use href="boot-icons.svg#circle"/></svg><span>检查运行环境</span><small>等待中</small></li>
           <li data-stage="checking"><svg><use href="boot-icons.svg#circle"/></svg><span>检查运行环境</span><small>等待中</small></li>
@@ -42,7 +45,7 @@
     </div>
     </div>
     <form id="connection-form" class="inset" hidden>
     <form id="connection-form" class="inset" hidden>
       <label for="server">服务器地址</label>
       <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>
     </form>
     <p id="status" class="status inset" role="status" aria-live="polite"></p>
     <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>
     <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">
   <section id="account-section" class="section pending" aria-labelledby="account-title">
     <h2 id="account-title"><span>03</span><span id="account-heading">登录账号</span></h2>
     <h2 id="account-title"><span>03</span><span id="account-heading">登录账号</span></h2>
     <p id="account-hint" class="muted inset">服务准备完成后,在此登录账号。</p>
     <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>
     <form id="login-form" hidden>
       <div id="credentials" class="inset">
       <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>
         <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>
     <button id="waiting" class="primary" type="button" disabled hidden>正在准备服务,请稍候</button>
   </section>
   </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>
   <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>
 </main>
 <script src="boot.js"></script>
 <script src="boot.js"></script>
 </body></html>
 </body></html>

+ 50 - 11
desktop/boot.js

@@ -2,10 +2,20 @@ const api = window.yaoyaoDesktop
 const $ = id => document.getElementById(id)
 const $ = id => document.getElementById(id)
 let registering = false
 let registering = false
 let state, initialized = false, pending = false, serverDirty = false, generation = 0, actionError = ''
 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) {
 function display(next) {
   state = 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
   $('onboarding').hidden = restoring
   $('session-loading').hidden = !restoring
   $('session-loading').hidden = !restoring
   document.title = restoring ? '夭夭' : '开始使用夭夭'
   document.title = restoring ? '夭夭' : '开始使用夭夭'
@@ -16,8 +26,8 @@ function display(next) {
     initialized = true
     initialized = true
   }
   }
   const busy = pending || state.busy
   const busy = pending || state.busy
+  if (state.mode) draftMode = state.mode
   const clientOnly = state.supportedModes && !state.supportedModes.includes('server')
   const clientOnly = state.supportedModes && !state.supportedModes.includes('server')
-  $('choice-section').hidden = Boolean(clientOnly)
   $('step-choice').hidden = Boolean(clientOnly)
   $('step-choice').hidden = Boolean(clientOnly)
   if (clientOnly) {
   if (clientOnly) {
     $('prepare-title').firstElementChild.textContent = '01'
     $('prepare-title').firstElementChild.textContent = '01'
@@ -28,7 +38,7 @@ function display(next) {
     $('help').lastElementChild.textContent = '更换服务器不会迁移原服务器上的数据。连接失败时请检查服务器地址和网络。'
     $('help').lastElementChild.textContent = '更换服务器不会迁移原服务器上的数据。连接失败时请检查服务器地址和网络。'
   }
   }
   const local = state.mode === 'local'
   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 setup = ready && state.setupRequired
   const confirm = setup || registering
   const confirm = setup || registering
   const accountTitle = setup ? '创建管理员' : registering ? '注册子账号' : '登录账号'
   const accountTitle = setup ? '创建管理员' : registering ? '注册子账号' : '登录账号'
@@ -38,7 +48,8 @@ function display(next) {
   $('registration-notice').textContent = state.registrationNotice || ''
   $('registration-notice').textContent = state.registrationNotice || ''
   $('registration-notice').hidden = !ready || !state.registrationNotice
   $('registration-notice').hidden = !ready || !state.registrationNotice
   $('mode-options').disabled = busy
   $('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
   $('remember').disabled = busy
   $('prepare-section').classList.toggle('pending', !state.mode)
   $('prepare-section').classList.toggle('pending', !state.mode)
   $('prepare-section').classList.toggle('local-ready', local && ready)
   $('prepare-section').classList.toggle('local-ready', local && ready)
@@ -48,15 +59,15 @@ function display(next) {
   $('connection-form').hidden = state.mode !== 'remote'
   $('connection-form').hidden = state.mode !== 'remote'
   $('server').disabled = busy
   $('server').disabled = busy
   $('detect').disabled = busy || !$('server').value.trim()
   $('detect').disabled = busy || !$('server').value.trim()
-  $('detect').textContent = busy && !ready ? '正在检测…' : ready ? '重新检测' : '检测连接'
+  $('detect').textContent = busy && !ready ? '正在检测…' : '检测并继续'
   $('prepare-local').hidden = !local || state.phase !== 'idle'
   $('prepare-local').hidden = !local || state.phase !== 'idle'
   $('prepare-local').disabled = busy
   $('prepare-local').disabled = busy
-  $('actions').hidden = !local || state.phase !== 'error'
+  $('actions').hidden = !local || !(state.phase === 'error' || state.error)
   $('retry').disabled = busy
   $('retry').disabled = busy
   $('force-sync').hidden = !state.canForceSync
   $('force-sync').hidden = !state.canForceSync
   $('force-sync').disabled = busy
   $('force-sync').disabled = busy
   const preparationError = !ready && !serverDirty && (actionError || state.error)
   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').setAttribute('role', preparationError ? 'alert' : 'status')
   $('status').classList.toggle('success', ready && !preparationError)
   $('status').classList.toggle('success', ready && !preparationError)
   $('prepare-section').setAttribute('aria-busy', String(busy && !ready))
   $('prepare-section').setAttribute('aria-busy', String(busy && !ready))
@@ -64,7 +75,7 @@ function display(next) {
   $('account-step-title').textContent = accountTitle
   $('account-step-title').textContent = accountTitle
   $('account-section').classList.toggle('pending', !ready)
   $('account-section').classList.toggle('pending', !ready)
   $('account-section').setAttribute('aria-busy', String(busy && ready))
   $('account-section').setAttribute('aria-busy', String(busy && ready))
-  $('account-hint').textContent = !ready ? '服务准备完成后,在此创建账号或登录。'
+  $('account-hint').textContent = !ready ? ''
     : state.authenticated ? `已登录${state.username ? `:${state.username}` : ''},可以继续进入夭夭。`
     : state.authenticated ? `已登录${state.username ? `:${state.username}` : ''},可以继续进入夭夭。`
     : setup ? '首次使用,请设置管理员账号和密码。密码至少 8 个字符。' : registering ? '子账号仅可使用 Bot 模式,注册后需管理员分配机器人并开通。' : '登录所选服务器的夭夭账号。'
     : setup ? '首次使用,请设置管理员账号和密码。密码至少 8 个字符。' : registering ? '子账号仅可使用 Bot 模式,注册后需管理员分配机器人并开通。' : '登录所选服务器的夭夭账号。'
   $('login-form').hidden = !ready
   $('login-form').hidden = !ready
@@ -78,13 +89,29 @@ function display(next) {
   $('password').autocomplete = confirm ? 'new-password' : 'current-password'
   $('password').autocomplete = confirm ? 'new-password' : 'current-password'
   $('toggle-password').disabled = busy
   $('toggle-password').disabled = busy
   $('submit').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 ? '注册子账号' : '登录并进入夭夭'
     : state.phase === 'entering' ? '正在进入…' : state.authenticated ? '进入夭夭' : setup ? '创建并进入夭夭' : registering ? '注册子账号' : '登录并进入夭夭'
   $('login-error').textContent = ready ? actionError || state.error : ''
   $('login-error').textContent = ready ? actionError || state.error : ''
   $('login-error').hidden = !$('login-error').textContent
   $('login-error').hidden = !$('login-error').textContent
   $('waiting').hidden = !busy || ready
   $('waiting').hidden = !busy || ready
   $('waiting').textContent = local ? '正在准备服务,请稍候' : '正在检测连接,请稍候'
   $('waiting').textContent = local ? '正在准备服务,请稍候' : '正在检测连接,请稍候'
   const activeStep = ready ? 2 : state.mode ? 1 : 0
   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()) {
   for (const [index, id] of ['step-choice', 'step-prepare', 'step-account'].entries()) {
     $(id).classList.toggle('complete', index < activeStep)
     $(id).classList.toggle('complete', index < activeStep)
     if (index === activeStep) $(id).setAttribute('aria-current', 'step')
     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', () => {
 for (const input of document.querySelectorAll('[name=mode]')) input.addEventListener('change', () => {
+  draftMode = input.value
+  display(state)
+})
+$('continue').onclick = () => {
   $('login-form').reset(); $('password').type = 'password'
   $('login-form').reset(); $('password').type = 'password'
   $('toggle-password').setAttribute('aria-pressed', 'false'); $('toggle-password').setAttribute('aria-label', '显示密码')
   $('toggle-password').setAttribute('aria-pressed', 'false'); $('toggle-password').setAttribute('aria-label', '显示密码')
   $('password').removeAttribute('aria-invalid'); $('confirmation').removeAttribute('aria-invalid')
   $('password').removeAttribute('aria-invalid'); $('confirmation').removeAttribute('aria-invalid')
   serverDirty = false; registering = false
   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', () => {
 $('server').addEventListener('input', () => {
   serverDirty = true; registering = false; actionError = ''
   serverDirty = true; registering = false; actionError = ''
   // Credentials belong to the checked server, never carry them to a new address.
   // 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)
     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})
     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()
     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}/**`)
     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 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})
     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 { resolveDataHome } from './data-home.mjs'
 import { DesktopEnvironmentHost, DesktopHostCore } from './environment-host.mjs'
 import { DesktopEnvironmentHost, DesktopHostCore } from './environment-host.mjs'
 import { DesktopHostManager } from './host-manager.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 { DesktopOnboarding } from './onboarding.mjs'
 import { DesktopUpdateManager } from './update-manager.mjs'
 import { DesktopUpdateManager } from './update-manager.mjs'
 import { DesktopAutoUpdateManager } from './auto-update-manager.mjs'
 import { DesktopAutoUpdateManager } from './auto-update-manager.mjs'
@@ -34,6 +35,7 @@ else {
   let remoteMode = false, remoteURL = '', serverModeActive = false, switchingMode = false
   let remoteMode = false, remoteURL = '', serverModeActive = false, switchingMode = false
   let recoveringService = false
   let recoveringService = false
   let resumeLocalAfterUpdate = false
   let resumeLocalAfterUpdate = false
+  let checkRemoteAuthorization = async () => {}
   const serviceURL = () => remoteMode ? remoteURL : manager?.state.url
   const serviceURL = () => remoteMode ? remoteURL : manager?.state.url
   // Use the same Chromium networking as server detection/navigation. Auth owns
   // Use the same Chromium networking as server detection/navigation. Auth owns
   // its cookie jar: do not send or overwrite another account's browser cookies.
   // its cookie jar: do not send or overwrite another account's browser cookies.
@@ -251,7 +253,7 @@ else {
             }
             }
           }
           }
           if (manager) stateChanged(manager.state)
           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?.()
           updater?.startChecking?.()
           show()
           show()
         }
         }
@@ -284,7 +286,7 @@ else {
       }
       }
     }
     }
     clearInterval(timer)
     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()
     window?.show(); updateWindow?.show()
   }
   }
   app.on('before-quit', event => {
   app.on('before-quit', event => {
@@ -313,6 +315,46 @@ else {
     }
     }
     app.setAboutPanelOptions({applicationName:'夭夭',applicationVersion:packageVersion,version:`${String(buildInfo.commit).slice(0,12)}${buildInfo.dirty?' · 工作区快照':''}`})
     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 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) {
     if (platform.server) {
     manager = new DesktopServiceManager({ home, port, version: packageVersion, log, onState: stateChanged,
     manager = new DesktopServiceManager({ home, port, version: packageVersion, log, onState: stateChanged,
       prepareHome: !fixtureHome ? onProgress => migrateLocalData({ home, port, root, onProgress }) : undefined,
       prepareHome: !fixtureHome ? onProgress => migrateLocalData({ home, port, root, onProgress }) : undefined,
@@ -426,7 +468,12 @@ else {
     onboarding = new DesktopOnboarding({
     onboarding = new DesktopOnboarding({
       platform: platform.platform, supportedModes: platform.supportedModes,
       platform: platform.platform, supportedModes: platform.supportedModes,
       remoteServer: () => preferences.value.remoteServer,
       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 }) => {
       prepareLocal: async ({ force }) => {
         if (!platform.server) throw new Error('此客户端不支持在本机运行服务器')
         if (!platform.server) throw new Error('此客户端不支持在本机运行服务器')
         serverModeActive = true; remoteMode = false; remoteURL = ''
         serverModeActive = true; remoteMode = false; remoteURL = ''
@@ -441,11 +488,8 @@ else {
       authenticate: async ({ mode, serverURL, setup, username, password }) => {
       authenticate: async ({ mode, serverURL, setup, username, password }) => {
         authorizedOnboardingServer = ''
         authorizedOnboardingServer = ''
         const auth = await remoteSession(serverURL, { setup, username, password }, authFetch)
         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
         let warning
         if (mode === 'remote' && auth.user.role === 'admin') {
         if (mode === 'remote' && auth.user.role === 'admin') {
           try { await authorizeComputer(serverURL, auth); authorizedOnboardingServer = serverURL }
           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.
       // A session expiry returns to the same main-page flow for the active server.
       return openOnboarding(remoteMode ? 'remote' : 'local', { autoPrepare: true })
       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(){
     function syncModeMenu(){
       const menu=Menu.getApplicationMenu()
       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')
       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 })
       remember: preferences.value.startupChoice !== 'ask', restoring: restoreSession })
     // Finish the initial navigation before a remembered session enters its server.
     // Finish the initial navigation before a remembered session enters its server.
     await createWindow()
     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()
     syncModeMenu()
     if (restoreSession)
     if (restoreSession)
       await onboarding.prepare({ autoEnter: true })
       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 identityReady=new Promise(resolve=>{releaseIdentity=resolve})
   const local=createServer(async(req,res)=>{
   const local=createServer(async(req,res)=>{
     if(req.url==='/desktop/service'){await identityReady;res.setHeader('Content-Type','application/json');res.end(JSON.stringify(identity))}
     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 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>')}
     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)=>{
   const remote=createServer((_req,res)=>{
     if(_req.url.split('?')[0]==='/api/app/bootstrap'){
     if(_req.url.split('?')[0]==='/api/app/bootstrap'){
       res.setHeader('Content-Type','application/json')
       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
       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'){
     if(_req.url==='/api/app/admin/desktop-hosts'&&_req.method==='POST'){
       enrollments++
       enrollments++
       assert.equal(_req.headers.origin,remoteURL)
       assert.equal(_req.headers.origin,remoteURL)
       assert.equal(_req.headers['x-csrf-token'],'fixture')
       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')
       res.setHeader('Content-Type','application/json')
       if(enrollmentFails){res.statusCode=503;res.end(JSON.stringify({error:'fixture enrollment unavailable'}));return}
       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
       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.evaluate(()=>{void window.yaoyaoDesktop.openRemoteLogin()})
     await page.waitForURL('**/boot.html')
     await page.waitForURL('**/boot.html')
     assert.equal(app.windows().length,1)
     assert.equal(app.windows().length,1)
+    await page.locator('#back').click()
     await page.getByRole('radio',{name:/本机运行/}).check()
     await page.getByRole('radio',{name:/本机运行/}).check()
+    await page.locator('#continue').click()
     await page.locator('#prepare-local').click()
     await page.locator('#prepare-local').click()
-    await page.locator('#submit').click()
     await page.waitForURL(origin+'/**')
     await page.waitForURL(origin+'/**')
     enrollmentFails = false
     enrollmentFails = false
     await app.evaluate(async (_electron, credentialsModule) => {
     await app.evaluate(async (_electron, credentialsModule) => {
-      // Fixture credentials stay in this temporary directory; do not access Keychain.
       const module = process.getBuiltinModule('module')
       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') }
       globalThis.fetch = async () => { throw new Error('fixture Node transport is unavailable') }
       const http = process.getBuiltinModule('http')
       const http = process.getBuiltinModule('http')
       const originalRequest = http.request
       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.evaluate(()=>{void window.yaoyaoDesktop.openRemoteLogin()})
     await page.waitForURL('**/boot.html')
     await page.waitForURL('**/boot.html')
     assert.equal(app.windows().length,1)
     assert.equal(app.windows().length,1)
+    await page.locator('#back').click()
     await page.getByRole('radio',{name:/本机运行/}).check()
     await page.getByRole('radio',{name:/本机运行/}).check()
+    await page.locator('#continue').click()
     await page.locator('#prepare-local').click()
     await page.locator('#prepare-local').click()
-    await page.locator('#submit').click()
     await page.waitForURL(origin+'/**')
     await page.waitForURL(origin+'/**')
     await expect.poll(()=>page.evaluate(()=>window.yaoyaoDesktop.modeState())).toEqual({mode:'server',serverURL:origin,switching:false,platform:'darwin',supportedModes:['client','server']})
     await expect.poll(()=>page.evaluate(()=>window.yaoyaoDesktop.modeState())).toEqual({mode:'server',serverURL:origin,switching:false,platform:'darwin',supportedModes:['client','server']})
   }finally{
   }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)) }
     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') {
     if (req.url.split('?')[0] === '/api/app/bootstrap') {
       res.setHeader('set-cookie', 'csrf=fixture; Path=/; HttpOnly')
       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 }
       if (holdBootstrap) { replyBootstrap = reply; return }
       return reply()
       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='))
         assert.ok(!String(req.headers.cookie).includes('session='))
         if (body.username !== 'user' || body.password !== 'correct-password') return json({ error: 'wrong' }, 401)
         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)
         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' })
         json({ user: { id: 'fixture-user', username: 'user', role: loginRole }, csrfToken: 'fixture' })
       }); return
       }); return
     }
     }
@@ -46,7 +46,7 @@ test('first launch, failed connection and remote login all stay in the only main
       enrollmentCount++
       enrollmentCount++
       assert.equal(req.headers.origin, url)
       assert.equal(req.headers.origin, url)
       assert.equal(req.headers['x-csrf-token'], 'fixture')
       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.
       // Exercise authorization without provisioning a real computer in the fixture.
       return json({ error: 'enrollment unavailable' }, 503)
       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).
     // are unavailable (for example, after a macOS local-network permission change).
     await app.evaluate(({ session }, origin) => {
     await app.evaluate(({ session }, origin) => {
       globalThis.fetch = async () => { throw new TypeError('fetch failed', { cause: { code: 'EHOSTUNREACH' } }) }
       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)
     }, url)
     const errors = []; page.on('pageerror', error => errors.push(error.message))
     const errors = []; page.on('pageerror', error => errors.push(error.message))
     await expect(page.getByRole('heading', { name: '开始使用夭夭' })).toBeVisible()
     await expect(page.getByRole('heading', { name: '开始使用夭夭' })).toBeVisible()
     assert.equal(app.windows().length, 1)
     assert.equal(app.windows().length, 1)
     assert.equal((await page.evaluate(() => window.yaoyaoDesktop.status())).mode, null)
     assert.equal((await page.evaluate(() => window.yaoyaoDesktop.status())).mode, null)
     await page.getByRole('radio', { name: /连接远程服务器/ }).check()
     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('#server').fill('http://127.0.0.1:1')
     await page.locator('#detect').click()
     await page.locator('#detect').click()
     await expect(page.locator('#status[role=alert]')).toBeVisible()
     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(registrationCount, 1)
     assert.equal(loginCount, 0, 'registration never signs in or authorizes this computer')
     assert.equal(loginCount, 0, 'registration never signs in or authorizes this computer')
     assert.equal(await app.evaluate(async ({ session }, origin) =>
     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')
     'previous-account', 'registration does not replace the active browser session')
     assert.match(page.url(), /boot.html$/)
     assert.match(page.url(), /boot.html$/)
     await page.locator('#username').fill('user')
     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('用户名或密码不正确')
     await expect(page.locator('#login-error')).toContainText('用户名或密码不正确')
     assert.equal(await page.locator('#password').inputValue(), '')
     assert.equal(await page.locator('#password').inputValue(), '')
     // Editing the address invalidates the checked target and hides credentials.
     // 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 page.locator('#server').fill('http://another-server.test')
     await expect(page.locator('#login-form')).toBeHidden()
     await expect(page.locator('#login-form')).toBeHidden()
     await page.locator('#server').fill(url); await page.locator('#detect').click()
     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()
     await page.locator('#toggle-password').click()
     assert.equal(await page.locator('#password').getAttribute('type'), 'text')
     assert.equal(await page.locator('#password').getAttribute('type'), 'text')
     await page.locator('#toggle-password').click()
     await page.locator('#toggle-password').click()
-    await page.locator('#remember').check()
     await mkdir(join(root, 'docs/design/desktop-onboarding'), { recursive: true })
     await mkdir(join(root, 'docs/design/desktop-onboarding'), { recursive: true })
     await page.emulateMedia({ colorScheme: 'light' })
     await page.emulateMedia({ colorScheme: 'light' })
     assert.equal(await page.evaluate(() => document.querySelector('footer').getBoundingClientRect().bottom <= innerHeight), true, 'default window shows its footer without scrolling')
     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))
       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
       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')
     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.
     // An expired session still returns to the same page with usable login fields.
     sessionValid = false
     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)
     assert.equal(window.document.getElementById('login-form').hidden, false)
   } finally { window.close() }
   } 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()
     return this.snapshot()
   }
   }
   select(mode) {
   select(mode) {
+    if (mode === null) return this.open({ remember: this.state.remember })
     if (!['local', 'remote'].includes(mode)) throw new Error('请选择运行方式')
     if (!['local', 'remote'].includes(mode)) throw new Error('请选择运行方式')
     return this.open({ mode, serverURL: this.options.remoteServer(), remember: this.state.remember })
     return this.open({ mode, serverURL: this.options.remoteServer(), remember: this.state.remember })
   }
   }
@@ -53,12 +54,13 @@ export class DesktopOnboarding {
     try {
     try {
       const target = mode === 'local' ? await this.options.prepareLocal({ force }) : normalizeServerURL(serverURL)
       const target = mode === 'local' ? await this.options.prepareLocal({ force }) : normalizeServerURL(serverURL)
       this.state.serverURL = target
       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('远程服务器尚未初始化,请先在服务器本机创建管理员,再重新检测。')
       if (mode === 'remote' && info.setupRequired) throw new Error('远程服务器尚未初始化,请先在服务器本机创建管理员,再重新检测。')
       Object.assign(this.state, { phase: 'ready', stage: 'ready', setupRequired: info.setupRequired === true, registrationAvailable: info.registrationAvailable === true,
       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 || '',
         authenticated: info.authenticated === true && !this.state.forceLogin, username: info.user?.username || '',
         message: mode === 'local' ? '本机服务已就绪' : '连接成功,服务器已就绪' })
         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) {
     } catch (error) {
       this.state.phase = 'error'
       this.state.phase = 'error'
       this.state.error = error.message || '准备未完成,请重试'
       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' })
   await flow.prepare({ serverURL: 'http://another-server.test' })
   assert.equal(flow.snapshot().registrationNotice, '', 'registration belongs to the checked server')
   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),
   prepareServer: input => ipcRenderer.invoke('desktop:onboarding-prepare', input),
   login: input => ipcRenderer.invoke('desktop:onboarding-submit', input),
   login: input => ipcRenderer.invoke('desktop:onboarding-submit', input),
   openLogin: () => ipcRenderer.invoke('desktop:open-login'),
   openLogin: () => ipcRenderer.invoke('desktop:open-login'),
+  forgetLogin: () => ipcRenderer.invoke('desktop:forget-login'),
   retry: () => ipcRenderer.invoke('desktop:retry'),
   retry: () => ipcRenderer.invoke('desktop:retry'),
   forceSync: () => ipcRenderer.invoke('desktop:force-sync'),
   forceSync: () => ipcRenderer.invoke('desktop:force-sync'),
   logs: () => ipcRenderer.invoke('desktop:logs'),
   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
   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){
 export async function remoteSession(serverURL,{username,password,setup=false},fetchImpl=globalThis.fetch){
   const base=normalizeServerURL(serverURL)
   const base=normalizeServerURL(serverURL)
   const user=String(username??'').trim(),pass=String(password??'')
   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),{
   const response=await requestServer(fetchImpl,new URL(setup?'/api/app/setup':'/api/app/login',base),{
     method:'POST',redirect:'error',signal:AbortSignal.timeout(15000),
     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},
     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)
   jar.absorb(response)
   if(!setup&&(response.status===401||response.status===403)) {
   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 assert from 'node:assert/strict'
 import {createServer} from 'node:http'
 import {createServer} from 'node:http'
 import {randomUUID,randomBytes} from 'node:crypto'
 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 () => {
 test('server inspection requests local login metadata without binding Hermes', async () => {
   const calls = []
   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(bootstrap.url,'/api/app/bootstrap')
     assert.equal(login.origin,fake.url)
     assert.equal(login.origin,fake.url)
     assert.equal(login.csrf,fake.csrf)
     assert.equal(login.csrf,fake.csrf)
+    assert.equal(JSON.parse(login.body).desktop,true)
     assert.equal(enroll.csrf,fake.csrf+'-rotated')
     assert.equal(enroll.csrf,fake.csrf+'-rotated')
     assert.ok(enroll.cookie.includes(`session=${fake.session}`))
     assert.ok(enroll.cookie.includes(`session=${fake.session}`))
     assert.deepEqual(JSON.parse(enroll.body),{name:'书房 iMac',installId,previousHostId})
     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()=>{
 test('wrong credentials and non-admin accounts surface clear errors',async()=>{
   const fake=await fakeServer()
   const fake=await fakeServer()
   try{
   try{

+ 9 - 0
desktop/service-manager.mjs

@@ -99,6 +99,15 @@ export class DesktopServiceManager {
     if (this.stopping || generation !== this.generation) throw new Error('本机服务状态已改变,请重试')
     if (this.stopping || generation !== this.generation) throw new Error('本机服务状态已改变,请重试')
     this.activationRequested = true
     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) {
   async activateRecord(record, service) {
     if (service.activationRequired)
     if (service.activationRequired)
       await localJSON(new URL('/desktop/service/activate', service.url), { 'x-yaoyao-desktop-token': record.token }, 'POST')
       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' } = {}) {
 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)) }
     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') {
     if (req.url.split('?')[0] === '/api/app/bootstrap') {
       res.setHeader('set-cookie', 'csrf=fixture; Path=/; HttpOnly')
       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') {
     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' })
       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 }) }
     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()
     await expect(page.locator('#choice-section')).toBeHidden()
     assert.deepEqual((await page.evaluate(() => window.yaoyaoDesktop.status())).supportedModes, ['client'])
     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)
     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 page.locator('#server').fill(url); await page.locator('#detect').click()
     await expect(page.locator('#login-form')).toBeVisible()
     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 + '/**')
     await page.waitForURL(url + '/**')
     const state = await page.evaluate(() => window.yaoyaoDesktop.modeState())
     const state = await page.evaluate(() => window.yaoyaoDesktop.modeState())
     assert.equal(state.mode, 'client'); assert.deepEqual(state.supportedModes, ['client'])
     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
 HERMES_YAOYAO_PUBLISHED_PORT=15300
 # 远程通用镜像支持 AMD64 / ARM64;每次发布更新 latest 并保留旧版本标签。
 # 远程通用镜像支持 AMD64 / ARM64;每次发布更新 latest 并保留旧版本标签。
 # HERMES_YAOYAO_IMAGE=samienluo/yaoyao: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:local。
 # YAOYAO_DESKTOP_IMAGE=hermes-yaoyao-desktop:0.4.0
 # YAOYAO_DESKTOP_IMAGE=hermes-yaoyao-desktop:0.4.0
 # Cursor Universal 兼容桌面的本地构建名称;基础镜像在 deploy/computer/Dockerfile.cursor 中固定。
 # 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` 页面中完成。
 已确认的设计见 `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
 ```sh
-docker pull samienluo/yaoyao:v0.4.73
+docker pull samienluo/yaoyao:v0.4.74
 # 需要固定 AMD64 时:
 # 需要固定 AMD64 时:
-docker pull samienluo/yaoyao:v0.4.73-amd
+docker pull samienluo/yaoyao:v0.4.74-amd
 ```
 ```
 
 
 每次新版本发布都会更新 `latest`,旧版本标签继续保留。需要固定部署版本时使用具体版本标签。
 每次新版本发布都会更新 `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_UPSTREAM` | Hermes 上游地址,默认 `http://host.docker.internal:9119` |
 | `HERMES_YAOYAO_BIND_ADDRESS` | Web 的宿主机发布地址,默认 `127.0.0.1`;局域网访问可设为 `0.0.0.0` |
 | `HERMES_YAOYAO_BIND_ADDRESS` | Web 的宿主机发布地址,默认 `127.0.0.1`;局域网访问可设为 `0.0.0.0` |
 | `HERMES_YAOYAO_PUBLISHED_PORT` | 宿主机 Web 端口,默认 `15300` |
 | `HERMES_YAOYAO_PUBLISHED_PORT` | 宿主机 Web 端口,默认 `15300` |
@@ -211,7 +211,7 @@ curl --fail http://127.0.0.1:15300/healthz
 使用 Docker Hub 发布镜像时,在 `docker.env` 设置:
 使用 Docker Hub 发布镜像时,在 `docker.env` 设置:
 
 
 ```dotenv
 ```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
 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` 提供该构建环境。
 日常可以在 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",
   "name": "yaoyao",
-  "version": "0.4.73",
+  "version": "0.4.74",
   "lockfileVersion": 3,
   "lockfileVersion": 3,
   "requires": true,
   "requires": true,
   "packages": {
   "packages": {
     "": {
     "": {
       "name": "yaoyao",
       "name": "yaoyao",
-      "version": "0.4.73",
+      "version": "0.4.74",
       "license": "Apache-2.0",
       "license": "Apache-2.0",
       "dependencies": {
       "dependencies": {
         "@koa/bodyparser": "^5.0.0",
         "@koa/bodyparser": "^5.0.0",

+ 1 - 1
package.json

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

+ 3 - 3
release.json

@@ -1,6 +1,6 @@
 {
 {
   "schemaVersion": 1,
   "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 flowReady = import('/desktop/onboarding.mjs').then(({DesktopOnboarding}) => {
   const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
   const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
   const flow = new DesktopOnboarding({
   const flow = new DesktopOnboarding({
+    platform: 'darwin',
     remoteServer: () => 'http://192.168.1.10:15300',
     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 () => {
     prepareLocal: async () => {
       for (const [stage,message] of [['checking','正在检查运行环境…'],['installing','正在准备服务组件…'],['starting','正在启动并检查服务…']]) {
       for (const [stage,message] of [['checking','正在检查运行环境…'],['installing','正在准备服务组件…'],['starting','正在启动并检查服务…']]) {
         flow.serviceChanged({phase:'starting',stage,message}); await delay(stage==='installing'?3000:1000);
         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}; },
     authenticate: async ({username,password}) => { await delay(600); if(password==='error')throw new Error('用户名或密码不正确'); return {username}; },
     activate: async () => {},
     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;
   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.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 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) => {
 const server = createServer(async (req, res) => {

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

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

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

@@ -12,6 +12,7 @@ interface ImportMeta {
 interface Window {
 interface Window {
   yaoyaoDesktop?: {
   yaoyaoDesktop?: {
     openLogin?(): Promise<unknown>
     openLogin?(): Promise<unknown>
+    forgetLogin?(): Promise<unknown>
     openUpdates?(): Promise<void>
     openUpdates?(): Promise<void>
     modeState?(): Promise<{ mode: 'client' | 'server'; serverURL: string; switching: boolean; platform?: string; supportedModes?: ('client' | 'server')[] }>
     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 }>
     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> {
   async function logout(): Promise<void> {
     try { await authApi.logout() } catch { /* local state still signs out */ }
     try { await authApi.logout() } catch { /* local state still signs out */ }
+    await window.yaoyaoDesktop?.forgetLogin?.()
     status.value = 'anonymous'
     status.value = 'anonymous'
     user.value = undefined
     user.value = undefined
     serverIdentity.value = undefined; identityRequest++
     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> {
   async function changeCredentials(input: { currentPassword: string; newPassword: string; username?: string }): Promise<void> {
+    const localDesktop = user.value?.localDesktop
     user.value = await authApi.changeCredentials(input)
     user.value = await authApi.changeCredentials(input)
+    if (window.yaoyaoDesktop && !localDesktop) await window.yaoyaoDesktop.forgetLogin?.()
     await bootstrap()
     await bootstrap()
   }
   }
   async function updateAccountAvatar(avatar: string | null): Promise<void> {
   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,
     role: string(source.role) || undefined,
     enabled: source.enabled === undefined ? undefined : bool(source.enabled),
     enabled: source.enabled === undefined ? undefined : bool(source.enabled),
     mustChangePassword: bool(pick(source, 'mustChangePassword', 'must_change_password')),
     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).
 // Native update admission runs before routes (including streamed requests).
 // The capability is loopback-only and never shared with the Web renderer.
 // The capability is loopback-only and never shared with the Web renderer.
 runtime.app.middleware.unshift(instance.middleware(shutdown, () => runtime.realtime.broker.idleForUpdate
 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) {
 if (runtime.config.production) {
   const dist = resolve(process.env.HERMES_YAOYAO_STATIC_DIR || resolve(process.cwd(), 'dist'))
   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_COOKIE = 'hermes_yaoyao_session'
 const SESSION_TTL_SECONDS = 60 * 60 * 24 * 30
 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
 const USERNAME_PATTERN = /^[^\u0000-\u001f\u007f\s/@\\]{1,100}$/u
 
 
 export type LocalRole = 'admin' | 'user'
 export type LocalRole = 'admin' | 'user'
@@ -50,6 +51,8 @@ interface StoredUser {
 interface StoredUsers { version: 1; users: StoredUser[] }
 interface StoredUsers { version: 1; users: StoredUser[] }
 
 
 interface SessionRecord {
 interface SessionRecord {
+  desktop?: boolean
+  localDesktop?: boolean
   userID: string
   userID: string
   authVersion: number
   authVersion: number
   expiresAt: number
   expiresAt: number
@@ -57,6 +60,7 @@ interface SessionRecord {
 interface StoredSessions { version: 1; sessions: Array<SessionRecord & { tokenHash: string }> }
 interface StoredSessions { version: 1; sessions: Array<SessionRecord & { tokenHash: string }> }
 
 
 export interface LocalUser {
 export interface LocalUser {
+  localDesktop?: boolean
   registrationStatus?: RegistrationStatus
   registrationStatus?: RegistrationStatus
   id: string
   id: string
   username: string
   username: string
@@ -152,7 +156,7 @@ export class LocalAuthStore {
     return this.#issueSession(ctx, admin)
     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 normalized = usernameValue.trim().toLocaleLowerCase('en-US')
     const user = this.#users.find(candidate => candidate.normalizedUsername === normalized)
     const user = this.#users.find(candidate => candidate.normalizedUsername === normalized)
     if (!user || !this.#passwordMatches(user, password)) {
     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.registrationStatus === 'pending') throw new HttpError(403, '账号尚未开通,请等待管理员分配机器人并开通。', 'account_pending_approval')
     if (!user.enabled) throw new HttpError(401, '用户名或密码错误', 'login_failed')
     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 {
   issueSession(ctx: Koa.Context, userID: string): LocalUser {
@@ -171,12 +195,14 @@ export class LocalAuthStore {
     return this.#issueSession(ctx, user)
     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')
     const token = randomBytes(32).toString('base64url')
     this.#sessions.set(this.#sessionKey(token), {
     this.#sessions.set(this.#sessionKey(token), {
       userID: user.id,
       userID: user.id,
       authVersion: user.authVersion,
       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()
     this.#saveSessions()
     appendSetCookies(ctx, [serialize(SESSION_COOKIE, token, {
     appendSetCookies(ctx, [serialize(SESSION_COOKIE, token, {
@@ -184,9 +210,9 @@ export class LocalAuthStore {
       secure: this.#secureCookie,
       secure: this.#secureCookie,
       sameSite: 'strict',
       sameSite: 'strict',
       path: '/',
       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
     ctx.state.localUser = published
     return published
     return published
   }
   }
@@ -223,7 +249,7 @@ export class LocalAuthStore {
       this.#saveSessions()
       this.#saveSessions()
       return undefined
       return undefined
     }
     }
-    return publicUser(user)
+    return { ...publicUser(user), ...(session.localDesktop ? { localDesktop: true, mustChangePassword: false } : {}) }
   }
   }
 
 
   require(ctx: Koa.Context, allowPasswordChange = false): LocalUser {
   require(ctx: Koa.Context, allowPasswordChange = false): LocalUser {
@@ -349,7 +375,7 @@ export class LocalAuthStore {
   ): LocalUser {
   ): LocalUser {
     const current = this.require(ctx, true)
     const current = this.require(ctx, true)
     const user = this.#users.find(candidate => candidate.id === current.id)!
     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')
       throw new HttpError(401, '当前密码错误', 'invalid_current_password')
     }
     }
     const username = newUsername === undefined ? user.username : canonicalUsername(newUsername)
     const username = newUsername === undefined ? user.username : canonicalUsername(newUsername)
@@ -363,7 +389,14 @@ export class LocalAuthStore {
     user.mustChangePassword = false
     user.mustChangePassword = false
     user.updatedAt = Date.now()
     user.updatedAt = Date.now()
     this.#saveUsers()
     this.#saveUsers()
+    const session = this.#sessions.get(this.#sessionKey(this.#token(ctx) ?? ''))
     this.#revokeUser(user.id)
     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)
     return this.login(ctx, username, newPassword)
   }
   }
 
 
@@ -377,7 +410,7 @@ export class LocalAuthStore {
     else delete user.avatar
     else delete user.avatar
     user.updatedAt = Date.now()
     user.updatedAt = Date.now()
     this.#saveUsers()
     this.#saveUsers()
-    const published = publicUser(user)
+    const published = { ...publicUser(user), ...(current.localDesktop ? { localDesktop: true } : {}) }
     ctx.state.localUser = published
     ctx.state.localUser = published
     return published
     return published
   }
   }
@@ -494,6 +527,8 @@ export class LocalAuthStore {
         if (session.tokenHash && session.expiresAt > now) {
         if (session.tokenHash && session.expiresAt > now) {
           this.#sessions.set(session.tokenHash, {
           this.#sessions.set(session.tokenHash, {
             userID: session.userID, authVersion: session.authVersion, expiresAt: session.expiresAt,
             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')
     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)
   await bootstrap(ctx, dependencies, true)
 }
 }
 
 
@@ -1716,6 +1716,10 @@ export function createApiRouter(dependencies: RouteDependencies): Router {
   router.post('/api/app/login', async (ctx) => {
   router.post('/api/app/login', async (ctx) => {
     await login(ctx, dependencies)
     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 => {
   router.post('/api/app/register', ctx => {
     const retry = registrations.take(ctx.req.socket.remoteAddress ?? 'unknown')
     const retry = registrations.take(ctx.req.socket.remoteAddress ?? 'unknown')
     if (retry) {
     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. */ }
       } catch { /* Never remove a lock that can no longer be identified. */ }
       finally { lock.close() }
       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) => {
       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 (!control) {
           if(stopping){ctx.status=503;ctx.body={error:'后台服务正在停止',code:'service_stopping'};return}
           if(stopping){ctx.status=503;ctx.body={error:'后台服务正在停止',code:'service_stopping'};return}
           if (quiesced() && !['/healthz', '/readyz', '/api/status'].includes(ctx.path)) {
           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 ?? '')
         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 }
         if (!local || ctx.get('origin') || !timingSafeEqual(received, expected)) { ctx.status = 403; ctx.body = { error: '桌面服务授权无效' }; return }
         ctx.set('Cache-Control', 'no-store')
         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/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.path === '/desktop/service/activate') {
           if (ctx.method !== 'POST' || !activation) { ctx.status = 405; return }
           if (ctx.method !== 'POST' || !activation) { ctx.status = 405; return }

+ 1 - 0
src/shared/types.ts

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

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

@@ -5,16 +5,20 @@ const mocks = vi.hoisted(() => ({
   login: vi.fn(),
   login: vi.fn(),
   setup: vi.fn(),
   setup: vi.fn(),
   authorizeComputer: vi.fn(),
   authorizeComputer: vi.fn(),
+  bootstrap: vi.fn(),
+  logout: vi.fn(),
+  changeCredentials: vi.fn(),
+  forgetLogin: vi.fn(),
 }))
 }))
 
 
 vi.mock('@/api/auth', () => ({
 vi.mock('@/api/auth', () => ({
   login: mocks.login,
   login: mocks.login,
   setup: mocks.setup,
   setup: mocks.setup,
-  bootstrap: vi.fn(),
-  logout: vi.fn(),
+  bootstrap: mocks.bootstrap,
+  logout: mocks.logout,
   fetchProfiles: vi.fn(),
   fetchProfiles: vi.fn(),
   fetchProfileIdentities: vi.fn(),
   fetchProfileIdentities: vi.fn(),
-  changeCredentials: vi.fn(),
+  changeCredentials: mocks.changeCredentials,
   updateAccountAvatar: vi.fn(),
   updateAccountAvatar: vi.fn(),
 }))
 }))
 vi.mock('@/api/serverIdentity', () => ({ fetchServerIdentity: vi.fn(), onServerIdentity: () => () => {} }))
 vi.mock('@/api/serverIdentity', () => ({ fetchServerIdentity: vi.fn(), onServerIdentity: () => () => {} }))
@@ -38,7 +42,11 @@ beforeEach(() => {
   mocks.login.mockReset()
   mocks.login.mockReset()
   mocks.setup.mockReset()
   mocks.setup.mockReset()
   mocks.authorizeComputer.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 () => {
 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()
   await expect(auth.login({ username: 'admin', password: 'secret' })).resolves.toBeUndefined()
   expect(auth.status).toBe('authenticated')
   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)
     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 fixture = gateway(() => Response.json({ detail: `upstream-${status}` }, { status }))
     const runtime = runtimeFor(fixture)
     const runtime = runtimeFor(fixture)
     const response = await request(runtime.app.callback())
     const response = await request(runtime.app.callback())
       .get('/Users/samien/.hermes/profiles/yaoer/cache/images/result.png')
       .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.downloads().length).toBeGreaterThan(0)
     expect(fixture.calls.some(call => call.url.pathname === '/api/fs/read-data-url' || call.url.pathname === '/api/media')).toBe(false)
     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 { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { join } from 'node:path'
 import type Koa from 'koa'
 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'
 import { LocalAuthStore } from '../../src/server/localAuth.js'
 
 
 const homes: string[] = []
 const homes: string[] = []
@@ -24,6 +24,7 @@ function issuedCookie(ctx: Koa.Context): string {
 }
 }
 
 
 afterEach(() => {
 afterEach(() => {
+  vi.restoreAllMocks()
   for (const home of homes.splice(0)) rmSync(home, { recursive: true, force: true })
   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')
     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() }
     } 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'],
     [401, 'Session not found'], [403, 'Session not found'], [500, 'Session not found'],
   ])('preserves cache on lookup failure %i: %s', async (status, error) => {
   ])('preserves cache on lookup failure %i: %s', async (status, error) => {
     const f = await fixture(() => Response.json({ error }, { status }))
     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.localDetail(f.owner, 'default', sessionID)).toBeDefined()
     expect(f.store.messagePage(f.owner, 'default', sessionID, 0, 100)).toBeDefined()
     expect(f.store.messagePage(f.owner, 'default', sessionID, 0, 100)).toBeDefined()
     f.assertOtherCopies()
     f.assertOtherCopies()

Nem az összes módosított fájl került megjelenítésre, mert túl sok fájl változott