device-center 0.2.0-beta.3 → 0.2.0-beta.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/CONTRIBUTING.md +2 -0
  2. package/Dockerfile +2 -1
  3. package/README.md +24 -7
  4. package/SECURITY.md +8 -2
  5. package/bin/device-center.mjs +6 -5
  6. package/docs/agent-install.md +43 -9
  7. package/docs/driver-binding.md +14 -4
  8. package/docs/friend-trial.md +5 -5
  9. package/docs/hosted-access.md +1 -1
  10. package/docs/npm.md +13 -9
  11. package/docs/onboarding.md +5 -3
  12. package/docs/remote-access.md +2 -2
  13. package/docs/roadmap.md +13 -1
  14. package/docs/security-and-sync.md +4 -0
  15. package/docs/troubleshooting.md +11 -0
  16. package/docs/windows-acceptance.md +57 -0
  17. package/favicon.svg +4 -0
  18. package/index.html +1 -0
  19. package/login.html +2 -2
  20. package/npm-shrinkwrap.json +2 -2
  21. package/package.json +4 -2
  22. package/scripts/access.mjs +0 -1
  23. package/scripts/driver.mjs +27 -11
  24. package/scripts/service.mjs +42 -16
  25. package/server/cloudAccess.mjs +7 -1
  26. package/server/drivers/access.mjs +15 -14
  27. package/server/drivers/browserPairing.mjs +46 -0
  28. package/server/drivers/cloudLink.mjs +19 -20
  29. package/server/drivers/cloudRegistry.mjs +23 -6
  30. package/server/drivers/privateFiles.mjs +57 -0
  31. package/server/drivers/windowsSecurity.mjs +119 -0
  32. package/server/drivers/windowsService.mjs +77 -0
  33. package/server/index.mjs +30 -7
  34. package/server/unifiedAccess.mjs +24 -4
  35. package/src/api.js +4 -0
  36. package/src/install-prompt.js +2 -2
  37. package/src/install-walkthrough.js +49 -0
  38. package/src/login-flow.js +37 -0
  39. package/src/login.css +3 -1
  40. package/src/login.js +20 -11
  41. package/src/main.js +20 -12
  42. package/src/messages.js +1745 -162
  43. package/src/onboarding.js +60 -17
  44. package/src/pair.css +23 -0
  45. package/src/pair.js +60 -0
  46. package/src/pairing-handoff.js +30 -0
  47. package/src/start.css +265 -45
  48. package/src/start.js +155 -26
  49. package/start.html +143 -27
