# Windows Client Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Restore the Windows proxy workflow as a script-first product with two install modes: full local `sing-box` + ProxiFyre, or ProxiFyre-only routing to an existing proxy, controlled by a clean local web UI. **Architecture:** Reuse the current Node API, React/Vite UI, subscription parser, `sing-box` config generation, and log surfaces. Add `APP_MODE=windows`, a Windows profile domain module, a ProxiFyre config generator, a structured PowerShell helper boundary, Windows-specific API routes, and a Windows overview UI that follows the approved clean mockup. **Tech Stack:** Node.js ESM, `node:test`, React 19/Vite 7, plain Node HTTP server, PowerShell 7, native `sing-box.exe`, ProxiFyre/WinPacketFilter. --- ## File Structure - Create `src/server/windowsProfiles.js`: profile normalization, proxy target normalization, executable resolution, ProxiFyre config generation, activity helpers. - Create `src/server/windowsHelper.js`: structured bridge from Node to PowerShell helper with mockable runner. - Modify `src/server/config.js`: accept `APP_MODE=windows`, add Windows data/helper paths, bind defaults used by installer. - Modify `src/server/singbox.js`: treat Windows as proxy-only local `sing-box` mode. - Modify `src/server/index.js`: expose `/api/windows/*` endpoints and include Windows status in public state. - Create `test/server/windows-profiles.test.js`: domain tests for profiles, targets, executable resolution, ProxiFyre config output. - Create `test/server/windows-helper.test.js`: JSON command contract tests with mock runner. - Create `test/server/singbox-windows-mode.test.js`: `APP_MODE=windows` config tests. - Create `src/web/components/WindowsOverviewPage.jsx`: clean UI surface for route status, profiles, profile details, activity. - Modify `src/web/App.jsx`: route Windows mode to Windows overview and hide gateway-only pages. - Modify `src/web/api.js`: add `api.windows`. - Modify `src/web/components/Sidebar.jsx` and `src/web/components/Topbar.jsx`: Windows labels/navigation. - Modify `src/web/styles.css`: add Windows clean UI styles. - Create `scripts/windows/VpnProxy.Windows.psm1`: privileged Windows operations. - Create `scripts/windows/helper.ps1`: JSON stdin/stdout command wrapper for the Node server. - Create `scripts/windows/manage.ps1`: local management commands. - Create `scripts/install-windows-client.ps1`: curl-friendly installer. - Modify `README.md`: add Windows install and recovery section. --- ### Task 1: Windows App Mode Config Contract **Files:** - Modify: `src/server/config.js` - Modify: `src/server/singbox.js` - Test: `test/server/singbox-windows-mode.test.js` - [ ] **Step 1: Write the failing Windows mode config test** Create `test/server/singbox-windows-mode.test.js`: ```js import assert from "node:assert/strict"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import test from "node:test"; process.env.APP_MODE = "windows"; process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "vpn-proxy-windows-test-")); process.env.SING_BOX_CACHE = path.join(process.env.DATA_DIR, "cache.db"); process.env.PROXY_PORT = "1080"; process.env.PROXY_BIND_IP = "127.0.0.1"; const { settings } = await import( `../../src/server/config.js?windows-mode=${Date.now()}` ); const { buildGatewayConfig } = await import( `../../src/server/singbox.js?windows-mode=${Date.now()}` ); const subscriptionConfig = { outbounds: [ { type: "vless", tag: "win-vpn", server: "vpn.example.test", server_port: 443, uuid: "00000000-0000-4000-8000-000000000000", tls: { enabled: true }, }, ], customRules: [], }; test("settings accepts APP_MODE=windows", () => { assert.equal(settings.appMode, "windows"); assert.equal(settings.proxyPort, 1080); assert.equal(settings.bindIp, "127.0.0.1"); }); test("windows mode exposes only local mixed proxy inbound", () => { const config = buildGatewayConfig(subscriptionConfig, "win-vpn"); assert.deepEqual(config.inbounds.map((inbound) => inbound.tag), ["mixed-in"]); assert.equal(config.inbounds[0].type, "mixed"); assert.equal(config.inbounds[0].listen, "127.0.0.1"); assert.equal(config.inbounds[0].listen_port, 1080); }); test("windows mode routes mixed proxy to selected VPN outbound", () => { const config = buildGatewayConfig(subscriptionConfig, "win-vpn"); assert.deepEqual(config.route.rule_set, []); assert.deepEqual(config.route.rules, [ { inbound: ["mixed-in"], outbound: "win-vpn" }, ]); assert.deepEqual(config.outbounds.map((outbound) => outbound.tag), [ "win-vpn", "direct", "block", ]); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: ```bash npm test -- test/server/singbox-windows-mode.test.js ``` Expected: FAIL because `settings.appMode` is `gateway` and generated config still includes `tproxy-in`. - [ ] **Step 3: Add Windows settings** In `src/server/config.js`, replace the current `settings` construction with this app-mode helper and added Windows paths: ```js import path from "node:path"; const dataDir = process.env.DATA_DIR || path.resolve(".vpn-proxy"); const rawAppMode = String(process.env.APP_MODE || "gateway").toLowerCase(); const appMode = ["gateway", "client", "windows"].includes(rawAppMode) ? rawAppMode : "gateway"; export const settings = { appMode, port: Number(process.env.PORT || 3456), proxyPort: Number(process.env.PROXY_PORT || 8080), clientProxyPortStart: Number(process.env.CLIENT_PROXY_PORT_START || 8080), clientProxyPortEnd: Number(process.env.CLIENT_PROXY_PORT_END || 8090), tproxyPort: Number(process.env.TPROXY_PORT || 7895), bindIp: process.env.PROXY_BIND_IP || "0.0.0.0", dataDir, distDir: process.env.DIST_DIR || "/app/dist", configPath: process.env.SING_BOX_CONFIG || path.join(dataDir, "sing-box-config.json"), cachePath: process.env.SING_BOX_CACHE || "/var/lib/sing-box/cache.db", statePath: path.join(dataDir, "state.json"), customRulesPath: path.join(dataDir, "custom-rules.json"), customRuleSetsPath: path.join(dataDir, "custom-rule-sets.json"), clientSettingsPath: path.join(dataDir, "client-settings.json"), devicesPath: path.join(dataDir, "devices.json"), deviceRulesPath: path.join(dataDir, "device-rules.json"), subscriptionCachePath: path.join(dataDir, "subscription-cache.json"), windowsProfilesPath: path.join(dataDir, "windows-profiles.json"), windowsTargetsPath: path.join(dataDir, "proxy-targets.json"), windowsStatePath: path.join(dataDir, "windows-state.json"), windowsActivityPath: path.join(dataDir, "windows-activity.json"), windowsHelperPath: process.env.WINDOWS_HELPER || path.resolve("scripts/windows/helper.ps1"), proxifyreConfigPath: process.env.PROXIFYRE_CONFIG || "C:\\Tools\\ProxiFyre\\app-config.json", sharedProxyHost: process.env.SHARED_PROXY_HOST || "", hwidPath: path.join(dataDir, "hwid"), routingRuDirect: String(process.env.ROUTING_RU_DIRECT || "true") !== "false", ruleSetDownloadDetour: process.env.RULE_SET_DOWNLOAD_DETOUR || "vpn", logLevel: process.env.LOG_LEVEL || "info", appName: appMode === "windows" ? "VPN Proxy Windows" : appMode === "client" ? "VPN Proxy Client" : "VPN Proxy Gateway", }; ``` - [ ] **Step 4: Make `singbox.js` proxy-only for Windows** In `src/server/singbox.js`, update `buildGatewayConfig()` around the existing client-mode logic: ```js const proxyOnlyMode = settings.appMode === "client" || settings.appMode === "windows"; const clientMode = settings.appMode === "client"; const clientSettings = clientMode ? readClientSettings() : null; ``` Then replace uses that currently mean "no tproxy" from `clientMode` to `proxyOnlyMode`: ```js ...(proxyOnlyMode ? [] : [ { type: "tproxy", tag: "tproxy-in", listen: "::", listen_port: settings.tproxyPort, sniff: true, sniff_override_destination: true, }, ]), ``` and: ```js rule_set: bypassAll || proxyOnlyMode ? [] : ruleSets(customRuleSets, vpnOutbound.tag), rules: bypassAll ? [{ ip_is_private: true, outbound: "direct" }] : proxyOnlyMode ? proxyOnlyRules : routeRules(subscriptionConfig.customRules, vpnOutbound.tag, { includeTransparent: !proxyOnlyMode, }), ``` Keep `clientSettings` behavior only for `client` mode so Windows uses `settings.proxyPort` and selected VPN outbound. - [ ] **Step 5: Run test to verify it passes** Run: ```bash npm test -- test/server/singbox-windows-mode.test.js ``` Expected: PASS. - [ ] **Step 6: Run existing config tests** Run: ```bash npm test -- test/server/singbox-client-mode.test.js ``` Expected: PASS. - [ ] **Step 7: Commit** ```bash git add src/server/config.js src/server/singbox.js test/server/singbox-windows-mode.test.js git commit -m "feat: add windows proxy-only app mode" ``` --- ### Task 2: Windows Profile Domain Model **Files:** - Create: `src/server/windowsProfiles.js` - Test: `test/server/windows-profiles.test.js` - [ ] **Step 1: Write failing profile tests** Create `test/server/windows-profiles.test.js`: ```js import assert from "node:assert/strict"; import test from "node:test"; import { buildProxiFyreConfig, normalizeProxyTargets, normalizeWindowsProfiles, resolveProfileItems, } from "../../src/server/windowsProfiles.js"; test("normalizeWindowsProfiles keeps process folder and exe source items", () => { const profiles = normalizeWindowsProfiles([ { id: "Discord + Vesktop", name: "Discord + Vesktop", enabled: true, proxyTargetId: "local-singbox", protocols: ["TCP", "UDP", "bad"], items: [ { type: "process", value: "Discord.exe" }, { type: "folder", value: "%LOCALAPPDATA%\\vesktop", recursive: true }, { type: "exe", value: "C:\\Games\\SomeGame\\game.exe" }, { type: "bad", value: "ignored" }, ], }, ]); assert.deepEqual(profiles, [ { id: "discord-vesktop", name: "Discord + Vesktop", enabled: true, proxyTargetId: "local-singbox", protocols: ["TCP", "UDP"], items: [ { type: "process", value: "Discord", recursive: false }, { type: "folder", value: "%LOCALAPPDATA%\\vesktop", recursive: true }, { type: "exe", value: "C:\\Games\\SomeGame\\game.exe", recursive: false }, ], }, ]); }); test("normalizeProxyTargets always includes local-singbox", () => { const targets = normalizeProxyTargets([ { id: "gateway", name: "Home gateway", host: "192.168.50.111", port: 8080 }, ]); assert.deepEqual(targets, [ { id: "local-singbox", name: "Local sing-box", protocol: "socks5", host: "127.0.0.1", port: 1080, managed: true, }, { id: "gateway", name: "Home gateway", protocol: "socks5", host: "192.168.50.111", port: 8080, managed: false, }, ]); }); test("resolveProfileItems expands folders and exe paths into process names", () => { const files = new Map([ ["C:\\Users\\me\\App\\a.exe", true], ["C:\\Users\\me\\App\\nested\\b.exe", true], ["C:\\Games\\Game\\game.exe", true], ]); const dirs = new Map([ ["C:\\Users\\me\\App", ["a.exe", "nested", "note.txt"]], ["C:\\Users\\me\\App\\nested", ["b.exe"]], ]); const fsAdapter = { existsSync: (value) => files.has(value) || dirs.has(value), statSync: (value) => ({ isDirectory: () => dirs.has(value), isFile: () => files.has(value), }), readdirSync: (value, options) => dirs.get(value).map((name) => ({ name, isDirectory: () => dirs.has(`${value}\\${name}`), isFile: () => files.has(`${value}\\${name}`), })), }; const resolved = resolveProfileItems( [ { type: "process", value: "Discord", recursive: false }, { type: "folder", value: "C:\\Users\\me\\App", recursive: true }, { type: "exe", value: "C:\\Games\\Game\\game.exe", recursive: false }, ], { fsAdapter, pathSep: "\\" }, ); assert.deepEqual(resolved.map((item) => item.appName), [ "Discord", "a", "b", "game", ]); }); test("buildProxiFyreConfig groups enabled profiles by target", () => { const profiles = normalizeWindowsProfiles([ { id: "discord", name: "Discord", enabled: true, proxyTargetId: "local-singbox", protocols: ["TCP", "UDP"], items: [{ type: "process", value: "Discord" }], }, { id: "work", name: "Work", enabled: true, proxyTargetId: "gateway", protocols: ["TCP"], items: [{ type: "process", value: "Code" }], }, { id: "off", name: "Disabled", enabled: false, proxyTargetId: "local-singbox", items: [{ type: "process", value: "Ignored" }], }, ]); const targets = normalizeProxyTargets([ { id: "gateway", name: "Gateway", host: "192.168.50.111", port: 8080 }, ]); const config = buildProxiFyreConfig(profiles, targets); assert.deepEqual(config, { logLevel: "Info", proxies: [ { appNames: ["Discord"], socks5ProxyEndpoint: "127.0.0.1:1080", supportedProtocols: ["TCP", "UDP"], }, { appNames: ["Code"], socks5ProxyEndpoint: "192.168.50.111:8080", supportedProtocols: ["TCP"], }, ], excludes: [], }); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: ```bash npm test -- test/server/windows-profiles.test.js ``` Expected: FAIL with module not found for `src/server/windowsProfiles.js`. - [ ] **Step 3: Implement `windowsProfiles.js`** Create `src/server/windowsProfiles.js`: ```js import fs from "node:fs"; import path from "node:path"; const ITEM_TYPES = new Set(["process", "folder", "exe"]); const PROTOCOLS = new Set(["TCP", "UDP"]); function slug(value, fallback) { const cleaned = String(value || "") .trim() .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, ""); return cleaned || fallback; } function cleanString(value) { return String(value || "").trim(); } function processName(value) { const base = cleanString(value).split(/[\\/]/).pop() || ""; return base.replace(/\.exe$/i, "").trim(); } function unique(values) { return Array.from(new Set(values.filter(Boolean))); } export function normalizeWindowsProfiles(input) { return (Array.isArray(input) ? input : []).map((profile, index) => { const name = cleanString(profile.name) || `Profile ${index + 1}`; const items = (Array.isArray(profile.items) ? profile.items : []) .filter((item) => ITEM_TYPES.has(item?.type)) .map((item) => ({ type: item.type, value: item.type === "process" ? processName(item.value) : cleanString(item.value), recursive: item.type === "folder" ? item.recursive !== false : false, })) .filter((item) => item.value); return { id: slug(profile.id || name, `profile-${index + 1}`), name, enabled: profile.enabled !== false, proxyTargetId: cleanString(profile.proxyTargetId) || "local-singbox", protocols: unique( (Array.isArray(profile.protocols) ? profile.protocols : ["TCP", "UDP"]) .map((protocol) => cleanString(protocol).toUpperCase()) .filter((protocol) => PROTOCOLS.has(protocol)), ), items, }; }).map((profile) => ({ ...profile, protocols: profile.protocols.length ? profile.protocols : ["TCP", "UDP"], })); } export function normalizeProxyTargets(input) { const local = { id: "local-singbox", name: "Local sing-box", protocol: "socks5", host: "127.0.0.1", port: 1080, managed: true, }; const seen = new Set([local.id]); const custom = (Array.isArray(input) ? input : []) .map((target, index) => ({ id: slug(target.id || target.name, `target-${index + 1}`), name: cleanString(target.name) || `Proxy target ${index + 1}`, protocol: cleanString(target.protocol || "socks5").toLowerCase() === "http" ? "http" : "socks5", host: cleanString(target.host), port: Number.parseInt(target.port, 10), managed: false, })) .filter((target) => { if (!target.host || !Number.isInteger(target.port)) return false; if (target.port <= 0 || target.port > 65535) return false; if (seen.has(target.id)) return false; seen.add(target.id); return true; }); return [local, ...custom]; } function joinPath(base, name, pathSep) { return base.endsWith(pathSep) ? `${base}${name}` : `${base}${pathSep}${name}`; } function walkExeFiles(dir, { fsAdapter, recursive, pathSep }) { const entries = fsAdapter.readdirSync(dir, { withFileTypes: true }); const results = []; for (const entry of entries) { const fullPath = joinPath(dir, entry.name, pathSep); if (entry.isFile() && /\.exe$/i.test(entry.name)) results.push(fullPath); if (recursive && entry.isDirectory()) { results.push(...walkExeFiles(fullPath, { fsAdapter, recursive, pathSep })); } } return results; } export function resolveProfileItems(items, options = {}) { const fsAdapter = options.fsAdapter || fs; const pathSep = options.pathSep || path.sep; const resolved = []; for (const item of Array.isArray(items) ? items : []) { if (item.type === "process") { const appName = processName(item.value); if (appName) resolved.push({ ...item, appName, source: item.value }); } if (item.type === "exe") { const appName = processName(item.value); if (appName) resolved.push({ ...item, appName, source: item.value }); } if (item.type === "folder" && fsAdapter.existsSync(item.value)) { const stat = fsAdapter.statSync(item.value); if (stat.isDirectory()) { for (const filePath of walkExeFiles(item.value, { fsAdapter, recursive: item.recursive !== false, pathSep, })) { resolved.push({ ...item, appName: processName(filePath), source: filePath }); } } } } const byName = new Map(); for (const item of resolved) if (!byName.has(item.appName)) byName.set(item.appName, item); return Array.from(byName.values()); } export function buildProxiFyreConfig(profiles, targets, options = {}) { const normalizedTargets = normalizeProxyTargets(targets); const targetById = new Map(normalizedTargets.map((target) => [target.id, target])); const groups = new Map(); for (const profile of normalizeWindowsProfiles(profiles).filter((item) => item.enabled)) { const target = targetById.get(profile.proxyTargetId) || targetById.get("local-singbox"); const resolved = resolveProfileItems(profile.items, options); if (!target || resolved.length === 0) continue; const key = `${target.id}|${profile.protocols.join(",")}`; const existing = groups.get(key) || { appNames: [], socks5ProxyEndpoint: `${target.host}:${target.port}`, supportedProtocols: profile.protocols, }; existing.appNames.push(...resolved.map((item) => item.appName)); existing.appNames = unique(existing.appNames).sort((a, b) => a.localeCompare(b)); groups.set(key, existing); } return { logLevel: "Info", proxies: Array.from(groups.values()), excludes: [], }; } ``` - [ ] **Step 4: Run test to verify it passes** Run: ```bash npm test -- test/server/windows-profiles.test.js ``` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add src/server/windowsProfiles.js test/server/windows-profiles.test.js git commit -m "feat: add windows profile model" ``` --- ### Task 3: Windows Helper Bridge **Files:** - Create: `src/server/windowsHelper.js` - Test: `test/server/windows-helper.test.js` - [ ] **Step 1: Write failing helper bridge tests** Create `test/server/windows-helper.test.js`: ```js import assert from "node:assert/strict"; import test from "node:test"; import { createWindowsHelper } from "../../src/server/windowsHelper.js"; test("windows helper sends action and payload as JSON", async () => { const calls = []; const helper = createWindowsHelper({ helperPath: "scripts/windows/helper.ps1", runner: async (command, args, options) => { calls.push({ command, args, input: options.input }); return { status: 0, stdout: JSON.stringify({ success: true, action: "status.get", result: { proxifyre: "Running" }, }), stderr: "", }; }, }); const result = await helper.run("status.get", { service: "ProxiFyre" }); assert.deepEqual(result, { success: true, action: "status.get", result: { proxifyre: "Running" }, }); assert.equal(calls[0].command, "pwsh"); assert.deepEqual(calls[0].args, [ "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", "scripts/windows/helper.ps1", ]); assert.deepEqual(JSON.parse(calls[0].input), { action: "status.get", payload: { service: "ProxiFyre" }, }); }); test("windows helper normalizes non-zero exit into structured error", async () => { const helper = createWindowsHelper({ helperPath: "scripts/windows/helper.ps1", runner: async () => ({ status: 1, stdout: "", stderr: "service failed", }), }); await assert.rejects( () => helper.run("service.restart", { name: "proxifyre" }), /Windows helper failed: service failed/, ); }); test("windows helper rejects invalid JSON stdout", async () => { const helper = createWindowsHelper({ helperPath: "scripts/windows/helper.ps1", runner: async () => ({ status: 0, stdout: "not-json", stderr: "", }), }); await assert.rejects( () => helper.run("status.get", {}), /Windows helper returned invalid JSON/, ); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: ```bash npm test -- test/server/windows-helper.test.js ``` Expected: FAIL with module not found for `src/server/windowsHelper.js`. - [ ] **Step 3: Implement helper bridge** Create `src/server/windowsHelper.js`: ```js import { spawn } from "node:child_process"; import { settings } from "./config.js"; function defaultRunner(command, args, options = {}) { return new Promise((resolve) => { const child = spawn(command, args, { stdio: ["pipe", "pipe", "pipe"], windowsHide: true, }); let stdout = ""; let stderr = ""; child.stdout.on("data", (chunk) => { stdout += chunk.toString("utf8"); }); child.stderr.on("data", (chunk) => { stderr += chunk.toString("utf8"); }); child.on("error", (error) => { resolve({ status: 1, stdout, stderr: error.message }); }); child.on("close", (status) => { resolve({ status, stdout, stderr }); }); child.stdin.end(options.input || ""); }); } export function createWindowsHelper(options = {}) { const helperPath = options.helperPath || settings.windowsHelperPath; const command = options.command || "pwsh"; const runner = options.runner || defaultRunner; return { async run(action, payload = {}) { const input = JSON.stringify({ action, payload }); const result = await runner( command, ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", helperPath], { input }, ); if (result.status !== 0) { throw new Error( `Windows helper failed: ${(result.stderr || result.stdout || "helper exited without stderr").trim()}`, ); } try { return JSON.parse(result.stdout); } catch { throw new Error(`Windows helper returned invalid JSON: ${result.stdout}`); } }, }; } export const windowsHelper = createWindowsHelper(); ``` - [ ] **Step 4: Run test to verify it passes** Run: ```bash npm test -- test/server/windows-helper.test.js ``` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add src/server/windowsHelper.js test/server/windows-helper.test.js git commit -m "feat: add windows helper bridge" ``` --- ### Task 4: Windows API Endpoints **Files:** - Modify: `src/server/index.js` - Modify: `src/server/config.js` - Test: `test/server/windows-api-model.test.js` - [ ] **Step 1: Extract Windows model functions for API use** Before editing routes, add these exports to `src/server/windowsProfiles.js` below existing exports: ```js 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, }; } ``` - [ ] **Step 2: Write focused API model test** Create `test/server/windows-api-model.test.js`: ```js 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"]); }); ``` - [ ] **Step 3: Run test to verify it passes** Run: ```bash npm test -- test/server/windows-api-model.test.js ``` Expected: PASS. - [ ] **Step 4: Add imports to `src/server/index.js`** At the top of `src/server/index.js`, add: ```js import { buildProxiFyreConfig, createActivityEntry, normalizeProxyTargets, normalizeWindowsProfiles, summarizeProfiles, } from "./windowsProfiles.js"; import { windowsHelper } from "./windowsHelper.js"; ``` - [ ] **Step 5: Add Windows state helpers to `src/server/index.js`** Place these helpers near `normalizeCustomRules()`: ```js 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, }; } ``` - [ ] **Step 6: Include Windows status in public state** In `publicState()`, add: ```js windows: settings.appMode === "windows" ? { profiles: summarizeProfiles(readWindowsProfiles(), readProxyTargets()), targets: readProxyTargets(), activity: readWindowsActivity().slice(-20).reverse(), } : null, ``` - [ ] **Step 7: Add `/api/windows/*` routes** In the request handler before static serving, add: ```js 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 }); } ``` - [ ] **Step 8: Run server tests** Run: ```bash npm test ``` Expected: PASS. - [ ] **Step 9: Commit** ```bash git add src/server/index.js src/server/windowsProfiles.js test/server/windows-api-model.test.js git commit -m "feat: add windows API model" ``` --- ### Task 5: Clean Windows UI **Files:** - Create: `src/web/components/WindowsOverviewPage.jsx` - Modify: `src/web/api.js` - Modify: `src/web/App.jsx` - Modify: `src/web/components/Sidebar.jsx` - Modify: `src/web/components/Topbar.jsx` - Modify: `src/web/styles.css` - [ ] **Step 1: Add Windows API client methods** In `src/web/api.js`, add this object before `configValidate`: ```js windows: { status: () => request("/api/windows/status"), profiles: { get: () => request("/api/windows/profiles"), save: (profiles) => request("/api/windows/profiles", { method: "PUT", body: JSON.stringify({ profiles }), }), scan: (profiles) => request("/api/windows/profiles/scan", { method: "POST", body: JSON.stringify({ profiles }), }), apply: () => request("/api/windows/profiles/apply", { method: "POST", }), }, targets: { get: () => request("/api/windows/targets"), save: (targets) => request("/api/windows/targets", { method: "PUT", body: JSON.stringify({ targets }), }), }, service: (service, action) => request("/api/windows/service", { method: "POST", body: JSON.stringify({ service, action }), }), logs: () => request("/api/windows/logs"), }, ``` - [ ] **Step 2: Create Windows overview component** Create `src/web/components/WindowsOverviewPage.jsx`: ```jsx import React, { useEffect, useMemo, useState } from 'react'; import { api } from '../api.js'; function targetLabel(target) { if (!target) return 'No proxy target'; return `${target.name} - ${target.host}:${target.port}`; } function routeTitle(status) { const helper = status?.helperStatus; const proxifyre = helper?.result?.proxifyre || helper?.proxifyre; const singbox = helper?.result?.singbox || helper?.singbox; if (proxifyre === 'Running' && singbox === 'Running') return 'Apps are routed through local sing-box'; if (proxifyre === 'Running') return 'Apps are routed through an existing proxy'; return 'App routing is stopped'; } function emptyProfile() { return { id: `profile-${Date.now()}`, name: 'New profile', enabled: true, proxyTargetId: 'local-singbox', protocols: ['TCP', 'UDP'], items: [], }; } function ProfileList({ profiles, selectedId, onSelect }) { return (
Profiles send selected apps through ProxiFyre to local sing-box or an existing proxy target.