first commit

This commit is contained in:
douboer
2026-03-21 18:57:10 +08:00
commit c49aa1a5e9
570 changed files with 107167 additions and 0 deletions

View File

@@ -0,0 +1,19 @@
{
"name": "@remoteconn/plugin-runtime",
"version": "1.0.0",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit",
"lint": "eslint src --ext .ts",
"test": "vitest run"
},
"dependencies": {
"@remoteconn/shared": "1.0.0"
}
}

View File

@@ -0,0 +1,311 @@
import type { PluginHostApis, PluginPackage, PluginRecord, PluginFsAdapter, PluginCommand } from "../types/plugin";
import { validatePluginPackage } from "./validator";
interface RuntimeSlot {
pluginId: string;
cleanupFns: Array<() => void>;
styleDisposer?: () => void;
api?: {
onload?: (ctx: unknown) => Promise<void> | void;
onunload?: () => Promise<void> | void;
};
}
/**
* 生产可用插件管理器:
* 1. 安装校验
* 2. 生命周期
* 3. 单插件熔断
* 4. 权限最小化
*/
export class PluginManager {
private readonly records = new Map<string, PluginRecord>();
private readonly runtime = new Map<string, RuntimeSlot>();
private readonly commands = new Map<string, PluginCommand>();
public constructor(
private readonly fs: PluginFsAdapter,
private readonly apis: PluginHostApis,
private readonly options: {
appVersion: string;
mountStyle: (pluginId: string, css: string) => () => void;
logger: (level: "info" | "warn" | "error", pluginId: string, message: string) => void;
}
) {}
public async bootstrap(): Promise<void> {
const stored = await this.fs.readStore<PluginRecord[]>("plugin_records", []);
for (const record of stored) {
this.records.set(record.id, record);
}
const packages = await this.fs.listPackages();
for (const item of packages) {
if (!this.records.has(item.manifest.id)) {
this.records.set(item.manifest.id, this.createRecord(item.manifest.id));
}
}
await this.persistRecords();
for (const record of this.records.values()) {
if (record.enabled) {
try {
await this.enable(record.id);
} catch (error) {
this.options.logger("error", record.id, `自动启用失败: ${(error as Error).message}`);
}
}
}
}
public async installPackage(payload: PluginPackage): Promise<void> {
const valid = validatePluginPackage(payload);
await this.fs.upsertPackage(valid);
const record = this.records.get(valid.manifest.id) ?? this.createRecord(valid.manifest.id);
record.status = "validated";
record.updatedAt = new Date().toISOString();
record.lastError = "";
this.records.set(valid.manifest.id, record);
await this.persistRecords();
}
public async enable(pluginId: string): Promise<void> {
const pluginPackage = await this.fs.getPackage(pluginId);
if (!pluginPackage) {
throw new Error("插件不存在");
}
const validPackage = validatePluginPackage(pluginPackage);
const record = this.records.get(pluginId) ?? this.createRecord(pluginId);
if (record.errorCount >= 3) {
throw new Error("插件已熔断,请先重载后再启用");
}
if (this.runtime.has(pluginId)) {
await this.disable(pluginId);
}
record.status = "loading";
record.lastError = "";
this.records.set(pluginId, record);
await this.persistRecords();
const runtime: RuntimeSlot = {
pluginId,
cleanupFns: []
};
try {
runtime.styleDisposer = this.options.mountStyle(pluginId, validPackage.stylesCss);
const ctx = this.createContext(validPackage.manifest, runtime);
runtime.api = this.loadPluginApi(validPackage.mainJs, ctx);
if (runtime.api?.onload) {
await this.runWithTimeout(Promise.resolve(runtime.api.onload(ctx)), 3000, "onload 超时");
}
record.enabled = true;
record.status = "active";
record.lastLoadedAt = new Date().toISOString();
this.runtime.set(pluginId, runtime);
await this.persistRecords();
this.options.logger("info", pluginId, "插件已启用");
} catch (error) {
runtime.styleDisposer?.();
record.enabled = false;
record.status = "failed";
record.errorCount += 1;
record.lastError = (error as Error).message;
this.records.set(pluginId, record);
await this.persistRecords();
this.options.logger("error", pluginId, `启用失败: ${(error as Error).message}`);
throw error;
}
}
public async disable(pluginId: string): Promise<void> {
const runtime = this.runtime.get(pluginId);
const record = this.records.get(pluginId) ?? this.createRecord(pluginId);
if (runtime) {
try {
if (runtime.api?.onunload) {
await this.runWithTimeout(Promise.resolve(runtime.api.onunload()), 3000, "onunload 超时");
}
} catch (error) {
this.options.logger("warn", pluginId, `onunload 异常: ${(error as Error).message}`);
}
for (const cleanup of runtime.cleanupFns) {
cleanup();
}
runtime.styleDisposer?.();
this.runtime.delete(pluginId);
}
for (const id of Array.from(this.commands.keys())) {
if (id.startsWith(`${pluginId}:`)) {
this.commands.delete(id);
}
}
record.enabled = false;
if (record.status !== "failed") {
record.status = "stopped";
}
this.records.set(pluginId, record);
await this.persistRecords();
}
public async reload(pluginId: string): Promise<void> {
const record = this.records.get(pluginId) ?? this.createRecord(pluginId);
record.errorCount = 0;
record.lastError = "";
this.records.set(pluginId, record);
await this.persistRecords();
await this.disable(pluginId);
await this.enable(pluginId);
}
public async remove(pluginId: string): Promise<void> {
await this.disable(pluginId);
await this.fs.removePackage(pluginId);
this.records.delete(pluginId);
await this.persistRecords();
}
public listRecords(): PluginRecord[] {
return Array.from(this.records.values());
}
public listCommands(sessionState: "connected" | "disconnected"): PluginCommand[] {
return Array.from(this.commands.values()).filter((command) => {
if (command.when === "connected") {
return sessionState === "connected";
}
return true;
});
}
public async runCommand(commandId: string): Promise<void> {
const command = this.commands.get(commandId);
if (!command) {
throw new Error("命令不存在");
}
await Promise.resolve(command.handler());
}
private createContext(manifest: PluginPackage["manifest"], runtime: RuntimeSlot): unknown {
const assertPermission = (permission: string): void => {
if (!manifest.permissions.includes(permission as never)) {
throw new Error(`权限不足: ${permission}`);
}
};
return {
app: this.apis.getAppMeta(),
commands: {
register: (command: PluginCommand) => {
assertPermission("commands.register");
const id = `${manifest.id}:${command.id}`;
this.commands.set(id, {
...command,
id
});
runtime.cleanupFns.push(() => this.commands.delete(id));
}
},
session: {
send: async (input: string) => {
assertPermission("session.write");
await this.apis.session.send(input);
},
on: (eventName: "connected" | "disconnected" | "stdout" | "stderr" | "latency", handler: (payload: unknown) => void) => {
assertPermission("session.read");
const off = this.apis.session.on(eventName, handler);
runtime.cleanupFns.push(off);
return off;
}
},
storage: {
get: async (key: string) => {
assertPermission("storage.read");
const value = await this.fs.readStore<Record<string, unknown>>(`plugin_data_${manifest.id}`, {});
return value[key];
},
set: async (key: string, value: unknown) => {
assertPermission("storage.write");
const previous = await this.fs.readStore<Record<string, unknown>>(`plugin_data_${manifest.id}`, {});
previous[key] = value;
await this.fs.writeStore(`plugin_data_${manifest.id}`, previous);
}
},
ui: {
showNotice: (message: string, level: "info" | "warn" | "error" = "info") => {
assertPermission("ui.notice");
this.apis.showNotice(message, level);
}
},
logger: {
info: (...args: string[]) => this.options.logger("info", manifest.id, args.join(" ")),
warn: (...args: string[]) => this.options.logger("warn", manifest.id, args.join(" ")),
error: (...args: string[]) => this.options.logger("error", manifest.id, args.join(" "))
}
};
}
private loadPluginApi(mainJs: string, ctx: unknown): RuntimeSlot["api"] {
const module = { exports: {} as RuntimeSlot["api"] };
const exportsRef = module.exports;
const fn = new Function(
"ctx",
"module",
"exports",
`"use strict";
const window = undefined;
const document = undefined;
const localStorage = undefined;
const globalThis = undefined;
${mainJs}
return module.exports;`
);
return (fn(ctx, module, exportsRef) as RuntimeSlot["api"]) ?? module.exports;
}
private async runWithTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> {
let timer: NodeJS.Timeout | undefined;
const timeout = new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error(message)), timeoutMs);
});
try {
return await Promise.race([promise, timeout]);
} finally {
if (timer) {
clearTimeout(timer);
}
}
}
private createRecord(id: string): PluginRecord {
const now = new Date().toISOString();
return {
id,
enabled: false,
status: "discovered",
errorCount: 0,
lastError: "",
installedAt: now,
updatedAt: now,
lastLoadedAt: ""
};
}
private async persistRecords(): Promise<void> {
await this.fs.writeStore("plugin_records", Array.from(this.records.values()));
}
}

