Skip to content

Commit 07267b7

Browse files
dbfxclaude
andcommitted
feat: add macOS and Linux cross-platform support
Add platform abstraction for ping and traceroute commands, handling different flags, output formats, and regex patterns per OS. Update forge config with macOS/Linux ZIP maker. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent f1dd46f commit 07267b7

File tree

4 files changed

+83
-38
lines changed

4 files changed

+83
-38
lines changed

forge.config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ const config: ForgeConfig = {
2929
setupIcon: path.resolve(__dirname, 'build', 'icon.ico'),
3030
iconUrl: 'https://raw.githubusercontent.com/dbfx/modern-win-mtr/main/build/icon.ico',
3131
}),
32-
new MakerZIP({}),
32+
new MakerZIP({}, ['darwin', 'linux']),
3333
],
3434
plugins: [
3535
new VitePlugin({

src/main/services/persistent-ping.ts

Lines changed: 7 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,7 @@
11
import { spawn, ChildProcess } from 'child_process';
22
import { EventEmitter } from 'events';
3+
import { getPersistentPingArgs, PING_RTT_REGEX, PING_TIMEOUT_REGEX } from './platform';
34

4-
const REPLY_REGEX = /time[<=](\d+)ms/i;
5-
const TIMEOUT_REGEX = /Request timed out|General failure|Destination .* unreachable/i;
6-
7-
/**
8-
* A long-lived ping process that continuously pings a target using `ping -t`.
9-
* Emits 'result' events with the RTT (number) or null (timeout/failure).
10-
* Avoids the overhead of spawning a new process for every ping round.
11-
*/
125
export class PersistentPing extends EventEmitter {
136
private child: ChildProcess | null = null;
147
private ip: string;
@@ -22,28 +15,25 @@ export class PersistentPing extends EventEmitter {
2215
}
2316

2417
start(): void {
25-
this.child = spawn('ping', ['-t', '-w', String(this.timeout), this.ip], {
26-
windowsHide: true,
27-
});
18+
const { command, args } = getPersistentPingArgs(this.ip, this.timeout);
19+
this.child = spawn(command, args, { windowsHide: true });
2820

2921
this.child.stdout?.on('data', (data: Buffer) => {
3022
this.buffer += data.toString();
3123
const lines = this.buffer.split(/\r?\n/);
3224
this.buffer = lines.pop() || '';
3325

3426
for (const line of lines) {
35-
const match = line.match(REPLY_REGEX);
27+
const match = line.match(PING_RTT_REGEX);
3628
if (match) {
37-
this.emit('result', parseInt(match[1], 10));
38-
} else if (TIMEOUT_REGEX.test(line)) {
29+
this.emit('result', Math.round(parseFloat(match[1])));
30+
} else if (PING_TIMEOUT_REGEX.test(line)) {
3931
this.emit('result', null);
4032
}
4133
}
4234
});
4335

44-
this.child.on('error', () => {
45-
// Process failed to start — nothing we can do
46-
});
36+
this.child.on('error', () => {});
4737

4838
this.child.on('close', () => {
4939
this.child = null;

src/main/services/platform.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
const isWin = process.platform === 'win32';
2+
3+
export function getPersistentPingArgs(ip: string, timeout: number): { command: string; args: string[] } {
4+
if (isWin) {
5+
// ping -t = continuous, -w = timeout in ms
6+
return { command: 'ping', args: ['-t', '-w', String(timeout), ip] };
7+
}
8+
// macOS/Linux: no -t flag; use huge count for continuous. -W = timeout in seconds (min 1)
9+
const timeoutSec = Math.max(1, Math.ceil(timeout / 1000));
10+
return { command: 'ping', args: ['-c', '1000000', '-W', String(timeoutSec), ip] };
11+
}
12+
13+
export function getTracerouteArgs(target: string, maxHops: number): { command: string; args: string[] } {
14+
if (isWin) {
15+
return { command: 'tracert', args: ['-d', '-w', '1000', '-h', String(maxHops), target] };
16+
}
17+
// macOS/Linux: traceroute -n (no DNS), -w 1 (timeout), -m maxHops
18+
return { command: 'traceroute', args: ['-n', '-w', '1', '-m', String(maxHops), target] };
19+
}
20+
21+
// Windows: time<=1ms or time=12ms
22+
// macOS: time=1.234 ms
23+
export const PING_RTT_REGEX = isWin
24+
? /time[<=](\d+)ms/i
25+
: /time=(\d+(?:\.\d+)?)\s*ms/i;
26+
27+
export const PING_TIMEOUT_REGEX = isWin
28+
? /Request timed out|General failure|Destination .* unreachable/i
29+
: /Request timeout|100(\.0)?% packet loss|Host Unreachable|No route to host/i;
30+
31+
// Windows tracert output:
32+
// 1 3 ms 1 ms 2 ms 192.168.50.1
33+
// 3 * * * Request timed out.
34+
// macOS traceroute output:
35+
// 1 192.168.50.1 1.234 ms 0.987 ms 1.012 ms
36+
// 3 * * *
37+
export const TRACEROUTE_HOP_REGEX = isWin
38+
? /^\s*(\d+)\s+([\d<]+\s*ms|\*)\s+([\d<]+\s*ms|\*)\s+([\d<]+\s*ms|\*)\s*([\d.]+)?\s*/
39+
: /^\s*(\d+)\s+(?:(\d[\d.]*)\s+[\d.]+\s*ms|\*)/;
40+
41+
export function parseTracerouteHop(line: string): { hopNumber: number; ip: string | null } | null {
42+
if (isWin) {
43+
const match = line.match(
44+
/^\s*(\d+)\s+([\d<]+\s*ms|\*)\s+([\d<]+\s*ms|\*)\s+([\d<]+\s*ms|\*)\s*([\d.]+)?\s*/,
45+
);
46+
if (!match) return null;
47+
return { hopNumber: parseInt(match[1], 10), ip: match[5] || null };
48+
}
49+
50+
// macOS: " 1 192.168.1.1 1.234 ms ..." or " 3 * * *"
51+
const match = line.match(/^\s*(\d+)\s+(\d[\d.]+)\s+[\d.]+\s*ms/);
52+
if (match) {
53+
return { hopNumber: parseInt(match[1], 10), ip: match[2] };
54+
}
55+
const timeoutMatch = line.match(/^\s*(\d+)\s+\*/);
56+
if (timeoutMatch) {
57+
return { hopNumber: parseInt(timeoutMatch[1], 10), ip: null };
58+
}
59+
return null;
60+
}
61+
62+
export function isTraceComplete(line: string): boolean {
63+
if (isWin) return line.includes('Trace complete');
64+
// macOS traceroute just ends — no explicit "complete" message
65+
return false;
66+
}

src/main/services/traceroute.ts

Lines changed: 9 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,7 @@
11
import { spawn } from 'child_process';
22
import { DiscoveredHop } from '../../shared/types';
33
import dns from 'dns';
4-
5-
// Match hop lines — the IP at the end is optional (timeouts have none)
6-
// Examples:
7-
// 1 3 ms 1 ms 2 ms 192.168.50.1
8-
// 3 * * * Request timed out.
9-
// 12 * * * Request timed out.
10-
const HOP_REGEX = /^\s*(\d+)\s+([\d<]+\s*ms|\*)\s+([\d<]+\s*ms|\*)\s+([\d<]+\s*ms|\*)\s*([\d.]+)?\s*/;
4+
import { getTracerouteArgs, parseTracerouteHop, isTraceComplete } from './platform';
115

126
// How many consecutive timeouts after a real hop before we stop early
137
const MAX_CONSECUTIVE_TIMEOUTS = 5;
@@ -31,9 +25,8 @@ export function runTraceroute(
3125
let foundAnyIp = false;
3226

3327
const sanitized = target.replace(/[;&|`$(){}[\]!#]/g, '');
34-
const child = spawn('tracert', ['-d', '-w', '1000', '-h', String(maxHops), sanitized], {
35-
windowsHide: true,
36-
});
28+
const { command, args } = getTracerouteArgs(sanitized, maxHops);
29+
const child = spawn(command, args, { windowsHide: true });
3730

3831
signal?.addEventListener('abort', () => {
3932
child.kill();
@@ -49,24 +42,20 @@ export function runTraceroute(
4942
const processLine = (line: string) => {
5043
if (completed) return;
5144

52-
// Detect "Trace complete" to end
53-
if (line.includes('Trace complete')) {
45+
if (isTraceComplete(line)) {
5446
finish();
5547
return;
5648
}
5749

58-
const match = line.match(HOP_REGEX);
59-
if (!match) return;
60-
61-
const hopNumber = parseInt(match[1], 10);
62-
const ip = match[5] || null;
50+
const parsed = parseTracerouteHop(line);
51+
if (!parsed) return;
6352

64-
const hop: DiscoveredHop = { hopNumber, ip };
53+
const hop: DiscoveredHop = { hopNumber: parsed.hopNumber, ip: parsed.ip };
6554
hops.push(hop);
6655
callbacks.onHop(hop);
6756

6857
// Track consecutive timeouts to stop early
69-
if (ip) {
58+
if (parsed.ip) {
7059
foundAnyIp = true;
7160
consecutiveTimeouts = 0;
7261
} else {
@@ -108,7 +97,7 @@ export function runTraceroute(
10897
});
10998

11099
child.on('error', (err) => {
111-
callbacks.onError(`Failed to start tracert: ${err.message}`);
100+
callbacks.onError(`Failed to start traceroute: ${err.message}`);
112101
});
113102
}
114103

0 commit comments

Comments
 (0)