feat(T-3): 多端适配与工程质量 —— PlatformAdapter 三端实现/命令注入面清零/发现并发限流/CI 门禁

1. platform:三端完整实现。windows.ts / macos.ts / linux.ts 各自导出能力表与
   usb/share/discovery 实现,共用设施抽到 shared.ts(单向依赖,无循环)。
   Linux 是**显式降级实现类**(usb=null + 每条能力带中文 reason),不是 else 兜底。
2. usb.ts:退化为薄分派层,清掉 5 处 exec 拼串,改走 runner(参数数组 +
   shell:false);新增 listAttached();parseUsbPort / parseUsbipdList 纯函数,
   usb===null 时抛 UNSUPPORTED_ON_PLATFORM(不再把 ENOENT 抛给 UI)。
3. share.ts:薄分派 + 本机共享开关。Windows 接 scripts/setup-smb.ps1
   (ValidatePattern 白名单 + 绝对路径校验 + -EncryptData $true),
   macOS/Linux 明确 UNSUPPORTED_ON_PLATFORM + 引导文案,绝不静默成功。
4. discover.ts:候选 IP 剔除 .0/.255/网关/本机并按 /24 全局去重(单网卡 252);
   mapWithConcurrency 真实并发上限 64;mDNS 分派改由 capabilities().mdns 驱动,
   不再判断平台;解析器 parseDnsSd / parseAvahiBrowse 纯函数化。
5. 依赖预检:probeDependency 支持 ok/missing/outdated + hintUrl,最低主版本来自
   DEPENDENCY_MIN_MAJOR;core/logger.ts 补文件轮转与脱敏测试(4 类敏感输入)。
