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:
DrSmoothl
2026-06-25 02:53:44 +08:00
parent 41bad7f24c
commit 7343ae6372
2 changed files with 62 additions and 2 deletions
+13
View File
@@ -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())]
+49 -2
View File
@@ -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}`;