device-center 0.2.0-beta.7 → 0.2.0-beta.8

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.
@@ -1,5 +1,6 @@
1
1
  import { agentInstallPlan, terminalInstallScript, OFFICIAL_CONTROL_ORIGIN } from './onboarding.js';
2
2
  import { translate, getLocale, h } from './i18n.js';
3
+ import { installedCliCommand } from './installed-cli.js';
3
4
 
4
5
  // Public instructions only. No installation, authentication or credential generation here.
5
6
  export function agentSetupPrompt({ role = 'manager', locale = 'zh-CN', ...options } = {}) {
@@ -19,9 +20,8 @@ export function agentSetupPrompt({ role = 'manager', locale = 'zh-CN', ...option
19
20
  export function managerSetupPlan({ origin, os = 'posix', locale = 'zh-CN' } = {}) {
20
21
  const options = { mode: origin === OFFICIAL_CONTROL_ORIGIN ? 'official' : 'self-hosted', ...(origin === OFFICIAL_CONTROL_ORIGIN ? {} : { origin }), os, background: true };
21
22
  const plan = agentInstallPlan(options);
22
- const cli = plan.commands.version.slice(0, -' --version'.length);
23
- const authorization = `${cli} auth login --minutes 60 --confirm`;
24
- const verify = `${cli} devices list --cloud --json`;
23
+ const authorization = installedCliCommand('auth login --minutes 60 --confirm', os);
24
+ const verify = installedCliCommand('devices list --cloud --json', os);
25
25
  const install = terminalInstallScript(options);
26
26
  // Keep the original installer's stop-on-existing-install and error semantics.
27
27
  const script = os === 'windows'
package/src/api.js CHANGED
@@ -4,11 +4,12 @@ async function request(path, { timeoutMs = 12000, ...options } = {}) {
4
4
  const response = await fetch(path, { ...options, cache: 'no-store', signal: AbortSignal.timeout(timeoutMs) });
5
5
  const payload = await response.json().catch(() => ({}));
6
6
  if (response.status === 401) { location.replace('/login?lang=' + getLocale()); throw new Error('authentication_required'); }
7
- if (!response.ok) throw new Error(payload.error ?? `http_${response.status}`);
7
+ if (!response.ok) throw Object.assign(new Error(payload.error ?? `http_${response.status}`), { statusCode: response.status });
8
8
  clearLoginNavigation();
9
9
  return payload;
10
10
  }
11
11
  export const api = {
12
+ browserPairingResult: browserTicket => request('/api/drivers/pairing/result', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ browserTicket }) }),
12
13
  lookupBrowserPairing: browserTicket => request('/api/drivers/pairing/lookup', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ browserTicket }) }),
13
14
  approveBrowserPairing: input => request('/api/drivers/pairing/approve', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(input) }),
14
15
  saveDeviceProfile: (profile) => request('/api/devices/profile', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(profile) }),
@@ -1,6 +1,11 @@
1
1
  import { t } from './i18n.js';
2
2
 
3
3
  const platforms = { windows: 'win32', macos: 'darwin', linux: 'linux' };
