beauticode-dsh 1.0.7 → 1.0.13

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.
@@ -1,6 +1,6 @@
1
1
  import path from "node:path";
2
2
  import { fileURLToPath } from "node:url";
3
- import { ApplyTransaction, BackgroundStore, MediaServerController, buildHostApplyPayload, defaultDataRoot, persistAndApplyImportedMedia, resolveSessionBundledThemes, } from "../core/index.js";
3
+ import { ApplyTransaction, BackgroundStore, MediaServerController, buildHostApplyPayload, defaultDataRoot, isLocalBackgroundSource, resolveSessionBundledThemes, resolveBackgroundImagePath, } from "../core/index.js";
4
4
  import { DshHostApplier, normalizeDshBaseUrl } from "./bridge.js";
5
5
  import { DSH_HOST_DESCRIPTOR } from "./host-descriptor.js";
6
6
  import { acquireDshInjectorLock } from "./injector-lock.js";
@@ -93,16 +93,26 @@ export class DshSession {
93
93
  this.startHandoffLoop();
94
94
  void this.yieldToTrayIfRequested();
95
95
  }
96
- // Finish the initial restore check before accepting the first user apply.
97
- // Otherwise a click immediately after lazy session startup can race the
98
- // background reapply and be rejected as a concurrent operation.
99
- await this.watchOnce();
96
+ void this.watchOnce();
100
97
  return { port: this.bridgePort() };
101
98
  }
102
99
  async apply(input) {
103
100
  return this.trackOperation(this.applyInternal(input));
104
101
  }