View File

@@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import { validatePluginPackage } from "./validator";
describe("plugin validator", () => {
it("通过最小合法插件", () => {
expect(() =>
validatePluginPackage({
manifest: {
id: "codex-shortcuts",
name: "Codex Shortcuts",
version: "0.1.0",
minAppVersion: "0.1.0",
description: "test",
entry: "main.js",
style: "styles.css",
permissions: ["commands.register"]
},
mainJs: "module.exports = {};",
stylesCss: ".x { color: red; }"
})
).not.toThrow();
});
});

View File

@@ -0,0 +1,55 @@
import { PERMISSION_WHITELIST, type PluginPackage } from "../types/plugin";
/**
* 插件包静态校验。
*/
export function validatePluginPackage(pluginPackage: PluginPackage): PluginPackage {
const manifest = pluginPackage?.manifest;
if (!manifest) {
throw new Error("缺少 manifest");
}
const required = ["id", "name", "version", "minAppVersion", "description", "entry", "style", "permissions"];
for (const key of required) {
if (!(key in manifest)) {
throw new Error(`manifest 缺少字段: ${key}`);
}
}
if (!/^[a-z0-9][a-z0-9-]{1,62}$/.test(manifest.id)) {
throw new Error("插件 id 不符合规范");
}
if (!/^\d+\.\d+\.\d+$/.test(manifest.version)) {
throw new Error("version 必须是 SemVer例如 0.1.0");
}
if (!/^\d+\.\d+\.\d+$/.test(manifest.minAppVersion)) {
throw new Error("minAppVersion 必须是 SemVer");
}
if (manifest.entry !== "main.js" || manifest.style !== "styles.css") {
throw new Error("entry/style 目前固定为 main.js / styles.css");
}
const allowed = new Set(PERMISSION_WHITELIST);
for (const permission of manifest.permissions || []) {
if (!allowed.has(permission)) {
throw new Error(`未知权限: ${permission}`);
}
}
if (!pluginPackage.mainJs?.trim()) {
throw new Error("mainJs 不能为空");
}
if (pluginPackage.stylesCss == null) {
throw new Error("stylesCss 不能为空");
}
if (/^\s*(\*|body|html)\s*[{,]/m.test(pluginPackage.stylesCss)) {
throw new Error("styles.css 禁止全局选择器(* / body / html");
}
return pluginPackage;
}

View File

@@ -0,0 +1,3 @@
export * from "./types/plugin";
export * from "./core/pluginManager";
export * from "./core/validator";

View File

@@ -0,0 +1,69 @@
export const PERMISSION_WHITELIST = [
"commands.register",
"session.read",
"session.write",
"ui.notice",
"ui.statusbar",
"storage.read",
"storage.write",
"logs.read"
] as const;
export type PluginPermission = (typeof PERMISSION_WHITELIST)[number];
export interface PluginManifest {
id: string;
name: string;
version: string;
minAppVersion: string;
description: string;
entry: "main.js";
style: "styles.css";
permissions: PluginPermission[];
author?: string;
homepage?: string;
}
export interface PluginPackage {
manifest: PluginManifest;
mainJs: string;
stylesCss: string;
}
export interface PluginRecord {
id: string;
enabled: boolean;
status: "discovered" | "validated" | "loading" | "active" | "stopped" | "failed";
errorCount: number;
lastError?: string;
installedAt: string;
updatedAt: string;
lastLoadedAt?: string;
}
export interface PluginCommand {
id: string;
title: string;
when?: "always" | "connected";
handler: () => Promise<void> | void;
}
export interface PluginFsAdapter {
listPackages(): Promise<PluginPackage[]>;
getPackage(pluginId: string): Promise<PluginPackage | null>;
upsertPackage(pluginPackage: PluginPackage): Promise<void>;
removePackage(pluginId: string): Promise<void>;
readStore<T>(key: string, fallback: T): Promise<T>;
writeStore<T>(key: string, value: T): Promise<void>;
}
export interface PluginSessionApi {
send(input: string): Promise<void>;
on(eventName: "connected" | "disconnected" | "stdout" | "stderr" | "latency", handler: (payload: unknown) => void): () => void;
}
export interface PluginHostApis {
getAppMeta(): { version: string; platform: "web" | "ios" | "miniapp" };
session: PluginSessionApi;
showNotice(message: string, level: "info" | "warn" | "error"): void;
}

View File

@@ -0,0 +1,12 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"baseUrl": ".",
"paths": {
"@remoteconn/shared": ["../shared/src/index.ts"]
}
},
"include": ["src/**/*.ts"]
}

