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
package/src/main.js CHANGED
@@ -12,7 +12,7 @@ Object.assign(state, { pairCode: '', pairing: null, pairBusy: false, pairError:
12
12
  Object.assign(state, { cloudChoice: 'official', customOrigin: '', logManager: '', logTarget: '', logPath: '', logLabel: 'app', remotePeers: [], remoteLoading: false, remoteError: null });
13
13
  let cloudRefreshTimer, cloudRefreshBusy = false, cloudRefreshFailures = 0;
14
14
  Object.assign(state, { profileDevice: null, profileDraft: null, profileError: null, profileSaving: false, collectingDevice: null, pendingCollections: {} });
15
- Object.assign(state, { installMode: 'local', installOrigin: '', installBackground: true });
15
+ Object.assign(state, { installMode: 'local', installOs: 'posix', installOrigin: '', installBackground: true });
16
16
  const RELAY_PROVIDERS = [
17
17
  { id: 'self-hosted', get name() { return t("panel.1"); }, get description() { return t("panel.2"); } },
18
18
  { id: 'managed', get name() { return t("panel.3"); }, get description() { return t("panel.4"); } },
@@ -234,12 +234,17 @@ function openAgentInstall() {
234
234
  state.toast = null; state.dialog = 'agent-install';
235
235
  }
236
236
  function installDraft() {
237
- try { return { text: agentInstallPrompt({ locale: getLocale(), mode: state.installMode, background: state.installBackground, ...(state.installMode === 'self-hosted' ? { origin: state.installOrigin.trim() } : {}) }), error: null }; }
237
+ try { return { text: agentInstallPrompt({ locale: getLocale(), mode: state.installMode, os: state.installOs, background: state.installBackground, ...(state.installMode === 'self-hosted' ? { origin: state.installOrigin.trim() } : {}) }), error: null }; }
238
238
  catch (error) { return { text: '', error: t(error.translationKey ?? 'error.install') }; }
239
239
  }
240
+ function manualInstallHref(draft) {
241
+ const manualParams = new URLSearchParams({ lang: getLocale(), method: 'manual', mode: state.installMode, os: state.installOs });
242
+ if (state.installMode === 'self-hosted' && !draft.error) manualParams.set('origin', state.installOrigin.trim());
243
+ return `/start?${manualParams}#install-title`;
244
+ }
240
245
  function agentInstallBody() {
241
246
  const draft = installDraft();
242
- return `<p class="modal-intro">${h("panel.219")}</p><div class="method-tabs install-mode-tabs" role="group" aria-label="${h("panel.220")}">${[['local', t("panel.210")], ['official', t("panel.211")], ['self-hosted', t("panel.212")]].map(([mode, label]) => `<button data-action="install-mode" data-mode="${mode}" aria-pressed="${state.installMode === mode}" class="${state.installMode === mode ? 'active' : ''}">${label}</button>`).join('')}</div><p class="small-note">${state.installMode === 'local' ? t("panel.213") : state.installMode === 'official' ? t("panel.214") : t("panel.215")}</p>${state.installMode === 'self-hosted' ? `<label class="field-label" for="install-origin">${h("panel.216")}</label><input id="install-origin" data-field="installOrigin" type="url" value="${escapeHtml(state.installOrigin)}" placeholder="https://devices.example.com" autocomplete="off" spellcheck="false">` : ''}<label class="binding-scope"><input type="checkbox" data-field="installBackground" ${state.installBackground ? 'checked' : ''}>${h("panel.221")}</label><ol class="install-flow"><li>${h("panel.222")}</li><li>${state.installMode === 'local' ? t("panel.217") : t("panel.218")}</li><li>${h("panel.223")}</li></ol><p id="install-error" class="form-error" role="alert" ${draft.error ? '' : 'hidden'}>${escapeHtml(draft.error ?? '')}</p><button class="button button-primary install-copy" data-action="agent-install-copy" ${draft.error ? 'disabled' : ''}>${icon('copy', 15)} ${h("panel.224")}</button><details class="install-details"><summary>${h("panel.225")}</summary><textarea id="agent-install-text" aria-label="${h("panel.226")}" readonly rows="7" spellcheck="false">${escapeHtml(draft.text)}</textarea></details><p class="small-note">${h("panel.227")}</p>`;
247
+ return `<p class="modal-intro">${h("panel.219")}</p><label class="field-label" for="install-os">${h("guide.os.title")}</label><select id="install-os" data-field="installOs"><option value="posix" ${state.installOs === "posix" ? "selected" : ""}>macOS / Linux</option><option value="windows" ${state.installOs === "windows" ? "selected" : ""}>Windows 11</option></select><p class="small-note">${h("guide.windowsCandidate")}</p><p class="small-note"><a data-manual-install href="${escapeHtml(manualInstallHref(draft))}">${h('guide.manual.openGuide')}</a></p><div class="method-tabs install-mode-tabs" role="group" aria-label="${h("panel.220")}">${[['local', t("panel.210")], ['official', t("panel.211")], ['self-hosted', t("panel.212")]].map(([mode, label]) => `<button data-action="install-mode" data-mode="${mode}" aria-pressed="${state.installMode === mode}" class="${state.installMode === mode ? 'active' : ''}">${label}</button>`).join('')}</div><p class="small-note">${state.installMode === 'local' ? t("panel.213") : state.installMode === 'official' ? t("panel.214") : t("panel.215")}</p>${state.installMode === 'self-hosted' ? `<label class="field-label" for="install-origin">${h("panel.216")}</label><input id="install-origin" data-field="installOrigin" type="url" value="${escapeHtml(state.installOrigin)}" placeholder="https://devices.example.com" autocomplete="off" spellcheck="false">` : ''}<label class="binding-scope"><input type="checkbox" data-field="installBackground" ${state.installBackground ? 'checked' : ''}>${h("panel.221")}</label><ol class="install-flow"><li>${h("panel.222")}</li><li>${state.installMode === 'local' ? t("panel.217") : t("panel.218")}</li><li>${h("panel.223")}</li></ol><p id="install-error" class="form-error" role="alert" ${draft.error ? '' : 'hidden'}>${escapeHtml(draft.error ?? '')}</p><button class="button button-primary install-copy" data-action="agent-install-copy" ${draft.error ? 'disabled' : ''}>${icon('copy', 15)} ${h("panel.224")}</button><details class="install-details"><summary>${h("panel.225")}</summary><textarea id="agent-install-text" aria-label="${h("panel.226")}" readonly rows="7" spellcheck="false">${escapeHtml(draft.text)}</textarea></details><p class="small-note">${h("panel.227")}</p>`;
243
248
  }
244
249
  async function copyAgentInstall() {
245
250
  const field = root.querySelector('#agent-install-text');
@@ -306,7 +311,7 @@ function cloudConnectBody() {
306
311
  function logCommandDraft() {
307
312
  const peers = cloudMode() ? state.overview?.drivers?.filter(p=>p.revokedAt == null) ?? [] : state.remotePeers;
308
313
  const manager = peers.find(p=>p.id === state.logManager), target = peers.find(p=>p.id === state.logTarget);
309
- try { return { ...logAccessCommands({manager:manager?.fingerprint,target:target?.fingerprint,path:state.logPath,label:state.logLabel}), error:null }; }
314
+ try { return { ...logAccessCommands({manager:manager?.fingerprint,target:target?.fingerprint,path:state.logPath,label:state.logLabel,os:/Windows/i.test(target?.platform ?? '')?'windows':'posix',managerOs:/Windows/i.test(manager?.platform ?? '')?'windows':'posix'}), error:null }; }
310
315
  catch(error) { return {trust:'',grant:'',error:t(error.translationKey ?? 'error.devices')}; }
311
316
  }
312
317
  function logCommandFields() {
@@ -319,7 +324,7 @@ function logCommandFields() {
319
324
  return `<div class="plain-panel"><h3>${h("panel.319")}</h3><label class="field-label" for="log-manager">${h("panel.320")}</label><select id="log-manager" data-log-field="logManager">${options(state.logManager)}</select><label class="field-label" for="log-target">${h("panel.321")}</label><select id="log-target" data-log-field="logTarget">${options(state.logTarget)}</select><label class="field-label" for="log-path">${h("panel.322")}</label><input id="log-path" data-log-field="logPath" value="${escapeHtml(state.logPath)}" placeholder="/home/me/app.log" autocomplete="off" spellcheck="false"><label class="field-label" for="log-label">${h("panel.323")}</label><input id="log-label" data-log-field="logLabel" value="${escapeHtml(state.logLabel)}" maxlength="40"><p id="log-command-error" class="small-note">${escapeHtml(draft.error??t("panel.318"))}</p><label class="field-label" for="log-trust-command">${h("panel.324")}</label><textarea id="log-trust-command" readonly rows="2">${escapeHtml(draft.trust)}</textarea><button class="button button-light" data-action="log-command-copy" data-field-id="log-trust-command" ${draft.error?'disabled':''}>${h("panel.325")}</button><label class="field-label" for="log-grant-command">${h("panel.326")}</label><textarea id="log-grant-command" readonly rows="3">${escapeHtml(draft.grant)}</textarea><button class="button button-primary" data-action="log-command-copy" data-field-id="log-grant-command" ${draft.error?'disabled':''}>${h("panel.327")}</button><p class="small-note">${h("panel.328")}</p></div>`;
320
325
  }
321
326
  function secureHelpBody() {
322
- return `<p class="modal-intro">${h("panel.329")}</p>${logCommandFields()}<ol class="simple-steps"><li>${h("panel.330")} <code>npm install -g device-center@0.2.0-beta.3</code>${h("panel.331")} <code>device-center access enable --confirm</code>.</li><li>${h("panel.332")} <code>device-center access peers</code>${h("panel.333")}</li><li>${h("panel.334")}</li></ol><details open><summary>${h("panel.324")}</summary><code class="block-code">${h("panel.335")}</code></details><details open><summary>${h("panel.326")}</summary><code class="block-code">${h("panel.336")}</code></details><p>${h("panel.337")} <code>device_center_read_log</code>${h("panel.338")}</p><details><summary>${h("panel.339")}</summary><p>${h("panel.340")} <code>device-center access listen --bind 192.168.1.10 --port 5181 --confirm</code>${h("panel.341")} <code>--lan 192.168.1.10 --port 5181</code>${h("panel.342")}</p><p>${h("panel.343")}</p></details><p class="small-note">${h("panel.344")} <code>${h("panel.345")}</code> ${h("panel.346")}</p>`;
327
+ return `<p class="modal-intro">${h("panel.329")}</p>${logCommandFields()}<ol class="simple-steps"><li>${h("panel.330")} <code>npm install -g device-center@0.2.0-beta.4</code>${h("panel.331")} <code>device-center access enable --confirm</code>.</li><li>${h("panel.332")} <code>device-center access peers</code>${h("panel.333")}</li><li>${h("panel.334")}</li></ol><details open><summary>${h("panel.324")}</summary><code class="block-code">${h("panel.335")}</code></details><details open><summary>${h("panel.326")}</summary><code class="block-code">${h("panel.336")}</code></details><p>${h("panel.337")} <code>device_center_read_log</code>${h("panel.338")}</p><details><summary>${h("panel.339")}</summary><p>${h("panel.340")} <code>device-center access listen --bind 192.168.1.10 --port 5181 --confirm</code>${h("panel.341")} <code>--lan 192.168.1.10 --port 5181</code>${h("panel.342")}</p><p>${h("panel.343")}</p></details><p class="small-note">${h("panel.344")} <code>${h("panel.345")}</code> ${h("panel.346")}</p>`;
323
328
  }
324
329
  function cloudBindingBody() {
325
330
  const pairing = state.pairing, disabled = state.pairBusy ? 'disabled' : '';
@@ -327,9 +332,9 @@ function cloudBindingBody() {
327
332
  const bindings = state.overview?.drivers ?? [];
328
333
  return `<p class="modal-intro">${h("panel.365")}</p>
329
334
  <section class="binding-step"><h3><span>1</span> ${h("panel.366")}</h3><p>${h("panel.367")}</p><details><summary>${h("panel.368")}</summary><a class="button button-light" href="/api/driver/download" download>${h("panel.369")}</a><p>${h("panel.370")} <code>npm ci --ignore-scripts</code>${h("panel.371")} <code>${escapeHtml(sourceCommand)}</code>${h("panel.372")}</p></details></section>
330
- <section class="binding-step"><h3><span>2</span> ${h("panel.373")}</h3><label class="binding-scope"><input id="request-ssh" type="checkbox" data-field="requestSsh" ${state.requestSsh ? 'checked' : ''} ${disabled}>${h("panel.374")}</label><textarea id="driver-command" readonly rows="3" spellcheck="false" aria-label="${h("panel.375")}">${escapeHtml(driverCommand())}</textarea><button class="button button-primary" data-action="driver-copy">${icon('copy', 15)} ${h("panel.309")}</button><p>${h("panel.376")}</p></section>
331
- <section class="binding-step"><h3><span>3</span> ${h("panel.377")}</h3><div class="pair-code-row"><label for="pair-code" class="sr-only">${h("panel.378")}</label><input id="pair-code" data-field="pairCode" autocomplete="off" spellcheck="false" maxlength="9" placeholder="XXXX-XXXX" value="${escapeHtml(state.pairCode)}" ${disabled}><button class="button button-light" data-action="pair-lookup" ${disabled}>${state.pairBusy ? t("panel.347") : t("panel.348")}</button></div>${state.pairError ? `<p class="form-error" role="alert">${escapeHtml(t(state.pairError))}</p>` : ''}
332
- ${pairing ? `<div class="pair-review"><strong>${escapeHtml(pairing.name)}</strong><p>${escapeHtml(pairing.platform)} ${h("panel.350")} ${escapeHtml(new Date(pairing.expiresAt).toLocaleTimeString(getLocale()))}</p><label class="field-label">${h("panel.351")}</label><code class="fingerprint">${escapeHtml(pairing.fingerprint)}</code><p>${h("panel.352")}</p>${pairing.requestedSsh ? `<label class="binding-scope"><input id="pair-share" type="checkbox" data-field="pairShare" ${state.pairShare ? 'checked' : ''} ${disabled}>${h("panel.349")}</label>` : ''}<button class="button button-primary" data-action="pair-approve" ${disabled}>${h("panel.353")}</button></div>` : `<p class="small-note">${h("panel.354")}</p>`}</section>
335
+ <section class="binding-step"><h3><span>2</span> ${h("panel.373")}</h3><label class="binding-scope"><input id="request-ssh" type="checkbox" data-field="requestSsh" ${state.requestSsh ? 'checked' : ''} ${disabled}>${h("panel.374")}</label><textarea id="driver-command" readonly rows="3" spellcheck="false" aria-label="${h("panel.375")}">${escapeHtml(driverCommand())}</textarea><button class="button button-primary" data-action="driver-copy">${icon('copy', 15)} ${h("panel.309")}</button><p>${h("pair.browserHint")}</p></section>
336
+ <details class="binding-step" ${pairing || state.pairError ? 'open' : ''}><summary>${h("pair.manual")}</summary><p>${h("pair.manualHint")}</p><textarea id="manual-driver-command" readonly rows="3" spellcheck="false" aria-label="${h("panel.375")}">${escapeHtml(driverCommand().replace(' --browser', ''))}</textarea><button class="button button-light" data-action="driver-manual-copy">${h("panel.309")}</button><h3><span>3</span> ${h("panel.377")}</h3><div class="pair-code-row"><label for="pair-code" class="sr-only">${h("panel.378")}</label><input id="pair-code" data-field="pairCode" autocomplete="off" spellcheck="false" maxlength="9" placeholder="XXXX-XXXX" value="${escapeHtml(state.pairCode)}" ${disabled}><button class="button button-light" data-action="pair-lookup" ${disabled}>${state.pairBusy ? t("panel.347") : t("panel.348")}</button></div>${state.pairError ? `<p class="form-error" role="alert">${escapeHtml(t(state.pairError))}</p>` : ''}
337
+ ${pairing ? `<div class="pair-review"><strong>${escapeHtml(pairing.name)}</strong><p>${escapeHtml(pairing.platform)} ${h("panel.350")} ${escapeHtml(new Date(pairing.expiresAt).toLocaleTimeString(getLocale()))}</p><label class="field-label">${h("panel.351")}</label><code class="fingerprint">${escapeHtml(pairing.fingerprint)}</code><p>${h("panel.352")}</p>${pairing.requestedSsh ? `<label class="binding-scope"><input id="pair-share" type="checkbox" data-field="pairShare" ${state.pairShare ? 'checked' : ''} ${disabled}>${h("panel.349")}</label>` : ''}<button class="button button-primary" data-action="pair-approve" ${disabled}>${h("panel.353")}</button></div>` : `<p class="small-note">${h("panel.354")}</p>`}</details>
333
338
  ${bindings.length ? `<details class="binding-management" open><summary>${h("count.active", { count: bindings.filter(item => item.revokedAt == null).length })}</summary>${bindings.map(item => `<div class="binding-row"><div><strong>${escapeHtml(item.name)}</strong><small>${item.revokedAt != null ? t("panel.355") : item.lastSeen == null ? t("panel.356") : `${h("panel.14")} ${formatAgo(item.lastSeen)}`}</small><code class="fingerprint">${escapeHtml(item.fingerprint)}</code></div>${item.revokedAt == null ? state.revokeId === item.id ? `<div class="revoke-confirm"><p>${h("panel.357")}</p><button class="button button-light" data-action="revoke-confirm" data-device="${item.id}" ${disabled}>${h("panel.358")}</button><button class="text-button" data-action="revoke-cancel" ${disabled}>${h("panel.359")}</button></div>` : `<button class="text-button" data-action="revoke-arm" data-device="${item.id}" ${disabled}>${h("panel.360")}</button>` : ''}</div>`).join('')}</details>` : ''}
334
339
  <p class="small-note">${state.overview?.accountMode === 'multi-user' ? t("panel.363") : t("panel.364")}${h("panel.379")}</p>`;
335
340
  }
@@ -350,13 +355,14 @@ async function pairAction(action, id) {
350
355
  } catch (error) { state.pairError = pairErrorText(error.message); }
351
356
  finally { state.pairBusy = false; render(); }
352
357
  }
353
- async function copyDriverCommand() {
354
- const field = root.querySelector(state.dialog === 'cloud-connect' ? '#cloud-connect-command' : '#driver-command'); if (!field || !field.value) return;
358
+ async function copyDriverCommand(manual = false) {
359
+ const selector = manual ? '#manual-driver-command' : state.dialog === 'cloud-connect' ? '#cloud-connect-command' : '#driver-command';
360
+ const field = root.querySelector(selector); if (!field || !field.value) return;
355
361
  field.focus(); field.select(); let copied = false;
356
362
  try { if (navigator.clipboard?.writeText) { await navigator.clipboard.writeText(field.value); copied = true; } } catch { /* manual selection remains available */ }
357
363
  if (!copied) try { copied = document.execCommand('copy'); } catch { /* manual selection */ }
358
364
  toast(copied ? t("panel.390") : t("panel.391"));
359
- if (!copied) { const current = root.querySelector(state.dialog === 'cloud-connect' ? '#cloud-connect-command' : '#driver-command'); current?.focus(); current?.select(); }
365
+ if (!copied) { const current = root.querySelector(selector); current?.focus(); current?.select(); }
360
366
  }
361
367
  function initializationBody() {
362
368
  if (state.sshLoading) return `<p class="modal-intro">${h("panel.392")}</p>`;
@@ -535,7 +541,7 @@ root.addEventListener('click', async (event) => {
535
541
  if (action === 'agent-install-copy') return copyAgentInstall();
536
542
  if (action === 'logout') { try { await api.logout(); location.replace('/login?lang=' + getLocale()); } catch { state.toast = t("panel.491"); render(); } return; }
537
543
  if (cloudMode() && action === 'add') { state.dialog = 'add'; state.toast = null; state.pairError = null; state.revokeId = null; render(); return; }
538
- if (['driver-copy', 'cloud-command-copy'].includes(action)) return copyDriverCommand();
544
+ if (['driver-copy', 'cloud-command-copy', 'driver-manual-copy'].includes(action)) return copyDriverCommand(action === 'driver-manual-copy');
539
545
  if (action === 'secure-help') { state.dialog='secure-help'; state.toast=null; state.remoteError=null; render(); if (!cloudMode()) { state.remoteLoading=true; render(); try { const data=await api.remotePeers(); state.remotePeers=data.peers; state.logManager=data.driverId??''; } catch { state.remoteError=true; } finally { state.remoteLoading=false; render(); } } return; }
540
546
  if (action === 'log-command-copy') { const field=root.querySelector('#'+button.dataset.fieldId); if(!field?.value)return;field.focus();field.select();try {await navigator.clipboard.writeText(field.value);toast(t("panel.492"));}catch{toast(t("panel.493"));} return; }
541
547
  if (action === 'cloud-connect') { state.dialog = 'cloud-connect'; state.toast = null; render(); return; }
@@ -578,6 +584,7 @@ root.addEventListener('click', async (event) => {
578
584
  render();
579
585
  });
580
586
  root.addEventListener('change', (event) => {
587
+ if (event.target.dataset.field === 'installOs') { state.installOs = event.target.value; state.toast = null; render(); return; }
581
588
  if (event.target.dataset.field === 'installBackground') { state.installBackground = event.target.checked; render(); return; }
582
589
  if (event.target.dataset.profileField && state.profileDraft) { state.profileDraft[event.target.dataset.profileField] = event.target.value; return; }
583
590
  if (['requestSsh', 'pairShare'].includes(event.target.dataset.field)) { state[event.target.dataset.field] = event.target.checked; render(); return; }
@@ -594,6 +601,7 @@ root.addEventListener('input', (event) => {
594
601
  root.querySelector('#agent-install-text').value = draft.text;
595
602
  const message = root.querySelector('#install-error'); message.textContent = draft.error ?? ''; message.hidden = !draft.error;
596
603
  root.querySelector('[data-action="agent-install-copy"]').disabled = Boolean(draft.error);
604
+ root.querySelector('[data-manual-install]').href = manualInstallHref(draft);
597
605
  return;
598
606
  }
599
607
  if (event.target.dataset.profileField && state.profileDraft) { state.profileDraft[event.target.dataset.profileField] = event.target.value; return; }