Przeglądaj źródła

将桌面服务器选择、安装与登录整合为单页流程

Samien 1 miesiąc temu
rodzic
commit
8470ebd512
39 zmienionych plików z 1464 dodań i 263 usunięć
  1. 56 0
      design-qa.md
  2. 1 0
      desktop/boot-icons.svg
  3. 588 1
      desktop/boot.css
  4. 64 4
      desktop/boot.html
  5. 159 23
      desktop/boot.js
  6. 2 1
      desktop/core-lifecycle.test.mjs
  7. 3 1
      desktop/lifecycle.test.mjs
  8. 132 134
      desktop/main.mjs
  9. 16 12
      desktop/mode-switch.test.mjs
  10. 107 0
      desktop/onboarding-flow.test.mjs
  11. 103 0
      desktop/onboarding.mjs
  12. 72 0
      desktop/onboarding.test.mjs
  13. 4 0
      desktop/preload.cjs
  14. 0 5
      desktop/remote-login-preload.cjs
  15. 0 21
      desktop/remote-login.css
  16. 0 17
      desktop/remote-login.html
  17. 0 23
      desktop/remote-login.js
  18. 44 10
      desktop/remote-login.mjs
  19. 14 0
      desktop/remote-login.test.mjs
  20. 4 3
      desktop/service-manager.mjs
  21. 3 1
      desktop/service-sync.test.mjs
  22. 4 1
      desktop/startup-feedback.test.mjs
  23. 8 0
      desktop/test-support/onboarding.mjs
  24. 1 0
      desktop/update-entry.test.mjs
  25. 23 0
      docs/design/desktop-onboarding/README.md
  26. BIN
      docs/design/desktop-onboarding/approved-design.png
  27. BIN
      docs/design/desktop-onboarding/preview-local-account.png
  28. BIN
      docs/design/desktop-onboarding/preview-local-install.png
  29. BIN
      docs/design/desktop-onboarding/preview-narrow.png
  30. BIN
      docs/design/desktop-onboarding/preview-remote.png
  31. BIN
      docs/design/desktop-onboarding/remote-dark.png
  32. BIN
      docs/design/desktop-onboarding/remote-login.png
  33. BIN
      docs/design/desktop-onboarding/remote-narrow.png
  34. 1 1
      scripts/build-desktop.mjs
  35. 42 0
      scripts/preview-desktop-onboarding.mjs
  36. 3 3
      src/client/components/app/DesktopModePanel.vue
  37. 1 0
      src/client/env.d.ts
  38. 7 1
      src/client/views/LoginView.vue
  39. 2 1
      tests/client/settingsCenterDialog.test.ts

+ 56 - 0
design-qa.md

@@ -1,3 +1,59 @@
+# 桌面单页安装与登录设计核对
+
+final result: passed
+
+## 设计与证据
+
+- 视觉依据:`docs/design/desktop-onboarding/approved-design.png`,1672 × 941 px,包含同一设计的本机安装、远程登录两个状态。它是展示画板,不是两个运行窗口。
+- 浏览器实现:`preview-local-install.png`、`preview-local-account.png`、`preview-remote.png`,均在 `docs/design/desktop-onboarding/`。使用生产 `boot.html`、CSS、JS 和状态控制器,服务行为由预览适配器模拟。
+- Electron 实现:同目录 `remote-login.png`、`remote-dark.png`、`remote-narrow.png`;真实主窗口、IPC、模拟 HTTP 服务器完成认证。
+- 默认核对视口:1280 × 832 CSS px;Electron 原生标题栏下内容区约 1280 × 828 px。截图密度为 1;使用完整页面截图时高度可能比视口略长。
+- 窄窗口:720 × 560(Electron),390 × 844(浏览器补充响应式检查),均无横向溢出,内容可纵向滚动。
+- 归一化:分别比较设计画板中两个白色应用区域与实现的页面内容区域,排除画板标题、外围留白和说明。保留原始截图,未将不同屏幕宽度的比较称为逐像素复刻。宽窗口采用 820 px 内容列,延续参考的单列步骤、并排服务器选择和全宽主按钮。
+- 全视图对照:在同一次视觉检查输入中打开设计画板、安装中截图与远程登录截图;另检查创建管理员、深色及窄窗口截图。
+- 重点区域:对照服务器选项、连接反馈、表单标签与主按钮。全尺寸截图中这些文字清晰可读,不需要再放大裁剪。
+
+## 核对结果
+
+- 字体与层级:沿用系统中文字体及现有品牌图;26 px 页面标题、17 px 区块标题、13–14 px 表单文字。修复“确认密码”标签换行,中文产品名使用准确的“夭夭”。
+- 间距与布局:服务器选择始终可见;准备与账号步骤由分隔线分组,当前步骤突出。默认桌面窗口下远程登录和本机创建账号的主按钮及页底控件完整可见。
+- 颜色:沿用白色画布、黑色主按钮、灰色次级面和绿色成功提示;深色模式对应既有色值,错误不会仅靠颜色表达。
+- 资产:复用项目品牌图片;图标来自已安装的 `@vicons/tabler`,未绘制近似图标或引入额外字体、图片服务。
+- 文案与内容:本机准备绑定真实服务状态;没有可测量百分比时使用不定进度。首次初始化与已有账号由服务响应决定。准备完成后收起详情,保留成功摘要与展开入口。
+- 交互:本机/远程选择、安装详情、帮助、密码显示、必填与确认密码、重新检测、地址修改后的凭据清空均有实现。登录完成后只导航原主窗口。
+- 可访问性:明确标签、原生单选框、键盘焦点、44 px 输入/按钮、页内 alert/status 与减少动画设置。
+
+## 迭代记录
+
+1. P2:初版默认窗口的远程登录页底控件略超出视口,主按钮与服务器选择左边缘不齐。
+   修复:调整步骤与区块间距,主按钮移出缩进的字段区域。重新截图 `preview-remote.png`,Electron 测试断言 footer 位于窗口内。
+2. P2:本机创建管理员的“确认密码”标签换行,已完成安装列表占用过多高度。
+   修复:增加标签列宽、禁止标签断行,准备完成后收起进度列表并保留详情摘要。重新截图 `preview-local-account.png`;浏览器检查 footer 底部为 809.89 px,视口高 832 px。
+3. 最终检查:远程登录、本机安装中、本机账号、深色、窄窗口均无待修复的 P0/P1/P2 问题。浏览器控制台无 warning/error。
+
+## 有意保留的差异与验证边界
+
+- 设计图的示例百分比替换为真实阶段进度;“安装组件”改为“准备服务组件”,覆盖安装和已有环境复用。
+- 当前服务器尚未检测完成时,不预先承诺创建管理员;检测后才切换为“创建管理员”或“登录账号”。
+- 安装与登录使用现有真实服务逻辑;浏览器预览仅演示交互。未连接用户的真实远程服务器执行登录。
+- 本次生成了桌面运行资源并验证;没有发布版本或替换用户已安装的 App。
+
+## 实现检查清单
+
+- [x] 单主窗口内完成服务器选择、安装准备及登录。
+- [x] 登录错误、安装重试、电脑授权异常保持在页内。
+- [x] 凭据绑定已检测服务器,IPC 限制在本地可信主页面。
+- [x] 默认窗口、窄窗口和深色模式核对。
+- [x] 服务管理、认证、生命周期集成验证与类型检查。
+
+## 自动化验证结果
+
+- 桌面完整测试:51 项通过,0 项失败。
+- 前端认证与设置相关测试:23 项通过。
+- 完整构建、类型检查与桌面资源打包通过。
+
+---
+
 # 聊天气泡外观 · 三端设计验收(2026-09-21)
 
 ## Findings

Plik diff jest za duży
+ 1 - 0
desktop/boot-icons.svg


+ 588 - 1
desktop/boot.css

@@ -1 +1,588 @@
-:root{color-scheme:light dark;font-family:-apple-system,BlinkMacSystemFont,sans-serif;background:#f7f7f7;color:#202020}body{margin:0;min-height:100vh;display:grid;place-items:center}main{max-width:420px;padding:32px;text-align:center}img{border-radius:18px}h1{font-size:24px;margin:24px 0 12px}p{font-size:15px;line-height:1.7;white-space:pre-line}button{font:inherit;min-height:44px;padding:10px 20px;border:1px solid #aaa;border-radius:10px;background:transparent;color:inherit;cursor:pointer;margin:8px}button:focus-visible{outline:2px solid currentColor;outline-offset:3px}button:disabled{opacity:.5;cursor:default}@media(prefers-color-scheme:dark){:root{background:#151515;color:#eee}button{border-color:#777}}
+:root {
+  color-scheme:light dark;
+  --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);
+  font:14px/1.5 -apple-system,BlinkMacSystemFont,"PingFang SC","Microsoft YaHei",sans-serif;
+  color:var(--text);
+  background:var(--canvas)
+}
+* {
+  box-sizing:border-box
+}
+body {
+  margin:0
+}
+button,input {
+  font:inherit;
+  color:inherit
+}
+button {
+  cursor:pointer
+}
+button:disabled {
+  cursor:default;
+  opacity:.52
+}
+input[type=radio],input[type=checkbox] {
+  accent-color:var(--solid);
+  width:16px;
+  height:16px;
+  margin:0;
+  flex:none
+}
+button:focus-visible,input:focus-visible,summary:focus-visible {
+  outline:2px solid var(--solid);
+  outline-offset:3px
+}
+[hidden] {
+  display:none!important
+}
+.sr-only {
+  position:absolute;
+  width:1px;
+  height:1px;
+  overflow:hidden;
+  clip-path:inset(50%);
+  white-space:nowrap
+}
+svg {
+  width:20px;
+  height:20px;
+  flex:none
+}
+h1,h2,p {
+  margin:0
+}
+.page {
+  width:min(820px,calc(100% - 80px));
+  margin:20px auto 20px
+}
+.brand {
+  display:flex;
+  align-items:center;
+  justify-content:space-between
+}
+.brand>div {
+  display:flex;
+  align-items:center;
+  gap:10px
+}
+.brand img {
+  object-fit:contain
+}
+.brand strong {
+  font-size:24px;
+  font-weight:650
+}
+.brand>span {
+  font-size:12px;
+  color:var(--muted)
+}
+.intro {
+  margin-top:16px
+}
+.intro h1 {
+  font-size:26px;
+  line-height:1.4;
+  letter-spacing:-.035em;
+  font-weight:650
+}
+.intro p {
+  margin-top:4px;
+  color:var(--muted)
+}
+.steps {
+  display:flex;
+  align-items:center;
+  justify-content:center;
+  gap:0;
+  list-style:none;
+  margin:20px 0 18px;
+  padding:0;
+  font-size:13px
+}
+.steps li {
+  display:flex;
+  align-items:center;
+  gap:10px;
+  color:var(--muted)
+}
+.steps li+li:before {
+  content:"";
+  width:72px;
+  height:1px;
+  background:var(--line);
+  margin:0 20px 0 10px
+}
+.step-dot {
+  width:28px;
+  height:28px;
+  border-radius:50%;
+  display:grid;
+  place-items:center;
+  background:var(--soft);
+  color:var(--muted)
+}
+.step-dot b {
+  font-weight:500
+}
+.step-dot svg {
+  display:none;
+  width:18px;
+  height:18px
+}
+.steps [aria-current=step] {
+  color:var(--text)
+}
+.steps [aria-current=step] .step-dot {
+  color:var(--on-solid);
+  background:var(--solid)
+}
+.steps .complete .step-dot {
+  background:var(--success);
+  color:#fff
+}
+.steps .complete b {
+  display:none
+}
+.steps .complete svg {
+  display:block
+}
+.section {
+  padding:14px 0;
+  border-top:1px solid var(--line)
+}
+.choice-section {
+  border-top:0;
+  padding-top:0
+}
+h2 {
+  display:flex;
+  gap:20px;
+  align-items:baseline;
+  font-size:17px;
+  font-weight:630;
+  line-height:1.5
+}
+h2>span:first-child {
+  font-variant-numeric:tabular-nums
+}
+.inset {
+  margin-left:42px
+}
+.muted {
+  color:var(--muted);
+  font-size:13px;
+  margin-top:4px
+}
+.pending h2 {
+  color:var(--muted)
+}
+.mode-options {
+  display:grid;
+  grid-template-columns:1fr 1fr;
+  gap:14px;
+  border:0;
+  margin:12px 0 0;
+  padding:0;
+  min-width:0
+}
+.mode-option {
+  display:flex;
+  align-items:center;
+  gap:16px;
+  min-height:74px;
+  padding:15px 18px;
+  border:1px solid var(--line);
+  border-radius:10px;
+  cursor:pointer
+}
+.mode-option:has(:checked) {
+  border-color:var(--solid);
+  background:var(--soft);
+  box-shadow:inset 0 0 0 .5px var(--solid)
+}
+.mode-option:has(:focus-visible) {
+  outline:2px solid var(--solid);
+  outline-offset:3px
+}
+.mode-option:has(:disabled) {
+  cursor:default
+}
+.mode-option span {
+  min-width:0
+}
+.mode-option strong {
+  display:block;
+  font-size:14px;
+  font-weight:600
+}
+.mode-option small {
+  display:block;
+  color:var(--muted);
+  font-size:12px;
+  margin-top:4px
+}
+.mode-icon {
+  width:27px;
+  height:27px
+}
+.installation {
+  background:var(--soft);
+  border-radius:10px;
+  padding:4px 20px;
+  margin-top:12px
+}
+.install-steps {
+  list-style:none;
+  margin:0;
+  padding:0
+}
+.install-steps li {
+  display:flex;
+  align-items:center;
+  gap:14px;
+  padding:8px 0;
+  font-size:14px
+}
+.install-steps li+li {
+  border-top:1px solid var(--line)
+}
+.install-steps small {
+  margin-left:auto;
+  color:var(--muted);
+  font-size:12px
+}
+.install-steps svg {
+  width:18px;
+  height:18px;
+  color:var(--muted)
+}
+.install-steps .complete svg,.install-steps .complete small {
+  color:var(--success)
+}
+.install-steps .active svg {
+  color:var(--text);
+  animation:spin 1.2s linear infinite
+}
+.install-steps .active small {
+  color:var(--text)
+}
+progress {
+  display:block;
+  width:100%;
+  height:7px;
+  margin:10px 0 12px;
+  accent-color:var(--solid)
+}
+summary {
+  display:flex;
+  align-items:center;
+  gap:7px;
+  list-style:none;
+  cursor:pointer;
+  color:var(--muted);
+  font-size:12px;
+  min-height:32px
+}
+summary::-webkit-details-marker {
+  display:none
+}
+summary svg {
+  width:16px;
+  height:16px
+}
+details[open] summary svg {
+  transform:rotate(90deg)
+}
+pre {
+  font:12px/1.65 ui-monospace,monospace;
+  white-space:pre-wrap;
+  overflow-wrap:anywhere;
+  max-height:130px;
+  overflow:auto;
+  color:var(--muted);
+  margin:2px 0 10px
+}
+label[for] {
+  font-size:13px
+}
+.connection-row {
+  display:flex;
+  gap:10px;
+  margin-top:6px
+}
+.connection-row input {
+  flex:1;
+  min-width:0
+}
+#connection-form {
+  margin-top:8px
+}
+input:not([type=checkbox],[type=radio]) {
+  width:100%;
+  height:44px;
+  padding:0 13px;
+  border:1px solid var(--line);
+  border-radius:9px;
+  background:var(--canvas);
+  min-width:0
+}
+input::placeholder {
+  color:var(--muted);
+  opacity:.85
+}
+input:not([type=checkbox],[type=radio]):focus {
+  border-color:var(--solid);
+  box-shadow:0 0 0 3px var(--focus);
+  outline:0
+}
+input[aria-invalid=true] {
+  border-color:var(--danger)
+}
+.secondary {
+  min-height:44px;
+  padding:9px 16px;
+  border:1px solid var(--line);
+  border-radius:9px;
+  background:var(--canvas);
+  white-space:nowrap
+}
+.secondary:hover {
+  background:var(--soft)
+}
+.primary {
+  display:flex;
+  align-items:center;
+  justify-content:center;
+  position:relative;
+  width:100%;
+  min-height:46px;
+  padding:10px 40px;
+  background:var(--solid);
+  color:var(--on-solid);
+  border:1px solid var(--solid);
+  border-radius:10px;
+  font-weight:550
+}
+.primary svg {
+  position:absolute;
+  right:18px
+}
+.primary:hover:not(:disabled) {
+  opacity:.9
+}
+#prepare-local {
+  margin-top:14px
+}
+#login-form {
+  margin-top:12px
+}
+#credentials {
+  display:grid;
+  gap:12px
+}
+.field {
+  display:grid;
+  grid-template-columns:60px minmax(0,1fr);
+  align-items:center;
+  gap:12px
+}
+.password-input {
+  position:relative
+}
+.password-input input {
+  padding-right:48px
+}
+.eye-button {
+  position:absolute;
+  right:0;
+  top:0;
+  display:grid;
+  place-items:center;
+  width:44px;
+  height:44px;
+  background:transparent;
+  border:0;
+  color:var(--muted);
+  border-radius:9px
+}
+#submit {
+  margin-top:14px
+}
+#waiting {
+  margin-top:14px;
+  background:var(--soft);
+  color:var(--muted);
+  border-color:var(--soft);
+  opacity:1
+}
+.status {
+  font-size:13px;
+  margin-top:10px;
+  overflow-wrap:anywhere
+}
+.status:empty {
+  display:none
+}
+.status.success {
+  color:var(--success)
+}
+.status[role=alert],.error {
+  color:var(--danger)
+}
+.error {
+  font-size:13px;
+  margin:12px 0;
+  overflow-wrap:anywhere
+}
+.actions {
+  display:flex;
+  gap:10px;
+  margin-top:12px;
+  flex-wrap:wrap
+}
+footer {
+  display:flex;
+  align-items:center;
+  justify-content:space-between;
+  margin-top:0;
+  font-size:13px;
+  color:var(--muted)
+}
+.remember {
+  display:flex;
+  align-items:center;
+  gap:8px;
+  min-height:44px;
+  cursor:pointer
+}
+.text-button {
+  background:transparent;
+  border:0;
+  padding:8px 0;
+  min-height:44px;
+  color:var(--muted);
+  text-decoration:underline;
+  text-underline-offset:3px;
+  font-size:13px
+}
+.help {
+  margin-top:8px;
+  border-top:1px solid var(--line);
+  padding-top:16px;
+  color:var(--muted);
+  font-size:13px
+}
+.help p+p {
+  margin-top:8px
+}
+@keyframes spin {
+  to {
+    transform:rotate(360deg)
+  }
+}
+@media(prefers-reduced-motion:reduce) {
+  .install-steps .active svg {
+    animation:none
+  }
+}
+@media(max-width:760px) {
+  .page {
+    width:calc(100% - 48px);
+    margin-top:20px
+  }
+  .mode-option {
+    gap:10px;
+    padding:12px
+  }
+  .mode-icon {
+    width:22px;
+    height:22px
+  }
+  .steps li+li:before {
+    width:30px;
+    margin:0 10px
+  }
+  .inset {
+    margin-left:0
+  }
+  .intro {
+    margin-top:20px
+  }
+}
+@media(max-width:540px) {
+  .page {
+    width:calc(100% - 36px)
+  }
+  .mode-options {
+    grid-template-columns:1fr
+  }
+  .steps {
+    font-size:12px
+  }
+  .steps li {
+    gap:5px
+  }
+  .steps li+li:before {
+    width:14px;
+    margin:0 6px
+  }
+  .step-dot {
+    width:24px;
+    height:24px
+  }
+  .field {
+    grid-template-columns:1fr;
+    gap:6px
+  }
+  .connection-row {
+    flex-wrap:wrap
+  }
+  .connection-row input {
+    flex-basis:100%
+  }
+  .intro h1 {
+    font-size:24px
+  }
+  h2 {
+    gap:14px
+  }
+}
+@media(prefers-color-scheme:dark) {
+  :root {
+    --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)
+  }
+  .brand img {
+    filter:invert(1)
+  }
+  .steps .complete .step-dot {
+    color:#151514
+  }
+}
+.field label {
+  white-space:nowrap
+}
+.local-ready #local-panel>.muted {
+  display:none
+}
+.local-ready .installation {
+  margin-top:6px
+}