View File

@@ -0,0 +1,16 @@
{
"name": "@remoteconn/shared",
"version": "1.0.0",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit",
"lint": "eslint src --ext .ts",
"test": "vitest run"
}
}

View File

@@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest";
import { buildCdCommand, buildCodexPlan } from "./orchestrator";
describe("buildCdCommand", () => {
it("`~` 应展开为 HOME", () => {
expect(buildCdCommand("~")).toBe('cd "$HOME"');
});
it("`~/...` 应保留 HOME 前缀并安全引用余下路径", () => {
expect(buildCdCommand("~/workspace/remoteconn")).toBe("cd \"$HOME\"/'workspace/remoteconn'");
});
it("普通绝对路径应保持单引号安全转义", () => {
expect(buildCdCommand("/var/www/my app")).toBe("cd '/var/www/my app'");
});
});
describe("buildCodexPlan", () => {
it("首条命令应使用 cd 计划且包含 sandbox 启动命令", () => {
const plan = buildCodexPlan({
projectPath: "~/workspace/remoteconn",
sandbox: "workspace-write"
});
expect(plan).toHaveLength(3);
const cdStep = plan[0];
const checkStep = plan[1];
const runStep = plan[2];
expect(cdStep).toBeDefined();
expect(checkStep).toBeDefined();
expect(runStep).toBeDefined();
if (!cdStep || !checkStep || !runStep) {
throw new Error("Codex 计划步骤缺失");
}
expect(cdStep).toEqual({
step: "cd",
command: "cd \"$HOME\"/'workspace/remoteconn'",
markerType: "cd"
});
expect(checkStep.command).toBe("command -v codex");
expect(runStep.command).toBe("codex --sandbox workspace-write");
});
it("resumeLast=true 时应改为恢复最近一次会话", () => {
const plan = buildCodexPlan({
projectPath: "~/workspace/remoteconn",
sandbox: "danger-full-access",
resumeLast: true
});
expect(plan[2]?.command).toBe("codex resume --last --sandbox danger-full-access");
});
});

View File

@@ -0,0 +1,72 @@
/**
* Codex 模式编排命令。
*/
export interface CodexCommandPlan {
step: "cd" | "check" | "run";
command: string;
markerType: "cd" | "check" | "run";
}
export interface CodexRunOptions {
projectPath: string;
sandbox: "read-only" | "workspace-write" | "danger-full-access";
/**
* 是否改为恢复最近一次 Codex 会话:
* - `true` 时生成 `codex resume --last --sandbox ...`
* - `false/undefined` 时沿用全新启动命令。
*/
resumeLast?: boolean;
}
/**
* 对路径做最小安全转义,防止单引号截断。
*/
export function shellQuote(value: string): string {
return `'${String(value).replace(/'/g, "'\\''")}'`;
}
/**
* 构造 `cd` 命令:
* - `~` 与 `~/...` 需要保留 HOME 展开语义,不能整体单引号包裹;
* - 其他路径走单引号最小转义,避免空格/特殊字符破坏命令。
*/
export function buildCdCommand(projectPath: string): string {
const normalized = String(projectPath || "~").trim() || "~";
if (normalized === "~") {
return 'cd "$HOME"';
}
if (normalized.startsWith("~/")) {
const relative = normalized.slice(2);
return relative ? `cd "$HOME"/${shellQuote(relative)}` : 'cd "$HOME"';
}
return `cd ${shellQuote(normalized)}`;
}
/**
* 生成 Codex 模式三步命令。
*/
export function buildCodexPlan(options: CodexRunOptions): CodexCommandPlan[] {
const runCommand = options.resumeLast
? `codex resume --last --sandbox ${options.sandbox}`
: `codex --sandbox ${options.sandbox}`;
return [
{
step: "cd",
command: buildCdCommand(options.projectPath),
markerType: "cd"
},
{
step: "check",
command: "command -v codex",
markerType: "check"
},
{
step: "run",
command: runCommand,
markerType: "run"
}
];
}

