@adecore/service 0.39.0 → 0.40.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/definitions.js +12 -20
- package/dist/manager.js +22 -23
- package/package.json +3 -3
- package/src/definitions.ts +45 -20
- package/src/manager.ts +44 -23
- package/src/platform.ts +6 -0
- package/src/system.ts +1 -0
package/dist/definitions.js
CHANGED
|
@@ -8,9 +8,7 @@ function xmlValue(text) {
|
|
|
8
8
|
function plistString(value, indent) {
|
|
9
9
|
return `${indent}<string>${xmlText(value)}</string>`;
|
|
10
10
|
}
|
|
11
|
-
/*
|
|
12
|
-
* `ProcessType=Interactive` avoids launchd throttling during a handover to a background process.
|
|
13
|
-
*/
|
|
11
|
+
/* `ProcessType=Interactive` avoids launchd throttling during a handover to a background process. */
|
|
14
12
|
export function launchAgentPlist(spec) {
|
|
15
13
|
const lines = [
|
|
16
14
|
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
@@ -40,9 +38,7 @@ function unitWord(value, command) {
|
|
|
40
38
|
}
|
|
41
39
|
return `"${escaped}"`;
|
|
42
40
|
}
|
|
43
|
-
/*
|
|
44
|
-
* User services stop at logout unless the caller explicitly enables lingering.
|
|
45
|
-
*/
|
|
41
|
+
/* User services stop at logout unless the caller enables lingering. */
|
|
46
42
|
export function systemdUnit(spec) {
|
|
47
43
|
const lines = [
|
|
48
44
|
'[Unit]',
|
|
@@ -70,17 +66,14 @@ function cmdArgument(arg) {
|
|
|
70
66
|
}
|
|
71
67
|
return cmdEscaped(`"${arg.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\*)$/, '$1$1')}"`);
|
|
72
68
|
}
|
|
73
|
-
/* The line cmd.exe runs: its title, the environment, the program and the log it appends to. */
|
|
74
69
|
function taskCommandLine(spec) {
|
|
75
70
|
const variables = Object.entries(spec.environment).map(([name, value]) => `set ${cmdEscaped(`"${name}=${value}"`)}`);
|
|
76
71
|
const program = [cmdEscaped(spec.program), ...spec.args.map(cmdArgument)].join(' ');
|
|
77
72
|
return `${scheduledTaskMarker(spec.label)}${[...variables, `${program} >>${cmdEscaped(spec.logFile)} 2>&1`].join(' && ')}`;
|
|
78
73
|
}
|
|
79
74
|
/*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
* while one runs. RestartOnFailure is left out, since it also starts a task a stop ended. A task carries no environment
|
|
83
|
-
* of its own, so cmd.exe sets it and appends the output to the log.
|
|
75
|
+
* Started at the person's logon without a stored password, and run again every minute so it comes back
|
|
76
|
+
* after a crash. RestartOnFailure would also start a task a stop ended. cmd.exe sets the environment.
|
|
84
77
|
*/
|
|
85
78
|
export function scheduledTask(spec) {
|
|
86
79
|
if (spec.user === undefined || spec.user === '') {
|
|
@@ -163,14 +156,17 @@ function launchAgentProgram(definition) {
|
|
|
163
156
|
let depth = 0;
|
|
164
157
|
let key = null;
|
|
165
158
|
let start = 0;
|
|
159
|
+
const close = (end) => {
|
|
160
|
+
if (key !== null) {
|
|
161
|
+
entries.set(key, xml.slice(start, end));
|
|
162
|
+
}
|
|
163
|
+
};
|
|
166
164
|
for (const token of xml.matchAll(/<(\/?)(dict|array)\s*>|<key>([^<]*)<\/key>/g)) {
|
|
167
165
|
if (token[3] !== undefined) {
|
|
168
166
|
if (depth !== 1) {
|
|
169
167
|
continue;
|
|
170
168
|
}
|
|
171
|
-
|
|
172
|
-
entries.set(key, xml.slice(start, token.index));
|
|
173
|
-
}
|
|
169
|
+
close(token.index);
|
|
174
170
|
key = token[3];
|
|
175
171
|
if (entries.has(key)) {
|
|
176
172
|
return null;
|
|
@@ -183,9 +179,7 @@ function launchAgentProgram(definition) {
|
|
|
183
179
|
else {
|
|
184
180
|
depth--;
|
|
185
181
|
if (depth === 0) {
|
|
186
|
-
|
|
187
|
-
entries.set(key, xml.slice(start, token.index));
|
|
188
|
-
}
|
|
182
|
+
close(token.index);
|
|
189
183
|
const program = entries.get('Program');
|
|
190
184
|
return program !== undefined
|
|
191
185
|
? (/^\s*<string>([^<]*)<\/string>\s*$/.exec(program)?.[1] ?? null)
|
|
@@ -220,9 +214,7 @@ function systemdRunsProgram(definition, program) {
|
|
|
220
214
|
const executable = unitWord(program, true);
|
|
221
215
|
return commands.length > 0 && commands.every((command) => command === executable || command.startsWith(`${executable} `));
|
|
222
216
|
}
|
|
223
|
-
/*
|
|
224
|
-
* Callers sharing a service identity can check executable ownership before replacing its definition.
|
|
225
|
-
*/
|
|
217
|
+
/* Callers sharing a service identity can check executable ownership before replacing its definition. */
|
|
226
218
|
export function definitionRunsProgram(definition, program) {
|
|
227
219
|
if (/<Task[\s>]/.test(definition)) {
|
|
228
220
|
return scheduledTaskRunsProgram(definition, program);
|
package/dist/manager.js
CHANGED
|
@@ -5,10 +5,22 @@ function expectSuccess(result, what) {
|
|
|
5
5
|
throw new Error(`${what} failed (${result.code}): ${(result.stderr || result.stdout).trim() || 'no output'}`);
|
|
6
6
|
}
|
|
7
7
|
}
|
|
8
|
+
/* Writes the definition when it differs and returns the one it replaces. */
|
|
9
|
+
function writeDefinition(files, path, definition) {
|
|
10
|
+
const previous = files.read(path);
|
|
11
|
+
if (previous !== definition) {
|
|
12
|
+
files.write(path, definition);
|
|
13
|
+
}
|
|
14
|
+
return previous;
|
|
15
|
+
}
|
|
16
|
+
async function waitWhile(condition, sleep) {
|
|
17
|
+
for (let attempt = 0; attempt < 25 && condition(); attempt++) {
|
|
18
|
+
await sleep(200);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
8
21
|
/*
|
|
9
|
-
* A plist in ~/Library/LaunchAgents, loaded
|
|
10
|
-
*
|
|
11
|
-
* the bundle, where the login shell's PATH and a log path in the home cannot be written.
|
|
22
|
+
* A plist in ~/Library/LaunchAgents, loaded into the person's GUI domain. Not SMAppService: it wants a
|
|
23
|
+
* plist sealed inside the bundle, where the login shell's PATH and a log path in the home cannot go.
|
|
12
24
|
*/
|
|
13
25
|
export function launchdManager(options) {
|
|
14
26
|
const path = posix.join(options.home, 'Library', 'LaunchAgents', `${options.label}.plist`);
|
|
@@ -27,10 +39,7 @@ export function launchdManager(options) {
|
|
|
27
39
|
install(definition) {
|
|
28
40
|
// launchd opens the log but makes no folder for it, and a job whose log cannot open never starts.
|
|
29
41
|
options.files.makeDirectory(posix.dirname(options.logFile));
|
|
30
|
-
const previous = options.files
|
|
31
|
-
if (previous !== definition) {
|
|
32
|
-
options.files.write(path, definition);
|
|
33
|
-
}
|
|
42
|
+
const previous = writeDefinition(options.files, path, definition);
|
|
34
43
|
return previous !== null && previous !== definition;
|
|
35
44
|
},
|
|
36
45
|
start() {
|
|
@@ -44,9 +53,7 @@ export function launchdManager(options) {
|
|
|
44
53
|
if (isLoaded()) {
|
|
45
54
|
launchctl('bootout', target);
|
|
46
55
|
// bootout returns before the label is released; bootstrapping sooner fails with an I/O error.
|
|
47
|
-
|
|
48
|
-
await options.sleep(200);
|
|
49
|
-
}
|
|
56
|
+
await waitWhile(isLoaded, options.sleep);
|
|
50
57
|
}
|
|
51
58
|
bootstrap();
|
|
52
59
|
},
|
|
@@ -107,9 +114,8 @@ export function systemdManager(options) {
|
|
|
107
114
|
};
|
|
108
115
|
}
|
|
109
116
|
/*
|
|
110
|
-
* A task in the person's own Task Scheduler library,
|
|
111
|
-
*
|
|
112
|
-
* a stop also disables it, and a start enables it again.
|
|
117
|
+
* A task in the person's own Task Scheduler library, which takes no admin. The task runs again every
|
|
118
|
+
* minute, so a stop also disables it and a start enables it again.
|
|
113
119
|
*/
|
|
114
120
|
export function taskSchedulerManager(options) {
|
|
115
121
|
const path = options.definitionFile;
|
|
@@ -147,26 +153,19 @@ export function taskSchedulerManager(options) {
|
|
|
147
153
|
install(definition) {
|
|
148
154
|
// cmd.exe appends to the log but makes no folder for it, and the task ends at once without one.
|
|
149
155
|
options.files.makeDirectory(win32.dirname(options.logFile));
|
|
150
|
-
const previous = options.files
|
|
151
|
-
if (previous !== definition) {
|
|
152
|
-
options.files.write(path, definition);
|
|
153
|
-
}
|
|
156
|
+
const previous = writeDefinition(options.files, path, definition);
|
|
154
157
|
if (previous !== definition || !exists()) {
|
|
155
158
|
expectSuccess(schtasks('/Create', '/TN', name, '/XML', path, '/F'), 'schtasks /Create');
|
|
156
159
|
}
|
|
157
160
|
return previous !== null && previous !== definition;
|
|
158
161
|
},
|
|
159
|
-
start
|
|
160
|
-
run();
|
|
161
|
-
},
|
|
162
|
+
start: run,
|
|
162
163
|
async restart() {
|
|
163
164
|
if (isRunning()) {
|
|
164
165
|
endTree();
|
|
165
166
|
schtasks('/End', '/TN', name);
|
|
166
167
|
// A task that still counts as running ignores the next start.
|
|
167
|
-
|
|
168
|
-
await options.sleep(200);
|
|
169
|
-
}
|
|
168
|
+
await waitWhile(isRunning, options.sleep);
|
|
170
169
|
}
|
|
171
170
|
run();
|
|
172
171
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adecore/service",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.40.0",
|
|
4
4
|
"description": "User service definitions and injectable launchd, systemd and Task Scheduler managers.",
|
|
5
5
|
"license": "FSL-1.1-MIT",
|
|
6
6
|
"author": "Bas Milius",
|
|
@@ -51,8 +51,8 @@
|
|
|
51
51
|
"test": "bun test"
|
|
52
52
|
},
|
|
53
53
|
"devDependencies": {
|
|
54
|
-
"@types/bun": "^1.4.
|
|
55
|
-
"@types/node": "^24.
|
|
54
|
+
"@types/bun": "^1.4.4",
|
|
55
|
+
"@types/node": "^24.19.2",
|
|
56
56
|
"typescript": "^7.0.2"
|
|
57
57
|
}
|
|
58
58
|
}
|
package/src/definitions.ts
CHANGED
|
@@ -25,9 +25,7 @@ function plistString(value: string, indent: string): string {
|
|
|
25
25
|
return `${indent}<string>${xmlText(value)}</string>`;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
/*
|
|
29
|
-
* `ProcessType=Interactive` avoids launchd throttling during a handover to a background process.
|
|
30
|
-
*/
|
|
28
|
+
/* `ProcessType=Interactive` avoids launchd throttling during a handover to a background process. */
|
|
31
29
|
export function launchAgentPlist(spec: ServiceSpec): string {
|
|
32
30
|
const lines = [
|
|
33
31
|
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
@@ -43,9 +41,11 @@ export function launchAgentPlist(spec: ServiceSpec): string {
|
|
|
43
41
|
' <key>EnvironmentVariables</key>',
|
|
44
42
|
' <dict>'
|
|
45
43
|
];
|
|
44
|
+
|
|
46
45
|
for (const [name, value] of Object.entries(spec.environment)) {
|
|
47
46
|
lines.push(` <key>${xmlText(name)}</key>`, plistString(value, ' '));
|
|
48
47
|
}
|
|
48
|
+
|
|
49
49
|
lines.push(
|
|
50
50
|
' </dict>',
|
|
51
51
|
' <key>WorkingDirectory</key>',
|
|
@@ -64,21 +64,22 @@ export function launchAgentPlist(spec: ServiceSpec): string {
|
|
|
64
64
|
'</plist>',
|
|
65
65
|
''
|
|
66
66
|
);
|
|
67
|
+
|
|
67
68
|
return lines.join('\n');
|
|
68
69
|
}
|
|
69
70
|
|
|
70
71
|
/* systemd expands `%` specifiers everywhere and `$` variables in a command line, so both are doubled; quotes keep a space in a path. */
|
|
71
72
|
function unitWord(value: string, command: boolean): string {
|
|
72
73
|
let escaped = value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/%/g, '%%');
|
|
74
|
+
|
|
73
75
|
if (command) {
|
|
74
76
|
escaped = escaped.replace(/\$/g, '$$$$');
|
|
75
77
|
}
|
|
78
|
+
|
|
76
79
|
return `"${escaped}"`;
|
|
77
80
|
}
|
|
78
81
|
|
|
79
|
-
/*
|
|
80
|
-
* User services stop at logout unless the caller explicitly enables lingering.
|
|
81
|
-
*/
|
|
82
|
+
/* User services stop at logout unless the caller enables lingering. */
|
|
82
83
|
export function systemdUnit(spec: ServiceSpec): string {
|
|
83
84
|
const lines = [
|
|
84
85
|
'[Unit]',
|
|
@@ -97,6 +98,7 @@ export function systemdUnit(spec: ServiceSpec): string {
|
|
|
97
98
|
'WantedBy=default.target',
|
|
98
99
|
''
|
|
99
100
|
];
|
|
101
|
+
|
|
100
102
|
return lines.join('\n');
|
|
101
103
|
}
|
|
102
104
|
|
|
@@ -105,27 +107,28 @@ function cmdArgument(arg: string): string {
|
|
|
105
107
|
if (/[\r\n]/.test(arg)) {
|
|
106
108
|
throw new Error('A scheduled task cannot pass on a line break');
|
|
107
109
|
}
|
|
110
|
+
|
|
108
111
|
return cmdEscaped(`"${arg.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\*)$/, '$1$1')}"`);
|
|
109
112
|
}
|
|
110
113
|
|
|
111
|
-
/* The line cmd.exe runs: its title, the environment, the program and the log it appends to. */
|
|
112
114
|
function taskCommandLine(spec: ServiceSpec): string {
|
|
113
115
|
const variables = Object.entries(spec.environment).map(([name, value]) => `set ${cmdEscaped(`"${name}=${value}"`)}`);
|
|
114
116
|
const program = [cmdEscaped(spec.program), ...spec.args.map(cmdArgument)].join(' ');
|
|
117
|
+
|
|
115
118
|
return `${scheduledTaskMarker(spec.label)}${[...variables, `${program} >>${cmdEscaped(spec.logFile)} 2>&1`].join(' && ')}`;
|
|
116
119
|
}
|
|
117
120
|
|
|
118
121
|
/*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
* while one runs. RestartOnFailure is left out, since it also starts a task a stop ended. A task carries no environment
|
|
122
|
-
* of its own, so cmd.exe sets it and appends the output to the log.
|
|
122
|
+
* Started at the person's logon without a stored password, and run again every minute so it comes back
|
|
123
|
+
* after a crash. RestartOnFailure would also start a task a stop ended. cmd.exe sets the environment.
|
|
123
124
|
*/
|
|
124
125
|
export function scheduledTask(spec: ServiceSpec): string {
|
|
125
126
|
if (spec.user === undefined || spec.user === '') {
|
|
126
127
|
throw new Error('A scheduled task needs the user it starts for.');
|
|
127
128
|
}
|
|
129
|
+
|
|
128
130
|
const user = xmlText(spec.user);
|
|
131
|
+
|
|
129
132
|
// No XML declaration: schtasks decodes the file itself and refuses one that names an encoding.
|
|
130
133
|
const lines = [
|
|
131
134
|
'<Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">',
|
|
@@ -181,73 +184,94 @@ export function scheduledTask(spec: ServiceSpec): string {
|
|
|
181
184
|
'</Task>',
|
|
182
185
|
''
|
|
183
186
|
];
|
|
187
|
+
|
|
184
188
|
return lines.join('\r\n');
|
|
185
189
|
}
|
|
186
190
|
|
|
187
191
|
function scheduledTaskRunsProgram(definition: string, program: string): boolean {
|
|
188
192
|
const exec = definition.match(/<Exec>[\s\S]*?<\/Exec>/g);
|
|
193
|
+
|
|
189
194
|
if (exec?.length !== 1) {
|
|
190
195
|
return false;
|
|
191
196
|
}
|
|
197
|
+
|
|
192
198
|
const args = /<Arguments>([^<]*)<\/Arguments>/.exec(exec[0])?.[1];
|
|
193
199
|
const line = args === undefined ? undefined : /\/c "([\s\S]*)"$/.exec(xmlValue(args))?.[1];
|
|
200
|
+
|
|
194
201
|
return line !== undefined && (line.split(' && ').at(-1) ?? '').startsWith(`${cmdEscaped(program)} `);
|
|
195
202
|
}
|
|
196
203
|
|
|
197
204
|
/* Only keys of the outer plist dictionary identify the executable; nested environment keys are data. */
|
|
198
205
|
function launchAgentProgram(definition: string): string | null {
|
|
199
206
|
const xml = definition.replace(/<!--[\s\S]*?-->/g, '');
|
|
207
|
+
|
|
200
208
|
if (xml.includes('<!--')) {
|
|
201
209
|
return null;
|
|
202
210
|
}
|
|
211
|
+
|
|
203
212
|
const entries = new Map<string, string>();
|
|
204
213
|
let depth = 0;
|
|
205
214
|
let key: string | null = null;
|
|
206
215
|
let start = 0;
|
|
216
|
+
|
|
217
|
+
const close = (end: number): void => {
|
|
218
|
+
if (key !== null) {
|
|
219
|
+
entries.set(key, xml.slice(start, end));
|
|
220
|
+
}
|
|
221
|
+
};
|
|
222
|
+
|
|
207
223
|
for (const token of xml.matchAll(/<(\/?)(dict|array)\s*>|<key>([^<]*)<\/key>/g)) {
|
|
208
224
|
if (token[3] !== undefined) {
|
|
209
225
|
if (depth !== 1) {
|
|
210
226
|
continue;
|
|
211
227
|
}
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
}
|
|
228
|
+
|
|
229
|
+
close(token.index);
|
|
215
230
|
key = token[3];
|
|
231
|
+
|
|
216
232
|
if (entries.has(key)) {
|
|
217
233
|
return null;
|
|
218
234
|
}
|
|
235
|
+
|
|
219
236
|
start = token.index + token[0].length;
|
|
220
237
|
} else if (token[1] === '') {
|
|
221
238
|
depth++;
|
|
222
239
|
} else {
|
|
223
240
|
depth--;
|
|
241
|
+
|
|
224
242
|
if (depth === 0) {
|
|
225
|
-
|
|
226
|
-
entries.set(key, xml.slice(start, token.index));
|
|
227
|
-
}
|
|
243
|
+
close(token.index);
|
|
228
244
|
const program = entries.get('Program');
|
|
245
|
+
|
|
229
246
|
return program !== undefined
|
|
230
247
|
? (/^\s*<string>([^<]*)<\/string>\s*$/.exec(program)?.[1] ?? null)
|
|
231
248
|
: (/^\s*<array>\s*<string>([^<]*)<\/string>/.exec(entries.get('ProgramArguments') ?? '')?.[1] ?? null);
|
|
232
249
|
}
|
|
233
250
|
}
|
|
234
251
|
}
|
|
252
|
+
|
|
235
253
|
return null;
|
|
236
254
|
}
|
|
237
255
|
|
|
238
256
|
function systemdRunsProgram(definition: string, program: string): boolean {
|
|
239
257
|
let service = false;
|
|
240
258
|
let commands: string[] = [];
|
|
259
|
+
|
|
241
260
|
for (const raw of definition.split('\n')) {
|
|
242
261
|
const line = raw.trim();
|
|
262
|
+
|
|
243
263
|
if (line.startsWith('#') || line.startsWith(';')) {
|
|
244
264
|
continue;
|
|
245
265
|
}
|
|
266
|
+
|
|
246
267
|
if (/^\[.*\]$/.test(line)) {
|
|
247
268
|
service = line === '[Service]';
|
|
269
|
+
|
|
248
270
|
continue;
|
|
249
271
|
}
|
|
272
|
+
|
|
250
273
|
const command = service ? /^ExecStart\s*=\s*(.*)$/.exec(line)?.[1] : undefined;
|
|
274
|
+
|
|
251
275
|
if (command !== undefined) {
|
|
252
276
|
if (command === '') {
|
|
253
277
|
commands = [];
|
|
@@ -256,16 +280,17 @@ function systemdRunsProgram(definition: string, program: string): boolean {
|
|
|
256
280
|
}
|
|
257
281
|
}
|
|
258
282
|
}
|
|
283
|
+
|
|
259
284
|
const executable = unitWord(program, true);
|
|
285
|
+
|
|
260
286
|
return commands.length > 0 && commands.every((command) => command === executable || command.startsWith(`${executable} `));
|
|
261
287
|
}
|
|
262
288
|
|
|
263
|
-
/*
|
|
264
|
-
* Callers sharing a service identity can check executable ownership before replacing its definition.
|
|
265
|
-
*/
|
|
289
|
+
/* Callers sharing a service identity can check executable ownership before replacing its definition. */
|
|
266
290
|
export function definitionRunsProgram(definition: string, program: string): boolean {
|
|
267
291
|
if (/<Task[\s>]/.test(definition)) {
|
|
268
292
|
return scheduledTaskRunsProgram(definition, program);
|
|
269
293
|
}
|
|
294
|
+
|
|
270
295
|
return definition.trimStart().startsWith('<') ? launchAgentProgram(definition) === xmlText(program) : systemdRunsProgram(definition, program);
|
|
271
296
|
}
|
package/src/manager.ts
CHANGED
|
@@ -48,6 +48,23 @@ function expectSuccess(result: CommandResult, what: string): void {
|
|
|
48
48
|
}
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
/* Writes the definition when it differs and returns the one it replaces. */
|
|
52
|
+
function writeDefinition(files: ServiceFiles, path: string, definition: string): string | null {
|
|
53
|
+
const previous = files.read(path);
|
|
54
|
+
|
|
55
|
+
if (previous !== definition) {
|
|
56
|
+
files.write(path, definition);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return previous;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function waitWhile(condition: () => boolean, sleep: (ms: number) => Promise<void>): Promise<void> {
|
|
63
|
+
for (let attempt = 0; attempt < 25 && condition(); attempt++) {
|
|
64
|
+
await sleep(200);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
51
68
|
export interface LaunchdOptions {
|
|
52
69
|
uid: number;
|
|
53
70
|
home: string;
|
|
@@ -59,15 +76,16 @@ export interface LaunchdOptions {
|
|
|
59
76
|
}
|
|
60
77
|
|
|
61
78
|
/*
|
|
62
|
-
* A plist in ~/Library/LaunchAgents, loaded
|
|
63
|
-
*
|
|
64
|
-
* the bundle, where the login shell's PATH and a log path in the home cannot be written.
|
|
79
|
+
* A plist in ~/Library/LaunchAgents, loaded into the person's GUI domain. Not SMAppService: it wants a
|
|
80
|
+
* plist sealed inside the bundle, where the login shell's PATH and a log path in the home cannot go.
|
|
65
81
|
*/
|
|
66
82
|
export function launchdManager(options: LaunchdOptions): ServiceManager {
|
|
67
83
|
const path = posix.join(options.home, 'Library', 'LaunchAgents', `${options.label}.plist`);
|
|
68
84
|
const domain = `gui/${options.uid}`;
|
|
69
85
|
const target = `${domain}/${options.label}`;
|
|
86
|
+
|
|
70
87
|
const launchctl = (...args: string[]): CommandResult => options.run('/bin/launchctl', args);
|
|
88
|
+
|
|
71
89
|
const isLoaded = (): boolean => launchctl('print', target).code === 0;
|
|
72
90
|
|
|
73
91
|
const bootstrap = (): void => {
|
|
@@ -82,27 +100,26 @@ export function launchdManager(options: LaunchdOptions): ServiceManager {
|
|
|
82
100
|
install(definition) {
|
|
83
101
|
// launchd opens the log but makes no folder for it, and a job whose log cannot open never starts.
|
|
84
102
|
options.files.makeDirectory(posix.dirname(options.logFile));
|
|
85
|
-
const previous = options.files
|
|
86
|
-
|
|
87
|
-
options.files.write(path, definition);
|
|
88
|
-
}
|
|
103
|
+
const previous = writeDefinition(options.files, path, definition);
|
|
104
|
+
|
|
89
105
|
return previous !== null && previous !== definition;
|
|
90
106
|
},
|
|
91
107
|
start() {
|
|
92
108
|
if (isLoaded()) {
|
|
93
109
|
expectSuccess(launchctl('kickstart', target), 'launchctl kickstart');
|
|
110
|
+
|
|
94
111
|
return;
|
|
95
112
|
}
|
|
113
|
+
|
|
96
114
|
bootstrap();
|
|
97
115
|
},
|
|
98
116
|
async restart() {
|
|
99
117
|
if (isLoaded()) {
|
|
100
118
|
launchctl('bootout', target);
|
|
101
119
|
// bootout returns before the label is released; bootstrapping sooner fails with an I/O error.
|
|
102
|
-
|
|
103
|
-
await options.sleep(200);
|
|
104
|
-
}
|
|
120
|
+
await waitWhile(isLoaded, options.sleep);
|
|
105
121
|
}
|
|
122
|
+
|
|
106
123
|
bootstrap();
|
|
107
124
|
},
|
|
108
125
|
stop() {
|
|
@@ -128,6 +145,7 @@ export interface SystemdOptions {
|
|
|
128
145
|
export function systemdManager(options: SystemdOptions): ServiceManager {
|
|
129
146
|
const unit = options.unitName;
|
|
130
147
|
const path = posix.join(options.configHome, 'systemd', 'user', unit);
|
|
148
|
+
|
|
131
149
|
const systemctl = (...args: string[]): CommandResult => options.run('systemctl', ['--user', ...args]);
|
|
132
150
|
|
|
133
151
|
const reload = (): void => {
|
|
@@ -141,11 +159,14 @@ export function systemdManager(options: SystemdOptions): ServiceManager {
|
|
|
141
159
|
read: () => options.files.read(path),
|
|
142
160
|
install(definition) {
|
|
143
161
|
const previous = options.files.read(path);
|
|
162
|
+
|
|
144
163
|
if (previous !== definition) {
|
|
145
164
|
options.files.write(path, definition);
|
|
146
165
|
reload();
|
|
147
166
|
}
|
|
167
|
+
|
|
148
168
|
expectSuccess(systemctl('enable', unit), 'systemctl enable');
|
|
169
|
+
|
|
149
170
|
return previous !== null && previous !== definition;
|
|
150
171
|
},
|
|
151
172
|
start() {
|
|
@@ -185,15 +206,17 @@ export interface TaskSchedulerOptions {
|
|
|
185
206
|
}
|
|
186
207
|
|
|
187
208
|
/*
|
|
188
|
-
* A task in the person's own Task Scheduler library,
|
|
189
|
-
*
|
|
190
|
-
* a stop also disables it, and a start enables it again.
|
|
209
|
+
* A task in the person's own Task Scheduler library, which takes no admin. The task runs again every
|
|
210
|
+
* minute, so a stop also disables it and a start enables it again.
|
|
191
211
|
*/
|
|
192
212
|
export function taskSchedulerManager(options: TaskSchedulerOptions): ServiceManager {
|
|
193
213
|
const path = options.definitionFile;
|
|
194
214
|
const name = options.taskName;
|
|
215
|
+
|
|
195
216
|
const schtasks = (...args: string[]): CommandResult => options.run('schtasks.exe', args);
|
|
217
|
+
|
|
196
218
|
const exists = (): boolean => schtasks('/Query', '/TN', name).code === 0;
|
|
219
|
+
|
|
197
220
|
// schtasks writes the state in the language of the system; PowerShell names it the same everywhere.
|
|
198
221
|
const isRunning = (): boolean =>
|
|
199
222
|
options
|
|
@@ -208,11 +231,13 @@ export function taskSchedulerManager(options: TaskSchedulerOptions): ServiceMana
|
|
|
208
231
|
// Before `/End`, which leaves what the task's cmd.exe started running without a parent to find it by.
|
|
209
232
|
const endTree = (): void => {
|
|
210
233
|
const marker = scheduledTaskMarker(name).replace(/'/g, "''");
|
|
234
|
+
|
|
211
235
|
const script = [
|
|
212
236
|
'Get-CimInstance Win32_Process -Filter "Name=\'cmd.exe\'"',
|
|
213
237
|
`Where-Object { $_.CommandLine -and $_.CommandLine.Contains('${marker}') }`,
|
|
214
238
|
'ForEach-Object { taskkill.exe /PID $_.ProcessId /T /F | Out-Null }'
|
|
215
239
|
].join(' | ');
|
|
240
|
+
|
|
216
241
|
options.run('powershell.exe', ['-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(script, 'utf16le').toString('base64')]);
|
|
217
242
|
};
|
|
218
243
|
|
|
@@ -229,27 +254,23 @@ export function taskSchedulerManager(options: TaskSchedulerOptions): ServiceMana
|
|
|
229
254
|
install(definition) {
|
|
230
255
|
// cmd.exe appends to the log but makes no folder for it, and the task ends at once without one.
|
|
231
256
|
options.files.makeDirectory(win32.dirname(options.logFile));
|
|
232
|
-
const previous = options.files
|
|
233
|
-
|
|
234
|
-
options.files.write(path, definition);
|
|
235
|
-
}
|
|
257
|
+
const previous = writeDefinition(options.files, path, definition);
|
|
258
|
+
|
|
236
259
|
if (previous !== definition || !exists()) {
|
|
237
260
|
expectSuccess(schtasks('/Create', '/TN', name, '/XML', path, '/F'), 'schtasks /Create');
|
|
238
261
|
}
|
|
262
|
+
|
|
239
263
|
return previous !== null && previous !== definition;
|
|
240
264
|
},
|
|
241
|
-
start
|
|
242
|
-
run();
|
|
243
|
-
},
|
|
265
|
+
start: run,
|
|
244
266
|
async restart() {
|
|
245
267
|
if (isRunning()) {
|
|
246
268
|
endTree();
|
|
247
269
|
schtasks('/End', '/TN', name);
|
|
248
270
|
// A task that still counts as running ignores the next start.
|
|
249
|
-
|
|
250
|
-
await options.sleep(200);
|
|
251
|
-
}
|
|
271
|
+
await waitWhile(isRunning, options.sleep);
|
|
252
272
|
}
|
|
273
|
+
|
|
253
274
|
run();
|
|
254
275
|
},
|
|
255
276
|
stop() {
|
package/src/platform.ts
CHANGED
|
@@ -20,12 +20,15 @@ export function platformServiceManager(platform: NodeJS.Platform, options: Platf
|
|
|
20
20
|
if (platform === 'darwin') {
|
|
21
21
|
return launchdManager(options.launchd);
|
|
22
22
|
}
|
|
23
|
+
|
|
23
24
|
if (platform === 'linux') {
|
|
24
25
|
return systemdManager(options.systemd);
|
|
25
26
|
}
|
|
27
|
+
|
|
26
28
|
if (platform === 'win32' && options.taskScheduler !== undefined) {
|
|
27
29
|
return taskSchedulerManager(options.taskScheduler);
|
|
28
30
|
}
|
|
31
|
+
|
|
29
32
|
return null;
|
|
30
33
|
}
|
|
31
34
|
|
|
@@ -33,11 +36,14 @@ export function serviceDefinition(platform: NodeJS.Platform, spec: ServiceSpec):
|
|
|
33
36
|
if (platform === 'darwin') {
|
|
34
37
|
return launchAgentPlist(spec);
|
|
35
38
|
}
|
|
39
|
+
|
|
36
40
|
if (platform === 'linux') {
|
|
37
41
|
return systemdUnit(spec);
|
|
38
42
|
}
|
|
43
|
+
|
|
39
44
|
if (platform === 'win32') {
|
|
40
45
|
return scheduledTask(spec);
|
|
41
46
|
}
|
|
47
|
+
|
|
42
48
|
throw new Error(`Background services are unsupported on ${platform}.`);
|
|
43
49
|
}
|
package/src/system.ts
CHANGED
|
@@ -6,6 +6,7 @@ import type { CommandResult, ServiceFiles } from './manager.js';
|
|
|
6
6
|
/* The real launchctl, systemctl, loginctl and schtasks under a service manager, bounded so a hung one cannot hold up a quit. */
|
|
7
7
|
export function runCommand(command: string, args: string[]): CommandResult {
|
|
8
8
|
const result = spawnSync(command, args, { encoding: 'utf8', timeout: 15_000, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
|
|
9
|
+
|
|
9
10
|
return { code: result.status ?? 1, stdout: result.stdout ?? '', stderr: result.stderr || result.error?.message || '' };
|
|
10
11
|
}
|
|
11
12
|
|