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:
1 parent
c5dfa4a948
commit
531991954c
27 files changed
+5734
-433
No files matched your search
@@ -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
|
||||
@@ -3,6 +3,7 @@ node_modules/
|
||||
|
||||
# ---- 构建产物 ----
|
||||
**/dist/
|
||||
**/coverage/
|
||||
launcher/release/
|
||||
|
||||
# ---- 运行期数据(含密码哈希,绝不入库) ----
|
||||
|
||||
Generated
+2141
File diff suppressed because it is too large.
Load diff
@@ -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,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 内就退出说明启动失败。
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 };
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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'),
|
||||
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
}
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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' } });
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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\]/);
|
||||
});
|
||||
});
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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": {
|
||||
|
||||
Reference in new issue
Block a user