Quellcode durchsuchen

修复恢复已登录会话时闪现登录引导页

Samien vor 1 Monat
Ursprung
Commit
74c240b

+ 12 - 0
desktop/boot.css

@@ -65,6 +65,18 @@ h1,h2,p {
   width:min(820px,calc(100% - 80px));
   margin:20px auto 20px
 }
+.session-loading {
+  min-height:100vh;
+  display:flex;
+  flex-direction:column;
+  align-items:center;
+  justify-content:center;
+  gap:16px;
+  color:var(--muted)
+}
+.session-loading img {
+  object-fit:contain
+}
 .brand {
   display:flex;
   align-items:center;

+ 6 - 2
desktop/boot.html

@@ -3,10 +3,14 @@
 <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">
+  <title>夭夭</title><link rel="stylesheet" href="boot.css">
 </head>
 <body>
-<main class="page">
+<main id="session-loading" class="session-loading" role="status" aria-live="polite" aria-busy="true">
+  <img src="icon.png" alt="" width="48" height="48">
+  <p id="session-loading-message">正在打开夭夭…</p>
+</main>
+<main id="onboarding" class="page" hidden>
   <header class="brand"><div><img src="icon.png" alt="" width="40" height="40"><strong>夭夭</strong></div><span>服务器与账号</span></header>
   <div class="intro"><h1>开始使用夭夭</h1><p>选择服务器,完成准备后即可开始使用。</p></div>
   <ol class="steps" aria-label="设置进度">

+ 12 - 1
desktop/boot.js

@@ -5,6 +5,11 @@ let state, initialized = false, pending = false, serverDirty = false, generation
 
 function display(next) {
   state = next
+  const restoring = state.restoring === true || state.phase === 'complete'
+  $('onboarding').hidden = restoring
+  $('session-loading').hidden = !restoring
+  document.title = restoring ? '夭夭' : '开始使用夭夭'
+  if (restoring) return
   if (!initialized) {
     $('server').value = state.serverURL || ''
     $('remember').checked = state.remember
@@ -169,7 +174,13 @@ $('help-button').onclick = () => {
 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') } }
+  catch {
+    if (!state) {
+      $('session-loading-message').textContent = '无法读取启动状态,请重新打开应用。'
+      $('session-loading').setAttribute('role', 'alert')
+      $('session-loading').setAttribute('aria-busy', 'false')
+    }
+  }
 }
 void render()
 const poll = setInterval(() => { void render() }, 500)

+ 9 - 7
desktop/main.mjs

@@ -195,7 +195,7 @@ else {
       if (!current.startsWith(`${state.url}/`) && current !== state.url) void window.loadURL(state.url)
     } else if (window.webContents.getURL() !== bootURL) {
       recoveringService = true
-      onboarding.open({ mode: 'local', remember: preferences.value.startupChoice === 'local' })
+      onboarding.open({ mode: 'local', remember: preferences.value.startupChoice === 'local', restoring: true })
       onboarding.serviceChanged(state)
       void window.loadURL(bootURL)
     }
@@ -413,11 +413,12 @@ else {
     })
     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 })
+      const prepare = autoPrepare && (mode === 'local' || Boolean(preferences.value.remoteServer))
+      onboarding.open({ mode, serverURL: preferences.value.remoteServer, remember, forceLogin, restoring: prepare && !forceLogin })
       if (!window || window.isDestroyed()) await createWindow()
       else if (window.webContents.getURL() !== bootURL) await window.loadURL(bootURL)
       window.show(); window.focus()
-      if (autoPrepare && (mode === 'local' || preferences.value.remoteServer))
+      if (prepare)
         await onboarding.prepare({ autoEnter: true })
       return onboarding.snapshot()
     }
@@ -499,19 +500,20 @@ 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)
-    // First launch stays in the main window. Remembered choices prepare in place
-    // and enter immediately only when that server already has a valid session.
+    // Restore a saved session behind neutral loading feedback. The guide only
+    // becomes visible if a connection or account actually needs attention.
     const startupMode = preferences.value.startupChoice === 'ask'
       ? (fixtureHome && process.env.HERMES_YAOYAO_DESKTOP_TEST_MODE !== 'ask' ? 'local' : null)
       : preferences.value.startupChoice
+    const restoreSession = Boolean(startupMode && (startupMode === 'local' || preferences.value.remoteServer))
     onboarding.open({ mode: startupMode, serverURL: preferences.value.remoteServer,
-      remember: preferences.value.startupChoice !== 'ask' })
+      remember: preferences.value.startupChoice !== 'ask', restoring: restoreSession })
     // Finish the initial navigation before a remembered session enters its server.
     await createWindow()
     powerMonitor.on('resume', () => { if (!closing && !quitting && serverModeActive) void manager.check() })
     timer = setInterval(() => { if (serverModeActive) void manager.check() }, 10_000); timer.unref()
     syncModeMenu()
