@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.
@@ -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
- * A scheduled task for one person, started at their logon in their own session without a stored
81
- * password, and run again every minute so it comes back after a crash: a second instance is ignored
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
- if (key !== null) {
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
- if (key !== null) {
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 with `launchctl bootstrap` into the person's GUI domain.
10
- * SMAppService (Electron's `agentService`) was the other way: it wants a plist that is sealed inside
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.read(path);
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
- for (let attempt = 0; attempt < 25 && isLoaded(); attempt++) {
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, registered with `schtasks` from a copy of the
111
- * definition on disk. Creating one for oneself takes no admin. The task runs again every minute, so
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.read(path);
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
- for (let attempt = 0; attempt < 25 && isRunning(); attempt++) {
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.39.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.2",
55
- "@types/node": "^24.13.6",
54
+ "@types/bun": "^1.4.4",
55
+ "@types/node": "^24.19.2",
56
56
  "typescript": "^7.0.2"
57
57
  }
58
58
  }
@@ -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
- * A scheduled task for one person, started at their logon in their own session without a stored
120
- * password, and run again every minute so it comes back after a crash: a second instance is ignored
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
- if (key !== null) {
213
- entries.set(key, xml.slice(start, token.index));
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
- if (key !== null) {
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 with `launchctl bootstrap` into the person's GUI domain.
63
- * SMAppService (Electron's `agentService`) was the other way: it wants a plist that is sealed inside
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.read(path);
86
- if (previous !== definition) {
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
- for (let attempt = 0; attempt < 25 && isLoaded(); attempt++) {
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, registered with `schtasks` from a copy of the
189
- * definition on disk. Creating one for oneself takes no admin. The task runs again every minute, so
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.read(path);
233
- if (previous !== definition) {
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
- for (let attempt = 0; attempt < 25 && isRunning(); attempt++) {
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