feat(bridge): darwin support — DYLD_INSERT-style hook discovery
Adds the missing macOS branch so HookManager + PipeWatcher discover QQ
processes when the dylib is injected via DYLD_INSERT_LIBRARIES (the
in-process injection model used by apps/qq/macos/qq_hook_dylib.cpp in
the nnphook private repo, vs Linux's out-of-process ptrace + N-API
addon model and Windows's manual-PE-loader injection).
Two small changes:
1. `linuxRuntimeDir()` (qq-hook-client.ts) — name kept for callsite
stability; the function now applies to every non-win32 platform.
New darwin branch prefers `$TMPDIR/snowluma-hook/` to match the
dylib's `runtime_dir()` (apps/qq/macos/qq_hook_dylib.cpp). The
Linux `/tmp/snowluma-<uid>` fallback stays as the last resort.
$TMPDIR is per-user under /var/folders/ on stock macOS, which is
the right place — /tmp/snowluma-<uid> doesn't exist by default.
2. `listSnowlumaPipePidsSync()` (qq-hook-client.ts) + darwin branch
in `listHookProcesses()` (injector.ts) — on macOS there is no
native enumerate-QQ-processes addon (DYLD_INSERT obviates the
need; the dylib is already mapped in QQ when SnowLuma launches).
The watcher's `listProcesses` dep is sync, so we provide a sync
directory scan that returns the pids encoded in mojo.<pid>.control.sock
filenames. The subsequent async `listLivePipes()` still gates the
pipe-up emit via a real connect probe — a dangling socket file
from a force-killed previous run can't drive a false connect.
Without these two changes, `pipe-watcher.tickOnce()` filtered out our
QQ pid at line 170 (`if (!newKnownPids.has(pid)) continue;`) because
`listProcessesNative()` returned []. The pipe stayed live (verified
by our nnphook recv hook firing), but the bridge never emitted
'pipe-up', HookManager never auto-loaded the session, and the WebUI
showed an empty process list.
Verified end-to-end against the resigned QQ 6.9.96-49738 + nnphook
gpt54 branch dylib on macOS 26.5.1 (Tahoe) arm64:
* Direct HookManager harness picks up PID=16032 in <2.5s,
fast-paths to 'reconnect' (the live-pipe-skip-injection branch
in hook-session.ts:200), pipe connected, status=loaded
* `pnpm dev` (SNOWLUMA_DEV_NO_NATIVE=1 for the websocket stub):
/api/processes returns the live QQ entry with method=reconnect,
connected=true, awaiting login
The websocket darwin-arm64.node native addon is still missing — set
SNOWLUMA_DEV_NO_NATIVE=1 to dev-mode boot with the existing stub
fallback. That's a separate runtime artifact and not in scope here.
`shouldAutoLoadPid()` returns true on non-linux without further checks
(no Electron-child-process scanning needed on darwin), and the
`hook-session.ts` "pipe already live → reconnect" fast path means
the bridge never tries to invoke the non-existent darwin native
injector. Auto-load on a freshly discovered macOS QQ is therefore
safe with or without `SNOWLUMA_HOOK_AUTOLOAD=1`.
Tests: bridge typecheck + 62 unit tests still pass.
This commit is contained in:
@@ -2,6 +2,8 @@ import { existsSync } from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
import { listSnowlumaPipePidsSync } from './qq-hook-client';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export interface ManualMapHandle {
|
||||
@@ -98,6 +100,17 @@ export function getNativeHookLoadError(): string | null {
|
||||
}
|
||||
|
||||
export function listHookProcesses(): HookProcessBaseInfo[] {
|
||||
// macOS: no native enumerate-QQ addon. The DYLD_INSERT injection model
|
||||
// means QQ ran with our dylib already mapped, so the dylib's listener
|
||||
// socket IS the discovery signal. Treat every `mojo.<pid>.control.sock`
|
||||
// in the runtime dir as a known process (the watcher's subsequent
|
||||
// connectable-probe gates the pipe-up emit, so a stale dangling socket
|
||||
// here doesn't drive a false connect).
|
||||
if (process.platform === 'darwin') {
|
||||
return [...listSnowlumaPipePidsSync()]
|
||||
.sort((a, b) => a - b)
|
||||
.map(pid => ({ pid, name: defaultProcessName(), path: '' }));
|
||||
}
|
||||
const addon = getNativeHookAddon();
|
||||
if (!addon) return [];
|
||||
return [...new Set(addon.getAllMainProcess())]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { EventEmitter } from 'events';
|
||||
import { promises as fs } from 'fs';
|
||||
import { readdirSync, promises as fs } from 'fs';
|
||||
import net from 'net';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
@@ -85,12 +85,59 @@ interface PendingAck {
|
||||
wantReply: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync directory scan for `mojo.<pid>.control.sock` socket files in the
|
||||
* runtime dir, returning the pid set. Used by the darwin path of
|
||||
* `listHookProcesses()` — on macOS the DYLD_INSERT injection model means
|
||||
* there is no native enumerate-QQ-processes addon; the existence of our
|
||||
* dylib's listener socket IS the proof that a QQ process is hooked. The
|
||||
* sync variant is needed because the watcher's `listProcesses` dep is sync.
|
||||
*
|
||||
* Mirrors `listLiveLinuxPipePids` (the async + connectable-probe variant)
|
||||
* but skips the connectable probe — the existence of the socket file is
|
||||
* sufficient signal for "process exists with our dylib mapped". The async
|
||||
* variant still gates the subsequent `pipe-up` emit via a real connect.
|
||||
*/
|
||||
export function listSnowlumaPipePidsSync(
|
||||
runtimeDir = linuxRuntimeDir(),
|
||||
): Set<number> {
|
||||
const result = new Set<number>();
|
||||
let names: string[];
|
||||
try {
|
||||
names = readdirSync(runtimeDir);
|
||||
} catch {
|
||||
return result;
|
||||
}
|
||||
for (const name of names) {
|
||||
const m = /^mojo\.(\d+)\.control\.sock$/i.exec(name);
|
||||
if (!m) continue;
|
||||
const pid = Number(m[1]);
|
||||
if (Number.isInteger(pid) && pid > 0) result.add(pid);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function linuxRuntimeDir(): string {
|
||||
// Name kept for historical reasons (the function predates the macOS port);
|
||||
// applies to every non-win32 platform. The fallback order mirrors each
|
||||
// platform hook's runtime_dir(): explicit override first, then the OS-
|
||||
// native ephemeral-dir convention.
|
||||
const explicit = process.env.SNOWLUMA_HOOK_RUNTIME_DIR;
|
||||
if (explicit && explicit.length > 0) return explicit;
|
||||
const xdg = process.env.XDG_RUNTIME_DIR;
|
||||
if (xdg && xdg.length > 0) return xdg;
|
||||
// Mirrors hook_stub.cpp runtime_dir() fallback.
|
||||
// macOS: dylib defaults to "$TMPDIR/snowluma-hook" (apps/qq/macos/
|
||||
// qq_hook_dylib.cpp's runtime_dir()). $TMPDIR is per-user on macOS
|
||||
// (under /var/folders/.../T) so this is the right place — Linux's
|
||||
// /tmp/snowluma-<uid> doesn't exist on a stock Mac.
|
||||
if (process.platform === 'darwin') {
|
||||
const tmpdir = process.env.TMPDIR;
|
||||
if (tmpdir && tmpdir.length > 0) {
|
||||
const trimmed = tmpdir.endsWith('/') ? tmpdir.slice(0, -1) : tmpdir;
|
||||
return path.join(trimmed, 'snowluma-hook');
|
||||
}
|
||||
}
|
||||
// Linux fallback: mirrors hook_stub.cpp runtime_dir() last-resort.
|
||||
// process.geteuid is POSIX-only; cast to allow non-Linux type checks.
|
||||
const uid = typeof process.geteuid === 'function' ? process.geteuid() : os.userInfo().uid;
|
||||
return `/tmp/snowluma-${uid}`;
|
||||
|
||||
Reference in New Issue
Block a user