@floless/app 0.31.0 → 0.31.2

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.
package/autostart.mjs ADDED
@@ -0,0 +1,251 @@
1
+ // autostart — register/unregister FloLess to start at LOGIN via a per-user
2
+ // "At log on" Scheduled Task (replaces the old HKCU\Run key).
3
+ //
4
+ // WHY a Scheduled Task and not the Run key (the bug this fixes):
5
+ // FloLess is installed by Velopack INSIDE Claude's MSIX container — the binary
6
+ // physically lives at
7
+ // %LOCALAPPDATA%\Packages\Claude_pzs8sxrjxfjjc\LocalCache\Local\FlolessApp\…
8
+ // (there is NO copy outside the container; %LOCALAPPDATA%\FlolessApp is just a
9
+ // junction back into it). Explorer's logon Run-key processor silently SKIPS an
10
+ // executable that resides inside another package's container. Verified on the
11
+ // target machine 2026-05-30: at logon, every *other* enabled HKCU\Run entry
12
+ // fired (Shell-Core Operational event 9707 logged OneDrive, GoogleDriveFS, …)
13
+ // but the FloLess entry NEVER appeared — while the exact same command launches a
14
+ // healthy server on demand (~0.8s) and via Task Scheduler (LastTaskResult 0x0).
15
+ // It was not Fast Startup (Run keys fire fine here), not Mark-of-the-Web, not
16
+ // Defender, not a StartupApproved disable (no such entry), and not timing (key
17
+ // written before the boot). The single distinguishing factor is the container path.
18
+ //
19
+ // A per-user "At log on" Scheduled Task has none of these problems, and three
20
+ // independent research passes (n8n / Docker / Ollama / Syncthing ecosystem +
21
+ // Microsoft Learn) converge on it as THE mechanism for a per-user local server
22
+ // that must come back after every reboot:
23
+ // • Fires on every Fast-Startup power-on — a real interactive logon always
24
+ // happens (Session 1 is torn down at shutdown; only the kernel session
25
+ // hibernates). The trap to avoid is the "At startup" trigger, which the
26
+ // Fast-Startup restore path skips — so we use a LOGON trigger, never a boot one.
27
+ // • Runs as the interactive user (InteractiveToken) → the supervisor sees the
28
+ // per-user license file (%LOCALAPPDATA%\FlolessApp-data) and ~/.aware. A
29
+ // Windows Service (LocalSystem/LocalService) would NOT — wrong profile.
30
+ // • Needs no admin / no UAC / no stored password (verified: Register-ScheduledTask
31
+ // for this user's own InteractiveToken logon task succeeds unelevated here).
32
+ // • Is NOT subject to the StartupApproved silent-disable that gates Run keys.
33
+ //
34
+ // The task's action is `"<exe>" --supervise` — the same headless, self-healing
35
+ // watchdog as before (starts the server, respawns it within seconds if it dies).
36
+ // Crash-recovery is the supervisor's job, NOT the autostart mechanism's (no OS
37
+ // autostart primitive restarts a long-lived process — that's why --supervise exists).
38
+ //
39
+ // .mjs (not .ts) so it can be imported by BOTH main.ts (the SEA dispatcher, TS) and
40
+ // — if ever needed — launch.mjs (plain JS), mirroring teardown.mjs / install-path.mjs.
41
+ // Pure logic (the XML builder) is unit-tested; the imperative wrappers (schtasks.exe)
42
+ // are exercised by the human's real Shut-Down → power-on E2E.
43
+ import { execFileSync } from 'node:child_process';
44
+ import { writeFileSync, rmSync, appendFileSync, mkdirSync } from 'node:fs';
45
+ import { tmpdir } from 'node:os';
46
+ import { join } from 'node:path';
47
+ import { RUN_KEY, RUN_VALUE } from './teardown.mjs';
48
+ import { logDir, logFilePath } from './log.mjs';
49
+
50
+ // Stable per-user task name (root of the user's Task Scheduler namespace: \FloLess).
51
+ export const TASK_NAME = 'FloLess';
52
+
53
+ const isWin = process.platform === 'win32';
54
+
55
+ /**
56
+ * Is autostart a meaningful, writable setting in THIS channel? True only for the
57
+ * packaged SEA exe (FlolessApp.exe) on Windows — that's the only build whose logon
58
+ * task makes sense. The npm/dev channel runs the server via node.exe (autostart is
59
+ * never wired, and registering node.exe would be wrong), and autostart is Windows-only.
60
+ * The /api/autostart endpoint reports this as `supported`; the UI HIDES the toggle
61
+ * (never shows a dead control) when it's false.
62
+ *
63
+ * @param {string} [platform] defaults to process.platform
64
+ * @param {string} [execPath] defaults to process.execPath
65
+ * @returns {boolean}
66
+ */
67
+ export function autostartSupported(platform = process.platform, execPath = process.execPath) {
68
+ return platform === 'win32' && /flolessapp\.exe$/i.test(execPath || '');
69
+ }
70
+
71
+ /** Minimal XML text escaping (element content + the few chars a path/user could carry). */
72
+ function escapeXml(s) {
73
+ return String(s)
74
+ .replace(/&/g, '&amp;')
75
+ .replace(/</g, '&lt;')
76
+ .replace(/>/g, '&gt;')
77
+ .replace(/"/g, '&quot;')
78
+ .replace(/'/g, '&apos;');
79
+ }
80
+
81
+ /**
82
+ * Build the Task Scheduler XML for the per-user logon autostart task.
83
+ * Pure + exported so the exact task definition is unit-tested (no side effects).
84
+ *
85
+ * @param {string} exePath the (un-virtualized) FlolessApp.exe path to launch.
86
+ * @param {string} userId the principal/trigger user, e.g. "MACHINE\\User".
87
+ * @returns {string} a Task Scheduler 1.2 XML document.
88
+ */
89
+ export function buildAutostartTaskXml(exePath, userId) {
90
+ const exe = escapeXml(exePath);
91
+ const user = escapeXml(userId);
92
+ // Notes on load-bearing settings:
93
+ // LogonTrigger → fires at user logon (Fast-Startup-safe; NOT a BootTrigger).
94
+ // InteractiveToken → runs as the logged-in user with their profile loaded
95
+ // (sees the per-user license + ~/.aware); no stored password.
96
+ // LeastPrivilege → no elevation, no UAC.
97
+ // ExecutionTimeLimit PT0S → never time-limit the action (default is 72h).
98
+ // MultipleInstancesPolicy IgnoreNew → never stack supervisors.
99
+ // battery flags false → still start on laptops running on battery.
100
+ // StartWhenAvailable → run a missed trigger as soon as possible.
101
+ return `<?xml version="1.0" encoding="UTF-16"?>
102
+ <Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
103
+ <RegistrationInfo>
104
+ <Description>Keeps the FloLess local server running so your browser tab reconnects after every login.</Description>
105
+ </RegistrationInfo>
106
+ <Triggers>
107
+ <LogonTrigger>
108
+ <Enabled>true</Enabled>
109
+ <UserId>${user}</UserId>
110
+ </LogonTrigger>
111
+ </Triggers>
112
+ <Principals>
113
+ <Principal id="Author">
114
+ <UserId>${user}</UserId>
115
+ <LogonType>InteractiveToken</LogonType>
116
+ <RunLevel>LeastPrivilege</RunLevel>
117
+ </Principal>
118
+ </Principals>
119
+ <Settings>
120
+ <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
121
+ <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
122
+ <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
123
+ <AllowHardTerminate>false</AllowHardTerminate>
124
+ <StartWhenAvailable>true</StartWhenAvailable>
125
+ <RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>
126
+ <IdleSettings>
127
+ <StopOnIdleEnd>false</StopOnIdleEnd>
128
+ <RestartOnIdle>false</RestartOnIdle>
129
+ </IdleSettings>
130
+ <AllowStartOnDemand>true</AllowStartOnDemand>
131
+ <Enabled>true</Enabled>
132
+ <Hidden>false</Hidden>
133
+ <RunOnlyIfIdle>false</RunOnlyIfIdle>
134
+ <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
135
+ <Priority>7</Priority>
136
+ </Settings>
137
+ <Actions Context="Author">
138
+ <Exec>
139
+ <Command>${exe}</Command>
140
+ <Arguments>--supervise</Arguments>
141
+ </Exec>
142
+ </Actions>
143
+ </Task>`;
144
+ }
145
+
146
+ /** The current interactive user as "DOMAIN\\User" (DOMAIN falls back to the machine name). */
147
+ function currentUserId() {
148
+ const dom = process.env.USERDOMAIN || process.env.COMPUTERNAME || '';
149
+ const user = process.env.USERNAME || '';
150
+ return dom ? `${dom}\\${user}` : user;
151
+ }
152
+
153
+ /** Drop the legacy HKCU\Run autostart value (the broken mechanism this replaces). Best-effort. */
154
+ function removeLegacyRunKey() {
155
+ try {
156
+ execFileSync('reg', ['delete', RUN_KEY, '/v', RUN_VALUE, '/f'], { stdio: 'ignore', windowsHide: true });
157
+ } catch {
158
+ /* value absent — nothing to remove */
159
+ }
160
+ }
161
+
162
+ /**
163
+ * Append one diagnostic line to the server logfile, best-effort (never throws).
164
+ * The original Run-key autostart failed SILENTLY — that is the exact failure class
165
+ * this rewrite kills, so a registration failure must always leave a breadcrumb
166
+ * (dual-review finding, 2026-05-30) rather than vanish.
167
+ */
168
+ function logLine(msg) {
169
+ try {
170
+ mkdirSync(logDir(), { recursive: true });
171
+ appendFileSync(logFilePath(), `${new Date().toISOString()} ${msg}\n`);
172
+ } catch {
173
+ /* logging must never crash the caller */
174
+ }
175
+ }
176
+
177
+ /**
178
+ * Register (or refresh) the per-user logon autostart task to run `"<exe>" --supervise`.
179
+ * Idempotent (`/F` overwrites, so this also fixes a stale path after an update).
180
+ * Windows-only; no-op elsewhere. Also removes the legacy Run-key value (migration).
181
+ *
182
+ * Creates the task with `schtasks.exe /Create /XML` reading a UTF-16LE temp file
183
+ * directly — NOT PowerShell's `Register-ScheduledTask -Xml`. schtasks is a plain
184
+ * Win32 exe (no ScheduledTasks PS module / execution-policy / constrained-language
185
+ * dependency — important on locked-down corporate machines), it reads the UTF-16LE
186
+ * file directly — avoiding PowerShell's `-Xml` STRING round-trip that could silently
187
+ * corrupt the task definition — and makes register/query/delete all one tool.
188
+ * (schtasks REQUIRES the UTF-16 BOM: a BOM-less file is rejected as "the task XML is
189
+ * malformed" — verified 2026-05-30.) Also verified: schtasks /Create of the user's
190
+ * own InteractiveToken logon task succeeds UNELEVATED (SUCCESS, on-demand 0x0).
191
+ *
192
+ * Throws on a schtasks failure (after logging a breadcrumb) so the best-effort
193
+ * callers (the Velopack install hook, ensureAutostart) decide — they swallow it,
194
+ * but the failure is no longer invisible.
195
+ */
196
+ export function registerAutostart(exePath) {
197
+ if (!isWin) return;
198
+ const xml = buildAutostartTaskXml(exePath, currentUserId());
199
+ // pid + timestamp so two registrations in one process can't clobber each other's temp.
200
+ const tmp = join(tmpdir(), `floless-autostart-${process.pid}-${Date.now()}.xml`);
201
+ // BOM is REQUIRED: schtasks /Create rejects a BOM-less file as "the task XML is
202
+ // malformed" (it needs the U+FEFF marker to detect UTF-16). Verified 2026-05-30.
203
+ writeFileSync(tmp, '' + xml, { encoding: 'utf16le' });
204
+ try {
205
+ execFileSync('schtasks', ['/Create', '/TN', TASK_NAME, '/XML', tmp, '/F'], {
206
+ stdio: ['ignore', 'ignore', 'ignore'],
207
+ windowsHide: true,
208
+ });
209
+ } catch (err) {
210
+ logLine(`autostart: schtasks /Create failed: ${err && err.message ? err.message : err}`);
211
+ throw err;
212
+ } finally {
213
+ try { rmSync(tmp, { force: true }); } catch { /* temp cleanup best-effort */ }
214
+ }
215
+ removeLegacyRunKey();
216
+ }
217
+
218
+ /** True iff the autostart task exists. Uses schtasks' exit code (0 = present). Win-only. */
219
+ export function autostartPresent() {
220
+ if (!isWin) return false;
221
+ try {
222
+ execFileSync('schtasks', ['/query', '/tn', TASK_NAME], { stdio: 'ignore', windowsHide: true });
223
+ return true;
224
+ } catch {
225
+ return false;
226
+ }
227
+ }
228
+
229
+ /**
230
+ * Self-heal: ensure the autostart task exists, registering it if missing. Cheap and
231
+ * idempotent — called on every packaged-app open (which runs OUTSIDE Claude's MSIX
232
+ * container, where task registration is reliable), so the task is guaranteed present
233
+ * after the user opens the app even once, regardless of whether the in-container
234
+ * install hook could register it. Best-effort; never throws. Win-only.
235
+ */
236
+ export function ensureAutostart(exePath) {
237
+ if (!isWin) return;
238
+ if (autostartPresent()) return;
239
+ try { registerAutostart(exePath); } catch { /* failure already logged in registerAutostart */ }
240
+ }
241
+
242
+ /** Remove the autostart task AND any legacy Run-key value. Idempotent; Win-only. */
243
+ export function unregisterAutostart() {
244
+ if (!isWin) return;
245
+ try {
246
+ execFileSync('schtasks', ['/delete', '/tn', TASK_NAME, '/f'], { stdio: 'ignore', windowsHide: true });
247
+ } catch {
248
+ /* task absent — nothing to remove */
249
+ }
250
+ removeLegacyRunKey();
251
+ }
@@ -52835,7 +52835,7 @@ function appVersion() {
52835
52835
  return resolveVersion({
52836
52836
  isSea: isSea2(),
52837
52837
  sqVersionXml: readSqVersionXml(),
52838
- define: true ? "0.31.0" : void 0,
52838
+ define: true ? "0.31.2" : void 0,
52839
52839
  pkgVersion: readPkgVersion()
52840
52840
  });
52841
52841
  }
@@ -52845,7 +52845,7 @@ function resolveChannel(s) {
52845
52845
  return "dev";
52846
52846
  }
52847
52847
  function appChannel() {
52848
- return resolveChannel({ isSea: isSea2(), define: true ? "0.31.0" : void 0 });
52848
+ return resolveChannel({ isSea: isSea2(), define: true ? "0.31.2" : void 0 });
52849
52849
  }
52850
52850
 
52851
52851
  // oauth-presets.ts
package/dist/web/app.css CHANGED
@@ -566,6 +566,7 @@
566
566
  font-size: 12px;
567
567
  background: var(--accent-soft);
568
568
  flex-shrink: 0;
569
+ cursor: help;
569
570
  }
570
571
  .agent-card .fav-btn {
571
572
  position: absolute;
@@ -1124,6 +1125,10 @@
1124
1125
  never deletes — matching the app's other destructive affordances. */
1125
1126
  .modal-actions button.danger { border-color: var(--border-strong); color: var(--text); font-weight: 600; }
1126
1127
  .modal-actions button.danger:hover { color: var(--err); border-color: var(--err); background: rgba(248, 113, 113, 0.1); box-shadow: none; }
1128
+ /* #125: form modal with many fields must not overflow the viewport — cap + scroll
1129
+ the body between the pinned title and actions, mirroring .modal.routines. */
1130
+ #form-modal .modal { max-height: 86vh; display: flex; flex-direction: column; }
1131
+ #form-modal-body { overflow-y: auto; flex: 1 1 auto; min-height: 0; }
1127
1132
  .modal.library {
1128
1133
  width: 720px;
1129
1134
  max-height: 82vh;
package/dist/web/app.js CHANGED
@@ -352,7 +352,7 @@ function cardEl(id, ports) {
352
352
  <div class="kind">${a.kind}</div>
353
353
  <div class="ver">${a.version}</div>
354
354
  </div>
355
- <div class="icon">${a.icon}</div>
355
+ <div class="icon"${a.iconTip ? ` data-tip="${escapeAttr(a.iconTip)}"` : ''}>${a.icon}</div>
356
356
  </div>
357
357
  <div class="title">${a.title}</div>
358
358
  <div class="subtitle">${a.subtitle}</div>
package/dist/web/aware.js CHANGED
@@ -94,10 +94,28 @@
94
94
  }
95
95
 
96
96
  const AGENT_ICONS = {
97
+ tekla: '<line x1="4" y1="5" x2="20" y2="5"/><line x1="12" y1="5" x2="12" y2="20"/>',
98
+ file: '<path d="M13 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V9z"/><polyline points="13 2 13 9 20 9"/>',
99
+ excel: '<rect x="2" y="3" width="20" height="18" rx="2"/><line x1="12" y1="3" x2="12" y2="21"/><line x1="2" y1="12" x2="22" y2="12"/>',
100
+ revit: '<rect x="3" y="4" width="18" height="17" rx="1"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="12" y1="21" x2="12" y2="12"/>',
101
+ slack: '<line x1="4" y1="10" x2="20" y2="10"/><line x1="4" y1="14" x2="20" y2="14"/><line x1="10" y1="4" x2="8" y2="20"/><line x1="16" y1="4" x2="14" y2="20"/>',
102
+ 'trimble-connect': '<path d="M17.5 10a4.5 4.5 0 00-9 0A4 4 0 009 18h6a4 4 0 002.5-7z"/><line x1="12" y1="18" x2="12" y2="12"/><polyline points="9 15 12 12 15 15"/>',
103
+ 'microsoft-365': '<rect x="2" y="2" width="8" height="8" rx="1"/><rect x="14" y="2" width="8" height="8" rx="1"/><rect x="2" y="14" width="8" height="8" rx="1"/><rect x="14" y="14" width="8" height="8" rx="1"/>',
104
+ email: '<rect x="2" y="4" width="20" height="16" rx="2"/><path d="M2 7l10 7 10-7"/>',
105
+ http: '<polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/>',
106
+ };
107
+ const AGENT_NAMES = {
108
+ tekla: 'Tekla', file: 'File', excel: 'Excel', revit: 'Revit', slack: 'Slack',
109
+ 'trimble-connect': 'Trimble Connect', 'microsoft-365': 'Microsoft 365', email: 'Email', http: 'HTTP',
110
+ };
111
+ const _svgWrap = (inner) => `<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${inner}</svg>`;
112
+ const iconFor = (agent) => _svgWrap(AGENT_ICONS[agent] || '<path d="M12 2l8 10-8 10L4 12z"/>');
113
+ const iconTipFor = (agent) => AGENT_NAMES[agent] || (agent ? agent.replace(/-/g, ' ') : '');
114
+ const AGENT_ICONS_TEXT = {
97
115
  tekla: '⊞', file: '🗀', excel: '◵', revit: '◰', slack: '✦',
98
116
  'trimble-connect': '☁', 'microsoft-365': '✉', email: '✉', http: '⇄',
99
117
  };
100
- const iconFor = (agent) => AGENT_ICONS[agent] || '◈';
118
+ const iconTextFor = (agent) => AGENT_ICONS_TEXT[agent] || '◈';
101
119
 
102
120
  // The demo's renderChat staggers narration via setTimeout; its bootstrap call
103
121
  // (fired before this script runs) would otherwise drip stale demo lines into
@@ -289,6 +307,7 @@
289
307
  // Description tab: a plain-English explanation + a small input table that
290
308
  // EXCLUDES the code (code belongs to the Code tab, never dumped here).
291
309
  const plainDesc = execSource ? leadingComment(execSource) : '';
310
+ const blurbText = plainDesc && (s => s.length > 80 ? s.slice(0, 79) + '…' : s)(plainDesc.replace(/\.\s+.*/, '.').trim());
292
311
  const inputsNoCode = {};
293
312
  for (const [k, v] of Object.entries(inputs || {})) if (k !== 'code') inputsNoCode[k] = v;
294
313
  return {
@@ -296,11 +315,12 @@
296
315
  _runtimeModel: !!n.runtimeModel, // B2: lock stamped runtime-model → card badge
297
316
  _frozen: !!n.frozen, // source node carries a `frozen:` block → card badge + Unfreeze menu
298
317
  icon: iconFor(n.agent),
318
+ iconTip: iconTipFor(n.agent),
299
319
  kind: n.kind === 'agent' && n.agent ? `${agentLabel} agent` : escapeHtml(n.kind),
300
320
  version: pin ? `v${escapeHtml(String(pin))}` : '—',
301
321
  title: escapeHtml(n.id),
302
322
  subtitle: `${agentLabel}${cmd ? '/' + cmd : ''} · ${mode}`,
303
- blurb: n.notes[0] ? escapeHtml(humanizeNote(n.notes[0].text)) : `${mode}-mode ${cmd ? '<code>' + cmd + '</code>' : escapeHtml(n.kind)} node`,
323
+ blurb: blurbText ? escapeHtml(blurbText) : n.notes[0] ? escapeHtml(humanizeNote(n.notes[0].text)) : `${mode}-mode ${cmd ? '<code>' + cmd + '</code>' : escapeHtml(n.kind)} node`,
304
324
  description: `${plainDesc ? `<p>${escapeHtml(plainDesc)}</p>` : ''}<p>Resolves to ${modeBadge} via <code>${escapeHtml(n.agent || n.kind)}${n.command ? '.' + escapeHtml(n.command) : ''}</code>.</p>${Object.keys(inputsNoCode).length ? `<p><strong>Inputs</strong></p>${kvTable(inputsNoCode)}` : ''}${execSource ? `<p class="dim-note">Full source in the <strong>Code</strong> tab.</p>` : ''}${notesHtml}`,
305
325
  // Rich agent/command detail when the manifest is installed (n.skill); else
306
326
  // the lock-pointer fallback below (exec/agent-less nodes, uninstalled agents).
@@ -1624,7 +1644,7 @@
1624
1644
  card.setAttribute('role', 'status');
1625
1645
 
1626
1646
  const status = mkEl('div', 'cred-fail-status');
1627
- const icon = mkEl('span', 'cred-fail-icon', iconFor(cred.id));
1647
+ const icon = mkEl('span', 'cred-fail-icon', iconTextFor(cred.id));
1628
1648
  icon.setAttribute('aria-hidden', 'true');
1629
1649
  status.append(icon, document.createTextNode('SESSION EXPIRED'));
1630
1650
 
@@ -1774,6 +1794,7 @@
1774
1794
  const first = $body.querySelector('input:not(.fm-file-input),textarea');
1775
1795
  if (first) { first.focus(); if (first.select) first.select(); }
1776
1796
  else if (danger) $cancel.focus();
1797
+ else $ok.focus();
1777
1798
  // ── images-field setup ──────────────────────────────────────────────────────
1778
1799
  const imageStates = new Map();
1779
1800
  $body.querySelectorAll('.fm-images').forEach((box) => {
@@ -2563,6 +2584,7 @@
2563
2584
  // popover → Update now → POST /api/update/apply, which downloads + relaunches into
2564
2585
  // the new version. Hidden in dev/npm (supported:false).
2565
2586
  const $appUpdate = document.getElementById('app-update');
2587
+ let _appUpdateRecoveryTimer = null; // cleared before each new apply attempt
2566
2588
  // The apply body, extracted so the popover's "Update now" can drive it with the
2567
2589
  // polled target version (no textContent scrape). Preserves the npm-vs-desktop
2568
2590
  // branch verbatim: npm copies the command; desktop confirms → relaunch.
@@ -2578,18 +2600,32 @@
2578
2600
  }
2579
2601
  // desktop (Velopack): one-click download + relaunch
2580
2602
  const v = version || '';
2581
- if (!window.confirm('Download v' + v + ' and relaunch floless.app now?')) return;
2603
+ const go = await formModal({ title: 'Update and relaunch', sub: 'FloLess will download v' + v + ', close, and relaunch into the new version.', fields: [], okLabel: 'Update now' });
2604
+ if (!go) return;
2582
2605
  // Record the pending version so the new build's first paint can reveal the
2583
2606
  // what's-new panel (survives the relaunch via localStorage; cleared once shown).
2584
2607
  try { localStorage.setItem('floless.whatsNew.pending', v); } catch { /* private mode */ }
2608
+ if (_appUpdateRecoveryTimer) { clearTimeout(_appUpdateRecoveryTimer); _appUpdateRecoveryTimer = null; }
2585
2609
  $appUpdate.disabled = true;
2586
2610
  $appUpdate.textContent = '↑ Updating…';
2587
2611
  try {
2588
- const r = await fetch('/api/update/apply', { method: 'POST', headers: { 'content-type': 'application/json' } });
2612
+ const r = await fetch('/api/update/apply', { method: 'POST', headers: { 'content-type': 'application/json' }, signal: AbortSignal.timeout(5 * 60 * 1000) });
2589
2613
  const d = await r.json().catch(() => ({}));
2590
2614
  if (!r.ok || !d.ok) throw new Error(d.error || 'update failed');
2591
2615
  // Success: the server is exiting + Update.exe relaunches the new build. The health
2592
2616
  // poll flips to offline (R1 overlay), then the new version reconnects on its own.
2617
+ // Recovery guard: if the relaunch never happens and this page stays alive (e.g. a UAC
2618
+ // prompt was declined after the server already responded), re-enable the pill after 90 s
2619
+ // so the user can retry — the page won't go offline in that case.
2620
+ _appUpdateRecoveryTimer = setTimeout(() => {
2621
+ _appUpdateRecoveryTimer = null;
2622
+ if ($appUpdate && $appUpdate.disabled) {
2623
+ $appUpdate.disabled = false;
2624
+ $appUpdate.textContent = '↑ Relaunch failed — retry';
2625
+ try { localStorage.removeItem('floless.whatsNew.pending'); } catch { /* private mode */ }
2626
+ refreshUpdate(); // re-poll so the pill state reflects reality, same as catch path
2627
+ }
2628
+ }, 90_000);
2593
2629
  } catch (e) {
2594
2630
  $appUpdate.disabled = false;
2595
2631
  try { localStorage.removeItem('floless.whatsNew.pending'); } catch { /* private mode */ }
@@ -2667,7 +2703,8 @@
2667
2703
  async function applyAwareUpdate(version) {
2668
2704
  if (!$awareUpdate) return;
2669
2705
  const v = version || '';
2670
- if (!window.confirm('Upgrade AWARE runtime to v' + v + '? This reinstalls the npm package in place the app stays open and the version restamps automatically.')) return;
2706
+ const go = await formModal({ title: 'Upgrade AWARE runtime', sub: 'Reinstalls @aware-aeco/cli to v' + v + ' in place. The app stays open the version updates automatically when the install finishes.', fields: [], okLabel: 'Upgrade now' });
2707
+ if (!go) return;
2671
2708
  $awareUpdate.disabled = true;
2672
2709
  $awareUpdate.textContent = '↑ Upgrading…';
2673
2710
  try {
@@ -0,0 +1,129 @@
1
+ // install-path — escape Claude's MSIX bind-link virtualization for paths we
2
+ // REGISTER with Windows (Run key, floless:// handler, Scheduled Task) and for
3
+ // the license-store directory.
4
+ //
5
+ // Claude desktop ships as a Windows MSIX package (`Claude_pzs8sxrjxfjjc`) that
6
+ // virtualizes `%LOCALAPPDATA%` for processes running inside its container. A
7
+ // Velopack install targeting `%LOCALAPPDATA%\FlolessApp\` actually lands at
8
+ // `%LOCALAPPDATA%\Packages\Claude_pzs8sxrjxfjjc\LocalCache\Local\FlolessApp\`.
9
+ // From INSIDE the container both paths appear via a transparent bind-link;
10
+ // from OUTSIDE only the Packages\... path exists. So registering Windows-level
11
+ // hooks with `process.execPath` (the apparent virtual path) makes them fail at
12
+ // logon / from Explorer / from the browser's protocol launch with
13
+ // ERROR_FILE_NOT_FOUND (0x80070002). The fix is to register the un-virtualized
14
+ // REAL path. (Verified on this machine 2026-05-30: `fs.realpathSync` does NOT
15
+ // un-virtualize — the bind-link is opaque to it — so we explicitly remap.)
16
+ //
17
+ // .mjs (not .ts) so launch.mjs (plain JS, shipped to npm as-is) can import it.
18
+ // Pure functions accept dependency-injected fs helpers so they unit-test
19
+ // without touching the real filesystem. Thin wrappers bind the real fs at the
20
+ // edge.
21
+
22
+ import { existsSync, readdirSync, realpathSync } from 'node:fs';
23
+
24
+ /**
25
+ * @param {string} execPath the path to (possibly) resolve.
26
+ * @param {string | undefined} localAppData the `%LOCALAPPDATA%` env var (apparent value).
27
+ * @param {(p: string) => boolean} exists `fs.existsSync` injection.
28
+ * @param {(p: string) => string[]} readdir `fs.readdirSync` injection (Packages enumeration).
29
+ * @param {(p: string) => string} realpath `fs.realpathSync` injection (may throw on failure).
30
+ * @returns {string} the resolved real path, or `execPath` unchanged when no remap applies.
31
+ */
32
+ export function resolveRealInstallExePure(execPath, localAppData, exists, readdir, realpath) {
33
+ if (!execPath) return execPath;
34
+ // 1. realpath handles real symlinks/junctions cleanly (forward-compatible with
35
+ // a future non-container install). Defensive: if realpath throws (e.g. on
36
+ // a path that doesn't exist), keep the original — never block on this step.
37
+ let resolved = execPath;
38
+ try { resolved = realpath(execPath); } catch { resolved = execPath; }
39
+ if (resolved !== execPath) return resolved;
40
+
41
+ // 2. Container detection + remap. Only applies when execPath sits under the
42
+ // apparent %LOCALAPPDATA% (i.e. we were launched from inside the container's
43
+ // virtualized view). On non-Windows / non-container installs this returns
44
+ // execPath unchanged.
45
+ if (!localAppData) return execPath;
46
+ // Normalize any trailing separator(s) on the env-var value before the prefix
47
+ // check — Windows env vars carry whatever the user/installer wrote and a
48
+ // trailing `\` would otherwise produce `...local\\` and silently fail to
49
+ // match `...Local\FlolessApp\…` (dual-review finding #3, 2026-05-30).
50
+ const laRoot = localAppData.replace(/[\\/]+$/, '');
51
+ const lcExec = execPath.toLowerCase();
52
+ const lcPrefix = laRoot.toLowerCase() + '\\';
53
+ if (!lcExec.startsWith(lcPrefix)) return execPath;
54
+ const tail = execPath.slice(laRoot.length); // e.g. \FlolessApp\current\FlolessApp.exe
55
+
56
+ // Already a Packages\<pkg>\LocalCache\Local\... path (i.e. the REAL location)?
57
+ // Don't double-map. Also handles the case where outside-container launches
58
+ // arrive here with the real path already.
59
+ if (/^\\Packages\\[^\\]+\\LocalCache\\Local\\/i.test(tail)) return execPath;
60
+
61
+ const packagesDir = `${laRoot}\\Packages`;
62
+ let packages;
63
+ try { packages = readdir(packagesDir); } catch { return execPath; }
64
+ if (!Array.isArray(packages)) return execPath;
65
+ for (const pkg of packages) {
66
+ const candidate = `${packagesDir}\\${pkg}\\LocalCache\\Local${tail}`;
67
+ if (exists(candidate)) return candidate;
68
+ }
69
+ return execPath;
70
+ }
71
+
72
+ /**
73
+ * Pure: if `execPath` is under an MSIX package's `\Packages\<pkg>\LocalCache\Local\`
74
+ * (i.e. we were launched from OUTSIDE the container at the real path), derive
75
+ * `<pkg>\LocalCache\Local\FlolessApp-data` as the matching license-store
76
+ * directory. This is how a logon Scheduled Task / Run key launch (which sees
77
+ * `%LOCALAPPDATA%` as the REAL user LocalAppData, not the sandbox) still finds
78
+ * the license file Velopack wrote into the sandbox.
79
+ *
80
+ * @param {string} execPath
81
+ * @returns {string | null}
82
+ */
83
+ export function sandboxLicenseDirPure(execPath) {
84
+ if (!execPath) return null;
85
+ const m = execPath.match(/^(.*\\Packages\\[^\\]+\\LocalCache\\Local)\\/i);
86
+ return m ? `${m[1]}\\FlolessApp-data` : null;
87
+ }
88
+
89
+ // ── Real-fs wrappers ────────────────────────────────────────────────────────
90
+
91
+ /**
92
+ * @param {string} [execPath] defaults to `process.execPath`
93
+ * @returns {string}
94
+ */
95
+ export function resolveRealInstallExe(execPath = process.execPath) {
96
+ return resolveRealInstallExePure(
97
+ execPath,
98
+ process.env.LOCALAPPDATA,
99
+ existsSync,
100
+ readdirSync,
101
+ realpathSync,
102
+ );
103
+ }
104
+
105
+ /**
106
+ * Side-effect: when `process.execPath` reveals we were launched from the
107
+ * sandbox-real path (i.e. by a logon Scheduled Task / Run key sitting outside
108
+ * the container), set `FLOLESS_LICENSE_DIR` to the sandbox `FlolessApp-data`
109
+ * dir BEFORE any module reads it. Idempotent and respectful of an
110
+ * already-set env var (tests + the dev workflow). No-op in dev (tsx) and
111
+ * for non-container installs (execPath doesn't match the Packages\… shape).
112
+ */
113
+ export function deriveSandboxLicenseDir() {
114
+ if (process.env.FLOLESS_LICENSE_DIR) return;
115
+ const d = sandboxLicenseDirPure(process.execPath);
116
+ if (d) process.env.FLOLESS_LICENSE_DIR = d;
117
+ }
118
+
119
+ // Self-execute the side effect at module-evaluation time. ESM evaluates an
120
+ // imported module BEFORE control returns to subsequent imports in the
121
+ // importer, so making this top-level (rather than a call inside main.ts's
122
+ // body) guarantees FLOLESS_LICENSE_DIR is set before licensing.ts (a
123
+ // transitive import via main.ts → index.js) evaluates — even if some future
124
+ // rewrite of licensing.ts reads the env var at module-load time rather than
125
+ // lazily in storeDir(). Importing this module FIRST in main.ts preserves this
126
+ // ordering. Idempotent + no-op when the var is already set or execPath isn't a
127
+ // sandbox-real path, so the side effect is safe in every channel (SEA, npm,
128
+ // tests).
129
+ deriveSandboxLicenseDir();
package/log.mjs ADDED
@@ -0,0 +1,47 @@
1
+ // Shared logfile location + rotation for the floless.app server. Dependency-free
2
+ // (Node built-ins only) so BOTH launch.mjs (which routes the detached daemon's
3
+ // stdout/stderr here) and index.ts (which records crash stacks) resolve ONE log
4
+ // path — they must agree, or the daemon's output and the crash trace would land in
5
+ // different files and "why did it vanish?" stays unanswerable.
6
+ import { join } from 'node:path';
7
+ import { homedir } from 'node:os';
8
+ import { existsSync, mkdirSync, statSync, renameSync, openSync } from 'node:fs';
9
+
10
+ const MAX_BYTES = 2 * 1024 * 1024; // roll once to .1 past 2 MB
11
+
12
+ // Where logs live: FLOLESS_LOG_DIR override (tests), else the licensing store's home
13
+ // (%LocalAppData%\FlolessApp-data on Windows, ~/.aware elsewhere) under /logs.
14
+ export function logDir() {
15
+ if (process.env.FLOLESS_LOG_DIR) return process.env.FLOLESS_LOG_DIR;
16
+ if (process.platform === 'win32' && process.env.LOCALAPPDATA) {
17
+ return join(process.env.LOCALAPPDATA, 'FlolessApp-data', 'logs');
18
+ }
19
+ return join(homedir(), '.aware', 'logs');
20
+ }
21
+
22
+ export function logFilePath() {
23
+ return join(logDir(), 'floless-server.log');
24
+ }
25
+
26
+ export function ensureLogDir() {
27
+ try { mkdirSync(logDir(), { recursive: true }); } catch { /* best-effort */ }
28
+ }
29
+
30
+ // Roll an oversized log to .1 (replacing any previous .1). Best-effort; never throws.
31
+ // Safe to call from launch.mjs (before it opens the daemon's stdio fd) AND from the
32
+ // server at startup: the second call no-ops because the file is then freshly small.
33
+ export function rotateLog() {
34
+ try {
35
+ const p = logFilePath();
36
+ if (existsSync(p) && statSync(p).size > MAX_BYTES) renameSync(p, p + '.1');
37
+ } catch { /* best-effort */ }
38
+ }
39
+
40
+ // Open the logfile for appending (creating the dir first). Returns an fd, or null if
41
+ // it can't be opened — the caller then falls back to 'ignore' rather than failing.
42
+ export function openLogFd() {
43
+ try {
44
+ ensureLogDir();
45
+ return openSync(logFilePath(), 'a');
46
+ } catch { return null; }
47
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@floless/app",
3
- "version": "0.31.0",
3
+ "version": "0.31.2",
4
4
  "type": "module",
5
5
  "description": "Thin localhost host for floless.app — serves web/ and shells the aware CLI. No engine, no LLM.",
6
6
  "bin": {
@@ -8,8 +8,7 @@
8
8
  },
9
9
  "files": [
10
10
  "bin/",
11
- "launch.mjs",
12
- "teardown.mjs",
11
+ "*.mjs",
13
12
  "dist/",
14
13
  "package.json"
15
14
  ],
@@ -0,0 +1,83 @@
1
+ // One-shot, time-fresh marker signalling "the next launch is a post-self-update relaunch" (#37).
2
+ //
3
+ // After a Velopack self-update, Update.exe swaps current\ and relaunches FlolessApp.exe
4
+ // **bare** → the launcher defaults to `open` → a SECOND browser tab, while the user's existing
5
+ // tab is meant to adopt the new build in place. The marker tells cmdOpen to start the server
6
+ // WITHOUT opening a browser; the existing tab reloads itself onto the new build (see the health
7
+ // poll in web/aware.js).
8
+ //
9
+ // Two hard-won constraints shape the path + freshness:
10
+ //
11
+ // 1. WRITER ≠ READER, ACROSS AN MSIX BOUNDARY. The marker is dropped by the build completing
12
+ // the update (the updater process and/or Velopack's `--veloapp-updated` hook) and read by a
13
+ // DIFFERENT process (the bare relaunch's cmdOpen). In the Claude desktop install the hook
14
+ // runs INSIDE the MSIX container (virtualized %LOCALAPPDATA%) while the relaunch runs
15
+ // OUTSIDE it — so an execPath-derived path can disagree between writer and reader. We anchor
16
+ // the marker at ~/.floless (FLOLESS_HOME), which is OUTSIDE the virtualized LocalAppData
17
+ // bind-link and therefore identical in and out of the container. (We still ALSO read the
18
+ // legacy install-root location 0.12.1 wrote to, so an update FROM 0.12.1 is honored.)
19
+ //
20
+ // 2. TIME-FRESH, NOT JUST ONE-SHOT. If a marker is dropped but the relaunch never reaches
21
+ // cmdOpen (a failed/odd update, or Velopack writing the hook AFTER the relaunch), a stale
22
+ // marker must NOT silently swallow the browser on the user's next MANUAL open. Only a marker
23
+ // dropped within the last couple of minutes counts; either way it's consumed (deleted).
24
+ import { existsSync, writeFileSync, rmSync, statSync } from 'node:fs';
25
+ import { homedir } from 'node:os';
26
+ import { dirname, join } from 'node:path';
27
+
28
+ const FRESH_MS = 120_000;
29
+
30
+ /** Container-stable primary location (~/.floless/.post-update). FLOLESS_POST_UPDATE_MARKER overrides it (tests). */
31
+ export function markerPath() {
32
+ const override = (process.env.FLOLESS_POST_UPDATE_MARKER ?? '').trim();
33
+ if (override) return override;
34
+ const root = process.env.FLOLESS_HOME ?? join(homedir(), '.floless');
35
+ return join(root, '.post-update');
36
+ }
37
+
38
+ /** Legacy location 0.12.1 wrote to (install root, execPath-derived). Read for back-compat. */
39
+ function legacyMarkerPath() {
40
+ if ((process.env.FLOLESS_POST_UPDATE_MARKER ?? '').trim()) return null; // tests pin a single path
41
+ try {
42
+ return join(dirname(dirname(process.execPath)), '.floless-post-update');
43
+ } catch {
44
+ return null;
45
+ }
46
+ }
47
+
48
+ /** Best-effort: drop the one-shot marker before a self-update relaunch. Never throws. */
49
+ export function writePostUpdateMarker() {
50
+ let wrote = false;
51
+ for (const p of [markerPath(), legacyMarkerPath()]) {
52
+ if (!p) continue;
53
+ try {
54
+ writeFileSync(p, new Date().toISOString());
55
+ wrote = true;
56
+ } catch {
57
+ /* worst case: the pre-fix extra tab — never block the update on this */
58
+ }
59
+ }
60
+ return wrote;
61
+ }
62
+
63
+ /**
64
+ * Best-effort one-shot read: returns true iff a FRESH marker existed (dropped within FRESH_MS).
65
+ * Always deletes any marker it finds so it fires at most once and never lingers.
66
+ */
67
+ export function consumePostUpdateMarker() {
68
+ let fresh = false;
69
+ for (const p of [markerPath(), legacyMarkerPath()]) {
70
+ if (!p) continue;
71
+ try {
72
+ if (!existsSync(p)) continue;
73
+ const ageMs = Date.now() - statSync(p).mtimeMs;
74
+ rmSync(p, { force: true }); // consume regardless — one-shot, no lingering marker
75
+ // Fresh = written recently. No lower bound: a just-written file's mtime can read a few
76
+ // ms AHEAD of Date.now() (FS/clock granularity on Windows), which is still "recent".
77
+ if (ageMs < FRESH_MS) fresh = true;
78
+ } catch {
79
+ /* best-effort */
80
+ }
81
+ }
82
+ return fresh;
83
+ }