diff --git a/src/server/index.js b/src/server/index.js index 0eeeccc..c4ea382 100644 --- a/src/server/index.js +++ b/src/server/index.js @@ -27,6 +27,14 @@ import { } from "./sharedProxy.js"; import { matchRoute, detectRuleConflicts } from "./routeMatcher.js"; import { tcpPing, resolveHost } from "./ping.js"; +import { + buildProxiFyreConfig, + createActivityEntry, + normalizeProxyTargets, + normalizeWindowsProfiles, + summarizeProfiles, +} from "./windowsProfiles.js"; +import { windowsHelper } from "./windowsHelper.js"; const APPLY_HISTORY_LIMIT = 10; const RULE_SET_TAG_RE = /^[a-z0-9][a-z0-9_.@!-]*$/i; @@ -603,6 +611,8 @@ function publicState() { const customRules = readJson(settings.customRulesPath, []); const deviceProfiles = readDeviceProfiles(); const clientSettings = readClientSettings(); + const windowsTargets = + settings.appMode === "windows" ? readProxyTargets() : []; const { subscriptionUrl, ...rest } = state; return { mode: settings.appMode, @@ -634,6 +644,17 @@ function publicState() { directBypassCount, directBypassEnabled: DIRECT_BYPASS_CACHE, directBypassAvailable: IPSET_AVAILABLE, + windows: + settings.appMode === "windows" + ? { + profiles: summarizeProfiles( + readWindowsProfiles(), + windowsTargets, + ), + targets: windowsTargets, + activity: readWindowsActivity().slice(-20).reverse(), + } + : null, ...rest, }; } @@ -686,6 +707,62 @@ function normalizeDeviceRules(input) { })); } +function readWindowsProfiles() { + return normalizeWindowsProfiles(readJson(settings.windowsProfilesPath, [])); +} + +function writeWindowsProfiles(profiles) { + const normalized = normalizeWindowsProfiles(profiles); + writeJson(settings.windowsProfilesPath, normalized); + return normalized; +} + +function readProxyTargets() { + return normalizeProxyTargets(readJson(settings.windowsTargetsPath, [])); +} + +function writeProxyTargets(targets) { + const normalized = normalizeProxyTargets(targets); + writeJson( + settings.windowsTargetsPath, + normalized.filter((target) => !target.managed), + ); + return normalized; +} + +function readWindowsActivity() { + return readJson(settings.windowsActivityPath, []).slice(-100); +} + +function pushWindowsActivity(type, message, details = {}) { + const activity = readWindowsActivity(); + const entry = createActivityEntry(type, message, details); + writeJson(settings.windowsActivityPath, [...activity, entry].slice(-100)); + return entry; +} + +async function getWindowsStatus() { + let helperStatus = null; + if (settings.appMode === "windows") { + try { + helperStatus = await windowsHelper.run("status.get", {}); + } catch (error) { + helperStatus = { success: false, error: error.message }; + } + } + const profiles = readWindowsProfiles(); + const targets = readProxyTargets(); + return { + mode: settings.appMode, + installMode: + readJson(settings.windowsStatePath, {}).installMode || "not-configured", + profiles: summarizeProfiles(profiles, targets), + targets, + activity: readWindowsActivity().slice(-20).reverse(), + helperStatus, + }; +} + async function applySelectedServer(selectedTag) { const cached = readJson(settings.subscriptionCachePath, null); if (!cached?.config) { @@ -809,6 +886,99 @@ async function handleApi(req, res) { return sendJson(res, 200, { success: true, config }); } + if (req.method === "GET" && req.url === "/api/windows/status") { + return sendJson(res, 200, { success: true, ...(await getWindowsStatus()) }); + } + + if (req.method === "GET" && req.url === "/api/windows/profiles") { + const profiles = readWindowsProfiles(); + const targets = readProxyTargets(); + return sendJson(res, 200, { + success: true, + profiles, + summaries: summarizeProfiles(profiles, targets), + }); + } + + if (req.method === "PUT" && req.url === "/api/windows/profiles") { + const body = await readBody(req); + const profiles = writeWindowsProfiles(body.profiles || []); + pushWindowsActivity("profiles.saved", "Profiles saved", { + count: profiles.length, + }); + return sendJson(res, 200, { + success: true, + profiles, + summaries: summarizeProfiles(profiles, readProxyTargets()), + }); + } + + if (req.method === "POST" && req.url === "/api/windows/profiles/scan") { + const body = await readBody(req); + const profiles = normalizeWindowsProfiles(body.profiles || []); + return sendJson(res, 200, { + success: true, + summaries: summarizeProfiles(profiles, readProxyTargets()), + }); + } + + if (req.method === "POST" && req.url === "/api/windows/profiles/apply") { + const profiles = readWindowsProfiles(); + const targets = readProxyTargets(); + const proxifyreConfig = buildProxiFyreConfig(profiles, targets); + const helperResult = await windowsHelper.run("proxifyre.apply", { + configPath: settings.proxifyreConfigPath, + config: proxifyreConfig, + }); + pushWindowsActivity("profiles.applied", "ProxiFyre config applied", { + proxyGroups: proxifyreConfig.proxies.length, + }); + return sendJson(res, 200, { + success: true, + config: proxifyreConfig, + helperResult, + }); + } + + if (req.method === "GET" && req.url === "/api/windows/targets") { + return sendJson(res, 200, { success: true, targets: readProxyTargets() }); + } + + if (req.method === "PUT" && req.url === "/api/windows/targets") { + const body = await readBody(req); + const targets = writeProxyTargets(body.targets || []); + pushWindowsActivity("targets.saved", "Proxy targets saved", { + count: targets.length, + }); + return sendJson(res, 200, { success: true, targets }); + } + + if (req.method === "POST" && req.url === "/api/windows/service") { + const body = await readBody(req); + const service = String(body.service || ""); + const action = String(body.action || ""); + if (!["sing-box", "proxifyre", "ui"].includes(service)) { + return sendJson(res, 400, { success: false, error: "Unknown service" }); + } + if (!["start", "stop", "restart"].includes(action)) { + return sendJson(res, 400, { success: false, error: "Unknown action" }); + } + const helperResult = await windowsHelper.run("service.control", { + service, + action, + }); + pushWindowsActivity("service.control", `${service} ${action}`, { + service, + action, + }); + return sendJson(res, 200, { success: true, helperResult }); + } + + if (req.method === "GET" && req.url === "/api/windows/logs") { + const helperResult = await windowsHelper.run("logs.get", {}); + return sendJson(res, 200, { success: true, helperResult }); + } + if (req.method === "GET" && req.url === "/api/logs") { return sendJson(res, 200, { success: true, logs: logBuffer.slice(-200) }); } diff --git a/src/server/windowsProfiles.js b/src/server/windowsProfiles.js index e114f66..f6de6a1 100644 --- a/src/server/windowsProfiles.js +++ b/src/server/windowsProfiles.js @@ -180,3 +180,31 @@ export function buildProxiFyreConfig(profiles, targets, options = {}) { excludes: [], }; } + +export function summarizeProfiles(profiles, targets, options = {}) { + const normalizedTargets = normalizeProxyTargets(targets); + const targetById = new Map( + normalizedTargets.map((target) => [target.id, target]), + ); + return normalizeWindowsProfiles(profiles).map((profile) => { + const resolvedItems = resolveProfileItems(profile.items, options); + const target = + targetById.get(profile.proxyTargetId) || targetById.get("local-singbox"); + return { + ...profile, + target, + resolvedCount: resolvedItems.length, + resolvedItems, + }; + }); +} + +export function createActivityEntry(type, message, details = {}) { + return { + id: `${Date.now()}-${Math.random().toString(16).slice(2)}`, + ts: new Date().toISOString(), + type, + message, + details, + }; +} diff --git a/test/server/windows-api-model.test.js b/test/server/windows-api-model.test.js new file mode 100644 index 0000000..6632dc3 --- /dev/null +++ b/test/server/windows-api-model.test.js @@ -0,0 +1,26 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + buildProxiFyreConfig, + normalizeProxyTargets, + normalizeWindowsProfiles, + summarizeProfiles, +} from "../../src/server/windowsProfiles.js"; + +test("windows API model returns summaries and generated config", () => { + const profiles = normalizeWindowsProfiles([ + { + name: "Discord", + proxyTargetId: "local-singbox", + items: [{ type: "process", value: "Discord" }], + }, + ]); + const targets = normalizeProxyTargets([]); + + const summaries = summarizeProfiles(profiles, targets); + const config = buildProxiFyreConfig(profiles, targets); + + assert.equal(summaries[0].resolvedCount, 1); + assert.equal(summaries[0].target.id, "local-singbox"); + assert.deepEqual(config.proxies[0].appNames, ["Discord"]); +});