View File

@@ -0,0 +1,7 @@
export * from "./types/models";
export * from "./session/stateMachine";
export * from "./codex/orchestrator";
export * from "./theme/contrast";
export * from "./theme/presets";
export * from "./logs/mask";
export * from "./security/hostKey";

View File

@@ -0,0 +1,15 @@
/**
* 日志脱敏函数,避免导出文本泄露密码和主机信息。
*/
export function maskSensitive(value: string): string {
return String(value)
.replace(/([0-9]{1,3}\.){3}[0-9]{1,3}/g, "***.***.***.***")
.replace(/(token|password|passphrase|secret)\s*[=:]\s*[^\s]+/gi, "$1=***")
.replace(/~\/.+?(?=\s|$)/g, "~/***");
}
export function maskHost(host: string): string {
return String(host)
.replace(/([a-zA-Z0-9._%+-]+)@/, "***@")
.replace(/([0-9]{1,3}\.){3}[0-9]{1,3}/, "***.***.***.***");
}

View File

@@ -0,0 +1,61 @@
/**
* 主机指纹策略。
*/
export type HostKeyPolicy = "strict" | "trustFirstUse" | "manualEachTime";
export interface KnownHostsRecord {
[hostPort: string]: string;
}
export interface VerifyHostKeyParams {
hostPort: string;
incomingFingerprint: string;
policy: HostKeyPolicy;
knownHosts: KnownHostsRecord;
/**
* 当策略需要用户确认时由上层注入。
*/
onConfirm: (payload: { hostPort: string; fingerprint: string; reason: string }) => Promise<boolean>;
}
export async function verifyHostKey(params: VerifyHostKeyParams): Promise<{ accepted: boolean; updated: KnownHostsRecord }> {
const { hostPort, incomingFingerprint, policy, onConfirm } = params;
const knownHosts = { ...params.knownHosts };
const stored = knownHosts[hostPort];
if (stored && stored !== incomingFingerprint) {
return { accepted: false, updated: knownHosts };
}
if (policy === "trustFirstUse") {
if (!stored) {
knownHosts[hostPort] = incomingFingerprint;
}
return { accepted: true, updated: knownHosts };
}
if (policy === "strict") {
if (stored) {
return { accepted: true, updated: knownHosts };
}
const accepted = await onConfirm({
hostPort,
fingerprint: incomingFingerprint,
reason: "首次连接,严格模式要求确认"
});
if (accepted) {
knownHosts[hostPort] = incomingFingerprint;
}
return { accepted, updated: knownHosts };
}
const accepted = await onConfirm({
hostPort,
fingerprint: incomingFingerprint,
reason: "手动确认模式"
});
if (accepted) {
knownHosts[hostPort] = incomingFingerprint;
}
return { accepted, updated: knownHosts };
}

View File

@@ -0,0 +1,28 @@
import type { SessionState } from "../types/models";
/**
* 会话状态机,确保连接生命周期可预测。
*/
const transitions: Record<SessionState, SessionState[]> = {
idle: ["connecting", "disconnected"],
connecting: ["auth_pending", "error", "disconnected"],
auth_pending: ["connected", "error", "disconnected"],
connected: ["reconnecting", "disconnected", "error"],
reconnecting: ["connected", "error", "disconnected"],
disconnected: ["connecting", "idle"],
error: ["connecting", "disconnected"]
};
export function canTransition(from: SessionState, to: SessionState): boolean {
return transitions[from].includes(to);
}
export function assertTransition(from: SessionState, to: SessionState): void {
if (!canTransition(from, to)) {
throw new Error(`非法状态跳转: ${from} -> ${to}`);
}
}
export function allStates(): SessionState[] {
return Object.keys(transitions) as SessionState[];
}

View File

@@ -0,0 +1,17 @@
import { describe, expect, it } from "vitest";
import { contrastRatio, pickBestBackground, pickShellAccentColor } from "./contrast";
describe("theme contrast", () => {
it("计算对比度", () => {
expect(contrastRatio("#ffffff", "#000000")).toBeGreaterThan(7);
});
it("自动选择背景", () => {
const selected = pickBestBackground("#e6f0ff", "#5bd2ff");
expect(selected.startsWith("#")).toBe(true);
});
it("终端强调色取背景和前景之间,并略偏前景", () => {
expect(pickShellAccentColor("#192b4d", "#e6f0ff")).toBe("#9ca9bf");
});
});

View File

