sidebud 0.2.0 → 0.5.0

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.
@@ -31,10 +31,35 @@ let libraryConfirmed = false;
31
31
  /** What a tool may do, as the pack pages show it. */
32
32
  const accessPill = (access) => `<span class="pill ${access === 'write' ? 'warn' : ''}">${access === 'write' ? 'changes things' : 'reads'}</span>`;
33
33
 
34
+ /** Whether a pack can control the whole computer; adding or turning one on then needs an explicit OK. */
35
+ const controlsComputer = (overview) => overview?.level === 'computer';
36
+
37
+ /** How much a pack can control, as a badge for lists: red for the whole computer, amber for changes. */
38
+ function accessBadge(overview) {
39
+ if (!overview) return '';
40
+ return `<span class="badge ${{ computer: 'bad', changes: 'warn' }[overview.level] || ''}">${esc(overview.headline)}</span>`;
41
+ }
42
+
43
+ /** The first thing shown about a pack: how much it can control, then each point, most serious first. */
44
+ function accessOverviewHtml(overview) {
45
+ if (!overview) return '';
46
+ return `<div class="card stack access-overview level-${esc(overview.level)}" role="${controlsComputer(overview) ? 'alert' : 'note'}"><div class="row"><h3>What it can control</h3>${accessBadge(overview)}</div><ul class="access-points">${overview.points.map((point) => `<li class="${esc(point.tone)}">${esc(point.text)}</li>`).join('')}</ul></div>`;
47
+ }
48
+
49
+ /** The question asked before a full-control pack is added or turned on. */
50
+ const computerConfirmText = (name, overview) => `${name} can control this whole computer.\n\n${overview.points.filter((point) => point.tone !== 'info').map((point) => `• ${point.text}`).join('\n')}\n\nContinue only if you trust it with everything on this computer.`;
51
+
34
52
  /** Who stands behind a pack, and what installing any pack means. Community packs get a warning. */
35
53
  function trustNoticeHtml(publisher) {
36
- const community = publisher?.source === 'community';
37
- return `<div class="card trust-notice ${community ? 'community' : ''}" role="${community ? 'alert' : 'note'}"><strong>${community ? `Community widget by ${esc(publisher.name)}` : 'Made by Sidebud'}</strong><p>${community ? 'Sidebud did not make or review this widget, and may not support it. It may be unsafe, stop working, or behave differently than described. Install it only if you trust its author, and read what it can do first.' : 'The Sidebud team builds and maintains this widget and checks it before it is listed.'}</p><p class="hint">Every widget runs on this computer with the access listed here. It installs switched off, and your agent sets it up; you choose when to turn it on, and you can turn off any tool.</p></div>`;
54
+ const official = publisher?.official;
55
+ const community = publisher?.source === 'community' && !official;
56
+ const title = official ? `Official widget from ${esc(publisher.name)} (${esc(official.domain)})` : community ? `Community widget by ${esc(publisher.name)}` : 'Made by Sidebud';
57
+ const body = official
58
+ ? `Published by a verified ${esc(official.domain)} account, and reviewed by Sidebud before it was listed.`
59
+ : community
60
+ ? 'Sidebud reviewed this release before listing it, but did not make it and may not support it. It may stop working or behave differently than described. Install it only if you trust its author, and read what it can do first.'
61
+ : 'The Sidebud team builds and maintains this widget and checks it before it is listed.';
62
+ return `<div class="card trust-notice ${community ? 'community' : ''}" role="${community ? 'alert' : 'note'}"><strong>${title}</strong><p>${body}</p><p class="hint">Every widget runs on this computer with the access listed here. It installs switched off, and your agent sets it up; you choose when to turn it on, and you can turn off any tool.</p></div>`;
38
63
  }
39
64
 
40
65
  /** The library pack's page: what it can do, its trust, and its widget on a phone, before anything is installed. */
