@hraness/slopcamera 3.5.0 → 3.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +42 -26
- package/apps/desktop/README.md +1 -1
- package/apps/desktop/application/html-overlay-renderer.ts +6 -0
- package/apps/desktop/application/operation.ts +1 -1
- package/apps/desktop/cli/args.ts +63 -1
- package/apps/desktop/cli/command-host-resources.ts +5 -0
- package/apps/desktop/cli/commands.ts +27 -1
- package/apps/desktop/cli/help.ts +43 -21
- package/apps/desktop/cli/html-film-init.ts +158 -0
- package/apps/desktop/cli/html-film-names.ts +17 -0
- package/apps/desktop/cli/html-film.ts +471 -0
- package/apps/desktop/cli/html-overlay-renderer.ts +29 -2
- package/apps/desktop/cli/html-scene.ts +3 -3
- package/apps/desktop/cli/main.ts +1 -0
- package/apps/desktop/cli/menubar-status.ts +156 -0
- package/apps/desktop/cli/menubar.ts +88 -136
- package/apps/desktop/cli/portable-surface.ts +55 -1
- package/apps/desktop/dist/cli/main.js +353 -328
- package/apps/desktop/html-overlay/film.ts +418 -0
- package/dist/cli.js +19 -9
- package/dist/generate.js +1 -1
- package/dist/{index-8bhp7cj2.js → index-842t02pp.js} +1 -1
- package/dist/{index-jab2mg0s.js → index-nv3byqa1.js} +20 -4
- package/dist/index-p74xy76d.js +3 -0
- package/dist/{index-24w75s6a.js → index-y8spxs77.js} +10 -7
- package/dist/index.js +1 -1
- package/dist/operations.js +1 -1
- package/dist/workflow.js +1 -1
- package/docs/README.md +5 -5
- package/examples/html/launch-film/README.md +51 -0
- package/examples/html/launch-film/build.ts +158 -0
- package/examples/html/launch-film/film.css +593 -0
- package/examples/html/launch-film/film.html +85 -0
- package/examples/html/launch-film/film.js +410 -0
- package/examples/html/launch-film/film.json +63 -0
- package/examples/html/launch-film/mockups.tsx +74 -0
- package/examples/html/launch-film/timeline.ts +57 -0
- package/package.json +14 -8
- package/skills/slopcamera/SKILL.md +3 -2
- package/skills/slopcamera/references/install.md +3 -3
- package/skills/slopcamera/references/launch-films.md +154 -0
- package/skills/slopcamera/references/visual-communication.md +1 -1
- package/src/cli.ts +138 -19
- package/src/generate.ts +44 -1
- package/src/generation-pricing.ts +237 -0
- package/src/icon-set.ts +1086 -0
- package/src/icon.ts +396 -59
- package/src/index.ts +1 -0
- package/src/operations.ts +52 -0
- package/src/support.ts +6 -0
- package/src/version.ts +1 -1
- package/dist/index-77fjfg9f.js +0 -3
|
@@ -1844,6 +1844,30 @@ async function fulfillPreparedRoute(
|
|
|
1844
1844
|
});
|
|
1845
1845
|
}
|
|
1846
1846
|
|
|
1847
|
+
/**
|
|
1848
|
+
* Validates a still/preview frame selection: distinct, ascending, in range.
|
|
1849
|
+
* Ascending order keeps the runtime's virtual clock monotonic.
|
|
1850
|
+
*/
|
|
1851
|
+
export function selectedHtmlOverlayFrames(
|
|
1852
|
+
frames: readonly number[],
|
|
1853
|
+
frameCount: number,
|
|
1854
|
+
): readonly number[] {
|
|
1855
|
+
if (frames.length === 0) {
|
|
1856
|
+
throw new ApplicationError("invalid-data", "A frame selection must name at least one frame.");
|
|
1857
|
+
}
|
|
1858
|
+
let previous = -1;
|
|
1859
|
+
for (const frame of frames) {
|
|
1860
|
+
if (!Number.isSafeInteger(frame) || frame < 0 || frame >= frameCount) {
|
|
1861
|
+
throw new ApplicationError("invalid-data", `Frame ${String(frame)} is outside the render range of ${String(frameCount)} frames.`);
|
|
1862
|
+
}
|
|
1863
|
+
if (frame <= previous) {
|
|
1864
|
+
throw new ApplicationError("invalid-data", "A frame selection must be strictly ascending.");
|
|
1865
|
+
}
|
|
1866
|
+
previous = frame;
|
|
1867
|
+
}
|
|
1868
|
+
return [...frames];
|
|
1869
|
+
}
|
|
1870
|
+
|
|
1847
1871
|
export class PlaywrightHtmlOverlayRenderer implements HtmlOverlayRenderer {
|
|
1848
1872
|
readonly #browserStepTimeoutMs: number;
|
|
1849
1873
|
readonly #cacheRoot: string;
|
|
@@ -1963,6 +1987,9 @@ export class PlaywrightHtmlOverlayRenderer implements HtmlOverlayRenderer {
|
|
|
1963
1987
|
join(request.outputDirectory, ".html-overlay-frames-"),
|
|
1964
1988
|
);
|
|
1965
1989
|
const frameCount = htmlOverlayFrameCount(request.authoring.timing);
|
|
1990
|
+
const frameIndexes = request.frames === undefined
|
|
1991
|
+
? this.orderedFrameIndexes(frameCount)
|
|
1992
|
+
: selectedHtmlOverlayFrames(request.frames, frameCount);
|
|
1966
1993
|
const diagnostics: string[] = [];
|
|
1967
1994
|
const pageErrors: string[] = [];
|
|
1968
1995
|
let browserRuntimeSnapshot: PreparedBrowserRuntimeSnapshot | undefined;
|
|
@@ -2306,7 +2333,7 @@ export class PlaywrightHtmlOverlayRenderer implements HtmlOverlayRenderer {
|
|
|
2306
2333
|
"document load",
|
|
2307
2334
|
);
|
|
2308
2335
|
await assertNoDeniedBrowserActivity(host);
|
|
2309
|
-
for (const frameIndex of
|
|
2336
|
+
for (const frameIndex of frameIndexes) {
|
|
2310
2337
|
if (signal.aborted) throw cancellationReason(signal);
|
|
2311
2338
|
const frame = createHtmlOverlayRuntimeFrame(
|
|
2312
2339
|
frameIndex,
|
|
@@ -2445,7 +2472,7 @@ export class PlaywrightHtmlOverlayRenderer implements HtmlOverlayRenderer {
|
|
|
2445
2472
|
renderResult = {
|
|
2446
2473
|
...(gpuEvidence === undefined ? {} : { gpuEvidence }),
|
|
2447
2474
|
executionIntegrity: execution.integrity,
|
|
2448
|
-
frameCount,
|
|
2475
|
+
frameCount: frameIndexes.length,
|
|
2449
2476
|
framePattern: join(finalFrameDirectory, "frame-%08d.png"),
|
|
2450
2477
|
libraryLocks: locks,
|
|
2451
2478
|
};
|
|
@@ -67,7 +67,7 @@ async function readPngHeader(path: string, expectedBytes: number) {
|
|
|
67
67
|
} finally { await handle.close(); }
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
async function
|
|
70
|
+
export async function readHtmlSceneSource(application: ApplicationContext, input: unknown, signal: AbortSignal) {
|
|
71
71
|
const request = HtmlSceneInputSchema.parse(input);
|
|
72
72
|
assertHtmlOverlayExecutionProfileLibraries(request.libraries, request.executionProfile);
|
|
73
73
|
const loadedDocument = "path" in request.document
|
|
@@ -100,7 +100,7 @@ async function readSource(application: ApplicationContext, input: unknown, signa
|
|
|
100
100
|
}
|
|
101
101
|
|
|
102
102
|
export async function planHtmlScene(application: ApplicationContext, input: unknown, signal: AbortSignal) {
|
|
103
|
-
const source = await
|
|
103
|
+
const source = await readHtmlSceneSource(application, input, signal);
|
|
104
104
|
return {
|
|
105
105
|
kind: "slopcamera.html-scene-plan" as const, schemaVersion: 1 as const,
|
|
106
106
|
executed: false, name: source.request.name, frameCount: source.frameCount,
|
|
@@ -221,7 +221,7 @@ export async function renderHtmlScene(
|
|
|
221
221
|
};
|
|
222
222
|
await active();
|
|
223
223
|
dependencies.progress?.("source");
|
|
224
|
-
const source = await
|
|
224
|
+
const source = await readHtmlSceneSource(application, input, signal);
|
|
225
225
|
if (application.htmlOverlayRenderer === undefined) throw new CliError("unavailable", "HTML scene renderer is unavailable.");
|
|
226
226
|
const tools = await (dependencies.bindTools ?? bindExactCapabilities)(application, ["ffmpeg", "ffprobe", "html-browser"]);
|
|
227
227
|
const tool = (name: string): ExactCapabilityBinding => {
|
package/apps/desktop/cli/main.ts
CHANGED
|
@@ -55,6 +55,7 @@ export async function main(
|
|
|
55
55
|
}
|
|
56
56
|
const paths = await resolveRepositoryPaths(processIo.cwd(), processIo.env);
|
|
57
57
|
if (earlyCommand.kind === "html-render" || earlyCommand.kind === "spatial-world" || earlyCommand.kind === "spatial-asset" || earlyCommand.kind === "spatial-design" || earlyCommand.kind === "directing" || earlyCommand.kind === "studio"
|
|
58
|
+
|| earlyCommand.kind === "html-film"
|
|
58
59
|
|| earlyCommand.kind === "spatial-scene" && (earlyCommand.action === "render" || earlyCommand.action === "camera-track" || earlyCommand.action === "render-audit" || earlyCommand.action === "review")) {
|
|
59
60
|
const controller = new AbortController();
|
|
60
61
|
const cancel = () => controller.abort(new CliError("cancelled", "Command interrupted."));
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { chmodSync, lstatSync, openSync, closeSync, readFileSync, renameSync, rmSync, writeSync, constants } from "node:fs";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
import type { CliCommand } from "./args";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The small status file the menu bar reads: what Slopcamera is doing now,
|
|
9
|
+
* how the last render ended, and the last credits balance a command saw.
|
|
10
|
+
* It holds plain labels, times and fixed error codes only — never paths,
|
|
11
|
+
* prompts, tokens or account identifiers. Writes are best effort: a status
|
|
12
|
+
* that can't be saved never fails the command.
|
|
13
|
+
*/
|
|
14
|
+
export const MENUBAR_STATUS_FILE = "menubar-status.json";
|
|
15
|
+
const MAX_STATUS_BYTES = 16 * 1024;
|
|
16
|
+
|
|
17
|
+
export type ActivityState = "running" | "done" | "failed";
|
|
18
|
+
|
|
19
|
+
export interface MenubarActivity {
|
|
20
|
+
readonly state: ActivityState;
|
|
21
|
+
readonly label: string;
|
|
22
|
+
readonly startedAt: number;
|
|
23
|
+
readonly finishedAt?: number;
|
|
24
|
+
readonly error?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface MenubarCredits {
|
|
28
|
+
readonly usd: string;
|
|
29
|
+
readonly low: boolean;
|
|
30
|
+
readonly checkedAt: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface MenubarStatus {
|
|
34
|
+
readonly schemaVersion: 1;
|
|
35
|
+
readonly activity?: MenubarActivity;
|
|
36
|
+
readonly credits?: MenubarCredits;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Plain labels for the commands that make media. Everything else leaves the status alone. */
|
|
40
|
+
export function activityLabel(command: CliCommand): string | undefined {
|
|
41
|
+
switch (command.kind) {
|
|
42
|
+
case "render-run": return command.dryRun ? undefined : "Rendering a recording";
|
|
43
|
+
case "project-render": return command.action === "run" && !command.dryRun ? "Rendering a project" : undefined;
|
|
44
|
+
case "project-cinema": return "Rendering a project";
|
|
45
|
+
case "html-render": return "Rendering an HTML scene";
|
|
46
|
+
case "html-film": return command.action === "deliver" ? "Encoding a film" : "Rendering film stills";
|
|
47
|
+
case "diagram-render": return "Rendering a diagram";
|
|
48
|
+
case "image-vectorize": return "Vectorizing an image";
|
|
49
|
+
case "ai-image-generate": return "Generating an image";
|
|
50
|
+
case "ai-video-generate": return "Generating a video";
|
|
51
|
+
case "ai-speech-generate": return "Generating speech";
|
|
52
|
+
case "directing": return "Directing a clip";
|
|
53
|
+
case "studio": return "Running a studio job";
|
|
54
|
+
case "spatial-scene": return command.action === "render" ? "Rendering a 3D scene" : undefined;
|
|
55
|
+
case "workflows-run": return "Running a workflow";
|
|
56
|
+
case "code-run": return "Running a workflow";
|
|
57
|
+
default: return undefined;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function parse(text: string): MenubarStatus {
|
|
62
|
+
try {
|
|
63
|
+
const value: unknown = JSON.parse(text);
|
|
64
|
+
if (typeof value !== "object" || value === null || (value as { schemaVersion?: unknown }).schemaVersion !== 1) return { schemaVersion: 1 };
|
|
65
|
+
const record = value as Record<string, unknown>;
|
|
66
|
+
const activity = record.activity as MenubarActivity | undefined;
|
|
67
|
+
const credits = record.credits as MenubarCredits | undefined;
|
|
68
|
+
return {
|
|
69
|
+
schemaVersion: 1,
|
|
70
|
+
...(activity !== undefined && typeof activity === "object" && typeof activity.label === "string" ? { activity } : {}),
|
|
71
|
+
...(credits !== undefined && typeof credits === "object" && typeof credits.usd === "string" ? { credits } : {}),
|
|
72
|
+
};
|
|
73
|
+
} catch {
|
|
74
|
+
return { schemaVersion: 1 };
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function readMenubarStatus(stateRoot: string): MenubarStatus {
|
|
79
|
+
const path = join(stateRoot, MENUBAR_STATUS_FILE);
|
|
80
|
+
try {
|
|
81
|
+
const info = lstatSync(path);
|
|
82
|
+
if (!info.isFile() || info.size > MAX_STATUS_BYTES) return { schemaVersion: 1 };
|
|
83
|
+
return parse(readFileSync(path, "utf8"));
|
|
84
|
+
} catch {
|
|
85
|
+
return { schemaVersion: 1 };
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const LOCK_FILE = `.${MENUBAR_STATUS_FILE}.lock`;
|
|
90
|
+
/** A held lock blocks a write for this long before it is skipped. */
|
|
91
|
+
const LOCK_BUDGET_MS = 250;
|
|
92
|
+
/** A lock older than this is a crashed writer's and is reclaimed. */
|
|
93
|
+
const LOCK_STALE_MS = 10_000;
|
|
94
|
+
|
|
95
|
+
function sleepSync(ms: number): void {
|
|
96
|
+
Bun.sleepSync(ms);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Runs `update` holding a small exclusive lock file, so a render finishing
|
|
101
|
+
* while `credits status` runs cannot overwrite the other slot. Returns
|
|
102
|
+
* undefined when the lock never comes free; the write is then skipped
|
|
103
|
+
* rather than lost-halfway.
|
|
104
|
+
*/
|
|
105
|
+
function withStatusLock<T>(stateRoot: string, update: () => T): T | undefined {
|
|
106
|
+
const lock = join(stateRoot, LOCK_FILE);
|
|
107
|
+
const deadline = Date.now() + LOCK_BUDGET_MS;
|
|
108
|
+
for (;;) {
|
|
109
|
+
let fd: number;
|
|
110
|
+
try {
|
|
111
|
+
fd = openSync(lock, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
|
112
|
+
} catch (error) {
|
|
113
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") return undefined;
|
|
114
|
+
try {
|
|
115
|
+
if (Date.now() - lstatSync(lock).mtimeMs > LOCK_STALE_MS) rmSync(lock, { force: true });
|
|
116
|
+
} catch { /* races with the lock holder */ }
|
|
117
|
+
if (Date.now() >= deadline) return undefined;
|
|
118
|
+
sleepSync(5);
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
closeSync(fd);
|
|
122
|
+
try {
|
|
123
|
+
return update();
|
|
124
|
+
} finally {
|
|
125
|
+
try { rmSync(lock, { force: true }); } catch { /* best effort */ }
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Replaces the status file atomically with an owner-only file. Never throws. */
|
|
131
|
+
export function writeMenubarStatus(stateRoot: string, update: (current: MenubarStatus) => MenubarStatus): void {
|
|
132
|
+
withStatusLock(stateRoot, () => {
|
|
133
|
+
const path = join(stateRoot, MENUBAR_STATUS_FILE);
|
|
134
|
+
const staged = join(stateRoot, `.${MENUBAR_STATUS_FILE}.${randomUUID()}.tmp`);
|
|
135
|
+
try {
|
|
136
|
+
const root = lstatSync(stateRoot);
|
|
137
|
+
if (!root.isDirectory()) return;
|
|
138
|
+
const text = `${JSON.stringify(update(readMenubarStatus(stateRoot)))}\n`;
|
|
139
|
+
if (Buffer.byteLength(text) > MAX_STATUS_BYTES) return;
|
|
140
|
+
const fd = openSync(staged, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
|
141
|
+
try { writeSync(fd, text); } finally { closeSync(fd); }
|
|
142
|
+
chmodSync(staged, 0o600);
|
|
143
|
+
renameSync(staged, path);
|
|
144
|
+
} catch {
|
|
145
|
+
try { rmSync(staged, { force: true }); } catch { /* best effort */ }
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function recordActivity(stateRoot: string, activity: MenubarActivity): void {
|
|
151
|
+
writeMenubarStatus(stateRoot, (current) => ({ ...current, activity }));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function recordCredits(stateRoot: string, credits: MenubarCredits): void {
|
|
155
|
+
writeMenubarStatus(stateRoot, (current) => ({ ...current, credits }));
|
|
156
|
+
}
|
|
@@ -7,6 +7,7 @@ import { writeJson, writeLine } from "./io";
|
|
|
7
7
|
import { ensurePrivateDirectory } from "./paths";
|
|
8
8
|
|
|
9
9
|
const SETTLE_MS = 400;
|
|
10
|
+
/** The label earlier releases used; the menu bar now manages its own login item. */
|
|
10
11
|
const LABEL = "com.hraness.slopcamera.menubar";
|
|
11
12
|
|
|
12
13
|
function xml(value: string): string { return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'"); }
|
|
@@ -109,109 +110,86 @@ function readAgent(path: string, environment: Environment): string | null {
|
|
|
109
110
|
return readRegular(path, 16 * 1024).toString("utf8");
|
|
110
111
|
}
|
|
111
112
|
|
|
112
|
-
/**
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
return interpretLaunchctlResult(result, uid, allowMissing);
|
|
120
|
-
}
|
|
121
|
-
export function interpretLaunchctlResult(result: { success: boolean; exitCode: number; stdout: Uint8Array; stderr: Uint8Array }, uid: number, allowMissing: boolean): string | null {
|
|
122
|
-
if (result.stdout.byteLength + result.stderr.byteLength > 64 * 1024) throw new CliError("unavailable", "launchctl exceeded the bounded response size.");
|
|
123
|
-
if (result.success && result.exitCode === 0) return Buffer.from(result.stdout).toString();
|
|
124
|
-
if (allowMissing && result.exitCode === 113 && result.stdout.length === 0 && Buffer.from(result.stderr).toString() === `Bad request.\nCould not find service "${LABEL}" in domain for user gui: ${uid}\n`) return null;
|
|
125
|
-
throw new CliError("unavailable", "launchctl could not reconcile the Slopcamera menu-bar LaunchAgent. Run menubar install again to retry.");
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
function loadedOwned(path: string, binary: string, current: string | null, run: LaunchctlRunner): boolean {
|
|
129
|
-
const output = run(["print", `gui/{uid}/${LABEL}`], true);
|
|
130
|
-
if (output === null) return false;
|
|
131
|
-
const uid = process.getuid?.();
|
|
132
|
-
if (current === null || Buffer.byteLength(output) > 64 * 1024 || !output.startsWith(`gui/${uid}/${LABEL} = {\n`)) {
|
|
133
|
-
throw new CliError("conflict", "The loaded menu-bar service has an unverified identity.");
|
|
134
|
-
}
|
|
135
|
-
const lines = output.split("\n").map(line => line.trim());
|
|
136
|
-
const field = (key: string): string | null => {
|
|
137
|
-
const values = lines.filter(line => line.startsWith(`${key} = `));
|
|
138
|
-
return values.length === 1 ? values[0]!.slice(key.length + 3) : null;
|
|
139
|
-
};
|
|
140
|
-
const argumentsStart = lines.indexOf("arguments = {");
|
|
141
|
-
const argumentsEnd = lines.indexOf("}", argumentsStart + 1);
|
|
142
|
-
if (field("path") !== path || field("program") !== binary || argumentsStart < 0 || argumentsEnd !== argumentsStart + 2 || lines[argumentsStart + 1] !== binary) {
|
|
143
|
-
throw new CliError("conflict", "The loaded menu-bar service does not match this install; it was left untouched.");
|
|
144
|
-
}
|
|
145
|
-
return true;
|
|
146
|
-
}
|
|
147
|
-
export function launchAgentState(binary: string, environment: Environment = process.env): LaunchAgentState {
|
|
148
|
-
const content = readAgent(launchAgentPath(environment), environment);
|
|
149
|
-
if (content === null) return "absent";
|
|
150
|
-
checkParents(binary, environment);
|
|
151
|
-
assertOwnedFile(binary);
|
|
152
|
-
return content === launchAgentPlist(binary) && exists(binary) ? "installed" : "conflict";
|
|
153
|
-
}
|
|
154
|
-
function assertMac(platform: NodeJS.Platform): void {
|
|
155
|
-
if (platform !== "darwin") throw new CliError("unsupported-plan", "Slopcamera menu-bar LaunchAgents are supported only on macOS.");
|
|
156
|
-
}
|
|
157
|
-
export function installLaunchAgent(binary: string, environment: Environment = process.env, platform: NodeJS.Platform = process.platform, runLaunchctl: LaunchctlRunner = launchctl): LaunchAgentState {
|
|
158
|
-
assertMac(platform);
|
|
159
|
-
if (!isAbsolute(binary)) throw new CliError("unsafe-path", "LaunchAgent binary must be an absolute path.");
|
|
113
|
+
/**
|
|
114
|
+
* Copies the built menu bar into its installed location, replacing an
|
|
115
|
+
* earlier copy. Existing shared directory permissions are preserved;
|
|
116
|
+
* symbolic links and files other users can write are refused.
|
|
117
|
+
*/
|
|
118
|
+
export function installBinary(binary: string, environment: Environment = process.env): string {
|
|
119
|
+
if (!isAbsolute(binary)) throw new CliError("unsafe-path", "The menu-bar build must be an absolute path.");
|
|
160
120
|
const stable = installedBinaryPath(environment);
|
|
161
|
-
const path = launchAgentPath(environment);
|
|
162
|
-
const current = readAgent(path, environment);
|
|
163
|
-
const expected = launchAgentPlist(stable);
|
|
164
121
|
checkParents(stable, environment);
|
|
165
122
|
assertOwnedFile(stable);
|
|
166
|
-
if (current !== null && current !== expected || current === null && infoAt(stable) !== null) {
|
|
167
|
-
throw new CliError("conflict", "The existing Slopcamera menu-bar install is not owned by this command.");
|
|
168
|
-
}
|
|
169
123
|
const bytes = readRegular(binary, MAX_BINARY_BYTES, true);
|
|
170
|
-
loadedOwned(path, stable, current, runLaunchctl);
|
|
171
|
-
checkParents(path, environment, true);
|
|
172
124
|
checkParents(stable, environment, true);
|
|
173
|
-
|
|
174
|
-
const binaryStage = mkdtempSync(join(dirname(stable), ".install-"));
|
|
175
|
-
let agentStage: string | undefined;
|
|
125
|
+
const stage = mkdtempSync(join(dirname(stable), ".install-"));
|
|
176
126
|
try {
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
const stagedAgent = join(agentStage, "agent.plist");
|
|
180
|
-
writeFileSync(stagedBinary, bytes, { mode: 0o700, flag: "wx" });
|
|
181
|
-
writeFileSync(stagedAgent, expected, { mode: 0o600, flag: "wx" });
|
|
182
|
-
if (readAgent(path, environment) !== current) throw new CliError("conflict", "The menu-bar install changed concurrently.");
|
|
127
|
+
const staged = join(stage, "binary");
|
|
128
|
+
writeFileSync(staged, bytes, { mode: 0o700, flag: "wx" });
|
|
183
129
|
assertOwnedFile(stable);
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
renameSync(stagedBinary, stable);
|
|
187
|
-
renameSync(stagedAgent, path);
|
|
188
|
-
runLaunchctl(["bootstrap", "gui/{uid}", path]);
|
|
189
|
-
return "installed";
|
|
130
|
+
renameSync(staged, stable);
|
|
131
|
+
return stable;
|
|
190
132
|
} finally {
|
|
191
|
-
rmSync(
|
|
192
|
-
if (agentStage !== undefined) rmSync(agentStage, { recursive: true });
|
|
133
|
+
rmSync(stage, { recursive: true });
|
|
193
134
|
}
|
|
194
135
|
}
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Removes the login item earlier releases wrote under the old label, but
|
|
139
|
+
* only when it is exactly the file they wrote. It never calls launchctl: a
|
|
140
|
+
* copy that is already running keeps running until you quit it or log out.
|
|
141
|
+
*/
|
|
142
|
+
export function removeLegacyAgent(environment: Environment = process.env): "absent" | "removed" | "foreign" {
|
|
198
143
|
const path = launchAgentPath(environment);
|
|
199
144
|
const current = readAgent(path, environment);
|
|
200
145
|
if (current === null) return "absent";
|
|
201
|
-
if (current !== launchAgentPlist(
|
|
202
|
-
checkParents(stable, environment);
|
|
203
|
-
assertOwnedFile(stable);
|
|
204
|
-
if (loadedOwned(path, stable, current, runLaunchctl)) runLaunchctl(["bootout", `gui/{uid}/${LABEL}`], true);
|
|
205
|
-
if (readAgent(path, environment) !== current) throw new CliError("conflict", "The menu-bar install changed concurrently.");
|
|
206
|
-
assertOwnedFile(stable);
|
|
146
|
+
if (current !== launchAgentPlist(installedBinaryPath(environment))) return "foreign";
|
|
207
147
|
rmSync(path);
|
|
208
|
-
|
|
209
|
-
|
|
148
|
+
return "removed";
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function assertMac(platform: NodeJS.Platform): void {
|
|
152
|
+
if (platform !== "darwin") throw new CliError("unsupported-plan", "The Slopcamera menu bar runs only on macOS.");
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export interface HelperResult { readonly code: number; readonly stdout: string; readonly stderr: string }
|
|
156
|
+
/** Runs the installed menu bar's own install, uninstall or status command. */
|
|
157
|
+
export type HelperRunner = (binary: string, args: readonly string[], env: Environment) => Promise<HelperResult>;
|
|
158
|
+
|
|
159
|
+
async function runHelper(binary: string, args: readonly string[], env: Environment): Promise<HelperResult> {
|
|
160
|
+
const child = Bun.spawn([binary, ...args], { stdin: "ignore", stdout: "pipe", stderr: "pipe", env: { ...env } as Record<string, string> });
|
|
161
|
+
const [stdout, stderr, code] = await Promise.all([new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited]);
|
|
162
|
+
return { code, stdout: stdout.slice(0, 64 * 1024), stderr: stderr.slice(0, 64 * 1024) };
|
|
210
163
|
}
|
|
164
|
+
|
|
165
|
+
const AGENT_MARKERS = ["AI_AGENT", "CLAUDECODE", "CODEX_SANDBOX", "CODEX_SANDBOX_NETWORK_DISABLED", "CURSOR_AGENT", "GEMINI_CLI"];
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* The helper decides between human and agent output from its environment.
|
|
169
|
+
* Its output is captured here, so a person's terminal is passed on as
|
|
170
|
+
* `HRANESS_AUDIENCE=human`; agents and explicit settings pass through.
|
|
171
|
+
*/
|
|
172
|
+
export function helperEnvironment(env: Environment): Environment {
|
|
173
|
+
const agent = AGENT_MARKERS.some((name) => (env[name] ?? "") !== "");
|
|
174
|
+
return env.HRANESS_AUDIENCE !== undefined || agent ? env : { ...env, HRANESS_AUDIENCE: "human" };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function helperFailure(result: HelperResult): CliError {
|
|
178
|
+
// The helper prints the login-item notice on stderr before the error;
|
|
179
|
+
// the failure is the last ✗ line plus the → hint that follows it.
|
|
180
|
+
const lines = result.stderr.split("\n");
|
|
181
|
+
let start = -1;
|
|
182
|
+
for (const [index, line] of lines.entries()) {
|
|
183
|
+
const trimmed = line.trimStart();
|
|
184
|
+
if (trimmed.startsWith("✗") || trimmed.startsWith("FAIL")) start = index;
|
|
185
|
+
}
|
|
186
|
+
const text = (start === -1 ? result.stderr : lines.slice(start).join("\n")).trim().replace(/^(✗|FAIL)\s+/u, "");
|
|
187
|
+
return new CliError(result.code === 2 ? "usage" : "unavailable", text === "" ? "The Slopcamera menu bar couldn't change its login item." : text);
|
|
188
|
+
}
|
|
189
|
+
|
|
211
190
|
/** `slopcamera-menubar` exits with this code when another copy already holds the menu bar. */
|
|
212
191
|
export const ALREADY_RUNNING_EXIT = 3;
|
|
213
192
|
const NOT_BUILT = "The Slopcamera menu bar isn't in this release yet. Build it with cargo build --release --manifest-path desktop/Cargo.toml in a Slopcamera checkout; slopcamera finds it there, or set SLOPCAMERA_MENUBAR to the built file.";
|
|
214
|
-
const LOGIN_ITEMS = "System Settings › General › Login Items & Extensions";
|
|
215
193
|
|
|
216
194
|
export type Launched = "running" | "already-running" | "exited";
|
|
217
195
|
export type BinaryRunner = (binary: string, foreground: boolean) => Promise<number | null>;
|
|
@@ -240,11 +218,6 @@ export function marks(env: Readonly<Record<string, string | undefined>>): Marks
|
|
|
240
218
|
return ascii ? { ok: "OK", warn: "WARN", note: "NOTE", next: "->" } : { ok: "✓", warn: "⚠", note: "🔐", next: "→" };
|
|
241
219
|
}
|
|
242
220
|
|
|
243
|
-
/** The LOGIN_ITEM notice (notifies, so there is no confirm line). The requester is the bare companion until Slopcamera ships as an app. */
|
|
244
|
-
export function loginItemNotice(env: Readonly<Record<string, string | undefined>>): string {
|
|
245
|
-
return `${marks(env).note} macOS will show a notice that slopcamera-menubar can open at login. That's Slopcamera's menu bar.\n It lists your outputs and opens when you log in. Nothing else runs in the background. Turn it off any time in ${LOGIN_ITEMS}.\n`;
|
|
246
|
-
}
|
|
247
|
-
|
|
248
221
|
export function launchMessage(outcome: Launched, env: Readonly<Record<string, string | undefined>>): string {
|
|
249
222
|
const mark = marks(env);
|
|
250
223
|
if (outcome === "already-running") return `${mark.ok} Slopcamera is already in your menu bar. Look for 📷.`;
|
|
@@ -252,42 +225,6 @@ export function launchMessage(outcome: Launched, env: Readonly<Record<string, st
|
|
|
252
225
|
return "The Slopcamera menu bar closed.";
|
|
253
226
|
}
|
|
254
227
|
|
|
255
|
-
export function stateMessage(action: "install" | "uninstall" | "status", state: LaunchAgentState, running: boolean, env: Readonly<Record<string, string | undefined>>): string {
|
|
256
|
-
const mark = marks(env);
|
|
257
|
-
if (state === "conflict") return `${mark.warn} Another menu-bar setup that this command didn't create is in the way. It was left untouched.\n Remove ~/Library/LaunchAgents/${LABEL}.plist yourself if you don't need it.`;
|
|
258
|
-
if (state === "absent") return action === "uninstall" ? `${mark.ok} Slopcamera no longer opens in your menu bar at login.` : `Slopcamera doesn't open at login.\n${mark.next} slopcamera menubar install`;
|
|
259
|
-
if (action === "install") return `${mark.ok} Slopcamera opens in your menu bar now and at every login. Look for 📷.\n Remove it any time: slopcamera menubar uninstall`;
|
|
260
|
-
return running
|
|
261
|
-
? `${mark.ok} Slopcamera opens at login and is in your menu bar now.`
|
|
262
|
-
: `${mark.warn} Slopcamera is set to open at login but isn't running. It may be turned off in ${LOGIN_ITEMS}.\n${mark.next} slopcamera menubar install`;
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
/** Whether launchd reports the loaded companion as running. Reads state only. */
|
|
266
|
-
export function serviceRunning(runLaunchctl: LaunchctlRunner): boolean {
|
|
267
|
-
const output = runLaunchctl(["print", `gui/{uid}/${LABEL}`], true);
|
|
268
|
-
return output !== null && output.split("\n").some((line) => line.trim() === "state = running");
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
export type ProcessProbe = () => boolean;
|
|
272
|
-
/** Whether any copy of the companion runs for this user, including one started by hand. */
|
|
273
|
-
function companionProcessRunning(): boolean {
|
|
274
|
-
const uid = process.getuid?.();
|
|
275
|
-
if (uid === undefined) return false;
|
|
276
|
-
try {
|
|
277
|
-
return Bun.spawnSync(["/usr/bin/pgrep", "-x", "-U", String(uid), "slopcamera-menubar"], { stdin: "ignore", stdout: "ignore", stderr: "ignore", timeout: 5_000 }).exitCode === 0;
|
|
278
|
-
} catch { return false; }
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
/** The menu bar is showing when launchd runs the login item or another copy holds it. */
|
|
282
|
-
async function menubarShowing(runLaunchctl: LaunchctlRunner, probe: ProcessProbe, waitMs: number): Promise<boolean> {
|
|
283
|
-
const deadline = Date.now() + waitMs;
|
|
284
|
-
for (;;) {
|
|
285
|
-
if (serviceRunning(runLaunchctl) || probe()) return true;
|
|
286
|
-
if (Date.now() >= deadline) return false;
|
|
287
|
-
await Bun.sleep(200);
|
|
288
|
-
}
|
|
289
|
-
}
|
|
290
|
-
|
|
291
228
|
export async function launchMenubar(io: CliIo, repositoryRoot: string, asJson: boolean, mode: "foreground" | "background" = "foreground", runBinary: BinaryRunner = spawnBinary): Promise<void> {
|
|
292
229
|
const binary = resolveMenubarBinary(repositoryRoot, io.env);
|
|
293
230
|
if (binary === null) throw new CliError("unavailable", NOT_BUILT);
|
|
@@ -295,23 +232,38 @@ export async function launchMenubar(io: CliIo, repositoryRoot: string, asJson: b
|
|
|
295
232
|
if (asJson) writeJson(io, { running: outcome !== "exited", foreground: mode === "foreground", alreadyRunning: outcome === "already-running" });
|
|
296
233
|
else writeLine(io, launchMessage(outcome, io.env));
|
|
297
234
|
}
|
|
298
|
-
export async function manageMenubar(io: CliIo, repositoryRoot: string, action: "install" | "uninstall" | "status", asJson: boolean,
|
|
235
|
+
export async function manageMenubar(io: CliIo, repositoryRoot: string, action: "install" | "uninstall" | "status", asJson: boolean, run: HelperRunner = runHelper): Promise<void> {
|
|
299
236
|
assertMac(io.platform);
|
|
300
237
|
const stable = installedBinaryPath(io.env);
|
|
301
|
-
|
|
238
|
+
const args = asJson ? [action, "--json"] : [action];
|
|
239
|
+
const env = helperEnvironment(io.env);
|
|
240
|
+
const forward = (result: HelperResult): void => {
|
|
241
|
+
if (result.stdout !== "") io.stdout(result.stdout);
|
|
242
|
+
if (result.code !== 0) throw helperFailure(result);
|
|
243
|
+
if (result.stderr !== "") io.stderr(result.stderr);
|
|
244
|
+
};
|
|
302
245
|
if (action === "install") {
|
|
303
246
|
const binary = resolveMenubarBinary(repositoryRoot, io.env);
|
|
304
247
|
if (binary === null) throw new CliError("unavailable", NOT_BUILT);
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
248
|
+
const installed = binary === stable ? stable : installBinary(binary, io.env);
|
|
249
|
+
forward(await run(installed, args, env));
|
|
250
|
+
// The old login item goes only after the new one is in place, so a
|
|
251
|
+
// failed install never leaves nothing to start at login.
|
|
252
|
+
removeLegacyAgent(io.env);
|
|
253
|
+
return;
|
|
309
254
|
}
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
255
|
+
if (action === "uninstall") removeLegacyAgent(io.env);
|
|
256
|
+
if (!exists(stable)) {
|
|
257
|
+
if (asJson) writeJson(io, { login: "off", running: false, installed: false });
|
|
258
|
+
else if (action === "uninstall") writeLine(io, `${marks(io.env).ok} Slopcamera won't open at login.`);
|
|
259
|
+
else writeLine(io, `Slopcamera's menu bar isn't installed.\n${marks(io.env).next} slopcamera menubar install`);
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
const result = await run(stable, args, env);
|
|
263
|
+
forward(result);
|
|
264
|
+
if (action === "uninstall") rmSync(stable);
|
|
314
265
|
}
|
|
266
|
+
|
|
315
267
|
export async function reportOutputsRoot(io: CliIo, stateRoot: string, asJson: boolean): Promise<void> {
|
|
316
268
|
const directory = outputsRoot(stateRoot);
|
|
317
269
|
await ensurePrivateDirectory(directory);
|
|
@@ -15,6 +15,10 @@ import {
|
|
|
15
15
|
type HtmlOverlayScaffoldKind,
|
|
16
16
|
} from "../html-overlay";
|
|
17
17
|
import { CliError } from "./errors";
|
|
18
|
+
import {
|
|
19
|
+
createHtmlFilmProject, HTML_FILM_ASPECTS, HTML_FILM_TEMPLATES,
|
|
20
|
+
type HtmlFilmAspect, type HtmlFilmInitResult, type HtmlFilmTemplate,
|
|
21
|
+
} from "./html-film-init";
|
|
18
22
|
|
|
19
23
|
const HEADLESS_SLOPCAMERA_CLI_MODULE = "@hraness/slopcamera/cli";
|
|
20
24
|
|
|
@@ -42,6 +46,7 @@ export interface PortableSurfaceDependencies {
|
|
|
42
46
|
readonly log?: (value: string) => void;
|
|
43
47
|
readonly runHeadless?: (argv: readonly string[], options?: Readonly<{ onUsefulResult?: UsefulResultObserver }>) => Promise<void>;
|
|
44
48
|
readonly writeScaffold?: (path: string, html: string) => Promise<void>;
|
|
49
|
+
readonly createFilm?: typeof createHtmlFilmProject;
|
|
45
50
|
}
|
|
46
51
|
function optionValue(argv: readonly string[], name: string): string | undefined {
|
|
47
52
|
const indexes = argv.flatMap((value, index) => value === name ? [index] : []);
|
|
@@ -215,6 +220,53 @@ function runHtmlCatalog(
|
|
|
215
220
|
return 0;
|
|
216
221
|
}
|
|
217
222
|
|
|
223
|
+
const HTML_INIT_USAGE = "Use slopcamera html init <dir> --template launch-film [--aspect 16:9|1:1|9:16] [--json].";
|
|
224
|
+
|
|
225
|
+
async function runHtmlInit(
|
|
226
|
+
argv: readonly string[],
|
|
227
|
+
dependencies: PortableSurfaceDependencies,
|
|
228
|
+
): Promise<number> {
|
|
229
|
+
let directory: string | undefined;
|
|
230
|
+
let template: string | undefined;
|
|
231
|
+
let aspect: string | undefined;
|
|
232
|
+
let json = false;
|
|
233
|
+
for (let index = 2; index < argv.length; index += 1) {
|
|
234
|
+
const value = argv[index]!;
|
|
235
|
+
if (value === "--json") {
|
|
236
|
+
if (json) throw new CliError("usage", "--json may be supplied at most once.");
|
|
237
|
+
json = true;
|
|
238
|
+
} else if (value === "--template" || value === "--aspect") {
|
|
239
|
+
const next = argv[index + 1];
|
|
240
|
+
if (next === undefined || next.startsWith("--")) throw new CliError("usage", `${value} requires a value.`);
|
|
241
|
+
if ((value === "--template" ? template : aspect) !== undefined) throw new CliError("usage", `${value} may be supplied at most once.`);
|
|
242
|
+
if (value === "--template") template = next;
|
|
243
|
+
else aspect = next;
|
|
244
|
+
index += 1;
|
|
245
|
+
} else if (value.startsWith("-") || directory !== undefined) {
|
|
246
|
+
throw new CliError("usage", HTML_INIT_USAGE);
|
|
247
|
+
} else {
|
|
248
|
+
directory = value;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
if (directory === undefined || template === undefined) throw new CliError("usage", HTML_INIT_USAGE);
|
|
252
|
+
if (!(HTML_FILM_TEMPLATES as readonly string[]).includes(template)) {
|
|
253
|
+
throw new CliError("usage", `Unknown film template ${template}. Use one of: ${HTML_FILM_TEMPLATES.join(", ")}.`);
|
|
254
|
+
}
|
|
255
|
+
const selectedAspect = aspect ?? "16:9";
|
|
256
|
+
if (!(HTML_FILM_ASPECTS as readonly string[]).includes(selectedAspect)) {
|
|
257
|
+
throw new CliError("usage", `--aspect must be one of ${HTML_FILM_ASPECTS.join(", ")}.`);
|
|
258
|
+
}
|
|
259
|
+
const result: HtmlFilmInitResult = await (dependencies.createFilm ?? createHtmlFilmProject)(
|
|
260
|
+
resolve((dependencies.cwd ?? process.cwd)(), directory),
|
|
261
|
+
{ template: template as HtmlFilmTemplate, aspect: selectedAspect as HtmlFilmAspect },
|
|
262
|
+
);
|
|
263
|
+
(dependencies.log ?? console.log)(json
|
|
264
|
+
? JSON.stringify(result)
|
|
265
|
+
: [`Created ${result.directory} from ${result.template} (${result.aspect})`, ...result.next.map(line => `next ${line}`)].join("\n"));
|
|
266
|
+
reportUsefulResult(dependencies.onUsefulResult);
|
|
267
|
+
return 0;
|
|
268
|
+
}
|
|
269
|
+
|
|
218
270
|
export async function runPortableSurface(
|
|
219
271
|
argvInput: readonly string[],
|
|
220
272
|
dependencies: PortableSurfaceDependencies = {},
|
|
@@ -222,8 +274,10 @@ export async function runPortableSurface(
|
|
|
222
274
|
const argv = canonicalizeUnifiedCliArgs(argvInput);
|
|
223
275
|
if (argv[0] === "style" && (argv.includes("--help") || argv.includes("-h"))) return undefined;
|
|
224
276
|
if (argv[0] === "html") {
|
|
225
|
-
if (argv[1] === "render" || argv
|
|
277
|
+
if (argv[1] === "render" || argv[1] === "still" || argv[1] === "preview" || argv[1] === "deliver"
|
|
278
|
+
|| argv.includes("--help") || argv.includes("-h")) return undefined;
|
|
226
279
|
if (argv[1] === "catalog") return runHtmlCatalog(argv, dependencies);
|
|
280
|
+
if (argv[1] === "init") return await runHtmlInit(argv, dependencies);
|
|
227
281
|
return await runHtmlScaffold(argv, dependencies);
|
|
228
282
|
}
|
|
229
283
|
const delegatesToHeadless = argv[0] === "diagram"
|