105
- async applyInternal(input) {
102
+ async applyAndSaveTheme(input, name) {
103
+ const result = await this.trackOperation(this.applyInternal(input, name));
104
+ if (!result.ok)
105
+ return result;
106
+ if (!result.theme) {
107
+ return {
108
+ ok: false,
109
+ error: "Theme apply completed without a saved theme.",
110
+ rolledBack: false,
111
+ };
112
+ }
113
+ return { ...result, theme: result.theme };
114
+ }
115
+ async applyInternal(input, themeName) {
106
116
  if (!this.releaseLock || this.closed || !this.host) {
107
117
  throw new Error("Session is not started");
108
118
  }
@@ -115,16 +125,37 @@ export class DshSession {
115
125
  }
116
126
  this.userBusy = true;
117
127
  try {
128
+ // The startup/watch probe may already have opened active/background.json
129
+ // before userBusy became true. On Windows that read can overlap the
130
+ // transaction's atomic active-directory rename and cause EPERM. Drain
131
+ // that one read before beginning any apply mutation; later watch ticks
132
+ // observe userBusy and do not reapply.
133
+ const inFlightWatch = this.watchTask;
134
+ if (inFlightWatch)
135
+ await inFlightWatch;
118
136
  const tx = new ApplyTransaction({
119
137
  store: this.store,
120
138
  media: this.media,
121
139
  host: this.host,
140
+ includeImageDataUrl: false,
122
141
  verifyDeadlineMs: this.verifyDeadlineMs,
123
142
  offline: false,
124
143
  });
125
- const result = await tx.run(input);
144
+ const saved = { theme: null };
145
+ const result = await tx.run(input, themeName
146
+ ? {
147
+ beforeFinalize: async () => {
148
+ saved.theme = await this.store.saveCurrentTheme(themeName);
149
+ },
150
+ onRollback: async () => {
151
+ if (saved.theme) {
152
+ await this.store.deleteSavedTheme(saved.theme.id);
153
+ }
154
+ },
155
+ }
156
+ : undefined);
126
157
  if (result.ok) {
127
- this.activeThemeId = null;
158
+ this.activeThemeId = saved.theme?.type === "video" ? saved.theme.id : null;
128
159
  this.lastProgressWriteSec = -1;
129
160
  if (input.type === "clear") {
130
161
  this.fishMode = false;
@@ -143,7 +174,7 @@ export class DshSession {
143
174
  }
144
175
  await this.host.setBackgroundTone(this.backgroundTone).catch(() => null);
145
176
  }
146
- return result;
177
+ return result.ok && saved.theme ? { ...result, theme: saved.theme } : result;
147
178
  }
148
179
  finally {
149
180
  this.userBusy = false;
@@ -184,21 +215,26 @@ export class DshSession {
184
215
  }
185
216
  }
186
217
  if (manifest.background) {
187
- const runtimeImagePath = await this.store.prepareRuntimeImage(manifest);
188
- if (!runtimeImagePath) {
189
- throw new Error("DSH image reapply requires a detached runtime copy.");
190
- }
191
- stagedImage = await this.media.stage(runtimeImagePath);
192
- if (manifest.background.type === "video" && manifest.background.video) {
218
+ const imagePath = resolveBackgroundImagePath(this.store.paths.activeDir, manifest.background);
219
+ if (!imagePath)
220
+ throw new Error("Background has no image source.");
221
+ stagedImage = await this.media.stage(imagePath, {
222
+ validation: manifest.background.type === "image" && isLocalBackgroundSource(manifest.background)
223
+ ? "fast"
224
+ : "full",
225
+ });
226
+ if (manifest.background.type === "video") {
193
227
  const runtimeVideoPath = await this.store.prepareRuntimeVideo(manifest);
194
228
  if (!runtimeVideoPath) {
195
229
  throw new Error("DSH video reapply requires a detached runtime copy.");
196
230
  }
197
- stagedVideo = await this.media.stage(runtimeVideoPath);
231
+ stagedVideo = await this.media.stage(runtimeVideoPath, {
232
+ validation: isLocalBackgroundSource(manifest.background) ? "fast" : "full",
233
+ });
198
234
  }
199
235
  }
200
236
  const staged = { image: stagedImage, video: stagedVideo };
201
- const payload = await buildHostApplyPayload(this.store, manifest, staged, "");
237
+ const payload = await buildHostApplyPayload(this.store, manifest, staged, "", undefined, { includeImageDataUrl: false });
202
238
  if (payload.video && resumeAt != null)
203
239
  payload.video.startAt = resumeAt;
204
240
  await this.host.apply(payload);
@@ -261,7 +297,6 @@ export class DshSession {
261
297
  fish: this.fishMode,
262
298
  muted: this.videoMuted,
263
299
  tone: this.backgroundTone,
264
- themeId: this.activeThemeId,
265
300
  };
266
301
  }
267
302
  async saveCurrentTheme(name) {
@@ -278,16 +313,15 @@ export class DshSession {
278
313
  }
279
314
  }
280
315
  const theme = await this.store.saveCurrentTheme(name, { videoPositionSec });
281
- this.activeThemeId = theme.id;
282
- this.lastProgressWriteSec = -1;
316
+ if (theme.type === "video") {
317
+ this.activeThemeId = theme.id;
318
+ this.lastProgressWriteSec = -1;
319
+ }
320
+ else {
321
+ this.activeThemeId = null;
322
+ }
283
323
  return theme;
284
324
  }
285
- async importSavedTheme(input) {
286
- return this.store.importSavedThemeFromMedia(input);
287
- }
288
- async applyImportedMedia(input, opts = {}) {
289
- return this.trackOperation(persistAndApplyImportedMedia(this, input, opts));
290
- }
291
325
  async listSavedThemes() {
292
326
  return this.store.listSavedThemes();
293
327
  }
@@ -304,7 +338,7 @@ export class DshSession {
304
338
  const saved = await this.store.loadSavedTheme(themeId);
305
339
  const result = await this.apply(saved.input);
306
340
  if (result.ok) {
307
- this.activeThemeId = saved.themeId;
341
+ this.activeThemeId = saved.input.type === "video" ? saved.themeId : null;
308
342
  this.lastProgressWriteSec = -1;
309
343
  }
310
344
  return result;
@@ -1,19 +1,26 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
- import { isRecordedPidLive } from "../core/index.js";
4
3
  export const TRAY_CLAIM_FILE = "tray-claim.json";
5
4
  export const TRAY_CLAIM_SCHEMA = "beauticode.tray-claim/v1";
6
5
  export const SESSION_HOST_FILE = "session-host.json";
7
6
  export const SESSION_HOST_SCHEMA = "beauticode.session-host/v1";
8
7
  export const DSH_CONTROL_FILE = "dsh-control.json";
9
8
  export const DSH_CONTROL_SCHEMA = "beauticode.dsh-control/v1";
9
+ function isPidAlive(pid) {
10
+ if (!Number.isInteger(pid) || pid <= 0)
11
+ return false;
12
+ try {
13
+ process.kill(pid, 0);
14
+ return true;
15
+ }
16
+ catch (error) {
17
+ return Boolean(error && typeof error === "object" && "code" in error && error.code === "EPERM");
18
+ }
19
+ }
10
20
  async function readLivePid(filePath, schema) {
11
21
  let raw;
12
- let mtimeMs = 0;
13
22
  try {
14
- const [text, stat] = await Promise.all([fs.readFile(filePath, "utf8"), fs.stat(filePath)]);
15
- raw = text;
16
- mtimeMs = stat.mtimeMs;
23
+ raw = await fs.readFile(filePath, "utf8");
17
24
  }
18
25
  catch {
19
26
  return null;
@@ -23,12 +30,7 @@ async function readLivePid(filePath, schema) {
23
30
  if (schema && parsed.schema !== schema)
24
31
  return null;
25
32
  const pid = Number(parsed.pid);
26
- if (!Number.isInteger(pid) || pid <= 0)
27
- return null;
28
- const startedAt = typeof parsed.startedAt === "string" && parsed.startedAt
29
- ? parsed.startedAt
30
- : new Date(mtimeMs).toISOString();
31
- if (!(await isRecordedPidLive(pid, startedAt)))
33
+ if (!Number.isInteger(pid) || pid <= 0 || !isPidAlive(pid))
32
34
  return null;
33
35
  return pid;
34
36
  }
@@ -1,6 +1,7 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { MAX_INLINE_DATA_URL_BYTES } from "./constants.js";
4
+ import { isLocalBackgroundSource, resolveBackgroundImagePath, resolveBackgroundVideoPath, } from "./media-source.js";
4
5
  import { detectImageMime } from "./media-validation.js";
5
6
  /**
6
7
  * Minimal baseline only — live Codex path injects packages/adapter-codex
@@ -39,6 +40,7 @@ export class ApplyTransaction {
39
40
  cssText;
40
41
  verifyDeadlineMs;
41
42
  offline;
43
+ includeImageDataUrl;
42
44
  #busy = false;
43
45
  constructor(opts) {
44
46
  this.store = opts.store;
@@ -47,11 +49,12 @@ export class ApplyTransaction {
47
49
  this.cssText = opts.cssText ?? DEFAULT_CSS;
48
50
  this.verifyDeadlineMs = opts.verifyDeadlineMs ?? 30_000;
49
51
  this.offline = opts.offline ?? false;
52
+ this.includeImageDataUrl = opts.includeImageDataUrl ?? true;
50
53
  }
51
54
  get busy() {
52
55
  return this.#busy;
53
56
  }
54
- async run(input) {
57
+ async run(input, hooks = {}) {
55
58
  if (this.#busy) {
56
59
  return {
57
60
  ok: false,
@@ -61,7 +64,7 @@ export class ApplyTransaction {
61
64
  }
62
65
  this.#busy = true;
63
66
  try {
64
- return await this.store.withExclusiveMutation(() => this.#runExclusive(input));
67
+ return await this.store.withExclusiveMutation(() => this.#runExclusive(input, hooks));
65
68
  }
66
69
  catch (error) {
67
70
  return {
@@ -74,45 +77,78 @@ export class ApplyTransaction {
74
77
  this.#busy = false;
75
78
  }
76
79
  }
77
- async #runExclusive(input) {
80
+ async #runExclusive(input, hooks) {
81
+ const startedAt = performance.now();
82
+ const phases = {};
83
+ const measure = async (name, work) => {
84
+ const phaseStartedAt = performance.now();
85
+ try {
86
+ return await work();
87
+ }
88
+ finally {
89
+ phases[name] = Math.round((performance.now() - phaseStartedAt) * 10) / 10;
90
+ }
91
+ };
92
+ const requestedSourceMode = input.type === "clear" ? "clear" : input.source ?? "managed";
93
+ const timings = () => ({
94
+ totalMs: Math.round((performance.now() - startedAt) * 10) / 10,
95
+ phases: { ...phases },
96
+ });
78
97
  let snapshot = null;
79
98
  let staged = null;
80
99
  let runtimeVideoPath = null;
100
+ let finalizeStarted = false;
81
101
  try {
82
- await this.store.init();
83
- snapshot = await this.store.snapshot();
84
- const manifest = await this.store.commitImport(input);
85
- staged = await this.#stageMediaFor(manifest);
102
+ await measure("initialize", () => this.store.init());
103
+ snapshot = await measure("snapshot", () => this.store.snapshot());
104
+ const manifest = await measure("commitImport", () => this.store.commitImport(input));
105
+ const sourceMode = !manifest.background
106
+ ? "clear"
107
+ : isLocalBackgroundSource(manifest.background)
108
+ ? "local"
109
+ : "managed";
110
+ if (requestedSourceMode === "local" && sourceMode !== "local") {
111
+ throw new Error("Local import contract was not preserved by the media store.");
112
+ }
113
+ staged = await measure("stageMedia", () => this.#stageMediaFor(manifest));
86
114
  if (!this.offline && this.host) {
87
- const payload = await this.#buildPayload(manifest, staged, input.type === "video" ? input.startAt : undefined);
115
+ const payload = await measure("buildPayload", () => this.#buildPayload(manifest, staged, input.type === "video" ? input.startAt : undefined));
88
116
  runtimeVideoPath = payload.video?.localPath ?? null;
89
- await this.host.apply(payload);
90
- const verify = await this.host.verify({
117
+ await measure("hostApply", () => this.host.apply(payload));
118
+ const verify = await measure("rendererVerify", () => this.host.verify({
91
119
  generation: manifest.generation,
92
120
  media: manifest.background?.type ?? "clear",
93
- }, { deadlineMs: this.verifyDeadlineMs });
121
+ }, { deadlineMs: this.verifyDeadlineMs }));
94
122
  if (verify.status !== "pass") {
95
- await this.#rollback(snapshot, staged);
123
+ await measure("rollback", () => this.#rollback(snapshot, staged));
96
124
  staged = null;
97
125
  snapshot = null;
98
126
  return {
99
127
  ok: false,
100
128
  error: `Live verify did not pass (${verify.status}): ${verify.reason}`,
101
129
  rolledBack: true,
130
+ sourceMode,
131
+ timings: timings(),
102
132
  };
103
133
  }
104
134
  }
105
- await this.media.commit(staged);
106
- await this.store.pruneRuntimeMedia(runtimeKeepPaths(staged, runtimeVideoPath));
135
+ await measure("mediaCommit", () => this.media.commit(staged));
136
+ await measure("pruneRuntimeMedia", () => this.store.pruneRuntimeMedia(runtimeVideoPath));
107
137
  staged = null;
138
+ if (hooks.beforeFinalize) {
139
+ finalizeStarted = true;
140
+ await measure("saveTheme", () => hooks.beforeFinalize(manifest));
141
+ }
108
142
  if (snapshot) {
109
- await this.store.clearSnapshot(snapshot);
143
+ await measure("clearSnapshot", () => this.store.clearSnapshot(snapshot));
110
144
  snapshot = null;
111
145
  }
112
146
  return {
113
147
  ok: true,
114
148
  generation: manifest.generation,
115
149
  mode: manifest.background?.type ?? "clear",
150
+ sourceMode,
151
+ timings: timings(),
116
152
  };
117
153
  }
118
154
  catch (err) {
@@ -120,7 +156,7 @@ export class ApplyTransaction {
120
156
  let rolledBack = false;
121
157
  if (snapshot) {
122
158
  try {
123
- await this.#rollback(snapshot, staged);
159
+ await measure("rollback", () => this.#rollback(snapshot, staged));
124
160
  staged = null;
125
161
  snapshot = null;
126
162
  rolledBack = true;
@@ -134,32 +170,47 @@ export class ApplyTransaction {
134
170
  await this.#abortPair(staged);
135
171
  staged = null;
136
172
  }
137
- return { ok: false, error: message, rolledBack };
173
+ if (finalizeStarted && hooks.onRollback) {
174
+ await measure("finalizeRollback", () => hooks.onRollback()).catch(() => { });
175
+ }
176
+ return {
177
+ ok: false,
178
+ error: message,
179
+ rolledBack,
180
+ sourceMode: requestedSourceMode,
181
+ timings: timings(),
182
+ };
138
183
  }
139
184
  }
140
185
  async #stageMediaFor(manifest) {
141
186
  if (!manifest.background) {
142
187
  return { image: null, video: null };
143
188
  }
144
- // Serve stills and videos from runtime-media copies so Windows can rotate
145
- // `active/` while the previous generation is still being read.
146
- const runtimeImagePath = await this.store.prepareRuntimeImage(manifest);
147
- if (!runtimeImagePath) {
148
- throw new Error("Image media staging requires a detached runtime copy.");
149
- }
150
- const image = await this.media.stage(runtimeImagePath);
189
+ const imagePath = resolveBackgroundImagePath(this.store.paths.activeDir, manifest.background);
190
+ if (!imagePath)
191
+ throw new Error("Background has no image source.");
192
+ const image = await this.media.stage(imagePath, {
193
+ validation: manifest.background.type === "image" && isLocalBackgroundSource(manifest.background)
194
+ ? "fast"
195
+ : "full",
196
+ });
151
197
  let video = null;
152
- if (manifest.background.type === "video" && manifest.background.video) {
198
+ if (manifest.background.type === "video") {
199
+ // DSH streams this handle for the lifetime of the active <video>. On
200
+ // Windows, serving active/background.mp4 directly keeps the active
201
+ // directory open and the next atomic directory swap fails with EPERM.
153
202
  const runtimeVideoPath = await this.store.prepareRuntimeVideo(manifest);
154
203
  if (!runtimeVideoPath) {
155
204
  throw new Error("Video media staging requires a detached runtime copy.");
156
205
  }
157
- video = await this.media.stage(runtimeVideoPath);
206
+ video = await this.media.stage(runtimeVideoPath, {
207
+ validation: isLocalBackgroundSource(manifest.background) ? "fast" : "full",
208
+ });
158
209
  }
159
210
  return { image, video };
160
211
  }
161
212
  async #buildPayload(manifest, staged, videoStartAt) {
162
- return buildHostApplyPayload(this.store, manifest, staged, this.cssText, videoStartAt);
213
+ return buildHostApplyPayload(this.store, manifest, staged, this.cssText, videoStartAt, { includeImageDataUrl: this.includeImageDataUrl });
163
214
  }
164
215
  async #abortPair(staged) {
165
216
  if (!staged)
@@ -190,7 +241,7 @@ export class ApplyTransaction {
190
241
  }
191
242
  }
192
243
  await this.media.commit(restoredMedia);
193
- await this.store.pruneRuntimeMedia(runtimeKeepPaths(restoredMedia, runtimeVideoPath));
244
+ await this.store.pruneRuntimeMedia(runtimeVideoPath);
194
245
  restoredMedia = null;
195
246
  await this.store.clearSnapshot(snapshot);
196
247
  }
@@ -199,17 +250,7 @@ export class ApplyTransaction {
199
250
  }
200
251
  }
201
252
  }
202
- function runtimeKeepPaths(staged, extra) {
203
- const keep = [];
204
- if (staged?.image?.filePath)
205
- keep.push(staged.image.filePath);
206
- if (staged?.video?.filePath)
207
- keep.push(staged.video.filePath);
208
- if (extra)
209
- keep.push(extra);
210
- return keep;
211
- }
212
- export async function buildHostApplyPayload(store, manifest, staged, cssText, videoStartAt) {
253
+ export async function buildHostApplyPayload(store, manifest, staged, cssText, videoStartAt, opts = {}) {
213
254
  if (!manifest.background) {
214
255
  return {
215
256
  generation: manifest.generation,
@@ -220,8 +261,10 @@ export async function buildHostApplyPayload(store, manifest, staged, cssText, vi
220
261
  cssText,
221
262
  };
222
263
  }
223
- const imagePath = path.join(store.paths.activeDir, manifest.background.image);
224
- const imageDataUrl = await fileToDataUrl(imagePath);
264
+ const imagePath = resolveBackgroundImagePath(store.paths.activeDir, manifest.background);
265
+ if (!imagePath)
266
+ throw new Error("Background has no image source.");
267
+ const imageDataUrl = opts.includeImageDataUrl === false ? null : await fileToDataUrl(imagePath);
225
268
  const imageUrl = staged?.image?.srcUrl ?? null;
226
269
  if (manifest.background.type === "image") {
227
270
  return {
@@ -234,10 +277,9 @@ export async function buildHostApplyPayload(store, manifest, staged, cssText, vi
234
277
  atmosphere: manifest.background.effects ?? null,
235
278
  };
236
279
  }
237
- if (!manifest.background.video) {
238
- throw new Error("Video background is missing a video basename.");
239
- }
240
- const videoPath = path.join(store.paths.activeDir, manifest.background.video);
280
+ const videoPath = resolveBackgroundVideoPath(store.paths.activeDir, manifest.background);
281
+ if (!videoPath)
282
+ throw new Error("Video background has no video source.");
241
283
  const runtimeVideoPath = await store.prepareRuntimeVideo(manifest);
242
284
  if (!runtimeVideoPath) {
243
285
  throw new Error("Video payload requires a detached runtime copy.");