@@ -46,14 +71,20 @@ function libraryPackView() {
46
71
  if (!page) return '<section class="section"><a href="#mcp">← Integrations</a><div class="card muted">Loading…</div></section>';
47
72
  const a = page.access;
48
73
  const community = page.publisher.source === 'community';
74
+ const computer = controlsComputer(page.overview);
49
75
  const installed = page.installedVersion !== null;
76
+ const confirmText = computer
77
+ ? community
78
+ ? `I trust ${esc(page.publisher.name)} and understand ${esc(page.name)} can control this whole computer`
79
+ : `I understand ${esc(page.name)} can control this whole computer`
80
+ : `I trust ${esc(page.publisher.name)} and have read what this widget can do`;
50
81
  const tools = `<table class="tool-table"><thead><tr><th>Tool</th><th>Access</th><th>Used by</th></tr></thead><tbody>${a.tools.map((tool) => `<tr><td>${code(tool.name)}${tool.description ? `<div class="hint">${esc(tool.description)}</div>` : ''}</td><td>${accessPill(tool.access)}</td><td class="hint">${tool.usedBy.map(esc).join(', ') || 'Your agent, when you ask'}</td></tr>`).join('')}</tbody></table><p class="hint">Your computer only ever calls these tools. After installing, you can turn off any of them on the pack's page; a tool that is off is never called by the widget, a control, or your agent.</p>`;
51
82
  const runs = a.launch.transport === 'stdio' ? `<p>${code(a.launch.command)}</p><p class="hint">${a.launch.ownServer ? 'A small server that comes with this pack, run with Node.js while the pack is on. Its code is in the pack folder.' : 'A program already on this computer, started while the pack is on.'}${a.launch.env.length ? ` It receives ${a.launch.env.map(code).join(', ')}.` : ''}</p>` : `<p>${code(a.launch.url)}</p><p class="hint">A service on the internet this computer connects to. Keys go only to it.</p>`;
52
83
  const keys = `${a.accountSignIn ? '<p>You sign in to an account in this computer\'s browser.</p>' : ''}${a.secrets.map((secret) => `<p>${code(secret.name)} <span class="hint">${esc(secret.description)}</span></p>`).join('')}${!a.accountSignIn && !a.secrets.length ? '<p class="hint">No keys or sign-in.</p>' : ''}<p class="hint">Keys and tokens are saved in this computer's keychain and never shown again.</p>`;
53
84
  const action = installed
54
85
  ? `<a class="button" href="#mcp/${encodeURIComponent(page.id)}">Open its page · installed ${esc(page.installedVersion)}</a>`
55
- : `${community ? `<label class="confirm-line"><input type="checkbox" data-library-confirm ${libraryConfirmed ? 'checked' : ''}> I trust ${esc(page.publisher.name)} and have read what this widget can do</label>` : ''}<button class="primary" data-action="install-library-pack" data-id="${esc(page.slug)}" ${community && !libraryConfirmed ? 'disabled' : ''}>${page.needsReinstall ? 'Reinstall' : 'Install'} ${esc(page.name)}</button>`;
56
- return `<section class="section pack-page"><a href="#mcp">← Integrations</a><div class="pack-page-head"><div><h2>${esc(page.name)}</h2><p class="hint">${esc(page.author)} · ${esc(page.license)} · version ${esc(page.version)}${page.homepage ? ` · <a href="${esc(page.homepage)}" target="_blank" rel="noreferrer">Homepage</a>` : ''}</p><p>${esc(page.description)}</p></div><span class="badge ${community ? 'warn' : ''}">${community ? 'Community' : 'Sidebud'}</span></div>${trustNoticeHtml(page.publisher)}<div class="actions library-install">${action}</div><div class="pack-page-grid"><div class="stack"><div class="card stack"><h3>What it can do on this computer</h3>${tools}</div><div class="card stack"><h3>What it runs</h3>${runs}</div><div class="card stack"><h3>Keys and accounts</h3>${keys}</div><div class="card stack"><h3>Network</h3><p class="hint">${esc(a.network.join(', ') || 'None declared beyond what it runs.')}</p></div>${a.setup.steps.length ? `<div class="card stack"><h3>Setting it up</h3><ol class="plain-steps">${a.setup.steps.map((step) => `<li>${esc(step)}</li>`).join('')}</ol>${a.setup.prerequisites.length ? `<p class="hint">Needs: ${a.setup.prerequisites.map(esc).join(', ')}</p>` : ''}</div>` : ''}</div><aside class="pack-preview"><h3>On your phone</h3><p class="hint">${page.hasPreview ? 'Made-up sample data from the pack. Tap the tile to open it.' : 'This pack has no sample data, so its widget is drawn empty.'}</p><div id="library-preview">${libraryPreviewBody(page)}</div></aside></div></section>`;
86
+ : `${community || computer ? `<label class="confirm-line"><input type="checkbox" data-library-confirm ${libraryConfirmed ? 'checked' : ''}> ${confirmText}</label>` : ''}<button class="primary" data-action="install-library-pack" data-id="${esc(page.slug)}" ${(community || computer) && !libraryConfirmed ? 'disabled' : ''}>${page.needsReinstall ? 'Reinstall' : 'Install'} ${esc(page.name)}</button>`;
87
+ return `<section class="section pack-page"><a href="#mcp">← Integrations</a><div class="pack-page-head"><div><h2>${esc(page.name)}</h2><p class="hint">${esc(page.author)} · ${esc(page.license)} · version ${esc(page.version)}${page.homepage ? ` · <a href="${esc(page.homepage)}" target="_blank" rel="noreferrer">Homepage</a>` : ''}</p><p>${esc(page.description)}</p></div><span class="badge ${community && !page.publisher.official ? 'warn' : ''}">${page.publisher.official ? `Official · ${esc(page.publisher.official.domain)}` : community ? 'Community' : 'Sidebud'}</span></div>${accessOverviewHtml(page.overview)}${trustNoticeHtml(page.publisher)}${page.deprecated ? `<div class="card"><strong>Deprecated</strong><p class="hint">${esc(page.deprecated)}</p></div>` : ''}<div class="actions library-install">${action}</div><div class="pack-page-grid"><div class="stack"><div class="card stack"><h3>Tools it may call</h3>${tools}</div><div class="card stack"><h3>What it runs</h3>${runs}</div><div class="card stack"><h3>Keys and accounts</h3>${keys}</div><div class="card stack"><h3>Network</h3><p class="hint">${esc(a.network.join(', ') || 'None declared beyond what it runs.')}</p></div>${a.setup.steps.length ? `<div class="card stack"><h3>Setting it up</h3><ol class="plain-steps">${a.setup.steps.map((step) => `<li>${esc(step)}</li>`).join('')}</ol>${a.setup.prerequisites.length ? `<p class="hint">Needs: ${a.setup.prerequisites.map(esc).join(', ')}</p>` : ''}</div>` : ''}</div><aside class="pack-preview"><h3>On your phone</h3><p class="hint">${page.hasPreview ? 'Made-up sample data from the pack. Tap the tile to open it.' : 'This pack has no sample data, so its widget is drawn empty.'}</p><div id="library-preview">${libraryPreviewBody(page)}</div></aside></div></section>`;
57
88
  }
58
89
 
