1267 lines
48 KiB
JavaScript
1267 lines
48 KiB
JavaScript
import { spawn } from "node:child_process";
|
||
import { rm, stat } from "node:fs/promises";
|
||
import os from "node:os";
|
||
import path from "node:path";
|
||
import { DatabaseSync } from "node:sqlite";
|
||
|
||
const PERMISSION_DEFS = [
|
||
{
|
||
key: "accessibility",
|
||
label: "辅助功能",
|
||
description: "用于点击、输入和读取可访问控件",
|
||
tier: "core",
|
||
},
|
||
{
|
||
key: "screenRecording",
|
||
label: "屏幕录制",
|
||
description: "用于识别桌面画面和系统弹窗",
|
||
tier: "core",
|
||
},
|
||
];
|
||
|
||
const MACOS_PERMISSION_SETTINGS = {
|
||
core: "x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?Privacy_Accessibility",
|
||
accessibility: "x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?Privacy_Accessibility",
|
||
screenRecording: "x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?Privacy_ScreenCapture",
|
||
};
|
||
|
||
const AUTO_PREFLIGHT_PERMISSION_KEYS = new Set(["accessibility", "screenRecording"]);
|
||
|
||
const NATIVE_PERMISSION_QUERY_PARAMS = {
|
||
accessibility: "nativeAccessibility",
|
||
screenRecording: "nativeScreenRecording",
|
||
};
|
||
|
||
const BOSS_AGENT_BUNDLE_ID = "com.hyzq.boss.agent";
|
||
const BOSS_COMPUTER_USE_HELPER_BUNDLE_ID = "site.hyzq.boss.computer-use-helper";
|
||
const BOSS_COMPUTER_USE_HELPER_APP_CANDIDATES = [
|
||
path.join(os.homedir(), "Applications/BossComputerUseHelper.app"),
|
||
"/Applications/BossComputerUseHelper.app",
|
||
];
|
||
const HELPER_SCREEN_RECORDING_CACHE_TTL_MS = 30_000;
|
||
let helperScreenRecordingCache = {
|
||
status: "unknown",
|
||
expiresAt: 0,
|
||
};
|
||
const BOSS_AGENT_DEFAULTS_DOMAIN = "com.hyzq.boss.agent";
|
||
const TCC_PERMISSION_SERVICES = {
|
||
kTCCServiceAccessibility: "accessibility",
|
||
kTCCServiceScreenCapture: "screenRecording",
|
||
};
|
||
const TCC_PERMISSION_CLIENTS = {
|
||
kTCCServiceAccessibility: [BOSS_AGENT_BUNDLE_ID],
|
||
kTCCServiceScreenCapture: [BOSS_AGENT_BUNDLE_ID, BOSS_COMPUTER_USE_HELPER_BUNDLE_ID],
|
||
};
|
||
const TCC_PERMISSION_DATABASES = [
|
||
"/Library/Application Support/com.apple.TCC/TCC.db",
|
||
path.join(os.homedir(), "Library/Application Support/com.apple.TCC/TCC.db"),
|
||
];
|
||
|
||
function nonEmpty(value) {
|
||
const text = String(value ?? "").trim();
|
||
return text || undefined;
|
||
}
|
||
|
||
function escapeHtml(value) {
|
||
return String(value ?? "")
|
||
.replaceAll("&", "&")
|
||
.replaceAll("<", "<")
|
||
.replaceAll(">", ">")
|
||
.replaceAll('"', """)
|
||
.replaceAll("'", "'");
|
||
}
|
||
|
||
function maskValue(value) {
|
||
const text = nonEmpty(value);
|
||
if (!text) return "";
|
||
if (text.length <= 8) return "••••";
|
||
return `${text.slice(0, 4)}••••${text.slice(-4)}`;
|
||
}
|
||
|
||
function sqlQuote(value) {
|
||
return `'${String(value ?? "").replaceAll("'", "''")}'`;
|
||
}
|
||
|
||
function normalizePermissionStatus(value) {
|
||
return value === "granted" || value === "missing" || value === "unknown" ? value : "unknown";
|
||
}
|
||
|
||
function isPermissionStatus(value) {
|
||
return value === "granted" || value === "missing" || value === "unknown";
|
||
}
|
||
|
||
function statusTone(status) {
|
||
if (status === "granted" || status === "valid" || status === "connected" || status === "bound") {
|
||
return "good";
|
||
}
|
||
if (status === "missing" || status === "expired" || status === "disconnected") {
|
||
return "bad";
|
||
}
|
||
return "warn";
|
||
}
|
||
|
||
function formatDate(value) {
|
||
const text = nonEmpty(value);
|
||
if (!text) return "绑定后显示";
|
||
const date = new Date(text);
|
||
if (Number.isNaN(date.getTime())) return text;
|
||
return new Intl.DateTimeFormat("zh-CN", {
|
||
year: "numeric",
|
||
month: "2-digit",
|
||
day: "2-digit",
|
||
}).format(date);
|
||
}
|
||
|
||
function buildBindingPayload(config) {
|
||
const controlPlaneUrl = nonEmpty(config.controlPlaneUrl) ?? "https://boss.hyzq.net";
|
||
const params = new URLSearchParams({
|
||
server: controlPlaneUrl,
|
||
deviceId: nonEmpty(config.deviceId) ?? "local-device",
|
||
name: nonEmpty(config.name) ?? "本机电脑",
|
||
});
|
||
const pairingCode = nonEmpty(config.pairingCode);
|
||
if (pairingCode) params.set("pairingCode", pairingCode);
|
||
return `boss://agent-bind?${params.toString()}`;
|
||
}
|
||
|
||
function resolveApiUsage(config) {
|
||
return {
|
||
primary:
|
||
nonEmpty(config.primaryApiLabel) ??
|
||
nonEmpty(config.apiUsage?.primary) ??
|
||
nonEmpty(config.masterAgentModel) ??
|
||
"由 Boss 后台统一配置",
|
||
backup:
|
||
nonEmpty(config.backupApiLabel) ??
|
||
nonEmpty(config.apiUsage?.backup) ??
|
||
"未启用",
|
||
status:
|
||
nonEmpty(config.apiUsage?.status) ??
|
||
(config.masterAgentEnabled === false ? "未启用主 Agent" : "可用"),
|
||
};
|
||
}
|
||
|
||
function resolveLicense(config, bound) {
|
||
const license = config.license && typeof config.license === "object" ? config.license : {};
|
||
if (!bound) {
|
||
return {
|
||
status: "pending_binding",
|
||
label: "未授权 · 绑定后校验",
|
||
enterpriseName: "绑定后显示",
|
||
expiresAt: nonEmpty(config.licenseExpiresAt) ?? "",
|
||
expiresAtLabel: "绑定后显示",
|
||
scope: "桌面控制 / 浏览器控制 / Skill 同步",
|
||
};
|
||
}
|
||
|
||
const status = nonEmpty(license.status) ?? "valid";
|
||
const expiresAt = nonEmpty(license.expiresAt) ?? nonEmpty(config.licenseExpiresAt) ?? "";
|
||
return {
|
||
status,
|
||
label: status === "valid" ? "正版授权正常" : status === "expired" ? "授权已过期" : "授权状态待确认",
|
||
enterpriseName: nonEmpty(license.enterpriseName) ?? nonEmpty(config.enterpriseName) ?? "默认公司",
|
||
expiresAt,
|
||
expiresAtLabel: formatDate(expiresAt),
|
||
scope: nonEmpty(license.scope) ?? "桌面控制 / 浏览器控制 / Skill 同步",
|
||
};
|
||
}
|
||
|
||
function normalizeCommandArgs(value, fallback = []) {
|
||
if (!Array.isArray(value)) return [...fallback];
|
||
return value.map((item) => nonEmpty(item)).filter(Boolean);
|
||
}
|
||
|
||
function resolveBooleanWithDefault(value, defaultValue = false) {
|
||
if (value === undefined || value === null || value === "") return defaultValue;
|
||
if (value === false || value === "false" || value === "0" || value === 0) return false;
|
||
return true;
|
||
}
|
||
|
||
function resolveCodexRemoteControl(config, appServerEnabled) {
|
||
const enabled = resolveBooleanWithDefault(config.codexRemoteControlEnabled, appServerEnabled);
|
||
const command = nonEmpty(config.codexRemoteControlCommand) ?? nonEmpty(config.codexAppServerCommand) ?? "codex";
|
||
const args = normalizeCommandArgs(config.codexRemoteControlArgs, ["remote-control", "start", "--json"]);
|
||
const startCommandLabel = [command, ...args].join(" ");
|
||
|
||
return {
|
||
enabled,
|
||
mode: enabled ? "managed_daemon" : "disabled",
|
||
command,
|
||
args,
|
||
startCommandLabel,
|
||
statusLabel: enabled ? "可托管启动" : "未启用",
|
||
summary: enabled
|
||
? "Codex Remote Control 会通过 App Server daemon 提供官方远控入口;当前状态页只展示能力,不在刷新时自动启动。"
|
||
: "Codex Remote Control daemon 未启用;远程控制会继续使用当前 App Server / Computer Use 配置。",
|
||
};
|
||
}
|
||
|
||
function resolveCodexBinding(config) {
|
||
const appServerEnabled = config.codexAppServerEnabled === true;
|
||
const codexComputerUseEnabled = config.codexComputerUseEnabled === true;
|
||
const command = nonEmpty(config.codexAppServerCommand) ?? "codex";
|
||
const defaultDesktopProvider = codexComputerUseEnabled
|
||
? "codex-computer-use"
|
||
: "cua-driver-computer-use";
|
||
const bindingStatus = appServerEnabled || codexComputerUseEnabled ? "connected" : "not_configured";
|
||
return {
|
||
bindingStatus,
|
||
statusLabel: bindingStatus === "connected" ? "已默认绑定" : "未默认绑定",
|
||
command,
|
||
appServerEnabled,
|
||
computerUseEnabled: codexComputerUseEnabled,
|
||
defaultDesktopProvider,
|
||
desktopProviderLabel:
|
||
defaultDesktopProvider === "codex-computer-use"
|
||
? "Codex Computer Use"
|
||
: "Boss CUA Driver",
|
||
fallbackProvider: "cua-driver-computer-use",
|
||
fallbackLabel: "Boss CUA Driver",
|
||
remoteControl: resolveCodexRemoteControl(config, appServerEnabled),
|
||
summary:
|
||
defaultDesktopProvider === "codex-computer-use"
|
||
? "远程控制默认走 Codex Computer Use,失败后回退 Boss CUA Driver。"
|
||
: "远程控制默认走 Boss CUA Driver。",
|
||
};
|
||
}
|
||
|
||
function resolveAgentOta(config, runtime) {
|
||
const enabledValue = config.bossAgentOtaEnabled;
|
||
const enabled = enabledValue === undefined ? true : enabledValue !== false && enabledValue !== "false";
|
||
const currentVersion = nonEmpty(config.bossAgentVersion) ?? "dev";
|
||
const lastStatus = runtime.lastBossAgentOtaStatus && typeof runtime.lastBossAgentOtaStatus === "object"
|
||
? runtime.lastBossAgentOtaStatus
|
||
: {};
|
||
const lastApply = runtime.lastBossAgentOtaApply && typeof runtime.lastBossAgentOtaApply === "object"
|
||
? runtime.lastBossAgentOtaApply
|
||
: {};
|
||
const latest = lastStatus.latest && typeof lastStatus.latest === "object" ? lastStatus.latest : null;
|
||
const hasUpdate = enabled && lastStatus.hasUpdate === true && Boolean(latest);
|
||
const latestVersion = nonEmpty(latest?.version) ?? "";
|
||
const applyStatus = nonEmpty(lastApply.status) ?? "";
|
||
|
||
return {
|
||
enabled,
|
||
currentVersion,
|
||
hasUpdate,
|
||
latestVersion,
|
||
latestFileName: nonEmpty(latest?.fileName) ?? "",
|
||
latestUpdatedAt: nonEmpty(latest?.updatedAt) ?? "",
|
||
lastCheckedAt: nonEmpty(lastStatus.checkedAt) ?? "",
|
||
lastApplyStatus: applyStatus,
|
||
lastApplyAt: nonEmpty(lastApply.completedAt) ?? "",
|
||
statusLabel: !enabled
|
||
? "未启用"
|
||
: hasUpdate
|
||
? "发现新版本"
|
||
: lastStatus.error
|
||
? "检查失败"
|
||
: "当前版本",
|
||
detail: !enabled
|
||
? "boss-agent OTA 已关闭"
|
||
: hasUpdate
|
||
? `最新:${latestVersion || "未知版本"}`
|
||
: lastStatus.error
|
||
? String(lastStatus.error)
|
||
: `当前:${currentVersion}`,
|
||
};
|
||
}
|
||
|
||
function permissionItems(defs, permissions) {
|
||
return defs.map((item) => ({
|
||
...item,
|
||
status: normalizePermissionStatus(permissions[item.key]),
|
||
}));
|
||
}
|
||
|
||
function resolvePermissionReadiness(coreItems, extendedItems) {
|
||
const coreGrantedCount = coreItems.filter((item) => item.status === "granted").length;
|
||
const extendedGrantedCount = extendedItems.filter((item) => item.status === "granted").length;
|
||
const coreReady = coreGrantedCount === coreItems.length;
|
||
const fullControlReady = coreReady && extendedGrantedCount === extendedItems.length;
|
||
const summary = coreReady ? "基础桌面控制已可用" : "基础桌面控制待授权,桌面接管不可用";
|
||
|
||
return {
|
||
coreReady,
|
||
fullControlReady,
|
||
coreGrantedCount,
|
||
coreTotal: coreItems.length,
|
||
extendedGrantedCount,
|
||
extendedTotal: extendedItems.length,
|
||
summary,
|
||
detail:
|
||
"参考 Codex Computer Use 的最小权限模型,boss-agent 只要求辅助功能和屏幕录制:辅助功能负责点击输入,屏幕录制可由 Boss Computer Use Helper 提供画面识别。",
|
||
};
|
||
}
|
||
|
||
function buildPermissionSetupPlan(coreItems, readiness) {
|
||
const actions = coreItems.map((item) => ({
|
||
key: item.key,
|
||
label: item.label,
|
||
description: item.description,
|
||
tier: item.tier,
|
||
status: item.status,
|
||
requiredForSilentControl: item.tier === "core",
|
||
canPreflight: AUTO_PREFLIGHT_PERMISSION_KEYS.has(item.key),
|
||
settingsUrl: MACOS_PERMISSION_SETTINGS[item.key] ?? MACOS_PERMISSION_SETTINGS.core,
|
||
openUrl: `/api/v1/boss-agent/permissions/open?target=${encodeURIComponent(item.key)}&returnTab=permissions`,
|
||
owner: "boss-agent.app",
|
||
}));
|
||
const missingRequiredActions = actions.filter(
|
||
(action) => action.requiredForSilentControl && action.status !== "granted",
|
||
);
|
||
|
||
return {
|
||
mode: "minimal_computer_use",
|
||
title: "基础桌面控制授权",
|
||
goal: "按 Codex Computer Use 的思路,只申请辅助功能和屏幕录制两项最小权限。",
|
||
silentUseReady: missingRequiredActions.length === 0,
|
||
primaryAction: {
|
||
label: "打开基础授权",
|
||
href: "/api/v1/boss-agent/permissions/open?target=core&returnTab=permissions",
|
||
settingsUrl: MACOS_PERMISSION_SETTINGS.core,
|
||
},
|
||
actions,
|
||
missingKeys: missingRequiredActions.map((action) => action.key),
|
||
missingRequiredKeys: missingRequiredActions.map((action) => action.key),
|
||
optionalMissingKeys: [],
|
||
summary: readiness.coreReady
|
||
? "基础桌面控制已可用;后续控制只校验这两项权限。"
|
||
: "仍缺少基础桌面控制权限,请先授权辅助功能和屏幕录制。",
|
||
persistenceNote:
|
||
"macOS 会把授权持久写入系统隐私数据库;稳定签名后,后续更新不会因为二进制哈希变化反复丢失授权。",
|
||
};
|
||
}
|
||
|
||
export function mergeBossAgentNativePermissionOverrides(permissions = {}, queryParams = {}) {
|
||
const getQueryValue = (name) => {
|
||
if (typeof queryParams.get === "function") return queryParams.get(name);
|
||
return queryParams[name];
|
||
};
|
||
const merged = {};
|
||
for (const permissionKey of Object.keys(NATIVE_PERMISSION_QUERY_PARAMS)) {
|
||
if (isPermissionStatus(permissions[permissionKey])) {
|
||
merged[permissionKey] = permissions[permissionKey];
|
||
}
|
||
}
|
||
for (const [permissionKey, queryKey] of Object.entries(NATIVE_PERMISSION_QUERY_PARAMS)) {
|
||
const value = getQueryValue(queryKey);
|
||
if (isPermissionStatus(value)) {
|
||
if (merged[permissionKey] === "granted" && value !== "granted") {
|
||
continue;
|
||
}
|
||
merged[permissionKey] = value;
|
||
}
|
||
}
|
||
return merged;
|
||
}
|
||
|
||
export function mergeBossAgentStoredNativePermissions(permissions = {}, storedPermissions = {}) {
|
||
const merged = { ...permissions };
|
||
for (const permissionKey of Object.keys(NATIVE_PERMISSION_QUERY_PARAMS)) {
|
||
if (storedPermissions[permissionKey] === "granted") {
|
||
merged[permissionKey] = "granted";
|
||
}
|
||
}
|
||
return merged;
|
||
}
|
||
|
||
export function mergeBossComputerUseHelperScreenRecordingPermission(permissions = {}, helperStatus = "unknown") {
|
||
if (helperStatus !== "granted") return { ...permissions };
|
||
return {
|
||
...permissions,
|
||
screenRecording: "granted",
|
||
};
|
||
}
|
||
|
||
export function mergeBossAgentAppTccPermissions(permissions = {}, tccRows = "") {
|
||
const merged = { ...permissions };
|
||
const granted = new Set();
|
||
|
||
for (const rawLine of String(tccRows ?? "").split(/\r?\n/)) {
|
||
const line = rawLine.trim();
|
||
if (!line) continue;
|
||
const parts = line.split("|");
|
||
const [service] = parts;
|
||
const authValue = parts.length >= 3 ? parts[2] : parts[1];
|
||
const client = parts.length >= 3 ? parts[1] : BOSS_AGENT_BUNDLE_ID;
|
||
const permissionKey = TCC_PERMISSION_SERVICES[service];
|
||
if (!permissionKey) continue;
|
||
const allowedClients = TCC_PERMISSION_CLIENTS[service] ?? [];
|
||
if (client && allowedClients.length > 0 && !allowedClients.includes(client)) continue;
|
||
if (authValue === "2") {
|
||
merged[permissionKey] = "granted";
|
||
granted.add(permissionKey);
|
||
} else if (!granted.has(permissionKey) && authValue === "0") {
|
||
merged[permissionKey] = "missing";
|
||
}
|
||
}
|
||
|
||
return merged;
|
||
}
|
||
|
||
function resolveSkills(runtime) {
|
||
const rawSkills = Array.isArray(runtime.lastSkills) ? runtime.lastSkills : [];
|
||
const items = rawSkills
|
||
.map((item) => ({
|
||
name: nonEmpty(item?.name),
|
||
category: nonEmpty(item?.category) ?? "本机",
|
||
path: nonEmpty(item?.path) ?? "",
|
||
description: nonEmpty(item?.description) ?? "",
|
||
status: nonEmpty(item?.status) ?? "deployed",
|
||
}))
|
||
.filter((item) => item.name);
|
||
const syncOk = runtime.lastSkillSyncOk === true;
|
||
|
||
return {
|
||
total: items.length,
|
||
items: items.slice(0, 8),
|
||
syncOk,
|
||
syncStatus: syncOk ? "已同步" : items.length > 0 ? "待确认" : "未同步",
|
||
syncAt: nonEmpty(runtime.lastSkillSyncAt) ?? "",
|
||
summary: items.length > 0 ? `已部署 ${items.length} 个 Skill` : "本机暂无已同步 Skill",
|
||
};
|
||
}
|
||
|
||
export function buildBossAgentStatus(config = {}, runtime = {}, options = {}) {
|
||
const now = nonEmpty(options.now) ?? new Date().toISOString();
|
||
const token = nonEmpty(runtime.issuedToken) ?? nonEmpty(config.token);
|
||
const account = nonEmpty(config.account);
|
||
const bound = Boolean(token && account);
|
||
const permissions = options.permissions ?? {};
|
||
const serverOk = runtime.lastHeartbeatOk === true;
|
||
const qrPayload = bound ? "" : buildBindingPayload(config);
|
||
const corePermissionItems = permissionItems(PERMISSION_DEFS, permissions);
|
||
const extendedPermissionItems = [];
|
||
const permissionReadiness = resolvePermissionReadiness(corePermissionItems, extendedPermissionItems);
|
||
const permissionSetup = buildPermissionSetupPlan(corePermissionItems, permissionReadiness);
|
||
|
||
return {
|
||
appName: "boss-agent",
|
||
generatedAt: now,
|
||
device: {
|
||
id: nonEmpty(config.deviceId) ?? "local-device",
|
||
name: nonEmpty(config.name) ?? os.hostname() ?? "本机电脑",
|
||
avatar: nonEmpty(config.avatar) ?? "B",
|
||
role: nonEmpty(config.deviceRole) ?? "企业被控节点",
|
||
endpoint: nonEmpty(config.endpoint) ?? "mac://local",
|
||
},
|
||
binding: {
|
||
bound,
|
||
status: bound ? "bound" : "unbound",
|
||
account: bound ? account : "未绑定",
|
||
tokenMasked: bound ? maskValue(token) : "",
|
||
pairingCode: bound ? "" : nonEmpty(config.pairingCode) ?? "",
|
||
qrPayload,
|
||
qrExpiresInLabel: bound ? "" : nonEmpty(config.qrExpiresInLabel) ?? "二维码 04:58 后失效",
|
||
},
|
||
server: {
|
||
ok: serverOk,
|
||
status: serverOk ? "connected" : "disconnected",
|
||
endpoint: nonEmpty(config.controlPlaneUrl) ?? "未配置服务器",
|
||
latencyLabel: nonEmpty(config.serverLatencyLabel) ?? (serverOk ? "延迟 28ms" : "连接异常"),
|
||
lastHeartbeatAt: nonEmpty(runtime.lastHeartbeatAt) ?? "",
|
||
lastHeartbeatStatus: runtime.lastHeartbeatStatus ?? null,
|
||
},
|
||
api: resolveApiUsage(config),
|
||
codex: resolveCodexBinding(config),
|
||
agentOta: resolveAgentOta(config, runtime),
|
||
license: resolveLicense(config, bound),
|
||
permissions: {
|
||
summary: permissionReadiness.summary,
|
||
items: corePermissionItems,
|
||
extendedItems: extendedPermissionItems,
|
||
},
|
||
permissionReadiness,
|
||
permissionSetup,
|
||
skills: resolveSkills(runtime),
|
||
};
|
||
}
|
||
|
||
function renderPseudoQrSvg(payload) {
|
||
const size = 25;
|
||
let seed = 0;
|
||
for (const char of String(payload || "boss-agent")) {
|
||
seed = (seed * 31 + char.charCodeAt(0)) >>> 0;
|
||
}
|
||
const cells = [];
|
||
const finder = (x, y) => x < 7 && y < 7 || x > 17 && y < 7 || x < 7 && y > 17;
|
||
for (let y = 0; y < size; y += 1) {
|
||
for (let x = 0; x < size; x += 1) {
|
||
const finderCell = finder(x, y);
|
||
const edge = x === 0 || y === 0 || x === size - 1 || y === size - 1;
|
||
const value = finderCell
|
||
? !edge && (x % 6 === 1 || y % 6 === 1 || (x % 6 >= 2 && x % 6 <= 4 && y % 6 >= 2 && y % 6 <= 4))
|
||
: ((seed + x * 17 + y * 29 + x * y * 7) % 5) < 2;
|
||
if (value) {
|
||
cells.push(`<rect x="${x}" y="${y}" width="1" height="1" rx=".08"></rect>`);
|
||
}
|
||
}
|
||
}
|
||
return `<svg class="qr-svg" viewBox="0 0 ${size} ${size}" role="img" aria-label="Boss APP 绑定二维码">${cells.join("")}</svg>`;
|
||
}
|
||
|
||
function permissionText(status) {
|
||
if (status === "granted") return "已授权";
|
||
if (status === "missing") return "未授权";
|
||
return "待确认";
|
||
}
|
||
|
||
function setupActionRows(status) {
|
||
return status.permissionSetup.actions
|
||
.map((action) => {
|
||
const tone = statusTone(action.status);
|
||
const preflight = action.requiredForSilentControl
|
||
? "基础必需"
|
||
: action.canPreflight
|
||
? "按场景预触发"
|
||
: "按场景启用";
|
||
return `<div class="setup-action">
|
||
<div>
|
||
<div class="permission-name">${escapeHtml(action.label)}</div>
|
||
<div class="muted">${escapeHtml(action.description)} · ${escapeHtml(preflight)}</div>
|
||
</div>
|
||
<div class="setup-action-right">
|
||
<span class="pill ${tone}">${escapeHtml(permissionText(action.status))}</span>
|
||
<a class="text-link" href="${escapeHtml(action.openUrl)}">打开设置</a>
|
||
</div>
|
||
</div>`;
|
||
})
|
||
.join("");
|
||
}
|
||
|
||
function skillRows(status) {
|
||
const skills = status.skills?.items ?? [];
|
||
if (skills.length === 0) {
|
||
return `<div class="empty-state">本机暂无已同步 Skill。绑定账号后可由 Boss 后台下发或同步本机 Codex Skill。</div>`;
|
||
}
|
||
|
||
return skills
|
||
.map((skill) => {
|
||
const pathLabel = skill.path ? skill.path.replace(os.homedir(), "~") : "未记录路径";
|
||
return `<div class="skill-row">
|
||
<div>
|
||
<div class="skill-name">${escapeHtml(skill.name)}</div>
|
||
<div class="muted">${escapeHtml(skill.category)} · ${escapeHtml(pathLabel)}</div>
|
||
</div>
|
||
<span class="pill good">已部署</span>
|
||
</div>`;
|
||
})
|
||
.join("");
|
||
}
|
||
|
||
const BOSS_AGENT_TABS = new Set(["overview", "permissions", "skills", "license", "logs"]);
|
||
|
||
export function normalizeBossAgentTab(value = "overview") {
|
||
const tab = nonEmpty(value) ?? "overview";
|
||
return BOSS_AGENT_TABS.has(tab) ? tab : "overview";
|
||
}
|
||
|
||
export function renderBossAgentHtml(status, options = {}) {
|
||
return renderBossAgentHtmlBase(status, options);
|
||
}
|
||
|
||
export async function renderBossAgentHtmlWithQr(status, options = {}) {
|
||
let qrImageDataUrl = "";
|
||
if (!status.binding.bound && status.binding.qrPayload) {
|
||
try {
|
||
const qrModule = await import("qrcode");
|
||
const toDataURL = qrModule.toDataURL ?? qrModule.default?.toDataURL;
|
||
if (typeof toDataURL === "function") {
|
||
qrImageDataUrl = await toDataURL(status.binding.qrPayload, {
|
||
errorCorrectionLevel: "M",
|
||
margin: 1,
|
||
width: 288,
|
||
color: {
|
||
dark: "#18211a",
|
||
light: "#ffffff",
|
||
},
|
||
});
|
||
}
|
||
} catch {
|
||
qrImageDataUrl = "";
|
||
}
|
||
}
|
||
return renderBossAgentHtmlBase(status, { ...options, qrImageDataUrl });
|
||
}
|
||
|
||
function activeClass(activeTab, tab) {
|
||
return activeTab === tab ? ` class="active"` : "";
|
||
}
|
||
|
||
function renderBossAgentNav(status, activeTab) {
|
||
return `<nav class="nav">
|
||
<a${activeClass(activeTab, "overview")} href="/boss-agent?tab=overview"><span>概览</span>${activeTab === "overview" ? `<span class="nav-badge">当前</span>` : ""}</a>
|
||
<a${activeClass(activeTab, "permissions")} href="/boss-agent?tab=permissions"><span>本机权限获取</span><span class="nav-badge">${escapeHtml(status.permissionReadiness.coreReady ? "OK" : "待")}</span></a>
|
||
<a${activeClass(activeTab, "skills")} href="/boss-agent?tab=skills"><span>Skill</span><span class="nav-badge">${escapeHtml(status.skills.total)}</span></a>
|
||
<a${activeClass(activeTab, "license")} href="/boss-agent?tab=license"><span>绑定与授权</span></a>
|
||
<a${activeClass(activeTab, "logs")} href="/boss-agent?tab=logs"><span>日志</span></a>
|
||
</nav>`;
|
||
}
|
||
|
||
function renderTopbar(status, title = "boss-agent", subtitle = "企业电脑接入端") {
|
||
return `<header class="topbar">
|
||
<div>
|
||
<h1>${escapeHtml(title)}</h1>
|
||
<div class="subtitle">${escapeHtml(subtitle)}</div>
|
||
</div>
|
||
<div class="server-pill"><span class="dot ${status.server.ok ? "" : "off"}"></span>${escapeHtml(status.server.ok ? `已连接 ${status.server.endpoint}` : "服务器未连接")}</div>
|
||
</header>`;
|
||
}
|
||
|
||
function renderOverviewTab(status, { bound, heroTitle, heroSubtitle, qrBlock }) {
|
||
return `${renderTopbar(status)}
|
||
|
||
<section class="grid">
|
||
<div class="card hero">
|
||
${qrBlock}
|
||
<div>
|
||
<h2>${escapeHtml(heroTitle)}</h2>
|
||
<p>${escapeHtml(heroSubtitle)}</p>
|
||
<div class="button-row">
|
||
<button class="button">${escapeHtml(bound ? "查看绑定信息" : "刷新二维码")}</button>
|
||
<span class="timer">${escapeHtml(bound ? `账号:${status.binding.account}` : status.binding.qrExpiresInLabel)}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="card panel">
|
||
<h2>绑定状态</h2>
|
||
<div class="rows">
|
||
<div class="row"><span class="label">账号</span><span class="value">${escapeHtml(status.binding.account)}</span></div>
|
||
<div class="row"><span class="label">设备</span><span class="value">${escapeHtml(status.device.name)}</span></div>
|
||
<div class="row"><span class="label">角色</span><span class="value">${escapeHtml(status.device.role)}</span></div>
|
||
<div class="row"><span class="label">设备 ID</span><span class="value">${escapeHtml(status.device.id)}</span></div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="cards">
|
||
<div class="card metric">
|
||
<div class="metric-title">账号登录状态</div>
|
||
<div class="metric-value">${escapeHtml(bound ? "已绑定" : "等待绑定")}</div>
|
||
<div class="metric-detail">${escapeHtml(bound ? status.binding.account : "使用 Boss APP 扫码完成绑定")}</div>
|
||
</div>
|
||
<div class="card metric">
|
||
<div class="metric-title">当前 API 使用情况</div>
|
||
<div class="metric-value">${escapeHtml(status.api.primary)}</div>
|
||
<div class="metric-detail">备用 API:${escapeHtml(status.api.backup)}</div>
|
||
</div>
|
||
<div class="card metric">
|
||
<div class="metric-title">Codex 默认接管</div>
|
||
<div class="metric-value">${escapeHtml(status.codex.desktopProviderLabel)}</div>
|
||
<div class="metric-detail">${escapeHtml(status.codex.summary)}</div>
|
||
</div>
|
||
<div class="card metric">
|
||
<div class="metric-title">Codex Remote Control</div>
|
||
<div class="metric-value">${escapeHtml(status.codex.remoteControl.statusLabel)}</div>
|
||
<div class="metric-detail">${escapeHtml(status.codex.remoteControl.startCommandLabel)}</div>
|
||
</div>
|
||
<div class="card metric">
|
||
<div class="metric-title">boss-agent OTA</div>
|
||
<div class="metric-value">${escapeHtml(status.agentOta.statusLabel)}</div>
|
||
<div class="metric-detail">${escapeHtml(status.agentOta.detail)}</div>
|
||
</div>
|
||
<div class="card metric">
|
||
<div class="metric-title">服务器连接</div>
|
||
<div class="metric-value">${escapeHtml(status.server.ok ? "正常" : "异常")}</div>
|
||
<div class="metric-detail">${escapeHtml(status.server.latencyLabel)}</div>
|
||
</div>
|
||
<div class="card metric">
|
||
<div class="metric-title">正版授权</div>
|
||
<div class="metric-value">${escapeHtml(status.license.label)}</div>
|
||
<div class="metric-detail">到期:${escapeHtml(status.license.expiresAtLabel)}</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="card panel">
|
||
<h2>授权信息</h2>
|
||
<div class="rows">
|
||
<div class="row"><span class="label">企业</span><span class="value">${escapeHtml(status.license.enterpriseName)}</span></div>
|
||
<div class="row"><span class="label">授权到期</span><span class="value">${escapeHtml(status.license.expiresAtLabel)}</span></div>
|
||
<div class="row"><span class="label">权限范围</span><span class="value">${escapeHtml(status.license.scope)}</span></div>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="card panel">
|
||
<h2>boss-agent OTA</h2>
|
||
<div class="rows">
|
||
<div class="row"><span class="label">当前版本</span><span class="value">${escapeHtml(status.agentOta.currentVersion)}</span></div>
|
||
<div class="row"><span class="label">最新版本</span><span class="value">${escapeHtml(status.agentOta.latestVersion || "未发现新版本")}</span></div>
|
||
<div class="row"><span class="label">升级状态</span><span class="value">${escapeHtml(status.agentOta.statusLabel)}</span></div>
|
||
<div class="row"><span class="label">最近安装</span><span class="value">${escapeHtml(status.agentOta.lastApplyStatus || "暂无")}</span></div>
|
||
</div>
|
||
<div class="button-row ota-actions">
|
||
<form action="/api/v1/boss-agent/ota/check" method="get"><button class="button secondary" type="submit">检查更新</button></form>
|
||
<form action="/api/v1/boss-agent/ota/apply" method="post"><button class="button" type="submit">下载并安装</button></form>
|
||
</div>
|
||
<div class="hint">Mac 端 OTA 会先下载并校验安装包,再拉起本机安装器;原配置会被保留,失败不会覆盖当前运行版本。</div>
|
||
</section>`;
|
||
}
|
||
|
||
function renderPermissionsTab(status) {
|
||
return `${renderTopbar(status, "本机权限获取", "一次性完成本机接管权限配置")}
|
||
<section class="card panel setup-panel">
|
||
<div class="setup-head">
|
||
<div>
|
||
<h2>${escapeHtml(status.permissionSetup.title)}</h2>
|
||
<p>${escapeHtml(status.permissionSetup.goal)} 权限结论:${escapeHtml(status.permissions.summary)}。当前状态:${escapeHtml(status.permissionSetup.summary)} 后续静默使用依赖系统持久授权。</p>
|
||
</div>
|
||
<a class="button" href="${escapeHtml(status.permissionSetup.primaryAction.href)}">${escapeHtml(status.permissionSetup.primaryAction.label)}</a>
|
||
</div>
|
||
<div class="setup-actions">${setupActionRows(status)}</div>
|
||
<div class="hint">${escapeHtml(status.permissionSetup.persistenceNote)}</div>
|
||
</section>`;
|
||
}
|
||
|
||
function renderSkillsTab(status) {
|
||
return `${renderTopbar(status, "Skill", "部署在本机并可被 Boss 调用的能力")}
|
||
<section class="card panel">
|
||
<h2>Skill 部署情况</h2>
|
||
<div class="rows">${skillRows(status)}</div>
|
||
<div class="hint">Skill 用于把本机可复用能力分发给 Boss APP 和主 Agent;后续企业后台可按账号、设备和权限策略下发。</div>
|
||
</section>`;
|
||
}
|
||
|
||
function renderLicenseTab(status) {
|
||
return `${renderTopbar(status, "绑定与授权", "账号、设备、服务器和正版授权状态")}
|
||
<section class="lower">
|
||
<div class="card panel">
|
||
<h2>绑定状态</h2>
|
||
<div class="rows">
|
||
<div class="row"><span class="label">账号</span><span class="value">${escapeHtml(status.binding.account)}</span></div>
|
||
<div class="row"><span class="label">设备</span><span class="value">${escapeHtml(status.device.name)}</span></div>
|
||
<div class="row"><span class="label">角色</span><span class="value">${escapeHtml(status.device.role)}</span></div>
|
||
<div class="row"><span class="label">设备 ID</span><span class="value">${escapeHtml(status.device.id)}</span></div>
|
||
</div>
|
||
</div>
|
||
<div class="card panel">
|
||
<h2>授权信息</h2>
|
||
<div class="rows">
|
||
<div class="row"><span class="label">企业</span><span class="value">${escapeHtml(status.license.enterpriseName)}</span></div>
|
||
<div class="row"><span class="label">授权到期</span><span class="value">${escapeHtml(status.license.expiresAtLabel)}</span></div>
|
||
<div class="row"><span class="label">权限范围</span><span class="value">${escapeHtml(status.license.scope)}</span></div>
|
||
</div>
|
||
<div class="hint">${escapeHtml(status.permissionReadiness.detail)}</div>
|
||
</div>
|
||
</section>`;
|
||
}
|
||
|
||
function renderLogsTab(status) {
|
||
return `${renderTopbar(status, "日志", "本机 agent 最近运行状态")}
|
||
<section class="card panel">
|
||
<h2>暂无日志面板</h2>
|
||
<div class="hint">当前版本先保留入口;运行日志仍写入本机 LaunchAgent 日志和 Boss 后台事件。</div>
|
||
</section>`;
|
||
}
|
||
|
||
function renderBossAgentTabContent(status, activeTab, viewModel) {
|
||
if (activeTab === "permissions") return renderPermissionsTab(status);
|
||
if (activeTab === "skills") return renderSkillsTab(status);
|
||
if (activeTab === "license") return renderLicenseTab(status);
|
||
if (activeTab === "logs") return renderLogsTab(status);
|
||
return renderOverviewTab(status, viewModel);
|
||
}
|
||
|
||
function renderBossAgentHtmlBase(status, options = {}) {
|
||
const activeTab = normalizeBossAgentTab(options.activeTab);
|
||
const bound = status.binding.bound;
|
||
const heroTitle = bound ? "这台电脑已接入 Boss" : "扫码绑定 Boss APP";
|
||
const heroSubtitle = bound
|
||
? "本机 agent 正在接收企业控制台调度,可用于桌面控制、浏览器控制和 Skill 同步。"
|
||
: "使用 Boss APP 扫码,将这台电脑加入企业账号。";
|
||
const qrBlock = bound
|
||
? `<div class="bound-mark"><span>${escapeHtml(status.device.avatar)}</span></div>`
|
||
: `<div class="qr-box">${
|
||
options.qrImageDataUrl
|
||
? `<img class="qr-img" src="${escapeHtml(options.qrImageDataUrl)}" alt="Boss APP 绑定二维码" />`
|
||
: renderPseudoQrSvg(status.binding.qrPayload)
|
||
}</div>`;
|
||
const viewModel = { bound, heroTitle, heroSubtitle, qrBlock };
|
||
|
||
return `<!doctype html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="utf-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||
<title>boss-agent</title>
|
||
<style>
|
||
:root {
|
||
color-scheme: light;
|
||
--green: #07c160;
|
||
--green-soft: #e9f8ef;
|
||
--ink: #111418;
|
||
--muted: #707982;
|
||
--line: #e8ece9;
|
||
--panel: #ffffff;
|
||
--bg: #f5f7f4;
|
||
--warn: #d98512;
|
||
--warn-soft: #fff6df;
|
||
--bad: #e54d42;
|
||
--bad-soft: #fff1ef;
|
||
--blue-soft: #eef6ff;
|
||
--blue: #2377d1;
|
||
}
|
||
* { box-sizing: border-box; }
|
||
body {
|
||
margin: 0;
|
||
min-height: 100vh;
|
||
display: grid;
|
||
place-items: center;
|
||
background:
|
||
radial-gradient(circle at 18% 10%, rgba(7, 193, 96, .08), transparent 28%),
|
||
linear-gradient(135deg, #fbfcfa 0%, var(--bg) 100%);
|
||
color: var(--ink);
|
||
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
|
||
}
|
||
.window {
|
||
width: min(1180px, calc(100vw - 56px));
|
||
min-height: 760px;
|
||
display: grid;
|
||
grid-template-columns: 268px 1fr;
|
||
overflow: hidden;
|
||
border: 1px solid rgba(17, 20, 24, .08);
|
||
border-radius: 22px;
|
||
background: rgba(255, 255, 255, .92);
|
||
box-shadow: 0 32px 80px rgba(22, 38, 29, .14);
|
||
}
|
||
.sidebar {
|
||
padding: 18px 16px;
|
||
border-right: 1px solid var(--line);
|
||
background: rgba(248, 250, 247, .82);
|
||
}
|
||
.traffic { display: flex; gap: 8px; margin-bottom: 30px; }
|
||
.traffic span { width: 12px; height: 12px; border-radius: 999px; display: block; }
|
||
.red { background: #ff5f57; } .yellow { background: #febc2e; } .green-light { background: #28c840; }
|
||
.brand { display: flex; align-items: center; gap: 12px; margin-bottom: 24px; }
|
||
.brand-mark, .bound-mark {
|
||
width: 42px; height: 42px; border-radius: 16px;
|
||
display: grid; place-items: center;
|
||
background: var(--green-soft); color: var(--green);
|
||
font-weight: 800; font-size: 21px;
|
||
}
|
||
.brand-title { font-weight: 800; font-size: 18px; letter-spacing: -.02em; }
|
||
.device-name { margin-top: 4px; color: var(--muted); font-size: 12px; }
|
||
.nav { display: grid; gap: 6px; margin-bottom: 16px; }
|
||
.nav a {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
gap: 10px;
|
||
padding: 12px 12px;
|
||
border-radius: 14px;
|
||
color: #394139;
|
||
text-decoration: none;
|
||
font-size: 14px;
|
||
font-weight: 650;
|
||
}
|
||
.nav a.active { background: var(--green-soft); color: #058743; }
|
||
.nav-badge {
|
||
min-width: 22px;
|
||
padding: 3px 7px;
|
||
border-radius: 999px;
|
||
background: rgba(17, 20, 24, .06);
|
||
color: var(--muted);
|
||
text-align: center;
|
||
font-size: 11px;
|
||
font-weight: 800;
|
||
}
|
||
.nav a.active .nav-badge { background: rgba(7, 193, 96, .14); color: #058743; }
|
||
.content { padding: 26px 32px 32px; }
|
||
.topbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 28px; }
|
||
h1 { margin: 0; font-size: 28px; letter-spacing: -.04em; }
|
||
.subtitle { color: var(--muted); margin-top: 6px; font-size: 14px; }
|
||
.server-pill {
|
||
padding: 10px 14px;
|
||
border: 1px solid var(--line);
|
||
border-radius: 999px;
|
||
background: white;
|
||
color: #263128;
|
||
font-weight: 700;
|
||
font-size: 13px;
|
||
}
|
||
.dot {
|
||
width: 8px; height: 8px; border-radius: 999px; display: inline-block; margin-right: 8px;
|
||
background: var(--green);
|
||
}
|
||
.dot.off { background: var(--bad); }
|
||
.grid { display: grid; grid-template-columns: 1.1fr .9fr; gap: 18px; margin-bottom: 18px; }
|
||
.card {
|
||
border: 1px solid var(--line);
|
||
border-radius: 20px;
|
||
background: var(--panel);
|
||
box-shadow: 0 10px 34px rgba(21, 35, 27, .05);
|
||
}
|
||
.hero { padding: 24px; display: grid; grid-template-columns: 176px 1fr; gap: 24px; align-items: center; }
|
||
.qr-box {
|
||
width: 176px; height: 176px;
|
||
padding: 16px;
|
||
border: 1px solid var(--line);
|
||
border-radius: 22px;
|
||
background: linear-gradient(180deg, #fff, #f7faf7);
|
||
}
|
||
.qr-svg { width: 100%; height: 100%; fill: #18211a; image-rendering: pixelated; }
|
||
.qr-img { display: block; width: 100%; height: 100%; object-fit: contain; }
|
||
.hero h2, .panel h2 { margin: 0; font-size: 22px; letter-spacing: -.03em; }
|
||
.hero p { margin: 10px 0 18px; color: var(--muted); line-height: 1.65; }
|
||
.button-row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
|
||
.button {
|
||
border: 0;
|
||
border-radius: 14px;
|
||
padding: 12px 16px;
|
||
background: var(--green);
|
||
color: white;
|
||
font-weight: 800;
|
||
font-size: 14px;
|
||
}
|
||
.button.secondary { background: #eef3ef; color: #263128; }
|
||
form { margin: 0; }
|
||
.ota-actions { margin-top: 18px; }
|
||
.timer { color: var(--muted); font-size: 13px; }
|
||
.panel { padding: 22px; }
|
||
.rows { display: grid; gap: 14px; margin-top: 18px; }
|
||
.row, .permission-row, .skill-row, .setup-action { display: flex; justify-content: space-between; gap: 18px; align-items: center; }
|
||
.label, .muted { color: var(--muted); font-size: 13px; }
|
||
.value { font-weight: 750; text-align: right; }
|
||
.cards { display: grid; grid-template-columns: repeat(6, 1fr); gap: 14px; margin-bottom: 18px; }
|
||
.metric { padding: 18px; min-height: 126px; }
|
||
.metric-title { color: var(--muted); font-size: 13px; margin-bottom: 16px; }
|
||
.metric-value { font-size: 20px; font-weight: 850; letter-spacing: -.03em; line-height: 1.35; }
|
||
.metric-detail { color: var(--muted); margin-top: 8px; font-size: 12px; line-height: 1.5; }
|
||
.lower { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; }
|
||
.pill {
|
||
border-radius: 999px;
|
||
padding: 6px 10px;
|
||
font-size: 12px;
|
||
font-weight: 750;
|
||
white-space: nowrap;
|
||
}
|
||
.pill.good { color: #058743; background: var(--green-soft); }
|
||
.pill.warn { color: var(--warn); background: var(--warn-soft); }
|
||
.pill.bad { color: var(--bad); background: var(--bad-soft); }
|
||
.permission-name { font-weight: 780; margin-bottom: 3px; }
|
||
.skill-name { font-weight: 780; margin-bottom: 3px; }
|
||
.setup-panel { margin-bottom: 18px; }
|
||
.setup-head { display: flex; justify-content: space-between; gap: 18px; align-items: flex-start; }
|
||
.setup-head p { margin: 8px 0 0; color: var(--muted); line-height: 1.65; font-size: 14px; }
|
||
.setup-actions { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; margin-top: 18px; }
|
||
.setup-action {
|
||
padding: 14px;
|
||
border: 1px solid var(--line);
|
||
border-radius: 16px;
|
||
background: #fbfcfb;
|
||
}
|
||
.setup-action-right { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
|
||
.text-link {
|
||
color: #058743;
|
||
text-decoration: none;
|
||
font-size: 12px;
|
||
font-weight: 850;
|
||
white-space: nowrap;
|
||
}
|
||
.empty-state {
|
||
min-height: 120px;
|
||
display: grid;
|
||
place-items: center;
|
||
color: var(--muted);
|
||
line-height: 1.7;
|
||
text-align: center;
|
||
border: 1px dashed var(--line);
|
||
border-radius: 16px;
|
||
background: #fbfcfb;
|
||
}
|
||
.hint {
|
||
margin-top: 18px;
|
||
padding: 14px 16px;
|
||
border-radius: 16px;
|
||
color: var(--blue);
|
||
background: var(--blue-soft);
|
||
font-size: 13px;
|
||
line-height: 1.55;
|
||
}
|
||
@media (max-width: 980px) {
|
||
body { place-items: start; }
|
||
.window { width: 100vw; min-height: 100vh; border-radius: 0; grid-template-columns: 1fr; }
|
||
.sidebar { display: none; }
|
||
.grid, .lower { grid-template-columns: 1fr; }
|
||
.setup-actions { grid-template-columns: 1fr; }
|
||
.cards { grid-template-columns: repeat(2, 1fr); }
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<main class="window">
|
||
<aside class="sidebar">
|
||
<div class="traffic"><span class="red"></span><span class="yellow"></span><span class="green-light"></span></div>
|
||
<div class="brand">
|
||
<div class="brand-mark">B</div>
|
||
<div>
|
||
<div class="brand-title">boss-agent</div>
|
||
<div class="device-name">${escapeHtml(status.device.name)}</div>
|
||
</div>
|
||
</div>
|
||
${renderBossAgentNav(status, activeTab)}
|
||
</aside>
|
||
<section class="content">
|
||
${renderBossAgentTabContent(status, activeTab, viewModel)}
|
||
</section>
|
||
</main>
|
||
</body>
|
||
</html>`;
|
||
}
|
||
|
||
function runCommand(command, args, timeoutMs = 2500) {
|
||
return new Promise((resolve) => {
|
||
const child = spawn(command, args, {
|
||
stdio: ["ignore", "pipe", "pipe"],
|
||
});
|
||
let stdout = "";
|
||
let stderr = "";
|
||
const timer = setTimeout(() => {
|
||
child.kill("SIGKILL");
|
||
}, timeoutMs);
|
||
child.stdout.setEncoding("utf8");
|
||
child.stderr.setEncoding("utf8");
|
||
child.stdout.on("data", (chunk) => {
|
||
stdout += chunk;
|
||
});
|
||
child.stderr.on("data", (chunk) => {
|
||
stderr += chunk;
|
||
});
|
||
child.on("error", (error) => {
|
||
clearTimeout(timer);
|
||
resolve({ ok: false, stdout, stderr: error.message });
|
||
});
|
||
child.on("close", (code) => {
|
||
clearTimeout(timer);
|
||
resolve({ ok: code === 0, stdout: stdout.trim(), stderr: stderr.trim() });
|
||
});
|
||
});
|
||
}
|
||
|
||
async function findBossComputerUseHelperApp() {
|
||
for (const appPath of BOSS_COMPUTER_USE_HELPER_APP_CANDIDATES) {
|
||
try {
|
||
const info = await stat(appPath);
|
||
if (info.isDirectory()) return appPath;
|
||
} catch {
|
||
// Try the next installation location.
|
||
}
|
||
}
|
||
return "";
|
||
}
|
||
|
||
async function detectBossComputerUseHelperScreenRecording() {
|
||
const now = Date.now();
|
||
if (helperScreenRecordingCache.expiresAt > now) {
|
||
return helperScreenRecordingCache.status;
|
||
}
|
||
|
||
const helperApp = await findBossComputerUseHelperApp();
|
||
if (!helperApp) {
|
||
helperScreenRecordingCache = {
|
||
status: "unknown",
|
||
expiresAt: now + HELPER_SCREEN_RECORDING_CACHE_TTL_MS,
|
||
};
|
||
return helperScreenRecordingCache.status;
|
||
}
|
||
|
||
const screenshotPath = path.join(os.tmpdir(), `boss-helper-screen-permission-${now}.png`);
|
||
try {
|
||
const result = await runCommand(
|
||
"open",
|
||
["-W", "-na", helperApp, "--args", "screenshot", "--path", screenshotPath],
|
||
5000,
|
||
);
|
||
if (result.ok) {
|
||
const info = await stat(screenshotPath).catch(() => null);
|
||
if (info?.size > 1024) {
|
||
helperScreenRecordingCache = {
|
||
status: "granted",
|
||
expiresAt: now + HELPER_SCREEN_RECORDING_CACHE_TTL_MS,
|
||
};
|
||
return helperScreenRecordingCache.status;
|
||
}
|
||
}
|
||
} finally {
|
||
await rm(screenshotPath, { force: true }).catch(() => {});
|
||
}
|
||
|
||
helperScreenRecordingCache = {
|
||
status: "missing",
|
||
expiresAt: now + HELPER_SCREEN_RECORDING_CACHE_TTL_MS,
|
||
};
|
||
return helperScreenRecordingCache.status;
|
||
}
|
||
|
||
function normalizePermissionTarget(target = "core") {
|
||
return Object.hasOwn(MACOS_PERMISSION_SETTINGS, target) ? target : "core";
|
||
}
|
||
|
||
export function resolveBossAgentPermissionSettingsUrl(target = "core") {
|
||
return MACOS_PERMISSION_SETTINGS[normalizePermissionTarget(target)];
|
||
}
|
||
|
||
export async function openBossAgentPermissionSettings(target = "core", platform = process.platform) {
|
||
const normalizedTarget = normalizePermissionTarget(target);
|
||
const settingsUrl = resolveBossAgentPermissionSettingsUrl(normalizedTarget);
|
||
if (platform !== "darwin") {
|
||
return {
|
||
ok: false,
|
||
target: normalizedTarget,
|
||
settingsUrl,
|
||
message: "当前平台暂不支持自动打开系统隐私设置,请在系统设置中手动完成授权。",
|
||
};
|
||
}
|
||
|
||
const nativeUrl = `boss-agent://permissions/open?target=${encodeURIComponent(normalizedTarget)}&returnTab=permissions`;
|
||
const nativeLaunch = await runCommand(
|
||
"open",
|
||
[
|
||
"-na",
|
||
"/Applications/boss-agent.app",
|
||
"--args",
|
||
"--request-permission",
|
||
normalizedTarget,
|
||
"--return-tab",
|
||
"permissions",
|
||
],
|
||
2500,
|
||
);
|
||
if (nativeLaunch.ok) {
|
||
return {
|
||
ok: true,
|
||
target: normalizedTarget,
|
||
settingsUrl,
|
||
message: "已通过 boss-agent 发起系统权限申请。",
|
||
nativeRequest: true,
|
||
nativeUrl,
|
||
};
|
||
}
|
||
|
||
const nativeDeepLink = await runCommand("open", ["-b", "com.hyzq.boss.agent", nativeUrl], 2500);
|
||
if (nativeDeepLink.ok) {
|
||
return {
|
||
ok: true,
|
||
target: normalizedTarget,
|
||
settingsUrl,
|
||
message: "已通过 boss-agent 发起系统权限申请。",
|
||
nativeRequest: true,
|
||
nativeUrl,
|
||
};
|
||
}
|
||
|
||
const result = await runCommand("open", [settingsUrl], 2500);
|
||
return {
|
||
ok: result.ok,
|
||
target: normalizedTarget,
|
||
settingsUrl,
|
||
message: result.ok
|
||
? "已打开系统权限设置。"
|
||
: nativeLaunch.stderr
|
||
|| nativeLaunch.stdout
|
||
|| nativeDeepLink.stderr
|
||
|| nativeDeepLink.stdout
|
||
|| result.stderr
|
||
|| result.stdout
|
||
|| "打开系统权限设置失败。",
|
||
nativeRequest: false,
|
||
nativeUrl,
|
||
};
|
||
}
|
||
|
||
export async function detectLocalComputerPermissions(platform = process.platform) {
|
||
if (platform !== "darwin") {
|
||
return {
|
||
accessibility: "unknown",
|
||
screenRecording: "unknown",
|
||
};
|
||
}
|
||
|
||
const accessibility = await runCommand("osascript", [
|
||
"-e",
|
||
'tell application "System Events" to get UI elements enabled',
|
||
]);
|
||
const screenshotPath = path.join(os.tmpdir(), `boss-agent-permission-${Date.now()}.png`);
|
||
const screen = await runCommand("screencapture", ["-x", "-t", "png", screenshotPath], 3500);
|
||
let screenRecording = "missing";
|
||
if (screen.ok) {
|
||
try {
|
||
const info = await stat(screenshotPath);
|
||
screenRecording = info.size > 1024 ? "granted" : "unknown";
|
||
} catch {
|
||
screenRecording = "unknown";
|
||
} finally {
|
||
await rm(screenshotPath, { force: true }).catch(() => {});
|
||
}
|
||
}
|
||
|
||
const localProcessPermissions = {
|
||
accessibility: accessibility.ok && /true/i.test(accessibility.stdout) ? "granted" : "missing",
|
||
screenRecording,
|
||
};
|
||
const helperPermissions = mergeBossComputerUseHelperScreenRecordingPermission(
|
||
localProcessPermissions,
|
||
screenRecording === "granted" ? "unknown" : await detectBossComputerUseHelperScreenRecording(),
|
||
);
|
||
const appTccPermissions = mergeBossAgentAppTccPermissions(helperPermissions, await readBossAgentAppTccRows());
|
||
return mergeBossAgentStoredNativePermissions(appTccPermissions, await readBossAgentStoredNativePermissions());
|
||
}
|
||
|
||
async function readBossAgentAppTccRows() {
|
||
const outputs = [];
|
||
|
||
for (const dbPath of TCC_PERMISSION_DATABASES) {
|
||
try {
|
||
await stat(dbPath);
|
||
} catch {
|
||
continue;
|
||
}
|
||
|
||
for (const service of Object.keys(TCC_PERMISSION_SERVICES)) {
|
||
const clients = TCC_PERMISSION_CLIENTS[service] ?? [BOSS_AGENT_BUNDLE_ID];
|
||
const clientList = clients.map(sqlQuote).join(",");
|
||
const query = [
|
||
"select service,client,auth_value from access",
|
||
`where client in (${clientList})`,
|
||
`and service='${service}'`,
|
||
"order by auth_value desc",
|
||
].join(" ");
|
||
|
||
try {
|
||
const db = new DatabaseSync(dbPath, { readonly: true });
|
||
try {
|
||
const rows = db.prepare(query).all();
|
||
if (rows.length > 0) {
|
||
outputs.push(rows.map((row) => `${row.service}|${row.client}|${row.auth_value}`).join("\n"));
|
||
}
|
||
} finally {
|
||
db.close();
|
||
}
|
||
} catch {
|
||
// Keep the command-line reader below as a second source of truth.
|
||
}
|
||
|
||
const result = await runCommand("/usr/bin/sqlite3", [dbPath, `${query};`], 2500);
|
||
if (result.ok && result.stdout) {
|
||
outputs.push(result.stdout);
|
||
}
|
||
}
|
||
}
|
||
|
||
return outputs.join("\n");
|
||
}
|
||
|
||
async function readBossAgentStoredNativePermissions() {
|
||
const entries = await Promise.all(
|
||
Object.keys(NATIVE_PERMISSION_QUERY_PARAMS).map(async (permissionKey) => {
|
||
const result = await runCommand(
|
||
"/usr/bin/defaults",
|
||
["read", BOSS_AGENT_DEFAULTS_DOMAIN, `native.${permissionKey}`],
|
||
1500,
|
||
);
|
||
return [permissionKey, result.ok ? normalizePermissionStatus(result.stdout) : "unknown"];
|
||
}),
|
||
);
|
||
return Object.fromEntries(entries);
|
||
}
|