diff --git a/docs/superpowers/plans/2026-05-21-windows-client.md b/docs/superpowers/plans/2026-05-21-windows-client.md new file mode 100644 index 0000000..9ca0886 --- /dev/null +++ b/docs/superpowers/plans/2026-05-21-windows-client.md @@ -0,0 +1,2190 @@ +# 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.
+