59
90
  function libraryPreviewBody(page) {
@@ -137,7 +168,7 @@ function detailHtml(tile, { back = false } = {}) {
137
168
  if (!tile) return '';
138
169
  const blocks = tile.blocks.map((block) => {
139
170
  if (block.type === 'stats') return `<div class="phone-stats">${block.items.map((item) => `<div><strong class="${item.tone === 'neutral' ? '' : tone(item.tone)}">${esc(item.value)}</strong><span class="muted">${esc(item.label)}</span></div>`).join('')}</div>`;
140
- if (block.type === 'list') return `<div class="phone-list">${block.title ? `<span class="muted phone-list-title">${esc(block.title)}</span>` : ''}${block.items.slice(0, 6).map((item) => `<div><div><strong>${esc(item.title)}</strong>${item.subtitle ? `<span class="muted">${esc(item.subtitle)}</span>` : ''}</div><div class="phone-row-end">${item.status ? `<span class="${tone(item.status.tone)}">${esc(item.status.text)}</span>` : ''}${item.time ? `<span class="muted">${esc(relativeTime(item.time))}</span>` : ''}</div></div>`).join('') || `<p class="muted">${esc(block.emptyText || 'Nothing here')}</p>`}${block.items.length > 6 ? `<span class="phone-more">Show ${block.items.length - 6} more</span>` : ''}</div>`;
171
+ if (block.type === 'list') return `<div class="phone-list">${block.title ? `<span class="muted phone-list-title">${esc(block.title)}</span>` : ''}${block.items.slice(0, 6).map((item) => `<div><div><strong>${esc(item.title)}</strong>${item.subtitle ? `<span class="muted">${esc(item.subtitle)}</span>` : ''}</div><div class="phone-row-end">${item.status ? `<span class="${tone(item.status.tone)}">${esc(item.status.text)}</span>` : ''}${item.time ? `<span class="muted">${esc(relativeTime(item.time))}</span>` : ''}</div>${item.button ? `<span class="phone-row-button" title="A tap runs it on the phone">${esc(item.button.label)}</span>` : ''}</div>`).join('') || `<p class="muted">${esc(block.emptyText || 'Nothing here')}</p>`}${block.items.length > 6 ? `<span class="phone-more">Show ${block.items.length - 6} more</span>` : ''}</div>`;
141
172
  if (block.type === 'board') return `<div class="phone-board">${block.title ? `<span class="muted phone-list-title">${esc(block.title)}</span>` : ''}<div class="phone-columns">${block.columns.map((column) => `<div class="phone-column"><div class="phone-column-head"><span class="phone-dot bg-${esc(column.tone)}"></span><strong>${esc(column.title)}</strong><span class="muted">${column.count}</span></div>${column.items.map((item) => `<div class="phone-card"><span>${esc(item.title)}</span>${item.subtitle || item.time ? `<span class="muted">${esc(item.subtitle || '')}${item.subtitle && item.time ? ' · ' : ''}${item.time ? esc(relativeTime(item.time)) : ''}</span>` : ''}</div>`).join('') || '<span class="muted">Nothing here</span>'}${column.count > column.items.length ? `<span class="muted">+${column.count - column.items.length} more</span>` : ''}</div>`).join('')}</div></div>`;
142
173
  if (block.type === 'text') return `<p class="phone-text ${block.tone === 'neutral' ? '' : tone(block.tone)}">${esc(block.text)}</p>`;
143
174
  if (block.type === 'progress') return `<div class="phone-progress"><span>${esc(block.label)}</span><span class="phone-bar"><span data-progress="${Number(block.value) || 0}"></span></span></div>`;
@@ -274,7 +305,7 @@ function packPageView() {
274
305
  const controls = page?.controls.length ? `<ul class="plain-list">${page.controls.map((control) => `<li><strong>${esc(control.label)}</strong> ${control.default ? '<span class="pill">shown by default</span>' : ''} ${control.consequential ? '<span class="pill warn">asks first</span>' : ''}<div class="hint">${control.prompt ? `Says: “${esc(control.prompt)}”` : `Runs ${code(control.tool)}`}</div></li>`).join('')}</ul><p class="hint">Each phone chooses which controls appear on the widget.</p>` : '<p class="hint">No controls.</p>';
275
306
  const setup = page ? `${page.setup.steps.length ? `<ol class="plain-steps">${page.setup.steps.map((step) => `<li>${esc(step)}</li>`).join('')}</ol>` : '<p class="hint">No setup steps.</p>'}${page.setup.prerequisites.length ? `<p class="hint">Needs: ${page.setup.prerequisites.map((item) => `${esc(item.name)}${item.found === null ? '' : item.found ? ' ✓' : ' (not found on this computer)'}`).join(', ')}</p>` : ''}` : '';
276
307
  const runs = page ? `<p>${page.launch.transport === 'stdio' ? `Starts ${code(page.launch.command)}${page.ownServer ? ', the small server this pack ships' : ''}${page.launch.env.length ? ` with ${page.launch.env.map(code).join(', ')} set` : ''}.` : `Connects to ${code(page.launch.url)}.`}</p><p class="hint">Pack folder: ${code(page.folder)}. Keys and tokens are masked here and never leave this computer.</p>` : '';
277
- return `<section class="section pack-page"><a href="#mcp">← Integrations</a><div class="pack-page-head"><div><h2>${esc(p.name)}</h2><p class="hint">${esc(p.author)} · ${esc(p.license)} · version ${esc(p.version)}${page?.homepage ? ` · <a href="${esc(page.homepage)}" target="_blank" rel="noreferrer">Homepage</a>` : ''}</p><p>${esc(p.description)}</p></div><div class="stack"><span class="badge ${statusClass[p.status.state] || ''}">${esc(statusLabel[p.status.state] || p.status.state)}</span>${p.status.detail ? `<p class="hint">${esc(packStatusDetail(p))}</p>` : ''}</div></div><div class="actions"><button data-action="toggle-pack" data-id="${esc(p.id)}" data-enabled="${p.enabled}">${p.enabled ? 'Turn off' : 'Turn on'}</button><button class="primary" data-action="setup-pack" data-id="${esc(p.id)}">${p.status.state === 'ok' ? 'Check setup with my agent' : 'Set up with my agent'}</button><button data-action="check-pack" data-id="${esc(p.id)}" ${p.enabled ? '' : 'disabled'}>Check connection</button><button data-action="export-pack" data-id="${esc(p.id)}">Export</button><button data-action="customize-pack" data-id="${esc(p.id)}">Customize a copy</button><button class="danger" data-action="remove-pack" data-id="${esc(p.id)}">Remove</button></div><div id="pack-editor"></div>${packPageError ? `<div class="card muted">${esc(packPageError)}</div>` : ''}<div class="pack-page-grid"><div class="stack"><div class="card stack"><h3>Settings and keys</h3>${settings || '<p class="hint">No settings.</p>'}${keys}${settings || keys ? `<div class="actions"><button class="primary" data-action="save-pack" data-id="${esc(p.id)}">Save settings</button></div>` : ''}${account}</div><div class="card stack"><h3>Permissions</h3>${tools}</div><div class="card stack"><h3>Widget and voice</h3>${refresh}${announce}<h4>Controls</h4>${controls}</div><div class="card stack"><h3>Setup</h3>${setup}</div><div class="card stack"><h3>What it runs</h3>${runs}</div>${page?.skill ? `<details class="card"><summary><strong>Skill for the execution agent</strong></summary><p class="hint">Your agent reads this whenever the integration is on. Change it by editing ${code('SKILL.md')} in a copy of the pack, or ask your agent to.</p><pre class="skill">${esc(page.skill)}</pre></details>` : ''}</div><aside class="pack-preview"><h3>On your phone</h3><div id="pack-preview">${packPreviewBody(packId)}</div></aside></div></section>`;
308
+ return `<section class="section pack-page"><a href="#mcp">← Integrations</a><div class="pack-page-head"><div><h2>${esc(p.name)}</h2><p class="hint">${esc(p.author)} · ${esc(p.license)} · version ${esc(p.version)}${page?.homepage ? ` · <a href="${esc(page.homepage)}" target="_blank" rel="noreferrer">Homepage</a>` : ''}</p><p>${esc(p.description)}</p></div><div class="stack"><span class="badge ${statusClass[p.status.state] || ''}">${esc(statusLabel[p.status.state] || p.status.state)}</span>${p.status.detail ? `<p class="hint">${esc(packStatusDetail(p))}</p>` : ''}</div></div><div class="actions"><button data-action="toggle-pack" data-id="${esc(p.id)}" data-enabled="${p.enabled}">${p.enabled ? 'Turn off' : 'Turn on'}</button><button class="primary" data-action="setup-pack" data-id="${esc(p.id)}">${p.status.state === 'ok' ? 'Check setup with my agent' : 'Set up with my agent'}</button><button data-action="check-pack" data-id="${esc(p.id)}" ${p.enabled ? '' : 'disabled'}>Check connection</button><button data-action="export-pack" data-id="${esc(p.id)}">Export</button><button data-action="customize-pack" data-id="${esc(p.id)}">Customize a copy</button>${p.rollbackVersion ? `<button data-action="rollback-pack" data-id="${esc(p.id)}">Roll back to ${esc(p.rollbackVersion)}</button>` : ''}<button class="danger" data-action="remove-pack" data-id="${esc(p.id)}">Remove</button></div>${p.pulled ? `<div class="card trust-notice community" role="alert"><strong>Pulled from the widget library</strong><p>Sidebud pulled version ${esc(p.pulled.version)}: ${esc(p.pulled.reason)}. It was turned off and stays off until you update it${p.rollbackVersion ? ` or roll back to ${esc(p.rollbackVersion)}` : ''}.</p></div>` : ''}<div id="pack-editor"></div>${packPageError ? `<div class="card muted">${esc(packPageError)}</div>` : ''}${accessOverviewHtml(page?.overview)}<div class="pack-page-grid"><div class="stack"><div class="card stack"><h3>Settings and keys</h3>${settings || '<p class="hint">No settings.</p>'}${keys}${settings || keys ? `<div class="actions"><button class="primary" data-action="save-pack" data-id="${esc(p.id)}">Save settings</button></div>` : ''}${account}</div><div class="card stack"><h3>Permissions</h3>${tools}</div><div class="card stack"><h3>Widget and voice</h3>${refresh}${announce}<h4>Controls</h4>${controls}</div><div class="card stack"><h3>Setup</h3>${setup}</div><div class="card stack"><h3>What it runs</h3>${runs}</div>${page?.skill ? `<details class="card"><summary><strong>Skill for the execution agent</strong></summary><p class="hint">Your agent reads this whenever the integration is on. Change it by editing ${code('SKILL.md')} in a copy of the pack, or ask your agent to.</p><pre class="skill">${esc(page.skill)}</pre></details>` : ''}</div><aside class="pack-preview"><h3>On your phone</h3><div id="pack-preview">${packPreviewBody(packId)}</div></aside></div></section>`;
278
309
  }
279
310
 
280
311
  async function loadPackPage() {
@@ -1,4 +1,4 @@
1
- // Settings: the Sidebud account, this computer's name, and appearance. Loaded after manage.js and shares its globals.
1
+ // Settings: the Sidebud account and its settings sync, the network, this computer's name, crash reports, and appearance. Loaded after manage.js and shares its globals.
2
2
  let renaming = false;
3
3
 
4
4
  /** The sidebar's computer card: the saved name, and who this computer is signed in as. */
@@ -47,13 +47,78 @@ async function saveComputerName(value) {
47
47
  await reload();
48
48
  }
49
49
 
50
- function accountCard() {
50
+ // Account settings sync (ADR 0014): what each section carries, in the owner's words.
51
+ const SYNC_SECTIONS = [
52
+ { id: 'voice', short: 'Voice', label: 'Voice choices' },
53
+ { id: 'alerts', short: 'Alerts', label: 'Task and message alert choices and contacts' },
54
+ { id: 'agents', short: 'Agents', label: 'Agent profiles, inline skills, and your default agent' },
55
+ { id: 'packs', short: 'Integrations', label: 'Installed integrations and their non-folder settings' },
56
+ { id: 'packCopies', short: 'Customized integrations', label: 'Customized integrations (as files, without keys)' },
57
+ { id: 'surfaces', short: 'Built-in widgets', label: 'Built-in widgets on or off' },
58
+ ];
59
+ const syncSectionName = (id) => SYNC_SECTIONS.find((section) => section.id === id)?.short || id;
60
+
61
+ /** The opt-in question, in Settings and setup's last step. Nothing is sent until the button. */
62
+ function syncOptInBlock() {
63
+ const { sync } = state;
64
+ const switches = SYNC_SECTIONS.map((section) => `<div class="check"><input id="sync-choice-${section.id}" type="checkbox" data-sync-choice="${section.id}" ${sync.sections?.[section.id] === false ? '' : 'checked'}><label for="sync-choice-${section.id}">${esc(section.label)}</label></div>`).join('');
65
+ return `<div class="stack sync-block"><div class="check"><input id="sync-opt-in" type="checkbox" checked><label for="sync-opt-in">Sync settings to my account</label></div><div><p class="hint">What syncs:</p><ul class="hint sync-list">${SYNC_SECTIONS.map((section) => `<li>${esc(section.label)}</li>`).join('')}</ul><p class="hint">Keys and sign-ins, this computer's name, folders, and paths stay here.</p></div><details><summary>Advanced</summary><div class="stack">${switches}</div></details><div class="actions"><button class="primary" data-action="sync-opt-in">${sync.decided ? 'Turn on sync' : 'Continue'}</button>${sync.decided ? '<button class="danger" data-action="sync-delete">Delete account copy</button>' : ''}</div></div>`;
66
+ }
67
+
68
+ function syncStatusText(sync) {
69
+ if (sync.state === 'syncing') return 'Syncing…';
70
+ if (sync.state === 'offline') return 'Offline, will retry';
71
+ if (sync.state === 'error') return sync.lastError || 'Sync failed. It will try again.';
72
+ return sync.lastSyncedAt ? `Synced ${new Date(sync.lastSyncedAt).toLocaleString()}` : 'Not synced yet';
73
+ }
74
+
75
+ /** Sync while it is on: status, what waits to send, and the ways to stop or remove the account copy. */
76
+ function syncOnBlock() {
77
+ const { sync } = state;
78
+ const switches = SYNC_SECTIONS.map((section) => `<div class="check"><input id="sync-section-${section.id}" type="checkbox" data-sync-section="${section.id}" ${sync.sections[section.id] ? 'checked' : ''}><label for="sync-section-${section.id}">${esc(section.label)}</label></div>`).join('');
79
+ return `<div class="stack sync-block"><div class="row"><div><strong>Settings sync to your account</strong><p class="hint ${sync.state === 'error' ? 'error-text' : ''}" role="status">${esc(syncStatusText(sync))}</p></div><span class="badge ${{ idle: 'good', syncing: '', offline: 'warn', error: 'bad' }[sync.state] ?? ''}">${esc({ idle: 'On', syncing: 'Syncing', offline: 'Offline', error: 'Error' }[sync.state] || 'On')}</span></div>${sync.pending.length ? `<p class="hint">Waiting to send: ${esc(sync.pending.map(syncSectionName).join(', '))}.</p>` : ''}<details><summary>Advanced</summary><div class="stack"><p class="hint">Turn off a part to keep it on this computer only.</p>${switches}</div></details><div class="actions"><button data-action="sync-now" ${sync.state === 'syncing' ? 'disabled' : ''}>Sync now</button><button data-action="sync-stop">Stop syncing</button><button class="danger" data-action="sync-delete">Delete account copy</button></div></div>`;
80
+ }
81
+
82
+ /** The account card's sync part; setup shows only the question. */
83
+ function syncBlock({ setup = false } = {}) {
84
+ const { sync } = state;
85
+ if (!sync?.available) return '';
86
+ // A restart the account's changes need shows in the page's restart banner.
87
+ const notes = sync.notices.map((text) => `<p class="hint">${esc(text)}</p>`).join('');
88
+ if (!sync.enabled) return syncOptInBlock() + notes;
89
+ return (setup ? '<p class="hint">Your settings sync to your account. Manage sync under <a href="#settings">Settings</a>.</p>' : syncOnBlock()) + notes;
90
+ }
91
+
92
+ /** Customized packs from the account that run code here wait for consent on each computer (ADR 0012). */
93
+ function syncConsentsCard() {
94
+ const consents = state.sync?.available ? state.sync.consents : [];
95
+ if (!consents.length) return '';
96
+ return `<div class="card stack"><div><h3>Integrations from your account</h3><p class="hint">These run their own code or use this computer’s keys, so each computer asks first. They stay off here until you allow them.</p></div>${consents.map((consent) => `<div class="row"><p>${esc(consent.name)} ${esc(consent.version)} from your account runs code on this computer.</p><div class="actions"><button class="primary" data-action="sync-consent" data-id="${esc(consent.packId)}" data-allow="true">Allow</button><button data-action="sync-consent" data-id="${esc(consent.packId)}" data-allow="false">Not on this computer</button></div></div>`).join('')}</div>`;
97
+ }
98
+
99
+ /** A synced value in one line: JSON for objects, cut to about 120 characters. */
100
+ function syncValue(value) {
101
+ if (value === undefined) return '(not set)';
102
+ const text = typeof value === 'string' ? value : JSON.stringify(value);
103
+ return text.length > 120 ? `${text.slice(0, 119)}…` : text;
104
+ }
105
+
106
+ /** When this computer and the account changed the same setting, the account's value won; this is the way back. */
107
+ function syncConflictsCard() {
108
+ const conflicts = state.sync?.available ? state.sync.conflicts : [];
109
+ if (!conflicts.length) return '';
110
+ return `<div class="card stack"><div><h3>Changed in two places</h3><p class="hint">This computer and your account both changed these settings. Your account's value is in use; choose this computer's instead, or dismiss.</p></div>${conflicts.map((conflict) => `<div class="stack sync-conflict"><strong>${esc(conflict.label)}</strong><p class="hint">Your account's value: <span class="code">${esc(syncValue(conflict.remote))}</span></p><p class="hint">This computer had: <span class="code">${esc(syncValue(conflict.local))}</span></p><div class="actions"><button data-action="sync-conflict" data-id="${conflict.id}" data-choice="use-local">Use this instead</button><button data-action="sync-conflict" data-id="${conflict.id}" data-choice="dismiss">Dismiss</button></div></div>`).join('')}</div>`;
111
+ }
112
+
113
+ /** `setup` is setup's last step: the account is optional there, and signed in it asks only the sync question. */
114
+ function accountCard({ setup = false } = {}) {
51
115
  const { account } = state;
52
116
  const site = esc(account.server.replace(/^https?:\/\//, ''));
53
- const head = (text) => `<div><h3>Sidebud account</h3><p class="hint">${text}</p></div>`;
117
+ const head = (text) => `<div><h3>Sidebud account${setup ? ' <span class="optional">· Optional</span>' : ''}</h3><p class="hint">${text}</p></div>`;
54
118
  if (account.status === 'signed_in') {
55
119
  const who = account.user.name && account.user.email ? `${esc(account.user.name)} · ${esc(account.user.email)}` : esc(account.user.email || account.user.name || 'Your account');
56
- return `<div class="card stack">${head(`This computer is signed in to ${site}.`)}<div class="account-row"><span class="avatar" aria-hidden="true">${esc((account.user.name || account.user.email || 'S').slice(0, 1).toUpperCase())}</span><div><strong>${who}</strong><p class="hint">Signed in ${esc(new Date(account.signedInAt).toLocaleString())}</p></div></div><div class="actions"><button class="danger" data-action="account-sign-out">Sign out</button></div></div>`;
120
+ if (setup) return `<div class="card stack">${head(`Signed in as ${who}.`)}${syncBlock({ setup })}</div>`;
121
+ return `<div class="card stack">${head(`This computer is signed in to ${site} and shows in your account's list of computers.`)}<div class="account-row"><span class="avatar" aria-hidden="true">${esc((account.user.name || account.user.email || 'S').slice(0, 1).toUpperCase())}</span><div><strong>${who}</strong><p class="hint">Signed in ${esc(new Date(account.signedInAt).toLocaleString())}</p></div></div>${syncBlock()}<div class="actions"><button class="danger" data-action="account-sign-out">Sign out</button></div></div>`;
57
122
  }
58
123
  if (account.status === 'pending') {
59
124
  const link = account.verificationUriComplete || account.verificationUri;
@@ -61,13 +126,81 @@ function accountCard() {
61
126
  }
62
127
  if (account.status === 'unavailable')
63
128
  return `<div class="card stack">${head('Sign in to connect this computer to your account.')}<p class="hint">${esc(account.error)} Everything on this page keeps working without an account.</p><div class="actions"><button data-action="account-sign-in">Try again</button></div></div>`;
64
- return `<div class="card stack">${head(`Sign in to ${site} to connect this computer to your account. You approve it in your browser; no password is typed here, and the sign-in token stays on this computer.`)}${account.error ? `<p role="alert" class="hint error-text">${esc(account.error)}</p>` : ''}<div class="actions"><button class="primary" data-action="account-sign-in">Sign in with ${site}</button></div></div>`;
129
+ return `<div class="card stack">${head(`Sign in to ${site} to connect this computer to your account${setup ? ' and keep your settings in it. Setup finishes without an account' : ''}. You approve it in your browser; no password is typed here, and the sign-in token stays on this computer.`)}${account.error ? `<p role="alert" class="hint error-text">${esc(account.error)}</p>` : ''}<div class="actions"><button class="primary" data-action="account-sign-in">Sign in with ${site}</button></div></div>`;
130
+ }
131
+
132
+ /** Only while the site sells plans (`billing`); the plan is managed on the site, so there are no prices here. */
133
+ function planCard() {
134
+ if (!state.capabilities.flags.billing || !state.plan.url) return '';
135
+ const inactive = state.capabilities.flags.paywall && state.plan.status === 'inactive';
136
+ const text = inactive
137
+ ? "Your Sidebud plan isn't active, so calls, requests, and new tasks are paused on this computer. Choose or renew a plan on the Sidebud site. This computer checks again every hour and whenever you try to use it."
138
+ : 'Your Sidebud plan is managed on the Sidebud site.';
139
+ return `<div class="card stack"><div><h3>Plan</h3><p class="hint ${inactive ? 'error-text' : ''}"${inactive ? ' role="alert"' : ''}>${esc(text)}</p></div><div class="actions"><a class="button ${inactive ? 'primary' : ''}" href="${esc(state.plan.url)}" target="_blank" rel="noopener">Manage plan</a></div></div>`;
65
140
  }
66
141
 
142
+ const NETWORK_CHOICES = [
143
+ { id: 'private', label: 'Only this computer and my tailnet', hint: 'Phones reach this computer over Tailscale. Other devices on your Wi-Fi cannot connect. Recommended.' },
144
+ { id: 'lan', label: 'This network (LAN)', hint: 'Any phone on the same Wi-Fi or network can reach this computer, as well as over Tailscale. Pairing still needs a code from this page.' },
145
+ ];
146
+
147
+ /** Who can reach this computer's phone listener: the choice, its addresses, and a change that waits for a restart. */
148
+ function networkChoice({ setup = false } = {}) {
149
+ const { exposure, saved, listening, port } = state.network;
150
+ const pending = saved !== exposure;
151
+ const current = exposure === 'custom' ? `a custom address (${listening.join(', ')})` : NETWORK_CHOICES.find((choice) => choice.id === exposure).label.toLowerCase();
152
+ const tailnet = listening.some((host) => host.startsWith('100.') || host.startsWith('fd7a:')) || exposure === 'lan';
153
+ return `<div class="stack" role="radiogroup" aria-label="Who can reach this computer">${NETWORK_CHOICES.map((choice) => `<label class="check"><input type="radio" name="network-exposure" value="${choice.id}" ${(pending ? saved : exposure) === choice.id ? 'checked' : ''}><span><strong>${esc(choice.label)}</strong><br><span class="hint">${esc(choice.hint)}</span></span></label>`).join('')}</div>${setup ? '' : `<p class="hint">Now: ${esc(current)}, port ${port}.${listening.length ? ` Listening on ${esc(listening.join(', '))}.` : ''}</p>`}${exposure === 'private' && !tailnet ? '<p class="hint">Tailscale is not connected on this computer, so only this computer can reach Sidebud. Connect Tailscale on both devices; the companion picks it up within a minute.</p>' : ''}${pending ? '<p class="hint">Your new choice applies after you apply changes.</p>' : ''}`;
154
+ }
155
+
156
+ async function saveNetworkChoice() {
157
+ const exposure = document.querySelector('input[name="network-exposure"]:checked')?.value;
158
+ if (!exposure) throw new Error('Choose who can reach this computer.');
159
+ if (exposure !== state.network.saved) await api('network', { exposure });
160
+ await reload();
161
+ return !state.restart.pending || (await applyChanges());
162
+ }
163
+
164
+ /**
165
+ * Opt-in crash reports, in Settings and on setup's last step. Saved as it
166
+ * changes and applied at once. Builds without a crash report address (running
167
+ * from source) show it off and disabled; setup leaves it out.
168
+ */
169
+ function crashReportsCard({ setup = false } = {}) {
170
+ const { available, enabled } = state.crashReports;
171
+ if (setup && !available) return '';
172
+ return `<div class="card stack"><div><h3>Crash reports${setup ? ' <span class="optional">· Optional</span>' : ''}</h3></div><div class="check"><input id="crash-reports" type="checkbox" data-crash-reports ${enabled && available ? 'checked' : ''} ${available ? '' : 'disabled'}><label for="crash-reports">Send crash reports to Sidebud</label></div><p class="hint">Sends error details when something breaks, with your prompts, transcripts, audio, messages, and keys removed and your home folder hidden in file paths. Off unless you turn it on.</p>${available ? '' : '<p class="hint">Not available in this copy of Sidebud (running from source).</p>'}</div>`;
173
+ }
174
+
175
+ /**
176
+ * "Start Sidebud when I log in" (a macOS launchd agent). The page only writes or removes the login item: this
177
+ * companion already holds the ports, so it takes effect at the next login; `sidebud service install` starts it now.
178
+ */
179
+ function loginItemCard({ setup = false } = {}) {
180
+ const item = state.install?.loginItem;
181
+ if (!item || item.platform !== 'supported') return '';
182
+ const head = `<div><h3>${setup ? 'Keep Sidebud running <span class="optional">· Recommended</span>' : 'Start Sidebud when I log in'}</h3><p class="hint">Sidebud runs in the background on this Mac, so your phone can reach it without a terminal window. It restarts by itself if it stops unexpectedly.</p></div>`;
183
+ if (item.installed)
184
+ return `<div class="card stack">${head}<p>${item.running ? 'On. This companion is running from the login item.' : 'On. Sidebud starts by itself the next time you log in. To switch now, quit this companion and run <span class="code">sidebud service install</span>.'}</p><p class="hint">Logs: <span class="code">${esc(item.logPath)}</span></p><div class="actions"><button data-action="login-item" data-enabled="false">Stop starting at login</button></div></div>`;
185
+ if (item.unavailable) return `<div class="card stack">${head}<p class="hint">${esc(item.unavailable).replace(/`([^`]+)`/g, '<span class="code">$1</span>')}</p></div>`;
186
+ return `<div class="card stack">${head}<div class="actions"><button ${setup ? 'class="primary" ' : ''}data-action="login-item" data-enabled="true">Start Sidebud when I log in</button></div></div>`;
187
+ }
188
+ /** Installed version and the daily update check (`updates.check` in config.json). */
189
+ function versionCard() {
190
+ const install = state.install;
191
+ if (!install) return '';
192
+ const { updates } = install;
193
+ const checked = updates.checkedAt ? `Last checked ${esc(new Date(updates.checkedAt).toLocaleString())}.` : 'Not checked yet.';
194
+ const status = !updates.enabled
195
+ ? install.kind === 'source' ? 'Running from source; update checks are off.' : 'Update checks are off (<span class="code">updates.check</span> in config.json).'
196
+ : updates.available ? `Sidebud ${esc(updates.latest)} is available. Update with <span class="code">${esc(updates.command)}</span>.`
197
+ : `${updates.latest ? 'Up to date.' : ''} ${checked}${updates.error ? ` The last check failed: ${esc(updates.error)}.` : ''}`;
198
+ return `<div class="card stack"><div><h3>Version</h3><p class="hint">Sidebud ${esc(updates.current)}. Checks npm for a new release once a day and never updates by itself.</p></div><p>${status}</p></div>`;
199
+ }
67
200
  function settingsView() {
68
201
  const theme = document.documentElement.dataset.theme;
69
202
  const renamed = state.savedName !== state.name;
70
- return `<section class="section"><div><h2>Settings</h2><p class="lede">Your account and preferences for ${esc(state.name)}.</p></div>${accountCard()}<div class="card stack"><div><h3>Computer name</h3><p class="hint">Shown on your phones, on the system call screen, and to the voice assistant.</p></div><div class="actions"><input id="computer-name" maxlength="100" aria-label="Computer name" value="${esc(state.savedName)}"><button data-action="save-name">Save name</button></div>${renamed ? '<p class="hint">The new name is used after you apply changes.</p>' : ''}</div><div class="card stack"><div><h3>Appearance</h3><p class="hint">Remembered in this browser.</p></div><div class="segmented" role="group" aria-label="Theme"><button data-action="set-theme" data-id="dark" aria-pressed="${theme === 'dark'}">Dark</button><button data-action="set-theme" data-id="light" aria-pressed="${theme === 'light'}">Light</button></div></div></section>`;
203
+ return `<section class="section"><div><h2>Settings</h2><p class="lede">Your account and preferences for ${esc(state.name)}.</p></div>${accountCard()}${planCard()}${syncConsentsCard()}${syncConflictsCard()}<div class="card stack"><div><h3>Network</h3><p class="hint">Who can reach this computer to pair and connect a phone. This page itself is only ever available on this computer.</p></div>${networkChoice()}<div class="actions"><button data-action="save-network">Save network choice</button></div></div><div class="card stack"><div><h3>Computer name</h3><p class="hint">Shown on your phones, on the system call screen, and to the voice assistant.</p></div><div class="actions"><input id="computer-name" maxlength="100" aria-label="Computer name" value="${esc(state.savedName)}"><button data-action="save-name">Save name</button></div>${renamed ? '<p class="hint">The new name is used after you apply changes.</p>' : ''}</div>${crashReportsCard()}<div class="card stack"><div><h3>Appearance</h3><p class="hint">Remembered in this browser.</p></div><div class="segmented" role="group" aria-label="Theme"><button data-action="set-theme" data-id="dark" aria-pressed="${theme === 'dark'}">Dark</button><button data-action="set-theme" data-id="light" aria-pressed="${theme === 'light'}">Light</button></div></div>${loginItemCard()}${versionCard()}</section>`;
71
204
  }
72
205
 
73
206
  /** Handles Settings and sidebar actions; returns true when it took the action. */
@@ -76,10 +209,32 @@ async function handleSettingsAction(action, button) {
76
209
  startInlineRename();
77
210
  return true;
78
211
  }
212
+ if (action === 'save-network') {
213
+ if (await saveNetworkChoice()) notice('Network choice saved.');
214
+ return true;
215
+ }
79
216
  if (action === 'save-name') {
80
217
  await saveComputerName($('#computer-name').value);
81
218
  return true;
82
219
  }
220
+ if (action === 'update') {
221
+ button.disabled = true;
222
+ try {
223
+ const result = await api('update', { when: button.dataset.when });
224
+ notice(result.message);
225
+ await reload();
226
+ } finally { button.disabled = false; }
227
+ return true;
228
+ }
229
+ if (action === 'login-item') {
230
+ button.disabled = true;
231
+ try {
232
+ const result = await api('login-item', { enabled: button.dataset.enabled === 'true' });
233
+ notice(result.message);
234
+ await reload();
235
+ } finally { button.disabled = false; }
236
+ return true;
237
+ }
83
238
  if (action === 'set-theme') {
84
239
  localStorage.setItem('rvaManageTheme', button.dataset.id);
85
240
  applyTheme(button.dataset.id);
@@ -101,6 +256,54 @@ async function handleSettingsAction(action, button) {
101
256
  await reload();
102
257
  return true;
103
258
  }
259
+ if (action === 'sync-opt-in') {
260
+ const enabled = $('#sync-opt-in').checked;
261
+ const sections = Object.fromEntries([...document.querySelectorAll('[data-sync-choice]')].map((box) => [box.dataset.syncChoice, box.checked]));
262
+ button.disabled = true;
263
+ try {
264
+ await api('sync/opt-in', { enabled, sections });
265
+ notice(enabled ? 'Sync is on. Your settings are on their way to your account.' : 'Settings stay on this computer only. Turn on sync any time under Settings.');
266
+ await reload();
267
+ } finally { button.disabled = false; }
268
+ return true;
269
+ }
270
+ if (action === 'sync-now') {
271
+ button.disabled = true;
272
+ try {
273
+ await api('sync/now', {});
274
+ await reload();
275
+ } finally { button.disabled = false; }
276
+ return true;
277
+ }
278
+ if (action === 'sync-stop') {
279
+ if (!confirm('Stop syncing? Settings on this computer stay as they are.')) return true;
280
+ await api('sync/stop', {});
281
+ notice('Sync stopped. Settings on this computer stay as they are.');
282
+ await reload();
283
+ return true;
284
+ }
285
+ if (action === 'sync-delete') {
286
+ if (!confirm('Delete the copy of your settings stored in your Sidebud account? Every computer keeps its own settings; sync stops.')) return true;
287
+ // A failed delete answers with the sync status and its error; the account copy is still there.
288
+ const next = await api('sync/delete-account-copy', {});
289
+ if (next.state === 'error' || next.state === 'offline') notice(`The account copy was not deleted: ${next.lastError || 'your account could not be reached'}. Try again.`, true);
290
+ else notice('The copy in your account is deleted. Settings on this computer stay as they are.');
291
+ await reload();
292
+ return true;
293
+ }
294
+ if (action === 'sync-conflict') {
295
+ await api('sync/conflict', { id: Number(button.dataset.id), action: button.dataset.choice });
296
+ if (button.dataset.choice === 'use-local') notice('This computer’s value is back in use and goes to your account.');
297
+ await reload();
298
+ return true;
299
+ }
300
+ if (action === 'sync-consent') {
301
+ const allow = button.dataset.allow === 'true';
302
+ await api('sync/consent', { packId: button.dataset.id, allow });
303
+ notice(allow ? 'Allowed on this computer.' : 'It stays off on this computer.');
304
+ await reload();
305
+ return true;
306
+ }
104
307
  if (action === 'account-sign-out') {
105
308
  if (!confirm('Sign this computer out of your Sidebud account?')) return true;
106
309
  await api('account/sign-out', {});
@@ -110,3 +313,26 @@ async function handleSettingsAction(action, button) {
110
313
  }
111
314
  return false;
112
315
  }
316
+
317
+ // The crash report switch saves as it changes; no restart is needed.
318
+ document.addEventListener('change', (event) => {
319
+ if (!event.target.matches?.('[data-crash-reports]') || !state) return;
320
+ const enabled = event.target.checked;
321
+ void api('crash-reports', { enabled }).then(() => {
322
+ notice(enabled ? 'Crash reports are on. Thank you.' : 'Crash reports are off. Nothing more is sent.');
323
+ return reload();
324
+ }, (error) => {
325
+ notice(error.message, true);
326
+ return reload();
327
+ });
328
+ });
329
+
330
+ // Sync's per-part switches (while sync is on) save as they change.
331
+ document.addEventListener('change', (event) => {
332
+ const section = event.target.dataset?.syncSection;
333
+ if (!section || !state) return;
334
+ void api('sync/section', { section, enabled: event.target.checked }).then(() => reload(), (error) => {
335
+ notice(error.message, true);
336
+ return reload();
337
+ });
338
+ });