@@ -0,0 +1,105 @@
/**
* 主题引擎:提供 WCAG 对比度计算与背景自动优化。
*/
export interface RgbColor {
r: number;
g: number;
b: number;
}
/**
* 终端强调色插值系数:
* - 0.5 代表正中间;
* - >0.5 代表向前景色偏移;
* - 当前取 0.64,满足“中间色且略偏前景”的视觉要求。
*/
export const SHELL_ACCENT_BLEND_T = 0.64;
export function normalizeHex(input: string, fallback: string): string {
return /^#[0-9a-fA-F]{6}$/.test(input) ? input.toLowerCase() : fallback;
}
export function hexToRgb(hex: string): RgbColor {
const value = normalizeHex(hex, "#000000");
return {
r: Number.parseInt(value.slice(1, 3), 16),
g: Number.parseInt(value.slice(3, 5), 16),
b: Number.parseInt(value.slice(5, 7), 16)
};
}
function srgbToLinear(value: number): number {
const normalized = value / 255;
if (normalized <= 0.03928) {
return normalized / 12.92;
}
return ((normalized + 0.055) / 1.055) ** 2.4;
}
export function luminance(hex: string): number {
const rgb = hexToRgb(hex);
return 0.2126 * srgbToLinear(rgb.r) + 0.7152 * srgbToLinear(rgb.g) + 0.0722 * srgbToLinear(rgb.b);
}
export function contrastRatio(a: string, b: string): number {
const la = luminance(a);
const lb = luminance(b);
const lighter = Math.max(la, lb);
const darker = Math.min(la, lb);
return (lighter + 0.05) / (darker + 0.05);
}
export function pickBestBackground(textColor: string, accentColor: string): string {
const candidates = [
"#0a1325",
"#132747",
"#102b34",
"#2e223b",
normalizeHex(accentColor, "#5bd2ff")
];
let best = candidates[0] ?? "#0a1325";
let bestScore = 0;
for (const candidate of candidates) {
const score = contrastRatio(normalizeHex(textColor, "#e6f0ff"), candidate);
if (score > bestScore) {
bestScore = score;
best = candidate;
}
}
return best;
}
/**
* 颜色线性插值:
* - t=0 表示返回背景色;
* - t=1 表示返回前景色;
* - 用于按钮色与终端强调色等“在 bg/text 之间取色”的场景。
*/
function mixColor(bgColor: string, textColor: string, t: number, fallbackBg: string, fallbackText: string): string {
const bg = hexToRgb(normalizeHex(bgColor, fallbackBg));
const text = hexToRgb(normalizeHex(textColor, fallbackText));
const factor = Number.isFinite(t) ? Math.min(1, Math.max(0, t)) : 0.5;
const r = Math.round(bg.r + (text.r - bg.r) * factor);
const g = Math.round(bg.g + (text.g - bg.g) * factor);
const b = Math.round(bg.b + (text.b - bg.b) * factor);
return `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`;
}
/**
* 按钮色自动推导在背景色和文本色之间取色偏向文本色一侧t=0.72)。
* 确保按钮与背景有足够对比度,同时色调协调。
*/
export function pickBtnColor(bgColor: string, textColor: string): string {
return mixColor(bgColor, textColor, 0.72, "#192b4d", "#e6f0ff");
}
/**
* 终端强调色自动推导:
* - 在终端背景色与前景色之间取“中间偏前景”的颜色;
* - 目标是避免强调色贴近背景导致识别度不足,同时避免过亮抢占正文层级。
*/
export function pickShellAccentColor(bgColor: string, textColor: string): string {
return mixColor(bgColor, textColor, SHELL_ACCENT_BLEND_T, "#192b4d", "#e6f0ff");
}

View File

@@ -0,0 +1,11 @@
import { describe, expect, it } from "vitest";
import { pickShellAccentColor } from "./contrast";
import { getShellVariant } from "./presets";
describe("theme presets", () => {
it("shell 变体的 cursor 按背景和前景自动推导", () => {
const variant = getShellVariant("tide", "dark");
expect(variant.cursor).toBe(pickShellAccentColor(variant.bg, variant.text));
expect(variant.cursor).toBe("#9ca9bf");
});
});

View File

