beauticode-dsh 1.0.3 → 1.0.4
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/cli.js +151 -15
- package/package.json +1 -1
- package/vendor/adapter-dsh/session.js +5 -1
- package/vendor/core/apply-transaction.js +19 -7
- package/vendor/core/background-store.js +82 -46
- package/vendor/core/media-server.js +23 -9
package/cli.js
CHANGED
|
@@ -78,19 +78,66 @@ function packageInsert() {
|
|
|
78
78
|
].join("\n");
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
-
function hasBridge(text) {
|
|
82
|
-
return new RegExp(`^\\s*-\\s*id:\\s*${bridgeId}\\s*$`, "m").test(text);
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
function stripBridge(text) {
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
let
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
81
|
+
function hasBridge(text) {
|
|
82
|
+
return new RegExp(`^\\s*-\\s*id:\\s*${bridgeId}\\s*$`, "m").test(text);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function stripBridge(text) {
|
|
86
|
+
const lines = text.split(/\r?\n/);
|
|
87
|
+
const removed = new Array(lines.length).fill(false);
|
|
88
|
+
const indentOf = (line) => line.match(/^[ \t]*/)?.[0].length ?? 0;
|
|
89
|
+
|
|
90
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
91
|
+
const insert = lines[i].match(/^([ \t]*)-\s*insert:\s*(?:#.*)?$/);
|
|
92
|
+
if (!insert) continue;
|
|
93
|
+
const insertIndent = insert[1].length;
|
|
94
|
+
let blockEnd = i + 1;
|
|
95
|
+
while (blockEnd < lines.length) {
|
|
96
|
+
const line = lines[blockEnd];
|
|
97
|
+
if (line.trim() && indentOf(line) <= insertIndent) break;
|
|
98
|
+
blockEnd += 1;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
let foundBridge = false;
|
|
102
|
+
for (let j = i + 1; j < blockEnd; ) {
|
|
103
|
+
const bridge = lines[j].match(
|
|
104
|
+
/^([ \t]*)-\s*id:\s*beauticode-bridge\s*(?:#.*)?$/,
|
|
105
|
+
);
|
|
106
|
+
if (!bridge || bridge[1].length <= insertIndent) {
|
|
107
|
+
j += 1;
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
foundBridge = true;
|
|
111
|
+
const itemIndent = bridge[1].length;
|
|
112
|
+
let itemEnd = j + 1;
|
|
113
|
+
while (itemEnd < blockEnd) {
|
|
114
|
+
const line = lines[itemEnd];
|
|
115
|
+
if (line.trim() && indentOf(line) <= itemIndent) break;
|
|
116
|
+
itemEnd += 1;
|
|
117
|
+
}
|
|
118
|
+
for (let k = j; k < itemEnd; k += 1) removed[k] = true;
|
|
119
|
+
j = itemEnd;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (!foundBridge) continue;
|
|
123
|
+
const marker = `${insert[1]}# beauticode-bridge (installer)`;
|
|
124
|
+
if (i > 0 && lines[i - 1].trimEnd() === marker) removed[i - 1] = true;
|
|
125
|
+
const hasSibling = lines
|
|
126
|
+
.slice(i + 1, blockEnd)
|
|
127
|
+
.some(
|
|
128
|
+
(line, offset) =>
|
|
129
|
+
!removed[i + 1 + offset] &&
|
|
130
|
+
line.trim() !== "" &&
|
|
131
|
+
!line.trimStart().startsWith("#"),
|
|
132
|
+
);
|
|
133
|
+
if (!hasSibling) {
|
|
134
|
+
for (let k = i; k < blockEnd; k += 1) removed[k] = true;
|
|
135
|
+
}
|
|
136
|
+
i = blockEnd - 1;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return lines.filter((_, index) => !removed[index]).join("\n");
|
|
140
|
+
}
|
|
94
141
|
|
|
95
142
|
function overlayPayload(text) {
|
|
96
143
|
return text
|
|
@@ -196,6 +243,83 @@ async function ensureEngine(dest) {
|
|
|
196
243
|
await stageEngineInto(dest);
|
|
197
244
|
}
|
|
198
245
|
|
|
246
|
+
function pluginLinkPath(webProfile) {
|
|
247
|
+
return path.join(webProfile, "node_modules", pluginName);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function legacyPluginLinkPath(webProfile) {
|
|
251
|
+
return path.join(webProfile, "node_modules", "@beauticode", "dsh-plugin");
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function linkSpecFor(pluginHome) {
|
|
255
|
+
return `link:${path.resolve(pluginHome).replaceAll("\\", "/")}`;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async function sameLinkTarget(link, dest) {
|
|
259
|
+
try {
|
|
260
|
+
const stat = await fsp.lstat(link);
|
|
261
|
+
if (stat.isSymbolicLink()) {
|
|
262
|
+
const target = await fsp.readlink(link);
|
|
263
|
+
return path.resolve(path.dirname(link), target) === path.resolve(dest);
|
|
264
|
+
}
|
|
265
|
+
if (stat.isDirectory()) {
|
|
266
|
+
return path.resolve(link) === path.resolve(dest);
|
|
267
|
+
}
|
|
268
|
+
} catch {
|
|
269
|
+
return false;
|
|
270
|
+
}
|
|
271
|
+
return false;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async function linkPluginIntoProfile(webProfile, dest) {
|
|
275
|
+
const link = pluginLinkPath(webProfile);
|
|
276
|
+
const legacy = legacyPluginLinkPath(webProfile);
|
|
277
|
+
if (fs.existsSync(legacy)) {
|
|
278
|
+
await fsp.rm(legacy, { recursive: true, force: true });
|
|
279
|
+
}
|
|
280
|
+
await fsp.mkdir(path.dirname(link), { recursive: true });
|
|
281
|
+
if (fs.existsSync(link)) {
|
|
282
|
+
if (await sameLinkTarget(link, dest)) return;
|
|
283
|
+
await fsp.rm(link, { recursive: true, force: true });
|
|
284
|
+
}
|
|
285
|
+
const type = process.platform === "win32" ? "junction" : "dir";
|
|
286
|
+
await fsp.symlink(path.resolve(dest), link, type);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async function ensureWebPackageDep(webPackage, pluginHome) {
|
|
290
|
+
const raw = await fsp.readFile(webPackage, "utf8");
|
|
291
|
+
const json = JSON.parse(raw);
|
|
292
|
+
if (!json.dependencies || typeof json.dependencies !== "object" || Array.isArray(json.dependencies)) {
|
|
293
|
+
json.dependencies = {};
|
|
294
|
+
}
|
|
295
|
+
const spec = linkSpecFor(pluginHome);
|
|
296
|
+
const hadLegacy = Object.prototype.hasOwnProperty.call(
|
|
297
|
+
json.dependencies,
|
|
298
|
+
"@beauticode/dsh-plugin",
|
|
299
|
+
);
|
|
300
|
+
delete json.dependencies["@beauticode/dsh-plugin"];
|
|
301
|
+
const current = json.dependencies[pluginName];
|
|
302
|
+
if (current === spec && !hadLegacy) return;
|
|
303
|
+
json.dependencies[pluginName] = spec;
|
|
304
|
+
await fsp.writeFile(webPackage, `${JSON.stringify(json, null, 2)}\n`, "utf8");
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
async function removeWebPackageDep(webPackage) {
|
|
308
|
+
if (!fs.existsSync(webPackage)) return false;
|
|
309
|
+
const json = JSON.parse(await fsp.readFile(webPackage, "utf8"));
|
|
310
|
+
if (!json.dependencies || typeof json.dependencies !== "object") return false;
|
|
311
|
+
let changed = false;
|
|
312
|
+
for (const name of [pluginName, "@beauticode/dsh-plugin"]) {
|
|
313
|
+
if (Object.prototype.hasOwnProperty.call(json.dependencies, name)) {
|
|
314
|
+
delete json.dependencies[name];
|
|
315
|
+
changed = true;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
if (!changed) return false;
|
|
319
|
+
await fsp.writeFile(webPackage, `${JSON.stringify(json, null, 2)}\n`, "utf8");
|
|
320
|
+
return true;
|
|
321
|
+
}
|
|
322
|
+
|
|
199
323
|
async function install(opts) {
|
|
200
324
|
const dest = path.resolve(opts.pluginHome);
|
|
201
325
|
const dshHome = path.resolve(opts.dshHome);
|
|
@@ -213,7 +337,9 @@ async function install(opts) {
|
|
|
213
337
|
}
|
|
214
338
|
|
|
215
339
|
if (fs.existsSync(webPackage)) {
|
|
216
|
-
await
|
|
340
|
+
await linkPluginIntoProfile(webProfile, dest);
|
|
341
|
+
await ensureWebPackageDep(webPackage, dest);
|
|
342
|
+
await writePatch(webPatch, packageInsert());
|
|
217
343
|
if (fs.existsSync(homePatch)) {
|
|
218
344
|
const homeRaw = await fsp.readFile(homePatch, "utf8");
|
|
219
345
|
if (hasBridge(homeRaw)) await removePatch(homePatch);
|
|
@@ -234,13 +360,23 @@ async function install(opts) {
|
|
|
234
360
|
|
|
235
361
|
async function uninstall(opts) {
|
|
236
362
|
const dshHome = path.resolve(opts.dshHome);
|
|
363
|
+
const webProfile = path.join(dshHome, "profiles", "web");
|
|
237
364
|
const removed = [];
|
|
238
|
-
if (await removePatch(path.join(
|
|
365
|
+
if (await removePatch(path.join(webProfile, "cordis.patch.yml"))) {
|
|
239
366
|
removed.push("web patch");
|
|
240
367
|
}
|
|
241
368
|
if (await removePatch(path.join(dshHome, "cordis.patch.yml"))) {
|
|
242
369
|
removed.push("home patch");
|
|
243
370
|
}
|
|
371
|
+
if (await removeWebPackageDep(path.join(webProfile, "package.json"))) {
|
|
372
|
+
removed.push("web package.json");
|
|
373
|
+
}
|
|
374
|
+
for (const link of [pluginLinkPath(webProfile), legacyPluginLinkPath(webProfile)]) {
|
|
375
|
+
if (fs.existsSync(link)) {
|
|
376
|
+
await fsp.rm(link, { recursive: true, force: true });
|
|
377
|
+
removed.push(link);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
244
380
|
const dest = path.resolve(opts.pluginHome);
|
|
245
381
|
if (fs.existsSync(dest)) {
|
|
246
382
|
await fsp.rm(dest, { recursive: true, force: true });
|
package/package.json
CHANGED
|
@@ -181,7 +181,11 @@ export class DshSession {
|
|
|
181
181
|
}
|
|
182
182
|
}
|
|
183
183
|
if (manifest.background) {
|
|
184
|
-
|
|
184
|
+
const runtimeImagePath = await this.store.prepareRuntimeImage(manifest);
|
|
185
|
+
if (!runtimeImagePath) {
|
|
186
|
+
throw new Error("DSH image reapply requires a detached runtime copy.");
|
|
187
|
+
}
|
|
188
|
+
stagedImage = await this.media.stage(runtimeImagePath);
|
|
185
189
|
if (manifest.background.type === "video" && manifest.background.video) {
|
|
186
190
|
const runtimeVideoPath = await this.store.prepareRuntimeVideo(manifest);
|
|
187
191
|
if (!runtimeVideoPath) {
|
|
@@ -103,7 +103,7 @@ export class ApplyTransaction {
|
|
|
103
103
|
}
|
|
104
104
|
}
|
|
105
105
|
await this.media.commit(staged);
|
|
106
|
-
await this.store.pruneRuntimeMedia(runtimeVideoPath);
|
|
106
|
+
await this.store.pruneRuntimeMedia(runtimeKeepPaths(staged, runtimeVideoPath));
|
|
107
107
|
staged = null;
|
|
108
108
|
if (snapshot) {
|
|
109
109
|
await this.store.clearSnapshot(snapshot);
|
|
@@ -141,13 +141,15 @@ export class ApplyTransaction {
|
|
|
141
141
|
if (!manifest.background) {
|
|
142
142
|
return { image: null, video: null };
|
|
143
143
|
}
|
|
144
|
-
|
|
145
|
-
|
|
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);
|
|
146
151
|
let video = null;
|
|
147
152
|
if (manifest.background.type === "video" && manifest.background.video) {
|
|
148
|
-
// DSH streams this handle for the lifetime of the active <video>. On
|
|
149
|
-
// Windows, serving active/background.mp4 directly keeps the active
|
|
150
|
-
// directory open and the next atomic directory swap fails with EPERM.
|
|
151
153
|
const runtimeVideoPath = await this.store.prepareRuntimeVideo(manifest);
|
|
152
154
|
if (!runtimeVideoPath) {
|
|
153
155
|
throw new Error("Video media staging requires a detached runtime copy.");
|
|
@@ -188,7 +190,7 @@ export class ApplyTransaction {
|
|
|
188
190
|
}
|
|
189
191
|
}
|
|
190
192
|
await this.media.commit(restoredMedia);
|
|
191
|
-
await this.store.pruneRuntimeMedia(runtimeVideoPath);
|
|
193
|
+
await this.store.pruneRuntimeMedia(runtimeKeepPaths(restoredMedia, runtimeVideoPath));
|
|
192
194
|
restoredMedia = null;
|
|
193
195
|
await this.store.clearSnapshot(snapshot);
|
|
194
196
|
}
|
|
@@ -197,6 +199,16 @@ export class ApplyTransaction {
|
|
|
197
199
|
}
|
|
198
200
|
}
|
|
199
201
|
}
|
|
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
|
+
}
|
|
200
212
|
export async function buildHostApplyPayload(store, manifest, staged, cssText, videoStartAt) {
|
|
201
213
|
if (!manifest.background) {
|
|
202
214
|
return {
|
|
@@ -56,7 +56,7 @@ export class BackgroundStore {
|
|
|
56
56
|
maxSavedBytes;
|
|
57
57
|
bundledThemes;
|
|
58
58
|
#runtimeSessionName = `session-${process.pid}-${crypto.randomUUID()}`;
|
|
59
|
-
#
|
|
59
|
+
#runtimeMediaCache = new Map();
|
|
60
60
|
#writeChain = Promise.resolve();
|
|
61
61
|
#writeContext = new AsyncLocalStorage();
|
|
62
62
|
constructor(opts = {}) {
|
|
@@ -242,6 +242,24 @@ export class BackgroundStore {
|
|
|
242
242
|
this.#writeChain = run.then(() => undefined, () => undefined);
|
|
243
243
|
return run;
|
|
244
244
|
}
|
|
245
|
+
/**
|
|
246
|
+
* Copy the active still to a renderer-only path outside `active`.
|
|
247
|
+
*
|
|
248
|
+
* Serving `active/poster.png` keeps that directory open on Windows, so the
|
|
249
|
+
* next atomic active-directory rotation fails with EPERM. The detached copy
|
|
250
|
+
* matches the video runtime path.
|
|
251
|
+
*/
|
|
252
|
+
async prepareRuntimeImage(manifest) {
|
|
253
|
+
if (!manifest.background?.image)
|
|
254
|
+
return null;
|
|
255
|
+
return this.#prepareRuntimeMedia({
|
|
256
|
+
generation: manifest.generation,
|
|
257
|
+
kind: "image",
|
|
258
|
+
sourceName: manifest.background.image,
|
|
259
|
+
ext: path.extname(manifest.background.image) || ".png",
|
|
260
|
+
validate: (filePath) => validateImageFile(filePath),
|
|
261
|
+
});
|
|
262
|
+
}
|
|
245
263
|
/**
|
|
246
264
|
* Copy the active video to a renderer-only path outside `active`.
|
|
247
265
|
*
|
|
@@ -251,57 +269,73 @@ export class BackgroundStore {
|
|
|
251
269
|
* renderer lifetime independent from the disk transaction.
|
|
252
270
|
*/
|
|
253
271
|
async prepareRuntimeVideo(manifest) {
|
|
272
|
+
if (manifest.background?.type !== "video" ||
|
|
273
|
+
!manifest.background.video) {
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
return this.#prepareRuntimeMedia({
|
|
277
|
+
generation: manifest.generation,
|
|
278
|
+
kind: "video",
|
|
279
|
+
sourceName: manifest.background.video,
|
|
280
|
+
ext: ".mp4",
|
|
281
|
+
validate: (filePath) => validateVideoFile(filePath),
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
async #prepareRuntimeMedia(opts) {
|
|
254
285
|
return this.#withWriteLock(async () => {
|
|
255
|
-
if (manifest.background?.type !== "video" ||
|
|
256
|
-
!manifest.background.video) {
|
|
257
|
-
return null;
|
|
258
|
-
}
|
|
259
286
|
await this.init();
|
|
260
|
-
const
|
|
287
|
+
const cacheKey = `${opts.kind}:${opts.generation}`;
|
|
288
|
+
const cached = this.#runtimeMediaCache.get(cacheKey);
|
|
261
289
|
if (cached) {
|
|
262
290
|
try {
|
|
263
|
-
await
|
|
291
|
+
await opts.validate(cached);
|
|
264
292
|
return cached;
|
|
265
293
|
}
|
|
266
294
|
catch {
|
|
267
|
-
this.#
|
|
295
|
+
this.#runtimeMediaCache.delete(cacheKey);
|
|
268
296
|
}
|
|
269
297
|
}
|
|
270
|
-
const source = path.join(this.paths.activeDir,
|
|
271
|
-
const validated = await
|
|
272
|
-
const sessionDir =
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
const
|
|
277
|
-
if (!runtimeRootStat.isDirectory() ||
|
|
278
|
-
runtimeRootStat.isSymbolicLink()) {
|
|
279
|
-
throw new MediaValidationError("Runtime media root must be a real directory.");
|
|
280
|
-
}
|
|
281
|
-
await fs.mkdir(sessionDir, { recursive: true });
|
|
282
|
-
const sessionStat = await fs.lstat(sessionDir);
|
|
283
|
-
if (!sessionStat.isDirectory() || sessionStat.isSymbolicLink()) {
|
|
284
|
-
throw new MediaValidationError("Runtime media session must be a real directory.");
|
|
285
|
-
}
|
|
286
|
-
const [realRuntimeRoot, realSessionDir] = await Promise.all([
|
|
287
|
-
fs.realpath(this.paths.runtimeMediaDir),
|
|
288
|
-
fs.realpath(sessionDir),
|
|
289
|
-
]);
|
|
290
|
-
if (!isPathInsideRoot(realRuntimeRoot, realSessionDir)) {
|
|
291
|
-
throw new MediaValidationError("Runtime media session escaped runtime-media root.");
|
|
292
|
-
}
|
|
293
|
-
const destination = path.join(sessionDir, `video-${manifest.generation}-${validated.identity.slice(0, 16)}.mp4`);
|
|
298
|
+
const source = path.join(this.paths.activeDir, opts.sourceName);
|
|
299
|
+
const validated = await opts.validate(source);
|
|
300
|
+
const sessionDir = await this.#ensureRuntimeSessionDir();
|
|
301
|
+
const safeExt = opts.ext.toLowerCase().startsWith(".")
|
|
302
|
+
? opts.ext.toLowerCase()
|
|
303
|
+
: `.${opts.ext}`;
|
|
304
|
+
const destination = path.join(sessionDir, `${opts.kind}-${opts.generation}-${validated.identity.slice(0, 16)}${safeExt}`);
|
|
294
305
|
await copyFileAtomic(validated.filePath, destination);
|
|
295
|
-
const copied = await
|
|
306
|
+
const copied = await opts.validate(destination);
|
|
296
307
|
if (copied.identity !== validated.identity) {
|
|
297
308
|
await fs.rm(destination, { force: true }).catch(() => { });
|
|
298
|
-
throw new MediaValidationError(
|
|
309
|
+
throw new MediaValidationError(`Runtime ${opts.kind} copy failed its identity check.`);
|
|
299
310
|
}
|
|
300
|
-
this.#
|
|
301
|
-
await this.#pruneRuntimeMediaUnlocked(
|
|
311
|
+
this.#runtimeMediaCache.set(cacheKey, destination);
|
|
312
|
+
await this.#pruneRuntimeMediaUnlocked([...this.#runtimeMediaCache.values()]);
|
|
302
313
|
return destination;
|
|
303
314
|
});
|
|
304
315
|
}
|
|
316
|
+
async #ensureRuntimeSessionDir() {
|
|
317
|
+
const sessionDir = path.join(this.paths.runtimeMediaDir, this.#runtimeSessionName);
|
|
318
|
+
if (!isPathInsideRoot(this.paths.runtimeMediaDir, sessionDir)) {
|
|
319
|
+
throw new MediaValidationError("Runtime media path escaped runtime-media root.");
|
|
320
|
+
}
|
|
321
|
+
const runtimeRootStat = await fs.lstat(this.paths.runtimeMediaDir);
|
|
322
|
+
if (!runtimeRootStat.isDirectory() || runtimeRootStat.isSymbolicLink()) {
|
|
323
|
+
throw new MediaValidationError("Runtime media root must be a real directory.");
|
|
324
|
+
}
|
|
325
|
+
await fs.mkdir(sessionDir, { recursive: true });
|
|
326
|
+
const sessionStat = await fs.lstat(sessionDir);
|
|
327
|
+
if (!sessionStat.isDirectory() || sessionStat.isSymbolicLink()) {
|
|
328
|
+
throw new MediaValidationError("Runtime media session must be a real directory.");
|
|
329
|
+
}
|
|
330
|
+
const [realRuntimeRoot, realSessionDir] = await Promise.all([
|
|
331
|
+
fs.realpath(this.paths.runtimeMediaDir),
|
|
332
|
+
fs.realpath(sessionDir),
|
|
333
|
+
]);
|
|
334
|
+
if (!isPathInsideRoot(realRuntimeRoot, realSessionDir)) {
|
|
335
|
+
throw new MediaValidationError("Runtime media session escaped runtime-media root.");
|
|
336
|
+
}
|
|
337
|
+
return sessionDir;
|
|
338
|
+
}
|
|
305
339
|
/**
|
|
306
340
|
* Best-effort cleanup. Locked stale files are retained and retried on the
|
|
307
341
|
* next apply/start instead of turning successful theme switching into an
|
|
@@ -310,14 +344,15 @@ export class BackgroundStore {
|
|
|
310
344
|
async pruneRuntimeMedia(keepPath = null) {
|
|
311
345
|
await this.#withWriteLock(async () => {
|
|
312
346
|
await this.init();
|
|
313
|
-
|
|
347
|
+
const keep = (Array.isArray(keepPath) ? keepPath : [keepPath]).filter((value) => typeof value === "string" && value.length > 0);
|
|
348
|
+
await this.#pruneRuntimeMediaUnlocked(keep);
|
|
314
349
|
});
|
|
315
350
|
}
|
|
316
|
-
async #pruneRuntimeMediaUnlocked(
|
|
351
|
+
async #pruneRuntimeMediaUnlocked(keepPaths) {
|
|
317
352
|
const runtimeRoot = path.resolve(this.paths.runtimeMediaDir);
|
|
318
|
-
const keep =
|
|
319
|
-
|
|
320
|
-
|
|
353
|
+
const keep = new Set(keepPaths
|
|
354
|
+
.filter((value) => isPathInsideRoot(runtimeRoot, value))
|
|
355
|
+
.map((value) => path.resolve(value)));
|
|
321
356
|
let sessions;
|
|
322
357
|
try {
|
|
323
358
|
sessions = await fs.readdir(runtimeRoot);
|
|
@@ -331,7 +366,8 @@ export class BackgroundStore {
|
|
|
331
366
|
const sessionDir = path.join(runtimeRoot, name);
|
|
332
367
|
if (!isPathInsideRoot(runtimeRoot, sessionDir))
|
|
333
368
|
continue;
|
|
334
|
-
|
|
369
|
+
const keepInSession = [...keep].some((file) => isPathInsideRoot(sessionDir, file));
|
|
370
|
+
if (!keepInSession) {
|
|
335
371
|
await rmrf(sessionDir).catch(() => { });
|
|
336
372
|
continue;
|
|
337
373
|
}
|
|
@@ -344,15 +380,15 @@ export class BackgroundStore {
|
|
|
344
380
|
}
|
|
345
381
|
for (const file of files) {
|
|
346
382
|
const candidate = path.resolve(sessionDir, file);
|
|
347
|
-
if (candidate
|
|
383
|
+
if (keep.has(candidate) || !isPathInsideRoot(sessionDir, candidate)) {
|
|
348
384
|
continue;
|
|
349
385
|
}
|
|
350
386
|
await fs.rm(candidate, { recursive: true, force: true }).catch(() => { });
|
|
351
387
|
}
|
|
352
388
|
}
|
|
353
|
-
for (const [
|
|
354
|
-
if (path.resolve(cached)
|
|
355
|
-
this.#
|
|
389
|
+
for (const [key, cached] of this.#runtimeMediaCache) {
|
|
390
|
+
if (!keep.has(path.resolve(cached))) {
|
|
391
|
+
this.#runtimeMediaCache.delete(key);
|
|
356
392
|
}
|
|
357
393
|
}
|
|
358
394
|
}
|
|
@@ -36,6 +36,27 @@ function parseRange(value, size) {
|
|
|
36
36
|
function makeToken() {
|
|
37
37
|
return randomUUID().replaceAll("-", "");
|
|
38
38
|
}
|
|
39
|
+
function pipeOpenedFile(handle, response, range) {
|
|
40
|
+
const stream = handle.createReadStream({
|
|
41
|
+
start: range.start,
|
|
42
|
+
...(range.end != null ? { end: range.end } : {}),
|
|
43
|
+
autoClose: false,
|
|
44
|
+
});
|
|
45
|
+
let closed = false;
|
|
46
|
+
const closeHandle = () => {
|
|
47
|
+
if (closed)
|
|
48
|
+
return;
|
|
49
|
+
closed = true;
|
|
50
|
+
void handle.close().catch(() => { });
|
|
51
|
+
};
|
|
52
|
+
stream.on("error", () => {
|
|
53
|
+
closeHandle();
|
|
54
|
+
response.destroy();
|
|
55
|
+
});
|
|
56
|
+
stream.on("close", closeHandle);
|
|
57
|
+
response.on("close", closeHandle);
|
|
58
|
+
stream.pipe(response);
|
|
59
|
+
}
|
|
39
60
|
async function hashOpenedFile(handle, size) {
|
|
40
61
|
const hash = createHash("sha256");
|
|
41
62
|
const chunk = Buffer.allocUnsafe(1024 * 1024);
|
|
@@ -401,11 +422,8 @@ export class LoopbackMediaHub {
|
|
|
401
422
|
opened = null;
|
|
402
423
|
return;
|
|
403
424
|
}
|
|
404
|
-
|
|
425
|
+
pipeOpenedFile(opened, response, { start: 0 });
|
|
405
426
|
opened = null;
|
|
406
|
-
stream
|
|
407
|
-
.on("error", () => response.destroy())
|
|
408
|
-
.pipe(response);
|
|
409
427
|
return;
|
|
410
428
|
}
|
|
411
429
|
headers["Content-Range"] =
|
|
@@ -418,15 +436,11 @@ export class LoopbackMediaHub {
|
|
|
418
436
|
opened = null;
|
|
419
437
|
return;
|
|
420
438
|
}
|
|
421
|
-
|
|
439
|
+
pipeOpenedFile(opened, response, {
|
|
422
440
|
start: range.start,
|
|
423
441
|
end: range.end,
|
|
424
|
-
autoClose: true,
|
|
425
442
|
});
|
|
426
443
|
opened = null;
|
|
427
|
-
stream
|
|
428
|
-
.on("error", () => response.destroy())
|
|
429
|
-
.pipe(response);
|
|
430
444
|
}
|
|
431
445
|
catch {
|
|
432
446
|
await opened?.close().catch(() => { });
|