-    if (startupMode && (startupMode === 'local' || preferences.value.remoteServer))
+    if (restoreSession)
       await onboarding.prepare({ autoEnter: true })
   }).catch(error => { log(error.stack || error.message); quitting = true; app.quit() })
 }

+ 24 - 1
desktop/onboarding-flow.test.mjs

@@ -11,11 +11,14 @@ test('first launch, failed connection and remote login all stay in the only main
   const home = await realpath(await mkdtemp(join(tmpdir(), 'yaoyao-onboarding-')))
   let loginCount = 0
   let registrationCount = 0
+  let holdBootstrap = false, replyBootstrap, sessionValid = true
   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, registrationAvailable: true, csrfToken: 'fixture', serverKind: 'yaoyao-web' })
+      const reply = () => json({ authenticated: sessionValid && String(req.headers.cookie).includes('session=fixture'), setupRequired: false, registrationAvailable: true, csrfToken: 'fixture', serverKind: 'yaoyao-web' })
+      if (holdBootstrap) { replyBootstrap = reply; return }
+      return reply()
     }
     if (req.url === '/api/app/register') {
       let raw = ''; req.on('data', chunk => { raw += chunk }); req.on('end', () => {
@@ -112,9 +115,22 @@ test('first launch, failed connection and remote login all stay in the only main
     const firstProcess = app.process()
     const firstExit = firstProcess.exitCode === null ? new Promise(done => firstProcess.once('exit', done)) : Promise.resolve()
     await app.close(); await firstExit; app = undefined
+    holdBootstrap = true
     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()
+    await expect.poll(() => Boolean(replyBootstrap)).toBe(true)
+    await expect(reopened.locator('#session-loading')).toBeVisible()
+    await expect(reopened.locator('#onboarding')).toBeHidden()
+    await mkdir(join(root, 'test-results/desktop'), { recursive: true })
+    await reopened.screenshot({ path: join(root, 'test-results/desktop/session-restoring.png') })
+    let guideFlashed = false
+    await reopened.exposeFunction('recordGuideVisibility', visible => { guideFlashed ||= visible })
+    await reopened.evaluate(() => {
+      const guide = document.getElementById('onboarding')
+      new MutationObserver(() => { void window.recordGuideVisibility(!guide.hidden) }).observe(guide, { attributes: true, attributeFilter: ['hidden'] })
+    })
+    holdBootstrap = false; replyBootstrap()
     try { await reopened.waitForURL(url + '/**', { timeout: 8000 }) }
     catch (error) {
       console.log('Reopened fixture URL:', reopened.url())
@@ -123,6 +139,13 @@ test('first launch, failed connection and remote login all stay in the only main
       throw error
     }
     assert.equal(loginCount, 2, 'a remembered session keeps the server-provided cookie expiry and is reused')
+    assert.equal(guideFlashed, false, 'restoring a valid session never displays the guide')
+    // An expired session still returns to the same page with usable login fields.
+    sessionValid = false
+    await reopened.evaluate(() => { void window.yaoyaoDesktop.openLogin() })
+    await expect(reopened.locator('#login-form')).toBeVisible()
+    await expect(reopened.locator('#session-loading')).toBeHidden()
+    assert.equal(app.windows().length, 1)
   } finally {
     await app?.close().catch(() => {})
     await new Promise(done => { server.close(done); server.closeAllConnections() })

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

@@ -0,0 +1,33 @@
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+import { readFile } from 'node:fs/promises'
+import { JSDOM } from 'jsdom'
+
+test('the first paint and every restoring snapshot hide the entire login guide', async () => {
+  const html = await readFile(new URL('./boot.html', import.meta.url), 'utf8')
+  const script = await readFile(new URL('./boot.js', import.meta.url), 'utf8')
+  const dom = new JSDOM(html, { runScripts: 'outside-only' })
+  const { window } = dom
+  const guide = window.document.getElementById('onboarding')
+  const loading = window.document.getElementById('session-loading')
+  let respond, poll
+  window.yaoyaoDesktop = { status: () => new Promise(resolve => { respond = resolve }) }
+  window.setInterval = callback => { poll = callback; return 1 }
+  try {
+    assert.equal(guide.hidden, true, 'HTML hides the guide before JavaScript or IPC runs')
+    assert.equal(loading.hidden, false)
+    window.eval(script)
+    for (const phase of ['idle', 'preparing', 'ready', 'entering', 'complete']) {
+      respond({ phase, restoring: true })
+      await new Promise(resolve => setImmediate(resolve))
+      assert.equal(guide.hidden, true, phase)
+      assert.equal(loading.hidden, false, phase)
+      poll()
+    }
+    respond({ mode: 'remote', phase: 'ready', restoring: false, history: [], serverURL: 'http://remote.test' })
+    await new Promise(resolve => setImmediate(resolve))
+    assert.equal(guide.hidden, false, 'an expired session shows the account form')
+    assert.equal(loading.hidden, true)
+    assert.equal(window.document.getElementById('login-form').hidden, false)
+  } finally { window.close() }
+})

+ 7 - 3
desktop/onboarding.mjs

@@ -9,9 +9,9 @@ export class DesktopOnboarding {
   }
   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 } = {}) {
+  open({ mode = null, serverURL = '', remember = false, forceLogin = false, restoring = false } = {}) {
     if (this.state && this.busy) throw new Error('当前步骤尚未完成,请稍候')
-    this.state = { active: true, mode, serverURL, remember, forceLogin, phase: 'idle', stage: 'checking',
+    this.state = { active: true, mode, serverURL, remember, forceLogin, restoring, phase: 'idle', stage: 'checking',
       setupRequired: false, registrationAvailable: false, registrationNotice: '', authenticated: false, username: '', message: '', error: '',
       canForceSync: false, history: [] }
     return this.snapshot()
@@ -32,7 +32,7 @@ export class DesktopOnboarding {
     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 (!this.busy) { this.state.phase = 'error'; this.state.restoring = false }
     }
     if (['error', 'restarting', 'stopped', 'disconnected'].includes(service.phase)) {
       this.state.authenticated = false
@@ -58,6 +58,10 @@ export class DesktopOnboarding {
     } catch (error) {
       this.state.phase = 'error'
       this.state.error = error.message || '准备未完成,请重试'
+    } finally {
+      // Keep the guide hidden through successful navigation. Only reveal it
+      // when restoring needs user input, including connection/navigation errors.
+      if (this.state.active) this.state.restoring = false
     }
     return this.snapshot()
   }

+ 36 - 0
desktop/onboarding.test.mjs

@@ -63,6 +63,42 @@ test('valid remembered sessions skip credentials, but reauthorization requests a
   assert.equal(flow.snapshot().active, true); assert.equal(flow.snapshot().authenticated, false)
   assert.equal(calls.length, 2)
 })
+test('restoring sessions stays hidden through inspection and navigation for either server mode', async () => {
+  for (const mode of ['local', 'remote']) {
+    let inspected, navigated
+    const { flow } = fixture({
+      inspect: () => new Promise(resolve => { inspected = resolve }),
+      navigate: () => new Promise(resolve => { navigated = resolve }),
+    })
+    flow.open({ mode, serverURL: 'http://remote.test', restoring: true })
+    const checking = flow.prepare({ autoEnter: true })
+    await new Promise(resolve => setImmediate(resolve))
+    assert.equal(flow.snapshot().restoring, true)
+    inspected({ authenticated: true })
+    await new Promise(resolve => setImmediate(resolve))
+    assert.equal(flow.snapshot().restoring, true, 'the guide must not appear while the workspace loads')
+    navigated(); await checking
+    assert.equal(flow.snapshot().restoring, true)
+    assert.equal(flow.snapshot().active, false)
+  }
+})
+test('expired sessions and failed restoration reveal the guide with actionable state', async () => {
+  for (const overrides of [
+    { inspect: async () => ({ authenticated: false }) },
+    { inspect: async () => { throw new Error('服务器离线') } },
+    { inspect: async () => ({ authenticated: true }), navigate: async () => { throw new Error('工作区加载失败') } },
+  ]) {
+    const { flow } = fixture(overrides)
+    flow.open({ mode: 'remote', serverURL: 'http://remote.test', restoring: true })
+    await flow.prepare({ autoEnter: true })
+    assert.equal(flow.snapshot().restoring, false)
+    assert.equal(flow.snapshot().active, true)
+  }
+  const { flow } = fixture()
+  flow.open({ mode: 'local', restoring: true })
+  flow.serviceChanged({ phase: 'error', message: '服务恢复失败' })
+  assert.equal(flow.snapshot().restoring, false, 'background recovery errors must expose retry controls')
+})
 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' })

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

@@ -3,6 +3,7 @@
 已确认的设计见 `approved-design.png`。桌面 App 的服务器选择、服务准备、创建管理员和登录在主窗口同一个 `boot.html` 页面中完成。
 
 - 首次启动选择本机运行或远程连接;记住选择后再次启动自动检测。
+- 恢复已保存的会话时,首屏只显示“正在打开夭夭…”。有效会话直接进入工作区,整个恢复过程隐藏引导内容;会话过期或连接失败后才显示登录、检测和重试控件。
 - 本机复用服务管理器的环境检查、组件准备、服务启动流程。只有服务器要求初始化时才创建管理员;否则登录已有账号。
 - 安装阶段显示后台实际步骤与消息,不显示未经测量的百分比。已完成的准备步骤收起为摘要,详情仍可在页内展开。
 - 远程服务器检测成功后才接受账号密码。更改服务器地址会清空凭据并要求重新检测。