@@ -0,0 +1,257 @@
import { pickShellAccentColor } from "./contrast";
/**
* 主题色板预设(按配置计划 §主题实现方案)。
*
* 每套主题包含:
* - palette: 原始色板(按 Figma 顺序)
* - dark: dark 变体bg / text / accent / btn
* - light: light 变体bg / text / accent / btn
*/
export type ThemePreset =
| "tide"
| "暮砂"
| "霓潮"
| "苔暮"
| "焰岩"
| "岩陶"
| "靛雾"
| "绛霓"
| "玫蓝"
| "珊湾"
| "苔荧"
| "铜暮"
| "炽潮"
| "藕夜"
| "沙海"
| "珀岚"
| "炫虹"
| "鎏霓"
| "珊汐"
| "黛苔"
| "霜绯";
export interface ThemeVariant {
/** 页面/卡片背景色 */
bg: string;
/** 正文字体色 */
text: string;
/** 强调色(链接、输入框聚焦线、开关等) */
accent: string;
/** 主按钮底色 */
btn: string;
}
export interface ThemeDefinition {
name: ThemePreset;
/** 色板原始顺序 */
palette: string[];
dark: ThemeVariant;
light: ThemeVariant;
/** shell 专用 dark 变体(终端背景/前景/光标) */
shellDark: { bg: string; text: string; cursor: string };
/** shell 专用 light 变体 */
shellLight: { bg: string; text: string; cursor: string };
}
export const THEME_PRESETS: Record<ThemePreset, ThemeDefinition> = {
tide: {
name: "tide",
palette: ["#192b4d", "#5bd2ff", "#e6f0ff", "#3D86FF"],
dark: { bg: "#192b4d", text: "#e6f0ff", accent: "#5bd2ff", btn: "#3D86FF" },
light: { bg: "#e6f0ff", text: "#192b4d", accent: "#3D86FF", btn: "#3D86FF" },
shellDark: { bg: "#192b4d", text: "#e6f0ff", cursor: "#5bd2ff" },
shellLight: { bg: "#e6f0ff", text: "#192b4d", cursor: "#3D86FF" }
},
: {
name: "暮砂",
palette: ["#F4F1DE", "#EAB69F", "#E07A5F", "#8F5D5D", "#3D405B", "#5F797B", "#81B29A", "#9EB998", "#BABF95", "#F2CC8F"],
dark: { bg: "#3D405B", text: "#F4F1DE", accent: "#81B29A", btn: "#E07A5F" },
light: { bg: "#F4F1DE", text: "#3D405B", accent: "#E07A5F", btn: "#8F5D5D" },
shellDark: { bg: "#3D405B", text: "#F4F1DE", cursor: "#81B29A" },
shellLight: { bg: "#F4F1DE", text: "#3D405B", cursor: "#E07A5F" }
},
: {
name: "霓潮",
palette: ["#EF476F", "#F78C6B", "#FFD166", "#83D483", "#06D6A0", "#001914", "#118AB2", "#0F7799", "#0C637F", "#073B4C"],
dark: { bg: "#073B4C", text: "#FFD166", accent: "#06D6A0", btn: "#118AB2" },
light: { bg: "#FFD166", text: "#073B4C", accent: "#EF476F", btn: "#0F7799" },
shellDark: { bg: "#073B4C", text: "#FFD166", cursor: "#06D6A0" },
shellLight: { bg: "#FFD166", text: "#073B4C", cursor: "#EF476F" }
},
: {
name: "苔暮",
palette: ["#A8B868", "#798575", "#4A5282", "#393F7C", "#313679", "#282C75", "#3C3C9D", "#4E4CC3", "#645FD4", "#7A71E4"],
dark: { bg: "#282C75", text: "#A8B868", accent: "#7A71E4", btn: "#84916c" },
light: { bg: "#A8B868", text: "#282C75", accent: "#4E4CC3", btn: "#393F7C" },
shellDark: { bg: "#282C75", text: "#A8B868", cursor: "#7A71E4" },
shellLight: { bg: "#A8B868", text: "#282C75", cursor: "#4E4CC3" }
},
: {
name: "焰岩",
palette: ["#5F0F40", "#7D092F", "#9A031E", "#CB4721", "#FB8B24", "#EF781C", "#E36414", "#AE5E26", "#795838", "#0F4C5C"],
dark: { bg: "#0F4C5C", text: "#FB8B24", accent: "#E36414", btn: "#CB4721" },
light: { bg: "#FB8B24", text: "#0F4C5C", accent: "#CB4721", btn: "#9A031E" },
shellDark: { bg: "#0F4C5C", text: "#FB8B24", cursor: "#E36414" },
shellLight: { bg: "#FB8B24", text: "#0F4C5C", cursor: "#CB4721" }
},
: {
name: "岩陶",
palette: ["#283D3B", "#21585A", "#197278", "#83A8A6", "#EDDDD4", "#D99185", "#E9B5AF", "#9E3A2E", "#772E25"],
dark: { bg: "#283D3B", text: "#EDDDD4", accent: "#E9B5AF", btn: "#D99185" },
light: { bg: "#EDDDD4", text: "#283D3B", accent: "#D99185", btn: "#9E3A2E" },
shellDark: { bg: "#283D3B", text: "#EDDDD4", cursor: "#E9B5AF" },
shellLight: { bg: "#EDDDD4", text: "#283D3B", cursor: "#D99185" }
},
: {
name: "靛雾",
palette: ["#292281", "#2D2586", "#31278B", "#382D93", "#3F329B", "#4437A1", "#4A3BA6", "#9D96BA", "#C7C4C4", "#F1F0CD"],
dark: { bg: "#292281", text: "#F1F0CD", accent: "#9D96BA", btn: "#b9b6b8" },
light: { bg: "#F1F0CD", text: "#292281", accent: "#4A3BA6", btn: "#382D93" },
shellDark: { bg: "#292281", text: "#F1F0CD", cursor: "#9D96BA" },
shellLight: { bg: "#F1F0CD", text: "#292281", cursor: "#4A3BA6" }
},
: {
name: "绛霓",
palette: ["#F72585", "#B5179E", "#7209B7", "#560BAD", "#480CA8", "#3A0CA3", "#3F37C9", "#4361EE", "#4895EF", "#4CC9F0"],
dark: { bg: "#3A0CA3", text: "#4CC9F0", accent: "#4895EF", btn: "#7209B7" },
light: { bg: "#4CC9F0", text: "#3A0CA3", accent: "#F72585", btn: "#7209B7" },
shellDark: { bg: "#3A0CA3", text: "#4CC9F0", cursor: "#4895EF" },
shellLight: { bg: "#4CC9F0", text: "#3A0CA3", cursor: "#F72585" }
},
: {
name: "玫蓝",
palette: ["#D06A79", "#984B8D", "#5E2BA1", "#482398", "#3D1F94", "#311B90", "#3D39B6", "#4857DC", "#567BE3", "#629FEB"],
dark: { bg: "#3D1F94", text: "#629FEB", accent: "#D06A79", btn: "#567BE3" },
light: { bg: "#629FEB", text: "#3D1F94", accent: "#D06A79", btn: "#4857DC" },
shellDark: { bg: "#3D1F94", text: "#629FEB", cursor: "#567BE3" },
shellLight: { bg: "#629FEB", text: "#3D1F94", cursor: "#D06A79" }
},
: {
name: "珊湾",
palette: ["#EC5B57", "#AD635D", "#6C6B64", "#526660", "#45645D", "#37615B", "#3E8983", "#44B0AB", "#4BC8C4", "#52DFDD"],
dark: { bg: "#37615B", text: "#52DFDD", accent: "#EC5B57", btn: "#44B0AB" },
light: { bg: "#52DFDD", text: "#37615B", accent: "#EC5B57", btn: "#3E8983" },
shellDark: { bg: "#37615B", text: "#52DFDD", cursor: "#44B0AB" },
shellLight: { bg: "#52DFDD", text: "#37615B", cursor: "#EC5B57" }
},
: {
name: "苔荧",
palette: ["#414731", "#515A23", "#616C15", "#99A32C", "#D1D942", "#C2CB37", "#B3BC2C", "#909636", "#6C6F41", "#252157"],
dark: { bg: "#252157", text: "#D1D942", accent: "#99A32C", btn: "#C2CB37" },
light: { bg: "#D1D942", text: "#252157", accent: "#909636", btn: "#99A32C" },
shellDark: { bg: "#252157", text: "#D1D942", cursor: "#C2CB37" },
shellLight: { bg: "#D1D942", text: "#252157", cursor: "#909636" }
},
: {
name: "铜暮",
palette: ["#502939", "#672F2A", "#7E351A", "#B27225", "#E6B030", "#D99F27", "#CB8E1E", "#9F782D", "#72623C", "#1A375A"],
dark: { bg: "#1A375A", text: "#E6B030", accent: "#B27225", btn: "#D99F27" },
light: { bg: "#E6B030", text: "#1A375A", accent: "#B27225", btn: "#9F782D" },
shellDark: { bg: "#1A375A", text: "#E6B030", cursor: "#D99F27" },
shellLight: { bg: "#E6B030", text: "#1A375A", cursor: "#B27225" }
},
: {
name: "炽潮",
palette: ["#5B2A28", "#771E1C", "#921211", "#C43133", "#F55054", "#E94347", "#DC363A", "#AA3E40", "#774547", "#125554"],
dark: { bg: "#125554", text: "#F55054", accent: "#C43133", btn: "#E94347" },
light: { bg: "#F55054", text: "#125554", accent: "#AA3E40", btn: "#DC363A" },
shellDark: { bg: "#125554", text: "#F55054", cursor: "#E94347" },
shellLight: { bg: "#F55054", text: "#125554", cursor: "#AA3E40" }
},
: {
name: "藕夜",
palette: ["#322F4F", "#433E71", "#554C93", "#98958C", "#DBDD85", "#D8DD7D", "#D5DB74", "#CED56E", "#C8CF67", "#BAC35A"],
dark: { bg: "#322F4F", text: "#DBDD85", accent: "#554C93", btn: "#C8CF67" },
light: { bg: "#DBDD85", text: "#322F4F", accent: "#D5DB74", btn: "#433E71" },
shellDark: { bg: "#322F4F", text: "#DBDD85", cursor: "#C8CF67" },
shellLight: { bg: "#DBDD85", text: "#322F4F", cursor: "#554C93" }
},
: {
name: "沙海",
palette: ["#2B3B51", "#355971", "#3F7690", "#91A483", "#E2D075", "#E4C66F", "#E4BD69", "#E0B464", "#DBAA5F", "#D19654"],
dark: { bg: "#2B3B51", text: "#E2D075", accent: "#3F7690", btn: "#DBAA5F" },
light: { bg: "#E2D075", text: "#2B3B51", accent: "#355971", btn: "#D19654" },
shellDark: { bg: "#2B3B51", text: "#E2D075", cursor: "#DBAA5F" },
shellLight: { bg: "#E2D075", text: "#2B3B51", cursor: "#3F7690" }
},
: {
name: "珀岚",
palette: ["#274D4C", "#2B7171", "#2F9595", "#8B9395", "#E79094", "#EC878A", "#EF7D7F", "#EC7578", "#E86D6F", "#E15D5F"],
dark: { bg: "#274D4C", text: "#E79094", accent: "#2F9595", btn: "#EC7578" },
light: { bg: "#E79094", text: "#274D4C", accent: "#2B7171", btn: "#E15D5F" },
shellDark: { bg: "#274D4C", text: "#E79094", cursor: "#EC7578" },
shellLight: { bg: "#E79094", text: "#274D4C", cursor: "#2F9595" }
},
: {
name: "炫虹",
palette: ["#FFBE0B", "#FD8A09", "#FB5607", "#FD2B3B", "#FF006E", "#C11CAD", "#A22ACD", "#8338EC", "#5F5FF6", "#3A86FF"],
dark: { bg: "#8338EC", text: "#FFBE0B", accent: "#FF006E", btn: "#3A86FF" },
light: { bg: "#FFBE0B", text: "#8338EC", accent: "#FD2B3B", btn: "#5F5FF6" },
shellDark: { bg: "#8338EC", text: "#FFBE0B", cursor: "#3A86FF" },
shellLight: { bg: "#FFBE0B", text: "#8338EC", cursor: "#FF006E" }
},
: {
name: "鎏霓",
palette: ["#F3D321", "#E7B019", "#DC8C10", "#D67039", "#D05460", "#A2529A", "#8C51B8", "#7550D5", "#5F5FE3", "#476CEF"],
dark: { bg: "#7550D5", text: "#F3D321", accent: "#A2529A", btn: "#476CEF" },
light: { bg: "#F3D321", text: "#7550D5", accent: "#D67039", btn: "#5F5FE3" },
shellDark: { bg: "#7550D5", text: "#F3D321", cursor: "#476CEF" },
shellLight: { bg: "#F3D321", text: "#7550D5", cursor: "#A2529A" }
},
: {
name: "珊汐",
palette: ["#FB5860", "#F74046", "#F2292C", "#F23433", "#F23E39", "#B86E68", "#9C867F", "#7F9E96", "#5FB4AE", "#3DCAC5"],
dark: { bg: "#7F9E96", text: "#FB5860", accent: "#3DCAC5", btn: "#F23E39" },
light: { bg: "#FB5860", text: "#7F9E96", accent: "#F2292C", btn: "#5FB4AE" },
shellDark: { bg: "#7F9E96", text: "#FB5860", cursor: "#3DCAC5" },
shellLight: { bg: "#FB5860", text: "#7F9E96", cursor: "#F2292C" }
},
: {
name: "黛苔",
palette: ["#2F2E3B", "#353159", "#3A3376", "#908EA6", "#E7E8D6", "#BEC388", "#949D3A", "#788031", "#5B6127"],
dark: { bg: "#2F2E3B", text: "#E7E8D6", accent: "#788031", btn: "#949D3A" },
light: { bg: "#E7E8D6", text: "#2F2E3B", accent: "#788031", btn: "#5B6127" },
shellDark: { bg: "#2F2E3B", text: "#E7E8D6", cursor: "#949D3A" },
shellLight: { bg: "#E7E8D6", text: "#2F2E3B", cursor: "#788031" }
},
: {
name: "霜绯",
palette: ["#293B3B", "#235959", "#1D7575", "#84A6A6", "#ECD7D8", "#D58A8A", "#BD3C3D", "#993333", "#732829"],
dark: { bg: "#293B3B", text: "#ECD7D8", accent: "#1D7575", btn: "#BD3C3D" },
light: { bg: "#ECD7D8", text: "#293B3B", accent: "#993333", btn: "#BD3C3D" },
shellDark: { bg: "#293B3B", text: "#ECD7D8", cursor: "#BD3C3D" },
shellLight: { bg: "#ECD7D8", text: "#293B3B", cursor: "#993333" }
}
};
/**
* 按主题名和模式获取 UI 变体颜色。
*/
export function getThemeVariant(preset: ThemePreset, mode: "dark" | "light"): ThemeVariant {
const def = THEME_PRESETS[preset] ?? THEME_PRESETS["tide"];
return mode === "dark" ? def.dark : def.light;
}
/**
* 按主题名和模式获取 Shell 变体颜色。
*/
export function getShellVariant(
preset: ThemePreset,
mode: "dark" | "light"
): { bg: string; text: string; cursor: string } {
const def = THEME_PRESETS[preset] ?? THEME_PRESETS["tide"];
const base = mode === "dark" ? def.shellDark : def.shellLight;
return {
bg: base.bg,
text: base.text,
// 终端强调色统一按“背景与前景之间、略偏前景”的规则实时推导。
cursor: pickShellAccentColor(base.bg, base.text)
};
}
/**
* 返回所有可用主题预设名称列表。
*/
export const THEME_PRESET_NAMES: ThemePreset[] = Object.keys(THEME_PRESETS) as ThemePreset[];