+ 64 - 4
desktop/boot.html

@@ -1,5 +1,65 @@
 <!doctype html>
-<html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
-<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'self'; script-src 'self'; img-src 'self'">
-<title>夭夭</title><link rel="stylesheet" href="boot.css"></head>
-<body><main><img src="icon.png" alt="" width="72" height="72"><h1>夭夭</h1><p id="status" role="status" aria-live="polite">正在启动本地服务…</p><div id="actions" hidden><button id="force-sync" hidden>使用当前 App 覆盖同版本 Web</button><button id="retry">重试</button><button id="logs">查看日志</button></div></main><script src="boot.js"></script></body></html>
+<html lang="zh-CN">
+<head>
+  <meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
+  <meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'self'; script-src 'self'; img-src 'self'">
+  <title>开始使用夭夭</title><link rel="stylesheet" href="boot.css">
+</head>
+<body>
+<main class="page">
+  <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>
+  <ol class="steps" aria-label="设置进度">
+    <li id="step-choice" aria-current="step"><span class="step-dot"><b>1</b><svg><use href="boot-icons.svg#check"/></svg></span>选择服务器</li>
+    <li id="step-prepare"><span class="step-dot"><b>2</b><svg><use href="boot-icons.svg#check"/></svg></span>准备服务</li>
+    <li id="step-account"><span class="step-dot"><b>3</b><svg><use href="boot-icons.svg#check"/></svg></span><span id="account-step-title">登录账号</span></li>
+  </ol>
+  <section class="section choice-section" aria-labelledby="choice-title">
+    <h2 id="choice-title"><span>01</span>选择服务器</h2>
+    <fieldset id="mode-options" class="mode-options"><legend class="sr-only">服务器运行方式</legend>
+      <label class="mode-option"><input type="radio" name="mode" value="local"><svg class="mode-icon"><use href="boot-icons.svg#monitor"/></svg><span><strong>本机运行</strong><small>在这台电脑准备并运行服务</small></span></label>
+      <label class="mode-option"><input type="radio" name="mode" value="remote"><svg class="mode-icon"><use href="boot-icons.svg#link"/></svg><span><strong>连接远程服务器</strong><small>使用已有服务器,本机无需安装</small></span></label>
+    </fieldset>
+  </section>
+  <section id="prepare-section" class="section pending" aria-labelledby="prepare-title">
+    <h2 id="prepare-title"><span>02</span><span id="prepare-heading">准备服务</span></h2>
+    <p id="choose-hint" class="muted inset">请先选择本机运行或连接远程服务器。</p>
+    <div id="local-panel" hidden>
+      <p class="muted inset">检测已有环境,按顺序准备缺少的组件。</p>
+      <div class="installation">
+        <ol class="install-steps">
+          <li data-stage="checking"><svg><use href="boot-icons.svg#circle"/></svg><span>检查运行环境</span><small>等待中</small></li>
+          <li data-stage="installing"><svg><use href="boot-icons.svg#circle"/></svg><span>准备服务组件</span><small>等待中</small></li>
+          <li data-stage="starting"><svg><use href="boot-icons.svg#circle"/></svg><span>启动并检查服务</span><small>等待中</small></li>
+        </ol>
+        <progress id="install-progress" aria-label="正在准备本机服务" hidden></progress>
+        <details id="install-details"><summary>查看安装详情<svg><use href="boot-icons.svg#chevron"/></svg></summary><pre id="install-log">准备开始</pre><button id="logs" class="text-button" type="button">查看日志</button></details>
+      </div>
+    </div>
+    <form id="connection-form" class="inset" hidden>
+      <label for="server">服务器地址</label>
+      <div class="connection-row"><input id="server" name="server" type="text" inputmode="url" autocomplete="url" spellcheck="false" placeholder="192.168.1.10:15300" required><button id="detect" class="secondary" type="submit">检测连接</button></div>
+    </form>
+    <p id="status" class="status inset" role="status" aria-live="polite"></p>
+    <div id="actions" class="actions inset" hidden><button id="retry" class="secondary" type="button">重试</button><button id="force-sync" class="secondary" type="button" hidden>使用当前 App 覆盖同版本 Web</button></div>
+    <button id="prepare-local" class="primary" type="button" hidden><span>检测并准备本机服务</span><svg><use href="boot-icons.svg#arrow"/></svg></button>
+  </section>
+  <section id="account-section" class="section pending" aria-labelledby="account-title">
+    <h2 id="account-title"><span>03</span><span id="account-heading">登录账号</span></h2>
+    <p id="account-hint" class="muted inset">服务准备完成后,在此登录账号。</p>
+    <form id="login-form" hidden>
+      <div id="credentials" class="inset">
+        <div class="field"><label for="username">账号</label><input id="username" name="username" autocomplete="username" spellcheck="false" placeholder="输入用户名" required maxlength="320"></div>
+        <div class="field"><label for="password">密码</label><div class="password-input"><input id="password" name="password" type="password" autocomplete="current-password" placeholder="输入密码" required maxlength="4096"><button id="toggle-password" class="eye-button" type="button" aria-label="显示密码" aria-pressed="false"><svg><use href="boot-icons.svg#eye"/></svg></button></div></div>
+        <div id="confirmation-field" class="field" hidden><label for="confirmation">确认密码</label><input id="confirmation" name="confirmation" type="password" autocomplete="new-password" placeholder="再次输入密码" maxlength="4096"></div>
+      </div>
+      <p id="login-error" class="error inset" role="alert" hidden></p>
+      <button id="submit" class="primary" type="submit"><span>登录并进入夭夭</span><svg><use href="boot-icons.svg#arrow"/></svg></button>
+    </form>
+    <button id="waiting" class="primary" type="button" disabled hidden>正在准备服务,请稍候</button>
+  </section>
+  <footer><label class="remember"><input id="remember" type="checkbox">记住本次选择</label><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>
+</main>
+<script src="boot.js"></script>
+</body></html>

+ 159 - 23
desktop/boot.js