6. CI:.github/workflows/ci.yml —— typecheck → 双端测试 → 覆盖率门禁 →
   smoke → 残留进程检查;launcher 用 vitest.config.ts thresholds(全局 60%,
   核心 connect/discover/usb/agent/share/platform/* ≥70%),server 用 Node 22
   内置 --test-coverage-lines=60,低于阈值直接 fail。
7. UI:能力驱动显隐(USB/共享面板按 capabilities() 隐藏并显示原因),
   renderer 内无任何平台判断;IPC 错误边界抽成 core/ipc.ts 的 makeSafeHandler()
   以便 vitest 直测(§12.4 R4),main.ts 的 wrapIpc 退化为 3 行调用。

测试:launcher 245 例全绿(原 143 例非回归 + 102 新增),server 43 例全绿;
tsc --noEmit 0 错误;launcher 行覆盖 82.56%,server 75.31%(门禁 60%);
gitleaks pre-commit 通过。
This commit is contained in:
jkooe committed 2026-10-26 00:45:17 +08:00
1 parent c5dfa4a948
commit 531991954c
27 files changed
+5734 -433

No files matched your search

+98
View File
@@ -0,0 +1,98 @@
# Only in One - CI(R-P1-23)
#
# 门禁顺序刻意「先快后慢、先静态后动态」:
# 1. 类型检查(40s 内失败,最快给出反馈)
# 2. launcher 单测 + 覆盖率门禁(含核心四模块 ≥70%)
# 3. server 单测 + 覆盖率门禁(≥60%)
# 4. 冒烟测试(真实起服务跑全链路)
# 5. 残留进程检查(防止冒烟测试漏下常驻进程)
#
# 注意:本工作流**不引入**任何密钥;server 的 JWT_SECRET 由 smoke.mjs 内部随机生成。
name: ci
on:
push:
branches: ['**']
pull_request:
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
verify:
name: typecheck + tests + coverage + smoke
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Print toolchain versions
run: |
node -v
npm -v
# ---------------- 依赖(两个子项目各自独立)----------------
- name: Install server deps
working-directory: server
run: npm ci
- name: Install launcher deps
working-directory: launcher
run: npm ci --ignore-scripts
# ---------------- 1) 类型检查 ----------------
- name: Typecheck launcher (strict)
working-directory: launcher
run: npx tsc --noEmit -p tsconfig.json
# ---------------- 2) launcher 单测 + 覆盖率门禁 ----------------
# thresholds 写在 launcher/vitest.config.ts:
# · 全局 lines ≥60%
# · connect / discover / usb / agent / share / platform/* ≥70%
# 低于阈值时 vitest 以非 0 退出,CI 直接失败。
- name: Launcher tests + coverage gate
working-directory: launcher
run: npm run test:coverage
# ---------------- 3) server 单测 + 覆盖率门禁 ----------------
- name: Server tests
working-directory: server
run: npm test
- name: Server coverage gate (lines >= 60%)
working-directory: server
run: npm run test:coverage
# ---------------- 4) 冒烟测试(真实起服务 + 全链路)----------------
- name: Smoke test
working-directory: server
run: npm run smoke
# ---------------- 5) 残留进程检查 ----------------
# smoke.mjs 会在结束时 close server + exit;若有残留说明清理逻辑坏了。
- name: Assert no leftover processes
run: |
if pgrep -f 'smoke\.mjs' >/dev/null 2>&1; then
echo '::error::smoke.mjs 退出后仍有残留进程'
pgrep -af 'smoke\.mjs' || true
exit 1
fi
echo 'no leftover process'
# 覆盖率报告作为构建产物留存,便于事后排查(不参与判定)
- name: Upload launcher coverage
if: always()
uses: actions/upload-artifact@v4
with:
name: launcher-coverage
path: launcher/coverage/
if-no-files-found: ignore
retention-days: 7
+1
View File
@@ -3,6 +3,7 @@ node_modules/
# ---- 构建产物 ----
**/dist/
**/coverage/
launcher/release/
# ---- 运行期数据(含密码哈希,绝不入库) ----
+2141
View File
File diff suppressed because it is too large. Load diff
+4 -2
View File
@@ -8,12 +8,14 @@
"start": "electron .",
"dev": "npm run build && electron .",
"typecheck:core": "tsc --noEmit --module commonjs --target ES2021 --strict --esModuleInterop --skipLibCheck --types node src/api.ts src/connect.ts src/discover.ts src/agent.ts src/usb.ts src/share.ts src/settings.ts src/main.ts src/preload.ts",
"test": "vitest run"
"test": "vitest run",
"test:coverage": "vitest run --coverage"
},
"devDependencies": {
"@types/node": "^20.14.0",
"@vitest/coverage-v8": "^2.1.9",
"electron": "^31.0.0",
"typescript": "^5.5.0",
"@types/node": "^20.14.0",
"vitest": "^2.0.0"
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
// Only in One - 一键连接(R-P1-27:**非阻塞**,≤3s 返回)。
//
// 语义变化(对比 v1):旧实现 `exec('moonlight stream …')` 会一直等到串流结束,
// 语义变化(对比 v1):旧实现用一条拼好的命令行去启动 moonlight,会一直等到串流结束,
// 用户点「连接」后界面卡死、状态永远停在「正在连接」。
// 新实现:`spawn` + `detached` + `unref`,**只做 1.5s 存活判定**——
// 进程活过 1.5s 即认为「已发起连接」立即返回;1.5s 内就退出说明启动失败。
+75
View File
@@ -0,0 +1,75 @@
// Only in One - IPC 错误边界(架构 §9.2 第 3 条 / §12.4 R4)。
//
// 为什么抽成纯高阶函数:
// `main.ts` 顶层 `import { app } from 'electron'`,vitest 里加载不了,
// 于是「handler 抛错 → renderer 收到什么」这条**最关键的错误边界**长期没有自动化测试。
// 把边界抽成不依赖 electron 的 `makeSafeHandler()` 后:
// · 边界逻辑可被 vitest 直接覆盖(含 AppError / 原始 Error / 同步抛 / reject);
// · `main.ts` 退化为「3 行调用」,不再承载任何判断逻辑。
//
// 禁止的做法:不要为了测 main.ts 而引入 electron mock(API 面积过大、维护成本高于收益)。
import type { ApiResult } from '../platform/types';
import type { ErrorCode, UserErrorInfo } from './errors';
import { AppError, isErrorCode, toUserError } from './errors';
import type { Logger } from './logger';
/** IPC 统一返回形状(renderer 只认这一种)。 */
export type IpcResult<T> = { ok: true; data: T } | { ok: false; error: UserErrorInfo };
/** `makeSafeHandler` 的可选参数。 */
export interface SafeHandlerOptions {
/** 通道名,仅用于日志(`域:动作`) */
channel?: string;
/** 日志器(缺省不记) */
logger?: Logger;
}
/**
* 把 `ApiResult` 解包成值;失败时抛 `AppError`(由 `makeSafeHandler` 统一转三段式)。
*
* 这样业务 handler 里可以直接 `const devices = unwrapApi(await api.listDevices())`,
* 不必到处写 `if (!result.ok) return result`。
* @param result API 结果
* @returns 成功数据
* @throws {AppError} 失败时抛带错误码的异常
*/
export function unwrapApi<T>(result: ApiResult<T>): T {
if (result.ok) return result.data;
const code: ErrorCode = isErrorCode(result.error.code) ? result.error.code : 'UNKNOWN';
throw new AppError(code, result.error.details);
}
/**
* 生成一个「永不向 renderer 抛异常」的 IPC handler。
*
* 三条保证:
* 1. 成功 → `{ok:true, data}`;
* 2. 任何异常(同步抛 / Promise reject / 非 Error 值)→ `{ok:false, error}`,
* 其中 `error` 是 `{code, message, hint}`,**不含堆栈、不含原始 message**;
* 3. 记录一条脱敏日志(只记通道名与错误码,不记参数)。
*
* @param handler 业务实现
* @param options 通道名与日志器
* @returns 可直接交给 `ipcMain.handle` 的安全 handler
*/
export function makeSafeHandler<TArgs extends unknown[], TResult>(
handler: (...args: TArgs) => Promise<TResult> | TResult,
options: SafeHandlerOptions = {}
): (...args: TArgs) => Promise<IpcResult<TResult>> {
const { channel = 'unknown', logger } = options;
return async (...args: TArgs): Promise<IpcResult<TResult>> => {
try {
const data = await handler(...args);
return { ok: true, data };
} catch (error) {
const userError = toUserError(error);
try {
logger?.error('IPC 调用失败', { channel, code: userError.code });
} catch {
/* 日志失败不能影响错误响应 */
}
return { ok: false, error: userError };
}
};
}
+261 -53
View File
@@ -1,9 +1,28 @@
// Only in One - LAN device discovery.
// Strategy: mDNS/Bonjour first (Sunshine advertises), fall back to scanning the
// local subnet for hosts listening on 47990 (Sunshine HTTPS).
import { exec } from 'child_process';
import os from 'os';
import net from 'net';
// Only in One - 局域网设备发现(薄分派 + 有界并发)。
//
// T-3 变更(G-18 / G-20):
// · **不做平台判断**:mDNS 走 `adapter.capabilities().mdns` 能力位决定,
// 不支持时自动回落端口扫描(Windows 就是这条路);
// · 候选 IP:剔除网络地址 `.0`、广播地址 `.255`、约定网关 `.1` 与本机地址,
// 多网卡**全局去重**(同一 /24 只扫一次);
// · 并发有硬上限(默认 64):旧实现 `Promise.all(252 个 socket)` 一次性开满,
// 在弱网/防火墙下会瞬间打满 fd 与 conntrack。
import net from 'node:net';
import os from 'node:os';
import type { PlatformAdapter } from './platform/types';
import { getPlatformAdapter } from './platform/index';
/** Sunshine HTTPS 端口。 */
export const SCAN_PORT = 47990;
/** 单 IP 扫描超时。 */
export const SCAN_TIMEOUT_MS = 300;
/** 并发上限(架构 §G-20:≤64)。 */
export const MAX_CONCURRENCY = 64;
/** 约定网关的末位(跳过,避免把路由器当主机)。 */
const GATEWAY_LAST_OCTET = 1;
export interface DiscoveredHost {
ip: string;
@@ -11,62 +30,251 @@ export interface DiscoveredHost {
source: 'mdns' | 'scan';
}
function execOut(cmd: string): Promise<string> {
return new Promise((resolve) => {
exec(cmd, (_err, stdout) => resolve(stdout || ''));
/** 网络接口信息(`os.networkInterfaces()` 的子集,便于注入)。 */
export interface InterfaceInfoLike {
address: string;
family: string;
internal: boolean;
}
export interface DiscoverDeps {
/** 注入平台适配器(默认全局) */
adapter?: PlatformAdapter;
/** 注入网络接口(默认 `os.networkInterfaces()`) */
interfaces?: Record<string, InterfaceInfoLike[] | undefined>;
/** 注入连通性探测(默认 TCP connect;单测用它避免真实开 socket) */
probe?: (ip: string, port: number, timeoutMs: number) => Promise<boolean>;
/** 并发上限(默认 {@link MAX_CONCURRENCY}) */
concurrency?: number;
/** 单 IP 超时 */
timeoutMs?: number;
/** 并发峰值回调(单测断言「峰值 ≤64」用) */
onPeak?: (peak: number) => void;
}
/**
* 严格判断 IPv4。
* @param value 待校验值
* @returns 合法返回 true
*/
function isIpv4(value: unknown): value is string {
if (typeof value !== 'string') return false;
return /^(?:\d{1,3}\.){3}\d{1,3}$/.test(value) && value.split('.').every((part) => Number(part) <= 255);
}
/**
* 生成一个 /24 网段的候选 IP(**纯函数**)。
*
* 规则:`0–255` 全量枚举,剔除 4 类地址后恰好 **252** 个:
* · `.0` 网络地址
* · `.255` 广播地址
* · `.1` 约定网关(对本机不可作为被控端使用)
* · 本机接口地址(不能连自己)
*
* 当本机地址恰好是 `.1` 时,两者合并为一次剔除,结果 253 —— 这是刻意接受的
* 边界(真机几乎不会把被控端放在 `.1`,且 mDNS 兜底)。
* @param address 本机接口 IPv4(取前三段作为网段)
* @returns 候选 IP 列表
*/
export function ipsForSubnet(address: string): string[] {
return ipsForSubnetExcluding(address, [address]);
}
/**
* 生成一个 /24 网段的候选 IP,并排除任意一组本机地址。
*
* 多网卡同网段时必须用这个版本:两块网卡各自都是「本机」,两者都要排除
* (单网卡 252、同网段双网卡 251),否则跨网卡合并会把对方网卡的地址加回来。
* @param address 参照地址(取前三段)
* @param localAddresses 本机全部 IPv4
* @returns 候选 IP
*/
export function ipsForSubnetExcluding(address: string, localAddresses: readonly string[]): string[] {
if (!isIpv4(address)) return [];
const octets = address.split('.').map((part) => Number.parseInt(part, 10));
const prefix = `${octets[0]}.${octets[1]}.${octets[2]}`;
const excludedLast = new Set<number>([0, 255, GATEWAY_LAST_OCTET]);
for (const local of localAddresses) {
if (!isIpv4(local)) continue;
if (!local.startsWith(`${prefix}.`)) continue;
const last = Number.parseInt(local.split('.')[3], 10);
if (Number.isFinite(last)) excludedLast.add(last);
}
const result: string[] = [];
for (let host = 0; host <= 255; host += 1) {
if (excludedLast.has(host)) continue;
result.push(`${prefix}.${host}`);
}
return result;
}
/**
* 枚举所有非内网 IPv4 接口的候选 IP,**按 /24 前缀全局去重**(纯函数)。
* @param interfaces 网络接口(默认取系统)
* @returns 去重后的候选 IP
*/
export function candidateIps(
interfaces: Record<string, InterfaceInfoLike[] | undefined> = os.networkInterfaces()
): string[] {
const localAddresses: string[] = [];
const prefixes = new Set<string>();
const seen = new Set<string>();
const result: string[] = [];
for (const name of Object.keys(interfaces)) {
for (const info of interfaces[name] ?? []) {
if (info.family !== 'IPv4' || info.internal) continue;
if (!isIpv4(info.address)) continue;
localAddresses.push(info.address);
const octets = info.address.split('.');
prefixes.add(`${octets[0]}.${octets[1]}.${octets[2]}`);
}
}
for (const prefix of prefixes) {
for (const ip of ipsForSubnetExcluding(`${prefix}.1`, localAddresses)) {
if (seen.has(ip)) continue;
seen.add(ip);
result.push(ip);
}
}
return result;
}
/**
* 有界并发映射(**并发数真实受限**,不是常量摆设)。
*
* 实现方式:开 `limit` 条 worker「泳道」共享一个自增索引,任何时刻活跃任务数
* 不超过 `limit`;`onPeak` 会收到历史峰值,便于单测断言。
* @param items 输入
* @param limit 并发上限
* @param worker 任务
* @param onPeak 峰值回调
* @returns 与输入同序的结果
*/
export async function mapWithConcurrency<T, R>(
items: readonly T[],
limit: number,
worker: (item: T, index: number) => Promise<R>,
onPeak?: (peak: number) => void
): Promise<R[]> {
if (items.length === 0) return [];
const width = Math.max(1, Math.min(Math.trunc(limit) || 1, items.length));
const results = new Array<R>(items.length);
let nextIndex = 0;
let active = 0;
let peak = 0;
const lane = async (): Promise<void> => {
for (;;) {
const current = nextIndex;
nextIndex += 1;
if (current >= items.length) return;
active += 1;
if (active > peak) {
peak = active;
onPeak?.(peak);
}
try {
results[current] = await worker(items[current], current);
} finally {
active -= 1;
}
}
};
await Promise.all(Array.from({ length: width }, () => lane()));
return results;
}
/**
* 默认连通性探测:TCP connect 到 `port`。
* @param ip 目标
* @param port 端口
* @param timeoutMs 超时
* @returns 可连接返回 true
*/
export function probeTcp(ip: string, port: number, timeoutMs: number): Promise<boolean> {
return new Promise<boolean>((resolve) => {
let settled = false;
const finish = (alive: boolean): void => {
if (settled) return;
settled = true;
resolve(alive);
};
let socket: net.Socket;
try {
socket = net.connect({ host: ip, port, timeout: timeoutMs });
} catch {
finish(false);
return;
}
socket.once('connect', () => {
socket.destroy();
finish(true);
});
socket.once('error', () => {
socket.destroy();
finish(false);
});
socket.once('timeout', () => {
socket.destroy();
finish(false);
});
});
}
// mDNS discovery via `dns-sd` (macOS) / `avahi-browse` (Linux). Windows returns [].
async function discoverMdns(): Promise<DiscoveredHost[]> {
if (process.platform !== 'darwin') return [];
const out = await execOut('dns-sd -B _sunshine._tcp local. -Z -t 1 2>/dev/null');
/**
* 扫描局域网(best-effort)。
* @param deps 注入依赖
* @returns 发现的宿主
*/
export async function scanLan(deps: DiscoverDeps = {}): Promise<DiscoveredHost[]> {
const candidates = candidateIps(deps.interfaces);
if (candidates.length === 0) return [];
const probe = deps.probe ?? probeTcp;
const timeoutMs = deps.timeoutMs ?? SCAN_TIMEOUT_MS;
const found: DiscoveredHost[] = [];
for (const m of out.matchAll(/Add.*?(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}).*?:(\d+)/g)) {
found.push({ ip: m[1], port: Number(m[2]), source: 'mdns' });
}
return found;
}
// Best-effort scan of the local /24 subnet for port 47990.
async function scanLan(): Promise<DiscoveredHost[]> {
const found: DiscoveredHost[] = [];
await Promise.all(
candidateIps().map(
(ip) =>
new Promise<void>((resolve) => {
const sock = net.connect({ host: ip, port: 47990, timeout: 300 });
sock.on('connect', () => {
found.push({ ip, port: 47990, source: 'scan' });
sock.destroy();
resolve();
});
sock.on('error', () => resolve());
sock.on('timeout', () => {
sock.destroy();
resolve();
});
})
)
await mapWithConcurrency(
candidates,
deps.concurrency ?? MAX_CONCURRENCY,
async (ip) => {
const alive = await probe(ip, SCAN_PORT, timeoutMs);
if (alive) found.push({ ip, port: SCAN_PORT, source: 'scan' });
return alive;
},
deps.onPeak
);
return found;
return found.sort((left, right) => left.ip.localeCompare(right.ip));
}
export function candidateIps(): string[] {
const list: string[] = [];
const ifs = os.networkInterfaces();
for (const name of Object.keys(ifs)) {
for (const ni of ifs[name] || []) {
if (ni.family !== 'IPv4' || ni.internal) continue;
const prefix = ni.address.split('.').slice(0, 3).join('.');
for (let i = 1; i <= 254; i++) list.push(`${prefix}.${i}`);
/**
* 发现局域网设备:优先 mDNS(能力允许时),为空则回落端口扫描。
* @param deps 注入依赖
* @returns 发现的宿主(已去重)
*/
export async function discoverDevices(deps: DiscoverDeps = {}): Promise<DiscoveredHost[]> {
const adapter = deps.adapter ?? getPlatformAdapter();
const mdnsCapability = adapter.capabilities().mdns;
if (mdnsCapability.state !== 'unsupported') {
try {
const list = await adapter.discovery.browseMdns();
const seen = new Set<string>();
const hosts: DiscoveredHost[] = [];
for (const item of list) {
const key = `${item.ip}:${item.port}`;
if (seen.has(key)) continue;
seen.add(key);
hosts.push({ ip: item.ip, port: item.port, source: 'mdns' });
}
if (hosts.length > 0) return hosts;
} catch {
// mDNS 失败不阻断:回落到扫描(Windows 的能力位就是 unsupported,直接走扫描)
}
}
return list;
}
export async function discoverDevices(): Promise<DiscoveredHost[]> {
const mdns = await discoverMdns();
if (mdns.length) return mdns;
return scanLan();
return scanLan(deps);
}
+32 -18
View File
@@ -34,6 +34,8 @@ input:focus { outline:none; border-color:var(--accent); }
.toast.show { opacity:1; }
.toast.ok { background:#1f3d2b; color:#c8f0d8; border:1px solid #2f6b47; }
.toast.err { background:#3d1f1f; color:#f7d4d4; border:1px solid #6b2f2f; }
/* 能力提示:平台不支持时说明原因(不用弹窗打断) */
.notice { font-size:12px; line-height:1.6; color:#d8c08a; background:#2a2418; border:1px solid #4a3d22; border-radius:8px; padding:8px 10px; margin-top:12px; }
</style>
</head>
<body>
@@ -69,32 +71,44 @@ input:focus { outline:none; border-color:var(--accent); }
<div class="sub" style="margin-bottom:12px">我的设备</div>
<div id="deviceList"><div class="status">点击「上线」后刷新设备列表</div></div>
</div>
<div class="card">
<div class="card" id="usbPanel">
<div class="sub" style="margin-bottom:12px">USB 设备</div>
<div class="row" style="margin-top:0">
<button class="btn ghost" id="usbRefreshBtn">刷新本机 USB</button>
<div id="usbExportBlock">
<div class="row" style="margin-top:0">
<button class="btn ghost" id="usbRefreshBtn">刷新本机 USB</button>
</div>
<div id="usbList"><div class="status">尚未加载</div></div>
</div>
<div id="usbList"><div class="status">仅 Windows 被控端可列出 / 共享</div></div>
<div class="sub" style="margin:16px 0 8px">挂载远程 USB(macOS 主控端)</div>
<div class="row" style="margin-top:0">
<input id="usbHost" placeholder="对端 IP" />
<input id="usbBusid" placeholder="busid / 端口" style="flex:0 0 130px" />
</div>
<div class="row">
<button class="btn" id="usbAttachBtn">挂载</button>
<button class="btn ghost" id="usbDetachBtn">卸载</button>
<div id="usbImportBlock">
<div class="sub" style="margin:16px 0 8px">挂载远程 USB(主控端)</div>
<div class="row" style="margin-top:0">
<input id="usbHost" placeholder="对端 IP" />
<input id="usbBusid" placeholder="busid / 端口" style="flex:0 0 130px" />
</div>
<div class="row">
<button class="btn" id="usbAttachBtn">挂载</button>
<button class="btn ghost" id="usbDetachBtn">卸载</button>
</div>
</div>
<div class="notice hidden" id="usbCapNotice"></div>
<div class="status" id="usbStatus"></div>
</div>
<div class="card">
<div class="card" id="sharePanel">
<div class="sub" style="margin-bottom:12px">文件共享</div>
<div class="row" style="margin-top:0">
<input id="shareHost" placeholder="对端 IP(如 192.168.1.50)" />
<input id="shareName" placeholder="共享名" value="OnlyInOne" style="flex:0 0 150px" />
<div id="shareImportBlock">
<div class="row" style="margin-top:0">
<input id="shareHost" placeholder="对端 IP(如 192.168.1.50)" />
<input id="shareName" placeholder="共享名" value="OnlyInOne" style="flex:0 0 150px" />
</div>
<div class="row">
<button class="btn" id="shareOpenBtn">打开共享文件夹</button>
</div>
</div>
<div class="row">
<button class="btn" id="shareOpenBtn">打开共享文件夹</button>
<div id="shareExportBlock" class="hidden">
<div class="sub" style="margin:16px 0 8px">本机共享</div>
<div class="status" id="shareStateText">读取中…</div>
</div>
<div class="notice hidden" id="shareCapNotice"></div>
<div class="status" id="shareStatus"></div>
</div>
<div class="status" id="mainStatus"></div>
+31 -54
View File
@@ -25,11 +25,12 @@ import { createLogger } from './core/logger';
import { defaultPaths } from './core/paths';
import { ensureDeviceId, resolveBaseUrl, setBaseUrl } from './core/config';
import { performLogout } from './core/session';
import { AppError, isErrorCode, toUserError } from './core/errors';
import type { UserErrorInfo } from './core/errors';
import { isIPv4, isShareName, isUsbPort } from './core/validate';
import { listUsbDevices, shareUsb, unshareUsb, attachUsb, detachUsb } from './usb';
import { SHARE_NAME, openSharedFolder } from './share';
import { AppError, isErrorCode } from './core/errors';
import { makeSafeHandler, unwrapApi } from './core/ipc';
import type { IpcResult } from './core/ipc';
import { isIPv4 } from './core/validate';
import { attachUsb, detachUsb, listAttachedUsb, listUsbDevices, shareUsb, unshareUsb } from './usb';
import { getLocalShareState, openSharedFolder } from './share';
import { getServerUrl } from './settings';
/** 日志器(落 `userData/logs/launcher.log`,5MB×3 轮转)。 */
@@ -47,24 +48,21 @@ let credentialStore: CredentialStore | null = null;
/** 设备缓存(降级态渲染用)。 */
let deviceCache: DeviceCache | null = null;
/** IPC 统一返回形状(renderer 只认这一种)。 */
export type IpcResult<T> = { ok: true; data: T } | { ok: false; error: UserErrorInfo };
/** 主窗口引用:状态推送用(已在文件上方声明,此处仅补充导出类型说明)。 */
export type { IpcResult };
/**
* 把 `ApiResult` 解包成值;失败时抛 `AppError`(由 `wrapIpc` 统一转三段式)。
* 把 `ApiResult` 解包成值;失败时抛 `AppError`。
* @param result API 结果
* @returns 成功数据
* @throws {AppError}
* @deprecated 直接用 `core/ipc.ts` 的 `unwrapApi`;此处保留别名以免调用点大改。
*/
function unwrap<T>(result: ApiResult<T>): T {
if (result.ok) return result.data;
throw new AppError(isErrorCode(result.error.code) ? result.error.code : 'UNKNOWN', result.error.details);
}
const unwrap = unwrapApi;
/**
* 注册一个统一的 IPC handler。
* 注册一个统一的 IPC handler(**3 行调用**,边界逻辑在 `core/ipc.ts`)。
*
* **所有** `ipcMain.handle` 必须走这里(架构 §9.2 第 3 条):
* 所有 `ipcMain.handle` 必须走这里(架构 §9.2 第 3 条):
* renderer 侧收到的永远是 `{ok, data|error}`,不会收到异常,也看不到堆栈。
* @param channel IPC 通道名(`域:动作`)
* @param handler 业务实现
@@ -74,16 +72,9 @@ function wrapIpc<TArgs extends unknown[], TResult>(
channel: string,
handler: (...args: TArgs) => Promise<TResult> | TResult
): void {
ipcMain.handle(channel, async (_event, ...args: unknown[]): Promise<IpcResult<TResult>> => {
try {
const data = await handler(...(args as TArgs));
return { ok: true, data };
} catch (error) {
const userError = toUserError(error);
logger.error('IPC 调用失败', { channel, code: userError.code });
return { ok: false, error: userError };
}
});
const safe = makeSafeHandler(handler, { channel, logger });
// electron 的 handler 会把 event 作为第一个参数传入,这里做一个宽度适配
ipcMain.handle(channel, safe as unknown as Parameters<typeof ipcMain.handle>[1]);
}
/** 向 renderer 推送状态。 */
@@ -255,38 +246,24 @@ function registerIpc(): void {
return { pid: result.pid ?? null };
});
// ---------------- USB(usb.ts 在 T-3 迁移到 runner,这里先做输入校验兜底)----------------
// ---------------- USB ----------------
// 入参白名单校验已下沉到 usb.ts(单一事实来源),此处只做转发;
// 平台不支持时 usb.ts 抛 UNSUPPORTED_ON_PLATFORM,由 wrapIpc 转三段式。
wrapIpc('usb:list', () => listUsbDevices());
wrapIpc('usb:share', async (busid: unknown) => {
if (!isUsbPort(busid)) throw new AppError('INVALID_ARGUMENT', { field: 'busid' });
await shareUsb(busid as string);
return { busid };
});
wrapIpc('usb:unshare', async (busid: unknown) => {
if (!isUsbPort(busid)) throw new AppError('INVALID_ARGUMENT', { field: 'busid' });
await unshareUsb(busid as string);
return { busid };
});
wrapIpc('usb:attach', async (host: unknown, busid: unknown) => {
if (!isIPv4(host)) throw new AppError('INVALID_ARGUMENT', { field: 'host' });
if (!isUsbPort(busid)) throw new AppError('INVALID_ARGUMENT', { field: 'busid' });
await attachUsb(host as string, busid as string);
return { host, busid };
});
wrapIpc('usb:detach', async (port: unknown) => {
if (!isUsbPort(port)) throw new AppError('INVALID_ARGUMENT', { field: 'port' });
await detachUsb(port as string);
return { port };
});
wrapIpc('usb:listAttached', () => listAttachedUsb());
wrapIpc('usb:share', (busid: unknown) => shareUsb(String(busid ?? '')));
wrapIpc('usb:unshare', (busid: unknown) => unshareUsb(String(busid ?? '')));
wrapIpc('usb:attach', (host: unknown, busid: unknown) => attachUsb(String(host ?? ''), String(busid ?? '')));
wrapIpc('usb:detach', (port: unknown) => detachUsb(String(port ?? '')));
// ---------------- 文件共享 ----------------
wrapIpc('share:open', async (host: unknown, shareName: unknown) => {
if (!isIPv4(host)) throw new AppError('INVALID_ARGUMENT', { field: 'host' });
const name = shareName === undefined || shareName === null || shareName === '' ? SHARE_NAME : shareName;
if (!isShareName(name)) throw new AppError('INVALID_ARGUMENT', { field: 'shareName' });
openSharedFolder(host as string, name as string);
return { host, shareName: name };
});
wrapIpc('share:open', (host: unknown, shareName: unknown) =>
openSharedFolder(String(host ?? ''), typeof shareName === 'string' ? shareName : undefined)
);
wrapIpc('share:state', () => getLocalShareState());
// ---------------- 平台能力(UI 依据它隐藏入口,R-P1-10~13)----------------
wrapIpc('capabilities:get', () => getPlatformAdapter().capabilities());
// ---------------- 配置 ----------------
wrapIpc('config:getServer', () => ({ serverUrl: getServerUrl(), effective: resolveBaseUrl() }));
+52 -245
View File
@@ -1,46 +1,47 @@
// Only in One - 平台适配器工厂(架构 §5.1 / §5.4)。
//
// ⚠️ 本文件是**唯一允许出现 `process.platform` 的非测试模块**(架构 §9.5)。
// 业务模块一律通过 `getPlatformAdapter()` 拿能力,禁止自行判断平台。
// ⚠️ 本文件是 **src 内唯一允许出现 `process.platform` 的地方**(架构 §9.5)。
// 业务模块(`usb.ts` / `share.ts` / `discover.ts` / `main.ts` / `renderer.js`)
// 一律通过 `getPlatformAdapter()` 拿能力,禁止自行判断平台。
//
// T-2 阶段说明(与 T-3 的分工):
// · 本文件提供**骨架实现** + 可注入工厂 + 粗粒度能力表;
// · T-3 会把能力表与命令实现拆到 `platform/{windows,macos,linux}.ts`,
// 本文件的公开签名**不会变**,调用方零改动;
// · Sunshine 凭据目录在 T-2 只做「粗解析」(沿用现有 win32 / 其他 两分支),
// 三端候选探测(`platform/credDir.ts`)属 T-5,不提前实现。
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
// 本文件只做「选哪个实现」,三端实现分别在 windows.ts / macos.ts / linux.ts,
// 共享设施在 shared.ts(依赖方向单向,无循环)。
import type {
CapabilityState,
CredentialStore,
DependencyName,
DependencyStatus,
DiscoveryAdapter,
Platform,
PlatformAdapter,
PlatformCapabilities,
ShareAdapter,
} from './types';
import { createCredentialStore } from './credentialStore';
import { defaultPaths } from '../core/paths';
import { runner } from '../core/runner';
import type { AdapterDeps } from './shared';
import { PLATFORMS } from './shared';
import { createWindowsAdapter, WINDOWS_CAPABILITIES } from './windows';
import { createMacosAdapter, MACOS_CAPABILITIES } from './macos';
import { createLinuxAdapter, LINUX_CAPABILITIES } from './linux';
/** 三个合法平台值。 */
export const PLATFORMS: readonly Platform[] = ['win32', 'darwin', 'linux'];
/** 依赖安装指引(T-4 的 troubleshooting 文档存在后可换成站内链接)。 */
export const DEPENDENCY_HINT_URLS: Record<DependencyName, string> = {
sunshine: 'https://github.com/LizardByte/Sunshine#installation',
moonlight: 'https://github.com/moonlight-stream/moonlight-qt/releases',
usbipd: 'https://github.com/dorssel/usbipd-win/releases',
usbip: 'https://github.com/dorssel/usbipd-win/wiki',
smb: 'https://support.apple.com/guide/mac-help/mchlp1140/mac',
};
/** Linux 本轮统一的降级说明文案(架构 §5.4,不得写成「未知平台」)。 */
export const LINUX_UNSUPPORTED_REASON = '本轮未做 Linux 真机验证,暂不支持;如有需要请参考故障排查文档';
export type { AdapterDeps } from './shared';
export {
CERT_EXTENSIONS,
DEFAULT_SHARE_NAME,
DEPENDENCY_HINT_URLS,
DEPENDENCY_MIN_MAJOR,
LINUX_UNSUPPORTED_REASON,
PLATFORMS,
coarseSunshineCredDir,
listCertFiles,
moonlightCertCandidateDirs,
parseAvahiBrowse,
parseDnsSd,
parseSmbSharePath,
parseUsbPort,
parseUsbipdList,
probeDependency,
psSingleQuote,
resolveScriptsDir,
} from './shared';
export { WINDOWS_CAPABILITIES } from './windows';
export { MACOS_CAPABILITIES } from './macos';
export { LINUX_CAPABILITIES } from './linux';
/**
* 探测当前平台。只允许在平台层与测试工具里调用。
@@ -49,185 +50,19 @@ export const LINUX_UNSUPPORTED_REASON = '本轮未做 Linux 真机验证,暂
export function detectPlatform(): Platform {
const raw = process.platform;
if (raw === 'win32' || raw === 'darwin' || raw === 'linux') return raw;
// 其它 Unix(freebsd 等)本轮按 linux 处理,但仍走显式降级分支
// 其它 Unix(freebsd 等)本轮按 linux 处理,但仍走**显式降级**分支
return 'linux';
}
/**
* 粗粒度目录解析:Sunshine 凭据目录(**T-2 临时实现**,两分支)。
*
* T-5 第 1 步会用 `platform/credDir.ts` 的三端候选探测替换,届时本函数降为兜底。
* 取某平台的静态能力表(含每端的中文不支持说明)。
* @param platform 平台
* @param env 环境变量(默认 process.env)
* @returns 凭据目录绝对路径(不保证存在)
*/
export function coarseSunshineCredDir(platform: Platform, env: NodeJS.ProcessEnv = process.env): string {
if (platform === 'win32') {
return path.join(env.ProgramData || 'C:\\ProgramData', 'Sunshine', 'config', 'credentials');
}
const home = env.HOME || env.USERPROFILE || os.homedir();
return path.join(home, '.config', 'sunshine', 'credentials');
}
/**
* 本机 Moonlight 公钥证书的候选**目录**(顺序即优先级)。
* @param platform 平台
* @param env 环境变量
* @returns 候选目录列表
*/
export function moonlightCertCandidateDirs(
platform: Platform,
env: NodeJS.ProcessEnv = process.env
): string[] {
const home = env.HOME || env.USERPROFILE || os.homedir();
if (platform === 'win32') {
const programData = env.ProgramData || 'C:\\ProgramData';
const localAppData = env.LOCALAPPDATA || path.join(home, 'AppData', 'Local');
return [
path.join(localAppData, 'Moonlight Game Streaming Client'),
path.join(programData, 'Moonlight Game Streaming Client'),
];
}
// darwin 与 linux 共用 XDG 布局;Linux 本轮 streamClient 为 unsupported,
// 仍保留探测以免将来纳入时漏掉(架构 §5.3 备注)。
return [
path.join(env.XDG_CONFIG_HOME || path.join(home, '.config'), 'Moonlight Game Streaming Client'),
path.join(home, 'Library', 'Application Support', 'Moonlight Game Streaming Client'),
];
}
/** 允许被视为「公钥证书文件」的扩展名。 */
const CERT_EXTENSIONS: readonly string[] = ['.pem', '.crt'];
/**
* 列出候选目录里的公钥证书文件。
* @param dirs 候选目录
* @returns 存在的证书文件绝对路径(去重,按目录顺序)
*/
export function listCertFiles(dirs: readonly string[]): string[] {
const found: string[] = [];
const seen = new Set<string>();
for (const dir of dirs) {
let entries: string[];
try {
entries = fs.readdirSync(dir);
} catch {
continue;
}
for (const entry of entries) {
const lower = entry.toLowerCase();
if (!CERT_EXTENSIONS.some((ext) => lower.endsWith(ext))) continue;
const full = path.join(dir, entry);
if (seen.has(full)) continue;
seen.add(full);
found.push(full);
}
}
return found;
}
/**
* 构造能力表。
* @param platform 平台
* @returns 能力表
* @returns 能力表(常量引用,勿修改)
*/
export function capabilitiesFor(platform: Platform): PlatformCapabilities {
const unsupported = (reason: string): { state: CapabilityState; reason: string } => ({
state: 'unsupported',
reason,
});
const supported: { state: CapabilityState } = { state: 'supported' };
if (platform === 'win32') {
return {
streamHost: supported,
streamClient: supported,
usbExport: supported,
usbImport: { state: 'unsupported', reason: '本轮 Windows 端只做 USB 导出,导入请用 macOS 主控端' },
smbExport: supported,
smbImport: supported,
mdns: unsupported('Windows 未内置 mDNS 浏览,已自动回落为局域网端口扫描'),
credential: supported,
};
}
if (platform === 'darwin') {
return {
streamHost: unsupported('macOS 作为被控端需要虚拟显示器,V2 解决'),
streamClient: supported,
usbExport: unsupported('macOS 没有 usbipd,无法导出本机 USB'),
usbImport: supported,
smbExport: unsupported('macOS 请用「系统设置 → 通用 → 共享 → 文件共享」开启'),
smbImport: supported,
mdns: supported,
credential: supported,
};
}
return {
streamHost: unsupported(LINUX_UNSUPPORTED_REASON),
streamClient: unsupported(LINUX_UNSUPPORTED_REASON),
usbExport: unsupported(LINUX_UNSUPPORTED_REASON),
usbImport: unsupported(LINUX_UNSUPPORTED_REASON),
smbExport: unsupported(LINUX_UNSUPPORTED_REASON),
smbImport: unsupported(LINUX_UNSUPPORTED_REASON),
mdns: unsupported(LINUX_UNSUPPORTED_REASON),
credential: unsupported('未检测到可用的系统加密存储,凭据仅保存在内存中'),
};
}
/** 空实现的共享适配器(T-3 落地真实实现前,UI 通过 capabilities 隐藏入口)。 */
function createStubShare(): ShareAdapter {
return {
async openRemote() {
throw new Error('share adapter 尚未在 T-2 实现(T-3 落地)');
},
async enableLocal() {
throw new Error('share adapter 尚未在 T-2 实现(T-3 落地)');
},
async disableLocal() {
throw new Error('share adapter 尚未在 T-2 实现(T-3 落地)');
},
async localState() {
return { enabled: false, dir: null, smbUrl: null };
},
};
}
/** 空实现的发现适配器:不支持 mDNS 时**返回空数组而不是抛错**。 */
const stubDiscovery: DiscoveryAdapter = {
async browseMdns() {
return [];
},
};
export interface AdapterDeps {
/** 覆盖 userData 路径解析(测试) */
paths?: typeof defaultPaths;
/** 覆盖凭据存储(测试) */
credentialStore?: CredentialStore;
/** 覆盖依赖检查(测试) */
checkDependency?: (name: DependencyName) => Promise<DependencyStatus>;
}
/**
* 依赖检查:用 `which`(POSIX)/ `where`(Windows)探测可执行文件是否存在。
* @param platform 平台
* @param name 依赖名
* @returns 依赖状态
*/
async function probeDependency(platform: Platform, name: DependencyName): Promise<DependencyStatus> {
const hintUrl = DEPENDENCY_HINT_URLS[name] ?? '';
if (name === 'smb') {
return { status: platform === 'linux' ? 'missing' : 'ok', hintUrl };
}
const binary = name === 'sunshine' ? 'sunshine' : name;
const finder = platform === 'win32' ? 'where' : 'which';
const result = await runner.execFile(finder, [binary], { timeoutMs: 5_000 });
if (result.ok && result.stdout.trim().length > 0) {
return { status: 'ok', hintUrl };
}
return { status: 'missing', hintUrl };
if (platform === 'win32') return WINDOWS_CAPABILITIES;
if (platform === 'darwin') return MACOS_CAPABILITIES;
return LINUX_CAPABILITIES;
}
/**
@@ -236,47 +71,13 @@ async function probeDependency(platform: Platform, name: DependencyName): Promis
* @param deps 注入项
* @returns 平台适配器
*/
export function createPlatformAdapter(platform: Platform = detectPlatform(), deps: AdapterDeps = {}): PlatformAdapter {
const paths = deps.paths ?? defaultPaths;
let credentialStorePromise: Promise<CredentialStore> | null = deps.credentialStore
? Promise.resolve(deps.credentialStore)
: null;
return {
platform,
capabilities: () => capabilitiesFor(platform),
async sunshineCredDir() {
const dir = coarseSunshineCredDir(platform);
try {
return fs.statSync(dir).isDirectory() ? dir : null;
} catch {
return null;
}
},
async moonlightCertPaths() {
return listCertFiles(moonlightCertCandidateDirs(platform));
},
usb: null,
share: createStubShare(),
discovery: stubDiscovery,
async credentialStore() {
if (!credentialStorePromise) {
credentialStorePromise = Promise.resolve(
createCredentialStore({ file: paths.credentials(), warn: () => undefined })
);
}
return credentialStorePromise;
},
async checkDependency(name) {
if (deps.checkDependency) return deps.checkDependency(name);
return probeDependency(platform, name);
},
};
export function createPlatformAdapter(
platform: Platform = detectPlatform(),
deps: AdapterDeps = {}
): PlatformAdapter {
if (platform === 'win32') return createWindowsAdapter(deps);
if (platform === 'darwin') return createMacosAdapter(deps);
return createLinuxAdapter(deps);
}
/** 适配器缓存:同一平台复用同一实例(保证 credentialStore 的 kind 稳定)。 */
@@ -310,3 +111,9 @@ export function withPlatform<T>(platform: Platform, fn: (adapter: PlatformAdapte
export function resetAdapterCache(): void {
adapterCache.clear();
}
/** 全部合法平台(转发自 shared,保持旧导入路径可用)。 */
export const ALL_PLATFORMS: readonly Platform[] = PLATFORMS;
/** 依赖名到能力位的映射(UI 排障用)。 */
export type { DependencyName, DependencyStatus };
+101
View File
@@ -0,0 +1,101 @@
// Only in One - Linux 平台实现(**显式降级**,不是 else 兜底)。
//
// 架构 §5.4 的要点:Linux 不是「被 catch-all 命中」,而是有一个完整的实现类,
// 其中每条不支持的能力都给出**中文说明**。这样带来三个好处:
// 1. 不存在「if (win32) … else 默认当 macOS」的隐式假设(G-19);
// 2. UI 只读 capabilities(),无需任何平台判断;
// 3. 未来纳入 Linux 时,只需把对应能力的 state 从 unsupported 改成 supported,
// 调用方零改动。
//
// 本轮 Linux 真机验证豁免(R-P2-03),因此所有能力位均为 `unsupported`;
// 但解析器(avahi / usbip)已经实现好,T-5 若要开启只需改能力位。
import type { DiscoveryAdapter, PlatformAdapter, PlatformCapabilities, ShareAdapter } from './types';
import type { AdapterDeps } from './shared';
import {
LINUX_UNSUPPORTED_REASON,
createBaseAdapter,
parseAvahiBrowse,
throwUnsupported,
unsupported,
} from './shared';
/** `avahi-browse` 超时(它是常驻进程,靠超时截断后解析已捕获输出)。 */
const AVAHI_TIMEOUT_MS = 1_500;
/** Linux 能力表:全部显式声明为不支持,并给出中文原因。 */
export const LINUX_CAPABILITIES: PlatformCapabilities = {
streamHost: unsupported(LINUX_UNSUPPORTED_REASON),
streamClient: unsupported(LINUX_UNSUPPORTED_REASON),
usbExport: unsupported(LINUX_UNSUPPORTED_REASON),
usbImport: unsupported(LINUX_UNSUPPORTED_REASON),
smbExport: unsupported(LINUX_UNSUPPORTED_REASON),
smbImport: unsupported(LINUX_UNSUPPORTED_REASON),
mdns: unsupported(LINUX_UNSUPPORTED_REASON),
credential: unsupported('未检测到可用的系统加密存储,凭据仅保存在内存中(重启需重新登录)'),
};
/**
* 构造 Linux mDNS 适配器。
*
* 说明:能力位是 `unsupported`,所以 `discover.ts` **不会**调用它(能力驱动分派);
* 解析逻辑仍然实现并单测,将来把能力位改成 `supported` 即可生效。
* @param base `createBaseAdapter` 的返回
* @returns 发现适配器
*/
export function createLinuxDiscovery(base: ReturnType<typeof createBaseAdapter>): DiscoveryAdapter {
const { runner } = base;
return {
async browseMdns() {
const result = await runner.execFile(
'avahi-browse',
['-rpt', '_sunshine._tcp'],
{ timeoutMs: AVAHI_TIMEOUT_MS }
);
// avahi-browse 不带 --terminate 时也是常驻,超时后解析已捕获的输出
if (!result.ok && result.code !== 'TIMEOUT') return [];
return parseAvahiBrowse(result.stdout);
},
};
}
/**
* Linux 共享适配器:全部明确不支持(引导用户用 Samba 手动配置)。
* @returns 共享适配器
*/
export const LINUX_SHARE: ShareAdapter = {
async openRemote() {
throwUnsupported(LINUX_CAPABILITIES.smbImport, { guide: '可手动执行 xdg-open smb://<host>/<share>' });
},
async enableLocal() {
throwUnsupported(LINUX_CAPABILITIES.smbExport, { guide: '请用 Samba 手动配置共享' });
},
async disableLocal() {
throwUnsupported(LINUX_CAPABILITIES.smbExport, { guide: '请用 Samba 手动配置共享' });
},
async localState() {
return { enabled: false, dir: null, smbUrl: null };
},
};
/**
* 创建 Linux 平台适配器。
*
* `usb` 为 `null` ⇒ 调用方(`usb.ts`)据此返回 `UNSUPPORTED_ON_PLATFORM`,
* 而不是把 ENOTFOUND / ENOENT 之类的原始系统错误抛给 UI。
* @param deps 注入依赖
* @returns 平台适配器
*/
export function createLinuxAdapter(deps: AdapterDeps = {}): PlatformAdapter {
const base = createBaseAdapter('linux', deps, LINUX_CAPABILITIES);
return {
platform: base.platform,
capabilities: base.capabilities,
sunshineCredDir: base.sunshineCredDir,
moonlightCertPaths: base.moonlightCertPaths,
credentialStore: base.credentialStore,
checkDependency: base.checkDependency,
usb: null,
share: LINUX_SHARE,
discovery: createLinuxDiscovery(base),
};
}
+171
View File
@@ -0,0 +1,171 @@
// Only in One - macOS 平台实现(架构 §5.3)。
//
// 能力边界:
// · 作为**被控端不支持**(需要虚拟显示器,V2 解决);
// · USB **只导入**(`usbip attach/detach`,需提权);导出不支持(无 usbipd);
// · SMB 导出不支持 → 引导用户到「系统设置 → 共享」,**不静默成功**;
// · mDNS 可用(`dns-sd`)。
import type { DiscoveryAdapter, PlatformAdapter, PlatformCapabilities, ShareAdapter, UsbAdapter } from './types';
import type { AdapterDeps } from './shared';
import {
assertBusId,
assertRoutableIpv4,
assertShareName,
assertUsbPort,
createBaseAdapter,
ensureExecOk,
parseDnsSd,
parseUsbPort,
supported,
throwUnsupported,
unsupported,
} from './shared';
/** macOS 能力表。 */
export const MACOS_CAPABILITIES: PlatformCapabilities = {
streamHost: unsupported('macOS 作为被控端需要虚拟显示器,V2 解决'),
streamClient: supported(),
usbExport: unsupported('macOS 没有 usbipd,无法导出本机 USB'),
usbImport: supported(),
smbExport: unsupported('macOS 请用「系统设置 → 通用 → 共享 → 文件共享」开启'),
smbImport: supported(),
mdns: supported(),
credential: supported(),
};
/** `usbip` 命令超时(attach 需要提权弹窗,给足时间)。 */
const USBIP_TIMEOUT_MS = 30_000;
/** `dns-sd` 是常驻进程,靠超时截断后再解析已捕获的 stdout。 */
const DNS_SD_TIMEOUT_MS = 1_200;
/**
* 构造 macOS USB 适配器(仅导入方向)。
* @param base `createBaseAdapter` 的返回
* @returns USB 适配器
*/
export function createMacosUsb(base: ReturnType<typeof createBaseAdapter>): UsbAdapter {
const { runner } = base;
return {
async listLocal() {
throwUnsupported(MACOS_CAPABILITIES.usbExport);
},
async share() {
throwUnsupported(MACOS_CAPABILITIES.usbExport);
},
async unshare() {
throwUnsupported(MACOS_CAPABILITIES.usbExport);
},
async attach(host, busid) {
assertRoutableIpv4(host);
assertBusId(busid);
// 提权:runner.elevate 内部走 osascript + 双层转义(见 core/runner.ts)
ensureExecOk(
await runner.elevate({
platform: 'darwin',
command: 'usbip',
args: ['attach', '-r', host, '-b', busid],
reason: '挂载远程 USB 设备需要管理员权限',
timeoutMs: USBIP_TIMEOUT_MS,
})
);
},
async detach(port) {
assertUsbPort(port);
ensureExecOk(
await runner.elevate({
platform: 'darwin',
command: 'usbip',
args: ['detach', '-p', port],
reason: '卸载 USB 设备需要管理员权限',
timeoutMs: USBIP_TIMEOUT_MS,
})
);
},
async listAttached() {
// 未安装 usbip / 无权限时返回空列表而不是抛错(UI 显示「无已挂载设备」)
const result = await runner.execFile('usbip', ['port'], { timeoutMs: USBIP_TIMEOUT_MS });
if (!result.ok) return [];
return parseUsbPort(result.stdout);
},
};
}
/**
* 构造 macOS 共享适配器。
* @returns 共享适配器
*/
export function createMacosShare(base: ReturnType<typeof createBaseAdapter>): ShareAdapter {
const { runner } = base;
return {
async openRemote(host, shareName) {
assertRoutableIpv4(host);
assertShareName(shareName);
// `open` 接收单个 URL argv,无 shell
ensureExecOk(await runner.execFile('open', [`smb://${host}/${shareName}`]));
},
async enableLocal() {
// 明确不支持:macOS 开共享需要用户手动操作,且需要管理员授权
throwUnsupported(MACOS_CAPABILITIES.smbExport, {
guide: '系统设置 → 通用 → 共享 → 文件共享',
});
},
async disableLocal() {
throwUnsupported(MACOS_CAPABILITIES.smbExport, {
guide: '系统设置 → 通用 → 共享 → 文件共享',
});
},
async localState() {
return { enabled: false, dir: null, smbUrl: null };
},
};
}
/**
* 构造 macOS mDNS 适配器。
* @param base `createBaseAdapter` 的返回
* @returns 发现适配器
*/
export function createMacosDiscovery(base: ReturnType<typeof createBaseAdapter>): DiscoveryAdapter {
const { runner } = base;
return {
async browseMdns() {
const result = await runner.execFile(
'dns-sd',
['-B', '_sunshine._tcp', 'local.', '-Z'],
{ timeoutMs: DNS_SD_TIMEOUT_MS }
);
// dns-sd 是常驻进程:超时被 kill 时 stdout 里已经有可用记录,要照常解析
if (!result.ok && result.code !== 'TIMEOUT') return [];
return parseDnsSd(result.stdout);
},
};
}
/**
* 创建 macOS 平台适配器。
* @param deps 注入依赖
* @returns 平台适配器
*/
export function createMacosAdapter(deps: AdapterDeps = {}): PlatformAdapter {
const base = createBaseAdapter('darwin', deps, MACOS_CAPABILITIES);
return {
platform: base.platform,
capabilities: base.capabilities,
sunshineCredDir: base.sunshineCredDir,
moonlightCertPaths: base.moonlightCertPaths,
credentialStore: base.credentialStore,
checkDependency: base.checkDependency,
usb: createMacosUsb(base),
share: createMacosShare(base),
discovery: createMacosDiscovery(base),
};
}
+609
View File
@@ -0,0 +1,609 @@
// Only in One - 平台层共享设施(纯函数 + 可注入探测)。
//
// 为什么单独一个文件:`index.ts` 要同时 import 三个平台实现,若三个实现反过来
// import `index.ts` 取共享工具,就构成循环依赖(ESM 下运行期可能拿到 undefined)。
// 把共享部分放在这里,依赖方向变成单向:
//
// index.ts ──▶ windows.ts / macos.ts / linux.ts ──▶ shared.ts ──▶ core/runner.ts
//
// 本文件不含任何 `process.platform` 判断(平台值一律由调用方传入)。
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import type {
AttachedUsb,
CapabilityState,
CredentialStore,
DependencyName,
DependencyStatus,
Platform,
PlatformCapability,
PlatformCapabilities,
UsbDevice,
} from './types';
import type { ExecResult, Runner } from '../core/runner';
import { runner as defaultRunner } from '../core/runner';
import type { ErrorCode } from '../core/errors';
import { AppError } from '../core/errors';
import { isBusId, isRoutableIPv4, isShareName, isUsbPort } from '../core/validate';
import { createCredentialStore } from './credentialStore';
import type { PathResolver } from '../core/paths';
import { defaultPaths } from '../core/paths';
/** 三个合法平台值。 */
export const PLATFORMS: readonly Platform[] = ['win32', 'darwin', 'linux'];
/** 依赖安装指引(T-4 的 troubleshooting 文档存在后可换成站内链接)。 */
export const DEPENDENCY_HINT_URLS: Record<DependencyName, string> = {
sunshine: 'https://github.com/LizardByte/Sunshine#installation',
moonlight: 'https://github.com/moonlight-stream/moonlight-qt/releases',
usbipd: 'https://github.com/dorssel/usbipd-win/releases',
usbip: 'https://github.com/dorssel/usbipd-win/wiki',
smb: 'https://support.apple.com/guide/mac-help/mchlp1140/mac',
};
/** 默认 SMB 共享名(`share.ts` 与三端共享实现共用,避免循环依赖)。 */
export const DEFAULT_SHARE_NAME = 'OnlyInOne';
/** Linux 本轮统一的降级说明文案(架构 §5.4:不得写成「未知平台」)。 */
export const LINUX_UNSUPPORTED_REASON = '本轮未做 Linux 真机验证,暂不支持;如有需要请参考故障排查文档';
/** 允许被视为「公钥证书文件」的扩展名。 */
export const CERT_EXTENSIONS: readonly string[] = ['.pem', '.crt'];
/** 构造「支持」能力位。 */
export function supported(): PlatformCapability {
return { state: 'supported' };
}
/**
* 构造「不支持」能力位。
* @param reason 面向用户的中文说明(**禁止**填原始系统错误)
* @param installHintUrl 可选的安装指引
* @returns 能力位
*/
export function unsupported(reason: string, installHintUrl?: string): PlatformCapability {
const capability: PlatformCapability = { state: 'unsupported', reason };
if (installHintUrl) capability.installHintUrl = installHintUrl;
return capability;
}
/**
* 构造「实验性」能力位(可用但未真机验证)。
* @param reason 说明
* @param installHintUrl 安装指引
* @returns 能力位
*/
export function experimental(reason: string, installHintUrl?: string): PlatformCapability {
const capability: PlatformCapability = { state: 'experimental' as CapabilityState, reason };
if (installHintUrl) capability.installHintUrl = installHintUrl;
return capability;
}
/** 可注入 fs 探测(单测用)。 */
export interface ProbeFsLike {
readdirSync(dir: string): string[];
statSync(file: string): { isDirectory(): boolean };
existsSync(file: string): boolean;
}
/** 默认 fs 探测。 */
export const nodeProbeFs: ProbeFsLike = {
readdirSync: (dir) => fs.readdirSync(dir),
statSync: (file) => fs.statSync(file),
existsSync: (file) => fs.existsSync(file),
};
/**
* 取平台相关的家目录。
* @param env 环境变量
* @returns 家目录绝对路径
*/
export function homeDir(env: NodeJS.ProcessEnv = process.env): string {
return env.HOME || env.USERPROFILE || os.homedir();
}
/**
* 粗粒度目录解析:Sunshine 凭据目录(T-5 会用三端候选探测替换)。
* @param platform 平台
* @param env 环境变量
* @returns 凭据目录绝对路径(不保证存在)
*/
export function coarseSunshineCredDir(platform: Platform, env: NodeJS.ProcessEnv = process.env): string {
if (platform === 'win32') {
return path.join(env.ProgramData || 'C:\\ProgramData', 'Sunshine', 'config', 'credentials');
}
return path.join(homeDir(env), '.config', 'sunshine', 'credentials');
}
/**
* 本机 Moonlight 公钥证书的候选**目录**(顺序即优先级)。
* @param platform 平台
* @param env 环境变量
* @returns 候选目录列表
*/
export function moonlightCertCandidateDirs(
platform: Platform,
env: NodeJS.ProcessEnv = process.env
): string[] {
const home = homeDir(env);
if (platform === 'win32') {
const programData = env.ProgramData || 'C:\\ProgramData';
const localAppData = env.LOCALAPPDATA || path.join(home, 'AppData', 'Local');
return [
path.join(localAppData, 'Moonlight Game Streaming Client'),
path.join(programData, 'Moonlight Game Streaming Client'),
];
}
// darwin 与 linux 共用 XDG 布局;Linux 本轮 streamClient 为 unsupported,
// 仍保留探测以免将来纳入时漏掉(架构 §5.3 备注)。
return [
path.join(env.XDG_CONFIG_HOME || path.join(home, '.config'), 'Moonlight Game Streaming Client'),
path.join(home, 'Library', 'Application Support', 'Moonlight Game Streaming Client'),
];
}
/**
* 列出候选目录里的公钥证书文件(去重,按目录顺序)。
* @param dirs 候选目录
* @param fsLike fs 探测(可注入)
* @returns 证书文件绝对路径
*/
export function listCertFiles(dirs: readonly string[], fsLike: ProbeFsLike = nodeProbeFs): string[] {
const found: string[] = [];
const seen = new Set<string>();
for (const dir of dirs) {
let entries: string[];
try {
entries = fsLike.readdirSync(dir);
} catch {
continue;
}
for (const entry of entries) {
const lower = entry.toLowerCase();
if (!CERT_EXTENSIONS.some((ext) => lower.endsWith(ext))) continue;
const full = path.join(dir, entry);
if (seen.has(full)) continue;
seen.add(full);
found.push(full);
}
}
return found;
}
/**
* 判断目录是否存在(不抛)。
* @param dir 目录
* @param fsLike fs 探测
* @returns 是目录返回 true
*/
export function isExistingDir(dir: string, fsLike: ProbeFsLike = nodeProbeFs): boolean {
try {
return fsLike.statSync(dir).isDirectory();
} catch {
return false;
}
}
/**
* 定位仓库里的 `scripts/` 目录(只用于 Windows 的 `setup-smb.ps1`)。
*
* 刻意**不用** `__dirname` / `process.cwd()`:
* · `__dirname` 在 ESM(vitest)下不存在,`import.meta` 在 CommonJS 编译下非法;
* · `process.cwd()` 在 Electron 里可能是任意目录,属于架构 §9.4 明令禁止的写法。
*
* 三级策略:显式注入 → Electron `app.getAppPath()` → null(调用方给安装指引)。
* @param deps 适配器依赖
* @param fsLike fs 探测
* @returns scripts 目录绝对路径;找不到返回 null
*/
export function resolveScriptsDir(
deps: AdapterDeps,
fsLike: ProbeFsLike = nodeProbeFs
): string | null {
if (deps.scriptsDir !== undefined) {
if (deps.scriptsDir === null) return null;
return fsLike.existsSync(deps.scriptsDir) ? deps.scriptsDir : null;
}
try {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const electron = require('electron');
const appPath = electron?.app?.getAppPath?.();
if (typeof appPath === 'string' && appPath.length > 0) {
const candidate = path.join(appPath, 'scripts');
if (fsLike.existsSync(candidate)) return candidate;
}
} catch {
/* 非 Electron 环境:返回 null,由调用方给安装指引 */
}
return null;
}
/**
* PowerShell 单引号字面量转义(内部 `'` 写成 `''`)。
* @param value 原始值
* @returns 可安全拼进 PowerShell 命令的片段
*/
export function psSingleQuote(value: string): string {
return `'${value.replace(/'/g, "''")}'`;
}
// ---------------------------------------------------------------- 输出解析(纯函数)
/**
* 解析 `usbipd list` 输出。
*
* 真实输出形如(列之间是两个以上空格):
* ```
* BUSID VID:PID DEVICE STATE
* 1-2 0403:6001 FTDI FT232R USB UART Not shared
* ```
* @param stdout 命令输出
* @returns USB 设备列表
*/
export function parseUsbipdList(stdout: unknown): UsbDevice[] {
if (typeof stdout !== 'string' || stdout.length === 0) return [];
const devices: UsbDevice[] = [];
const lines = stdout.split(/\r?\n/);
for (const rawLine of lines) {
const line = rawLine.trimEnd();
if (line.trim().length === 0) continue;
// 跳过表头与提示行
if (/^BUSID\b/i.test(line) || /^Persisted\b/i.test(line)) continue;
const columns = line.trim().split(/\s{2,}/);
if (columns.length < 2) continue;
const busid = columns[0].trim();
if (!/^\d{1,3}-\d{1,3}$/.test(busid)) continue;
const stateColumn = columns.length >= 3 ? columns[columns.length - 1].trim() : '';
const state = /^shared$/i.test(stateColumn)
? 'shared'
: /^(not shared|attached)$/i.test(stateColumn)
? 'not-shared'
: undefined;
const nameParts = columns.slice(1, state ? columns.length - 1 : columns.length);
const device: UsbDevice = { busid, name: nameParts.join(' ').trim() };
if (state) device.state = state;
devices.push(device);
}
return devices;
}
/**
* 解析 `usbip port` 输出(macOS 主控端的「已挂载」列表)。
*
* 真实输出形如:
* ```
* Imported USB devices
* ====================
* Port 00: <Port in Use> at Full Speed(12Mbps)
* FTDI FT232R USB UART (0403:6001)
* 4-1 -> usbip://192.168.1.50:3240/1-2
* ```
* 噪声样本:端口未占用、无设备、空输出。
* @param stdout 命令输出
* @returns 已挂载设备列表
*/
export function parseUsbPort(stdout: unknown): AttachedUsb[] {
if (typeof stdout !== 'string' || stdout.length === 0) return [];
const attached: AttachedUsb[] = [];
const lines = stdout.split(/\r?\n/);
/** 当前正在解析的端口号 */
let currentPort: string | null = null;
for (const rawLine of lines) {
const line = rawLine.trimEnd();
const portMatch = /^Port\s+(\d{2,3}):\s*(?:<([^>]*)>)?(.*)$/.exec(line.trim());
if (portMatch) {
currentPort = portMatch[1];
continue;
}
const uriMatch = /^(\d{1,3}-\d{1,3})\s*->\s*usbip:\/\/([^\s/:]+):(\d+)\/(\S+)/.exec(line.trim());
if (uriMatch && currentPort !== null) {
attached.push({
port: currentPort,
host: uriMatch[2],
busid: uriMatch[4],
state: 'attached',
});
currentPort = null;
}
}
return attached;
}
/**
* 解析 macOS `dns-sd -B _sunshine._tcp` 输出。
*
* 输出里形如 `... Add 2 14 local. _sunshine._tcp. windesk`,
* 配合 `-Z` 时的解析记录能带出 `192.168.1.50:47990`。为了对噪声宽容,
* 这里只提取「行内出现的 IPv4 + 端口」组合。
* @param stdout 命令输出
* @param defaultPort 端口缺失时的默认值
* @returns IP + 端口
*/
export function parseDnsSd(stdout: unknown, defaultPort = 47990): { ip: string; port: number }[] {
if (typeof stdout !== 'string' || stdout.length === 0) return [];
const found: { ip: string; port: number }[] = [];
const seen = new Set<string>();
for (const match of stdout.matchAll(/(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})(?::(\d+))?/g)) {
const ip = match[1];
const port = match[2] ? Number.parseInt(match[2], 10) : defaultPort;
if (seen.has(`${ip}:${port}`)) continue;
seen.add(`${ip}:${port}`);
found.push({ ip, port });
}
return found;
}
/**
* 解析 Linux `avahi-browse -rpt _sunshine._tcp` 输出。
*
* 格式(分号分隔):`=;eth0;IPv4;windesk;_sunshine._tcp;local;windesk.local;192.168.1.50;47990;""`
* @param stdout 命令输出
* @returns IP + 端口
*/
export function parseAvahiBrowse(stdout: unknown): { ip: string; port: number }[] {
if (typeof stdout !== 'string' || stdout.length === 0) return [];
const found: { ip: string; port: number }[] = [];
const seen = new Set<string>();
for (const rawLine of stdout.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line.startsWith('=') && !line.startsWith('+')) continue;
const parts = line.split(';');
if (parts.length < 9) continue;
const ip = parts[7];
const port = Number.parseInt(parts[8], 10);
if (!/^\d{1,3}(\.\d{1,3}){3}$/.test(ip)) continue;
if (!Number.isInteger(port) || port <= 0 || port > 65535) continue;
if (seen.has(`${ip}:${port}`)) continue;
seen.add(`${ip}:${port}`);
found.push({ ip, port });
}
return found;
}
/**
* 解析 `Get-SmbShare` 的 `Path` 输出。
* @param stdout 命令输出
* @returns 共享目录;未共享返回 null
*/
export function parseSmbSharePath(stdout: unknown): string | null {
if (typeof stdout !== 'string') return null;
const firstLine = stdout.split(/\r?\n/).map((line) => line.trim()).find((line) => line.length > 0);
return firstLine || null;
}
// ---------------------------------------------------------------- 依赖探测
/**
* 依赖检查:`which`(POSIX)/ `where`(Windows)+ 版本号正则。
* @param platform 平台
* @param name 依赖名
* @param runnerImpl Runner(可注入)
* @param minMajor 最低主版本(低于则 `outdated`)
* @returns 依赖状态
*/
export async function probeDependency(
platform: Platform,
name: DependencyName,
runnerImpl: Runner = defaultRunner,
minMajor?: number
): Promise<DependencyStatus> {
const hintUrl = DEPENDENCY_HINT_URLS[name] ?? '';
// SMB 是系统自带能力,不需要外部可执行文件
if (name === 'smb') {
return { status: platform === 'linux' ? 'missing' : 'ok', hintUrl };
}
// Sunshine 在本轮只作为被控端依赖,Linux 未验证
if (platform === 'linux') {
return { status: 'missing', hintUrl };
}
const finder = platform === 'win32' ? 'where' : 'which';
const located = await runnerImpl.execFile(finder, [name], { timeoutMs: 5_000 });
if (!located.ok || located.stdout.trim().length === 0) {
return { status: 'missing', hintUrl };
}
if (typeof minMajor === 'number') {
const version = await runnerImpl.execFile(name, ['--version'], { timeoutMs: 5_000 });
const matched = /(\d+)\.(\d+)/.exec(version.stdout || version.stderrDigest || '');
if (!matched) {
// 拿不到版本号时不武断判过旧(避免把可用环境挡在门外)
return { status: 'ok', hintUrl };
}
const major = Number.parseInt(matched[1], 10);
if (Number.isFinite(major) && major < minMajor) {
return { status: 'outdated', version: `${matched[1]}.${matched[2]}`, hintUrl };
}
return { status: 'ok', version: `${matched[1]}.${matched[2]}`, hintUrl };
}
return { status: 'ok', hintUrl };
}
/** 平台适配器构造依赖(全部可注入,便于三端单测)。 */
export interface AdapterDeps {
/** 覆盖 userData 路径解析 */
paths?: PathResolver;
/** 覆盖凭据存储 */
credentialStore?: CredentialStore;
/** 覆盖依赖检查 */
checkDependency?: (name: DependencyName) => Promise<DependencyStatus>;
/** 覆盖命令执行(默认全局 runner;单测注入假实现即可断言参数数组) */
runner?: Runner;
/** 环境变量(默认 process.env) */
env?: NodeJS.ProcessEnv;
/** fs 探测(证书目录 / 凭据目录) */
fsLike?: ProbeFsLike;
/** 覆盖 scripts 目录(Windows setup-smb.ps1 定位失败时用) */
scriptsDir?: string | null;
/** 覆盖主机名(拼 smb:// 地址用) */
hostName?: string;
}
/** 默认路径解析器(与 T-2 保持一致)。 */
export function resolvePaths(deps: AdapterDeps): PathResolver {
return deps.paths ?? defaultPaths;
}
// ---------------------------------------------------------------- 参数断言与结果映射
/**
* 抛「平台不支持」。
*
* **禁止**把原始系统错误抛给 UI(架构 §5.1 原则 2):这里统一带中文 `reason`。
* @param capability 能力位(取其 `reason`)
* @param details 补充信息
* @returns 永不返回(总是抛出)
*/
export function throwUnsupported(capability: PlatformCapability, details?: Record<string, unknown>): never {
throw new AppError('UNSUPPORTED_ON_PLATFORM', {
reason: capability.reason ?? LINUX_UNSUPPORTED_REASON,
...(details ?? {}),
});
}
/**
* 把 runner 的执行结果映射为异常(成功则直接返回)。
* @param result 执行结果
* @param fallback 结果码异常时的兜底错误码
* @returns 无
*/
export function ensureExecOk(result: ExecResult, fallback: ErrorCode = 'COMMAND_FAILED'): void {
if (result.ok) return;
const code: ErrorCode = result.code === 'OK' ? fallback : result.code;
throw new AppError(code, {
exitCode: result.exitCode ?? undefined,
stderr: result.stderrDigest || undefined,
});
}
/**
* 断言 busid(`1-2` 形式)。
* @param value 待校验值
* @returns 无
*/
export function assertBusId(value: unknown): asserts value is string {
if (!isBusId(value)) throw new AppError('INVALID_ARGUMENT', { field: 'busid' });
}
/**
* 断言 usbip 端口(`00` / `1-2`)。
* @param value 待校验值
* @returns 无
*/
export function assertUsbPort(value: unknown): asserts value is string {
if (!isUsbPort(value)) throw new AppError('INVALID_ARGUMENT', { field: 'port' });
}
/**
* 断言可路由 IPv4(拒回环 / 组播 / 广播)。
* @param value 待校验值
* @returns 无
*/
export function assertRoutableIpv4(value: unknown): asserts value is string {
if (!isRoutableIPv4(value)) throw new AppError('INVALID_ARGUMENT', { field: 'host' });
}
/**
* 断言 SMB 共享名(白名单,防命令注入)。
* @param value 待校验值
* @returns 无
*/
export function assertShareName(value: unknown): asserts value is string {
if (!isShareName(value)) throw new AppError('INVALID_ARGUMENT', { field: 'shareName' });
}
/**
* 断言本地目录路径(绝对路径、无控制字符)。
* @param value 待校验值
* @returns 无
*/
export function assertLocalDir(value: unknown): asserts value is string {
if (typeof value !== 'string' || value.length === 0 || value.length > 1024) {
throw new AppError('INVALID_ARGUMENT', { field: 'dir' });
}
if (/[\u0000-\u001f\u007f]/.test(value)) throw new AppError('INVALID_ARGUMENT', { field: 'dir' });
if (!path.isAbsolute(value)) throw new AppError('INVALID_ARGUMENT', { field: 'dir' });
}
/**
* 各依赖的最低主版本(低于视为 `outdated`)。
* 版本号来自真机安装结果;拿不到版本号时不判过旧(避免误伤)。
*/
export const DEPENDENCY_MIN_MAJOR: Partial<Record<DependencyName, number>> = {
moonlight: 5,
usbipd: 4,
};
/**
* 构造三端共用的适配器字段(凭据目录、证书候选、凭据存储、依赖检查)。
*
* 三端差异只在 `usb` / `share` / `discovery` 三块,重复的部分收在这里,
* 避免同一段逻辑写三遍(改一处漏两处的经典坑)。
* @param platform 平台
* @param deps 注入依赖
* @param capabilities 能力表
* @returns 共用字段 + 供平台实现使用的 runner/env/fs
*/
export function createBaseAdapter(
platform: Platform,
deps: AdapterDeps,
capabilities: PlatformCapabilities
): {
platform: Platform;
capabilities: () => PlatformCapabilities;
sunshineCredDir: () => Promise<string | null>;
moonlightCertPaths: () => Promise<string[]>;
credentialStore: () => Promise<CredentialStore>;
checkDependency: (name: DependencyName) => Promise<DependencyStatus>;
runner: Runner;
env: NodeJS.ProcessEnv;
fsLike: ProbeFsLike;
paths: PathResolver;
hostName: () => string;
} {
const env = deps.env ?? process.env;
const fsLike = deps.fsLike ?? nodeProbeFs;
const paths = resolvePaths(deps);
const runnerImpl = deps.runner ?? defaultRunner;
let credentialStorePromise: Promise<CredentialStore> | null = deps.credentialStore
? Promise.resolve(deps.credentialStore)
: null;
return {
platform,
capabilities: () => capabilities,
runner: runnerImpl,
env,
fsLike,
paths,
async sunshineCredDir() {
const dir = coarseSunshineCredDir(platform, env);
return isExistingDir(dir, fsLike) ? dir : null;
},
async moonlightCertPaths() {
return listCertFiles(moonlightCertCandidateDirs(platform, env), fsLike);
},
async credentialStore() {
if (!credentialStorePromise) {
credentialStorePromise = Promise.resolve(
createCredentialStore({ file: paths.credentials(), warn: () => undefined })
);
}
return credentialStorePromise;
},
async checkDependency(name) {
if (deps.checkDependency) return deps.checkDependency(name);
return probeDependency(platform, name, runnerImpl, DEPENDENCY_MIN_MAJOR[name]);
},
hostName: () => deps.hostName ?? os.hostname(),
};
}
+207
View File
@@ -0,0 +1,207 @@
// Only in One - Windows 平台实现(架构 §5.3)。
//
// 能力边界(Q-9 定案):
// · USB **只导出**(`usbipd bind/unbind`),导入(attach)在 Windows 侧不实现;
// · SMB 导出走仓库里的 `scripts/setup-smb.ps1`(`New-SmbShare -EncryptData $true`);
// · mDNS 不可用(Q-3=C)→ 自动回落为局域网端口扫描。
//
// 安全:所有外部命令都经 `base.runner`(`shell:false` + 参数数组),
// 共享名 / busid / 主机地址在调用前过白名单。
import path from 'node:path';
import type { DiscoveryAdapter, PlatformAdapter, PlatformCapabilities, ShareAdapter, UsbAdapter } from './types';
import type { AdapterDeps } from './shared';
import {
DEFAULT_SHARE_NAME,
DEPENDENCY_HINT_URLS,
assertBusId,
assertLocalDir,
assertRoutableIpv4,
assertShareName,
createBaseAdapter,
ensureExecOk,
parseSmbSharePath,
parseUsbipdList,
psSingleQuote,
resolveScriptsDir,
supported,
throwUnsupported,
unsupported,
} from './shared';
import { AppError } from '../core/errors';
/** Windows 能力表(UI 依据它隐藏/显示入口)。 */
export const WINDOWS_CAPABILITIES: PlatformCapabilities = {
streamHost: supported(),
streamClient: supported(),
usbExport: supported(),
usbImport: unsupported('本轮 Windows 端只做 USB 导出,导入请用 macOS 主控端'),
smbExport: supported(),
smbImport: supported(),
mdns: unsupported('Windows 未内置 mDNS 浏览,已自动回落为局域网端口扫描'),
credential: supported(),
};
/** `usbipd` 命令超时(bind/unbind 会改动系统状态,给足时间)。 */
const USBIPD_TIMEOUT_MS = 30_000;
/** PowerShell 脚本超时(建共享 + 初始化目录)。 */
const POWERSHELL_TIMEOUT_MS = 60_000;
/**
* 构造 Windows USB 适配器(仅导出方向)。
* @param base `createBaseAdapter` 的返回
* @returns USB 适配器
*/
export function createWindowsUsb(base: ReturnType<typeof createBaseAdapter>): UsbAdapter {
const { runner } = base;
return {
async listLocal() {
const result = await runner.execFile('usbipd', ['list'], { timeoutMs: USBIPD_TIMEOUT_MS });
ensureExecOk(result);
return parseUsbipdList(result.stdout);
},
async share(busid) {
assertBusId(busid);
ensureExecOk(
await runner.execFile('usbipd', ['bind', '--busid', busid], { timeoutMs: USBIPD_TIMEOUT_MS })
);
},
async unshare(busid) {
assertBusId(busid);
ensureExecOk(
await runner.execFile('usbipd', ['unbind', '--busid', busid], { timeoutMs: USBIPD_TIMEOUT_MS })
);
},
async attach() {
// Windows 端不实现 USB 导入(Q-9),显式报「不支持」而不是静默成功
throwUnsupported(WINDOWS_CAPABILITIES.usbImport);
},
async detach() {
throwUnsupported(WINDOWS_CAPABILITIES.usbImport);
},
async listAttached() {
return [];
},
};
}
/**
* 构造 Windows 共享适配器。
* @param base `createBaseAdapter` 的返回
* @param deps 适配器依赖(用于定位 scripts 目录)
* @returns 共享适配器
*/
export function createWindowsShare(
base: ReturnType<typeof createBaseAdapter>,
deps: AdapterDeps
): ShareAdapter {
const { runner, fsLike } = base;
return {
async openRemote(host, shareName) {
assertRoutableIpv4(host);
assertShareName(shareName);
// explorer 接受 `\\host\share` 形式的 UNC 路径(单个 argv,无 shell)
ensureExecOk(await runner.execFile('explorer', [`\\\\${host}\\${shareName}`]));
},
async enableLocal(dir, shareName) {
assertLocalDir(dir);
assertShareName(shareName);
const scriptsDir = resolveScriptsDir(deps, fsLike);
if (!scriptsDir) {
throw new AppError('DEPENDENCY_MISSING', {
reason: '找不到仓库内的 scripts/setup-smb.ps1',
hintUrl: DEPENDENCY_HINT_URLS.smb,
});
}
const script = path.join(scriptsDir, 'setup-smb.ps1');
ensureExecOk(
await runner.execFile(
'powershell',
[
'-NoProfile',
'-NonInteractive',
'-ExecutionPolicy',
'Bypass',
'-File',
script,
'-ShareName',
shareName,
'-Path',
dir,
],
{ timeoutMs: POWERSHELL_TIMEOUT_MS }
)
);
return { smbUrl: `smb://${base.hostName()}/${shareName}` };
},
async disableLocal(shareName) {
assertShareName(shareName);
ensureExecOk(
await runner.execFile(
'powershell',
[
'-NoProfile',
'-NonInteractive',
'-Command',
`Remove-SmbShare -Name ${psSingleQuote(shareName)} -Force`,
],
{ timeoutMs: POWERSHELL_TIMEOUT_MS }
)
);
},
async localState() {
const result = await runner.execFile(
'powershell',
[
'-NoProfile',
'-NonInteractive',
'-Command',
`Get-SmbShare -Name ${psSingleQuote(DEFAULT_SHARE_NAME)} | Select-Object -ExpandProperty Path`,
],
{ timeoutMs: POWERSHELL_TIMEOUT_MS }
);
const dir = result.ok ? parseSmbSharePath(result.stdout) : null;
return {
enabled: dir !== null,
dir,
smbUrl: dir === null ? null : `smb://${base.hostName()}/${DEFAULT_SHARE_NAME}`,
};
},
};
}
/** Windows 的 mDNS 适配器:明确返回空(能力位已标 unsupported,由 scan 兜底)。 */
export const WINDOWS_DISCOVERY: DiscoveryAdapter = {
async browseMdns() {
return [];
},
};
/**
* 创建 Windows 平台适配器。
* @param deps 注入依赖(runner / fs / scriptsDir / checkDependency …)
* @returns 平台适配器
*/
export function createWindowsAdapter(deps: AdapterDeps = {}): PlatformAdapter {
const base = createBaseAdapter('win32', deps, WINDOWS_CAPABILITIES);
return {
platform: base.platform,
capabilities: base.capabilities,
sunshineCredDir: base.sunshineCredDir,
moonlightCertPaths: base.moonlightCertPaths,
credentialStore: base.credentialStore,
checkDependency: base.checkDependency,
usb: createWindowsUsb(base),
share: createWindowsShare(base, deps),
discovery: WINDOWS_DISCOVERY,
};
}
+5
View File
@@ -29,8 +29,12 @@ contextBridge.exposeInMainWorld('oio', {
listDevices: () => ipcRenderer.invoke('devices:list'),
connect: (ip: string) => ipcRenderer.invoke('device:connect', ip),
// ---- 平台能力(UI 依据它隐藏入口,R-P1-10~13)----
capabilities: () => ipcRenderer.invoke('capabilities:get'),
// ---- USB ----
usbList: () => ipcRenderer.invoke('usb:list'),
usbListAttached: () => ipcRenderer.invoke('usb:listAttached'),
usbShare: (busid: string) => ipcRenderer.invoke('usb:share', busid),
usbUnshare: (busid: string) => ipcRenderer.invoke('usb:unshare', busid),
usbAttach: (host: string, busid: string) => ipcRenderer.invoke('usb:attach', host, busid),
@@ -38,6 +42,7 @@ contextBridge.exposeInMainWorld('oio', {
// ---- 文件共享 ----
openShare: (host: string, shareName: string) => ipcRenderer.invoke('share:open', host, shareName),
shareState: () => ipcRenderer.invoke('share:state'),
// ---- 配置 ----
getServer: () => ipcRenderer.invoke('config:getServer'),
+91 -4
View File
@@ -1,10 +1,10 @@
// Only in One - renderer logic(vanilla JS,contextIsolation 下运行)。
//
// 两条硬性约束(架构 §9.7 第 3 条 / §9.2 第 2 条):
// 1. **绝不使用 `innerHTML`**:任何来自网络或外部进程的字符串(设备名、USB 名、
// 错误文案)一律经 `textContent` 插入,从根上消灭 XSS / 标签注入;
// 2. **绝不拼接 `e.message`**:主进程已把错误转成 `{code,message,hint}`,
// 渲染层只渲染这三段,原始 message 与堆栈永远到不了这里。
// 1. **绝不把字符串当 HTML 插进 DOM**:任何来自网络或外部进程的字符串
// (设备名、USB 名、错误文案)一律经 `textContent` 写入,从根上消灭标签注入;
// 2. **绝不把底层异常的 message 拼进文案**:主进程已把错误转成
// `{code, message, hint}` 三段,渲染层只渲染这三段。
const api = window.oio;
const loginView = document.getElementById('loginView');
@@ -17,6 +17,15 @@ const selfRoleEl = document.getElementById('selfRole');
const usbStatus = document.getElementById('usbStatus');
const shareStatus = document.getElementById('shareStatus');
const toastEl = document.getElementById('toast');
// 能力驱动显隐用到的容器(不存在时全部为 null,逻辑里已做空值保护)
const usbPanel = document.getElementById('usbPanel');
const usbExportBlock = document.getElementById('usbExportBlock');
const usbImportBlock = document.getElementById('usbImportBlock');
const usbCapNotice = document.getElementById('usbCapNotice');
const sharePanel = document.getElementById('sharePanel');
const shareImportBlock = document.getElementById('shareImportBlock');
const shareExportBlock = document.getElementById('shareExportBlock');
const shareCapNotice = document.getElementById('shareCapNotice');
let selfName = '';
@@ -125,6 +134,15 @@ function el(tag, className, text) {
}
})();
// 拉取平台能力表并显隐入口(失败不影响主流程)
(async () => {
try {
renderCapabilities(unwrap(await api.capabilities()));
} catch {
/* 老版本 preload 没有该通道时忽略 */
}
})();
// 订阅主进程推送的在线状态
try {
if (typeof api.onStatus === 'function') {
@@ -136,12 +154,81 @@ try {
/* 老版本 preload 没有该通道时忽略 */
}
/**
* 判断能力位是否「可用」(supported / experimental 都算可用)。
* @param {any} capability 能力位
* @returns {boolean}
*/
function isCapable(capability) {
return Boolean(capability && (capability.state === 'supported' || capability.state === 'experimental'));
}
/**
* 取能力位的不可用原因(用于展示,缺失时给兜底文案)。
* @param {any} capability 能力位
* @param {string} fallback 兜底文案
* @returns {string}
*/
function reasonOf(capability, fallback) {
if (capability && typeof capability.reason === 'string' && capability.reason) return capability.reason;
return fallback;
}
/**
* 按能力表显隐入口(架构 §5.1 原则 1:不支持的入口**隐藏**,而不是点了才报错)。
*
* 注意:本函数**不含任何平台判断**(不读运行平台、也不比较平台名字),
* 完全由主进程下发的 capabilities 驱动。
* @param {any} caps PlatformCapabilities
*/
function renderCapabilities(caps) {
if (!caps) return;
// ---- USB ----
const usbExportOk = isCapable(caps.usbExport);
const usbImportOk = isCapable(caps.usbImport);
if (usbExportBlock) usbExportBlock.classList.toggle('hidden', !usbExportOk);
if (usbImportBlock) usbImportBlock.classList.toggle('hidden', !usbImportOk);
if (usbPanel) {
// 两个方向都不支持时整块隐藏,把原因放到「我的设备」下方的一句提示里
usbPanel.classList.toggle('hidden', !usbExportOk && !usbImportOk);
}
if (usbCapNotice) {
const notes = [];
if (!usbExportOk) notes.push(`USB 导出:${reasonOf(caps.usbExport, '当前平台不支持')}`);
if (!usbImportOk) notes.push(`USB 挂载:${reasonOf(caps.usbImport, '当前平台不支持')}`);
usbCapNotice.textContent = notes.join(';');
usbCapNotice.classList.toggle('hidden', notes.length === 0);
}
// ---- 文件共享 ----
const smbImportOk = isCapable(caps.smbImport);
const smbExportOk = isCapable(caps.smbExport);
if (shareImportBlock) shareImportBlock.classList.toggle('hidden', !smbImportOk);
if (shareExportBlock) shareExportBlock.classList.toggle('hidden', !smbExportOk);
if (sharePanel) sharePanel.classList.toggle('hidden', !smbImportOk && !smbExportOk);
if (shareCapNotice) {
const notes = [];
if (!smbImportOk) notes.push(`打开对端共享:${reasonOf(caps.smbImport, '当前平台不支持')}`);
if (!smbExportOk) notes.push(`本机共享:${reasonOf(caps.smbExport, '当前平台不支持')}`);
shareCapNotice.textContent = notes.join(';');
shareCapNotice.classList.toggle('hidden', notes.length === 0);
}
// ---- 串流客户端为 unsupported 时,连接按钮统一禁用(保留可见,给出原因)----
const streamClientOk = isCapable(caps.streamClient);
if (!streamClientOk) {
mainStatus.textContent = `串流:${reasonOf(caps.streamClient, '当前平台不支持')}`;
}
}
/**
* 渲染心跳状态(在线 / 降级 / 未上线)。
* @param {any} status AgentStatus
*/
function renderAgentStatus(status) {
if (!status) return;
if (status.capabilities) renderCapabilities(status.capabilities);
if (typeof status.deviceName === 'string' && status.deviceName) {
selfName = status.deviceName;
selfNameEl.textContent = selfName;
+102 -12
View File
@@ -1,15 +1,105 @@
// Only in One - file sharing helpers (SMB primary, WebDAV fallback).
import { exec } from 'child_process';
// Only in One - 文件共享(**薄分派层**)。
//
// T-3 变更(G-21 / 安全红线第 2 条):
// · 本文件**不再直接调用 `exec`**,也**不做平台判断**;
// · `enableLocal` 在非 Windows 上**明确报不支持 + 引导文案**,绝不静默成功
// (静默成功会让用户以为共享开好了,实际对端连不上,最难排查);
// · 共享名一律过 `isShareName` 白名单(`[A-Za-z0-9_$-]{1,64}`)。
import path from 'node:path';
import type { PlatformAdapter, ShareAdapter } from './platform/types';
import { DEFAULT_SHARE_NAME, getPlatformAdapter } from './platform/index';
import { AppError } from './core/errors';
import { isRoutableIPv4, isShareName } from './core/validate';
export function openSharedFolder(host: string, shareName: string): void {
const url = `smb://${host}/${shareName}`;
if (process.platform === 'darwin') {
exec(`open "${url}"`);
} else if (process.platform === 'win32') {
exec(`explorer "\\\\${host}\\${shareName}"`);
} else {
exec(`xdg-open "${url}"`);
}
/** 默认共享名(Windows 侧 `New-SmbShare -Name OnlyInOne`)。 */
export const SHARE_NAME = DEFAULT_SHARE_NAME;
/** 依赖注入(单测传假适配器)。 */
export interface ShareDeps {
adapter?: PlatformAdapter;
}
export const SHARE_NAME = 'OnlyInOne';
/** 本机共享状态。 */
export interface LocalShareState {
enabled: boolean;
dir: string | null;
smbUrl: string | null;
}
/**
* 取共享适配器。
* @param deps 注入依赖
* @returns 共享适配器
*/
export function requireShareAdapter(deps: ShareDeps = {}): ShareAdapter {
const adapter = deps.adapter ?? getPlatformAdapter();
return adapter.share;
}
/**
* 校验共享名(缺省用 `OnlyInOne`)。
* @param shareName 用户输入
* @returns 合法共享名
*/
function normalizeShareName(shareName: unknown): string {
const name = shareName === undefined || shareName === null || shareName === '' ? SHARE_NAME : shareName;
if (!isShareName(name)) throw new AppError('INVALID_ARGUMENT', { field: 'shareName' });
return name as string;
}
/**
* 打开对端的共享文件夹(macOS `open smb://…` / Windows `explorer \\host\share`)。
* @param host 对端可路由 IPv4
* @param shareName 共享名(缺省 `OnlyInOne`)
* @param deps 注入依赖
* @returns 无
*/
export async function openSharedFolder(
host: string,
shareName?: string,
deps: ShareDeps = {}
): Promise<void> {
if (!isRoutableIPv4(host)) throw new AppError('INVALID_ARGUMENT', { field: 'host' });
const name = normalizeShareName(shareName);
await requireShareAdapter(deps).openRemote(host, name);
}
/**
* 开启本机共享(仅 Windows 支持;其它平台明确报「不支持 + 引导」)。
* @param dir 要共享的本地目录(绝对路径)
* @param shareName 共享名(缺省 `OnlyInOne`)
* @param deps 注入依赖
* @returns `smb://<本机>/<共享名>`
*/
export async function enableLocalShare(
dir: string,
shareName?: string,
deps: ShareDeps = {}
): Promise<{ smbUrl: string }> {
if (typeof dir !== 'string' || !path.isAbsolute(dir)) {
throw new AppError('INVALID_ARGUMENT', { field: 'dir' });
}
if (/[\u0000-\u001f\u007f]/.test(dir)) throw new AppError('INVALID_ARGUMENT', { field: 'dir' });
const name = normalizeShareName(shareName);
return requireShareAdapter(deps).enableLocal(dir, name);
}
/**
* 关闭本机共享(仅 Windows 支持)。
* @param shareName 共享名(缺省 `OnlyInOne`)
* @param deps 注入依赖
* @returns 无
*/
export async function disableLocalShare(shareName?: string, deps: ShareDeps = {}): Promise<void> {
const name = normalizeShareName(shareName);
await requireShareAdapter(deps).disableLocal(name);
}
/**
* 读取本机共享状态(用于 UI 显示「已开启 / 未开启」)。
* @param deps 注入依赖
* @returns 共享状态
*/
export async function getLocalShareState(deps: ShareDeps = {}): Promise<LocalShareState> {
return requireShareAdapter(deps).localState();
}
+88 -38
View File
@@ -1,50 +1,100 @@
// Only in One - USB/IP control wrappers.
// Windows host: usbipd-win. macOS client: usbip (Homebrew, needs sudo).
import { exec } from 'child_process';
// Only in One - USB 控制(**薄分派层**)。
//
// T-3 变更(G-50 / R-P1-08 / 安全红线第 2 条):
// · 本文件**不再直接调用 `exec`**,也**不做平台判断**;
// · 一律 `getPlatformAdapter().usb` 分派,`usb === null` 时返回
// `UNSUPPORTED_ON_PLATFORM` + 中文原因(而非把 ENOENT/ENOTFOUND 抛给 UI);
// · `parseUsbPort` 是纯函数,三端共用(macOS 适配器也用它)。
import type { AttachedUsb, PlatformAdapter, UsbAdapter, UsbDevice } from './platform/types';
import { getPlatformAdapter } from './platform/index';
import { parseUsbPort } from './platform/shared';
import { AppError } from './core/errors';
import { isBusId, isRoutableIPv4, isUsbPort } from './core/validate';
export interface UsbDevice {
busid: string;
name: string;
export type { AttachedUsb, UsbDevice };
/** 供测试与调用方复用(纯函数,不触发 IO)。 */
export { parseUsbPort };
/** 依赖注入(单测传假适配器,生产留空走全局)。 */
export interface UsbDeps {
adapter?: PlatformAdapter;
}
export function listUsbDevices(): Promise<UsbDevice[]> {
return new Promise((resolve, reject) => {
exec('usbipd list', (err, stdout) => {
if (err) return reject(err);
const lines = stdout.trim().split('\n').slice(1); // skip header
const devices = lines
.map((line) => {
const parts = line.trim().split(/\s+/);
return { busid: parts[0], name: parts.slice(1).join(' ') };
})
.filter((d) => d.busid);
resolve(devices);
});
});
/**
* 取 USB 适配器;平台不支持时抛 `UNSUPPORTED_ON_PLATFORM`。
* @param deps 注入依赖
* @returns USB 适配器
* @throws {AppError} 平台无 USB 能力
*/
export function requireUsbAdapter(deps: UsbDeps = {}): UsbAdapter {
const adapter = deps.adapter ?? getPlatformAdapter();
if (adapter.usb) return adapter.usb;
const capabilities = adapter.capabilities();
const reason =
capabilities.usbExport.reason ?? capabilities.usbImport.reason ?? '当前平台不支持 USB 共享';
throw new AppError('UNSUPPORTED_ON_PLATFORM', { reason, platform: adapter.platform });
}
export function shareUsb(busid: string): Promise<void> {
return new Promise((resolve, reject) => {
exec(`usbipd bind --busid=${busid}`, (err) => (err ? reject(err) : resolve()));
});
/**
* 列出本机可共享的 USB 设备(Windows:`usbipd list`)。
* @param deps 注入依赖
* @returns 设备列表
*/
export async function listUsbDevices(deps: UsbDeps = {}): Promise<UsbDevice[]> {
return requireUsbAdapter(deps).listLocal();
}
export function unshareUsb(busid: string): Promise<void> {
return new Promise((resolve, reject) => {
exec(`usbipd unbind --busid=${busid}`, (err) => (err ? reject(err) : resolve()));
});
/**
* 共享(导出)一个 USB 设备。
* @param busid USB/IP busid(`1-2` 形式)
* @param deps 注入依赖
* @returns 无
*/
export async function shareUsb(busid: string, deps: UsbDeps = {}): Promise<void> {
if (!isBusId(busid)) throw new AppError('INVALID_ARGUMENT', { field: 'busid' });
await requireUsbAdapter(deps).share(busid);
}
// macOS client side: attach a shared device from a Windows host.
export function attachUsb(host: string, busid: string): Promise<void> {
return new Promise((resolve, reject) => {
// requires admin; launcher should prompt via osascript/sudo
exec(`sudo usbip attach -r ${host} -b ${busid}`, (err) => (err ? reject(err) : resolve()));
});
/**
* 取消共享一个 USB 设备。
* @param busid USB/IP busid
* @param deps 注入依赖
* @returns 无
*/
export async function unshareUsb(busid: string, deps: UsbDeps = {}): Promise<void> {
if (!isBusId(busid)) throw new AppError('INVALID_ARGUMENT', { field: 'busid' });
await requireUsbAdapter(deps).unshare(busid);
}
export function detachUsb(port: string): Promise<void> {
return new Promise((resolve, reject) => {
exec(`sudo usbip detach -p ${port}`, (err) => (err ? reject(err) : resolve()));
});
/**
* 挂载对端共享的 USB 设备(macOS:`usbip attach`,需提权)。
* @param host 对端可路由 IPv4
* @param busid USB/IP busid
* @param deps 注入依赖
* @returns 无
*/
export async function attachUsb(host: string, busid: string, deps: UsbDeps = {}): Promise<void> {
if (!isRoutableIPv4(host)) throw new AppError('INVALID_ARGUMENT', { field: 'host' });
if (!isBusId(busid)) throw new AppError('INVALID_ARGUMENT', { field: 'busid' });
await requireUsbAdapter(deps).attach(host, busid);
}
/**
* 卸载一个已挂载的 USB 设备。
* @param port usbip 端口号(`00` / `1-2`)
* @param deps 注入依赖
* @returns 无
*/
export async function detachUsb(port: string, deps: UsbDeps = {}): Promise<void> {
if (!isUsbPort(port)) throw new AppError('INVALID_ARGUMENT', { field: 'port' });
await requireUsbAdapter(deps).detach(port);
}
/**
* 列出已挂载的远程 USB 设备(`usbip port` 解析)。
* @param deps 注入依赖
* @returns 已挂载设备
*/
export async function listAttachedUsb(deps: UsbDeps = {}): Promise<AttachedUsb[]> {
return requireUsbAdapter(deps).listAttached();
}
+327
View File
@@ -0,0 +1,327 @@
// Only in One - 局域网发现单测(G-18 / G-20)。
//
// 关键断言:
// · 单个 /24 的候选恰好 **252** 个(剔除 .0 / .255 / 网关 .1 / 本机地址);
// · 多网卡**全局去重**(同一 /24 只扫一次);
// · 并发**真实受限**(峰值 ≤64,且满负载时确实达到 64 —— 不是常量摆设);
// · mDNS 分派由能力位驱动:macOS 有记录即返回,空则回落扫描;Windows 直接扫描。
import { describe, it, expect } from 'vitest';
import {
MAX_CONCURRENCY,
SCAN_PORT,
candidateIps,
discoverDevices,
ipsForSubnet,
mapWithConcurrency,
scanLan,
} from '../src/discover';
import type { DiscoveredHost, InterfaceInfoLike } from '../src/discover';
import { createPlatformAdapter } from '../src/platform/index';
import { parseAvahiBrowse, parseDnsSd } from '../src/platform/shared';
import type { PlatformAdapter } from '../src/platform/types';
/**
* 造一个网络接口表。
* @param entries 接口名 → 地址列表
* @returns 接口表
*/
function interfacesOf(entries: Record<string, { address: string; internal?: boolean }[]>): Record<string, InterfaceInfoLike[]> {
const result: Record<string, InterfaceInfoLike[]> = {};
for (const name of Object.keys(entries)) {
result[name] = entries[name].map((item) => ({
address: item.address,
family: 'IPv4',
internal: item.internal ?? false,
}));
}
return result;
}
/**
* 造一个「只有 mDNS 不同」的适配器。
* @param mdnsSupported mDNS 是否支持
* @param hosts mDNS 返回的主机
* @returns PlatformAdapter
*/
function adapterWithMdns(mdnsSupported: boolean, hosts: { ip: string; port: number }[]): PlatformAdapter {
const base = createPlatformAdapter(mdnsSupported ? 'darwin' : 'win32');
return {
...base,
discovery: {
async browseMdns() {
return hosts;
},
},
};
}
describe('ipsForSubnet / candidateIps', () => {
it('单个 /24 恰好 252 个候选,且不含 .0 / .255 / .1 / 本机', () => {
const ips = ipsForSubnet('192.168.1.50');
expect(ips).toHaveLength(252);
expect(new Set(ips).size).toBe(252);
for (const excluded of ['192.168.1.0', '192.168.1.255', '192.168.1.1', '192.168.1.50']) {
expect(ips).not.toContain(excluded);
}
expect(ips).toContain('192.168.1.2');
expect(ips).toContain('192.168.1.254');
});
it('非法地址返回空数组', () => {
expect(ipsForSubnet('not-an-ip')).toEqual([]);
expect(ipsForSubnet('192.168.1.256')).toEqual([]);
expect(ipsForSubnet('')).toEqual([]);
});
it('单网卡 → 252;多网卡同网段 → 去重后 251(另一块网卡也是本机地址,同样排除)', () => {
const single = candidateIps(interfacesOf({ en0: [{ address: '192.168.1.50' }] }));
expect(single).toHaveLength(252);
const dual = candidateIps(
interfacesOf({
en0: [{ address: '192.168.1.50' }],
en1: [{ address: '192.168.1.77' }],
})
);
// 同一 /24 只扫一次(不是 504),且两块网卡的地址都不扫(.50 / .77 都是本机)
expect(dual).toHaveLength(251);
expect(new Set(dual).size).toBe(dual.length);
expect(dual).not.toContain('192.168.1.50');
expect(dual).not.toContain('192.168.1.77');
});
it('多网卡不同网段 → 累加且互不重复', () => {
const ips = candidateIps(
interfacesOf({
en0: [{ address: '192.168.1.50' }],
en1: [{ address: '10.0.0.20' }],
})
);
expect(ips).toHaveLength(504);
expect(new Set(ips).size).toBe(504);
});
it('排除回环 / 内网接口与 IPv6', () => {
const ips = candidateIps(
interfacesOf({
lo0: [{ address: '127.0.0.1', internal: true }],
utun: [{ address: '100.64.1.1', internal: true }],
en0: [{ address: '192.168.1.50' }],
})
);
expect(ips).toHaveLength(252);
expect(ips.some((ip) => ip.startsWith('127.'))).toBe(false);
expect(ips.some((ip) => ip.startsWith('100.64.'))).toBe(false);
});
});
describe('mapWithConcurrency(并发真实受限)', () => {
it('252 个任务、上限 64 → 峰值恰好 64(不是 252)', async () => {
const items = Array.from({ length: 252 }, (_value, index) => index);
let peak = 0;
const onPeak = (value: number): void => {
peak = value;
};
const results = await mapWithConcurrency(
items,
MAX_CONCURRENCY,
async (item) => {
// 让每个任务真的「挂起」一小会儿,确保并发可观测
await new Promise((resolve) => setTimeout(resolve, 1));
return item * 2;
},
onPeak
);
expect(results).toHaveLength(252);
expect(results[0]).toBe(0);
expect(results[251]).toBe(502);
expect(peak).toBe(MAX_CONCURRENCY);
expect(peak).toBeLessThanOrEqual(64);
});
it('任务数少于上限时峰值等于任务数', async () => {
let peak = 0;
await mapWithConcurrency([1, 2, 3], 64, async (item) => item, (value) => {
peak = value;
});
expect(peak).toBe(3);
});
it('空输入直接返回空数组(不建定时器)', async () => {
expect(await mapWithConcurrency([], 64, async () => 1)).toEqual([]);
});
it('结果顺序与输入一致(并发不改变顺序)', async () => {
const results = await mapWithConcurrency(
[30, 10, 20],
3,
async (ms) => {
await new Promise((resolve) => setTimeout(resolve, ms));
return ms;
}
);
expect(results).toEqual([30, 10, 20]);
});
it('单个任务抛错时整体 reject(调用方负责兜底)', async () => {
await expect(
mapWithConcurrency([1, 2], 2, async (item) => {
if (item === 2) throw new Error('boom');
return item;
})
).rejects.toThrow('boom');
});
});
describe('scanLan', () => {
it('只把探测成功的主机计入结果,端口固定 47990', async () => {
const probed: string[] = [];
const hosts = await scanLan({
interfaces: interfacesOf({ en0: [{ address: '192.168.1.50' }] }),
concurrency: 32,
probe: async (ip, port) => {
probed.push(`${ip}:${port}`);
return ip === '192.168.1.10' || ip === '192.168.1.20';
},
});
expect(hosts).toEqual([
{ ip: '192.168.1.10', port: SCAN_PORT, source: 'scan' },
{ ip: '192.168.1.20', port: SCAN_PORT, source: 'scan' },
]);
// 探测次数 = 候选数(252),且每个都带正确端口
expect(probed).toHaveLength(252);
expect(probed.every((item) => item.endsWith(`:${SCAN_PORT}`))).toBe(true);
});
it('并发上限可通过 deps 覆盖,且峰值不超过该值', async () => {
let peak = 0;
await scanLan({
interfaces: interfacesOf({ en0: [{ address: '192.168.1.50' }] }),
concurrency: 8,
onPeak: (value) => {
peak = value;
},
probe: async () => {
await new Promise((resolve) => setTimeout(resolve, 1));
return false;
},
});
expect(peak).toBeLessThanOrEqual(8);
expect(peak).toBe(8);
});
it('无候选接口时返回空(不崩)', async () => {
expect(await scanLan({ interfaces: interfacesOf({ lo0: [{ address: '127.0.0.1', internal: true }] }) })).toEqual([]);
});
});
describe('discoverDevices:能力驱动分派', () => {
it('mDNS 支持且有记录 → 直接返回 mDNS 结果,不再扫描', async () => {
const adapter = adapterWithMdns(true, [{ ip: '192.168.1.10', port: 47990 }]);
let probeCalled = false;
const hosts = (await discoverDevices({
adapter,
interfaces: interfacesOf({ en0: [{ address: '192.168.1.50' }] }),
probe: async () => {
probeCalled = true;
return false;
},
})) as DiscoveredHost[];
expect(hosts).toEqual([{ ip: '192.168.1.10', port: 47990, source: 'mdns' }]);
expect(probeCalled).toBe(false);
});
it('mDNS 支持但无记录 → 回落扫描', async () => {
const adapter = adapterWithMdns(true, []);
const hosts = await discoverDevices({
adapter,
interfaces: interfacesOf({ en0: [{ address: '192.168.1.50' }] }),
probe: async (ip) => ip === '192.168.1.30',
});
expect(hosts).toEqual([{ ip: '192.168.1.30', port: 47990, source: 'scan' }]);
});
it('Windows(mdns unsupported)→ 直接扫描,不调用 browseMdns', async () => {
const base = createPlatformAdapter('win32');
let mdnsCalled = false;
const adapter: PlatformAdapter = {
...base,
discovery: {
async browseMdns() {
mdnsCalled = true;
return [];
},
},
};
const hosts = await discoverDevices({
adapter,
interfaces: interfacesOf({ en0: [{ address: '192.168.1.50' }] }),
probe: async (ip) => ip === '192.168.1.40',
});
expect(mdnsCalled).toBe(false);
expect(hosts).toHaveLength(1);
});
it('mDNS 抛异常时回落扫描(不把异常抛给 UI)', async () => {
const base = createPlatformAdapter('darwin');
const adapter: PlatformAdapter = {
...base,
discovery: {
async browseMdns() {
throw new Error('dns-sd exploded');
},
},
};
const hosts = await discoverDevices({
adapter,
interfaces: interfacesOf({ en0: [{ address: '192.168.1.50' }] }),
probe: async (ip) => ip === '192.168.1.60',
});
expect(hosts).toEqual([{ ip: '192.168.1.60', port: 47990, source: 'scan' }]);
});
it('mDNS 结果去重(同一 ip:port 只保留一条)', async () => {
const adapter = adapterWithMdns(true, [
{ ip: '192.168.1.10', port: 47990 },
{ ip: '192.168.1.10', port: 47990 },
]);
const hosts = await discoverDevices({ adapter, interfaces: interfacesOf({}) });
expect(hosts).toHaveLength(1);
});
});
describe('mDNS 输出解析(纯函数)', () => {
it('parseDnsSd 提取 IPv4 + 端口,缺端口时用默认 47990', () => {
const sample = `Browsing for _sunshine._tcp.local.
DATE: ---Sun 25 Oct 2026---
12:00:00.123 ...STARTING...
12:00:01.001 Add 2 14 local. _sunshine._tcp. windesk
12:00:01.002 Add 2 14 local. _sunshine._tcp. mbp
windesk._sunshine._tcp.local. 192.168.1.10:47990
mbp._sunshine._tcp.local. 192.168.1.11
`;
const parsed = parseDnsSd(sample);
expect(parsed).toContainEqual({ ip: '192.168.1.10', port: 47990 });
expect(parsed).toContainEqual({ ip: '192.168.1.11', port: 47990 });
expect(parseDnsSd('')).toEqual([]);
});
it('parseAvahiBrowse 解析 avahi-browse -rpt 的分号格式', () => {
const sample = [
'=;eth0;IPv4;windesk;_sunshine._tcp;local;windesk.local;192.168.1.10;47990;""',
'=;eth0;IPv4;mbp;_sunshine._tcp;local;mbp.local;192.168.1.11;47991;"a=b"',
'+;eth0;IPv4;windesk;_sunshine._tcp;local', // 只有 + 行(无解析记录)→ 跳过
'garbage line',
].join('\n');
const parsed = parseAvahiBrowse(sample);
expect(parsed).toEqual([
{ ip: '192.168.1.10', port: 47990 },
{ ip: '192.168.1.11', port: 47991 },
]);
expect(parseAvahiBrowse('')).toEqual([]);
expect(parseAvahiBrowse(undefined)).toEqual([]);
});
});
+168
View File
@@ -0,0 +1,168 @@
// Only in One - IPC 错误边界单测(架构 §12.4 R4)。
//
// 这是整个启动器最关键的一条边界:handler 里抛出的任何东西,到了 renderer
// 都必须变成 `{ok:false, error:{code,message,hint}}`,绝不能带出堆栈或内部路径。
// 以前这条逻辑写在 main.ts(依赖 electron,无法单测),现在抽到 core/ipc.ts。
import { describe, it, expect, vi } from 'vitest';
import { makeSafeHandler, unwrapApi } from '../src/core/ipc';
import { AppError, ERROR_INFO } from '../src/core/errors';
import { createLogger } from '../src/core/logger';
import type { ApiResult } from '../src/platform/types';
describe('makeSafeHandler', () => {
it('成功路径原样透传 data(含同步返回值)', async () => {
const handler = makeSafeHandler((a: number, b: number) => a + b);
expect(await handler(1, 2)).toEqual({ ok: true, data: 3 });
const asyncHandler = makeSafeHandler(async (value: string) => `got:${value}`);
expect(await asyncHandler('x')).toEqual({ ok: true, data: 'got:x' });
});
it('AppError → 三段式错误,且不泄漏 details 之外的东西', async () => {
const handler = makeSafeHandler(() => {
throw new AppError('UNSUPPORTED_ON_PLATFORM', { reason: '本轮未做 Linux 真机验证' });
});
const result = await handler();
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error.code).toBe('UNSUPPORTED_ON_PLATFORM');
expect(result.error.message).toBe(ERROR_INFO.UNSUPPORTED_ON_PLATFORM.message);
expect(result.error.hint.length).toBeGreaterThan(0);
expect(JSON.stringify(result)).not.toContain('at /');
});
it('原始 Error 的 message / 堆栈绝不外泄(只给 UNKNOWN 的人话)', async () => {
const handler = makeSafeHandler(() => {
throw new Error('EACCES: open /Users/secret/path/credential.bin -> 401');
});
const result = await handler();
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error.code).toBe('UNKNOWN');
expect(result.error.message).not.toContain('EACCES');
expect(result.error.message).not.toContain('/Users/secret');
expect(JSON.stringify(result)).not.toContain('401');
});
it('Promise reject 同样被兜住', async () => {
const handler = makeSafeHandler(async () => {
throw new Error('network down');
});
const result = await handler();
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error.code).toBe('UNKNOWN');
});
it('非 Error 值(字符串 / undefined / 对象)也能兜住', async () => {
for (const thrown of ['boom', undefined, null, { code: 'TIMEOUT' }, 42]) {
const handler = makeSafeHandler(() => {
throw thrown;
});
const result = await handler();
expect(result.ok, String(thrown)).toBe(false);
}
});
it('识别字符串里的大写错误码(旧代码把码塞进 message 的场景)', async () => {
const handler = makeSafeHandler(() => {
throw new Error('failed with TIMEOUT after 15000ms');
});
const result = await handler();
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error.code).toBe('TIMEOUT');
});
it('失败时记录一条脱敏日志(只记通道名与错误码,不记参数)', async () => {
const dir = await import('node:fs');
const os = await import('node:os');
const path = await import('node:path');
const tmp = dir.mkdtempSync(path.join(os.tmpdir(), 'oio-ipc-'));
const file = path.join(tmp, 'ipc.log');
const logger = createLogger({ level: 'debug', module: 'main', file, stdout: false });
const handler = makeSafeHandler(
(password: string) => {
throw new AppError('INVALID_CREDENTIALS', { attempt: password.length });
},
{ channel: 'auth:login', logger }
);
await handler('SuperSecret-Passw0rd');
const log = dir.readFileSync(file, 'utf8');
expect(log).toContain('auth:login');
expect(log).toContain('INVALID_CREDENTIALS');
expect(log).not.toContain('SuperSecret-Passw0rd');
dir.rmSync(tmp, { recursive: true, force: true });
});
it('logger 自身抛错不影响错误响应', async () => {
const brokenLogger = {
module: 'x',
level: 'info' as const,
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(() => {
throw new Error('log sink dead');
}),
child: vi.fn(),
};
const handler = makeSafeHandler(
() => {
throw new AppError('COMMAND_FAILED');
},
{ channel: 'usb:list', logger: brokenLogger as never }
);
const result = await handler();
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error.code).toBe('COMMAND_FAILED');
});
it('channel 缺省时不崩(日志用 unknown)', async () => {
const handler = makeSafeHandler(() => {
throw new Error('x');
});
await expect(handler()).resolves.toMatchObject({ ok: false });
});
});
describe('unwrapApi', () => {
it('成功时返回 data', () => {
const result: ApiResult<number> = { ok: true, data: 7 };
expect(unwrapApi(result)).toBe(7);
});
it('失败时抛 AppError,错误码与 details 透传', () => {
const result: ApiResult<number> = {
ok: false,
error: { code: 'RATE_LIMITED', message: 'x', details: { retryAfter: 900 } },
};
try {
unwrapApi(result);
expect.unreachable('应抛错');
} catch (error) {
const appError = error as AppError;
expect(appError.code).toBe('RATE_LIMITED');
expect(appError.details?.retryAfter).toBe(900);
}
});
it('未知错误码回落 UNKNOWN(不把非法码传给映射表)', () => {
const result: ApiResult<number> = { ok: false, error: { code: 'SOMETHING_NEW', message: '' } };
try {
unwrapApi(result);
expect.unreachable('应抛错');
} catch (error) {
expect((error as AppError).code).toBe('UNKNOWN');
}
});
it('与 makeSafeHandler 组合后得到完整三段式', async () => {
const handler = makeSafeHandler(async () => unwrapApi<number>({ ok: false, error: { code: 'NETWORK_UNREACHABLE', message: '' } }));
const result = await handler();
expect(result).toMatchObject({ ok: false, error: { code: 'NETWORK_UNREACHABLE' } });
});
});
+224
View File
@@ -0,0 +1,224 @@
// Only in One - 客户端结构化日志单测(R-P1-18 / 架构 §9.3)。
//
// 断言:4 类敏感输入必须脱敏(且输出里**不含原文**)、单行 JSON、级别过滤、
// 文件轮转只保留 3 份、磁盘失败不抛。
import { describe, it, expect, afterEach } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import {
DEFAULT_MAX_FILES,
REDACTED,
createLogger,
isSensitiveKey,
redact,
redactSnippet,
rotateFile,
shortFingerprint,
} from '../src/core/logger';
/** PEM 分隔线:运行时拼接,避免源码出现私钥标记字面量(gitleaks 误报)。 */
const DASH5 = '-'.repeat(5);
/**
* 拼一个 PEM 块(内容无意义,仅用于脱敏断言)。
* @param label 标签
* @param body 体
* @returns PEM 文本
*/
const pemBlock = (label: string, body: string): string =>
[`${DASH5}BEGIN ${label}${DASH5}`, body, `${DASH5}END ${label}${DASH5}`].join('\n');
/** JWT 分隔符(运行时取码点,避免完整 token 字面量)。 */
const DOT = String.fromCharCode(46);
/**
* 假密码:运行时拼接。
*
* 把口令原样写在 `password` 键旁边,会被 gitleaks 的 generic-api-key 规则判为
* 「关键字邻近 + 高熵串」而误报;拼成两个片段后,源码里就不存在这种形态了。
*/
const FAKE_PASSWORD = ['MySecret', 'Passw0rd'].join('-');
/**
* 造一个三段式 token 串。
* @returns token 形态字符串
*/
const fakeJwt = (): string => ['eyJhbGciOiJIUzI1NiJ9', 'eyJzdWIiOiIxIn0', 'abcdefgh'].join(DOT);
/** 本用例用到的临时目录。 */
const tmpDirs: string[] = [];
/**
* 建一个临时目录。
* @returns 目录路径
*/
function makeTmpDir(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'oio-log-'));
tmpDirs.push(dir);
return dir;
}
afterEach(() => {
while (tmpDirs.length > 0) {
const dir = tmpDirs.pop();
if (dir) fs.rmSync(dir, { recursive: true, force: true });
}
});
describe('redact(4 类敏感输入)', () => {
it('密码 / token / 证书全文 / 私钥块全部脱敏,且输出不含原文', () => {
const payload = {
password: FAKE_PASSWORD,
accessToken: fakeJwt(),
refreshToken: 'refresh-plain-value',
cert_pem: pemBlock('CERTIFICATE', 'MIIBfakebody'),
nested: { privateKey: pemBlock('PRIVATE KEY', 'MIIBVQ'), note: pemBlock('RSA PRIVATE KEY', 'abc') },
fingerprint: '0123456789abcdef',
safe: 'ok',
};
const output = redact(payload) as Record<string, unknown>;
const serialized = JSON.stringify(output);
expect(output.password).toBe(REDACTED);
expect(output.accessToken).toBe(REDACTED);
expect(output.refreshToken).toBe(REDACTED);
expect(output.cert_pem).toBe(REDACTED);
expect((output.nested as Record<string, unknown>).privateKey).toBe(REDACTED);
expect((output.nested as Record<string, unknown>).note).toBe(REDACTED);
expect(output.fingerprint).toBe('01234567');
expect(output.safe).toBe('ok');
for (const secret of [FAKE_PASSWORD, 'refresh-plain-value', 'MIIBfakebody', 'PRIVATE KEY']) {
expect(serialized.includes(secret), secret).toBe(false);
}
});
it('isSensitiveKey 覆盖下划线 / 驼峰 / 大小写', () => {
for (const key of ['password', 'accessToken', 'refresh_token', 'cert_pem', 'PRIVATE-KEY', 'Authorization']) {
expect(isSensitiveKey(key), key).toBe(true);
}
expect(isSensitiveKey('deviceName')).toBe(false);
expect(isSensitiveKey('lan_ip')).toBe(false);
expect(isSensitiveKey(123)).toBe(false);
});
it('redactSnippet 裁剪 JWT / 私钥块 / 超长文本', () => {
expect(redactSnippet(fakeJwt())).toBe(REDACTED);
expect(redactSnippet(pemBlock('PRIVATE KEY', 'x'))).toBe(REDACTED);
const truncated = redactSnippet('a'.repeat(600), 100);
expect(truncated.length).toBeLessThan(200);
expect(truncated).toContain('truncated');
expect(redactSnippet('')).toBe('');
expect(redactSnippet(null)).toBe('');
});
it('shortFingerprint 只留 8 位', () => {
expect(shortFingerprint('0123456789abcdef')).toBe('01234567');
expect(shortFingerprint('')).toBeUndefined();
});
it('不修改入参(纯函数)', () => {
const payload = { password: 'keep' };
redact(payload);
expect(payload.password).toBe('keep');
});
});
describe('createLogger:单行 JSON + 级别过滤 + 脱敏', () => {
it('写入单行 JSON,字段完整且已脱敏', () => {
const dir = makeTmpDir();
const file = path.join(dir, 'launcher.log');
const logger = createLogger({ level: 'debug', module: 'certs', file, stdout: false, now: () => 1_700_000_000_000 });
logger.info('证书已写入', { deviceName: 'windesk', fingerprint: '0123456789abcdef', accessToken: fakeJwt() });
const lines = fs.readFileSync(file, 'utf8').trim().split('\n');
expect(lines).toHaveLength(1);
const entry = JSON.parse(lines[0]) as Record<string, unknown>;
expect(entry.level).toBe('info');
expect(entry.module).toBe('certs');
expect(entry.msg).toBe('证书已写入');
expect(entry.deviceName).toBe('windesk');
expect(entry.fingerprint).toBe('01234567');
expect(entry.accessToken).toBe(REDACTED);
expect(typeof entry.ts).toBe('string');
expect(String(entry.ts)).toContain('2023-11-14');
});
it('级别过滤:warn 阈值下 info / debug 不落盘', () => {
const dir = makeTmpDir();
const file = path.join(dir, 'level.log');
const logger = createLogger({ level: 'warn', module: 'main', file, stdout: false });
logger.debug('d');
logger.info('i');
logger.warn('w');
logger.error('e');
const lines = fs.readFileSync(file, 'utf8').trim().split('\n');
expect(lines).toHaveLength(2);
expect(lines[0]).toContain('"level":"warn"');
expect(lines[1]).toContain('"level":"error"');
});
it('child() 共享文件但改 module 名', () => {
const dir = makeTmpDir();
const file = path.join(dir, 'child.log');
const root = createLogger({ level: 'info', module: 'main', file, stdout: false });
root.child('agent').info('tick');
const entry = JSON.parse(fs.readFileSync(file, 'utf8').trim()) as Record<string, unknown>;
expect(entry.module).toBe('agent');
});
it('stdout=false 时不污染测试输出(不抛)', () => {
const logger = createLogger({ level: 'info', module: 'x', file: null, stdout: false });
expect(() => logger.info('hello')).not.toThrow();
});
it('目录不可写时不抛异常(日志不能成为故障源)', () => {
const logger = createLogger({ level: 'info', module: 'x', file: '/proc/definitely/not/writable/x.log', stdout: false });
expect(() => logger.error('boom')).not.toThrow();
});
});
describe('轮转(≤5MB × 3 份)', () => {
it('超过上限时轮转,且最多保留 3 份', () => {
const dir = makeTmpDir();
const file = path.join(dir, 'rotate.log');
// maxBytes 故意设得很小:8 行足以触发 ≥4 次轮转,从而验证「只保留 3 份」
const logger = createLogger({
level: 'info',
module: 'rotate',
file,
stdout: false,
maxBytes: 60,
maxFiles: DEFAULT_MAX_FILES,
});
for (let index = 0; index < 8; index += 1) {
logger.info(`line ${index} ${'x'.repeat(20)}`);
}
expect(fs.existsSync(file)).toBe(true);
expect(fs.existsSync(`${file}.1`)).toBe(true);
expect(fs.existsSync(`${file}.2`)).toBe(true);
expect(fs.existsSync(`${file}.3`)).toBe(true);
// 不能出现第 4 份
expect(fs.existsSync(`${file}.4`)).toBe(false);
expect(DEFAULT_MAX_FILES).toBe(3);
});
it('rotateFile 幂等:没有文件时不抛', () => {
const dir = makeTmpDir();
expect(() => rotateFile(path.join(dir, 'nope.log'), 3)).not.toThrow();
});
it('每次轮转后当前文件仍有内容(不会清空丢失)', () => {
const dir = makeTmpDir();
const file = path.join(dir, 'keep.log');
const logger = createLogger({ level: 'info', module: 'k', file, stdout: false, maxBytes: 120, maxFiles: 3 });
logger.info('first event payload aaaaaaaaaaaaaaaaaaaaaaaaaaaa');
logger.info('second event payload bbbbbbbbbbbbbbbbbbbbbbbbbbbb');
logger.info('third event payload ccccccccccccccccccccccccccccc');
expect(fs.readFileSync(file, 'utf8').length).toBeGreaterThan(0);
});
});
+378
View File
@@ -0,0 +1,378 @@
// Only in One - 三端平台适配器单测(R-P1-10 ~ R-P1-13 / 架构 §5.3 §5.4)。
//
// 断言四件事:
// 1. 三端 `capabilities()` 的**每一个能力位**都有明确结论(8 位 × 3 端 = 24 项);
// 2. Linux 是**显式降级**:`usb === null`,且调用方拿到 `UNSUPPORTED_ON_PLATFORM` + 中文原因;
// 3. 依赖预检 4 个依赖 × 三端都返回 `ok`/`missing`/`outdated` + `hintUrl`;
// 4. 架构守卫:`usb.ts` / `share.ts` / `discover.ts` 内**没有** `process.platform`。
import { describe, it, expect } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import {
LINUX_CAPABILITIES,
MACOS_CAPABILITIES,
PLATFORMS,
WINDOWS_CAPABILITIES,
capabilitiesFor,
createPlatformAdapter,
detectPlatform,
getPlatformAdapter,
resetAdapterCache,
} from '../src/platform/index';
import type { DependencyName, Platform, PlatformCapabilities } from '../src/platform/types';
import type { ExecResult, Runner } from '../src/core/runner';
import { AppError } from '../src/core/errors';
import { listUsbDevices } from '../src/usb';
/** 能力位键名(顺序即断言顺序)。 */
const CAPABILITY_KEYS: readonly (keyof PlatformCapabilities)[] = [
'streamHost',
'streamClient',
'usbExport',
'usbImport',
'smbExport',
'smbImport',
'mdns',
'credential',
];
/** src 目录(用于架构守卫用例)。 */
const SRC_DIR = path.resolve(process.cwd(), 'src');
/**
* 读源码文件。
* @param relative 相对 src 的路径
* @returns 文件内容
*/
function readSrc(relative: string): string {
return fs.readFileSync(path.join(SRC_DIR, relative), 'utf8');
}
/**
* 造一个只记录调用的 Runner。
* @param responder 按 file/args 返回结果片段
* @returns runner 与调用记录
*/
function createFakeRunner(responder: (file: string, args: string[]) => Partial<ExecResult> = () => ({})) {
const calls: { file: string; args: string[] }[] = [];
const runner: Runner = {
async execFile(file, args) {
calls.push({ file, args: [...args] });
return {
ok: true,
code: 'OK',
stdout: '',
stderrDigest: '',
exitCode: 0,
timedOut: false,
...responder(file, [...args]),
} as ExecResult;
},
async spawnDetached() {
return { ok: false, code: 'INVALID_ARGUMENT', pid: null, child: null };
},
async elevate(spec) {
calls.push({ file: spec.command, args: [...spec.args] });
return {
ok: true,
code: 'OK',
stdout: '',
stderrDigest: '',
exitCode: 0,
timedOut: false,
...responder(spec.command, [...spec.args]),
} as ExecResult;
},
};
return { runner, calls };
}
describe('能力表快照(8 位 × 3 端 = 24 项,逐项核对)', () => {
const expected: Record<Platform, Record<string, string>> = {
win32: {
streamHost: 'supported',
streamClient: 'supported',
usbExport: 'supported',
usbImport: 'unsupported',
smbExport: 'supported',
smbImport: 'supported',
mdns: 'unsupported',
credential: 'supported',
},
darwin: {
streamHost: 'unsupported',
streamClient: 'supported',
usbExport: 'unsupported',
usbImport: 'supported',
smbExport: 'unsupported',
smbImport: 'supported',
mdns: 'supported',
credential: 'supported',
},
linux: {
streamHost: 'unsupported',
streamClient: 'unsupported',
usbExport: 'unsupported',
usbImport: 'unsupported',
smbExport: 'unsupported',
smbImport: 'unsupported',
mdns: 'unsupported',
credential: 'unsupported',
},
};
it('能力位数固定为 8 个(与架构 §4.6 的 PlatformCapabilities 一致)', () => {
for (const platform of PLATFORMS) {
expect(Object.keys(capabilitiesFor(platform)).sort()).toEqual([...CAPABILITY_KEYS].sort());
}
});
for (const platform of PLATFORMS) {
it(`${platform}:8 个能力位逐一满足预期`, () => {
const capabilities = capabilitiesFor(platform);
for (const key of CAPABILITY_KEYS) {
expect(capabilities[key].state, `${platform}.${key}`).toBe(expected[platform][key]);
}
});
}
it('所有 unsupported 能力位都必须带中文 reason(禁止裸 unsupported)', () => {
for (const platform of PLATFORMS) {
for (const key of CAPABILITY_KEYS) {
const capability = capabilitiesFor(platform)[key];
if (capability.state !== 'unsupported') continue;
expect(typeof capability.reason, `${platform}.${key}`).toBe('string');
expect((capability.reason as string).length).toBeGreaterThan(6);
// 必须是中文说明,不能是英文系统错误
expect(capability.reason as string).toMatch(/[\u4e00-\u9fa5]/);
expect(capability.reason as string).not.toMatch(/^Error:/);
}
}
});
it('三端能力表是模块级常量(同一引用,避免每次新建导致 UI 抖动)', () => {
expect(capabilitiesFor('win32')).toBe(WINDOWS_CAPABILITIES);
expect(capabilitiesFor('darwin')).toBe(MACOS_CAPABILITIES);
expect(capabilitiesFor('linux')).toBe(LINUX_CAPABILITIES);
});
it('Linux 的 mdns 与 credential 降级有明确文案', () => {
expect(LINUX_CAPABILITIES.mdns.state).toBe('unsupported');
expect(LINUX_CAPABILITIES.credential.reason).toContain('内存');
});
});
describe('Linux 显式降级(不是 else 兜底)', () => {
it('linux.usb === null', () => {
expect(createPlatformAdapter('linux').usb).toBeNull();
});
it('usb===null 时调用方拿到 UNSUPPORTED_ON_PLATFORM + 中文原因(而非系统错误)', async () => {
const adapter = createPlatformAdapter('linux');
await expect(listUsbDevices({ adapter })).rejects.toMatchObject({
code: 'UNSUPPORTED_ON_PLATFORM',
});
try {
await listUsbDevices({ adapter });
expect.unreachable('应抛出 AppError');
} catch (error) {
const appError = error as AppError;
expect(appError.code).toBe('UNSUPPORTED_ON_PLATFORM');
expect(String(appError.details?.reason)).toMatch(/Linux|未做 Linux 真机验证/);
}
});
it('三端都有完整的 share / discovery 实现(不存在 undefined 分支)', () => {
for (const platform of PLATFORMS) {
const adapter = createPlatformAdapter(platform);
expect(adapter.share, platform).toBeTruthy();
expect(adapter.discovery, platform).toBeTruthy();
expect(typeof adapter.share.openRemote, platform).toBe('function');
expect(typeof adapter.discovery.browseMdns, platform).toBe('function');
}
});
});
describe('detectPlatform / 工厂', () => {
it('返回三端之一,未知平台按 linux 处理', () => {
expect(PLATFORMS).toContain(detectPlatform());
});
it('getPlatformAdapter 命中缓存;resetAdapterCache 后重建', () => {
resetAdapterCache();
const first = getPlatformAdapter('darwin');
const second = getPlatformAdapter('darwin');
expect(first).toBe(second);
resetAdapterCache();
expect(getPlatformAdapter('darwin')).not.toBe(first);
});
it('createPlatformAdapter 每次都新建(便于注入 deps)', () => {
const a = createPlatformAdapter('win32');
const b = createPlatformAdapter('win32');
expect(a).not.toBe(b);
expect(a.platform).toBe('win32');
});
});
describe('依赖预检(4 个依赖 × 三端)', () => {
const dependencies: DependencyName[] = ['sunshine', 'moonlight', 'usbipd', 'usbip', 'smb'];
it('每个依赖都返回 status + hintUrl', async () => {
for (const platform of PLATFORMS) {
const adapter = createPlatformAdapter(platform, {
checkDependency: async () => ({ status: 'ok', hintUrl: 'https://example.com' }),
});
for (const name of dependencies) {
const result = await adapter.checkDependency(name);
expect(['ok', 'missing', 'outdated'], `${platform}.${name}`).toContain(result.status);
expect(typeof result.hintUrl).toBe('string');
}
}
});
it('默认实现:moonlight 存在 → ok(含版本号),不存在 → missing + 安装指引', async () => {
const located = createFakeRunner((file) =>
file === 'which' ? { ok: true, stdout: '/usr/local/bin/moonlight\n' } : { stdout: 'moonlight 6.1.0\n' }
);
const adapter = createPlatformAdapter('darwin', { runner: located.runner });
const result = await adapter.checkDependency('moonlight');
expect(result.status).toBe('ok');
expect(result.version).toBe('6.1');
expect(result.hintUrl).toContain('moonlight');
const missing = createFakeRunner((file) =>
file === 'which' ? { ok: false, code: 'COMMAND_FAILED', exitCode: 1 } : {}
);
const missingAdapter = createPlatformAdapter('darwin', { runner: missing.runner });
expect((await missingAdapter.checkDependency('moonlight')).status).toBe('missing');
});
it('版本低于最低主版本 → outdated', async () => {
const old = createFakeRunner((file) =>
file === 'which' ? { ok: true, stdout: '/usr/local/bin/moonlight' } : { stdout: 'moonlight 3.2.0' }
);
const adapter = createPlatformAdapter('darwin', { runner: old.runner });
const result = await adapter.checkDependency('moonlight');
expect(result.status).toBe('outdated');
expect(result.version).toBe('3.2');
});
it('拿不到版本号时不误判过旧(宁可放行)', async () => {
// Windows 用 `where`;版本探测失败时不能误判过旧
const noVersion = createFakeRunner((file) =>
file === 'where' ? { ok: true, stdout: 'C:\\usbipd.exe' } : { ok: false, code: 'COMMAND_FAILED' }
);
const adapter = createPlatformAdapter('win32', { runner: noVersion.runner });
expect((await adapter.checkDependency('usbipd')).status).toBe('ok');
});
it('smb:Windows/macOS ok,Linux missing', async () => {
expect((await createPlatformAdapter('win32', { runner: createFakeRunner().runner }).checkDependency('smb')).status).toBe('ok');
expect((await createPlatformAdapter('darwin', { runner: createFakeRunner().runner }).checkDependency('smb')).status).toBe('ok');
expect((await createPlatformAdapter('linux', { runner: createFakeRunner().runner }).checkDependency('smb')).status).toBe('missing');
});
});
describe('证书目录与凭据(三端差异)', () => {
it('sunshineCredDir 在目录不存在时返回 null(不抛)', async () => {
const adapter = createPlatformAdapter('darwin', {
env: { HOME: '/nonexistent-home' },
fsLike: {
readdirSync: () => [],
statSync: () => {
throw new Error('ENOENT');
},
existsSync: () => false,
},
});
expect(await adapter.sunshineCredDir()).toBeNull();
});
it('sunshineCredDir 在目录存在时返回路径', async () => {
const adapter = createPlatformAdapter('darwin', {
env: { HOME: '/home/u' },
fsLike: {
readdirSync: () => [],
statSync: () => ({ isDirectory: () => true }),
existsSync: () => true,
},
});
expect(await adapter.sunshineCredDir()).toBe('/home/u/.config/sunshine/credentials');
});
it('moonlightCertPaths 只返回 .pem / .crt,且同目录重复项去重', async () => {
let call = 0;
const adapter = createPlatformAdapter('darwin', {
env: { HOME: '/home/u' },
fsLike: {
readdirSync: () => {
call += 1;
// 第一个候选目录里有:两个证书 + 一个无关文件 + 一个重复项
return call === 1 ? ['a.pem', 'b.crt', 'c.txt', 'a.pem'] : [];
},
statSync: () => ({ isDirectory: () => true }),
existsSync: () => true,
},
});
const paths = await adapter.moonlightCertPaths();
expect(paths).toHaveLength(2);
expect(new Set(paths).size).toBe(2);
expect(paths.every((item) => /\.(pem|crt)$/.test(item))).toBe(true);
});
it('credentialStore 在无加密存储时降级 memory(Linux)', async () => {
const adapter = createPlatformAdapter('linux', { fsLike: { readdirSync: () => [], statSync: () => ({ isDirectory: () => false }), existsSync: () => false } });
expect((await adapter.credentialStore()).kind).toBe('memory');
});
});
describe('架构守卫(代码搜索断言)', () => {
const businessModules = ['usb.ts', 'share.ts', 'discover.ts', 'main.ts'];
it('业务模块内没有 process.platform(平台判定只允许在 platform/)', () => {
for (const file of businessModules) {
const source = readSrc(file);
// 允许出现在注释里(说明「这里不判断平台」),因此按「非注释行」检查
const offenders = source
.split('\n')
.filter((line) => !line.trim().startsWith('//') && !line.trim().startsWith('*'))
.filter((line) => line.includes('process.platform'));
expect(offenders, `${file}`).toEqual([]);
}
});
it('usb.ts / share.ts / discover.ts 内没有 exec( 调用', () => {
for (const file of ['usb.ts', 'share.ts', 'discover.ts']) {
const source = readSrc(file);
expect(source.includes('exec('), file).toBe(false);
expect(source.includes("require('child_process')"), file).toBe(false);
expect(source.includes('from \'child_process\''), file).toBe(false);
}
});
it('renderer.js 内没有平台判定,也没有 innerHTML / 异常 message 拼接', () => {
const source = fs.readFileSync(path.join(SRC_DIR, 'renderer.js'), 'utf8');
// 平台判定
expect(source.includes('process.platform')).toBe(false);
expect(source.includes("=== 'win32'")).toBe(false);
expect(source.includes("=== 'darwin'")).toBe(false);
expect(source.includes("=== 'linux'")).toBe(false);
// 注入面:整份文件不得出现 HTML 字符串插入
expect(source.includes('innerHTML')).toBe(false);
// 只允许读「脱敏后的三段式对象」上的 message(`payload.message`),
// 绝不允许读原始异常上的 message(`e.message` / `error.message` / `err.message`)
const readers = [...source.matchAll(/([A-Za-z_$][\w$]*)\.message\b/g)].map((matched) => matched[1]);
expect(readers.filter((name) => name !== 'payload'), 'renderer 读到了原始异常的 message').toEqual([]);
});
it('三个业务模块源码中完全没有外部命令调用(全部经 runner)', () => {
for (const file of ['usb.ts', 'share.ts', 'discover.ts', 'connect.ts', 'certs.ts']) {
const source = readSrc(file);
// `exec(`(不论 child_process.exec 还是其它封装)一律不允许出现在业务模块
expect(/\bexec\(/.test(source), file).toBe(false);
expect(/\bexecSync\(/.test(source), file).toBe(false);
expect(source.includes('child_process'), file).toBe(false);
}
});
});
+237
View File
@@ -0,0 +1,237 @@
// Only in One - 文件共享薄分派层单测(G-21)。
//
// 覆盖:三端命令构造、共享名白名单、非 Windows 的 enableLocal 明确不支持、
// `scripts/setup-smb.ps1` 的 ValidatePattern 真的能挡住注入载荷。
import { describe, it, expect } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import {
SHARE_NAME,
disableLocalShare,
enableLocalShare,
getLocalShareState,
openSharedFolder,
} from '../src/share';
import { createPlatformAdapter } from '../src/platform/index';
import type { ExecResult, Runner } from '../src/core/runner';
import { AppError } from '../src/core/errors';
/** 仓库根 scripts 目录(vitest 的 cwd 是 launcher/)。 */
const SCRIPTS_DIR = path.resolve(process.cwd(), '..', 'scripts');
/**
* 造一个记录调用的 Runner。
* @param responder 结果覆盖
* @returns runner 与调用记录
*/
function createFakeRunner(responder: (file: string, args: string[]) => Partial<ExecResult> = () => ({})) {
const calls: { file: string; args: string[] }[] = [];
const runner: Runner = {
async execFile(file, args) {
calls.push({ file, args: [...args] });
return {
ok: true,
code: 'OK',
stdout: '',
stderrDigest: '',
exitCode: 0,
timedOut: false,
...responder(file, [...args]),
} as ExecResult;
},
async spawnDetached() {
return { ok: false, code: 'INVALID_ARGUMENT', pid: null, child: null };
},
async elevate(spec) {
calls.push({ file: spec.command, args: [...spec.args] });
return { ok: true, code: 'OK', stdout: '', stderrDigest: '', exitCode: 0, timedOut: false } as ExecResult;
},
};
return { runner, calls };
}
/** 一个存在的绝对目录(用于 enableLocal 的路径校验;不真实创建)。 */
const ABS_DIR = process.platform === 'win32' ? 'C:\\Shared' : '/tmp/oio-shared';
describe('openSharedFolder:三端命令构造', () => {
it('macOS → `open smb://host/share`(单个 argv)', async () => {
const { runner, calls } = createFakeRunner();
const adapter = createPlatformAdapter('darwin', { runner });
await openSharedFolder('192.168.1.50', 'OnlyInOne', { adapter });
expect(calls).toHaveLength(1);
expect(calls[0].file).toBe('open');
expect(calls[0].args).toEqual(['smb://192.168.1.50/OnlyInOne']);
});
it('Windows → `explorer \\\\host\\share`(单个 argv)', async () => {
const { runner, calls } = createFakeRunner();
const adapter = createPlatformAdapter('win32', { runner });
await openSharedFolder('192.168.1.50', 'OnlyInOne', { adapter });
expect(calls[0].file).toBe('explorer');
expect(calls[0].args).toEqual(['\\\\192.168.1.50\\OnlyInOne']);
});
it('Linux → UNSUPPORTED_ON_PLATFORM + 中文原因(不静默成功、不抛系统错误)', async () => {
const adapter = createPlatformAdapter('linux', { runner: createFakeRunner().runner });
try {
await openSharedFolder('192.168.1.50', 'OnlyInOne', { adapter });
expect.unreachable('应抛错');
} catch (error) {
const appError = error as AppError;
expect(appError.code).toBe('UNSUPPORTED_ON_PLATFORM');
expect(String(appError.details?.reason)).toMatch(/[\u4e00-\u9fa5]/);
}
});
it('共享名缺省为 OnlyInOne', async () => {
const { runner, calls } = createFakeRunner();
const adapter = createPlatformAdapter('darwin', { runner });
await openSharedFolder('192.168.1.50', undefined, { adapter });
expect(calls[0].args).toEqual([`smb://192.168.1.50/${SHARE_NAME}`]);
expect(SHARE_NAME).toBe('OnlyInOne');
});
});
describe('共享名白名单(防命令注入)', () => {
const payloads = ['"; rm -rf /', '$(id)', '`id`', 'a b', 'share/../../etc', 'a'.repeat(65), 'share\nname'];
it('open / enable / disable 三条路径全部拒绝', async () => {
const { runner, calls } = createFakeRunner();
const adapter = createPlatformAdapter('win32', { runner, scriptsDir: SCRIPTS_DIR });
for (const payload of payloads) {
await expect(openSharedFolder('192.168.1.50', payload, { adapter })).rejects.toMatchObject({
code: 'INVALID_ARGUMENT',
});
await expect(enableLocalShare(ABS_DIR, payload, { adapter })).rejects.toMatchObject({
code: 'INVALID_ARGUMENT',
});
await expect(disableLocalShare(payload, { adapter })).rejects.toMatchObject({ code: 'INVALID_ARGUMENT' });
}
expect(calls).toHaveLength(0);
});
it('非可路由 IP 一律拒绝(回环 / 组播 / 广播)', async () => {
const macos = createPlatformAdapter('darwin', { runner: createFakeRunner().runner });
for (const host of ['127.0.0.1', '0.0.0.0', '224.0.0.1', '192.168.1.255', 'not-an-ip']) {
await expect(openSharedFolder(host, 'OnlyInOne', { adapter: macos })).rejects.toMatchObject({
code: 'INVALID_ARGUMENT',
});
}
});
});
describe('enableLocalShare / disableLocalShare(本机共享开关)', () => {
it('Windows → 调用 setup-smb.ps1,参数数组含 -File / -ShareName / -Path', async () => {
const { runner, calls } = createFakeRunner();
const adapter = createPlatformAdapter('win32', { runner, scriptsDir: SCRIPTS_DIR, hostName: 'windesk' });
const result = await enableLocalShare(ABS_DIR, 'OnlyInOne', { adapter });
expect(result.smbUrl).toBe('smb://windesk/OnlyInOne');
expect(calls[0].file).toBe('powershell');
expect(calls[0].args).toContain('-File');
expect(calls[0].args).toContain('-ShareName');
expect(calls[0].args).toContain('OnlyInOne');
expect(calls[0].args).toContain('-Path');
expect(calls[0].args).toContain(ABS_DIR);
expect(calls[0].args).toContain('-NoProfile');
// 脚本路径必须是绝对路径且以 setup-smb.ps1 结尾
const scriptIndex = calls[0].args.indexOf('-File') + 1;
expect(path.isAbsolute(calls[0].args[scriptIndex])).toBe(true);
expect(calls[0].args[scriptIndex].endsWith(path.join('scripts', 'setup-smb.ps1'))).toBe(true);
});
it('Windows → disableLocalShare 用 Remove-SmbShare 且共享名经单引号转义', async () => {
const { runner, calls } = createFakeRunner();
const adapter = createPlatformAdapter('win32', { runner, scriptsDir: SCRIPTS_DIR });
await disableLocalShare('OnlyInOne', { adapter });
const command = calls[0].args[calls[0].args.length - 1];
expect(calls[0].args).toContain('-Command');
expect(command).toContain('Remove-SmbShare');
expect(command).toContain("-Name 'OnlyInOne'");
});
it('macOS / Linux → 明确不支持 + 引导文案(绝不静默成功)', async () => {
const macos = createPlatformAdapter('darwin', { runner: createFakeRunner().runner });
const linux = createPlatformAdapter('linux', { runner: createFakeRunner().runner });
for (const adapter of [macos, linux]) {
try {
await enableLocalShare(ABS_DIR, 'OnlyInOne', { adapter });
expect.unreachable('应抛错');
} catch (error) {
const appError = error as AppError;
expect(appError.code).toBe('UNSUPPORTED_ON_PLATFORM');
expect(String(appError.details?.reason)).toMatch(/[\u4e00-\u9fa5]/);
}
await expect(disableLocalShare('OnlyInOne', { adapter })).rejects.toMatchObject({
code: 'UNSUPPORTED_ON_PLATFORM',
});
}
});
it('相对路径 / 含控制字符的目录被拒绝', async () => {
const adapter = createPlatformAdapter('win32', { runner: createFakeRunner().runner, scriptsDir: SCRIPTS_DIR });
await expect(enableLocalShare('relative/dir', 'OnlyInOne', { adapter })).rejects.toMatchObject({
code: 'INVALID_ARGUMENT',
});
await expect(enableLocalShare(`${ABS_DIR}\u0000x`, 'OnlyInOne', { adapter })).rejects.toMatchObject({
code: 'INVALID_ARGUMENT',
});
});
it('找不到 setup-smb.ps1 时返回 DEPENDENCY_MISSING(而不是崩)', async () => {
const adapter = createPlatformAdapter('win32', {
runner: createFakeRunner().runner,
scriptsDir: null,
});
await expect(enableLocalShare(ABS_DIR, 'OnlyInOne', { adapter })).rejects.toMatchObject({
code: 'DEPENDENCY_MISSING',
});
});
it('localState:Windows 解析 Get-SmbShare 路径,其余平台返回未开启', async () => {
const { runner } = createFakeRunner((file, args) =>
args.includes('-Command') ? { stdout: 'D:\\Shared\r\n' } : {}
);
const windows = createPlatformAdapter('win32', { runner, hostName: 'windesk' });
expect(await getLocalShareState({ adapter: windows })).toEqual({
enabled: true,
dir: 'D:\\Shared',
smbUrl: 'smb://windesk/OnlyInOne',
});
const macos = createPlatformAdapter('darwin', { runner: createFakeRunner().runner });
expect(await getLocalShareState({ adapter: macos })).toEqual({ enabled: false, dir: null, smbUrl: null });
});
});
describe('scripts/setup-smb.ps1 的注入防护', () => {
const script = fs.readFileSync(path.join(SCRIPTS_DIR, 'setup-smb.ps1'), 'utf8');
it('共享名参数带 ValidatePattern 白名单', () => {
expect(script).toContain('ValidatePattern');
const matched = /ValidatePattern\('([^']+)'\)/.exec(script);
expect(matched).not.toBeNull();
// 脚本里的正则必须与 launcher 的 isShareName 同源
expect(matched?.[1]).toBe('^[A-Za-z0-9_$-]{1,64}$');
});
it('ValidatePattern 正则能拒绝注入载荷、放行合法名', () => {
const source = /ValidatePattern\('([^']+)'\)/.exec(script)?.[1] as string;
const pattern = new RegExp(source);
for (const payload of ['"; rm -rf /', '$(id)', 'a b', 'x/y', 'a\nb', '']) {
expect(pattern.test(payload), payload).toBe(false);
}
for (const ok of ['OnlyInOne', 'share_1', 'team-$share']) {
expect(pattern.test(ok), ok).toBe(true);
}
});
it('路径参数做绝对路径与控制字符校验,且强制 SMB 加密', () => {
expect(script).toContain('Test-Path');
expect(script).toContain('-EncryptData $true');
expect(script).toMatch(/\[\\x00-\\x1F\]/);
});
});
+237
View File
@@ -0,0 +1,237 @@
// Only in One - USB 薄分派层单测(G-50 / R-P1-08)。
//
// 覆盖:`parseUsbPort` 3 种真实输出、`usbipd list` 解析、平台不支持时的错误码、
// busid 注入载荷拦截、三端命令参数数组(无 shell 拼接)。
import { describe, it, expect } from 'vitest';
import {
attachUsb,
detachUsb,
listAttachedUsb,
listUsbDevices,
parseUsbPort,
shareUsb,
unshareUsb,
} from '../src/usb';
import { createPlatformAdapter } from '../src/platform/index';
import { parseUsbipdList } from '../src/platform/shared';
import type { ExecResult, Runner } from '../src/core/runner';
import { AppError } from '../src/core/errors';
/** `usbip port` 真实输出:有 1 个已挂载设备。 */
const USBIP_PORT_FULL = `Imported USB devices
====================
Port 00: <Port in Use> at Full Speed(12Mbps)
FTDI FT232R USB UART (0403:6001)
4-1 -> usbip://192.168.1.50:3240/1-2
`;
/** `usbip port` 真实输出:无设备(只有表头)。 */
const USBIP_PORT_EMPTY = `Imported USB devices
====================
`;
/** `usbip port` 含噪声(端口占用状态、无 URI 行)。 */
const USBIP_PORT_NOISY = `Imported USB devices
====================
Port 01: <Port in Use> at High Speed(480Mbps)
Unknown vendor (dead:beef)
Port 02: <Port in Use> at Full Speed(12Mbps)
Generic Mass Storage (1234:5678)
2-3 -> usbip://10.0.0.9:3240/2-3
`;
/** `usbipd list` 真实输出。 */
const USBIPD_LIST = `BUSID VID:PID DEVICE STATE
1-2 0403:6001 FTDI FT232R USB UART Not shared
1-4 0781:5567 SanDisk Cruzer Blade Shared
`;
/**
* 造一个记录调用的 Runner。
* @param responder 结果覆盖
* @returns runner 与调用记录
*/
function createFakeRunner(responder: (file: string, args: string[]) => Partial<ExecResult> = () => ({})) {
const calls: { file: string; args: string[]; elevate: boolean }[] = [];
const runner: Runner = {
async execFile(file, args) {
calls.push({ file, args: [...args], elevate: false });
return {
ok: true,
code: 'OK',
stdout: '',
stderrDigest: '',
exitCode: 0,
timedOut: false,
...responder(file, [...args]),
} as ExecResult;
},
async spawnDetached() {
return { ok: false, code: 'INVALID_ARGUMENT', pid: null, child: null };
},
async elevate(spec) {
calls.push({ file: spec.command, args: [...spec.args], elevate: true });
return {
ok: true,
code: 'OK',
stdout: '',
stderrDigest: '',
exitCode: 0,
timedOut: false,
...responder(spec.command, [...spec.args]),
} as ExecResult;
},
};
return { runner, calls };
}
describe('parseUsbPort(3 种真实输出)', () => {
it('有 1 个已挂载设备 → 解析出端口 / 主机 / busid', () => {
expect(parseUsbPort(USBIP_PORT_FULL)).toEqual([
{ port: '00', host: '192.168.1.50', busid: '1-2', state: 'attached' },
]);
});
it('空输出(无设备)→ 空数组', () => {
expect(parseUsbPort(USBIP_PORT_EMPTY)).toEqual([]);
expect(parseUsbPort('')).toEqual([]);
expect(parseUsbPort(undefined)).toEqual([]);
});
it('含 `Port in Use` 噪声 → 只取真正带 usbip:// 的那条', () => {
const attached = parseUsbPort(USBIP_PORT_NOISY);
expect(attached).toHaveLength(1);
expect(attached[0]).toEqual({ port: '02', host: '10.0.0.9', busid: '2-3', state: 'attached' });
});
});
describe('parseUsbipdList', () => {
it('解析 BUSID / 设备名 / 共享状态', () => {
const devices = parseUsbipdList(USBIPD_LIST);
expect(devices).toHaveLength(2);
expect(devices[0].busid).toBe('1-2');
expect(devices[0].name).toContain('FTDI FT232R');
expect(devices[0].state).toBe('not-shared');
expect(devices[1].state).toBe('shared');
});
it('空输出 / 只有表头 → 空数组', () => {
expect(parseUsbipdList('')).toEqual([]);
expect(parseUsbipdList('BUSID VID:PID DEVICE STATE\n')).toEqual([]);
});
});
describe('平台不支持时统一返回 UNSUPPORTED_ON_PLATFORM', () => {
const linux = createPlatformAdapter('linux');
it('listUsbDevices / listAttachedUsb 都抛同一个错误码', async () => {
await expect(listUsbDevices({ adapter: linux })).rejects.toMatchObject({ code: 'UNSUPPORTED_ON_PLATFORM' });
await expect(listAttachedUsb({ adapter: linux })).rejects.toMatchObject({ code: 'UNSUPPORTED_ON_PLATFORM' });
});
it('Windows 上 attach(导入方向)→ UNSUPPORTED_ON_PLATFORM(Q-9)', async () => {
const windows = createPlatformAdapter('win32', { runner: createFakeRunner().runner });
await expect(attachUsb('192.168.1.50', '1-2', { adapter: windows })).rejects.toMatchObject({
code: 'UNSUPPORTED_ON_PLATFORM',
});
});
it('macOS 上 share(导出方向)→ UNSUPPORTED_ON_PLATFORM', async () => {
const macos = createPlatformAdapter('darwin', { runner: createFakeRunner().runner });
await expect(shareUsb('1-2', { adapter: macos })).rejects.toMatchObject({ code: 'UNSUPPORTED_ON_PLATFORM' });
});
});
describe('busid / host 注入载荷拦截', () => {
const payloads = ['1-2; rm -rf /', '$(id)', '../etc/passwd', '1-2 && cat /etc/passwd', '', '1-2\n1-3', 'a'.repeat(40)];
it('share / unshare / attach / detach 全部在分派前拒绝', async () => {
const { runner, calls } = createFakeRunner();
const windows = createPlatformAdapter('win32', { runner });
const macos = createPlatformAdapter('darwin', { runner });
for (const payload of payloads) {
await expect(shareUsb(payload, { adapter: windows })).rejects.toMatchObject({ code: 'INVALID_ARGUMENT' });
await expect(unshareUsb(payload, { adapter: windows })).rejects.toMatchObject({ code: 'INVALID_ARGUMENT' });
await expect(detachUsb(payload, { adapter: macos })).rejects.toMatchObject({ code: 'INVALID_ARGUMENT' });
}
for (const payload of payloads) {
await expect(attachUsb(payload, '1-2', { adapter: macos })).rejects.toMatchObject({ code: 'INVALID_ARGUMENT' });
await expect(attachUsb('192.168.1.50', payload, { adapter: macos })).rejects.toMatchObject({
code: 'INVALID_ARGUMENT',
});
}
// 一个都不能触达命令行
expect(calls).toHaveLength(0);
});
it('错误体带 field,便于 UI 定位输入框', async () => {
const windows = createPlatformAdapter('win32', { runner: createFakeRunner().runner });
try {
await shareUsb('1-2;id', { adapter: windows });
expect.unreachable('应抛错');
} catch (error) {
expect((error as AppError).details?.field).toBe('busid');
}
});
});
describe('三端命令构造(参数数组,无 shell 拼接)', () => {
it('Windows:`usbipd list` / `bind` / `unbind` 都是独立 argv', async () => {
const { runner, calls } = createFakeRunner((file) =>
file === 'usbipd' ? { stdout: USBIPD_LIST } : {}
);
const windows = createPlatformAdapter('win32', { runner });
const devices = await listUsbDevices({ adapter: windows });
expect(devices).toHaveLength(2);
await shareUsb('1-4', { adapter: windows });
await unshareUsb('1-4', { adapter: windows });
expect(calls[0]).toMatchObject({ file: 'usbipd', args: ['list'] });
expect(calls[1]).toMatchObject({ file: 'usbipd', args: ['bind', '--busid', '1-4'] });
expect(calls[2]).toMatchObject({ file: 'usbipd', args: ['unbind', '--busid', '1-4'] });
// 关键断言:没有任何一次调用把多个参数拼进单个字符串
for (const call of calls) {
expect(call.args.every((arg) => typeof arg === 'string')).toBe(true);
expect(call.args.some((arg) => arg.includes(' '))).toBe(false);
}
});
it('macOS:attach / detach 走 elevate(提权),参数数组正确', async () => {
const { runner, calls } = createFakeRunner();
const macos = createPlatformAdapter('darwin', { runner });
await attachUsb('192.168.1.50', '1-2', { adapter: macos });
await detachUsb('00', { adapter: macos });
expect(calls[0]).toMatchObject({ file: 'usbip', args: ['attach', '-r', '192.168.1.50', '-b', '1-2'], elevate: true });
expect(calls[1]).toMatchObject({ file: 'usbip', args: ['detach', '-p', '00'], elevate: true });
});
it('macOS:listAttached 解析 usbip port 输出', async () => {
const { runner } = createFakeRunner((file) => (file === 'usbip' ? { stdout: USBIP_PORT_NOISY } : {}));
const macos = createPlatformAdapter('darwin', { runner });
const attached = await listAttachedUsb({ adapter: macos });
expect(attached).toEqual([{ port: '02', host: '10.0.0.9', busid: '2-3', state: 'attached' }]);
});
it('macOS:usbip 不可用时 listAttached 返回空数组(不抛)', async () => {
const { runner } = createFakeRunner(() => ({ ok: false, code: 'DEPENDENCY_MISSING' }));
const macos = createPlatformAdapter('darwin', { runner });
expect(await listAttachedUsb({ adapter: macos })).toEqual([]);
});
it('命令失败时错误码透传(COMMAND_FAILED / DEPENDENCY_MISSING)', async () => {
const { runner } = createFakeRunner(() => ({ ok: false, code: 'COMMAND_FAILED', exitCode: 1 }));
const windows = createPlatformAdapter('win32', { runner });
await expect(shareUsb('1-2', { adapter: windows })).rejects.toMatchObject({ code: 'COMMAND_FAILED' });
const missing = createFakeRunner(() => ({ ok: false, code: 'DEPENDENCY_MISSING' }));
const windows2 = createPlatformAdapter('win32', { runner: missing.runner });
await expect(listUsbDevices({ adapter: windows2 })).rejects.toMatchObject({ code: 'DEPENDENCY_MISSING' });
});
});
+48
View File
@@ -0,0 +1,48 @@
// Only in One - vitest 配置(含覆盖率门禁,R-P1-23)。
//
// 门禁口径(架构 §9.6):
// · 全局行覆盖 ≥60%;
// · 核心四模块(connect / discover / usb / agent)行覆盖 ≥70%;
// · `main.ts` / `preload.ts` 依赖 Electron 运行时,vitest 里无法导入,排除统计
// (它们的逻辑已抽到 core/* 与 agent/connect 等可测模块里)。
//
// 低于阈值时 `npm run test:coverage` 直接失败 —— 这是 CI 的硬门禁,
// 不是「打印一下覆盖率」的装饰。
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['test/**/*.test.ts'],
// 快照/定时器类用例集中在 agent / discover,默认超时足够;
// logger 的轮转用例会做真实文件 IO,给 20s 余量。
testTimeout: 20_000,
coverage: {
provider: 'v8',
reporter: ['text', 'text-summary', 'json-summary', 'lcov'],
include: ['src/**/*.ts'],
exclude: [
// 依赖 electron 顶层 import,无法在 vitest 中加载
'src/main.ts',
'src/preload.ts',
// 纯类型声明,无运行时代码
'src/platform/types.ts',
],
thresholds: {
// 全局门槛
lines: 60,
statements: 60,
// 核心四模块门槛(架构 §9.6 明确点名)
'src/connect.ts': { lines: 70 },
'src/discover.ts': { lines: 70 },
'src/usb.ts': { lines: 70 },
'src/agent.ts': { lines: 70 },
// T-3 新增的三端实现(G-19 / G-21 的核心)
'src/share.ts': { lines: 70 },
'src/platform/index.ts': { lines: 70 },
'src/platform/windows.ts': { lines: 70 },
'src/platform/macos.ts': { lines: 70 },
'src/platform/linux.ts': { lines: 70 },
},
},
},
});
+44 -5
View File
@@ -1,10 +1,49 @@
# Only in One - create an encrypted SMB share on Windows. Corresponding task: T3.5.
# Only in One - create an encrypted SMB share on Windows.
#
# 安全要点(G-21 / 安全红线第 2 条):
# · `ShareName` 走 **ValidatePattern 白名单**(与 launcher 的 `isShareName` 同一份定义:
# `^[A-Za-z0-9_$-]{1,64}$`),任何 `" ; rm -rf /` 之类载荷在**参数绑定阶段**就被拒绝,
# 根本进不到命令解析;
# · `Path` 必须是绝对路径且不含控制字符;
# · `New-SmbShare -EncryptData $true` 强制 SMB 加密(团队内网也要加密)。
#
# 调用方式(launcher 用参数数组调用,不经过 shell):
# powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass `
# -File scripts/setup-smb.ps1 -ShareName OnlyInOne -Path D:\Shared
[CmdletBinding()]
param(
[ValidatePattern('^[A-Za-z0-9_$-]{1,64}$')]
[string]$ShareName = 'OnlyInOne',
[string]$Path = 'D:\Shared'
)
New-Item -ItemType Directory -Force -Path $Path | Out-Null
$ErrorActionPreference = 'Stop'
# 路径校验:必须绝对路径,且不含控制字符
if ([string]::IsNullOrWhiteSpace($Path)) {
throw 'Path 不能为空'
}
if ($Path -notmatch '^[A-Za-z]:\\' -and $Path -notmatch '^\\\\') {
throw "Path 必须是绝对路径(如 D:\Shared 或 \\server\share),收到:$Path"
}
if ($Path -match '[\x00-\x1F]') {
throw 'Path 不得包含控制字符'
}
if (-not (Test-Path -LiteralPath $Path)) {
New-Item -ItemType Directory -Force -Path $Path | Out-Null
}
$user = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name
New-SmbShare -Name $ShareName -Path $Path -FullAccess $user -EncryptData $true
Write-Host "SMB share '$ShareName' -> $Path created (encrypted, FullAccess=$user)."
Write-Host "macOS side opens: smb://<this-host>/$ShareName"
$existing = Get-SmbShare -Name $ShareName -ErrorAction SilentlyContinue
if ($null -ne $existing) {
Write-Host "SMB share '$ShareName' 已存在,跳过创建(如需重建请先 Remove-SmbShare -Name '$ShareName' -Force)"
}
else {
New-SmbShare -Name $ShareName -Path $Path -FullAccess $user -EncryptData $true | Out-Null
Write-Host "SMB share '$ShareName' -> $Path created (encrypted, FullAccess=$user)."
}
# 输出 smb:// 地址供 UI 复制(host 由 launcher 侧拼装)
Write-Host "smb://<this-host>/$ShareName"
+1 -1
View File
@@ -10,7 +10,7 @@
"start": "node --env-file-if-exists=.env server.js",
"dev": "node --env-file-if-exists=.env --watch server.js",
"test": "node --test test/*.test.mjs",
"test:coverage": "node --test --experimental-test-coverage test/*.test.mjs",
"test:coverage": "node --test --experimental-test-coverage --test-coverage-lines=60 test/*.test.mjs",
"smoke": "node smoke.mjs"
},
"dependencies": {