View File

@@ -0,0 +1,181 @@
/**
* 认证类型:首期支持密码和私钥,证书为二期预留。
*/
export type AuthType = "password" | "privateKey" | "certificate";
/**
* 终端传输模式Web/小程序通过网关iOS 走原生插件。
*/
export type TransportMode = "gateway" | "ios-native";
/**
* 服务器配置。
*/
export interface JumpHostProfile {
enabled: boolean;
host: string;
port: number;
username: string;
authType: AuthType;
}
/**
* 跳板机配置默认值:
* - `enabled=false` 表示走现有单跳链路;
* - 其余字段保留,便于前端表单直接双向绑定。
*/
export const DEFAULT_JUMP_HOST: JumpHostProfile = {
enabled: false,
host: "",
port: 22,
username: "",
authType: "password"
};
export interface ServerProfile {
id: string;
name: string;
host: string;
port: number;
username: string;
authType: AuthType;
projectPath: string;
projectPresets: string[];
tags: string[];
timeoutSeconds: number;
heartbeatSeconds: number;
transportMode: TransportMode;
jumpHost?: JumpHostProfile;
/**
* 服务器列表排序位次(值越小越靠前):
* - 仅用于前端“我的服务器”列表排序持久化;
* - 历史数据可能缺失,业务层会在加载时自动回填。
*/
sortOrder?: number;
lastConnectedAt?: string;
}
/**
* 凭据引用,不在业务对象中保存明文。
*/
export interface CredentialRef {
id: string;
type: AuthType;
secureStoreKey: string;
createdAt: string;
updatedAt: string;
}
/**
* 已解析凭据,通常仅在临时连接阶段短时存在内存里。
*/
export interface ResolvedCredential {
type: AuthType;
password?: string;
privateKey?: string;
passphrase?: string;
certificate?: string;
}
/**
* 一次终端建链中的“连接跳点”。
* - `target`:最终业务主机;
* - `jump`:可选跳板机。
*/
export interface GatewayConnectHop {
host: string;
port: number;
username: string;
credential: ResolvedCredential;
knownHostFingerprint?: string;
}
export type SessionState =
| "idle"
| "connecting"
| "auth_pending"
| "connected"
| "reconnecting"
| "disconnected"
| "error";
export interface CommandMarker {
at: string;
command: string;
source: "manual" | "codex" | "copilot" | "plugin";
markerType: "manual" | "cd" | "check" | "run";
code: number;
elapsedMs: number;
}
export interface SessionLog {
sessionId: string;
serverId: string;
startAt: string;
endAt?: string;
status: SessionState;
commandMarkers: CommandMarker[];
error?: string;
}
/**
* stdin 输入来源标识:
* - keyboard常规键盘输入含终端快捷键与控制字符
* - assist输入法/语音等“候选提交型”输入。
*/
export type StdinSource = "keyboard" | "assist";
/**
* stdin 元信息:
* - source 用于区分输入路径,便于网关侧做策略(如去重、观测)。
* - txnId 用于 assist 路径幂等去重(同一事务只接受一次最终提交)。
*/
export interface StdinMeta {
source: StdinSource;
txnId?: string;
}
/**
* WebSocket 网关协议。
*/
export type GatewayFrame =
| {
type: "init";
payload: {
host: string;
port: number;
username: string;
credential: ResolvedCredential;
jumpHost?: GatewayConnectHop;
clientSessionKey?: string;
/**
* 终端续接驻留窗口(毫秒):
* - 客户端切后台/离开终端页时,可请求网关将 SSH 会话驻留一段时间;
* - 网关侧会再按服务端上限做收敛,避免单端无限占用资源。
*/
resumeGraceMs?: number;
knownHostFingerprint?: string;
pty: { cols: number; rows: number };
};
}
| { type: "stdin"; payload: { data: string; meta?: StdinMeta } }
| { type: "stdout"; payload: { data: string } }
| { type: "stderr"; payload: { data: string } }
| { type: "resize"; payload: { cols: number; rows: number } }
| {
type: "control";
payload: {
action: "ping" | "pong" | "disconnect" | "connected";
reason?: string;
fingerprint?: string;
fingerprintHostPort?: string;
resumed?: boolean;
};
}
| {
type: "error";
payload: {
code: string;
message: string;
};
};

View File

@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist"
},
"include": ["src/**/*.ts"]
}