@floless/app 0.31.0 → 0.31.1

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.1" : 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.1" : void 0 });
52849
52849
  }
52850
52850
 
52851
52851
  // oauth-presets.ts
@@ -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.1",
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
+ }