4
+ export function architectureChoices(os) {
5
+ const choices = { windows: [['x64', 'x64 (Intel / AMD)'], ['arm64', 'ARM64 (Windows on ARM)']], macos: [['arm64', 'Apple Silicon (ARM64)'], ['x64', 'Intel (x64)']], linux: [['x64', 'x86_64 (Intel / AMD)'], ['arm64', 'ARM64 (aarch64)']] };
6
+ if (!choices[os]) throw new Error('invalid_install_os');
7
+ return choices[os];
8
+ }
4
9
  export function matchingPackages(packages, os, arch) {
5
10
  return packages.filter(p => p.platform === platforms[os] && p.arch === arch &&
6
11
  /^device-center-[\w.-]+$/.test(p.id ?? '') && p.url === `/install/packages/${p.id}` &&
@@ -20,7 +25,10 @@ export function renderPackageChoices(container, { packages, os, arch, loading =
20
25
  const name = document.createElement('p'); name.className = 'hint'; name.textContent = `${item.filename} · ${(item.bytes / 1048576).toFixed(1)} MiB`;
21
26
  const digest = document.createElement('code'); digest.className = 'package-digest'; digest.textContent = `SHA-256: ${item.sha256}`;
22
27
  card.append(link, name, digest);
23
- if (item.candidate) { const notice = document.createElement('p'); notice.className = 'hint'; notice.textContent = t('guide.package.candidate'); card.append(notice); }
28
+ const launch = document.createElement('p'); launch.className = 'hint';
29
+ launch.textContent = t(item.platform === 'darwin' && item.signed && item.notarized ? 'guide.package.macosApp' : item.platform === 'darwin' ? 'guide.package.macos' : item.platform === 'win32' ? 'guide.package.windows' : 'guide.package.linux');
30
+ card.append(launch);
31
+ if (item.candidate) { const notice = document.createElement('p'); notice.className = 'hint'; notice.textContent = t(item.platform === 'darwin' ? 'guide.package.candidate' : 'guide.package.unsigned'); card.append(notice); }
24
32
  container.append(card);
25
33
  }
26
34
  }
@@ -0,0 +1,6 @@
1
+ // Resolve an existing installation on the target machine. Never installs or changes PATH.
2
+ export function installedCliCommand(args, os = 'posix') {
3
+ if (!['posix', 'windows'].includes(os) || typeof args !== 'string' || /[\r\n]/.test(args)) throw new Error('invalid_cli_command');
4
+ if (os === 'windows') return `& { $dcRoot = "$HOME\\AppData\\Local\\Programs\\Device Center\\offline"; $dcNpm = "$HOME\\AppData\\Local\\Programs\\Device Center\\cli\\device-center.cmd"; if ((Test-Path -LiteralPath "$dcRoot\\runtime\\node.exe") -and (Test-Path -LiteralPath "$dcRoot\\app\\bin\\device-center.mjs")) { & "$dcRoot\\runtime\\node.exe" "$dcRoot\\app\\bin\\device-center.mjs" ${args} } elseif (Test-Path -LiteralPath $dcNpm) { & $dcNpm ${args} } elseif (Get-Command device-center.cmd -ErrorAction SilentlyContinue) { & device-center.cmd ${args} } else { throw 'Device Center not found. Open the installer to continue.' }; if ($LASTEXITCODE -ne 0) { throw 'Device Center command failed. Keep the existing installation and inspect the error.' } }`;
5
+ return `( dc_root="$HOME/.local/share/device-center/offline"; if [ -x "$dc_root/runtime/bin/node" ] && [ -f "$dc_root/app/bin/device-center.mjs" ]; then "$dc_root/runtime/bin/node" "$dc_root/app/bin/device-center.mjs" ${args}; elif [ -x "$HOME/.local/bin/device-center" ]; then "$HOME/.local/bin/device-center" ${args}; elif command -v device-center >/dev/null 2>&1; then device-center ${args}; else printf '%s\\n' 'Device Center not found. Open the installer to continue.' >&2; exit 1; fi )`;
6
+ }
package/src/login-flow.js CHANGED
@@ -35,3 +35,38 @@ export async function continueSession({ fetchImpl = fetch, signal = AbortSignal.
35
35
  throw new Error(cause.name === 'TimeoutError' ? 'login.timeout' : 'login.unavailable');
36
36
  }
37
37
  }
38
+
39
+
40
+ const ACCOUNT_NAVIGATION_KEY = 'device-center-account-navigation';
41
+ const accountNavigationAge = 10 * 60_000;
42
+ const validAccountNavigation = (value, now) => value && /^[a-f0-9-]{36}$/.test(value.id ?? '') && Number.isSafeInteger(value.at) && value.at <= now && now - value.at < accountNavigationAge;
43
+ export function clearAccountNavigation({ store = storage() } = {}) { try { store.removeItem(ACCOUNT_NAVIGATION_KEY); } catch { /* No credentials stored. */ } }
44
+ export function pendingAccountNavigation({ store = storage(), now = Date.now() } = {}) {
45
+ try { if (validAccountNavigation(JSON.parse(store.getItem(ACCOUNT_NAVIGATION_KEY)), now)) return true; } catch { /* Optional storage. */ }
46
+ clearAccountNavigation({ store }); return false;
47
+ }
48
+ export function accountLoginDestination(accountUrl, locale, { origin = window.location.origin, search = globalThis.window?.location?.search ?? '', store = storage(), now = Date.now(), requestId = crypto.randomUUID() } = {}) {
49
+ const target = new URL(accountUrl);
50
+ if (target.protocol !== 'https:' || target.username || target.password || target.hash || target.pathname !== '/auth/connect' || target.searchParams.get('product') !== 'device-center' || target.searchParams.get('parentOrigin') !== origin || !validAccountNavigation({ id: requestId, at: now }, now)) throw new Error('login.unavailable');
51
+ const value = JSON.stringify({ id: requestId, at: now });
52
+ try { store.setItem(ACCOUNT_NAVIGATION_KEY, value); if (store.getItem(ACCOUNT_NAVIGATION_KEY) !== value) throw new Error(); }
53
+ catch { throw new Error('login.storage'); }
54
+ // Pairing and CLI proofs stay in this tab's product storage, never in the account URL.
55
+ target.searchParams.set('requestId', requestId);
56
+ const continuation = new URLSearchParams({ lang: locale });
57
+ if (new URLSearchParams(search).get('continue') === 'cli') continuation.set('continue', 'cli');
58
+ target.searchParams.set('path', '/login?' + continuation);
59
+ return target.href;
60
+ }
61
+ export function consumeAccountReturn({ location = window.location, history = window.history, store = storage(), now = Date.now() } = {}) {
62
+ const url = new URL(location.href);
63
+ if (!url.searchParams.has('authReturn')) return null;
64
+ const ids = url.searchParams.getAll('authReturn');
65
+ url.searchParams.delete('authReturn');
66
+ history.replaceState(null, '', url.pathname + url.search);
67
+ let pending;
68
+ try { pending = JSON.parse(store.getItem(ACCOUNT_NAVIGATION_KEY)); } catch { /* Never treat missing storage as verified return. */ }
69
+ const valid = ids.length === 1 && validAccountNavigation(pending, now) && ids[0] === pending.id;
70
+ clearAccountNavigation({ store });
71
+ return Boolean(valid);
72
+ }
package/src/login.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { initLanguage, getLocale, t } from './i18n.js';
2
2
  import { loginDestination } from './pairing-handoff.js';
3
- import { continueSession, rememberLoginNavigation, pendingLoginNavigation } from './login-flow.js';
3
+ import { continueSession, rememberLoginNavigation, pendingLoginNavigation, accountLoginDestination, consumeAccountReturn, pendingAccountNavigation, clearAccountNavigation } from './login-flow.js';
4
4
  if (location.pathname === '/pair') {
5
5
  await import('./pair.js');
6
6
  } else if (location.pathname === '/cli') {
@@ -10,8 +10,13 @@ initLanguage();
10
10
  function setMessage(element, key) { if (key) element.dataset.i18n = key; else delete element.dataset.i18n; element.textContent = key ? t(key) : ''; }
11
11
  const form = document.querySelector('#login');
12
12
  const loading = document.querySelector('#loading');
13
- let checking = false, watchingUntil = 0, timer, options;
14
- async function continueUnified(manual = false) {
13
+ let checking = false, options;
14
+ const accountReturn = consumeAccountReturn();
15
+ function enterAccount() { location.replace(accountLoginDestination(options.accountLoginUrl, getLocale())); }
16
+ function openAccount() {
17
+ try { enterAccount(); } catch (error) { setMessage(document.querySelector('#unified-message'), error.message); }
18
+ }
19
+ async function continueUnified() {
15
20
  if (checking || options?.provider !== 'app-services') return;
16
21
  checking = true;
17
22
  const message = document.querySelector('#unified-message'), button = document.querySelector('#continue-login');
@@ -20,22 +25,14 @@ async function continueUnified(manual = false) {
20
25
  setMessage(message, 'login.checking');
21
26
  try {
22
27
  await continueSession();
23
- clearInterval(timer); rememberLoginNavigation(); location.replace(loginDestination(getLocale()));
28
+ clearAccountNavigation(); rememberLoginNavigation(); location.replace(loginDestination(getLocale()));
24
29
  } catch (cause) {
25
- setMessage(message, cause.message === 'login.first' && !manual ? null : cause.message);
26
- if (cause.message !== 'login.first') { clearInterval(timer); watchingUntil = 0; }
30
+ if (cause.message === 'login.first' && accountReturn === null && !pendingAccountNavigation()) openAccount();
31
+ else setMessage(message, cause.message);
27
32
  }
28
33
  finally { checking = false; button.disabled = false; document.querySelector('#unified').setAttribute('aria-busy', 'false'); }
29
34
  }
30
- document.querySelector('#continue-login').addEventListener('click', () => continueUnified(true));
31
- document.querySelector('#unified-login').addEventListener('click', () => {
32
- watchingUntil = Date.now() + 5 * 60_000;
33
- setMessage(document.querySelector('#unified-message'), 'login.return');
34
- clearInterval(timer);
35
- timer = setInterval(() => { if (Date.now() > watchingUntil) clearInterval(timer); else void continueUnified(); }, 4000);
36
- });
37
- window.addEventListener('focus', () => { if (watchingUntil > Date.now()) void continueUnified(); });
38
- document.addEventListener('visibilitychange', () => { if (!document.hidden && watchingUntil > Date.now()) void continueUnified(); });
35
+ document.querySelector('#continue-login').addEventListener('click', openAccount);
39
36
  try {
40
37
  const response = await fetch('/api/auth/options', { signal: AbortSignal.timeout(5000) });
41
38
  if (!response.ok) throw new Error();
@@ -43,13 +40,11 @@ try {
43
40
  setMessage(document.querySelector('#access-notes'), options.accountMode === 'multi-user' ? 'login.multi' : 'login.personal');
44
41
  loading.hidden = true;
45
42
  if (options.provider === 'app-services') {
46
- const target = new URL(options.accountLoginUrl);
47
- if (target.protocol !== 'https:' || target.username || target.password) throw new Error();
48
- document.querySelector('#unified-login').href = target.href;
49
43
  document.querySelector('#unified').hidden = false;
50
44
  if (new URLSearchParams(location.search).get('reason') === 'unavailable') setMessage(document.querySelector('#unified-message'), 'login.unavailable');
51
- else if (pendingLoginNavigation()) setMessage(document.querySelector('#unified-message'), 'login.returned');
52
- else if (options.automaticContinue) void continueUnified();
45
+ else if (accountReturn === false || (accountReturn !== true && pendingLoginNavigation())) setMessage(document.querySelector('#unified-message'), 'login.returned');
46
+ else if (accountReturn === true || options.automaticContinue) void continueUnified();
47
+ else openAccount();
53
48
  } else if (options.provider === 'password') { form.hidden = false; form.elements.username.focus(); }
54
49
  else throw new Error();
55
50
  } catch { loading.hidden = false; setMessage(loading, 'login.optionsError'); }