prismcast 1.9.0 → 1.10.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/dist/app.d.ts +1 -1
- package/dist/app.js +27 -9
- package/dist/app.js.map +1 -1
- package/dist/browser/cdp.js +7 -7
- package/dist/browser/cdp.js.map +1 -1
- package/dist/browser/channelSelection.d.ts +41 -11
- package/dist/browser/channelSelection.js +75 -24
- package/dist/browser/channelSelection.js.map +1 -1
- package/dist/browser/display.d.ts +1 -1
- package/dist/browser/index.d.ts +8 -8
- package/dist/browser/index.js +42 -43
- package/dist/browser/index.js.map +1 -1
- package/dist/browser/login.d.ts +2 -2
- package/dist/browser/login.js +1 -1
- package/dist/browser/manifestInterceptor.d.ts +77 -0
- package/dist/browser/manifestInterceptor.js +323 -0
- package/dist/browser/manifestInterceptor.js.map +1 -0
- package/dist/browser/precaching.js +4 -4
- package/dist/browser/tuning/comcastPolymer.d.ts +1 -1
- package/dist/browser/tuning/cox.d.ts +1 -1
- package/dist/browser/tuning/directv.d.ts +1 -1
- package/dist/browser/tuning/directv.js +61 -62
- package/dist/browser/tuning/directv.js.map +1 -1
- package/dist/browser/tuning/fox.d.ts +1 -1
- package/dist/browser/tuning/fox.js +171 -14
- package/dist/browser/tuning/fox.js.map +1 -1
- package/dist/browser/tuning/hbo.d.ts +1 -1
- package/dist/browser/tuning/hbo.js +6 -6
- package/dist/browser/tuning/hbo.js.map +1 -1
- package/dist/browser/tuning/hulu.d.ts +1 -1
- package/dist/browser/tuning/hulu.js +117 -110
- package/dist/browser/tuning/hulu.js.map +1 -1
- package/dist/browser/tuning/shared.d.ts +3 -3
- package/dist/browser/tuning/shared.js +5 -5
- package/dist/browser/tuning/sling.d.ts +1 -1
- package/dist/browser/tuning/sling.js +28 -28
- package/dist/browser/tuning/sling.js.map +1 -1
- package/dist/browser/tuning/spectrum.d.ts +1 -1
- package/dist/browser/tuning/spectrum.js +21 -22
- package/dist/browser/tuning/spectrum.js.map +1 -1
- package/dist/browser/tuning/thumbnailRow.d.ts +1 -1
- package/dist/browser/tuning/thumbnailRow.js +1 -1
- package/dist/browser/tuning/tileClick.d.ts +1 -1
- package/dist/browser/tuning/tileClick.js +5 -5
- package/dist/browser/tuning/xfinity.d.ts +1 -1
- package/dist/browser/tuning/youtubeTv.d.ts +1 -1
- package/dist/browser/tuning/youtubeTv.js +17 -18
- package/dist/browser/tuning/youtubeTv.js.map +1 -1
- package/dist/browser/video.d.ts +20 -6
- package/dist/browser/video.js +30 -38
- package/dist/browser/video.js.map +1 -1
- package/dist/channels/index.d.ts +1 -2
- package/dist/channels/index.js +73 -40
- package/dist/channels/index.js.map +1 -1
- package/dist/config/channelForm.d.ts +58 -0
- package/dist/config/channelForm.js +129 -0
- package/dist/config/channelForm.js.map +1 -0
- package/dist/config/consistencyProbe.d.ts +7 -0
- package/dist/config/consistencyProbe.js +143 -0
- package/dist/config/consistencyProbe.js.map +1 -0
- package/dist/config/health.d.ts +4 -3
- package/dist/config/health.js +75 -62
- package/dist/config/health.js.map +1 -1
- package/dist/config/index.d.ts +1 -1
- package/dist/config/index.js +19 -12
- package/dist/config/index.js.map +1 -1
- package/dist/config/paths.d.ts +9 -3
- package/dist/config/paths.js +12 -4
- package/dist/config/paths.js.map +1 -1
- package/dist/config/persistence.context.d.ts +8 -0
- package/dist/config/persistence.context.js +30 -0
- package/dist/config/persistence.context.js.map +1 -0
- package/dist/config/persistence.d.ts +110 -12
- package/dist/config/persistence.js +351 -32
- package/dist/config/persistence.js.map +1 -1
- package/dist/config/presets.d.ts +14 -4
- package/dist/config/presets.js +84 -75
- package/dist/config/presets.js.map +1 -1
- package/dist/config/profiles.d.ts +2 -2
- package/dist/config/profiles.js +7 -9
- package/dist/config/profiles.js.map +1 -1
- package/dist/config/servicePacks.d.ts +2 -2
- package/dist/config/servicePacks.js +28 -28
- package/dist/config/servicePacks.js.map +1 -1
- package/dist/config/services.d.ts +62 -36
- package/dist/config/services.js +150 -136
- package/dist/config/services.js.map +1 -1
- package/dist/config/sites.d.ts +3 -3
- package/dist/config/sites.js +16 -16
- package/dist/config/sites.js.map +1 -1
- package/dist/config/userChannels.d.ts +500 -41
- package/dist/config/userChannels.js +1201 -312
- package/dist/config/userChannels.js.map +1 -1
- package/dist/config/userConfig.d.ts +103 -10
- package/dist/config/userConfig.js +277 -117
- package/dist/config/userConfig.js.map +1 -1
- package/dist/config/userProfiles.d.ts +4 -2
- package/dist/config/userProfiles.js +72 -26
- package/dist/config/userProfiles.js.map +1 -1
- package/dist/hdhr/channelMap.js +8 -11
- package/dist/hdhr/channelMap.js.map +1 -1
- package/dist/hdhr/deviceId.js +10 -9
- package/dist/hdhr/deviceId.js.map +1 -1
- package/dist/hdhr/discover.js +4 -5
- package/dist/hdhr/discover.js.map +1 -1
- package/dist/hdhr/index.js +6 -10
- package/dist/hdhr/index.js.map +1 -1
- package/dist/index.js +12 -12
- package/dist/index.js.map +1 -1
- package/dist/native/codecInference.d.ts +44 -0
- package/dist/native/codecInference.js +327 -0
- package/dist/native/codecInference.js.map +1 -0
- package/dist/native/decrypt.d.ts +1 -1
- package/dist/native/index.d.ts +5 -5
- package/dist/native/index.js +42 -64
- package/dist/native/index.js.map +1 -1
- package/dist/native/probe.d.ts +27 -9
- package/dist/native/probe.js +158 -67
- package/dist/native/probe.js.map +1 -1
- package/dist/native/proxy.d.ts +4 -2
- package/dist/native/proxy.js +52 -49
- package/dist/native/proxy.js.map +1 -1
- package/dist/native/tokenExpiry.d.ts +22 -0
- package/dist/native/tokenExpiry.js +153 -0
- package/dist/native/tokenExpiry.js.map +1 -0
- package/dist/routes/auth.js +6 -10
- package/dist/routes/auth.js.map +1 -1
- package/dist/routes/components.js +2 -2
- package/dist/routes/components.js.map +1 -1
- package/dist/routes/config/channels/endpoints/browse.d.ts +6 -0
- package/dist/routes/config/channels/endpoints/browse.js +195 -0
- package/dist/routes/config/channels/endpoints/browse.js.map +1 -0
- package/dist/routes/config/channels/endpoints/bulk.d.ts +6 -0
- package/dist/routes/config/channels/endpoints/bulk.js +128 -0
- package/dist/routes/config/channels/endpoints/bulk.js.map +1 -0
- package/dist/routes/config/channels/endpoints/crud.d.ts +6 -0
- package/dist/routes/config/channels/endpoints/crud.js +504 -0
- package/dist/routes/config/channels/endpoints/crud.js.map +1 -0
- package/dist/routes/config/channels/endpoints/importExport.d.ts +6 -0
- package/dist/routes/config/channels/endpoints/importExport.js +131 -0
- package/dist/routes/config/channels/endpoints/importExport.js.map +1 -0
- package/dist/routes/config/channels/endpoints/predefined.d.ts +6 -0
- package/dist/routes/config/channels/endpoints/predefined.js +78 -0
- package/dist/routes/config/channels/endpoints/predefined.js.map +1 -0
- package/dist/routes/config/channels/endpoints/prefs.d.ts +6 -0
- package/dist/routes/config/channels/endpoints/prefs.js +58 -0
- package/dist/routes/config/channels/endpoints/prefs.js.map +1 -0
- package/dist/routes/config/channels/endpoints/service.d.ts +6 -0
- package/dist/routes/config/channels/endpoints/service.js +149 -0
- package/dist/routes/config/channels/endpoints/service.js.map +1 -0
- package/dist/routes/config/channels/endpoints/tags.d.ts +6 -0
- package/dist/routes/config/channels/endpoints/tags.js +185 -0
- package/dist/routes/config/channels/endpoints/tags.js.map +1 -0
- package/dist/routes/config/channels/http/handler.d.ts +10 -0
- package/dist/routes/config/channels/http/handler.js +20 -0
- package/dist/routes/config/channels/http/handler.js.map +1 -0
- package/dist/routes/config/channels/http/playlistHint.d.ts +25 -0
- package/dist/routes/config/channels/http/playlistHint.js +40 -0
- package/dist/routes/config/channels/http/playlistHint.js.map +1 -0
- package/dist/routes/config/channels/http/serviceWarning.d.ts +10 -0
- package/dist/routes/config/channels/http/serviceWarning.js +28 -0
- package/dist/routes/config/channels/http/serviceWarning.js.map +1 -0
- package/dist/routes/config/channels/index.d.ts +3 -3
- package/dist/routes/config/channels/index.js +1 -1
- package/dist/routes/config/channels/index.js.map +1 -1
- package/dist/routes/config/channels/{routes.d.ts → setup.d.ts} +1 -1
- package/dist/routes/config/channels/setup.js +23 -0
- package/dist/routes/config/channels/setup.js.map +1 -0
- package/dist/routes/config/channels/table.d.ts +9 -9
- package/dist/routes/config/channels/table.js +124 -76
- package/dist/routes/config/channels/table.js.map +1 -1
- package/dist/routes/config/http/envelope.d.ts +111 -0
- package/dist/routes/config/http/envelope.js +103 -0
- package/dist/routes/config/http/envelope.js.map +1 -0
- package/dist/routes/config/index.d.ts +9 -9
- package/dist/routes/config/index.js +2 -2
- package/dist/routes/config/index.js.map +1 -1
- package/dist/routes/config/services.js +69 -68
- package/dist/routes/config/services.js.map +1 -1
- package/dist/routes/config/settings.d.ts +1 -1
- package/dist/routes/config/settings.js +45 -38
- package/dist/routes/config/settings.js.map +1 -1
- package/dist/routes/debug.js +12 -11
- package/dist/routes/debug.js.map +1 -1
- package/dist/routes/icons.js +2 -2
- package/dist/routes/icons.js.map +1 -1
- package/dist/routes/logs.js +19 -30
- package/dist/routes/logs.js.map +1 -1
- package/dist/routes/mpegts.js +1 -1
- package/dist/routes/playlist.d.ts +1 -1
- package/dist/routes/playlist.js +37 -26
- package/dist/routes/playlist.js.map +1 -1
- package/dist/routes/root/content.js +27 -6
- package/dist/routes/root/content.js.map +1 -1
- package/dist/routes/root/index.js +2 -5
- package/dist/routes/root/index.js.map +1 -1
- package/dist/routes/root/scripts/channels.js +16 -16
- package/dist/routes/root/scripts/channels.js.map +1 -1
- package/dist/routes/root/scripts/config.js +59 -59
- package/dist/routes/root/scripts/config.js.map +1 -1
- package/dist/routes/root/scripts/index.d.ts +4 -4
- package/dist/routes/root/scripts/shared.js +24 -10
- package/dist/routes/root/scripts/shared.js.map +1 -1
- package/dist/routes/root/scripts/status.d.ts +4 -0
- package/dist/routes/root/scripts/status.handlers.d.ts +182 -0
- package/dist/routes/root/scripts/status.handlers.js +713 -0
- package/dist/routes/root/scripts/status.handlers.js.map +1 -0
- package/dist/routes/root/scripts/status.js +86 -488
- package/dist/routes/root/scripts/status.js.map +1 -1
- package/dist/routes/root/styles.js +5 -5
- package/dist/routes/services.js +18 -15
- package/dist/routes/services.js.map +1 -1
- package/dist/routes/sse.d.ts +16 -0
- package/dist/routes/sse.js +29 -0
- package/dist/routes/sse.js.map +1 -0
- package/dist/routes/streams.js +10 -23
- package/dist/routes/streams.js.map +1 -1
- package/dist/routes/ui.d.ts +2 -2
- package/dist/routes/upgrade.js +6 -7
- package/dist/routes/upgrade.js.map +1 -1
- package/dist/service/commands.context.d.ts +6 -0
- package/dist/service/commands.context.js +47 -0
- package/dist/service/commands.context.js.map +1 -0
- package/dist/service/commands.d.ts +53 -8
- package/dist/service/commands.js +171 -188
- package/dist/service/commands.js.map +1 -1
- package/dist/service/generators.context.d.ts +6 -0
- package/dist/service/generators.context.js +45 -0
- package/dist/service/generators.context.js.map +1 -0
- package/dist/service/generators.d.ts +56 -16
- package/dist/service/generators.js +499 -383
- package/dist/service/generators.js.map +1 -1
- package/dist/service/index.d.ts +2 -2
- package/dist/streaming/clients.d.ts +2 -2
- package/dist/streaming/clients.js +2 -2
- package/dist/streaming/codec.d.ts +2 -2
- package/dist/streaming/codec.js +0 -1
- package/dist/streaming/codec.js.map +1 -1
- package/dist/streaming/fmp4Segmenter.d.ts +2 -2
- package/dist/streaming/fmp4Segmenter.js +12 -12
- package/dist/streaming/hls.d.ts +3 -3
- package/dist/streaming/hls.js +81 -70
- package/dist/streaming/hls.js.map +1 -1
- package/dist/streaming/hlsResume.d.ts +3 -3
- package/dist/streaming/hlsResume.js +5 -5
- package/dist/streaming/hlsSegments.d.ts +5 -2
- package/dist/streaming/hlsSegments.js +14 -9
- package/dist/streaming/hlsSegments.js.map +1 -1
- package/dist/streaming/monitor.d.ts +12 -12
- package/dist/streaming/monitor.js +80 -54
- package/dist/streaming/monitor.js.map +1 -1
- package/dist/streaming/mp4Parser.d.ts +34 -4
- package/dist/streaming/mp4Parser.js +111 -29
- package/dist/streaming/mp4Parser.js.map +1 -1
- package/dist/streaming/mpegts.d.ts +1 -1
- package/dist/streaming/mpegts.js +13 -10
- package/dist/streaming/mpegts.js.map +1 -1
- package/dist/streaming/playlistBuilder.d.ts +1 -1
- package/dist/streaming/playlistBuilder.js +2 -2
- package/dist/streaming/preroll.d.ts +9 -9
- package/dist/streaming/preroll.js +39 -41
- package/dist/streaming/preroll.js.map +1 -1
- package/dist/streaming/pretune.js +4 -4
- package/dist/streaming/pretune.js.map +1 -1
- package/dist/streaming/recovery.d.ts +3 -3
- package/dist/streaming/recovery.js +4 -4
- package/dist/streaming/registry.d.ts +6 -6
- package/dist/streaming/setup.d.ts +9 -8
- package/dist/streaming/setup.js +97 -49
- package/dist/streaming/setup.js.map +1 -1
- package/dist/streaming/showInfo.d.ts +9 -3
- package/dist/streaming/showInfo.js +26 -18
- package/dist/streaming/showInfo.js.map +1 -1
- package/dist/streaming/statusEmitter.d.ts +4 -4
- package/dist/streaming/statusEmitter.js +2 -2
- package/dist/types/channels.d.ts +133 -34
- package/dist/types/channels.js +39 -1
- package/dist/types/channels.js.map +1 -1
- package/dist/types/config.d.ts +20 -1
- package/dist/types/index.d.ts +9 -8
- package/dist/types/index.js +1 -0
- package/dist/types/index.js.map +1 -1
- package/dist/types/profiles.d.ts +5 -4
- package/dist/types/selection.d.ts +48 -4
- package/dist/types/streaming.d.ts +1 -1
- package/dist/upgrade/commands.context.d.ts +6 -0
- package/dist/upgrade/commands.context.js +29 -0
- package/dist/upgrade/commands.context.js.map +1 -0
- package/dist/upgrade/commands.d.ts +29 -2
- package/dist/upgrade/commands.js +87 -134
- package/dist/upgrade/commands.js.map +1 -1
- package/dist/upgrade/detection.context.d.ts +6 -0
- package/dist/upgrade/detection.context.js +24 -0
- package/dist/upgrade/detection.context.js.map +1 -0
- package/dist/upgrade/detection.d.ts +140 -10
- package/dist/upgrade/detection.js +153 -66
- package/dist/upgrade/detection.js.map +1 -1
- package/dist/upgrade/index.d.ts +2 -2
- package/dist/utils/chromeFetch.d.ts +1 -1
- package/dist/utils/chromeFetch.js +1 -1
- package/dist/utils/clock.d.ts +14 -0
- package/dist/utils/clock.js +28 -0
- package/dist/utils/clock.js.map +1 -0
- package/dist/utils/debugFilter.d.ts +1 -1
- package/dist/utils/debugFilter.js +6 -4
- package/dist/utils/debugFilter.js.map +1 -1
- package/dist/utils/delay.d.ts +2 -3
- package/dist/utils/delay.js +10 -16
- package/dist/utils/delay.js.map +1 -1
- package/dist/utils/evaluate.js +12 -14
- package/dist/utils/evaluate.js.map +1 -1
- package/dist/utils/ffmpeg.context.d.ts +6 -0
- package/dist/utils/ffmpeg.context.js +31 -0
- package/dist/utils/ffmpeg.context.js.map +1 -0
- package/dist/utils/ffmpeg.d.ts +85 -29
- package/dist/utils/ffmpeg.js +126 -129
- package/dist/utils/ffmpeg.js.map +1 -1
- package/dist/utils/fileLogger.d.ts +18 -2
- package/dist/utils/fileLogger.js +58 -30
- package/dist/utils/fileLogger.js.map +1 -1
- package/dist/utils/format.d.ts +1 -1
- package/dist/utils/format.js +2 -2
- package/dist/utils/format.js.map +1 -1
- package/dist/utils/html.js +1 -3
- package/dist/utils/html.js.map +1 -1
- package/dist/utils/index.d.ts +21 -20
- package/dist/utils/index.js +1 -0
- package/dist/utils/index.js.map +1 -1
- package/dist/utils/logger.js +16 -20
- package/dist/utils/logger.js.map +1 -1
- package/dist/utils/m3u.d.ts +18 -3
- package/dist/utils/m3u.js +28 -14
- package/dist/utils/m3u.js.map +1 -1
- package/dist/utils/memo.d.ts +19 -0
- package/dist/utils/memo.js +53 -0
- package/dist/utils/memo.js.map +1 -0
- package/dist/utils/morganStream.js +2 -2
- package/dist/utils/morganStream.js.map +1 -1
- package/dist/utils/pid.d.ts +2 -2
- package/dist/utils/pid.js +2 -2
- package/dist/utils/platform.d.ts +1 -1
- package/dist/utils/platform.js +3 -3
- package/dist/utils/platform.js.map +1 -1
- package/dist/utils/retry.d.ts +2 -0
- package/dist/utils/retry.js +9 -7
- package/dist/utils/retry.js.map +1 -1
- package/dist/utils/sanitize.d.ts +2 -2
- package/dist/utils/sanitize.js +4 -4
- package/dist/utils/timing.d.ts +6 -3
- package/dist/utils/timing.js +10 -6
- package/dist/utils/timing.js.map +1 -1
- package/dist/utils/version.d.ts +3 -2
- package/dist/utils/version.js +14 -27
- package/dist/utils/version.js.map +1 -1
- package/package.json +13 -7
- package/dist/native/intercept.d.ts +0 -35
- package/dist/native/intercept.js +0 -181
- package/dist/native/intercept.js.map +0 -1
- package/dist/routes/config/channels/routes.js +0 -1505
- package/dist/routes/config/channels/routes.js.map +0 -1
|
@@ -1,120 +1,163 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
*
|
|
8
|
-
* - KeepAlive: Restart automatically if the process exits
|
|
9
|
-
* - StandardOutPath/StandardErrorPath: Capture stdout/stderr to the data directory
|
|
1
|
+
/* Copyright(C) 2024-2026, HJD (https://github.com/hjdhjd). All rights reserved.
|
|
2
|
+
*
|
|
3
|
+
* generators.ts: Platform-specific service file generators for PrismCast.
|
|
4
|
+
*
|
|
5
|
+
* Each generator factory is a pure orchestrator over a GeneratorIO. The default I/O wiring lives in generators.context.ts; production callers go through
|
|
6
|
+
* getServiceGenerator() which uses createDefaultGeneratorIO() under the hood. Tests construct GeneratorIO literals inline so install/uninstall/start/stop run
|
|
7
|
+
* against fakes that record subprocess invocations and file writes without spawning real launchctl/systemctl/powershell.exe.
|
|
10
8
|
*/
|
|
9
|
+
import { SERVICE_ID, SERVICE_NAME, getLogsDirectory, getNodeExecutablePath, getPrismCastEntryPoint, getPrismCastWorkingDirectory } from "../utils/platform.js";
|
|
10
|
+
import { CONFIG_METADATA } from "../config/userConfig.js";
|
|
11
|
+
import { createDefaultGeneratorIO } from "./generators.context.js";
|
|
12
|
+
import path from "node:path";
|
|
11
13
|
/**
|
|
12
|
-
* Escapes a string for use in XML by replacing special characters with entities.
|
|
14
|
+
* Escapes a string for use in XML by replacing special characters with entities. Used by the macOS plist generator; the other platforms do not emit XML.
|
|
13
15
|
* @param str - The string to escape.
|
|
14
16
|
* @returns The escaped string safe for XML.
|
|
15
17
|
*/
|
|
16
18
|
function escapeXml(str) {
|
|
17
19
|
return str.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll("\"", """).replaceAll("'", "'");
|
|
18
20
|
}
|
|
21
|
+
/**
|
|
22
|
+
* Runs an asynchronous child_process call and, on failure, re-throws an Error whose message includes the trimmed stderr text captured from the failed child and
|
|
23
|
+
* whose cause chain points back at the original Error. Without the stderr enrichment, execFile/exec failures surface to users as a generic "Command failed: ..."
|
|
24
|
+
* line while the actual diagnostic - written by the child to stderr - is left on the Error's unread stderr Buffer. Attaching the original via Error.cause
|
|
25
|
+
* preserves the original stack and structured properties (status, signal, stdout, stderr) for any programmatic consumer.
|
|
26
|
+
* @param description - A short human-readable label describing what was attempted; prepended to the error message.
|
|
27
|
+
* @param run - The child_process thunk to execute.
|
|
28
|
+
*/
|
|
29
|
+
async function runAndSurfaceStderr(description, run) {
|
|
30
|
+
try {
|
|
31
|
+
await run();
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
if (!(error instanceof Error)) {
|
|
35
|
+
throw error;
|
|
36
|
+
}
|
|
37
|
+
const raw = error.stderr;
|
|
38
|
+
const detail = (Buffer.isBuffer(raw) ? raw.toString("utf8") : ((typeof raw === "string") ? raw : "")).trim();
|
|
39
|
+
throw new Error(description + ": " + ((detail.length > 0) ? detail : error.message), { cause: error });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Returns the service definition's environment variables as a deterministically ordered array of [key, value] entries. Consumed by every generator to produce
|
|
44
|
+
* byte-stable output across regenerations (essential for reliable stale-path detection and clean diffs when users inspect the generated files).
|
|
45
|
+
* @param envVars - The environment variable map to order.
|
|
46
|
+
* @returns The entries sorted alphabetically by key.
|
|
47
|
+
*/
|
|
48
|
+
function sortedEnvEntries(envVars) {
|
|
49
|
+
return Object.entries(envVars).toSorted(([a], [b]) => a.localeCompare(b));
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Checks whether a file exists using the async fs API via the supplied IO. Replaces fs.existsSync in async method bodies so the Promise<boolean> return type is
|
|
53
|
+
* backed by real I/O rather than a synchronous call papered over with an eslint-disable comment.
|
|
54
|
+
* @param io - The generator I/O context.
|
|
55
|
+
* @param filePath - The absolute path to check.
|
|
56
|
+
* @returns True if the file exists and is accessible, false otherwise.
|
|
57
|
+
*/
|
|
58
|
+
async function fileExists(io, filePath) {
|
|
59
|
+
try {
|
|
60
|
+
await io.access(filePath);
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/* Generates a launchd property list (plist) file for macOS. The plist is installed to ~/Library/LaunchAgents/ and configured with:
|
|
68
|
+
* - RunAtLoad: Start when user logs in.
|
|
69
|
+
* - KeepAlive: Restart automatically if the process exits.
|
|
70
|
+
* - StandardOutPath/StandardErrorPath: Capture stdout/stderr to the data directory.
|
|
71
|
+
*/
|
|
19
72
|
/**
|
|
20
73
|
* Creates a launchd service generator for macOS.
|
|
74
|
+
* @param io - The generator I/O context.
|
|
21
75
|
* @returns A ServiceGenerator for launchd.
|
|
22
76
|
*/
|
|
23
|
-
function createLaunchdGenerator() {
|
|
77
|
+
function createLaunchdGenerator(io) {
|
|
78
|
+
/**
|
|
79
|
+
* Builds the plist XML from a service definition.
|
|
80
|
+
* @param definition - The service definition to serialize.
|
|
81
|
+
* @returns The plist content as a UTF-8 string.
|
|
82
|
+
*/
|
|
83
|
+
function generatePlist(definition) {
|
|
84
|
+
const envEntries = sortedEnvEntries(definition.envVars)
|
|
85
|
+
.map(([key, value]) => [" <key>" + escapeXml(key) + "</key>", " <string>" + escapeXml(value) + "</string>"].join("\n"))
|
|
86
|
+
.join("\n");
|
|
87
|
+
return [
|
|
88
|
+
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>",
|
|
89
|
+
"<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">",
|
|
90
|
+
"<plist version=\"1.0\">",
|
|
91
|
+
"<dict>",
|
|
92
|
+
" <key>Label</key>",
|
|
93
|
+
" <string>" + escapeXml(SERVICE_ID) + "</string>",
|
|
94
|
+
"",
|
|
95
|
+
" <key>ProgramArguments</key>",
|
|
96
|
+
" <array>",
|
|
97
|
+
" <string>" + escapeXml(definition.nodePath) + "</string>",
|
|
98
|
+
" <string>" + escapeXml(definition.entryPoint) + "</string>",
|
|
99
|
+
" </array>",
|
|
100
|
+
"",
|
|
101
|
+
" <key>WorkingDirectory</key>",
|
|
102
|
+
" <string>" + escapeXml(definition.workingDir) + "</string>",
|
|
103
|
+
"",
|
|
104
|
+
" <key>EnvironmentVariables</key>",
|
|
105
|
+
" <dict>",
|
|
106
|
+
envEntries,
|
|
107
|
+
" </dict>",
|
|
108
|
+
"",
|
|
109
|
+
" <key>RunAtLoad</key>",
|
|
110
|
+
" <true/>",
|
|
111
|
+
"",
|
|
112
|
+
" <key>KeepAlive</key>",
|
|
113
|
+
" <true/>",
|
|
114
|
+
"",
|
|
115
|
+
" <key>StandardOutPath</key>",
|
|
116
|
+
" <string>" + escapeXml(path.join(definition.logsDir, "service-stdout.log")) + "</string>",
|
|
117
|
+
"",
|
|
118
|
+
" <key>StandardErrorPath</key>",
|
|
119
|
+
" <string>" + escapeXml(path.join(definition.logsDir, "service-stderr.log")) + "</string>",
|
|
120
|
+
"</dict>",
|
|
121
|
+
"</plist>",
|
|
122
|
+
""
|
|
123
|
+
].join("\n");
|
|
124
|
+
}
|
|
24
125
|
return {
|
|
25
|
-
|
|
26
|
-
const
|
|
27
|
-
const
|
|
28
|
-
const workingDir = getPrismCastWorkingDirectory();
|
|
29
|
-
const logsDir = getLogsDirectory();
|
|
30
|
-
// Build environment variables section. Always include PRISMCAST_SERVICE=1 for service detection.
|
|
31
|
-
const envVars = { PRISMCAST_SERVICE: "1", ...options.envVars };
|
|
32
|
-
// Generate the environment dictionary entries.
|
|
33
|
-
const envEntries = Object.entries(envVars)
|
|
34
|
-
.sort(([a], [b]) => a.localeCompare(b))
|
|
35
|
-
.map(([key, value]) => [
|
|
36
|
-
" <key>" + escapeXml(key) + "</key>",
|
|
37
|
-
" <string>" + escapeXml(value) + "</string>"
|
|
38
|
-
].join("\n"))
|
|
39
|
-
.join("\n");
|
|
40
|
-
// Generate the plist content.
|
|
41
|
-
return [
|
|
42
|
-
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>",
|
|
43
|
-
"<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">",
|
|
44
|
-
"<plist version=\"1.0\">",
|
|
45
|
-
"<dict>",
|
|
46
|
-
" <key>Label</key>",
|
|
47
|
-
" <string>" + escapeXml(SERVICE_ID) + "</string>",
|
|
48
|
-
"",
|
|
49
|
-
" <key>ProgramArguments</key>",
|
|
50
|
-
" <array>",
|
|
51
|
-
" <string>" + escapeXml(nodePath) + "</string>",
|
|
52
|
-
" <string>" + escapeXml(entryPoint) + "</string>",
|
|
53
|
-
" </array>",
|
|
54
|
-
"",
|
|
55
|
-
" <key>WorkingDirectory</key>",
|
|
56
|
-
" <string>" + escapeXml(workingDir) + "</string>",
|
|
57
|
-
"",
|
|
58
|
-
" <key>EnvironmentVariables</key>",
|
|
59
|
-
" <dict>",
|
|
60
|
-
envEntries,
|
|
61
|
-
" </dict>",
|
|
62
|
-
"",
|
|
63
|
-
" <key>RunAtLoad</key>",
|
|
64
|
-
" <true/>",
|
|
65
|
-
"",
|
|
66
|
-
" <key>KeepAlive</key>",
|
|
67
|
-
" <true/>",
|
|
68
|
-
"",
|
|
69
|
-
" <key>StandardOutPath</key>",
|
|
70
|
-
" <string>" + escapeXml(logsDir + "/service-stdout.log") + "</string>",
|
|
71
|
-
"",
|
|
72
|
-
" <key>StandardErrorPath</key>",
|
|
73
|
-
" <string>" + escapeXml(logsDir + "/service-stderr.log") + "</string>",
|
|
74
|
-
"</dict>",
|
|
75
|
-
"</plist>",
|
|
76
|
-
""
|
|
77
|
-
].join("\n");
|
|
78
|
-
},
|
|
79
|
-
getInstallPath() {
|
|
80
|
-
return getServiceFilePath();
|
|
81
|
-
},
|
|
82
|
-
async install(content) {
|
|
83
|
-
const installPath = this.getInstallPath();
|
|
84
|
-
const installDir = getServiceFileDirectory();
|
|
85
|
-
const logsDir = getLogsDirectory();
|
|
126
|
+
async install(definition) {
|
|
127
|
+
const installPath = io.getServiceFilePath();
|
|
128
|
+
const installDir = io.getServiceFileDirectory();
|
|
86
129
|
// Ensure directories exist.
|
|
87
|
-
await
|
|
88
|
-
await
|
|
89
|
-
// Write the plist
|
|
90
|
-
await
|
|
91
|
-
// Load the service with launchctl.
|
|
130
|
+
await io.mkdir(installDir, { recursive: true });
|
|
131
|
+
await io.mkdir(definition.logsDir, { recursive: true });
|
|
132
|
+
// Write the plist.
|
|
133
|
+
await io.writeFile(installPath, generatePlist(definition));
|
|
134
|
+
// Load the service with launchctl. If load fails because the definition is already loaded (common on reinstall), unload first then reload.
|
|
92
135
|
try {
|
|
93
|
-
|
|
136
|
+
await io.execFile("launchctl", ["load", "-w", installPath]);
|
|
94
137
|
}
|
|
95
138
|
catch {
|
|
96
|
-
// If load fails, try to unload first then reload (handles reinstall case).
|
|
97
139
|
try {
|
|
98
|
-
|
|
140
|
+
await io.execFile("launchctl", ["unload", installPath]);
|
|
99
141
|
}
|
|
100
142
|
catch {
|
|
101
143
|
// Ignore unload errors.
|
|
102
144
|
}
|
|
103
|
-
|
|
145
|
+
await runAndSurfaceStderr("launchctl load failed", async () => io.execFile("launchctl", ["load", "-w", installPath]));
|
|
104
146
|
}
|
|
105
147
|
},
|
|
106
|
-
// eslint-disable-next-line @typescript-eslint/require-await
|
|
107
148
|
async isInstalled() {
|
|
108
|
-
return
|
|
149
|
+
return fileExists(io, io.getServiceFilePath());
|
|
109
150
|
},
|
|
110
|
-
// eslint-disable-next-line @typescript-eslint/require-await
|
|
111
151
|
async isRunning() {
|
|
112
152
|
try {
|
|
113
|
-
//
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
const
|
|
117
|
-
|
|
153
|
+
// launchctl list emits tab-separated rows: "PID\tStatus\tLabel". We find the row for this service and parse the PID from the first column. A PID of "-"
|
|
154
|
+
// means the service is loaded but not actively running.
|
|
155
|
+
const { stdout } = await io.execFile("launchctl", ["list"]);
|
|
156
|
+
const line = stdout.split("\n").find((row) => row.includes(SERVICE_ID));
|
|
157
|
+
if (!line) {
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
const pid = line.trim().split("\t")[0];
|
|
118
161
|
return (pid !== "-") && !isNaN(Number(pid));
|
|
119
162
|
}
|
|
120
163
|
catch {
|
|
@@ -123,111 +166,102 @@ function createLaunchdGenerator() {
|
|
|
123
166
|
},
|
|
124
167
|
platform: "darwin",
|
|
125
168
|
serviceManager: "launchd",
|
|
126
|
-
// eslint-disable-next-line @typescript-eslint/require-await
|
|
127
169
|
async start() {
|
|
128
|
-
const installPath =
|
|
129
|
-
// Unload first to clear any stale loaded-but-not-running state. Without this, `launchctl load -w` is a no-op when the definition is already loaded
|
|
130
|
-
//
|
|
170
|
+
const installPath = io.getServiceFilePath();
|
|
171
|
+
// Unload first to clear any stale loaded-but-not-running state. Without this, `launchctl load -w` is a no-op when the definition is already loaded (e.g.,
|
|
172
|
+
// after a crash or upgrade with changed paths), and the cached stale definition is reused.
|
|
131
173
|
try {
|
|
132
|
-
|
|
174
|
+
await io.execFile("launchctl", ["unload", installPath]);
|
|
133
175
|
}
|
|
134
176
|
catch {
|
|
135
|
-
// Ignore
|
|
177
|
+
// Ignore - may not be loaded.
|
|
136
178
|
}
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
// Explicitly start the service. This handles the case where RunAtLoad doesn't trigger (e.g., load-then-start sequence).
|
|
140
|
-
execSync("launchctl start " + SERVICE_ID, { stdio: "pipe" });
|
|
179
|
+
await runAndSurfaceStderr("launchctl load failed", async () => io.execFile("launchctl", ["load", "-w", installPath]));
|
|
180
|
+
await runAndSurfaceStderr("launchctl start failed", async () => io.execFile("launchctl", ["start", SERVICE_ID]));
|
|
141
181
|
},
|
|
142
|
-
// eslint-disable-next-line @typescript-eslint/require-await
|
|
143
182
|
async stop() {
|
|
144
|
-
|
|
145
|
-
execSync("launchctl unload \"" + installPath + "\"", { stdio: "pipe" });
|
|
183
|
+
await runAndSurfaceStderr("launchctl unload failed", async () => io.execFile("launchctl", ["unload", io.getServiceFilePath()]));
|
|
146
184
|
},
|
|
147
185
|
async uninstall() {
|
|
148
|
-
const installPath =
|
|
186
|
+
const installPath = io.getServiceFilePath();
|
|
149
187
|
// Unload the service first.
|
|
150
188
|
try {
|
|
151
|
-
|
|
189
|
+
await io.execFile("launchctl", ["unload", installPath]);
|
|
152
190
|
}
|
|
153
191
|
catch {
|
|
154
|
-
// Ignore errors if service
|
|
192
|
+
// Ignore errors if the service was not loaded.
|
|
155
193
|
}
|
|
156
194
|
// Remove the plist file.
|
|
157
|
-
await
|
|
195
|
+
await io.rm(installPath, { force: true });
|
|
158
196
|
}
|
|
159
197
|
};
|
|
160
198
|
}
|
|
161
199
|
/* Generates a systemd user service unit file for Linux. The unit is installed to ~/.config/systemd/user/ and configured with:
|
|
162
|
-
* - Restart=always: Restart automatically if the process exits
|
|
163
|
-
* - RestartSec=5: Wait 5 seconds before restarting
|
|
164
|
-
* - WantedBy=default.target: Start when user session begins
|
|
200
|
+
* - Restart=always: Restart automatically if the process exits.
|
|
201
|
+
* - RestartSec=5: Wait 5 seconds before restarting.
|
|
202
|
+
* - WantedBy=default.target: Start when user session begins.
|
|
203
|
+
*
|
|
204
|
+
* Linux deliberately does not redirect stdout/stderr to files - systemd captures them to the journal, which is the native Linux logging surface (journalctl
|
|
205
|
+
* --user -u prismcast).
|
|
165
206
|
*/
|
|
166
207
|
/**
|
|
167
208
|
* Creates a systemd service generator for Linux.
|
|
209
|
+
* @param io - The generator I/O context.
|
|
168
210
|
* @returns A ServiceGenerator for systemd.
|
|
169
211
|
*/
|
|
170
|
-
function createSystemdGenerator() {
|
|
212
|
+
function createSystemdGenerator(io) {
|
|
213
|
+
/**
|
|
214
|
+
* Builds the systemd unit file from a service definition.
|
|
215
|
+
* @param definition - The service definition to serialize.
|
|
216
|
+
* @returns The unit file content as a UTF-8 string.
|
|
217
|
+
*/
|
|
218
|
+
function generateUnit(definition) {
|
|
219
|
+
const envLines = sortedEnvEntries(definition.envVars).map(([key, value]) => "Environment=\"" + key + "=" + value + "\"").join("\n");
|
|
220
|
+
return [
|
|
221
|
+
"[Unit]",
|
|
222
|
+
"Description=" + SERVICE_NAME + " Streaming Server",
|
|
223
|
+
"After=network.target",
|
|
224
|
+
"",
|
|
225
|
+
"[Service]",
|
|
226
|
+
"Type=simple",
|
|
227
|
+
"ExecStart=" + definition.nodePath + " " + definition.entryPoint,
|
|
228
|
+
"WorkingDirectory=" + definition.workingDir,
|
|
229
|
+
"Restart=always",
|
|
230
|
+
"RestartSec=5",
|
|
231
|
+
envLines,
|
|
232
|
+
"",
|
|
233
|
+
"[Install]",
|
|
234
|
+
"WantedBy=default.target",
|
|
235
|
+
""
|
|
236
|
+
].join("\n");
|
|
237
|
+
}
|
|
171
238
|
return {
|
|
172
|
-
|
|
173
|
-
const
|
|
174
|
-
const
|
|
175
|
-
const workingDir = getPrismCastWorkingDirectory();
|
|
176
|
-
// Build environment variables. Always include PRISMCAST_SERVICE=1 for service detection.
|
|
177
|
-
const envVars = { PRISMCAST_SERVICE: "1", ...options.envVars };
|
|
178
|
-
// Generate Environment= lines, sorted alphabetically.
|
|
179
|
-
const envLines = Object.entries(envVars).sort(([a], [b]) => a.localeCompare(b)).map(([key, value]) => "Environment=\"" + key + "=" + value + "\"").join("\n");
|
|
180
|
-
// Generate the unit file content.
|
|
181
|
-
return [
|
|
182
|
-
"[Unit]",
|
|
183
|
-
"Description=" + SERVICE_NAME + " Streaming Server",
|
|
184
|
-
"After=network.target",
|
|
185
|
-
"",
|
|
186
|
-
"[Service]",
|
|
187
|
-
"Type=simple",
|
|
188
|
-
"ExecStart=" + nodePath + " " + entryPoint,
|
|
189
|
-
"WorkingDirectory=" + workingDir,
|
|
190
|
-
"Restart=always",
|
|
191
|
-
"RestartSec=5",
|
|
192
|
-
envLines,
|
|
193
|
-
"",
|
|
194
|
-
"[Install]",
|
|
195
|
-
"WantedBy=default.target",
|
|
196
|
-
""
|
|
197
|
-
].join("\n");
|
|
198
|
-
},
|
|
199
|
-
getInstallPath() {
|
|
200
|
-
return getServiceFilePath();
|
|
201
|
-
},
|
|
202
|
-
async install(content) {
|
|
203
|
-
const installPath = this.getInstallPath();
|
|
204
|
-
const installDir = getServiceFileDirectory();
|
|
205
|
-
const logsDir = getLogsDirectory();
|
|
239
|
+
async install(definition) {
|
|
240
|
+
const installPath = io.getServiceFilePath();
|
|
241
|
+
const installDir = io.getServiceFileDirectory();
|
|
206
242
|
// Ensure directories exist.
|
|
207
|
-
await
|
|
208
|
-
await
|
|
243
|
+
await io.mkdir(installDir, { recursive: true });
|
|
244
|
+
await io.mkdir(definition.logsDir, { recursive: true });
|
|
209
245
|
// Write the unit file.
|
|
210
|
-
await
|
|
246
|
+
await io.writeFile(installPath, generateUnit(definition));
|
|
211
247
|
// Reload systemd to pick up the new unit file.
|
|
212
248
|
try {
|
|
213
|
-
|
|
249
|
+
await io.execFile("systemctl", ["--user", "daemon-reload"]);
|
|
214
250
|
}
|
|
215
251
|
catch {
|
|
216
252
|
// Ignore if systemctl isn't available (shouldn't happen on systemd systems).
|
|
217
253
|
}
|
|
218
254
|
// Enable and start the service.
|
|
219
|
-
|
|
220
|
-
|
|
255
|
+
await runAndSurfaceStderr("systemctl enable failed", async () => io.execFile("systemctl", ["--user", "enable", "prismcast.service"]));
|
|
256
|
+
await runAndSurfaceStderr("systemctl start failed", async () => io.execFile("systemctl", ["--user", "start", "prismcast.service"]));
|
|
221
257
|
},
|
|
222
|
-
// eslint-disable-next-line @typescript-eslint/require-await
|
|
223
258
|
async isInstalled() {
|
|
224
|
-
return
|
|
259
|
+
return fileExists(io, io.getServiceFilePath());
|
|
225
260
|
},
|
|
226
|
-
// eslint-disable-next-line @typescript-eslint/require-await
|
|
227
261
|
async isRunning() {
|
|
228
262
|
try {
|
|
229
|
-
const
|
|
230
|
-
return
|
|
263
|
+
const { stdout } = await io.execFile("systemctl", ["--user", "is-active", "prismcast.service"]);
|
|
264
|
+
return stdout.trim() === "active";
|
|
231
265
|
}
|
|
232
266
|
catch {
|
|
233
267
|
return false;
|
|
@@ -235,34 +269,32 @@ function createSystemdGenerator() {
|
|
|
235
269
|
},
|
|
236
270
|
platform: "linux",
|
|
237
271
|
serviceManager: "systemd",
|
|
238
|
-
// eslint-disable-next-line @typescript-eslint/require-await
|
|
239
272
|
async start() {
|
|
240
|
-
|
|
273
|
+
await runAndSurfaceStderr("systemctl start failed", async () => io.execFile("systemctl", ["--user", "start", "prismcast.service"]));
|
|
241
274
|
},
|
|
242
|
-
// eslint-disable-next-line @typescript-eslint/require-await
|
|
243
275
|
async stop() {
|
|
244
|
-
|
|
276
|
+
await runAndSurfaceStderr("systemctl stop failed", async () => io.execFile("systemctl", ["--user", "stop", "prismcast.service"]));
|
|
245
277
|
},
|
|
246
278
|
async uninstall() {
|
|
247
|
-
const installPath =
|
|
279
|
+
const installPath = io.getServiceFilePath();
|
|
248
280
|
// Stop and disable the service.
|
|
249
281
|
try {
|
|
250
|
-
|
|
282
|
+
await io.execFile("systemctl", ["--user", "stop", "prismcast.service"]);
|
|
251
283
|
}
|
|
252
284
|
catch {
|
|
253
285
|
// Ignore if not running.
|
|
254
286
|
}
|
|
255
287
|
try {
|
|
256
|
-
|
|
288
|
+
await io.execFile("systemctl", ["--user", "disable", "prismcast.service"]);
|
|
257
289
|
}
|
|
258
290
|
catch {
|
|
259
291
|
// Ignore if not enabled.
|
|
260
292
|
}
|
|
261
293
|
// Remove the unit file.
|
|
262
|
-
await
|
|
294
|
+
await io.rm(installPath, { force: true });
|
|
263
295
|
// Reload systemd.
|
|
264
296
|
try {
|
|
265
|
-
|
|
297
|
+
await io.execFile("systemctl", ["--user", "daemon-reload"]);
|
|
266
298
|
}
|
|
267
299
|
catch {
|
|
268
300
|
// Ignore.
|
|
@@ -270,165 +302,253 @@ function createSystemdGenerator() {
|
|
|
270
302
|
}
|
|
271
303
|
};
|
|
272
304
|
}
|
|
273
|
-
/*
|
|
274
|
-
*
|
|
275
|
-
*
|
|
276
|
-
*
|
|
305
|
+
/* Registers a Windows Task Scheduler task for PrismCast. The architecture is:
|
|
306
|
+
*
|
|
307
|
+
* - One support file: a PowerShell launcher (.ps1) that sets environment variables and spawns node with stdout/stderr redirected to the data directory. The
|
|
308
|
+
* launcher serves the same role as the macOS plist's EnvironmentVariables dict and the systemd unit's Environment= lines - it is the persistent, human-readable
|
|
309
|
+
* definition of the service's runtime.
|
|
310
|
+
*
|
|
311
|
+
* - One registration mechanism: the PowerShell ScheduledTasks module (Register-ScheduledTask, Start-ScheduledTask, etc.), which is the Task Scheduler 2.0 COM
|
|
312
|
+
* surface exposed as typed cmdlets. We do not emit Task Scheduler XML and we do not invoke schtasks.exe. That choice eliminates the MSXML encoding dialect, the
|
|
313
|
+
* shell-quoting hazards of schtasks /TR, and the need to get the Task XML schema's element order exactly right.
|
|
314
|
+
*
|
|
315
|
+
* - Task Scheduler spawns `powershell.exe -WindowStyle Hidden -File <launcher.ps1>`. PowerShell with -WindowStyle Hidden suppresses the console window natively on
|
|
316
|
+
* Windows 10+, so no .vbs launcher is needed. VBScript was deprecated by Microsoft in 2024.
|
|
317
|
+
*
|
|
318
|
+
* All PowerShell invocations from Node go through invokePowerShell(), which documents the single argument-escape rule in its docstring. Values flow through Node's
|
|
319
|
+
* execFile without shell interpretation, and inside the PowerShell command string the one escape surface is single-quote doubling - see invokePowerShell below.
|
|
277
320
|
*/
|
|
278
321
|
/**
|
|
279
|
-
* Escapes a
|
|
280
|
-
*
|
|
281
|
-
* @param value - The
|
|
282
|
-
* @returns The
|
|
322
|
+
* Escapes a value for embedding inside a single-quoted PowerShell string. PowerShell's single-quoted strings are literal (no interpolation, no backslash escapes);
|
|
323
|
+
* the only character that requires escaping is the single quote itself, which is written as two consecutive single quotes.
|
|
324
|
+
* @param value - The value to escape.
|
|
325
|
+
* @returns The value with internal single quotes doubled.
|
|
283
326
|
*/
|
|
284
|
-
function
|
|
285
|
-
return value.replaceAll("
|
|
327
|
+
function escapePowerShellSingleQuoted(value) {
|
|
328
|
+
return value.replaceAll("'", "''");
|
|
286
329
|
}
|
|
330
|
+
/**
|
|
331
|
+
* Wraps a value in a PowerShell single-quoted string literal, escaping any internal single quotes.
|
|
332
|
+
* @param value - The value to quote.
|
|
333
|
+
* @returns The PowerShell literal (including the surrounding quotes).
|
|
334
|
+
*/
|
|
335
|
+
function powerShellLiteral(value) {
|
|
336
|
+
return "'" + escapePowerShellSingleQuoted(value) + "'";
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* Builds a PowerShell invocation as a function bound to the supplied IO. The scriptBody is expected to declare its inputs via a param() clause at the top; each
|
|
340
|
+
* argument is serialized through powerShellLiteral() and appended to the command after the scriptblock, so PowerShell's own parser binds them to the declared
|
|
341
|
+
* parameters.
|
|
342
|
+
*
|
|
343
|
+
* The only escape surface in this path is the single-quote doubling performed by powerShellLiteral(). That one rule is total: inside a PowerShell single-quoted
|
|
344
|
+
* string, no other character has meaning - there is no interpolation, no backslash escape, no subshell, no variable expansion - and Windows filesystem rules forbid
|
|
345
|
+
* the double-quote character that could let a path escape the outer literal quoting we use inside the launcher's ArgumentList. Node's execFile then passes the
|
|
346
|
+
* composed command string to powershell.exe as a single UTF-16 argv element, bypassing cmd.exe entirely and eliminating shell-level quoting concerns.
|
|
347
|
+
*
|
|
348
|
+
* On failure, any stderr text that PowerShell wrote (e.g., a cmdlet's error record) is folded into the thrown Error's message by runAndSurfaceStderr, and the
|
|
349
|
+
* original Error is attached via the cause chain so programmatic consumers retain access to the structured failure details.
|
|
350
|
+
* @param io - The generator I/O context.
|
|
351
|
+
* @returns A function that runs a PowerShell script body with the given positional arguments.
|
|
352
|
+
*/
|
|
353
|
+
function makePowerShellInvoker(io) {
|
|
354
|
+
return async (scriptBody, args = []) => {
|
|
355
|
+
const quotedArgs = args.map((value) => powerShellLiteral(value)).join(" ");
|
|
356
|
+
const command = "& { " + scriptBody + " } " + quotedArgs;
|
|
357
|
+
await runAndSurfaceStderr("PowerShell invocation failed", async () => io.execFile("powershell.exe", ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", command]));
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
/* Script bodies for Task Scheduler operations. Each script is a self-contained PowerShell block that reads its inputs from positional parameters - never from string
|
|
361
|
+
* interpolation or inline variables - so that Node-side argument quoting is always the single-quoted-literal escape and nothing else.
|
|
362
|
+
*/
|
|
363
|
+
/* Registers (or replaces) the PrismCast Task Scheduler task. Takes two positional parameters: the task name and the absolute path to the launcher .ps1 file.
|
|
364
|
+
*
|
|
365
|
+
* The first step stops any currently-running instance of the existing task and waits for its process to actually terminate before we replace the definition. Two
|
|
366
|
+
* Microsoft-documented facts make this wait necessary: Stop-ScheduledTask initiates the stop but returns before the process has exited (there is no -Wait
|
|
367
|
+
* switch and no built-in completion primitive), and "You can make changes to a task definition even if an instance of the task is running. The changes do not
|
|
368
|
+
* affect the current instance." (Set-ScheduledTask docs, which applies equally to Register-ScheduledTask -Force). Without the poll loop, `prismcast service
|
|
369
|
+
* install --force` on a running service would leave the old node process holding port 5589 while Start-ScheduledTask spawns a new node that fails to bind,
|
|
370
|
+
* triggering the 1-minute RestartOnFailure backoff and silently delaying the service becoming available for up to three minutes.
|
|
371
|
+
*
|
|
372
|
+
* The poll checks State every 250 ms against a 30-second ceiling. The ceiling is a belt-and-suspenders fallback - if the old process is pathologically stuck,
|
|
373
|
+
* Task Scheduler's own TerminateProcess path will clean it up later. A null state means the task was unregistered concurrently, which is also a valid "not
|
|
374
|
+
* running anymore" exit.
|
|
375
|
+
*
|
|
376
|
+
* The action invokes powershell.exe with -WindowStyle Hidden and -File pointing at the launcher. -ExecutionPolicy Bypass is the Microsoft-documented pattern for
|
|
377
|
+
* installer-scripted scenarios - it disables the interactive-use execution policy gate without globally weakening script security on the machine.
|
|
378
|
+
*
|
|
379
|
+
* The trigger fires at user logon. The principal runs the task as the current user with Interactive logon and Limited (LeastPrivilege) run level, mirroring the
|
|
380
|
+
* user-scoped semantics of launchd user agents and systemd --user units. On workgroup machines where USERDOMAIN is not set, we fall back to COMPUTERNAME.
|
|
381
|
+
*
|
|
382
|
+
* Settings express unlimited execution time (PT0S), battery-friendly behavior, ignore-new-instance policy, and automatic restart on failure (3 attempts, 1-minute
|
|
383
|
+
* interval).
|
|
384
|
+
*/
|
|
385
|
+
const WINDOWS_REGISTER_SCRIPT = [
|
|
386
|
+
"param($TaskName, $Launcher)",
|
|
387
|
+
"$existing = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue",
|
|
388
|
+
"if($existing) {",
|
|
389
|
+
" Stop-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue | Out-Null",
|
|
390
|
+
" $deadline = (Get-Date).AddSeconds(30)",
|
|
391
|
+
" while((Get-Date) -lt $deadline) {",
|
|
392
|
+
" $state = (Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue).State",
|
|
393
|
+
" if(($null -eq $state) -or ($state -ne 'Running')) { break }",
|
|
394
|
+
" Start-Sleep -Milliseconds 250",
|
|
395
|
+
" }",
|
|
396
|
+
"}",
|
|
397
|
+
"$userId = $env:USERDOMAIN + '\\' + $env:USERNAME",
|
|
398
|
+
"if(-not $env:USERDOMAIN) { $userId = $env:COMPUTERNAME + '\\' + $env:USERNAME }",
|
|
399
|
+
"$taskArg = '-NoProfile -NonInteractive -WindowStyle Hidden -ExecutionPolicy Bypass -File \"' + $Launcher + '\"'",
|
|
400
|
+
"$action = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument $taskArg",
|
|
401
|
+
"$trigger = New-ScheduledTaskTrigger -AtLogOn -User $userId",
|
|
402
|
+
"$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -ExecutionTimeLimit (New-TimeSpan -Seconds 0) " +
|
|
403
|
+
"-MultipleInstances IgnoreNew -RestartCount 3 -RestartInterval (New-TimeSpan -Minutes 1)",
|
|
404
|
+
"$principal = New-ScheduledTaskPrincipal -UserId $userId -LogonType Interactive -RunLevel Limited",
|
|
405
|
+
"Register-ScheduledTask -TaskName $TaskName -Action $action -Trigger $trigger -Settings $settings -Principal $principal -Force | Out-Null"
|
|
406
|
+
].join("\n");
|
|
407
|
+
/* Unregisters the task. Takes the task name as its sole parameter.
|
|
408
|
+
*/
|
|
409
|
+
const WINDOWS_UNREGISTER_SCRIPT = [
|
|
410
|
+
"param($TaskName)",
|
|
411
|
+
"Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue | Out-Null"
|
|
412
|
+
].join("\n");
|
|
413
|
+
/* Starts the task, re-enabling it first in case a previous stop() call left it disabled. Takes the task name as its sole parameter.
|
|
414
|
+
*/
|
|
415
|
+
const WINDOWS_START_SCRIPT = [
|
|
416
|
+
"param($TaskName)",
|
|
417
|
+
"Enable-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue | Out-Null",
|
|
418
|
+
"Start-ScheduledTask -TaskName $TaskName | Out-Null"
|
|
419
|
+
].join("\n");
|
|
420
|
+
/* Stops the task. Disables it first to prevent the RestartOnFailure policy from re-launching the process after we stop it. Start-ScheduledTask in WINDOWS_START_SCRIPT
|
|
421
|
+
* re-enables the task. Takes the task name as its sole parameter.
|
|
422
|
+
*/
|
|
423
|
+
const WINDOWS_STOP_SCRIPT = [
|
|
424
|
+
"param($TaskName)",
|
|
425
|
+
"Disable-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue | Out-Null",
|
|
426
|
+
"Stop-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue | Out-Null"
|
|
427
|
+
].join("\n");
|
|
428
|
+
/* Exits 0 if the task is registered, 1 otherwise. Takes the task name as its sole parameter.
|
|
429
|
+
*/
|
|
430
|
+
const WINDOWS_IS_INSTALLED_SCRIPT = [
|
|
431
|
+
"param($TaskName)",
|
|
432
|
+
"if(Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue) { exit 0 } else { exit 1 }"
|
|
433
|
+
].join("\n");
|
|
434
|
+
/* Exits 0 if the task's State property is 'Running', 1 otherwise. Takes the task name as its sole parameter.
|
|
435
|
+
*/
|
|
436
|
+
const WINDOWS_IS_RUNNING_SCRIPT = [
|
|
437
|
+
"param($TaskName)",
|
|
438
|
+
"$task = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue",
|
|
439
|
+
"if($task -and ($task.State -eq 'Running')) { exit 0 } else { exit 1 }"
|
|
440
|
+
].join("\n");
|
|
287
441
|
/**
|
|
288
442
|
* Creates a Windows Task Scheduler generator.
|
|
443
|
+
* @param io - The generator I/O context.
|
|
289
444
|
* @returns A ServiceGenerator for Windows Task Scheduler.
|
|
290
445
|
*/
|
|
291
|
-
function createWindowsSchedulerGenerator() {
|
|
446
|
+
function createWindowsSchedulerGenerator(io) {
|
|
292
447
|
const taskName = SERVICE_NAME;
|
|
448
|
+
const invokePowerShell = makePowerShellInvoker(io);
|
|
293
449
|
/**
|
|
294
|
-
*
|
|
295
|
-
*
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
*
|
|
302
|
-
*
|
|
450
|
+
* Generates the PowerShell launcher script (.ps1) that Task Scheduler will invoke at user logon. The launcher sets environment variables and spawns node via
|
|
451
|
+
* Start-Process, with stdout and stderr redirected to separate log files in the data directory. Start-Process binds the child's stream file descriptors directly
|
|
452
|
+
* to the output files, so PowerShell's host-level encoding conversions never touch the stream - node's raw UTF-8 bytes land unchanged in the log file.
|
|
453
|
+
*
|
|
454
|
+
* Path and value embedding uses PowerShell single-quoted string literals, which are fully literal (no interpolation, no backslash escapes). The only escape
|
|
455
|
+
* needed is doubling any internal single-quote character. Windows filesystem rules forbid double quotes in paths, so wrapping the Start-Process ArgumentList in
|
|
456
|
+
* literal double quotes safely handles paths with spaces.
|
|
457
|
+
*
|
|
458
|
+
* The node and entry point paths are also written as comment metadata near the top of the file, so that stale-path detection can recover them without having to
|
|
459
|
+
* parse the Start-Process invocation.
|
|
460
|
+
* @param definition - The service definition to serialize.
|
|
461
|
+
* @returns The launcher content as a UTF-8 string with a leading BOM.
|
|
303
462
|
*/
|
|
304
|
-
function
|
|
305
|
-
|
|
463
|
+
function generateLauncher(definition) {
|
|
464
|
+
const stdoutLog = path.join(definition.logsDir, "service-stdout.log");
|
|
465
|
+
const stderrLog = path.join(definition.logsDir, "service-stderr.log");
|
|
466
|
+
const envLines = sortedEnvEntries(definition.envVars).map(([key, value]) => "$env:" + key + " = " + powerShellLiteral(value));
|
|
467
|
+
// The Start-Process ArgumentList is a PowerShell single-quoted string that contains a literal double-quoted entry-point path. Windows disallows double quotes
|
|
468
|
+
// in paths, so this quoting is always well-formed.
|
|
469
|
+
const argumentList = "'\"" + escapePowerShellSingleQuoted(definition.entryPoint) + "\"'";
|
|
470
|
+
// PowerShell 5.1's default file encoding is ambiguous. A leading UTF-8 BOM (U+FEFF) ensures every supported PowerShell version parses the launcher as UTF-8.
|
|
471
|
+
// Windows line endings (CRLF) are conventional for .ps1 files and match what tools like Set-Content produce.
|
|
472
|
+
return "\uFEFF" + [
|
|
473
|
+
"# PrismCast service launcher.",
|
|
474
|
+
"#",
|
|
475
|
+
"# This file is auto-generated by `prismcast service install`. Manual edits will be overwritten the next time the service is installed or restarted.",
|
|
476
|
+
"#",
|
|
477
|
+
"# The following metadata lines are read back by `prismcast service status` to detect stale paths after a PrismCast upgrade. Do not remove them.",
|
|
478
|
+
"# node: " + definition.nodePath,
|
|
479
|
+
"# entry: " + definition.entryPoint,
|
|
480
|
+
"",
|
|
481
|
+
"# Environment variables for the service process.",
|
|
482
|
+
...envLines,
|
|
483
|
+
"",
|
|
484
|
+
"# Spawn node with stdout and stderr redirected to separate log files. Start-Process binds the child's file descriptors directly, so byte-level content flows",
|
|
485
|
+
"# through unmodified. WindowStyle 'Hidden' gives node its own hidden console - the reliable pattern for combining stream redirection with a suppressed window",
|
|
486
|
+
"# across every supported PowerShell version. Since the outer PowerShell is already launched hidden by Task Scheduler, the user sees no window at any point.",
|
|
487
|
+
"# -Wait keeps the PowerShell host alive until node exits, so Task Scheduler's RestartOnFailure policy can observe non-zero exit codes and restart as intended.",
|
|
488
|
+
"$startArgs = @{",
|
|
489
|
+
" ArgumentList = " + argumentList,
|
|
490
|
+
" FilePath = " + powerShellLiteral(definition.nodePath),
|
|
491
|
+
" PassThru = $true",
|
|
492
|
+
" RedirectStandardError = " + powerShellLiteral(stderrLog),
|
|
493
|
+
" RedirectStandardOutput = " + powerShellLiteral(stdoutLog),
|
|
494
|
+
" Wait = $true",
|
|
495
|
+
" WindowStyle = 'Hidden'",
|
|
496
|
+
" WorkingDirectory = " + powerShellLiteral(definition.workingDir),
|
|
497
|
+
"}",
|
|
498
|
+
"",
|
|
499
|
+
"$process = Start-Process @startArgs",
|
|
500
|
+
"exit $process.ExitCode",
|
|
501
|
+
""
|
|
502
|
+
].join("\r\n");
|
|
306
503
|
}
|
|
307
504
|
/**
|
|
308
|
-
*
|
|
309
|
-
*
|
|
310
|
-
*
|
|
311
|
-
* @param vbsFilePath - The absolute path to the VBScript wrapper that launches PrismCast.
|
|
312
|
-
* @returns The XML content for the task definition.
|
|
505
|
+
* Removes the legacy three-file service artifacts (.cmd launcher, .vbs wrapper, .xml task definition) and the long-obsolete service-installed.marker file from
|
|
506
|
+
* prior PrismCast versions. Safe to call unconditionally; missing files are silently ignored.
|
|
507
|
+
* @param installDir - The service file directory.
|
|
313
508
|
*/
|
|
314
|
-
function
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
"
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
" <LogonTrigger>",
|
|
323
|
-
" <Enabled>true</Enabled>",
|
|
324
|
-
" </LogonTrigger>",
|
|
325
|
-
" </Triggers>",
|
|
326
|
-
" <Principals>",
|
|
327
|
-
" <Principal id=\"Author\">",
|
|
328
|
-
" <LogonType>InteractiveToken</LogonType>",
|
|
329
|
-
" <RunLevel>HighestAvailable</RunLevel>",
|
|
330
|
-
" </Principal>",
|
|
331
|
-
" </Principals>",
|
|
332
|
-
" <Settings>",
|
|
333
|
-
" <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>",
|
|
334
|
-
" <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>",
|
|
335
|
-
" <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>",
|
|
336
|
-
" <Enabled>true</Enabled>",
|
|
337
|
-
" <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>",
|
|
338
|
-
" <RestartOnFailure>",
|
|
339
|
-
" <Interval>PT5S</Interval>",
|
|
340
|
-
" <Count>3</Count>",
|
|
341
|
-
" </RestartOnFailure>",
|
|
342
|
-
" </Settings>",
|
|
343
|
-
" <Actions Context=\"Author\">",
|
|
344
|
-
" <Exec>",
|
|
345
|
-
" <Command>wscript.exe</Command>",
|
|
346
|
-
" <Arguments>" + escapeXml("\"" + vbsFilePath + "\"") + "</Arguments>",
|
|
347
|
-
" </Exec>",
|
|
348
|
-
" </Actions>",
|
|
349
|
-
"</Task>",
|
|
350
|
-
""
|
|
351
|
-
].join("\n");
|
|
509
|
+
async function removeLegacyWindowsArtifacts(installDir) {
|
|
510
|
+
const legacyFiles = [
|
|
511
|
+
path.join(installDir, "prismcast-service.cmd"),
|
|
512
|
+
path.join(installDir, "prismcast-service.vbs"),
|
|
513
|
+
path.join(installDir, "prismcast-task.xml"),
|
|
514
|
+
path.join(installDir, "service-installed.marker")
|
|
515
|
+
];
|
|
516
|
+
await Promise.all(legacyFiles.map(async (filePath) => io.rm(filePath, { force: true })));
|
|
352
517
|
}
|
|
353
518
|
return {
|
|
354
|
-
|
|
355
|
-
const
|
|
356
|
-
const
|
|
357
|
-
const workingDir = getPrismCastWorkingDirectory();
|
|
358
|
-
// Build environment variables. Always include PRISMCAST_SERVICE=1 for service detection.
|
|
359
|
-
const envVars = { PRISMCAST_SERVICE: "1", ...options.envVars };
|
|
360
|
-
// Generate set commands for environment variables, sorted alphabetically. Values are escaped for batch interpretation where literal % must be doubled.
|
|
361
|
-
const envLines = Object.entries(envVars)
|
|
362
|
-
.sort(([a], [b]) => a.localeCompare(b))
|
|
363
|
-
.map(([key, value]) => "set \"" + key + "=" + escapeBatchValue(value) + "\"");
|
|
364
|
-
// Generate the batch file content. Each line uses exactly one level of quoting because the batch file is a standalone script, not an inline argument embedded
|
|
365
|
-
// in another command. The rem lines provide machine-readable path metadata for stale path detection, parsed by getServicePaths(). CRLF line endings follow
|
|
366
|
-
// Windows batch file convention.
|
|
367
|
-
return [
|
|
368
|
-
"@echo off",
|
|
369
|
-
"rem node:" + nodePath,
|
|
370
|
-
"rem entry:" + entryPoint,
|
|
371
|
-
"cd /d \"" + escapeBatchValue(workingDir) + "\"",
|
|
372
|
-
...envLines,
|
|
373
|
-
"\"" + escapeBatchValue(nodePath) + "\" \"" + escapeBatchValue(entryPoint) + "\""
|
|
374
|
-
].join("\r\n") + "\r\n";
|
|
375
|
-
},
|
|
376
|
-
getInstallPath() {
|
|
377
|
-
return getServiceFilePath();
|
|
378
|
-
},
|
|
379
|
-
async install(content) {
|
|
380
|
-
const cmdPath = getServiceFilePath();
|
|
381
|
-
const vbsPath = getVbsPath();
|
|
382
|
-
const xmlPath = getXmlPath();
|
|
383
|
-
const installDir = getServiceFileDirectory();
|
|
384
|
-
const logsDir = getLogsDirectory();
|
|
519
|
+
async install(definition) {
|
|
520
|
+
const launcherPath = io.getServiceFilePath();
|
|
521
|
+
const installDir = io.getServiceFileDirectory();
|
|
385
522
|
// Ensure directories exist.
|
|
386
|
-
await
|
|
387
|
-
await
|
|
388
|
-
//
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
// Write the batch startup script.
|
|
396
|
-
await fsPromises.writeFile(cmdPath, content, "utf8");
|
|
397
|
-
// Write the VBScript wrapper that launches the batch file with a hidden console window. The WshShell.Run second parameter (0) specifies the vbHide window
|
|
398
|
-
// style, and the third parameter (False) means don't wait for the script to finish.
|
|
399
|
-
const vbsContent = "Set WshShell = CreateObject(\"WScript.Shell\")\r\nWshShell.Run \"\"\"" + cmdPath + "\"\"\", 0, False\r\n";
|
|
400
|
-
await fsPromises.writeFile(vbsPath, vbsContent, "utf8");
|
|
401
|
-
// Write the Task Scheduler XML definition and import it. Using XML import instead of schtasks /TR avoids all shell quoting issues and enables advanced task
|
|
402
|
-
// settings that command-line flags cannot express.
|
|
403
|
-
const xmlContent = generateTaskXml(vbsPath);
|
|
404
|
-
await fsPromises.writeFile(xmlPath, xmlContent, "utf8");
|
|
405
|
-
// Import the task definition. execFileSync passes arguments directly to the Windows API, bypassing cmd.exe shell interpretation entirely.
|
|
406
|
-
execFileSync("schtasks", ["/Create", "/XML", xmlPath, "/TN", taskName, "/F"], { stdio: "pipe" });
|
|
407
|
-
// Clean up legacy marker file from previous versions that used a separate metadata file for path tracking.
|
|
408
|
-
await fsPromises.rm(path.join(installDir, "service-installed.marker"), { force: true });
|
|
409
|
-
// Start the task immediately.
|
|
523
|
+
await io.mkdir(installDir, { recursive: true });
|
|
524
|
+
await io.mkdir(definition.logsDir, { recursive: true });
|
|
525
|
+
// Remove legacy artifacts from pre-PowerShell versions so the data directory contains only the current launcher.
|
|
526
|
+
await removeLegacyWindowsArtifacts(installDir);
|
|
527
|
+
// Write the launcher .ps1 with a UTF-8 BOM for unambiguous PowerShell parsing across versions.
|
|
528
|
+
await io.writeFile(launcherPath, generateLauncher(definition));
|
|
529
|
+
// Register (or replace) the scheduled task. -Force in the registration script handles reinstall.
|
|
530
|
+
await invokePowerShell(WINDOWS_REGISTER_SCRIPT, [taskName, launcherPath]);
|
|
531
|
+
// Start the task immediately so the user does not need to log out and back in.
|
|
410
532
|
try {
|
|
411
|
-
|
|
533
|
+
await invokePowerShell(WINDOWS_START_SCRIPT, [taskName]);
|
|
412
534
|
}
|
|
413
535
|
catch {
|
|
414
|
-
//
|
|
536
|
+
// Best-effort start. The task is registered and will fire at next logon regardless.
|
|
415
537
|
}
|
|
416
538
|
},
|
|
417
|
-
// eslint-disable-next-line @typescript-eslint/require-await
|
|
418
539
|
async isInstalled() {
|
|
419
540
|
try {
|
|
420
|
-
|
|
541
|
+
await invokePowerShell(WINDOWS_IS_INSTALLED_SCRIPT, [taskName]);
|
|
421
542
|
return true;
|
|
422
543
|
}
|
|
423
544
|
catch {
|
|
424
545
|
return false;
|
|
425
546
|
}
|
|
426
547
|
},
|
|
427
|
-
// eslint-disable-next-line @typescript-eslint/require-await
|
|
428
548
|
async isRunning() {
|
|
429
549
|
try {
|
|
430
|
-
|
|
431
|
-
return
|
|
550
|
+
await invokePowerShell(WINDOWS_IS_RUNNING_SCRIPT, [taskName]);
|
|
551
|
+
return true;
|
|
432
552
|
}
|
|
433
553
|
catch {
|
|
434
554
|
return false;
|
|
@@ -436,106 +556,85 @@ function createWindowsSchedulerGenerator() {
|
|
|
436
556
|
},
|
|
437
557
|
platform: "windows",
|
|
438
558
|
serviceManager: "windows-scheduler",
|
|
439
|
-
// eslint-disable-next-line @typescript-eslint/require-await
|
|
440
559
|
async start() {
|
|
441
|
-
|
|
442
|
-
try {
|
|
443
|
-
execFileSync("schtasks", ["/Change", "/TN", taskName, "/Enable"], { stdio: "pipe" });
|
|
444
|
-
}
|
|
445
|
-
catch {
|
|
446
|
-
// Ignore if already enabled or task doesn't exist.
|
|
447
|
-
}
|
|
448
|
-
execFileSync("schtasks", ["/Run", "/TN", taskName], { stdio: "pipe" });
|
|
560
|
+
await invokePowerShell(WINDOWS_START_SCRIPT, [taskName]);
|
|
449
561
|
},
|
|
450
|
-
// eslint-disable-next-line @typescript-eslint/require-await
|
|
451
562
|
async stop() {
|
|
452
|
-
|
|
453
|
-
try {
|
|
454
|
-
execFileSync("schtasks", ["/Change", "/TN", taskName, "/Disable"], { stdio: "pipe" });
|
|
455
|
-
}
|
|
456
|
-
catch {
|
|
457
|
-
// Ignore if task doesn't exist.
|
|
458
|
-
}
|
|
459
|
-
execFileSync("schtasks", ["/End", "/TN", taskName], { stdio: "pipe" });
|
|
563
|
+
await invokePowerShell(WINDOWS_STOP_SCRIPT, [taskName]);
|
|
460
564
|
},
|
|
461
565
|
async uninstall() {
|
|
462
|
-
const
|
|
463
|
-
const
|
|
464
|
-
|
|
465
|
-
// Delete the scheduled task.
|
|
566
|
+
const launcherPath = io.getServiceFilePath();
|
|
567
|
+
const installDir = io.getServiceFileDirectory();
|
|
568
|
+
// Deregister the task. SilentlyContinue in the script makes this a no-op if the task was never registered.
|
|
466
569
|
try {
|
|
467
|
-
|
|
570
|
+
await invokePowerShell(WINDOWS_UNREGISTER_SCRIPT, [taskName]);
|
|
468
571
|
}
|
|
469
572
|
catch {
|
|
470
|
-
// Ignore
|
|
573
|
+
// Ignore - task may not exist.
|
|
471
574
|
}
|
|
472
|
-
// Remove
|
|
473
|
-
await
|
|
575
|
+
// Remove the launcher and any residual legacy artifacts.
|
|
576
|
+
await io.rm(launcherPath, { force: true });
|
|
577
|
+
await removeLegacyWindowsArtifacts(installDir);
|
|
474
578
|
}
|
|
475
579
|
};
|
|
476
580
|
}
|
|
477
581
|
/**
|
|
478
582
|
* Reads the existing service file and extracts the node binary and PrismCast entry point paths. Each platform has its own format: launchd plist XML, systemd unit
|
|
479
|
-
* ExecStart line, or Windows
|
|
480
|
-
* @
|
|
583
|
+
* ExecStart line, or Windows PowerShell launcher with comment metadata.
|
|
584
|
+
* @param io - The generator I/O context. Defaults to createDefaultGeneratorIO() which wires real runtime I/O.
|
|
585
|
+
* @returns The extracted paths, or null if the file does not exist or cannot be parsed.
|
|
481
586
|
*/
|
|
482
|
-
export function getServicePaths() {
|
|
483
|
-
const filePath = getServiceFilePath();
|
|
484
|
-
if (!
|
|
587
|
+
export function getServicePaths(io = createDefaultGeneratorIO()) {
|
|
588
|
+
const filePath = io.getServiceFilePath();
|
|
589
|
+
if (!io.existsSync(filePath)) {
|
|
485
590
|
return null;
|
|
486
591
|
}
|
|
487
592
|
let content;
|
|
488
593
|
try {
|
|
489
|
-
content =
|
|
594
|
+
content = io.readFileSync(filePath);
|
|
490
595
|
}
|
|
491
596
|
catch {
|
|
492
597
|
return null;
|
|
493
598
|
}
|
|
494
|
-
switch (getPlatform()) {
|
|
495
|
-
// Launchd plist: ProgramArguments contains two <string> elements
|
|
599
|
+
switch (io.getPlatform()) {
|
|
600
|
+
// Launchd plist: the ProgramArguments array contains two <string> elements - the first is the node path, the second is the entry point. We slice to the section
|
|
601
|
+
// after the ProgramArguments key, then take the first two string values via matchAll + iterator helpers (lazy, early-terminating, no intermediate arrays).
|
|
496
602
|
case "darwin": {
|
|
497
|
-
const stringPattern = /<string>([^<]+)<\/string>/g;
|
|
498
|
-
const matches = [];
|
|
499
|
-
let match;
|
|
500
|
-
// Walk the ProgramArguments array. We look for the section after the ProgramArguments key and extract the first two string values.
|
|
501
603
|
const programArgsIndex = content.indexOf("<key>ProgramArguments</key>");
|
|
502
604
|
if (programArgsIndex === -1) {
|
|
503
605
|
return null;
|
|
504
606
|
}
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
}
|
|
512
|
-
}
|
|
513
|
-
if (matches.length < 2) {
|
|
607
|
+
const [nodePath, entryPoint] = content.slice(programArgsIndex)
|
|
608
|
+
.matchAll(/<string>([^<]+)<\/string>/g)
|
|
609
|
+
.take(2)
|
|
610
|
+
.map((match) => match[1])
|
|
611
|
+
.toArray();
|
|
612
|
+
if (!nodePath || !entryPoint) {
|
|
514
613
|
return null;
|
|
515
614
|
}
|
|
516
|
-
return { entryPoint
|
|
615
|
+
return { entryPoint, nodePath };
|
|
517
616
|
}
|
|
518
617
|
// Systemd unit: ExecStart=<node> <entrypoint> on one line.
|
|
519
618
|
case "linux": {
|
|
520
|
-
const
|
|
521
|
-
if (!
|
|
619
|
+
const execStart = /^ExecStart=(.+)$/m.exec(content)?.[1];
|
|
620
|
+
if (!execStart) {
|
|
522
621
|
return null;
|
|
523
622
|
}
|
|
524
|
-
const
|
|
525
|
-
if (
|
|
623
|
+
const [nodePath, entryPoint] = execStart.split(" ");
|
|
624
|
+
if (!nodePath || !entryPoint) {
|
|
526
625
|
return null;
|
|
527
626
|
}
|
|
528
|
-
return { entryPoint
|
|
627
|
+
return { entryPoint, nodePath };
|
|
529
628
|
}
|
|
530
|
-
// Windows: paths are stored as "
|
|
531
|
-
// endings
|
|
629
|
+
// Windows: paths are stored as "# node:<path>" and "# entry:<path>" metadata comments near the top of the PowerShell launcher. The .trim() handles CRLF line
|
|
630
|
+
// endings where \r would otherwise be captured by the regex.
|
|
532
631
|
case "windows": {
|
|
533
|
-
const
|
|
534
|
-
const
|
|
535
|
-
if (!
|
|
632
|
+
const nodePath = /^# node:(.+)$/m.exec(content)?.[1]?.trim();
|
|
633
|
+
const entryPoint = /^# entry:(.+)$/m.exec(content)?.[1]?.trim();
|
|
634
|
+
if (!nodePath || !entryPoint) {
|
|
536
635
|
return null;
|
|
537
636
|
}
|
|
538
|
-
return { entryPoint
|
|
637
|
+
return { entryPoint, nodePath };
|
|
539
638
|
}
|
|
540
639
|
default: {
|
|
541
640
|
return null;
|
|
@@ -545,71 +644,73 @@ export function getServicePaths() {
|
|
|
545
644
|
/**
|
|
546
645
|
* Checks whether the paths in the existing service file still exist on disk. This detects the common post-upgrade scenario where Homebrew or npm has moved the
|
|
547
646
|
* installation to a new versioned directory and the old paths no longer resolve.
|
|
548
|
-
* @
|
|
647
|
+
* @param io - The generator I/O context. Defaults to createDefaultGeneratorIO() which wires real runtime I/O.
|
|
648
|
+
* @returns A StalePathResult indicating which paths are missing, or null if the service file does not exist or cannot be parsed.
|
|
549
649
|
*/
|
|
550
|
-
export function detectStalePaths() {
|
|
551
|
-
const paths = getServicePaths();
|
|
650
|
+
export function detectStalePaths(io = createDefaultGeneratorIO()) {
|
|
651
|
+
const paths = getServicePaths(io);
|
|
552
652
|
if (!paths) {
|
|
553
653
|
return null;
|
|
554
654
|
}
|
|
555
|
-
const nodeStale = !
|
|
556
|
-
const entryStale = !
|
|
655
|
+
const nodeStale = !io.existsSync(paths.nodePath);
|
|
656
|
+
const entryStale = !io.existsSync(paths.entryPoint);
|
|
557
657
|
return {
|
|
558
658
|
entryPoint: entryStale ? paths.entryPoint : undefined,
|
|
559
659
|
nodePath: nodeStale ? paths.nodePath : undefined,
|
|
560
660
|
stale: nodeStale || entryStale
|
|
561
661
|
};
|
|
562
662
|
}
|
|
563
|
-
/* Returns the appropriate service generator for the current platform.
|
|
564
|
-
*/
|
|
565
663
|
/**
|
|
566
664
|
* Returns the service generator for the current platform.
|
|
665
|
+
* @param io - The generator I/O context. Defaults to createDefaultGeneratorIO() which wires real runtime I/O.
|
|
567
666
|
* @returns The appropriate ServiceGenerator, or null if the platform is not supported.
|
|
568
667
|
*/
|
|
569
|
-
export function getServiceGenerator() {
|
|
570
|
-
switch (getPlatform()) {
|
|
668
|
+
export function getServiceGenerator(io = createDefaultGeneratorIO()) {
|
|
669
|
+
switch (io.getPlatform()) {
|
|
571
670
|
case "darwin": {
|
|
572
|
-
return createLaunchdGenerator();
|
|
671
|
+
return createLaunchdGenerator(io);
|
|
573
672
|
}
|
|
574
673
|
case "linux": {
|
|
575
|
-
return createSystemdGenerator();
|
|
674
|
+
return createSystemdGenerator(io);
|
|
576
675
|
}
|
|
577
676
|
case "windows": {
|
|
578
|
-
return createWindowsSchedulerGenerator();
|
|
677
|
+
return createWindowsSchedulerGenerator(io);
|
|
579
678
|
}
|
|
580
679
|
default: {
|
|
581
680
|
return null;
|
|
582
681
|
}
|
|
583
682
|
}
|
|
584
683
|
}
|
|
684
|
+
/* Env vars that belong in the service environment but are not declared in CONFIG_METADATA. PRISMCAST_DATA_DIR resolves before config.json is read
|
|
685
|
+
* (chicken-and-egg bootstrap), and PRISMCAST_DEBUG is a runtime-only setting parsed in the entry point - see src/index.ts where both are called out as special
|
|
686
|
+
* cases for the same reason.
|
|
687
|
+
*/
|
|
688
|
+
const BOOTSTRAP_ENV_VARS = ["PRISMCAST_DATA_DIR", "PRISMCAST_DEBUG"];
|
|
689
|
+
/**
|
|
690
|
+
* Returns the full set of env var names that represent user-configurable PrismCast settings. Derived from CONFIG_METADATA (the documented single source of truth
|
|
691
|
+
* for configurable settings) plus the bootstrap-only variables enumerated above. Deriving from CONFIG_METADATA means new settings are automatically captured by
|
|
692
|
+
* the service layer the moment they are declared in config metadata - no second list to maintain.
|
|
693
|
+
* @returns An array of env var names.
|
|
694
|
+
*/
|
|
695
|
+
function getConfigurableEnvVarNames() {
|
|
696
|
+
const fromMetadata = Object.values(CONFIG_METADATA).flat().map((setting) => setting.envVar).filter((envVar) => envVar !== null);
|
|
697
|
+
return [...fromMetadata, ...BOOTSTRAP_ENV_VARS];
|
|
698
|
+
}
|
|
585
699
|
/**
|
|
586
|
-
* Collects environment variables that should be persisted in the service
|
|
700
|
+
* Collects environment variables that should be persisted in the service definition. Captures every CONFIG_METADATA-declared env var (plus the bootstrap
|
|
701
|
+
* variables that cannot live there) that is currently set in process.env, so anything the user configured via env for this run is preserved into the installed
|
|
702
|
+
* service.
|
|
587
703
|
* @returns A record of environment variable names to values.
|
|
588
704
|
*/
|
|
589
705
|
export function collectServiceEnvironment() {
|
|
590
706
|
const envVars = {};
|
|
591
707
|
// Always capture PATH so that FFmpeg and other tools can be found. Service managers like launchd use a minimal PATH by default (/usr/bin:/bin:/usr/sbin:/sbin)
|
|
592
|
-
// which
|
|
593
|
-
|
|
594
|
-
|
|
708
|
+
// which does not include Homebrew or other common tool locations.
|
|
709
|
+
const pathEnv = process.env["PATH"];
|
|
710
|
+
if (pathEnv) {
|
|
711
|
+
envVars["PATH"] = pathEnv;
|
|
595
712
|
}
|
|
596
|
-
|
|
597
|
-
const keysToCapture = [
|
|
598
|
-
"AUDIO_BITRATE",
|
|
599
|
-
"CAPTURE_MODE",
|
|
600
|
-
"CHROME_BIN",
|
|
601
|
-
"FRAME_RATE",
|
|
602
|
-
"HOST",
|
|
603
|
-
"LOG_MAX_SIZE",
|
|
604
|
-
"PORT",
|
|
605
|
-
"PRISMCAST_CHROME_DATA_DIR",
|
|
606
|
-
"PRISMCAST_DATA_DIR",
|
|
607
|
-
"PRISMCAST_DEBUG",
|
|
608
|
-
"PRISMCAST_LOG_FILE",
|
|
609
|
-
"QUALITY_PRESET",
|
|
610
|
-
"VIDEO_BITRATE"
|
|
611
|
-
];
|
|
612
|
-
for (const key of keysToCapture) {
|
|
713
|
+
for (const key of getConfigurableEnvVarNames()) {
|
|
613
714
|
const value = process.env[key];
|
|
614
715
|
if (value !== undefined) {
|
|
615
716
|
envVars[key] = value;
|
|
@@ -617,4 +718,19 @@ export function collectServiceEnvironment() {
|
|
|
617
718
|
}
|
|
618
719
|
return envVars;
|
|
619
720
|
}
|
|
721
|
+
/**
|
|
722
|
+
* Builds a ServiceDefinition from the current runtime context. Composes the platform helpers (node path, entry point, working directory, logs directory) with the
|
|
723
|
+
* collected service environment, and stamps PRISMCAST_SERVICE=1 for service-mode detection.
|
|
724
|
+
* @returns The structured service definition.
|
|
725
|
+
*/
|
|
726
|
+
export function buildServiceDefinition() {
|
|
727
|
+
const envVars = { PRISMCAST_SERVICE: "1", ...collectServiceEnvironment() };
|
|
728
|
+
return {
|
|
729
|
+
entryPoint: getPrismCastEntryPoint(),
|
|
730
|
+
envVars,
|
|
731
|
+
logsDir: getLogsDirectory(),
|
|
732
|
+
nodePath: getNodeExecutablePath(),
|
|
733
|
+
workingDir: getPrismCastWorkingDirectory()
|
|
734
|
+
};
|
|
735
|
+
}
|
|
620
736
|
//# sourceMappingURL=generators.js.map
|