@@ -1,27 +1,163 @@
-function display(state) {
-  document.getElementById('status').textContent = state.message
-  document.getElementById('actions').hidden = !['error','stopped','disconnected'].includes(state.phase)
-  document.getElementById('retry').textContent = state.phase === 'disconnected' ? '重新连接' : state.phase === 'stopped' ? '启动后台服务' : '重试'
-  document.getElementById('status').setAttribute('role', state.phase === 'error' ? 'alert' : 'status')
-  document.getElementById('force-sync').hidden = state.phase !== 'error' || state.canForceSync !== true
-  document.querySelector('main').setAttribute('aria-busy', String(['starting','restarting','stopping'].includes(state.phase)))
+const api = window.yaoyaoDesktop
+const $ = id => document.getElementById(id)
+let state, initialized = false, pending = false, serverDirty = false, generation = 0, actionError = ''
+
+function display(next) {
+  state = next
+  if (!initialized) {
+    $('server').value = state.serverURL || ''
+    $('remember').checked = state.remember
+    initialized = true
+  }
+  const busy = pending || state.busy
+  const local = state.mode === 'local'
+  const ready = ['ready', 'authenticating', 'entering'].includes(state.phase) && !serverDirty
+  const setup = ready && state.setupRequired
+  const accountTitle = setup ? '创建管理员' : '登录账号'
+  $('mode-options').disabled = busy
+  for (const input of document.querySelectorAll('[name=mode]')) input.checked = input.value === state.mode
+  $('remember').disabled = busy
+  $('prepare-section').classList.toggle('pending', !state.mode)
+  $('prepare-section').classList.toggle('local-ready', local && ready)
+  $('prepare-heading').textContent = local ? '准备本机服务' : state.mode ? '连接服务器' : '准备服务'
+  $('choose-hint').hidden = !!state.mode
+  $('local-panel').hidden = !local
+  $('connection-form').hidden = state.mode !== 'remote'
+  $('server').disabled = busy
+  $('detect').disabled = busy || !$('server').value.trim()
+  $('detect').textContent = busy && !ready ? '正在检测…' : ready ? '重新检测' : '检测连接'
+  $('prepare-local').hidden = !local || state.phase !== 'idle'
+  $('prepare-local').disabled = busy
+  $('actions').hidden = !local || state.phase !== 'error'
+  $('retry').disabled = busy
+  $('force-sync').hidden = !state.canForceSync
+  $('force-sync').disabled = busy
+  const preparationError = !ready && !serverDirty && (actionError || state.error)
+  $('status').textContent = preparationError || (serverDirty ? '服务器地址已更改,请重新检测。' : state.message)
+  $('status').setAttribute('role', preparationError ? 'alert' : 'status')
+  $('status').classList.toggle('success', ready && !preparationError)
+  $('prepare-section').setAttribute('aria-busy', String(busy && !ready))
+  $('account-heading').textContent = accountTitle
+  $('account-step-title').textContent = accountTitle
+  $('account-section').classList.toggle('pending', !ready)
+  $('account-section').setAttribute('aria-busy', String(busy && ready))
+  $('account-hint').textContent = !ready ? '服务准备完成后,在此创建账号或登录。'
+    : state.authenticated ? `已登录${state.username ? `:${state.username}` : ''},可以继续进入夭夭。`
+    : setup ? '首次使用,请设置管理员账号和密码。密码至少 8 个字符。' : '登录所选服务器的夭夭账号。'
+  $('login-form').hidden = !ready
+  $('credentials').hidden = state.authenticated
+  $('confirmation-field').hidden = !setup
+  $('username').disabled = busy || state.authenticated || !ready
+  $('password').disabled = busy || state.authenticated || !ready
+  $('confirmation').disabled = busy || !setup || state.authenticated
+  $('confirmation').required = setup && !state.authenticated
+  $('password').minLength = setup ? 8 : 1
+  $('password').autocomplete = setup ? 'new-password' : 'current-password'
+  $('toggle-password').disabled = busy
+  $('submit').disabled = busy
+  $('submit').querySelector('span').textContent = state.phase === 'authenticating' ? (setup ? '正在创建…' : '正在登录…')
+    : state.phase === 'entering' ? '正在进入…' : state.authenticated ? '进入夭夭' : setup ? '创建并进入夭夭' : '登录并进入夭夭'
+  $('login-error').textContent = ready ? actionError || state.error : ''
+  $('login-error').hidden = !$('login-error').textContent
+  $('waiting').hidden = !busy || ready
+  $('waiting').textContent = local ? '正在准备服务,请稍候' : '正在检测连接,请稍候'
+  const activeStep = ready ? 2 : state.mode ? 1 : 0
+  for (const [index, id] of ['step-choice', 'step-prepare', 'step-account'].entries()) {
+    $(id).classList.toggle('complete', index < activeStep)
+    if (index === activeStep) $(id).setAttribute('aria-current', 'step')
+    else $(id).removeAttribute('aria-current')
+  }
+  const stages = ['checking', 'installing', 'starting']
+  const current = stages.indexOf(state.stage)
+  for (const [index, row] of [...document.querySelectorAll('[data-stage]')].entries()) {
+    const complete = ready || (state.phase !== 'idle' && index < current)
+    const active = !complete && state.phase === 'preparing' && index === current
+    row.classList.toggle('complete', complete)
+    row.classList.toggle('active', active)
+    row.querySelector('use').setAttribute('href', `boot-icons.svg#${complete ? 'check' : active ? 'loader' : 'circle'}`)
+    row.querySelector('small').textContent = complete ? '已完成' : active ? '进行中' : state.phase === 'error' && index === current ? '未完成' : '等待中'
+  }
+  $('install-progress').hidden = !local || state.phase !== 'preparing'
+  document.querySelector('.install-steps').hidden = local && ready
+  const history = state.history?.join('\n') || '准备开始'
+  if ($('install-log').textContent !== history) $('install-log').textContent = history
 }
-document.getElementById('force-sync').onclick = async () => {
-  const controls = [...document.querySelectorAll('#actions button')]
-  for (const button of controls) button.disabled = true
-  try { await window.yaoyaoDesktop.forceSync(); await render() }
-  catch { display({ phase: 'error', message: '覆盖未完成,请重试或查看服务日志。' }) }
-  finally { for (const button of controls) button.disabled = false }
+
+async function action(run, { resetServer = false, focusAccount = false } = {}) {
+  if (pending) return
+  pending = true; actionError = ''; generation++
+  if (state) display(state)
+  try {
+    const next = await run()
+    if (resetServer) {
+      if (next.phase === 'ready') $('server').value = next.serverURL || ''
+      serverDirty = false
+    }
+    display(next)
+  } catch (error) {
+    actionError = error.message || '操作未完成,请重试。'
+  } finally {
+    pending = false
+    if (state) display(state)
+    if (focusAccount && state?.phase === 'ready' && !state.authenticated) $('username').focus()
+  }
 }
-async function render() {
-  try { display(await window.yaoyaoDesktop.status()) }
-  catch { display({ phase: 'error', message: '无法读取启动状态,请重试或查看服务日志。' }) }
+for (const input of document.querySelectorAll('[name=mode]')) input.addEventListener('change', () => {
+  $('login-form').reset(); $('password').type = 'password'
+  $('toggle-password').setAttribute('aria-pressed', 'false'); $('toggle-password').setAttribute('aria-label', '显示密码')
+  $('password').removeAttribute('aria-invalid'); $('confirmation').removeAttribute('aria-invalid')
+  serverDirty = false
+  void action(() => api.selectServer(input.value))
+})
+$('server').addEventListener('input', () => {
+  serverDirty = true; actionError = ''
+  // Credentials belong to the checked server, never carry them to a new address.
+  $('login-form').reset(); $('password').type = 'password'
+  $('toggle-password').setAttribute('aria-pressed', 'false'); $('toggle-password').setAttribute('aria-label', '显示密码')
+  if (state) display(state)
+})
+$('connection-form').addEventListener('submit', event => {
+  event.preventDefault()
+  void action(() => api.prepareServer({ serverURL: $('server').value, remember: $('remember').checked }), { resetServer: true, focusAccount: true })
+})
+$('prepare-local').onclick = () => action(() => api.prepareServer({ remember: $('remember').checked }), { focusAccount: true })
+$('retry').onclick = () => action(() => api.prepareServer({ remember: $('remember').checked }), { focusAccount: true })
+$('force-sync').onclick = () => action(() => api.forceSync(), { focusAccount: true })
+$('logs').onclick = async () => { try { await api.logs() } catch (error) { actionError = error.message; display(state) } }
+$('toggle-password').onclick = () => {
+  const show = $('password').type === 'password'
+  $('password').type = show ? 'text' : 'password'
+  $('toggle-password').setAttribute('aria-label', show ? '隐藏密码' : '显示密码')
+  $('toggle-password').setAttribute('aria-pressed', String(show))
+}
+$('confirmation').addEventListener('blur', () => {
+  const mismatch = $('confirmation').value && $('confirmation').value !== $('password').value
+  $('confirmation').setAttribute('aria-invalid', String(!!mismatch))
+  actionError = mismatch ? '两次输入的密码不一致' : ''
+  display(state)
+})
+$('login-form').addEventListener('submit', event => {
+  event.preventDefault()
+  if (serverDirty || pending) return
+  if (state.setupRequired && !state.authenticated && $('confirmation').value !== $('password').value) {
+    actionError = '两次输入的密码不一致'; $('confirmation').setAttribute('aria-invalid', 'true'); display(state); $('confirmation').focus(); return
+  }
+  void action(async () => {
+    const next = await api.login({ username: $('username').value, password: $('password').value,
+      confirmation: $('confirmation').value, remember: $('remember').checked })
+    $('password').value = ''; $('confirmation').value = ''
+    return next
+  })
+})
+$('help-button').onclick = () => {
+  $('help').hidden = !$('help').hidden
+  $('help-button').setAttribute('aria-expanded', String(!$('help').hidden))
 }
-document.getElementById('retry').onclick = async () => {
-  document.getElementById('retry').disabled = true
-  try { await window.yaoyaoDesktop.retry(); await render() }
-  catch { display({ phase: 'error', message: '启动未完成,请重试或查看服务日志。' }) }
-  finally { document.getElementById('retry').disabled = false }
+async function render() {
+  const request = generation
+  try { const next = await window.yaoyaoDesktop.status(); if (request === generation) display(next) }
+  catch { if (!state) { $('status').textContent = '无法读取启动状态,请重新打开应用。'; $('status').setAttribute('role', 'alert') } }
 }
-document.getElementById('logs').onclick = () => window.yaoyaoDesktop.logs()
-void render();setInterval(() => { void render() }, 500)
+void render()
+const poll = setInterval(() => { void render() }, 500)
+window.addEventListener('pagehide', () => clearInterval(poll), { once: true })

+ 2 - 1
desktop/core-lifecycle.test.mjs

@@ -1,3 +1,4 @@
+import { enterLocal } from './test-support/onboarding.mjs'
 import {test} from 'node:test'
 import assert from 'node:assert/strict'
 import {mkdtemp,readFile,rm,mkdir} from 'node:fs/promises'
@@ -18,7 +19,7 @@ test('packaged core service starts, preserves window-close work, recovers, and s
   try{
     await until(()=>localJSON(`http://127.0.0.1:${upstreamPort}/api/status`),Boolean)
     app=await electron.launch({...(process.env.DESKTOP_TEST_EXECUTABLE?{executablePath:process.env.DESKTOP_TEST_EXECUTABLE}:{}),args:process.env.DESKTOP_TEST_EXECUTABLE?[]:[root],cwd:root,env:{...process.env,HERMES_YAOYAO_DESKTOP_TEST_HOME:home,HERMES_YAOYAO_DESKTOP_PORT:String(port),HERMES_YAOYAO_UPSTREAM:`http://127.0.0.1:${upstreamPort}`},timeout:30000})
-    const page=await app.firstWindow();await page.waitForURL(`http://127.0.0.1:${port}/**`)
+    const page=await app.firstWindow();await enterLocal(page);await page.waitForURL(`http://127.0.0.1:${port}/**`)
     const initial=JSON.parse(await readFile(join(home,'service-instance.json'),'utf8'));pid=initial.pid
     const identity=await localJSON(`http://127.0.0.1:${port}/desktop/service`,{'x-yaoyao-desktop-token':initial.token})
     assert.equal(identity.desktopOwned,true);assert.equal(identity.pid,pid);assert.equal(identity.version,release.webVersion)

+ 3 - 1
desktop/lifecycle.test.mjs

@@ -1,3 +1,4 @@
+import { enterLocal } from './test-support/onboarding.mjs'
 import { test } from 'node:test'
 import assert from 'node:assert/strict'
 import { mkdtemp, readFile, writeFile, stat, rm, mkdir, realpath } from 'node:fs/promises'
@@ -40,6 +41,7 @@ test('real desktop launches its bundled service, survives window close and recov
       HERMES_YAOYAO_UPSTREAM: `http://127.0.0.1:${upstreamPort}` }, timeout: 30000 })
     desktop.process().stderr.on('data', b => { if (process.env.DESKTOP_TEST_LOGS) process.stderr.write(b) })
     const page = await desktop.firstWindow()
+    await enterLocal(page)
     await page.waitForURL(`http://127.0.0.1:${port}/**`, { timeout: 30000 })
     const owner = await until(async () => JSON.parse(await readFile(join(home, 'service-instance.json'), 'utf8')), r => r.url)
     servicePID = owner.pid
@@ -54,7 +56,7 @@ test('real desktop launches its bundled service, survives window close and recov
     const registration=await page.evaluate(async()=>{
       const bootstrap=await (await fetch('/api/app/bootstrap')).json()
       const headers={'Content-Type':'application/json','X-CSRF-Token':bootstrap.csrfToken}
-      const setup=await fetch('/api/app/setup',{method:'POST',headers,body:JSON.stringify({username:'desktop-fixture',password:'desktop-fixture-password'})})
+      const setup=await fetch('/api/app/login',{method:'POST',headers,body:JSON.stringify({username:'desktop-fixture',password:'desktop-fixture-password'})})
       if(!setup.ok)throw new Error('fixture setup failed')
       headers['X-CSRF-Token']=(await (await fetch('/api/app/bootstrap')).json()).csrfToken
       const response=await fetch('/api/app/admin/runners',{method:'POST',headers,body:JSON.stringify({name:'桌面验收节点',allowedProfiles:['default']})})

+ 132 - 134
desktop/main.mjs

@@ -11,7 +11,8 @@ import { synchronizeLocalService, stopLocalService, migrateLocalData } from './s
 import { resolveDataHome } from './data-home.mjs'
 import { DesktopEnvironmentHost, DesktopHostCore } from './environment-host.mjs'
 import { DesktopHostManager } from './host-manager.mjs'
-import { remoteSession, enrollDesktopHost, normalizeServerURL } from './remote-login.mjs'
+import { remoteSession, enrollDesktopHost, inspectServer } from './remote-login.mjs'
+import { DesktopOnboarding } from './onboarding.mjs'
 import { DesktopUpdateManager } from './update-manager.mjs'
 import { installComputerViewer } from './computer-viewer.mjs'
 
@@ -23,8 +24,9 @@ app.setName('夭夭')
 if (!app.requestSingleInstanceLock()) { app.quit() }
 else {
   let window, tray, manager, runnerManager, quitting = false, closing = false, timer
-  let updateWindow, updater, environmentHost, hostManager, loginWindow
+  let updateWindow, updater, environmentHost, hostManager, onboarding
   let remoteMode = false, remoteURL = '', serverModeActive = false, switchingMode = false
+  let recoveringService = false
   const serviceURL = () => remoteMode ? remoteURL : manager?.state.url
   const serviceOrigins = () => {
     try { return remoteMode && remoteURL ? [new URL(remoteURL).origin] : manager?.state.url ? [new URL(manager.state.url).origin] : [] }
@@ -46,15 +48,16 @@ else {
   const port = Number(process.env.HERMES_YAOYAO_DESKTOP_PORT || 15300)
   if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error('桌面服务端口无效')
 
-  const trustedBoot = event => event.sender === window?.webContents && event.senderFrame?.url === bootURL
+  const trustedBoot = event => !closing && !quitting && event.sender === window?.webContents
+    && event.senderFrame === window.webContents.mainFrame && event.senderFrame.url === bootURL
   ipcMain.handle('desktop:status', event => {
     if (!trustedBoot(event)) throw new Error('不允许此页面访问桌面服务')
-    return { phase: manager?.state.phase || 'starting', message: manager?.state.message || '正在启动…', canForceSync: manager?.state.canForceSync === true }
+    return onboarding.snapshot()
   })
   ipcMain.handle('desktop:retry', async event => {
     if (!trustedBoot(event)) throw new Error('不允许此页面启动桌面服务')
     manager.restarts = []
-    try { await manager.start(); return true } catch { return false }
+    return onboarding.prepare()
   })
   ipcMain.handle('desktop:logs', event => {
     if (!trustedBoot(event)) throw new Error('不允许此页面读取日志')
@@ -63,9 +66,14 @@ else {
   ipcMain.handle('desktop:force-sync', async event => {
     if (!trustedBoot(event) || event.senderFrame !== window.webContents.mainFrame || closing || quitting || !manager?.canForceSynchronization)
       throw new Error('不允许此页面覆盖本机 Web')
-    try { await manager.retrySynchronization({ force: true }); return true }
-    catch (error) { log(error.message); return false }
+    return onboarding.prepare({ force: true })
   })
+  for (const action of ['select', 'prepare', 'submit']) {
+    ipcMain.handle(`desktop:onboarding-${action}`, (event, input) => {
+      if (!trustedBoot(event)) throw new Error('不允许此页面操作安装与登录')
+      return onboarding[action](input)
+    })
+  }
   const trustedUpdate = event => event.sender === updateWindow?.webContents
     && event.senderFrame === updateWindow.webContents.mainFrame && event.senderFrame.url === updateURL
   ipcMain.handle('desktop:updates', async event => {
@@ -152,12 +160,14 @@ else {
     })
     window.on('close', event => { if (!quitting) { event.preventDefault(); window.hide() } })
     window.once('ready-to-show', () => { if (!startHidden) window.show() })
-    if (remoteMode && remoteURL) void window.loadURL(remoteURL)
+    if (onboarding?.state.active) void window.loadURL(bootURL)
+    else if (remoteMode && remoteURL) void window.loadURL(remoteURL)
     else if (manager?.state.phase === 'ready') void window.loadURL(manager.state.url)
     else void window.loadFile(join(desktopRoot, 'boot.html'))
   }
   function stateChanged(state) {
     log(`${state.phase}: ${state.message}`)
+    onboarding?.serviceChanged(state)
     // Quit owns the window from this point on. A detached connection is not a
     // stopped server and must never navigate the closing window to boot.html.
     if (closing || quitting || remoteMode) return
@@ -173,10 +183,22 @@ else {
     if (forceSync) forceSync.enabled = !closing && !quitting && manager?.canForceSynchronization === true
     tray?.setToolTip(`夭夭 · ${state.message}`)
     if (!window || window.isDestroyed()) return
+    if (onboarding?.state.active) {
+      if (recoveringService && state.phase === 'ready') {
+        recoveringService = false
+        if (!onboarding.busy) void onboarding.prepare({ autoEnter: true }).catch(error => log(error.message))
+      }
+      return
+    }
     if (state.phase === 'ready') {
       const current = window.webContents.getURL()
       if (!current.startsWith(`${state.url}/`) && current !== state.url) void window.loadURL(state.url)
-    } else if (window.webContents.getURL() !== bootURL) void window.loadFile(join(desktopRoot, 'boot.html'))
+    } else if (window.webContents.getURL() !== bootURL) {
+      recoveringService = true
+      onboarding.open({ mode: 'local', remember: preferences.value.startupChoice === 'local' })
+      onboarding.serviceChanged(state)
+      void window.loadURL(bootURL)
+    }
   }
   app.on('second-instance', show)
   app.on('activate', show)
@@ -184,7 +206,7 @@ else {
   function requestQuit(stopBackground = false) {
     if (closing || quitting) return
     closing = true; clearInterval(timer)
-    updater?.cancel(); updateWindow?.close(); loginWindow?.close()
+    updater?.cancel(); updateWindow?.close()
     window?.hide()
     // Native Cmd+Q can enter before-quit from Cocoa's termination callback.
     // Both cleanup and the final quit must run after that callback unwinds.
@@ -303,12 +325,12 @@ else {
     ipcMain.handle('desktop:mode-switch',async(event,mode)=>{
       requireModePage(event)
       if(mode!=='client'&&mode!=='server')throw new Error('运行模式无效')
-      return switchMode(mode==='client'?'remote':'local',false)
+      return switchMode(mode==='client'?'remote':'local')
     })
     ipcMain.handle('desktop:remote-login',event=>{
       requireModePage(event)
       if(switchingMode)throw new Error('正在切换运行模式,请稍候')
-      openRemoteLogin()
+      return openRemoteLogin()
     })
     ipcMain.handle('desktop:computer-authorize',async(event,csrfToken)=>{
       requireModePage(event)
@@ -322,7 +344,6 @@ else {
       }catch(error){
         const message=error instanceof Error?error.message:'电脑授权恢复失败'
         hostManager.publish(`电脑授权未恢复:${message}`)
-        await appDialog({type:'warning',message:'账号已登录,但电脑授权未恢复',detail:`${message}\n\n请在“电脑”菜单中选择“重新授权…”。`})
         return {registered:false}
       }
     })
@@ -332,44 +353,81 @@ else {
         :await dialog.showOpenDialog({title:'导入电脑配置',properties:['openFile'],filters:[{name:'电脑配置',extensions:['json']}]})
       if(!selection.canceled&&selection.filePaths[0]&&!closing)await hostAction(()=>hostManager.importFile(selection.filePaths[0]))
     }
-    function openRemoteLogin(){
-      if(closing||quitting)return
-      if(loginWindow&&!loginWindow.isDestroyed()){loginWindow.show();loginWindow.focus();return}
-      loginWindow=new BrowserWindow({width:420,height:580,minWidth:360,minHeight:440,show:false,title:'登录远程',backgroundColor:'#f7f7f7',
-        webPreferences:{contextIsolation:true,nodeIntegration:false,sandbox:true,webSecurity:true,preload:join(desktopRoot,'remote-login-preload.cjs')}})
-      loginWindow.once('ready-to-show',()=>{if(loginWindow)loginWindow.show()})
-      loginWindow.on('closed',()=>{loginWindow=undefined})
-      const stored=preferences.value.remoteServer
-      void loginWindow.loadFile(join(desktopRoot,'remote-login.html'),stored?{query:{server:stored}}:undefined)
+    if (!preferences.value.remoteServer) {
+      const enrolled = await hostManager.read().catch(() => undefined)
+      if (enrolled?.serverURL) await preferences.setRemoteServer(enrolled.serverURL)
     }
-    async function enterRemoteMode(){
-      const enrolled=await hostManager.read().catch(error=>{log(error.message);return undefined})
-      let server=''
-      const candidate=preferences.value.remoteServer||enrolled?.serverURL
-      if(candidate){try{server=normalizeServerURL(candidate)}catch(error){log(error.message)}}
-      if(!server){openRemoteLogin();return false}
-      serverModeActive=false
-      await environmentHost?.close().catch(error=>log(error.message))
-      await runnerManager?.stop().catch(error=>log(error.message))
-      await manager?.stop().catch(error=>log(error.message))
-      remoteMode=true;remoteURL=server
-      await preferences.setRemoteServer(server).catch(error=>log(error.message))
-      await preferences.setStartupChoice('remote').catch(error=>log(error.message))
-      if(enrolled&&new URL(enrolled.serverURL).origin===new URL(server).origin)await hostManager.start().catch(error=>{hostManager.publish(error.message)})
-      const status=Menu.getApplicationMenu()?.getMenuItemById('desktop-service-status')
-      if(status)status.label=`已登录远程 · ${new URL(server).host}`
-      tray?.setToolTip(`夭夭 · 远程 ${new URL(server).host}`)
-      syncModeMenu()
-      log(`远程模式:${server}`)
-      if(!fixtureHome){
-        try{
-          const probe=await net.fetch(new URL('/api/app/bootstrap',server),{headers:{accept:'application/json'},redirect:'error'})
-          const info=await probe.json().catch(()=>undefined)
-          if(info?.authenticated!==true)openRemoteLogin()
-        }catch{openRemoteLogin()}
-      }
-      return true
+    onboarding = new DesktopOnboarding({
+      remoteServer: () => preferences.value.remoteServer,
+      inspect: server => inspectServer(server, (...args) => net.fetch(...args)),
+      prepareLocal: async ({ force }) => {
+        serverModeActive = true; remoteMode = false; remoteURL = ''
+        await hostManager.stop()
+        manager.restarts = []
+        if (force) await manager.retrySynchronization({ force: true })
+        else await manager.start()
+        if (manager.state.phase !== 'ready') throw new Error(manager.state.message)
+        if (!closing) environmentHost.start()
+        await runnerManager.start().catch(error => runnerManager.publish(error.message))
+        return manager.state.url
+      },
+      authenticate: async ({ mode, serverURL, setup, username, password }) => {
+        const auth = await remoteSession(serverURL, { setup, username, password })
+        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()
+        let warning
+        if (mode === 'remote' && auth.user.role === 'admin') {
+          try { await authorizeComputer(serverURL, auth) }
+          catch (error) { warning = `账号已登录,但电脑授权未恢复:${error.message}。可先进入夭夭,稍后在“电脑”菜单重新授权。` }
+        }
+        return { username: auth.user.username, warning }
+      },
+      activate: async ({ mode, serverURL, remember }) => {
+        if (closing || quitting) throw new Error('App 正在退出')
+        if (mode === 'remote') {
+          // Detach only; an independently installed local service keeps running.
+          serverModeActive = false
+          await environmentHost.close()
+          await runnerManager.stop()
+          await manager.stop()
+          remoteMode = true; remoteURL = serverURL
+          await preferences.setRemoteServer(serverURL)
+          const enrolled = await hostManager.read().catch(error => { log(error.message); return undefined })
+          if (enrolled && new URL(enrolled.serverURL).origin === new URL(serverURL).origin)
+            await hostManager.start().catch(error => hostManager.publish(error.message))
+        }
+        await preferences.setStartupChoice(remember ? mode : 'ask')
+        syncModeMenu()
+        const status = Menu.getApplicationMenu()?.getMenuItemById('desktop-service-status')
+        if (status) status.label = mode === 'remote' ? `已登录远程 · ${new URL(serverURL).host}` : '后台服务运行中'
+        tray?.setToolTip(mode === 'remote' ? `夭夭 · 远程 ${new URL(serverURL).host}` : '夭夭 · 本机运行')
+      },
+      navigate: url => {
+        if (closing || quitting) throw new Error('App 正在退出')
+        return window.loadURL(url)
+      },
+    })
+    async function openOnboarding(mode, { autoPrepare = false, remember = preferences.value.startupChoice !== 'ask', forceLogin = false } = {}) {
+      if (closing || quitting) throw new Error('App 正在退出')
+      onboarding.open({ mode, serverURL: preferences.value.remoteServer, remember, forceLogin })
+      if (!window || window.isDestroyed()) createWindow()
+      else if (window.webContents.getURL() !== bootURL) await window.loadURL(bootURL)
+      window.show(); window.focus()
+      if (autoPrepare && (mode === 'local' || preferences.value.remoteServer))
+        await onboarding.prepare({ autoEnter: true })
+      return onboarding.snapshot()
+    }
+    function openRemoteLogin() {
+      return openOnboarding('remote', { forceLogin: true })
     }
+    ipcMain.handle('desktop:open-login', event => {
+      requireModePage(event)
+      // A session expiry returns to the same main-page flow for the active server.
+      return openOnboarding(remoteMode ? 'remote' : 'local', { autoPrepare: true })
+    })
     function syncModeMenu(){
       const menu=Menu.getApplicationMenu()
       const useRemote=menu?.getMenuItemById('desktop-host-use-remote'),useLocal=menu?.getMenuItemById('desktop-host-use-local'),ask=menu?.getMenuItemById('desktop-host-ask')
@@ -379,72 +437,16 @@ else {
       if(status)status.label=switchingMode?'正在切换运行模式…':remoteMode?'当前:客户端模式':serverModeActive?'当前:服务器模式':'请选择运行模式'
       if(ask)ask.checked=preferences.value.startupChoice==='ask'
     }
-    async function switchMode(mode,notify=true){
-      if(closing||quitting)return {ok:false,error:'App 正在退出'}
-      if(switchingMode)return {ok:false,error:'正在切换运行模式,请稍候'}
-      if(mode==='remote'&&remoteMode||mode==='local'&&serverModeActive)return {ok:true}
-      switchingMode=true;syncModeMenu()
-      try{
-        if(mode==='remote'){
-          if(await enterRemoteMode()){
-            if(window&&!window.isDestroyed())void window.loadURL(remoteURL)
-          }else return {ok:true,pendingLogin:true}
-        }else{
-          loginWindow?.close()
-          serverModeActive=true;remoteMode=false;remoteURL=''
-          await hostManager?.stop().catch(error=>log(error.message))
-          await preferences.setStartupChoice('local').catch(error=>log(error.message))
-          if(window&&!window.isDestroyed())void window.loadFile(join(desktopRoot,'boot.html'))
-          await manager?.start().catch(error=>log(error.message))
-          if(!closing)environmentHost?.start()
-        }
-        return {ok:true}
-      }catch(error){
-        if(notify)await appDialog({type:'error',message:'切换使用方式未完成',detail:error.message})
-        return {ok:false,error:error.message}
-      }finally{switchingMode=false;syncModeMenu()}
-    }
-    ipcMain.handle('desktop-remote-login:submit',async(_event,value)=>{
-      if(closing||quitting)return {ok:false,error:'App 正在退出'}
-      try{
-        const server=normalizeServerURL(value?.serverURL)
-        const auth=await remoteSession(server,{username:value?.username,password:value?.password})
-        let registered=false
-        if(auth.user?.role==='admin'){
-          await authorizeComputer(server,auth)
-          registered=true
-        }
-        try{
-          for(const [name,cookie] of auth.cookies)
-            await electronSession.defaultSession.cookies.set({url:server,name,value:cookie,httpOnly:true,secure:server.startsWith('https://')})
-        }catch(error){log(`远程会话 Cookie 写入失败:${error.message}`)}
-        await preferences.setRemoteServer(server)
-        await enterRemoteMode()
-        if(!window||window.isDestroyed())createWindow()
-        else void window.loadURL(server)
-        loginWindow?.close()
-        if(!registered)await appDialog({type:'info',message:'已登录远程服务器',detail:'当前账号不是管理员:窗口将使用远程夭夭。要把这台 Mac 注册为可远程控制的电脑,请使用管理员账号重新登录。'})
-        return {ok:true,hostRegistered:registered,user:{username:auth.user.username,role:auth.user.role}}
-      }catch(error){return {ok:false,error:error instanceof Error?error.message:'登录失败'}}
-    })
-    ipcMain.handle('desktop-remote-login:use-local',async()=>{
-      loginWindow?.close()
-      if(!serverModeActive){
-        if(!window||window.isDestroyed())createWindow()
-        await switchMode('local')
-      }
-      return {ok:true}
-    })
-    async function chooseStartupMode(){
-      if(fixtureHome)return 'local'
-      const stored=preferences.value.startupChoice
-      if(stored==='local'||stored==='remote')return stored
-      const choice=await dialog.showMessageBox({type:'question',title:'夭夭',message:'这台 Mac 如何使用夭夭?',
-        detail:'「登录远程」用夭夭服务器的地址和账号密码登录另一台电脑上的夭夭:本机不运行 Web 服务,窗口直接使用远程夭夭;管理员账号还会把这台 Mac 注册为可远程控制的电脑。只有「作为服务器运行」会启动这台 Mac 的服务,并使用服务器上的 Hermes。客户端不需要安装 Hermes。',
-        buttons:['连接服务器(客户端)','作为服务器运行'],defaultId:0,cancelId:0,checkboxLabel:'记住选择,之后启动不再询问',checkboxChecked:false,noLink:true})
-      const mode=choice.response===0?'remote':'local'
-      if(choice.checkboxChecked)await preferences.setStartupChoice(mode).catch(error=>log(error.message))
-      return mode
+    async function switchMode(mode) {
+      if (closing || quitting) return { ok: false, error: 'App 正在退出' }
+      if (switchingMode || onboarding.busy) return { ok: false, error: '当前步骤尚未完成,请稍候' }
+      switchingMode = true; syncModeMenu()
+      try {
+        const state = await openOnboarding(mode, { autoPrepare: true, remember: true })
+        return { ok: true, pendingLogin: state.active }
+      } catch (error) {
+        return { ok: false, error: error.message }
+      } finally { switchingMode = false; syncModeMenu() }
     }
     async function importRunner(){
       const selection=await dialog.showOpenDialog(window,{title:'导入执行节点配置',properties:['openFile'],filters:[{name:'Runner 配置',extensions:['json']}]})
@@ -480,11 +482,11 @@ else {
       {label:'运行模式',submenu:[{id:'desktop-mode-status',label:'请选择运行模式',enabled:false},{type:'separator'},
         {id:'desktop-host-use-remote',label:'客户端模式(连接服务器)',enabled:!remoteMode,click:()=>void switchMode('remote')},
         {id:'desktop-host-use-local',label:'服务器模式(本机运行)',enabled:!serverModeActive,click:()=>void switchMode('local')},
-        {label:'更换服务器…',click:openRemoteLogin},{type:'separator'},
+        {label:'更换服务器…',click:()=>{void openRemoteLogin().catch(error=>log(error.message))}},{type:'separator'},
         {id:'desktop-host-ask',label:'启动时询问运行模式',type:'checkbox',checked:preferences.value.startupChoice==='ask',
           click:item=>{void preferences.setStartupChoice(item.checked?'ask':remoteMode?'remote':'local').catch(async error=>{item.checked=preferences.value.startupChoice==='ask';await appDialog({type:'error',message:'无法保存启动偏好',detail:error.message})})}}]},
       {label:'电脑',submenu:[{id:'desktop-host-status',label:'未配置电脑',enabled:false},{type:'separator'},
-        {id:'desktop-host-authorize',label:'重新授权…',click:openRemoteLogin},
+        {id:'desktop-host-authorize',label:'重新授权…',click:()=>{void openRemoteLogin().catch(error=>log(error.message))}},
         {id:'desktop-host-import',label:'导入电脑配置…',click:importDesktopHost},
         {id:'desktop-host-reconnect',label:'重新连接',click:()=>hostAction(()=>hostManager.start())},
         {id:'desktop-host-forget',label:'断开并忘记配置',click:()=>hostAction(()=>hostManager.forget())}]},
@@ -496,22 +498,18 @@ else {
     tray.setToolTip('夭夭')
     tray.setContextMenu(Menu.buildFromTemplate([{ label: '打开夭夭', click: show }, { label: '查看日志', click: () => shell.showItemInFolder(logFile) }, { type: 'separator' }, { label: '退出夭夭(后台继续运行)', click: () => requestQuit() }, { label: '停止后台服务并退出', click: () => requestQuit(true) }]))
     tray.on('click', show)
-    let startupRemote=false,startupLogin=false
-    serverModeActive=(await chooseStartupMode())==='local'
-    if(!serverModeActive){
-      const enrolled=await hostManager.read().catch(()=>undefined)
-      if(preferences.value.remoteServer||enrolled)startupRemote=await enterRemoteMode()
-      startupLogin=!startupRemote
-    }
-    if(!startupLogin)createWindow()
-    powerMonitor.on('resume', () => { if(!closing&&!quitting&&serverModeActive)void manager.check() })
-    timer = setInterval(() => { if(serverModeActive)void manager.check() }, 10_000); timer.unref()
-    if(startupLogin)openRemoteLogin()
-    else if(serverModeActive){
-      syncModeMenu()
-      await manager.start().catch(error => log(error.message))
-      if(!closing)environmentHost.start()
-    }
-    if(!startupLogin&&!startupRemote&&manager.state.phase==='ready')await runnerManager.start().catch(error=>{runnerManager.publish(error.message)})
+    // First launch stays in the main window. Remembered choices prepare in place
+    // and enter immediately only when that server already has a valid session.
+    const startupMode = preferences.value.startupChoice === 'ask'
+      ? (fixtureHome && process.env.HERMES_YAOYAO_DESKTOP_TEST_MODE !== 'ask' ? 'local' : null)
+      : preferences.value.startupChoice
+    onboarding.open({ mode: startupMode, serverURL: preferences.value.remoteServer,
+      remember: preferences.value.startupChoice !== 'ask' })
+    createWindow()
+    powerMonitor.on('resume', () => { if (!closing && !quitting && serverModeActive) void manager.check() })
+    timer = setInterval(() => { if (serverModeActive) void manager.check() }, 10_000); timer.unref()
+    syncModeMenu()
+    if (startupMode && (startupMode === 'local' || preferences.value.remoteServer))
+      await onboarding.prepare({ autoEnter: true })
   }).catch(error => { log(error.stack || error.message); quitting = true; app.quit() })
 }

+ 16 - 12
desktop/mode-switch.test.mjs

@@ -15,9 +15,10 @@ test('signed-in desktop pages can switch both ways; other windows cannot control
   const identityReady=new Promise(resolve=>{releaseIdentity=resolve})
   const local=createServer(async(req,res)=>{
     if(req.url==='/desktop/service'){await identityReady;res.setHeader('Content-Type','application/json');res.end(JSON.stringify(identity))}
+    else if(req.url==='/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>')}
   })
-  const remote=createServer((_req,res)=>{res.setHeader('Content-Type','text/html; charset=utf-8');res.end('<title>已登录的服务器页面</title>')})
+  const remote=createServer((_req,res)=>{if(_req.url==='/api/app/bootstrap'){res.setHeader('Content-Type','application/json');res.end(JSON.stringify({authenticated:true,csrfToken:'fixture'}));return}res.setHeader('Content-Type','text/html; charset=utf-8');res.end('<title>已登录的服务器页面</title>')})
   await new Promise(done=>local.listen(0,'127.0.0.1',done))
   await new Promise(done=>remote.listen(0,'127.0.0.1',done))
   const origin=`http://127.0.0.1:${local.address().port}`,remoteURL=`http://127.0.0.1:${remote.address().port}`
@@ -53,19 +54,22 @@ test('signed-in desktop pages can switch both ways; other windows cannot control
     assert.equal(JSON.parse(await readFile(join(home,'desktop-preferences.json'),'utf8')).startupChoice,'local')
     assert.equal(local.listening,true,'switching must leave an independent service running')
     assert.equal(remote.listening,true)
-    // The server picker remains available after either login/mode selection.
-    const opened=app.waitForEvent('window');await page.evaluate(()=>window.yaoyaoDesktop.openRemoteLogin())
-    const login=await opened;await login.waitForLoadState()
-    assert.equal(app.windows().length,2)
-    await page.evaluate(()=>window.yaoyaoDesktop.openRemoteLogin())
-    assert.equal(app.windows().length,2)
-    await login.evaluate(()=>{void window.yaoyaoRemoteLogin.useLocal()})
-    await expect.poll(()=>app.windows().length).toBe(1)
+    // Changing servers reuses the main page, including when opened repeatedly.
+    await page.evaluate(()=>{void window.yaoyaoDesktop.openRemoteLogin()})
+    await page.waitForURL('**/boot.html')
+    assert.equal(app.windows().length,1)
+    await page.getByRole('radio',{name:/本机运行/}).check()
+    await page.locator('#prepare-local').click()
+    await page.locator('#submit').click()
+    await page.waitForURL(origin+'/**')
     await page.evaluate(()=>{void window.yaoyaoDesktop.switchMode('client')})
     await page.waitForURL(remoteURL+'/**')
-    const pickerOpened=app.waitForEvent('window');await page.evaluate(()=>window.yaoyaoDesktop.openRemoteLogin())
-    const picker=await pickerOpened;await picker.waitForLoadState()
-    await picker.evaluate(()=>{void window.yaoyaoRemoteLogin.useLocal()})
+    await page.evaluate(()=>{void window.yaoyaoDesktop.openRemoteLogin()})
+    await page.waitForURL('**/boot.html')
+    assert.equal(app.windows().length,1)
+    await page.getByRole('radio',{name:/本机运行/}).check()
+    await page.locator('#prepare-local').click()
+    await page.locator('#submit').click()
     await page.waitForURL(origin+'/**')
     await expect.poll(()=>page.evaluate(()=>window.yaoyaoDesktop.modeState())).toEqual({mode:'server',serverURL:origin,switching:false})
   }finally{

+ 107 - 0
desktop/onboarding-flow.test.mjs

@@ -0,0 +1,107 @@
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+import { createServer } from 'node:http'
+import { mkdtemp, realpath, rm, readFile, mkdir } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join, resolve } from 'node:path'
+import { _electron as electron, expect } from '@playwright/test'
+
+const root = resolve(import.meta.dirname, '..')
+test('first launch, failed connection and remote login all stay in the only main window', { timeout: 60000 }, async () => {
+  const home = await realpath(await mkdtemp(join(tmpdir(), 'yaoyao-onboarding-')))
+  let loginCount = 0
+  const server = createServer((req, res) => {
+    const json = (value, status = 200) => { res.statusCode = status; res.setHeader('content-type', 'application/json'); res.end(JSON.stringify(value)) }
+    if (req.url === '/api/app/bootstrap') {
+      res.setHeader('set-cookie', 'csrf=fixture; Path=/; HttpOnly')
+      return json({ authenticated: String(req.headers.cookie).includes('session=fixture'), setupRequired: false, csrfToken: 'fixture', serverKind: 'yaoyao-web' })
+    }
+    if (req.url === '/api/app/login') {
+      let raw = ''; req.on('data', chunk => { raw += chunk }); req.on('end', () => {
+        loginCount++
+        const body = JSON.parse(raw)
+        if (body.username !== 'user' || body.password !== 'correct-password') return json({ error: 'wrong' }, 401)
+        if (req.headers['x-csrf-token'] !== 'fixture' || !String(req.headers.cookie).includes('csrf=fixture')) return json({ error: 'csrf' }, 403)
+        res.setHeader('set-cookie', 'session=fixture; Path=/; HttpOnly; Max-Age=3600; SameSite=Strict')
+        json({ user: { id: 'fixture-user', username: 'user', role: 'user' }, csrfToken: 'fixture' })
+      }); return
+    }
+    res.setHeader('content-type', 'text/html; charset=utf-8'); res.end('<title>已登录远程夭夭</title><h1>远程工作区</h1>')
+  })
+  await new Promise(done => server.listen(0, '127.0.0.1', done))
+  const url = `http://127.0.0.1:${server.address().port}`
+  let app
+  try {
+    app = await electron.launch({ args: [root], cwd: root, env: { ...process.env,
+      HERMES_YAOYAO_DESKTOP_TEST_HOME: home, HERMES_YAOYAO_DESKTOP_TEST_MODE: 'ask', HERMES_YAOYAO_DESKTOP_TEST_SYNC: '0' } })
+    const page = await app.firstWindow()
+    const errors = []; page.on('pageerror', error => errors.push(error.message))
+    await expect(page.getByRole('heading', { name: '开始使用夭夭' })).toBeVisible()
+    assert.equal(app.windows().length, 1)
+    assert.equal((await page.evaluate(() => window.yaoyaoDesktop.status())).mode, null)
+    await page.getByRole('radio', { name: /连接远程服务器/ }).check()
+    await page.locator('#server').fill('http://127.0.0.1:1')
+    await page.locator('#detect').click()
+    await expect(page.locator('#status[role=alert]')).toBeVisible()
+    await expect(page.locator('#login-form')).toBeHidden()
+    await page.locator('#server').fill(url)
+    await page.locator('#detect').click()
+    await expect(page.locator('#status')).toHaveText('连接成功,服务器已就绪')
+    await expect(page.locator('#login-form')).toBeVisible()
+    assert.match(page.url(), /boot.html$/)
+    await page.locator('#username').fill('user')
+    await page.locator('#password').fill('wrong-password')
+    await page.locator('#submit').click()
+    await expect(page.locator('#login-error')).toContainText('用户名或密码不正确')
+    assert.equal(await page.locator('#password').inputValue(), '')
+    // Editing the address invalidates the checked target and hides credentials.
+    await page.locator('#server').fill('http://another-server.test')
+    await expect(page.locator('#login-form')).toBeHidden()
+    await page.locator('#server').fill(url); await page.locator('#detect').click()
+    assert.equal(await page.locator('#username').inputValue(), '')
+    await page.locator('#username').fill('user')
+    await page.locator('#password').fill('correct-password')
+    await page.locator('#toggle-password').click()
+    assert.equal(await page.locator('#password').getAttribute('type'), 'text')
+    await page.locator('#toggle-password').click()
+    await page.locator('#remember').check()
+    await mkdir(join(root, 'docs/design/desktop-onboarding'), { recursive: true })
+    await page.emulateMedia({ colorScheme: 'light' })
+    assert.equal(await page.evaluate(() => document.querySelector('footer').getBoundingClientRect().bottom <= innerHeight), true, 'default window shows its footer without scrolling')
+    await page.screenshot({ path: join(root, 'docs/design/desktop-onboarding/remote-login.png') })
+    await page.emulateMedia({ colorScheme: 'dark' })
+    await page.screenshot({ path: join(root, 'docs/design/desktop-onboarding/remote-dark.png') })
+    await page.setViewportSize({ width: 720, height: 560 })
+    assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true)
+    await page.screenshot({ path: join(root, 'docs/design/desktop-onboarding/remote-narrow.png'), fullPage: true })
+    await page.setViewportSize({ width: 1280, height: 832 })
+    await page.emulateMedia({ colorScheme: 'light' })
+    await page.locator('#submit').click()
+    await page.waitForURL(url + '/**')
+    await expect(page.getByRole('heading', { name: '远程工作区' })).toBeVisible()
+    assert.equal(app.windows().length, 1); assert.equal(loginCount, 2)
+    const preferences = JSON.parse(await readFile(join(home, 'desktop-preferences.json'), 'utf8'))
+    assert.equal(preferences.startupChoice, 'remote'); assert.equal(preferences.remoteServer, url)
+    assert.deepEqual(errors, [])
+    // The HTTP renderer cannot invoke privileged native setup/authentication.
+    assert.deepEqual(await page.evaluate(() => Promise.all([
+      window.yaoyaoDesktop.status(), window.yaoyaoDesktop.selectServer('local'),
+      window.yaoyaoDesktop.prepareServer({}), window.yaoyaoDesktop.login({ username: 'x', password: 'y' }),
+    ].map(promise => promise.then(() => false, () => true)))), [true, true, true, true])
+    await app.close(); app = undefined
+    app = await electron.launch({ args: [root], cwd: root, env: { ...process.env,
+      HERMES_YAOYAO_DESKTOP_TEST_HOME: home, HERMES_YAOYAO_DESKTOP_TEST_MODE: 'ask', HERMES_YAOYAO_DESKTOP_TEST_SYNC: '0' } })
+    const reopened = await app.firstWindow()
+    try { await reopened.waitForURL(url + '/**', { timeout: 8000 }) }
+    catch (error) {
+      console.log('Reopened fixture state:', await reopened.evaluate(() => window.yaoyaoDesktop.status()))
+      console.log('Fixture cookie metadata:', await app.evaluate(async ({ session }, url) => (await session.defaultSession.cookies.get({ url })).map(({ name, expirationDate, sameSite, session }) => ({ name, expirationDate, sameSite, session })), url))
+      throw error
+    }
+    assert.equal(loginCount, 2, 'a remembered session keeps the server-provided cookie expiry and is reused')
+  } finally {
+    await app?.close().catch(() => {})
+    await new Promise(done => { server.close(done); server.closeAllConnections() })
+    await rm(home, { recursive: true, force: true })
+  }
+})

+ 103 - 0
desktop/onboarding.mjs

@@ -0,0 +1,103 @@
+import { normalizeServerURL } from './remote-login.mjs'
+
+/** The native onboarding page owns one selected server and one in-flight action.
+ * Credentials and CSRF/session tokens never appear in renderer snapshots. */
+export class DesktopOnboarding {
+  constructor(options) {
+    this.options = options
+    this.open()
+  }
+  get busy() { return ['preparing', 'authenticating', 'entering'].includes(this.state.phase) }
+  snapshot() { return { ...this.state, history: [...this.state.history], busy: this.busy } }
+  open({ mode = null, serverURL = '', remember = false, forceLogin = false } = {}) {
+    if (this.state && this.busy) throw new Error('当前步骤尚未完成,请稍候')
+    this.state = { active: true, mode, serverURL, remember, forceLogin, phase: 'idle', stage: 'checking',
+      setupRequired: false, authenticated: false, username: '', message: '', error: '',
+      canForceSync: false, history: [] }
+    return this.snapshot()
+  }
+  select(mode) {
+    if (!['local', 'remote'].includes(mode)) throw new Error('请选择运行方式')
+    return this.open({ mode, serverURL: this.options.remoteServer(), remember: this.state.remember })
+  }
+  serviceChanged(service) {
+    if (!this.state.active || this.state.mode !== 'local') return
+    this.state.stage = service.stage || this.state.stage
+    this.state.message = service.message
+    this.state.canForceSync = service.canForceSync === true
+    if (this.state.history.at(-1) !== service.message) {
+      this.state.history.push(service.message)
+      this.state.history = this.state.history.slice(-30)
+    }
+    if (service.phase === 'error') {
+      this.state.error = service.message
+      // Keep the action locked until the outstanding prepare operation settles.
+      if (!this.busy) this.state.phase = 'error'
+    }
+    if (['error', 'restarting', 'stopped', 'disconnected'].includes(service.phase)) {
+      this.state.authenticated = false
+      if (this.state.phase === 'ready') this.state.phase = 'error'
+    }
+  }
+  async prepare({ serverURL = this.state.serverURL, remember = this.state.remember, force = false, autoEnter = false } = {}) {
+    if (this.busy) throw new Error('当前步骤尚未完成,请稍候')
+    const mode = this.state.mode
+    if (!mode) throw new Error('请先选择服务器')
+    Object.assign(this.state, { phase: 'preparing', error: '', authenticated: false,
+      setupRequired: false, remember: remember === true, stage: 'checking', canForceSync: false,
+      message: mode === 'local' ? '正在检查本机环境…' : '正在检测服务器连接…' })
+    try {
+      const target = mode === 'local' ? await this.options.prepareLocal({ force }) : normalizeServerURL(serverURL)
+      this.state.serverURL = target
+      const info = await this.options.inspect(target)
+      if (mode === 'remote' && info.setupRequired) throw new Error('远程服务器尚未初始化,请先在服务器本机创建管理员,再重新检测。')
+      Object.assign(this.state, { phase: 'ready', stage: 'ready', setupRequired: info.setupRequired === true,
+        authenticated: info.authenticated === true && !this.state.forceLogin, username: info.user?.username || '',
+        message: mode === 'local' ? '本机服务已就绪' : '连接成功,服务器已就绪' })
+      if (autoEnter && this.state.authenticated) await this.enter()
+    } catch (error) {
+      this.state.phase = 'error'
+      this.state.error = error.message || '准备未完成,请重试'
+    }
+    return this.snapshot()
+  }
+  async submit({ username, password, confirmation, remember = this.state.remember } = {}) {
+    if (this.state.phase !== 'ready') throw new Error('请先完成服务器检测')
+    this.state.remember = remember === true
+    this.state.error = ''
+    if (this.state.authenticated) return this.enter()
+    if (!String(username || '').trim() || !password) throw new Error('请填写用户名和密码')
+    if (this.state.setupRequired && (password.length < 8 || password !== confirmation))
+      throw new Error(password.length < 8 ? '密码至少需要 8 个字符' : '两次输入的密码不一致')
+    this.state.phase = 'authenticating'
+    try {
+      const result = await this.options.authenticate({ mode: this.state.mode, serverURL: this.state.serverURL,
+        setup: this.state.mode === 'local' && this.state.setupRequired, username, password })
+      this.state.authenticated = true
+      this.state.username = result.username
+      this.state.phase = 'ready'
+      if (result.warning) {
+        this.state.error = result.warning
+        this.state.message = '账号已登录,可以继续进入夭夭'
+      } else await this.enter()
+    } catch (error) {
+      this.state.phase = 'ready'
+      this.state.error = error.message || '登录未完成,请重试'
+    }
+    return this.snapshot()
+  }
+  async enter() {
+    this.state.phase = 'entering'
+    try {
+      await this.options.activate(this.snapshot())
+      this.state.active = false
+      this.state.phase = 'complete'
+      await this.options.navigate(this.state.serverURL)
+    } catch (error) {
+      this.state.active = true
+      this.state.phase = 'ready'
+      this.state.error = error.message || '打开服务未完成,请重试'
+    }
+    return this.snapshot()
+  }
+}

+ 72 - 0
desktop/onboarding.test.mjs

@@ -0,0 +1,72 @@
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+import { DesktopOnboarding } from './onboarding.mjs'
+
+function fixture(overrides = {}) {
+  const calls = []
+  const flow = new DesktopOnboarding({
+    remoteServer: () => 'http://remote.test:15300',
+    prepareLocal: async () => { calls.push('prepare'); return 'http://127.0.0.1:15300' },
+    inspect: async () => ({ authenticated: false, setupRequired: true }),
+    authenticate: async value => { calls.push(value); return { username: value.username } },
+    activate: async value => { calls.push({ mode: value.mode, remember: value.remember }) },
+    navigate: async url => { calls.push(url) }, ...overrides,
+  })
+  return { flow, calls }
+}
+test('selection, local preparation and account creation run in order; snapshots contain no credentials', async () => {
+  const { flow, calls } = fixture()
+  await assert.rejects(() => flow.submit({ username: 'admin', password: 'password' }), /检测/)
+  flow.select('local')
+  await flow.prepare()
+  assert.equal(flow.snapshot().setupRequired, true)
+  await assert.rejects(() => flow.submit({ username: 'admin', password: 'password', confirmation: 'different' }), /不一致/)
+  await flow.submit({ username: 'admin', password: 'password', confirmation: 'password', remember: true })
+  assert.deepEqual(calls, ['prepare', { mode: 'local', serverURL: 'http://127.0.0.1:15300', setup: true, username: 'admin', password: 'password' }, { mode: 'local', remember: true }, 'http://127.0.0.1:15300'])
+  assert.equal(flow.snapshot().active, false)
+  assert.equal(JSON.stringify(flow.snapshot()).includes('password'), false)
+  assert.doesNotThrow(() => flow.open({ mode: 'remote' }), 'completed flows can reopen in the same window')
+})
+test('an in-flight server check cannot be switched underneath credentials', async () => {
+  let release
+  const { flow, calls } = fixture({ inspect: () => new Promise(resolve => { release = resolve }) })
+  flow.select('remote')
+  const checking = flow.prepare()
+  assert.throws(() => flow.select('local'), /尚未完成/)
+  await assert.rejects(() => flow.submit({ username: 'admin', password: 'password' }), /检测/)
+  release({ authenticated: false }); await checking
+  assert.equal(flow.snapshot().serverURL, 'http://remote.test:15300')
+  assert.deepEqual(calls, [])
+})
+test('remote setup is blocked; failed checks and wrong passwords stay inline and can be retried', async () => {
+  let checks = 0, logins = 0
+  const { flow } = fixture({
+    inspect: async () => ++checks === 1 ? { setupRequired: true, authenticated: false } : { authenticated: false },
+    authenticate: async () => { if (++logins === 1) throw new Error('密码错误'); return { username: 'user' } },
+  })
+  flow.select('remote'); await flow.prepare()
+  assert.match(flow.snapshot().error, /本机创建管理员/)
+  assert.equal(flow.snapshot().phase, 'error')
+  await flow.prepare()
+  await flow.submit({ username: 'user', password: 'bad' })
+  assert.equal(flow.snapshot().phase, 'ready'); assert.equal(flow.snapshot().error, '密码错误')
+  await flow.submit({ username: 'user', password: 'good' })
+  assert.equal(flow.snapshot().active, false)
+})
+test('valid remembered sessions skip credentials, but reauthorization requests a fresh login', async () => {
+  const { flow, calls } = fixture({ inspect: async () => ({ authenticated: true, user: { username: 'user' } }) })
+  flow.open({ mode: 'remote', serverURL: 'http://remote.test', remember: true })
+  await flow.prepare({ autoEnter: true })
+  assert.equal(flow.snapshot().active, false)
+  flow.open({ mode: 'remote', serverURL: 'http://remote.test', forceLogin: true })
+  await flow.prepare({ autoEnter: true })
+  assert.equal(flow.snapshot().active, true); assert.equal(flow.snapshot().authenticated, false)
+  assert.equal(calls.length, 2)
+})
+test('failed computer enrollment preserves login and offers inline continuation', async () => {
+  const { flow, calls } = fixture({ inspect: async () => ({ authenticated: false }), authenticate: async () => ({ username: 'admin', warning: '电脑授权未恢复' }) })
+  flow.select('remote'); await flow.prepare(); await flow.submit({ username: 'admin', password: 'password' })
+  assert.equal(flow.snapshot().phase, 'ready'); assert.equal(flow.snapshot().authenticated, true)
+  assert.match(flow.snapshot().error, /授权未恢复/); assert.deepEqual(calls, [])
+  await flow.submit(); assert.equal(flow.snapshot().active, false)
+})

+ 4 - 0
desktop/preload.cjs

@@ -1,6 +1,10 @@
 const { contextBridge, ipcRenderer } = require('electron')
 contextBridge.exposeInMainWorld('yaoyaoDesktop', Object.freeze({
   status: () => ipcRenderer.invoke('desktop:status'),
+  selectServer: mode => ipcRenderer.invoke('desktop:onboarding-select', mode),
+  prepareServer: input => ipcRenderer.invoke('desktop:onboarding-prepare', input),
+  login: input => ipcRenderer.invoke('desktop:onboarding-submit', input),
+  openLogin: () => ipcRenderer.invoke('desktop:open-login'),
   retry: () => ipcRenderer.invoke('desktop:retry'),
   forceSync: () => ipcRenderer.invoke('desktop:force-sync'),
   logs: () => ipcRenderer.invoke('desktop:logs'),

+ 0 - 5
desktop/remote-login-preload.cjs

@@ -1,5 +0,0 @@
-const { contextBridge, ipcRenderer } = require('electron')
-contextBridge.exposeInMainWorld('yaoyaoRemoteLogin', Object.freeze({
-  submit: value => ipcRenderer.invoke('desktop-remote-login:submit', value),
-  useLocal: () => ipcRenderer.invoke('desktop-remote-login:use-local'),
-}))

+ 0 - 21
desktop/remote-login.css

@@ -1,21 +0,0 @@
-:root { color-scheme: light dark; }
-* { box-sizing: border-box; }
-body { margin: 0; min-height: 100vh; display: grid; place-items: center; padding: 28px;
-  font: 14px/1.6 -apple-system, "PingFang SC", "Helvetica Neue", sans-serif;
-  background: #f7f7f7; color: #1d2129; }
-@media (prefers-color-scheme: dark) { body { background: #14181e; color: #dce1e7; } }
-main { width: min(360px, 100%); display: grid; gap: 22px; }
-header { display: grid; justify-items: center; text-align: center; gap: 10px; }
-h1 { margin: 0; font-size: 19px; }
-header p { margin: 0; font-size: 12.5px; opacity: .75; }
-form { display: grid; gap: 14px; padding: 20px; border: 1px solid rgba(128,128,138,.35); border-radius: 14px; background: rgba(127,127,127,.06); }
-label { display: grid; gap: 6px; font-size: 13px; }
-input { font: inherit; padding: 9px 11px; border: 1px solid rgba(128,128,138,.5); border-radius: 8px; background: transparent; color: inherit; min-width: 0; }
-input:focus-visible, button:focus-visible { outline: 2px solid #3b82f6; outline-offset: 1px; }
-button { font: inherit; min-height: 38px; padding: 8px 14px; border: 1px solid rgba(128,128,138,.5); border-radius: 8px; background: transparent; color: inherit; cursor: pointer; }
-button.primary { background: #2f6fed; border-color: #2f6fed; color: #fff; }
-button.primary:disabled { opacity: .55; cursor: default; }
-footer { display: grid; justify-items: center; }
-footer button { border: 0; font-size: 12.5px; opacity: .8; }
-#error { margin: 0; font-size: 12.5px; color: #d5443c; }
-#status { margin: 0; font-size: 12.5px; opacity: .75; }

+ 0 - 17
desktop/remote-login.html

@@ -1,17 +0,0 @@
-<!doctype html>
-<html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
-<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self'">
-<title>登录远程 · 夭夭</title><link rel="stylesheet" href="remote-login.css"></head>
-<body><main>
-  <header><img src="icon.png" alt="" width="56" height="56"><h1>登录远程夭夭</h1>
-    <p>连接另一台电脑上的夭夭服务器。登录后本机无需 Hermes,也不运行 Web 服务,窗口直接使用远程夭夭;管理员账号同时会把这台 Mac 注册为可远程控制的电脑。</p></header>
-  <form id="login-form" aria-label="远程登录">
-    <label>服务器地址<input id="server" name="server" required autocomplete="off" spellcheck="false" placeholder="192.168.1.10:15300" inputmode="url"></label>
-    <label>用户名<input id="username" name="username" required autocomplete="username" autocapitalize="none" spellcheck="false"></label>
-    <label>密码<input id="password" name="password" type="password" required autocomplete="current-password"></label>
-    <button type="submit" id="submit" class="primary">登录</button>
-    <p id="error" role="alert" hidden></p>
-    <p id="status" role="status" aria-live="polite" hidden></p>
-  </form>
-  <footer><button type="button" id="use-local">改为作为服务器运行</button></footer>
-</main><script src="remote-login.js"></script></body></html>

+ 0 - 23
desktop/remote-login.js

@@ -1,23 +0,0 @@
-const byId = id => document.getElementById(id)
-const form = byId('login-form')
-const preset = new URLSearchParams(location.search).get('server')
-if (preset) byId('server').value = preset
-form.addEventListener('submit', async event => {
-  event.preventDefault()
-  const error = byId('error'), status = byId('status'), submit = byId('submit')
-  error.hidden = true; status.hidden = false; status.textContent = '正在登录…'; submit.disabled = true
-  try {
-    const result = await window.yaoyaoRemoteLogin.submit({
-      serverURL: byId('server').value,
-      username: byId('username').value,
-      password: byId('password').value,
-    })
-    if (!result.ok) throw new Error(result.error || '登录失败')
-    status.textContent = result.hostRegistered ? '登录成功,已注册本机为电脑…' : '登录成功…'
-  } catch (cause) {
-    status.hidden = true; error.hidden = false
-    error.textContent = cause instanceof Error ? cause.message : '登录失败'
-    submit.disabled = false
-  }
-})
-byId('use-local').addEventListener('click', () => { void window.yaoyaoRemoteLogin.useLocal() })

+ 44 - 10
desktop/remote-login.mjs

@@ -12,45 +12,79 @@ export function normalizeServerURL(input){
   return url.origin
 }
 
+async function requestServer(fetchImpl,url,options){
+  try{return await fetchImpl(url,options)}
+  catch(error){
+    if(['TimeoutError','AbortError'].includes(error.name))throw new Error('连接超时,请检查服务器地址和网络后重试。')
+    throw new Error('无法连接服务器,请检查服务器地址和网络后重试。')
+  }
+}
+
 /** Minimal cookie jar across the bootstrap → login dance. */
 class CookieJar{
-  constructor(){this.cookies=new Map()}
+  constructor(){this.cookies=new Map();this.details=new Map()}
   absorb(response){
     for(const line of response.headers.getSetCookie?.()??[]){
       const pair=line.split(';')[0],eq=pair.indexOf('=')
-      if(eq>0)this.cookies.set(pair.slice(0,eq).trim(),pair.slice(eq+1).trim())
+      if(eq<=0)continue
+      const name=pair.slice(0,eq).trim(),value=pair.slice(eq+1).trim()
+      this.cookies.set(name,value)
+      const attributes=new Map(line.split(';').slice(1).map(part=>{
+        const index=part.indexOf('=')
+        return index<0?[part.trim().toLowerCase(),'']:[part.slice(0,index).trim().toLowerCase(),part.slice(index+1).trim()]
+      }))
+      const detail={name,value,path:attributes.get('path')||'/',httpOnly:attributes.has('httponly'),secure:attributes.has('secure')}
+      const sameSite={strict:'strict',lax:'lax',none:'no_restriction'}[attributes.get('samesite')?.toLowerCase()]
+      if(sameSite)detail.sameSite=sameSite
+      const maxAge=attributes.get('max-age'),expires=Date.parse(attributes.get('expires'))
+      if(maxAge!==undefined&&/^-?\d+$/.test(maxAge))detail.expirationDate=Date.now()/1000+Number(maxAge)
+      else if(Number.isFinite(expires))detail.expirationDate=expires/1000
+      this.details.set(name,detail)
     }
   }
   header(){return [...this.cookies.entries()].map(([name,value])=>`${name}=${value}`).join('; ')}
 }
 
-export async function remoteSession(serverURL,{username,password},fetchImpl=globalThis.fetch){
+export async function inspectServer(serverURL,fetchImpl=globalThis.fetch){
+  const base=normalizeServerURL(serverURL)
+  const response=await requestServer(fetchImpl,new URL('/api/app/bootstrap',base),{
+    headers:{accept:'application/json'},redirect:'error',signal:AbortSignal.timeout(10000),
+  })
+  if(!response.ok)throw new Error(`无法连接服务器(HTTP ${response.status})`)
+  const info=await response.json().catch(()=>{throw new Error('服务器响应无效,请确认这是夭夭服务器地址')})
+  if(typeof info?.csrfToken!=='string'||typeof info?.authenticated!=='boolean'||(info.serverKind&&info.serverKind!=='yaoyao-web'))
+    throw new Error('服务器响应无效,请确认这是夭夭服务器地址')
+  return info
+}
+
+export async function remoteSession(serverURL,{username,password,setup=false},fetchImpl=globalThis.fetch){
   const base=normalizeServerURL(serverURL)
   const user=String(username??'').trim(),pass=String(password??'')
   if(!user||!pass)throw new Error('请填写用户名和密码')
   const jar=new CookieJar()
-  const bootstrap=await fetchImpl(new URL('/api/app/bootstrap',base),{headers:{accept:'application/json'},redirect:'error'})
+  const bootstrap=await requestServer(fetchImpl,new URL('/api/app/bootstrap',base),{headers:{accept:'application/json'},redirect:'error',signal:AbortSignal.timeout(10000)})
   jar.absorb(bootstrap)
   if(!bootstrap.ok)throw new Error(`无法连接服务器(HTTP ${bootstrap.status})`)
   const info=await bootstrap.json().catch(()=>{throw new Error('服务器响应无效,请确认这是夭夭 Web 地址')})
   if(typeof info?.csrfToken!=='string')throw new Error('服务器响应无效,请确认这是夭夭 Web 地址')
-  const response=await fetchImpl(new URL('/api/app/login',base),{
-    method:'POST',redirect:'error',
+  const response=await requestServer(fetchImpl,new URL(setup?'/api/app/setup':'/api/app/login',base),{
+    method:'POST',redirect:'error',signal:AbortSignal.timeout(15000),
     headers:{accept:'application/json','content-type':'application/json',cookie:jar.header(),'x-csrf-token':info.csrfToken,origin:base},
     body:JSON.stringify({username:user,password:pass}),
   })
   jar.absorb(response)
-  if(response.status===401||response.status===403)throw new Error('用户名或密码不正确')
+  if(!setup&&(response.status===401||response.status===403))throw new Error('用户名或密码不正确')
+  if(setup&&!response.ok){const body=await response.json().catch(()=>({}));throw new Error(body.error?.message||body.error||`创建管理员失败(HTTP ${response.status})`)}
   if(!response.ok)throw new Error(`登录失败(HTTP ${response.status})`)
   const result=await response.json().catch(()=>{throw new Error('登录响应无效')})
   if(!result?.user?.id)throw new Error('登录响应无效')
-  return {origin:base,user:result.user,csrfToken:String(result.csrfToken??info.csrfToken),cookies:[...jar.cookies.entries()]}
+  return {origin:base,user:result.user,csrfToken:String(result.csrfToken??info.csrfToken),cookies:[...jar.cookies.entries()],cookieDetails:[...jar.details.values()]}
 }
 
 export async function enrollDesktopHost(session,{name,installId,previousHostId},fetchImpl=globalThis.fetch){
   const cookie=session.cookies.map(([key,value])=>`${key}=${value}`).join('; ')
-  const response=await fetchImpl(new URL('/api/app/admin/desktop-hosts',session.origin),{
-    method:'POST',redirect:'error',
+  const response=await requestServer(fetchImpl,new URL('/api/app/admin/desktop-hosts',session.origin),{
+    method:'POST',redirect:'error',signal:AbortSignal.timeout(15000),
     headers:{accept:'application/json','content-type':'application/json',cookie,'x-csrf-token':session.csrfToken,origin:session.origin},
     body:JSON.stringify({name:String(name).slice(0,100),...(installId?{installId}:{}),...(previousHostId?{previousHostId}:{})}),
   })

+ 14 - 0
desktop/remote-login.test.mjs

@@ -75,3 +75,17 @@ test('wrong credentials and non-admin accounts surface clear errors',async()=>{
     fake.server.close();fake.server.closeAllConnections?.()
   }
 })
+
+test('native login preserves cookie expiry, scope and same-site policy', async () => {
+  let request = 0
+  const before = Date.now() / 1000
+  const auth = await remoteSession('https://server.test', { username: 'admin', password: 'password' }, async () => {
+    if (++request === 1) return new Response(JSON.stringify({ csrfToken: 'fixture' }), { headers: { 'set-cookie': 'csrf=one; Path=/; HttpOnly; Secure; SameSite=Strict' } })
+    return new Response(JSON.stringify({ user: { id: 'admin', role: 'admin' } }), { headers: { 'set-cookie': 'session=two; Path=/; Max-Age=3600; HttpOnly; Secure; SameSite=Strict' } })
+  })
+  const session = auth.cookieDetails.find(cookie => cookie.name === 'session')
+  assert.equal(session.secure, true); assert.equal(session.httpOnly, true); assert.equal(session.sameSite, 'strict')
+  assert.equal(session.path, '/')
+  assert.ok(session.expirationDate >= before + 3600 && session.expirationDate <= Date.now() / 1000 + 3600)
+  assert.equal(auth.cookieDetails.find(cookie => cookie.name === 'csrf').expirationDate, undefined)
+})

+ 4 - 3
desktop/service-manager.mjs

@@ -115,14 +115,14 @@ export class DesktopServiceManager {
   }
   async boot({ force = false } = {}) {
     const generation = ++this.generation
-    this.publish({ phase: 'starting', message: '正在启动本地服务…' })
+    this.publish({ phase: 'starting', stage: 'checking', message: '正在检查本地服务…' })
     try {
-      await this.options.prepareHome?.(message => this.publish({ phase: 'starting', message }))
+      await this.options.prepareHome?.(message => this.publish({ phase: 'starting', stage: 'checking', message }))
       await mkdir(this.options.home, { recursive: true, mode: 0o700 })
       this.root = await realpath(this.options.home)
       this.updateNotice = undefined
       if (this.options.synchronize) {
-        try { await this.options.synchronize(message => this.publish({ phase: 'starting', message }), { force }) }
+        try { await this.options.synchronize(message => this.publish({ phase: 'starting', stage: 'installing', message }), { force }) }
         catch (error) {
           // Busy is reported before stopping or switching. Other failures still
           // fail closed; the existing service must pass the usual identity checks.
@@ -131,6 +131,7 @@ export class DesktopServiceManager {
         }
         if (generation !== this.generation || this.stopping) return
       }
+      this.publish({ phase: 'starting', stage: 'starting', message: '正在启动并检查服务…' })
       let record = await this.readRecord()
       if (generation !== this.generation || this.stopping) return
       if (record?.url) {

+ 3 - 1
desktop/service-sync.test.mjs

@@ -1,3 +1,4 @@
+import { enterLocal } from './test-support/onboarding.mjs'
 import { test } from 'node:test'
 import assert from 'node:assert/strict'
 import { mkdtemp, readFile, writeFile, rm, cp, realpath } from 'node:fs/promises'
@@ -75,6 +76,7 @@ test('App first launch installs an independent Web; Web remains up after quit an
     }
     app = await launch()
     const page = await app.firstWindow()
+    await enterLocal(page, { username: 'sync-fixture', password: 'sync-fixture-password' })
     await page.waitForURL(`http://127.0.0.1:${port}/**`, { timeout: 90000 })
     const record = JSON.parse(await readFile(join(home, 'service-instance.json'), 'utf8'))
     assert.equal(record.desktopOwned, false)
@@ -88,7 +90,7 @@ test('App first launch installs an independent Web; Web remains up after quit an
     }
     const support = await page.evaluate(async () => {
       const bootstrap = await (await fetch('/api/app/bootstrap')).json()
-      const setup = await fetch('/api/app/setup', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': bootstrap.csrfToken },
+      const setup = await fetch('/api/app/login', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': bootstrap.csrfToken },
         body: JSON.stringify({ username: 'sync-fixture', password: 'sync-fixture-password' }) })
       if (!setup.ok) throw new Error('验收账号创建失败')
       return (await fetch('/api/app/system/update/status')).json()

+ 4 - 1
desktop/startup-feedback.test.mjs

@@ -1,3 +1,4 @@
+import { enterLocal } from './test-support/onboarding.mjs'
 import {test} from 'node:test'
 import assert from 'node:assert/strict'
 import {mkdtemp,realpath,readFile,rm,mkdir,cp,writeFile,readdir} from 'node:fs/promises'
@@ -29,11 +30,13 @@ test('built startup page shows a real helper error and Retry recovers into the W
     await page.locator('#status[role="alert"]').waitFor({timeout:30000})
     assert.match(await page.locator('#status').textContent(),/已有服务未注册/)
     assert.equal(await page.getByRole('button',{name:'重试',exact:true}).isVisible(),true)
+    await page.locator('#install-details summary').click()
     assert.equal(await page.getByRole('button',{name:'查看日志',exact:true}).isVisible(),true)
     await mkdir(join(root,'test-results/desktop'),{recursive:true})
     await page.screenshot({path:join(root,'test-results/desktop/startup-error-retry.png')})
     await new Promise(done=>blocker.close(done))
     await page.getByRole('button',{name:'重试',exact:true}).click()
+    await enterLocal(page)
     await page.waitForURL(`http://127.0.0.1:${port}/**`,{timeout:45000})
     assert.equal((await localRequest(`http://127.0.0.1:${port}/healthz`)).body.ok,true)
     await page.screenshot({path:join(root,'test-results/desktop/startup-recovered.png')})
@@ -65,7 +68,7 @@ test('same-version unknown ancestry offers an explicit overwrite and preserves e
   assert.match(await page.locator('#status').textContent(),/构建先后关系无法确定/)
   assert.equal(verifyRuntimePackage(driver.snapshot().root).commit,'f'.repeat(40))
   await mkdir(join(root,'test-results/desktop'),{recursive:true});await page.screenshot({path:join(root,'test-results/desktop/same-version-force.png')})
-  await button.click();await page.waitForURL(`http://127.0.0.1:${port}/**`,{timeout:45000})
+  await button.click();await enterLocal(page);await page.waitForURL(`http://127.0.0.1:${port}/**`,{timeout:45000})
   const record=JSON.parse(await readFile(join(home,'service-instance.json'),'utf8'))
   const identity=await localRequest(`http://127.0.0.1:${port}/desktop/service`,record.token)
   assert.equal(identity.body.build.artifactDigest,original.artifactDigest)

+ 8 - 0
desktop/test-support/onboarding.mjs

@@ -0,0 +1,8 @@
+/** Complete the real native first-run form; never bypass authentication. */
+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()
+}

+ 1 - 0
desktop/update-entry.test.mjs

@@ -17,6 +17,7 @@ test('only the main local app can open the native updater and repeated requests
   const identityReady = new Promise(resolve => { releaseIdentity = resolve })
   const server = createServer(async (req, res) => {
     if (req.url === '/desktop/service') { await identityReady; res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify(identity)) }
+    else if(req.url==='/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>Update entry fixture</title><button onclick="window.yaoyaoDesktop.openUpdates()">检测更新</button>') }
   })
   await new Promise(done => server.listen(0, '127.0.0.1', done))

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

@@ -0,0 +1,23 @@
+# 单页安装与登录
+
+已确认的设计见 `approved-design.png`。桌面 App 的服务器选择、服务准备、创建管理员和登录在主窗口同一个 `boot.html` 页面中完成。
+
+- 首次启动选择本机运行或远程连接;记住选择后再次启动自动检测。
+- 本机复用服务管理器的环境检查、组件准备、服务启动流程。只有服务器要求初始化时才创建管理员;否则登录已有账号。
+- 安装阶段显示后台实际步骤与消息,不显示未经测量的百分比。已完成的准备步骤收起为摘要,详情仍可在页内展开。
+- 远程服务器检测成功后才接受账号密码。更改服务器地址会清空凭据并要求重新检测。
+- 连接失败、登录失败、电脑授权失败均在页面内说明。账号登录成功但电脑授权失败时,可以继续进入工作区。
+- 登录会话保留服务器提供的有效期和 SameSite 设置;“记住本次选择”保存服务器与运行方式,不保存密码。
+- 设置中的模式切换、更换服务器、会话过期和重新授权复用主页面。原生登录弹窗及其四个资源文件已移除。
+
+`preview-*.png` 由 Codex 内置浏览器打开实际页面样式与控制器、使用模拟服务后截图。`remote-*.png` 来自 Electron 集成测试;后者验证真实 IPC、HTTP 认证、Cookie 和窗口数量。
+
+运行交互预览:
+
+```sh
+node scripts/preview-desktop-onboarding.mjs
+```
+
+打开 `http://127.0.0.1:4388`。预览会明确标注模拟服务,不会安装组件或连接输入的服务器。
+
+验证覆盖首次启动、本机初始化、已有服务复用、错误重试、同版本覆盖恢复、远程登录、会话重启恢复、权限隔离、窗口内切换、服务崩溃恢复及后台生命周期。设计核对记录位于仓库根目录 `design-qa.md`。

BIN
docs/design/desktop-onboarding/approved-design.png


BIN
docs/design/desktop-onboarding/preview-local-account.png


BIN
docs/design/desktop-onboarding/preview-local-install.png


BIN
docs/design/desktop-onboarding/preview-narrow.png


BIN
docs/design/desktop-onboarding/preview-remote.png


BIN
docs/design/desktop-onboarding/remote-dark.png


BIN
docs/design/desktop-onboarding/remote-login.png


BIN
docs/design/desktop-onboarding/remote-narrow.png


+ 1 - 1
scripts/build-desktop.mjs

@@ -31,7 +31,7 @@ const shell = resolve(out, 'shell')
 await mkdir(shell)
 await cp(resolve(root, 'bin/lib/data-home.mjs'), resolve(shell, 'data-home.mjs'))
 for (const file of await readdir(resolve(root, 'desktop'))) {
-  if (file === 'data-home.mjs' || file.endsWith('.test.mjs') || !/\.(js|mjs|cjs|html|css|png)$/.test(file)) continue
+  if (file === 'data-home.mjs' || file.endsWith('.test.mjs') || !/\.(js|mjs|cjs|html|css|png|svg)$/.test(file)) continue
   if(file==='file-transfer.mjs'){await build({entryPoints:[resolve(root,'desktop',file)],outfile:resolve(shell,file),bundle:true,platform:'node',format:'esm',target:'node24'});continue}
   if(/\.(js|mjs|cjs)$/.test(file))execFileSync(process.execPath,['--check',resolve(root,'desktop',file)],{stdio:'inherit'})
   await cp(resolve(root, 'desktop', file), resolve(shell, file))

+ 42 - 0
scripts/preview-desktop-onboarding.mjs

@@ -0,0 +1,42 @@
+/** Browser preview of the actual desktop HTML/CSS and controller, using fixture
+ * services only. Run: node scripts/preview-desktop-onboarding.mjs */
+import { createServer } from 'node:http'
+import { readFile } from 'node:fs/promises'
+import { resolve, extname } from 'node:path'
+const root = resolve(import.meta.dirname, '..')
+const bridge = `
+const flowReady = import('/desktop/onboarding.mjs').then(({DesktopOnboarding}) => {
+  const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
+  const flow = new DesktopOnboarding({
+    remoteServer: () => 'http://192.168.1.10:15300',
+    inspect: async () => { await delay(400); return {authenticated:false,setupRequired:flow.state.mode==='local'}; },
+    prepareLocal: async () => {
+      for (const [stage,message] of [['checking','正在检查运行环境…'],['installing','正在准备服务组件…'],['starting','正在启动并检查服务…']]) {
+        flow.serviceChanged({phase:'starting',stage,message}); await delay(stage==='installing'?3000:1000);
+      }
+      return 'http://127.0.0.1:15300';
+    },
+    authenticate: async ({username,password}) => { await delay(600); if(password==='error')throw new Error('用户名或密码不正确'); return {username}; },
+    activate: async () => {},
+    navigate: async () => { document.querySelector('.intro h1').textContent='设置完成'; document.querySelector('.intro p').textContent='交互预览已完成,正式应用将在这里进入工作区。'; },
+  });
+  flow.open({mode:'remote',serverURL:'http://192.168.1.10:15300'});
+  return flow;
+});
+window.yaoyaoDesktop={status:()=>flowReady.then(f=>f.snapshot()),selectServer:mode=>flowReady.then(f=>f.select(mode)),prepareServer:input=>flowReady.then(f=>f.prepare(input)),login:input=>flowReady.then(f=>f.submit(input)),forceSync:()=>flowReady.then(f=>f.prepare()),logs:async()=>{document.getElementById('install-log').textContent+='\\n交互预览使用模拟服务,无本机安装日志。';}};
+window.addEventListener('DOMContentLoaded',()=>{document.querySelector('.brand>span').textContent='交互预览 · 模拟服务';});
+`
+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 path = new URL(req.url, 'http://localhost').pathname
+  if (path === '/preview-bridge.js') { res.setHeader('content-type', 'text/javascript'); res.end(bridge); return }
+  if (path === '/') { res.writeHead(302, { location: '/desktop/boot.html' }); res.end(); return }
+  const allowed = ['boot.html', 'boot.css', 'boot.js', 'boot-icons.svg', 'icon.png', 'onboarding.mjs', 'remote-login.mjs']
+  if (!allowed.some(file => path === '/desktop/' + file)) { res.writeHead(404); res.end(); return }
+  try {
+    let data = await readFile(resolve(root, '.' + path))
+    if (path.endsWith('boot.html')) data = data.toString().replace('<script src="boot.js">', '<script src="/preview-bridge.js"></script><script src="boot.js">')
+    res.setHeader('content-type', types[extname(path)]); res.end(data)
+  } catch { res.writeHead(404); res.end() }
+})
+server.listen(Number(process.env.PORT || 4388), '127.0.0.1', () => console.log(`Desktop onboarding preview: http://127.0.0.1:${server.address().port}`))

+ 3 - 3
src/client/components/app/DesktopModePanel.vue

@@ -28,15 +28,15 @@ async function switchMode() {
   try {
     const result = await desktop!.switchMode!(selected.value)
     if (!result.ok) throw new Error(result.error || '切换运行模式未完成,请重试。')
-    if (result.pendingLogin) notice.value = '请在弹出的窗口中登录服务器。'
+    if (result.pendingLogin) notice.value = '请在当前页面完成服务器连接与登录。'
     else await load()
   } catch (e) { error.value = e instanceof Error ? e.message : '切换运行模式未完成,请重试。' }
   finally { busy.value = false }
 }
 async function changeServer() {
   busy.value = true; error.value = ''; notice.value = ''
-  try { await desktop!.openRemoteLogin!(); notice.value = '请在弹出的窗口中填写服务器地址并登录。' }
-  catch (e) { error.value = e instanceof Error ? e.message : '无法打开服务器登录窗口。' }
+  try { await desktop!.openRemoteLogin!() }
+  catch (e) { error.value = e instanceof Error ? e.message : '无法打开服务器登录页面。' }
   finally { busy.value = false }
 }
 onMounted(load)

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

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

+ 7 - 1
src/client/views/LoginView.vue

@@ -1,5 +1,5 @@
 <script setup lang="ts">
-import { computed, nextTick, ref } from 'vue'
+import { computed, nextTick, onMounted, ref } from 'vue'
 import BrandMark from '@/components/common/BrandMark.vue'
 import AppIcon from '@/components/common/AppIcon.vue'
 import { useAuthStore } from '@/stores/auth'
@@ -13,6 +13,12 @@ const passwordConfirmation = ref('')
 const localError = ref('')
 const submitting = computed(() => auth.status === 'authenticating')
 
+onMounted(() => {
+  if (window.yaoyaoDesktop?.openLogin) {
+    void window.yaoyaoDesktop.openLogin().catch(() => { localError.value = '无法打开服务器登录页面,请重新打开应用。' })
+  }
+})
+
 async function login() {
   if (!username.value.trim() || !password.value) return
   if (auth.setupRequired && password.value !== passwordConfirmation.value) {

+ 2 - 1
tests/client/settingsCenterDialog.test.ts

@@ -126,7 +126,8 @@ describe('Settings center dialog', () => {
     await wrapper.findAll('.mode-actions button').find(button => button.text() === '更换服务器…')!.trigger('click')
     await flushPromises()
     expect(openRemoteLogin).toHaveBeenCalledOnce()
-    expect(wrapper.get('[role="status"]').text()).toContain('服务器地址并登录')
+    expect(wrapper.text()).not.toContain('弹出的窗口')
+    expect(wrapper.find('[role="alert"]').exists()).toBe(false)
     wrapper.unmount()
   })
   it.each(['agent-identity', 'agent-models'])('keeps %s and the Profile selector out of Bot-mode personal settings', initialPage => {

Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików