onboarding.mjs 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124
  1. import { normalizeServerURL } from './remote-login.mjs'
  2. /** The native onboarding page owns one selected server and one in-flight action.
  3. * Credentials and CSRF/session tokens never appear in renderer snapshots. */
  4. export class DesktopOnboarding {
  5. constructor(options) {
  6. this.options = options
  7. this.open()
  8. }
  9. get busy() { return ['preparing', 'authenticating', 'entering'].includes(this.state.phase) }
  10. snapshot() { return { ...this.state, history: [...this.state.history], busy: this.busy,
  11. platform: this.options.platform ?? process.platform, supportedModes: this.options.supportedModes ?? ['client', 'server'] } }
  12. open({ mode = null, serverURL = '', remember = false, forceLogin = false, restoring = false } = {}) {
  13. if (this.state && this.busy) throw new Error('当前步骤尚未完成,请稍候')
  14. if (mode === 'local' && !this.snapshotModes().includes('server')) throw new Error('此客户端不支持在本机运行服务器')
  15. if (!mode && !this.snapshotModes().includes('server')) mode = 'remote'
  16. this.state = { active: true, mode, serverURL, remember, forceLogin, restoring, phase: 'idle', stage: 'checking',
  17. setupRequired: false, registrationAvailable: false, registrationNotice: '', authenticated: false, username: '', message: '', error: '',
  18. canForceSync: false, history: [] }
  19. return this.snapshot()
  20. }
  21. select(mode) {
  22. if (!['local', 'remote'].includes(mode)) throw new Error('请选择运行方式')
  23. return this.open({ mode, serverURL: this.options.remoteServer(), remember: this.state.remember })
  24. }
  25. snapshotModes() { return this.options.supportedModes ?? ['client', 'server'] }
  26. serviceChanged(service) {
  27. if (!this.state.active || this.state.mode !== 'local') return
  28. this.state.stage = service.stage || this.state.stage
  29. this.state.message = service.message
  30. this.state.canForceSync = service.canForceSync === true
  31. if (this.state.history.at(-1) !== service.message) {
  32. this.state.history.push(service.message)
  33. this.state.history = this.state.history.slice(-30)
  34. }
  35. if (service.phase === 'error') {
  36. this.state.error = service.message
  37. // Keep the action locked until the outstanding prepare operation settles.
  38. if (!this.busy) { this.state.phase = 'error'; this.state.restoring = false }
  39. }
  40. if (['error', 'restarting', 'stopped', 'disconnected'].includes(service.phase)) {
  41. this.state.authenticated = false
  42. if (this.state.phase === 'ready') this.state.phase = 'error'
  43. }
  44. }
  45. async prepare({ serverURL = this.state.serverURL, remember = this.state.remember, force = false, autoEnter = false } = {}) {
  46. if (this.busy) throw new Error('当前步骤尚未完成,请稍候')
  47. const mode = this.state.mode
  48. if (!mode) throw new Error('请先选择服务器')
  49. Object.assign(this.state, { phase: 'preparing', error: '', authenticated: false,
  50. setupRequired: false, registrationAvailable: false, registrationNotice: '', remember: remember === true, stage: 'checking', canForceSync: false,
  51. message: mode === 'local' ? '正在检查本机环境…' : '正在检测服务器连接…' })
  52. try {
  53. const target = mode === 'local' ? await this.options.prepareLocal({ force }) : normalizeServerURL(serverURL)
  54. this.state.serverURL = target
  55. const info = await this.options.inspect(target)
  56. if (mode === 'remote' && info.setupRequired) throw new Error('远程服务器尚未初始化,请先在服务器本机创建管理员,再重新检测。')
  57. Object.assign(this.state, { phase: 'ready', stage: 'ready', setupRequired: info.setupRequired === true, registrationAvailable: info.registrationAvailable === true,
  58. authenticated: info.authenticated === true && !this.state.forceLogin, username: info.user?.username || '',
  59. message: mode === 'local' ? '本机服务已就绪' : '连接成功,服务器已就绪' })
  60. if (autoEnter && this.state.authenticated) await this.enter()
  61. } catch (error) {
  62. this.state.phase = 'error'
  63. this.state.error = error.message || '准备未完成,请重试'
  64. } finally {
  65. // Keep the guide hidden through successful navigation. Only reveal it
  66. // when restoring needs user input, including connection/navigation errors.
  67. if (this.state.active) this.state.restoring = false
  68. }
  69. return this.snapshot()
  70. }
  71. async submit({ username, password, confirmation, register = false, remember = this.state.remember } = {}) {
  72. if (this.state.phase !== 'ready') throw new Error('请先完成服务器检测')
  73. this.state.remember = remember === true
  74. this.state.error = ''
  75. if (register) return this.register({ username, password, confirmation })
  76. if (this.state.authenticated) return this.enter()
  77. if (!String(username || '').trim() || !password) throw new Error('请填写用户名和密码')
  78. if (this.state.setupRequired && (password.length < 8 || password !== confirmation))
  79. throw new Error(password.length < 8 ? '密码至少需要 8 个字符' : '两次输入的密码不一致')
  80. this.state.phase = 'authenticating'
  81. try {
  82. const result = await this.options.authenticate({ mode: this.state.mode, serverURL: this.state.serverURL,
  83. setup: this.state.mode === 'local' && this.state.setupRequired, username, password })
  84. this.state.authenticated = true
  85. this.state.username = result.username
  86. this.state.phase = 'ready'
  87. if (result.warning) {
  88. this.state.error = result.warning
  89. this.state.message = '账号已登录,可以继续进入夭夭'
  90. } else await this.enter()
  91. } catch (error) {
  92. this.state.phase = 'ready'
  93. this.state.error = error.message || '登录未完成,请重试'
  94. }
  95. return this.snapshot()
  96. }
  97. async register({ username, password, confirmation }) {
  98. if (!this.state.registrationAvailable || this.state.setupRequired || this.state.authenticated) throw new Error('当前服务器尚不支持子账号注册,请联系管理员初始化或升级。')
  99. if (!String(username || '').trim() || !password || password.length < 8 || password !== confirmation) throw new Error('请填写用户名及至少 8 位密码,并确认两次密码一致。')
  100. this.state.phase = 'authenticating'
  101. this.state.registrationNotice = ''
  102. try {
  103. await this.options.register({ serverURL: this.state.serverURL, username, password })
  104. this.state.registrationNotice = '注册成功,请等待管理员开通。开通后使用此账号登录。'
  105. } catch (error) { this.state.error = error.message || '注册失败,请重试。' }
  106. finally { this.state.phase = 'ready' }
  107. return this.snapshot()
  108. }
  109. async enter() {
  110. this.state.phase = 'entering'
  111. try {
  112. await this.options.activate(this.snapshot())
  113. this.state.active = false
  114. this.state.phase = 'complete'
  115. await this.options.navigate(this.state.serverURL)
  116. } catch (error) {
  117. this.state.active = true
  118. this.state.phase = 'ready'
  119. this.state.error = error.message || '打开服务未完成,请重试'
  120. }
  121. return this.snapshot()
  122. }
  123. }