Skip to content

Commit 5727a46

Browse files
committed
feat: enhance Windows notification handling with Git Bash support
1 parent 9204e8f commit 5727a46

2 files changed

Lines changed: 156 additions & 5 deletions

File tree

‎packages/core/src/common/notify.ts‎

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { spawn, type SpawnOptions } from "child_process";
2+
import { findGitBashPath, windowsPathToPosixPath } from "./shell-utils";
23

34
type NotifyChildProcess = {
45
once(event: "error", listener: (error: NodeJS.ErrnoException) => void): NotifyChildProcess;
@@ -8,7 +9,7 @@ type NotifyChildProcess = {
89
export type NotifySpawn = (
910
command: string,
1011
args: string[],
11-
options: Pick<SpawnOptions, "cwd" | "detached" | "env" | "stdio">
12+
options: Pick<SpawnOptions, "cwd" | "detached" | "env" | "stdio" | "windowsHide">
1213
) => NotifyChildProcess;
1314

1415
export function formatDurationSeconds(durationMs: number): string {
@@ -58,24 +59,37 @@ export function launchNotifyScript(
5859
workingDirectory?: string,
5960
spawnProcess: NotifySpawn = spawn as unknown as NotifySpawn,
6061
configuredEnv: Record<string, string> = {},
61-
context: NotifyContext = {}
62+
context: NotifyContext = {},
63+
runtime: { platform: NodeJS.Platform; findGitBashPath: () => string } = {
64+
platform: process.platform,
65+
findGitBashPath,
66+
}
6267
): void {
6368
const commandPath = notifyPath?.trim();
6469
if (!commandPath) {
6570
return;
6671
}
6772

73+
const isWindows = runtime.platform === "win32";
6874
const options = {
6975
cwd: workingDirectory,
70-
detached: process.platform !== "win32",
76+
detached: !isWindows,
7177
env: buildNotifyEnv(durationMs, { ...process.env, ...configuredEnv }, context),
7278
stdio: "ignore" as const,
79+
...(isWindows ? { windowsHide: true } : {}),
7380
};
7481

7582
try {
76-
const child = spawnProcess(commandPath, [], options);
83+
const useGitBash = isWindows && /\.sh$/i.test(commandPath);
84+
const child = useGitBash
85+
? spawnProcess(
86+
runtime.findGitBashPath(),
87+
["--noprofile", "--norc", "--", windowsPathToPosixPath(commandPath)],
88+
options
89+
)
90+
: spawnProcess(commandPath, [], options);
7791
child.once("error", (error) => {
78-
if (process.platform === "win32") {
92+
if (isWindows) {
7993
return;
8094
}
8195
if (error.code !== "EACCES" && error.code !== "ENOEXEC") {
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import { test } from "node:test";
2+
import assert from "node:assert/strict";
3+
import { launchNotifyScript, type NotifySpawn } from "../common/notify";
4+
5+
const bashPath = "C:\\Program Files\\Git\\bin\\bash.exe";
6+
const runtime = { platform: "win32" as const, findGitBashPath: () => bashPath };
7+
8+
function captureSpawn(errorCode?: string) {
9+
const calls: Parameters<NotifySpawn>[] = [];
10+
let unrefs = 0;
11+
const spawn: NotifySpawn = (...args) => {
12+
calls.push(args);
13+
return {
14+
once(_event, listener) {
15+
if (errorCode) listener({ code: errorCode } as NodeJS.ErrnoException);
16+
return this;
17+
},
18+
unref() {
19+
unrefs++;
20+
},
21+
};
22+
};
23+
return { calls, spawn, unrefs: () => unrefs };
24+
}
25+
26+
test("Windows shell notifications pass the script as one Git Bash argument and preserve context", () => {
27+
const paths = [
28+
["C:\\Users\\张 三\\O'Brien & $(echo test)\\notify.sh", "/c/Users/张 三/O'Brien & $(echo test)/notify.sh"],
29+
["D:/通知 文件/notify.SH", "/d/通知 文件/notify.SH"],
30+
["/c/Users/you/notify.sh", "/c/Users/you/notify.sh"],
31+
["\\\\server\\share\\通知 文件\\notify.sh", "//server/share/通知 文件/notify.sh"],
32+
];
33+
for (const [script, bashScript] of paths) {
34+
const capture = captureSpawn();
35+
launchNotifyScript(
36+
` ${script} `,
37+
15999,
38+
"C:\\project",
39+
capture.spawn,
40+
{ NOTIFY_CUSTOM: "value" },
41+
{ status: "failed", failReason: "error", body: "a\nb", title: "test" },
42+
runtime
43+
);
44+
assert.equal(capture.calls.length, 1);
45+
const [command, args, options] = capture.calls[0];
46+
assert.equal(command, bashPath);
47+
assert.deepEqual(args, ["--noprofile", "--norc", "--", bashScript]);
48+
assert.equal(options.cwd, "C:\\project");
49+
assert.equal(options.detached, false);
50+
assert.equal(options.windowsHide, true);
51+
assert.equal(options.stdio, "ignore");
52+
assert.equal(options.env?.NOTIFY_CUSTOM, "value");
53+
assert.equal(options.env?.DURATION, "15");
54+
assert.equal(options.env?.STATUS, "failed");
55+
assert.equal(options.env?.FAIL_REASON, "error");
56+
assert.equal(options.env?.BODY, "a\nb");
57+
assert.equal(options.env?.TITLE, "test");
58+
assert.equal(capture.unrefs(), 1);
59+
}
60+
});
61+
62+
test("Windows non-shell notifications retain direct execution", () => {
63+
for (const script of ["C:\\notify.exe", "C:\\notify.py", "C:\\notify.cmd"]) {
64+
const capture = captureSpawn();
65+
launchNotifyScript(
66+
script,
67+
0,
68+
undefined,
69+
capture.spawn,
70+
{},
71+
{},
72+
{
73+
platform: "win32",
74+
findGitBashPath: () => {
75+
assert.fail("Non-shell notifications must not look up Git Bash");
76+
},
77+
}
78+
);
79+
assert.equal(capture.calls.length, 1);
80+
assert.equal(capture.calls[0][0], script);
81+
assert.deepEqual(capture.calls[0][1], []);
82+
}
83+
});
84+
85+
test("Windows notification lookup and spawn failures do not escape or start a fallback shell", () => {
86+
const capture = captureSpawn();
87+
assert.doesNotThrow(() =>
88+
launchNotifyScript(
89+
"C:\\notify.sh",
90+
0,
91+
undefined,
92+
capture.spawn,
93+
{},
94+
{},
95+
{
96+
platform: "win32",
97+
findGitBashPath: () => {
98+
throw new Error("Git Bash not found");
99+
},
100+
}
101+
)
102+
);
103+
assert.equal(capture.calls.length, 0);
104+
105+
const throwingSpawn: NotifySpawn = () => {
106+
throw new Error("spawn failed");
107+
};
108+
assert.doesNotThrow(() => launchNotifyScript("C:\\notify.sh", 0, undefined, throwingSpawn, {}, {}, runtime));
109+
110+
for (const code of ["ENOENT", "EACCES", "ENOEXEC"]) {
111+
const failed = captureSpawn(code);
112+
assert.doesNotThrow(() => launchNotifyScript("C:\\notify.sh", 0, undefined, failed.spawn, {}, {}, runtime));
113+
assert.equal(failed.calls.length, 1);
114+
assert.equal(failed.unrefs(), 1);
115+
}
116+
});
117+
118+
test("empty notification paths do not look up Git Bash or spawn a process", () => {
119+
const capture = captureSpawn();
120+
for (const script of [undefined, "", " "]) {
121+
launchNotifyScript(
122+
script,
123+
0,
124+
undefined,
125+
capture.spawn,
126+
{},
127+
{},
128+
{
129+
platform: "win32",
130+
findGitBashPath: () => {
131+
assert.fail("Empty paths must not look up Git Bash");
132+
},
133+
}
134+
);
135+
}
136+
assert.equal(capture.calls.length, 0);
137+
});

0 commit comments

Comments
 (0)