@@ -0,0 +1,119 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { win32 } from 'node:path';
3
+
4
+ // Fixed PowerShell code; paths and secrets travel only on stdin, never in arguments.
5
+ // No execution-policy change, elevation, password, external download or shell expansion.
6
+ export const powershellPath = (environment = process.env) => {
7
+ const systemRoot = environment.SystemRoot;
8
+ if (!systemRoot || !win32.isAbsolute(systemRoot) || /[\r\n\0]/.test(systemRoot)) throw new Error('windows_system_root_required');
9
+ return win32.join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe');
10
+ };
11
+ export const powershellArguments = source => ['-NoLogo', '-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(source, 'utf16le').toString('base64')];
12
+ export function runPowerShell(source, input, { spawnImpl = spawnSync, environment = process.env } = {}) {
13
+ const result = spawnImpl(powershellPath(environment), powershellArguments(source), {
14
+ input: JSON.stringify(input), encoding: 'utf8', windowsHide: true, shell: false, timeout: 15_000, maxBuffer: 512 * 1024,
15
+ });
16
+ // PowerShell diagnostics can contain input values. Never relay them to the caller.
17
+ if (result.error || result.status !== 0) throw new Error('windows_security_operation_failed');
18
+ try { return JSON.parse(result.stdout.replace(/^\uFEFF/, '').trim()); }
19
+ catch { throw new Error('windows_security_response_invalid'); }
20
+ }
21
+ export const powershellPreamble = `
22
+ $ErrorActionPreference = 'Stop'
23
+ # A Node child launched from PowerShell 7 inherits its module search path.
24
+ # Windows PowerShell 5.1 must load its own built-in modules (including Set-Acl),
25
+ # not incompatible PS7 or user-installed modules. This affects only this child.
26
+ $env:PSModulePath = [IO.Path]::Combine($PSHOME, 'Modules')
27
+ [Console]::InputEncoding = [System.Text.UTF8Encoding]::new($false)
28
+ [Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false)
29
+ $request = [Console]::In.ReadToEnd() | ConvertFrom-Json
30
+ $sid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User
31
+ `;
32
+ export const windowsStorageSource = powershellPreamble + `
33
+ function Assert-PlainPath([string] $path) {
34
+ $cursor = [IO.Path]::GetFullPath($path)
35
+ while ($cursor) {
36
+ if ([IO.File]::Exists($cursor) -or [IO.Directory]::Exists($cursor)) {
37
+ $item = Get-Item -LiteralPath $cursor -Force
38
+ if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { throw 'reparse_point' }
39
+ }
40
+ $parent = [IO.Path]::GetDirectoryName($cursor)
41
+ if ($parent -eq $cursor) { break }
42
+ $cursor = $parent
43
+ }
44
+ }
45
+ function Assert-Private([string] $path) {
46
+ Assert-PlainPath $path
47
+ $acl = Get-Acl -LiteralPath $path
48
+ if ($acl.GetOwner([System.Security.Principal.SecurityIdentifier]).Value -ne $sid.Value) { throw 'owner_required' }
49
+ $currentAllowed = $false
50
+ foreach ($rule in $acl.GetAccessRules($true, $true, [System.Security.Principal.SecurityIdentifier])) {
51
+ if ($rule.AccessControlType -ne [System.Security.AccessControl.AccessControlType]::Allow) { throw 'deny_rule' }
52
+ if ($rule.IdentityReference.Value -notin @($sid.Value, 'S-1-5-18')) { throw 'private_acl_required' }
53
+ if ($rule.IdentityReference.Value -eq $sid.Value -and ($rule.FileSystemRights -band [System.Security.AccessControl.FileSystemRights]::FullControl) -eq [System.Security.AccessControl.FileSystemRights]::FullControl) { $currentAllowed = $true }
54
+ }
55
+ if (-not $currentAllowed) { throw 'current_user_access_required' }
56
+ }
57
+ switch ($request.operation) {
58
+ 'ensure-directory' {
59
+ Assert-PlainPath $request.path
60
+ if (-not [IO.Directory]::Exists($request.path)) {
61
+ $directory = [IO.Directory]::CreateDirectory($request.path)
62
+ $acl = New-Object System.Security.AccessControl.DirectorySecurity
63
+ $acl.SetOwner($sid)
64
+ $acl.SetAccessRuleProtection($true, $false)
65
+ foreach ($principal in @($sid.Value, 'S-1-5-18')) {
66
+ $identity = [System.Security.Principal.SecurityIdentifier]::new($principal)
67
+ $rule = [System.Security.AccessControl.FileSystemAccessRule]::new($identity, 'FullControl', 'ContainerInherit, ObjectInherit', 'None', 'Allow')
68
+ $acl.AddAccessRule($rule)
69
+ }
70
+ Set-Acl -LiteralPath $directory.FullName -AclObject $acl
71
+ }
72
+ Assert-Private $request.path
73
+ @{ ok = $true } | ConvertTo-Json -Compress
74
+ }
75
+ 'assert-file' {
76
+ Assert-Private ([IO.Path]::GetDirectoryName($request.path))
77
+ Assert-Private $request.path
78
+ @{ ok = $true } | ConvertTo-Json -Compress
79
+ }
80
+ 'secure-new-file' {
81
+ # Called only after our exclusive wx creation in an already protected directory.
82
+ Assert-Private ([IO.Path]::GetDirectoryName($request.path))
83
+ Assert-PlainPath $request.path
84
+ $acl = [System.Security.AccessControl.FileSecurity]::new()
85
+ $acl.SetOwner($sid)
86
+ $acl.SetAccessRuleProtection($true, $false)
87
+ foreach ($principal in @($sid.Value, 'S-1-5-18')) {
88
+ $identity = [System.Security.Principal.SecurityIdentifier]::new($principal)
89
+ $acl.AddAccessRule([System.Security.AccessControl.FileSystemAccessRule]::new($identity, 'FullControl', 'Allow'))
90
+ }
91
+ Set-Acl -LiteralPath $request.path -AclObject $acl
92
+ Assert-Private $request.path
93
+ @{ ok = $true } | ConvertTo-Json -Compress
94
+ }
95
+ 'protect' {
96
+ Add-Type -AssemblyName System.Security
97
+ $data = [Convert]::FromBase64String($request.data)
98
+ $encrypted = [System.Security.Cryptography.ProtectedData]::Protect($data, $null, [System.Security.Cryptography.DataProtectionScope]::CurrentUser)
99
+ @{ data = [Convert]::ToBase64String($encrypted) } | ConvertTo-Json -Compress
100
+ [Array]::Clear($data, 0, $data.Length)
101
+ }
102
+ 'unprotect' {
103
+ Add-Type -AssemblyName System.Security
104
+ $data = [System.Security.Cryptography.ProtectedData]::Unprotect([Convert]::FromBase64String($request.data), $null, [System.Security.Cryptography.DataProtectionScope]::CurrentUser)
105
+ @{ data = [Convert]::ToBase64String($data) } | ConvertTo-Json -Compress
106
+ [Array]::Clear($data, 0, $data.Length)
107
+ }
108
+ default { throw 'unknown_storage_operation' }
109
+ }
110
+ `;
111
+ export function windowsStorage(operation, values) {
112
+ if (process.platform !== 'win32') throw new Error('windows_only');
113
+ return runPowerShell(windowsStorageSource, { operation, ...values });
114
+ }
115
+ export const protectWindowsIdentity = value => ({ format: 'device-center-dpapi-v1', data: windowsStorage('protect', { data: Buffer.from(JSON.stringify(value)).toString('base64') }).data });
116
+ export function unprotectWindowsIdentity(value) {
117
+ if (value?.format !== 'device-center-dpapi-v1' || typeof value.data !== 'string' || !/^[A-Za-z0-9+/]+={0,2}$/.test(value.data)) throw new Error('windows_encrypted_identity_required');
118
+ return JSON.parse(Buffer.from(windowsStorage('unprotect', { data: value.data }).data, 'base64').toString('utf8'));
119
+ }
@@ -0,0 +1,77 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { powershellArguments, powershellPath, powershellPreamble, runPowerShell } from './windowsSecurity.mjs';
3
+
4
+ const literal = value => `'${String(value).replaceAll("'", "''")}'`;
5
+ export function windowsServiceDefinition({ home, root, node, script, port, dbPath, logPath, errorLogPath, environment = process.env }) {
6
+ const launcher = `$ErrorActionPreference = 'Stop'\n$env:PORT = '${port}'\n$env:DEVICE_CENTER_DB_PATH = ${literal(dbPath)}\n$env:DEVICE_CENTER_SERVICE_MANAGER = 'windows-task'\n$env:DEVICE_CENTER_RUNTIME = 'native'\n$env:DEVICE_CENTER_PUBLIC_ORIGIN = $null\n$env:DEVICE_CENTER_AUTH_FILE = $null\nSet-Location -LiteralPath ${literal(root)}\n# Windows PowerShell 5.1 treats redirected native stderr as ErrorRecord.\n# Keep Node warnings visible in the log; use its exit code for task failure.\n$ErrorActionPreference = 'Continue'\n$LASTEXITCODE = 1\n& ${literal(node)} ${literal(script)} >> ${literal(logPath)} 2>> ${literal(errorLogPath)}\n$exitCode = $LASTEXITCODE\n$ErrorActionPreference = 'Stop'\nexit $exitCode\n`;
7
+ return {
8
+ taskName: `DeviceCenter-${createHash('sha256').update(home.toLowerCase()).digest('hex').slice(0, 16)}`,
9
+ executable: powershellPath(environment),
10
+ arguments: ['-WindowStyle', 'Hidden', ...powershellArguments(launcher)].join(' '),
11
+ workingDirectory: root, logonType: 'InteractiveToken', runLevel: 'Limited', trigger: 'CurrentUserLogon',
12
+ };
13
+ }
14
+ // COM Task Scheduler is built into Windows. Only this user's exact task is touched.
15
+ export const windowsTaskSource = powershellPreamble + `
16
+ $scheduler = New-Object -ComObject 'Schedule.Service'
17
+ $scheduler.Connect()
18
+ $folder = $scheduler.GetFolder('\\')
19
+ $task = $null
20
+ try { $task = $folder.GetTask($request.task.taskName) } catch {
21
+ $exception = $_.Exception
22
+ while ($exception.InnerException) { $exception = $exception.InnerException }
23
+ if ($exception.HResult -ne -2147024894) { throw }
24
+ }
25
+ function Assert-OurTask {
26
+ if (-not $task) { throw 'task_missing' }
27
+ $definition = $task.Definition
28
+ $principal = $definition.Principal
29
+ $principalSid = $principal.UserId
30
+ if ($principal.UserId -ne $sid.Value) { $principalSid = ([System.Security.Principal.NTAccount]::new($principal.UserId)).Translate([System.Security.Principal.SecurityIdentifier]).Value }
31
+ if ($principalSid -ne $sid.Value -or $principal.LogonType -ne 3 -or $principal.RunLevel -ne 0) { throw 'task_principal_changed' }
32
+ if ($definition.Actions.Count -ne 1 -or $definition.Triggers.Count -ne 1) { throw 'task_changed' }
33
+ $action = $definition.Actions.Item(1)
34
+ $trigger = $definition.Triggers.Item(1)
35
+ if ($action.Type -ne 0 -or $action.Path -ne $request.task.executable -or $action.Arguments -cne $request.task.arguments -or $action.WorkingDirectory -cne $request.task.workingDirectory -or $trigger.Type -ne 9 -or $trigger.UserId -ne $sid.Value) { throw 'task_action_changed' }
36
+ }
37
+ switch ($request.operation) {
38
+ 'install' {
39
+ if ($task) { throw 'task_already_exists' }
40
+ $definition = $scheduler.NewTask(0)
41
+ $definition.RegistrationInfo.Description = 'Device Center: current user local Driver; login startup only.'
42
+ $definition.Principal.UserId = $sid.Value
43
+ $definition.Principal.LogonType = 3
44
+ $definition.Principal.RunLevel = 0
45
+ $trigger = $definition.Triggers.Create(9)
46
+ $trigger.UserId = $sid.Value
47
+ $action = $definition.Actions.Create(0)
48
+ $action.Path = $request.task.executable
49
+ $action.Arguments = $request.task.arguments
50
+ $action.WorkingDirectory = $request.task.workingDirectory
51
+ $definition.Settings.Enabled = $true
52
+ $definition.Settings.AllowDemandStart = $true
53
+ $definition.Settings.StartWhenAvailable = $true
54
+ $definition.Settings.DisallowStartIfOnBatteries = $false
55
+ $definition.Settings.StopIfGoingOnBatteries = $false
56
+ $definition.Settings.ExecutionTimeLimit = 'PT0S'
57
+ $definition.Settings.MultipleInstances = 2
58
+ $definition.Settings.RestartInterval = 'PT1M'
59
+ $definition.Settings.RestartCount = 3
60
+ $task = $folder.RegisterTaskDefinition($request.task.taskName, $definition, 2, $sid.Value, $null, 3)
61
+ $null = $task.Run($null)
62
+ @{ registered = $true } | ConvertTo-Json -Compress
63
+ }
64
+ 'status' {
65
+ if ($task) { Assert-OurTask }
66
+ @{ exists = [bool]$task; state = $(if ($task) { [int]$task.State } else { $null }) } | ConvertTo-Json -Compress
67
+ }
68
+ 'stop' { Assert-OurTask; $task.Stop(0); @{ stopped = $true } | ConvertTo-Json -Compress }
69
+ 'remove' { Assert-OurTask; $folder.DeleteTask($request.task.taskName, 0); @{ removed = $true } | ConvertTo-Json -Compress }
70
+ default { throw 'unknown_task_operation' }
71
+ }
72
+ `;
73
+ export function windowsTask(operation, task) {
74
+ if (process.platform !== 'win32') throw new Error('windows_only');
75
+ try { return runPowerShell(windowsTaskSource, { operation, task }); }
76
+ catch { throw new Error('Windows 当前用户任务操作失败。请检查任务计划程序、组策略或既有任务;未提权、改策略或覆盖任务。可改用 start 前台模式。'); }
77
+ }
package/server/index.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  import { createServer as createHttpServer } from 'node:http';
2
+ import { readFileSync } from 'node:fs';
2
3
  import { readFile } from 'node:fs/promises';
3
- import { extname, resolve } from 'node:path';
4
+ import { extname, resolve, sep } from 'node:path';
4
5
  import { fileURLToPath, pathToFileURL } from 'node:url';
5
6
  import { openDatabase } from './database.mjs';
6
7
  import { currentPrincipal } from './adapters/identity/localDemo.mjs';
@@ -14,6 +15,14 @@ import { createRemoteAgent, createRemoteClient } from './drivers/remote.mjs';
14
15
  const PROJECT_ROOT = resolve(fileURLToPath(new URL('..', import.meta.url)));
15
16
  const MIME_TYPES = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.svg': 'image/svg+xml', '.png': 'image/png' };
16
17
  const PUBLIC_GUIDES = Object.freeze({ '/install/agent.md': 'docs/agent-install.md', '/install/trial.md': 'docs/friend-trial.md', '/install/remote-access.md': 'docs/remote-access.md', '/install/device-profiles.md': 'docs/device-profiles.md', '/install/troubleshooting.md': 'docs/troubleshooting.md' });
18
+ // Deployed archives carry release.json; npm and source installs report only the package version.
19
+ function deployedVersion(releaseFile) {
20
+ const version = JSON.parse(readFileSync(resolve(PROJECT_ROOT, 'package.json'), 'utf8')).version;
21
+ let manifest = null;
22
+ try { manifest = JSON.parse(readFileSync(releaseFile, 'utf8')); } catch { /* not a deployed archive */ }
23
+ const matches = manifest?.kind === 'device-center-web' && manifest.version === version && /^[a-f0-9]{40}$/.test(manifest.sourceSha ?? '');
24
+ return { version, sourceSha: matches ? manifest.sourceSha : null };
25
+ }
17
26
  function sendJson(response, status, payload, headers = {}) {
18
27
  response.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store', 'x-content-type-options': 'nosniff', ...headers });
19
28
  response.end(JSON.stringify(payload));
@@ -42,11 +51,13 @@ async function readJson(request) {
42
51
  async function serveStatic(pathname, response) {
43
52
  let filePath;
44
53
  if (pathname === '/' || pathname === '/index.html') filePath = resolve(PROJECT_ROOT, 'index.html');
54
+ else if (pathname === '/favicon.svg') filePath = resolve(PROJECT_ROOT, 'favicon.svg');
45
55
  else if (pathname === '/login') filePath = resolve(PROJECT_ROOT, 'login.html');
56
+ else if (pathname === '/pair') filePath = resolve(PROJECT_ROOT, 'login.html');
46
57
  else if (pathname === '/start' || pathname === '/start.html') filePath = resolve(PROJECT_ROOT, 'start.html');
47
58
  else if (pathname.startsWith('/src/')) {
48
59
  filePath = resolve(PROJECT_ROOT, `.${pathname}`);
49
- if (!filePath.startsWith(resolve(PROJECT_ROOT, 'src') + '/')) return sendJson(response, 404, { error: 'not_found' });
60
+ if (!filePath.startsWith(resolve(PROJECT_ROOT, 'src') + sep)) return sendJson(response, 404, { error: 'not_found' });
50
61
  } else return sendJson(response, 404, { error: 'not_found' });
51
62
  try {
52
63
  const body = await readFile(filePath);
@@ -59,10 +70,11 @@ async function serveStatic(pathname, response) {
59
70
  } catch { sendJson(response, 404, { error: 'not_found' }); }
60
71
  }
61
72
 
62
- export function createDeviceCenterServer({ dbPath = './data/device-center.sqlite', web = true, runtime = 'native', sshHome, sshProbe, publicOrigin, authFile, authFetch, cloudLinkFile, driverFetch, driverBundlePath = resolve(PROJECT_ROOT, 'driver-source.tar.gz') } = {}) {
73
+ export function createDeviceCenterServer({ dbPath = './data/device-center.sqlite', web = true, runtime = 'native', sshHome, sshProbe, publicOrigin, authFile, authFetch, cloudLinkFile, driverFetch, driverBundlePath = resolve(PROJECT_ROOT, 'driver-source.tar.gz'), releaseFile = resolve(PROJECT_ROOT, 'release.json') } = {}) {
63
74
  const cloudAccess = createCloudAccess({ publicOrigin, authFile, fetchImpl: authFetch });
64
75
  if (runtime === 'cloud' && !cloudAccess) throw new Error('cloud_authentication_required');
65
76
  const cloud = Boolean(cloudAccess);
77
+ const health = Object.freeze({ ok: true, mode: cloud ? 'cloud' : 'local', runtime: cloud ? 'cloud' : runtime, ...deployedVersion(releaseFile) });
66
78
  const absoluteDbPath = resolve(dbPath);
67
79
  const db = openDatabase(absoluteDbPath);
68
80
  const startedAt = Date.now();
@@ -81,8 +93,11 @@ export function createDeviceCenterServer({ dbPath = './data/device-center.sqlite
81
93
  const origin = cloudAccess ? cloudAccess.validate(request) : validateLocalRequest(request);
82
94
  const url = new URL(request.url ?? '/', origin);
83
95
  const path = url.pathname;
96
+ // Public liveness and deployed version only; no account, device or configuration data.
97
+ if (path === '/api/health' && request.method === 'GET') return sendJson(response, 200, health);
98
+ if (['/pair', '/src/pair.js', '/src/pair.css', '/src/pairing-handoff.js', '/src/api.js'].includes(path) && request.method === 'GET') return await serveStatic(path, response);
84
99
  // Public static installation reference only. No identity, configuration or owner API.
85
- if (['/start', '/start.html', '/src/start.js', '/src/start.css', '/src/onboarding.js', '/src/i18n.js', '/src/i18n.css', '/src/messages.js', '/src/install-prompt.js'].includes(path) && request.method === 'GET') return await serveStatic(path, response);
100
+ if (['/start', '/start.html', '/favicon.svg', '/src/start.js', '/src/start.css', '/src/onboarding.js', '/src/i18n.js', '/src/i18n.css', '/src/messages.js', '/src/install-prompt.js', '/src/install-walkthrough.js'].includes(path) && request.method === 'GET') return await serveStatic(path, response);
86
101
  if (Object.hasOwn(PUBLIC_GUIDES, path) && request.method === 'GET') {
87
102
  const body = await readFile(resolve(PROJECT_ROOT, PUBLIC_GUIDES[path]));
88
103
  response.writeHead(200, { 'content-type': 'text/markdown; charset=utf-8', 'cache-control': 'no-store', 'x-content-type-options': 'nosniff', 'content-security-policy': "default-src 'none'; frame-ancestors 'none'", 'referrer-policy': 'no-referrer' });
@@ -93,7 +108,7 @@ export function createDeviceCenterServer({ dbPath = './data/device-center.sqlite
93
108
  const machineActions = { '/api/driver/pair/start': 'start', '/api/driver/pair/poll': 'poll', '/api/driver/report': 'report', '/api/driver/disconnect': 'disconnect' };
94
109
  if (Object.hasOwn(machineActions, path) && request.method === 'POST') return sendJson(response, 200, registry[machineActions[path]](await readJson(request)));
95
110
  if (['control','certificate','peers','relay/open','relay/accept','relay/exchange','relay/close'].some(action => path === `/api/driver/${action}`) && request.method === 'POST') return sendJson(response, 200, registry.machine(path.slice('/api/driver/'.length), await readJson(request)));
96
- if (['/login', '/src/login.js', '/src/login.css'].includes(path) && request.method === 'GET') return await serveStatic(path, response);
111
+ if (['/login', '/src/login.js', '/src/login-flow.js', '/src/login.css'].includes(path) && request.method === 'GET') return await serveStatic(path, response);
97
112
  if (path === '/api/auth/options' && request.method === 'GET') return sendJson(response, 200, cloudAccess.options(request));
98
113
  if (path === '/api/auth/continue' && request.method === 'POST') {
99
114
  if (request.headers.origin !== origin) return sendJson(response, 403, { error: 'same_origin_required' });
@@ -113,12 +128,21 @@ export function createDeviceCenterServer({ dbPath = './data/device-center.sqlite
113
128
  if (request.headers.origin !== origin) return sendJson(response, 403, { error: 'same_origin_required' });
114
129
  return sendJson(response, 200, { ok: true }, { 'set-cookie': cloudAccess.logout(request) });
115
130
  }
116
- const principal = await cloudAccess.resolvePrincipal(request);
131
+ let principal;
132
+ try { principal = await cloudAccess.resolvePrincipal(request); }
133
+ catch (cause) {
134
+ // An identity-provider outage is recoverable in the login UI, not another trip to the account homepage.
135
+ if (path !== '/' || request.method !== 'GET' || cause.statusCode !== 503) throw cause;
136
+ const query = new URLSearchParams({ reason: 'unavailable' });
137
+ if (/^(zh-CN|zh-TW|en|ja|ko|es|fr|de|pt-BR)$/.test(url.searchParams.get('lang') ?? '')) query.set('lang', url.searchParams.get('lang'));
138
+ response.writeHead(303, { location: `/login?${query}`, 'cache-control': 'no-store' }); return response.end();
139
+ }
117
140
  if (!principal) {
118
141
  if (path === '/' && request.method === 'GET') { response.writeHead(303, { location: `/login${/^(zh-CN|zh-TW|en|ja|ko|es|fr|de|pt-BR)$/.test(url.searchParams.get('lang') ?? '') ? '?lang=' + url.searchParams.get('lang') : ''}`, 'cache-control': 'no-store' }); return response.end(); }
119
142
  return sendJson(response, 401, { error: 'authentication_required' });
120
143
  }
121
144
  accountRegistry = registry.forTenant(principal.tenantId);
145
+ if (path === '/api/auth/session' && request.method === 'GET') return sendJson(response, 200, { authenticated: true });
122
146
  if (path === '/api/drivers' && request.method === 'GET') return sendJson(response, 200, { drivers: accountRegistry.bindings() });
123
147
  const ownerActions = { '/api/devices/profile': 'saveProfile', '/api/devices/collect': 'collect', '/api/drivers/pairing/lookup': 'lookup', '/api/drivers/pairing/approve': 'approve', '/api/drivers/revoke': 'revoke' };
124
148
  if (Object.hasOwn(ownerActions, path) && request.method === 'POST') {
@@ -190,7 +214,6 @@ export function createDeviceCenterServer({ dbPath = './data/device-center.sqlite
190
214
  }
191
215
  if (path === '/api/ssh/candidates') return sendJson(response, 200, inventory.discovery());
192
216
  if (path === '/api/ssh/initialization') return sendJson(response, 200, inventory.initialization());
193
- if (path === '/api/health') return sendJson(response, 200, { ok: true, mode: cloud ? 'cloud' : 'local', runtime: cloud ? 'cloud' : runtime });
194
217
  if (path === '/api/connector/setup') return sendJson(response, 200, {
195
218
  ...(cloud ? { name: 'device-center-connector', dataMode: 'cloud', available: false, url: null, setupCommand: null, reason: 'cloud_driver_not_bound', tools: [] } : {
196
219
  name: 'device-center-connector', transport: 'Streamable HTTP', accessMode: 'read-only', dataMode: 'local-inventory',
@@ -4,6 +4,7 @@ const COOKIE = '__Host-device-center';
4
4
  const SIGNED_OUT = '__Host-device-center-signed-out';
5
5
  const SESSION_AGE = 8 * 60 * 60 * 1000;
6
6
  const PROFILE_AGE = 10_000;
7
+ const PROFILE_TIMEOUT = 8_000;
7
8
  const digest = (value) => createHash('sha256').update(value).digest('hex');
8
9
  const failure = (message, statusCode) => Object.assign(new Error(message), { statusCode });
9
10
  const cookie = (name, value, age) => `${name}=${value}; Path=/; Secure; HttpOnly; SameSite=Strict; Max-Age=${age}`;
@@ -29,7 +30,7 @@ export function createUnifiedAccess({ config, publicOrigin, now = Date.now, fetc
29
30
  const ownerEmail = typeof config.ownerEmail === 'string' ? config.ownerEmail.toLowerCase() : undefined;
30
31
  const validId = id => typeof id === 'string' && id.length > 0 && id.length <= 256 && !/[\x00-\x1f\x7f]/.test(id);
31
32
  if (config.legacyOwnerId !== undefined && (accountMode !== 'multi-user' || !validId(config.legacyOwnerId))) throw new Error('unified_legacy_owner_invalid');
32
- const sessions = new Map(), profiles = new Map(), attempts = new Map();
33
+ const sessions = new Map(), profiles = new Map(), attempts = new Map(), verifying = new Map();
33
34
  let active = 0;
34
35
  const tokenFrom = (request) => {
35
36
  const token = readCookie(request, 'sessionToken');
@@ -40,15 +41,34 @@ export function createUnifiedAccess({ config, publicOrigin, now = Date.now, fetc
40
41
  for (const [id, profile] of profiles) if (profile.expires <= now()) profiles.delete(id);
41
42
  for (const [id, times] of attempts) if (!times.some((time) => time > now() - 60_000)) attempts.delete(id);
42
43
  };
43
- async function verifyProfile(token) {
44
+ async function requestProfile(token) {
45
+ for (let attempt = 0; attempt < 2; attempt++) {
46
+ try {
47
+ const response = await fetchImpl(new URL('/v1/me', backend), { headers: { authorization: `Bearer ${token}`, accept: 'application/json' }, redirect: 'error', signal: AbortSignal.timeout(PROFILE_TIMEOUT) });
48
+ if (attempt === 0 && [502, 503, 504].includes(response.status)) { await response.body?.cancel(); continue; }
49
+ return response;
50
+ } catch (cause) {
51
+ // Retry only transport failures, never an invalid identity, body or TLS redirect.
52
+ const transient = ['TimeoutError', 'AbortError'].includes(cause.name) || ['ETIMEDOUT', 'ECONNRESET', 'EHOSTUNREACH', 'ENETUNREACH', 'EAI_AGAIN', 'UND_ERR_CONNECT_TIMEOUT', 'UND_ERR_SOCKET'].includes(cause.code ?? cause.cause?.code);
53
+ if (attempt || !transient) throw cause;
54
+ }
55
+ }
56
+ }
57
+ function verifyProfile(token) {
44
58
  expire();
45
59
  const key = digest(token), cached = profiles.get(key);
46
- if (cached) return cached.profile;
60
+ if (cached) return Promise.resolve(cached.profile);
61
+ if (verifying.has(key)) return verifying.get(key);
62
+ const pending = loadProfile(token, key).finally(() => verifying.delete(key));
63
+ verifying.set(key, pending);
64
+ return pending;
65
+ }
66
+ async function loadProfile(token, key) {
47
67
  if (active >= 8) throw failure('unified_service_busy', 503);
48
68
  active++;
49
69
  let phase = 'request', upstreamStatus;
50
70
  try {
51
- const response = await fetchImpl(new URL('/v1/me', backend), { headers: { authorization: `Bearer ${token}`, accept: 'application/json' }, redirect: 'error', signal: AbortSignal.timeout(5000) });
71
+ const response = await requestProfile(token);
52
72
  phase = 'response'; upstreamStatus = response.status;
53
73
  if ([401, 403].includes(response.status)) throw failure('unified_sign_in_required', 401);
54
74
  if (!response.ok || !/^application\/json(?:;|$)/i.test(response.headers.get('content-type') ?? '')) throw failure('unified_service_unavailable', 503);
package/src/api.js CHANGED
@@ -1,12 +1,16 @@
1
1
  import { getLocale } from './i18n.js';
2
+ import { clearLoginNavigation } from './login-flow.js';
2
3
  async function request(path, { timeoutMs = 12000, ...options } = {}) {
3
4
  const response = await fetch(path, { ...options, cache: 'no-store', signal: AbortSignal.timeout(timeoutMs) });
4
5
  const payload = await response.json().catch(() => ({}));
5
6
  if (response.status === 401) { location.replace('/login?lang=' + getLocale()); throw new Error('authentication_required'); }
6
7
  if (!response.ok) throw new Error(payload.error ?? `http_${response.status}`);
8
+ clearLoginNavigation();
7
9
  return payload;
8
10
  }
9
11
  export const api = {
12
+ lookupBrowserPairing: browserTicket => request('/api/drivers/pairing/lookup', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ browserTicket }) }),
13
+ approveBrowserPairing: input => request('/api/drivers/pairing/approve', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(input) }),
10
14
  saveDeviceProfile: (profile) => request('/api/devices/profile', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(profile) }),
11
15
  collectDevice: (deviceId) => request('/api/devices/collect', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ deviceId }), timeoutMs: 30000 }),
12
16
  remotePeers: () => request('/api/remote/peers'),
@@ -7,10 +7,10 @@ export function localizedInstallPrompt(plan, locale) {
7
7
  tr('prompt.intro'),
8
8
  tr('prompt.choice', { mode: tr(plan.mode === 'local' ? 'guide.local' : plan.mode === 'official' ? 'guide.hosted' : 'guide.custom'), version: plan.version, run: tr(plan.background ? 'prompt.background' : 'prompt.foreground') }),
9
9
  plan.controlOrigin ? tr('prompt.private', { origin: plan.controlOrigin }) : '',
10
- `1. ${tr('prompt.environment')}`,
10
+ `1. ${tr(plan.os === 'windows' ? 'prompt.windowsEnvironment' : 'prompt.environment')}`,
11
11
  `2. ${tr('prompt.existing', { url: plan.url, status: c.bindingStatus, service: c.serviceStatus })}`,
12
12
  `3. ${tr('prompt.userInstall')}\n ${c.install}\n ${c.version}\n ${tr('prompt.config')}`,
13
- `4. ${plan.background ? `${tr('prompt.bg')}\n ${c.servicePlan}\n ${c.serviceInstall}\n ${c.serviceStatus}` : `${tr('prompt.fg')}${plan.controlOrigin ? '' : `\n ${c.foreground}`}`}`,
13
+ `4. ${plan.background ? `${tr(plan.os === 'windows' ? 'prompt.windowsBackground' : 'prompt.bg')}\n ${c.servicePlan}\n ${c.serviceInstall}\n ${c.serviceStatus}` : `${tr('prompt.fg')}${plan.controlOrigin ? '' : `\n ${c.foreground}`}`}`,
14
14
  `5. ${plan.controlOrigin ? `${tr('prompt.pairBefore')}\n ${c.pairingPlan}\n ${c.connect}\n ${tr('prompt.pairAfter', { origin: plan.controlOrigin })}` : tr('prompt.local')}`,
15
15
  `6. ${tr('prompt.mcp', { url: plan.url, mcp: plan.mcpUrl })}`,
16
16
  `7. ${tr('prompt.verify')}\n ${tr('prompt.uninstall')}\n ${c.uninstallService}`,
@@ -0,0 +1,49 @@
1
+ import { translate } from './i18n.js';
2
+
3
+ // Instruction illustrations only. Navigation never installs software or confirms a binding.
4
+ export function installationSteps({ method = 'agent', mode = 'local', pairingMethod = 'browser', os = 'posix' } = {}) {
5
+ if (!['agent', 'manual'].includes(method) || !['local', 'official', 'self-hosted'].includes(mode) || !['posix', 'windows'].includes(os) || !['browser', 'headless'].includes(pairingMethod)) throw new Error('invalid_installation_walkthrough');
6
+ const manual = method === 'manual', cloud = mode !== 'local', headless = manual && pairingMethod === 'headless';
7
+ return [
8
+ { tab: 'walk.pasteTab', title: manual ? os === 'windows' ? 'walk.windowsTitle' : 'walk.terminalTitle' : 'walk.agentTitle', body: manual ? os === 'windows' ? 'walk.windowsBody' : 'walk.terminalBody' : 'walk.agentBody', picture: manual ? 'terminal' : 'chat' },
9
+ { tab: cloud ? 'walk.bindTab' : 'walk.installTab', title: cloud ? headless ? 'walk.headlessTitle' : 'walk.browserTitle' : 'walk.installTitle', body: cloud ? headless ? 'walk.headlessBody' : 'walk.browserBody' : manual ? 'walk.installBody' : 'walk.agentInstallBody', picture: cloud ? headless ? 'headless' : 'pair' : 'install' },
10
+ { tab: 'walk.checkTab', title: 'walk.checkTitle', body: cloud ? 'walk.cloudCheckBody' : 'walk.localCheckBody', picture: 'dashboard' },
11
+ ];
12
+ }
13
+
14
+ const escape = value => String(value).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
15
+ const arrows = '<svg viewBox="0 0 32 20" aria-hidden="true"><path d="M2 10h26M20 3l8 7-8 7"/></svg>';
16
+ const clipboard = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M8 5H5v16h14V5h-3M8 3h8v5H8zM8 12h8m-8 4h5"/></svg>';
17
+ const pointer = '<svg class="picture-pointer" viewBox="0 0 30 36" aria-hidden="true"><path d="m4 3 21 18-10 1-5 10z"/></svg>';
18
+
19
+ export function installWalkthroughMarkup({ method = 'agent', mode = 'local', pairingMethod = 'browser', os = 'posix', step = 0, copied = false, controlOrigin = '', localUrl = 'http://127.0.0.1:5174', locale = 'zh-CN' } = {}) {
20
+ const steps = installationSteps({ method, mode, pairingMethod, os });
21
+ if (!Number.isInteger(step) || step < 0 || step >= steps.length) throw new Error('invalid_installation_step');
22
+ const tr = (key, values) => escape(translate(locale, key, values));
23
+ const active = steps[step], cloud = mode !== 'local';
24
+ const window = (title, contents, type = '') => `<div class="picture-window ${type}"><div class="picture-titlebar"><i></i><i></i><i></i><span>${title}</span></div>${contents}</div>`;
25
+ const paste = type => `<div class="picture-transfer"><span>${clipboard}${tr('walk.copiedContent')}</span>${arrows}<span>${tr(type === 'terminal' ? 'walk.terminal' : 'walk.chat')}</span></div>`;
26
+ const fingerprint = `<div class="picture-fingerprints"><span>${tr('walk.terminal')}</span><code>${tr('walk.fullFingerprint')}</code><b>=</b><span>${tr('walk.browser')}</span><code>${tr('walk.fullFingerprint')}</code></div>`;
27
+ const approval = `<div class="picture-approval">${tr('walk.approve')}${pointer}</div>`;
28
+ const browserTitle = `<span class="picture-address">${escape(controlOrigin || 'https://devices.example.com')}</span>`;
29
+ let picture;
30
+ if (active.picture === 'terminal') {
31
+ picture = `${paste('terminal')}${window(os === 'windows' ? 'Windows PowerShell' : tr('walk.targetTerminal'), `<div class="picture-terminal"><div class="picture-paste-line"><span>${os === 'windows' ? 'PS&gt;' : '$'}</span><div>${tr('walk.pasteScript')}<i class="picture-caret"></i></div>${pointer}</div><div class="picture-keyline"><span>${os === 'windows' ? 'Windows <kbd>Ctrl V</kbd>' : 'macOS <kbd>⌘ V</kbd> · Linux <kbd>Ctrl Shift V</kbd>'}</span><strong>${tr('walk.pressEnter')} <kbd>Enter ↵</kbd></strong></div></div>`, 'terminal-picture')}<div class="picture-launch">${os === 'windows' ? `<span>${tr('walk.openWindows')}</span>` : `<span>${tr('walk.openMac')}</span><span>${tr('walk.openLinux')}</span>`}</div>`;
32
+ } else if (active.picture === 'chat') {
33
+ picture = `${paste('chat')}${window(tr('walk.targetChat'), `<div class="picture-chat"><div class="picture-chat-message">${tr('walk.pastePrompt')}<i class="picture-caret"></i></div><div class="picture-send"><span>${tr('walk.send')}</span><b>↑</b>${pointer}</div></div>`, 'chat-picture')}`;
34
+ } else if (active.picture === 'pair' || active.picture === 'headless') {
35
+ const headless = active.picture === 'headless';
36
+ const transfer = headless ? `<div class="picture-transfer headless-transfer"><span>${tr('walk.targetTerminal')}<code>${tr('walk.pairCode')}</code></span>${arrows}<span>${tr('walk.otherBrowser')}</span></div>` : `<div class="picture-auto-open">${tr('walk.autoBrowser')}</div>`;
37
+ picture = `${transfer}${window(browserTitle, `<div class="picture-pair"><div class="picture-signin"><span>01</span>${tr('walk.signIn')}</div>${headless ? `<div class="picture-code-entry"><span>${tr('walk.openPairing', { action: translate(locale, 'panel.434') })}</span><div>${tr('walk.pasteCode')}${pointer}</div></div>` : ''}<div class="picture-signin"><span>02</span>${tr('walk.compare')}</div>${fingerprint}${approval}</div>`, 'browser-picture')}`;
38
+ } else if (active.picture === 'install') {
39
+ picture = window(tr('walk.targetTerminal'), `<div class="picture-install"><div class="picture-install-command"><span>$</span> device-center</div><div class="picture-install-row"><span>01</span>${tr('walk.driver')}</div><div class="picture-install-row"><span>02</span>${tr('walk.service')}</div>${method === 'agent' ? `<div class="picture-install-row"><span>03</span>${tr('walk.mcp')}</div>` : ''}<div class="picture-install-track"><i></i><i></i><i></i></div></div>`, 'terminal-picture');
40
+ } else {
41
+ picture = `${!cloud ? `<div class="picture-auto-open">${tr('walk.sameComputer')}</div>` : ''}${window(`<span class="picture-address">${escape(cloud ? controlOrigin || 'https://devices.example.com' : localUrl)}</span>`, `<div class="picture-dashboard"><div class="picture-device"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="3" width="18" height="13" rx="2"/><path d="M1 21h22M8 16l-1 5m9-5 1 5"/></svg><strong>${tr('walk.yourDevice')}</strong><span>${tr('walk.awaitReport')}</span></div><div class="picture-metrics"><div><span>${tr('panel.8')}</span><b>— / —</b><i></i></div><div><span>${tr('panel.9')}</span><b>— / —</b><i></i></div></div><div class="picture-report-time">${tr('walk.reportTime')}${pointer}</div></div>`, 'browser-picture')}`;
42
+ }
43
+ return `<ol class="walkthrough-tabs" aria-label="${tr('walk.steps')}">${steps.map((item, index) => `<li><button type="button" data-walk-step="${index}" ${index === step ? 'aria-current="step"' : ''}><span>0${index + 1}</span>${tr(item.tab)}</button></li>`).join('')}</ol>
44
+ <div class="walkthrough-caption" aria-live="polite"><span>${tr(active.title)}</span><small>${tr('walk.counter', { current: step + 1, total: steps.length })}</small></div>
45
+ <div class="walkthrough-picture" role="img" aria-label="${tr(active.title)}. ${tr(active.body)}">${picture}</div>
46
+ <p class="walkthrough-description">${tr(active.body)}</p>
47
+ ${step === 0 && !copied ? `<p class="walkthrough-copy-first">${tr('walk.copyFirst')}</p>` : ''}
48
+ <div class="walkthrough-navigation"><button type="button" data-walk-step="${step - 1}" ${step === 0 ? 'disabled' : ''}>${tr('walk.previous')}</button><button type="button" data-walk-step="${step === 2 ? 0 : step + 1}">${tr(step === 2 ? 'walk.restart' : 'walk.next')}</button></div>`;
49
+ }
@@ -0,0 +1,37 @@
1
+ // A successful POST alone does not prove that the browser saved the panel cookie.
2
+ // This read-only probe keeps a missing session from becoming a navigation loop.
3
+ const NAVIGATION_KEY = 'device-center-login-navigation';
4
+ const storage = () => { try { return window.sessionStorage; } catch { return null; } };
5
+ export function clearLoginNavigation({ store = storage() } = {}) { try { store.removeItem(NAVIGATION_KEY); } catch { /* Optional storage. */ } }
6
+ export function rememberLoginNavigation({ store = storage(), now = Date.now() } = {}) { try { store.setItem(NAVIGATION_KEY, String(now)); } catch { /* The session probe still applies. */ } }
7
+ export function pendingLoginNavigation({ store = storage(), now = Date.now() } = {}) {
8
+ try {
9
+ const raw = store.getItem(NAVIGATION_KEY), at = Number(raw);
10
+ if (raw && Number.isSafeInteger(at) && at <= now && now - at < 60_000) return true;
11
+ } catch { /* Only a timestamp is stored, never an identity, token or destination. */ }
12
+ clearLoginNavigation({ store }); return false;
13
+ }
14
+
15
+ async function checkedFetch(fetchImpl, path, signal, method = 'GET') {
16
+ const response = await fetchImpl(path, { method, credentials: 'same-origin', cache: 'no-store', redirect: 'error', signal,
17
+ ...(method === 'POST' ? { headers: { 'content-type': 'application/json' }, body: '{}' } : {}) });
18
+ if (![200, 401].includes(response.status)) throw new Error(response.status === 403 ? 'login.owner' : response.status === 429 ? 'login.rate' : 'login.unavailable');
19
+ return response;
20
+ }
21
+
22
+ export async function continueSession({ fetchImpl = fetch, signal = AbortSignal.timeout(20_000) } = {}) {
23
+ try {
24
+ const existing = await checkedFetch(fetchImpl, '/api/auth/session', signal);
25
+ if (existing.status === 200 && (await existing.json()).authenticated === true) return;
26
+ if (existing.status !== 401) throw new Error('login.unavailable');
27
+ const resumed = await checkedFetch(fetchImpl, '/api/auth/continue', signal, 'POST');
28
+ if (resumed.status === 401) throw new Error('login.first');
29
+ if ((await resumed.json()).ok !== true) throw new Error('login.unavailable');
30
+ const session = await checkedFetch(fetchImpl, '/api/auth/session', signal);
31
+ if (session.status === 401) throw new Error('login.session');
32
+ if ((await session.json()).authenticated !== true) throw new Error('login.unavailable');
33
+ } catch (cause) {
34
+ if (cause.message?.startsWith('login.')) throw cause;
35
+ throw new Error(cause.name === 'TimeoutError' ? 'login.timeout' : 'login.unavailable');
36
+ }
37
+ }
package/src/login.css CHANGED
@@ -11,7 +11,9 @@ input:focus { outline: 2px solid #769e5e; outline-offset: 2px; }
11
11
  button { width: 100%; height: 42px; border: 0; border-radius: 8px; color: white; background: #3c622a; font: inherit; cursor: pointer; }
12
12
  button:disabled { opacity: .6; }
13
13
  #message, #unified-message { min-height: 20px; color: #a53939; }
14
- .login-link { display: grid; place-items: center; min-height: 44px; margin-top: 24px; border-radius: 8px; color: white; background: #3c622a; text-decoration: none; font-size: 14px; }
14
+ .login-link { display: grid; place-items: center; min-height: 44px; margin-top: 16px; border: 1px solid #cfd7cb; border-radius: 8px; color: #3c622a; text-decoration: none; font-size: 14px; }
15
+ .login-link:focus-visible { outline: 2px solid #769e5e; outline-offset: 2px; }
16
+ #unified[aria-busy="true"] #unified-message { color: #667363; }
15
17
  .help { line-height: 1.7; margin: 20px 0; }
16
18
  small { color: #788473; }
17
19
 
package/src/login.js CHANGED
@@ -1,4 +1,9 @@
1
1
  import { initLanguage, getLocale, t } from './i18n.js';
2
+ import { loginDestination } from './pairing-handoff.js';
3
+ import { continueSession, rememberLoginNavigation, pendingLoginNavigation } from './login-flow.js';
4
+ if (location.pathname === '/pair') {
5
+ await import('./pair.js');
6
+ } else {
2
7
  initLanguage();
3
8
  function setMessage(element, key) { if (key) element.dataset.i18n = key; else delete element.dataset.i18n; element.textContent = key ? t(key) : ''; }
4
9
  const form = document.querySelector('#login');
@@ -9,16 +14,16 @@ async function continueUnified(manual = false) {
9
14
  checking = true;
10
15
  const message = document.querySelector('#unified-message'), button = document.querySelector('#continue-login');
11
16
  button.disabled = true;
17
+ document.querySelector('#unified').setAttribute('aria-busy', 'true');
18
+ setMessage(message, 'login.checking');
12
19
  try {
13
- const response = await fetch('/api/auth/continue', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}', signal: AbortSignal.timeout(10_000) });
14
- if (response.ok) { clearInterval(timer); location.replace('/?lang=' + getLocale()); return; }
15
- if (response.status === 401) setMessage(message, manual ? 'login.first' : null);
16
- else {
17
- clearInterval(timer);
18
- setMessage(message, response.status === 403 ? 'login.owner' : response.status === 429 ? 'login.rate' : 'login.unavailable');
19
- }
20
- } catch { setMessage(message, 'login.timeout'); clearInterval(timer); }
21
- finally { checking = false; button.disabled = false; }
20
+ await continueSession();
21
+ clearInterval(timer); rememberLoginNavigation(); location.replace(loginDestination(getLocale()));
22
+ } catch (cause) {
23
+ setMessage(message, cause.message === 'login.first' && !manual ? null : cause.message);
24
+ if (cause.message !== 'login.first') { clearInterval(timer); watchingUntil = 0; }
25
+ }
26
+ finally { checking = false; button.disabled = false; document.querySelector('#unified').setAttribute('aria-busy', 'false'); }
22
27
  }
23
28
  document.querySelector('#continue-login').addEventListener('click', () => continueUnified(true));
24
29
  document.querySelector('#unified-login').addEventListener('click', () => {
@@ -40,7 +45,9 @@ try {
40
45
  if (target.protocol !== 'https:' || target.username || target.password) throw new Error();
41
46
  document.querySelector('#unified-login').href = target.href;
42
47
  document.querySelector('#unified').hidden = false;
43
- if (options.automaticContinue) void continueUnified();
48
+ if (new URLSearchParams(location.search).get('reason') === 'unavailable') setMessage(document.querySelector('#unified-message'), 'login.unavailable');
49
+ else if (pendingLoginNavigation()) setMessage(document.querySelector('#unified-message'), 'login.returned');
50
+ else if (options.automaticContinue) void continueUnified();
44
51
  } else if (options.provider === 'password') { form.hidden = false; form.elements.username.focus(); }
45
52
  else throw new Error();
46
53
  } catch { loading.hidden = false; setMessage(loading, 'login.optionsError'); }
@@ -53,7 +60,9 @@ form.addEventListener('submit', async (event) => {
53
60
  const data = Object.fromEntries(new FormData(form));
54
61
  const response = await fetch('/api/auth/login', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(data), signal: AbortSignal.timeout(10_000) });
55
62
  if (!response.ok) throw new Error(response.status === 429 ? 'login.rate' : 'login.credentials');
56
- form.reset(); location.replace('/?lang=' + getLocale());
63
+ form.reset(); location.replace(loginDestination(getLocale()));
57
64
  } catch (cause) { setMessage(message, cause.name === 'TimeoutError' ? 'login.timeout' : cause.message.startsWith('login.') ? cause.message : 'login.unavailable'); }
58
65
  finally { button.disabled = false; }
59
66
  });
67
+
68
+ }