@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 +251 -0
- package/dist/floless-server.cjs +2 -2
- package/dist/web/app.css +5 -0
- package/dist/web/app.js +1 -1
- package/dist/web/aware.js +43 -6
- package/install-path.mjs +129 -0
- package/log.mjs +47 -0
- package/package.json +2 -3
- package/post-update-marker.mjs +83 -0
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, '&')
|
|
75
|
+
.replace(/</g, '<')
|
|
76
|
+
.replace(/>/g, '>')
|
|
77
|
+
.replace(/"/g, '"')
|
|
78
|
+
.replace(/'/g, ''');
|
|
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
|
+
}
|
package/dist/floless-server.cjs
CHANGED
|
@@ -52835,7 +52835,7 @@ function appVersion() {
|
|
|
52835
52835
|
return resolveVersion({
|
|
52836
52836
|
isSea: isSea2(),
|
|
52837
52837
|
sqVersionXml: readSqVersionXml(),
|
|
52838
|
-
define: true ? "0.31.
|
|
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.
|
|
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
|
|
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',
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
package/install-path.mjs
ADDED
|
@@ -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.
|
|
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
|
-
"
|
|
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
|
+
}
|