From b5d4c617831c95c443bdac18ed91ce24c3c7008c Mon Sep 17 00:00:00 2001 From: Dmitriy Petrov Date: Thu, 21 May 2026 20:04:51 +0300 Subject: [PATCH] docs: add windows client implementation plan --- .../plans/2026-05-21-windows-client.md | 2190 +++++++++++++++++ 1 file changed, 2190 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-21-windows-client.md 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.map((profile) => ( + + ))} +
+ ); +} + +function ProfileDetails({ profile, targets, onChange }) { + const [newItem, setNewItem] = useState(''); + const [newType, setNewType] = useState('process'); + if (!profile) { + return
Select or add a profile.
; + } + function patch(patchValue) { + onChange({ ...profile, ...patchValue }); + } + function addItem() { + const value = newItem.trim(); + if (!value) return; + patch({ + items: [ + ...profile.items, + { type: newType, value, recursive: newType === 'folder' }, + ], + }); + setNewItem(''); + } + return ( +
+ + +
+ + setNewItem(event.target.value)} + onKeyDown={(event) => event.key === 'Enter' && addItem()} + /> + +
+
+ {profile.items.map((item, index) => ( +
+ {item.value} + {item.type} + +
+ ))} +
+
+ ); +} + +export function WindowsOverviewPage({ pushToast }) { + const [status, setStatus] = useState(null); + const [profiles, setProfiles] = useState([]); + const [targets, setTargets] = useState([]); + const [selectedId, setSelectedId] = useState(''); + const [busy, setBusy] = useState(false); + + async function load() { + const data = await api.windows.status(); + setStatus(data); + const nextProfiles = data.profiles || []; + setProfiles(nextProfiles); + setTargets(data.targets || []); + setSelectedId((current) => current || nextProfiles[0]?.id || ''); + } + + useEffect(() => { + load().catch((error) => pushToast?.({ kind: 'danger', title: 'Windows status failed', message: error.message })); + const timer = setInterval(() => load().catch(() => {}), 5000); + return () => clearInterval(timer); + }, []); + + const selected = useMemo( + () => profiles.find((profile) => profile.id === selectedId) || null, + [profiles, selectedId], + ); + + function replaceProfile(nextProfile) { + setProfiles((prev) => prev.map((profile) => profile.id === nextProfile.id ? nextProfile : profile)); + } + + async function saveProfiles(nextProfiles = profiles) { + setBusy(true); + try { + const data = await api.windows.profiles.save(nextProfiles); + setProfiles(data.summaries || data.profiles || []); + pushToast?.({ kind: 'success', title: 'Profiles saved' }); + } finally { + setBusy(false); + } + } + + async function applyProfiles() { + setBusy(true); + try { + await api.windows.profiles.save(profiles); + await api.windows.profiles.apply(); + await load(); + pushToast?.({ kind: 'success', title: 'ProxiFyre updated' }); + } finally { + setBusy(false); + } + } + + function addProfile() { + const profile = emptyProfile(); + setProfiles((prev) => [...prev, profile]); + setSelectedId(profile.id); + } + + return ( +
+
+
+ +
+

{routeTitle(status)}

+

Profiles send selected apps through ProxiFyre to local sing-box or an existing proxy target.

+
+
+
+ Selected apps->ProxiFyre->Proxy target +
+
+ +
+
+
+
+

Profiles

+ {profiles.filter((profile) => profile.enabled).length} enabled +
+ +
+ +
+ +
+
+
+

{selected?.name || 'Profile'}

+ {selected ? targetLabel(targets.find((target) => target.id === selected.proxyTargetId)) : 'No selection'} +
+ +
+ +
+
+ +
+
+

Recent activity

+ +
+ {(status?.activity || []).slice(0, 5).map((entry) => ( +
+ {entry.type} + {entry.message} + {entry.ts} +
+ ))} +
+
+ ); +} +``` + +- [ ] **Step 3: Wire Windows page in `App.jsx`** + +Add import: + +```jsx +import { WindowsOverviewPage } from './components/WindowsOverviewPage.jsx'; +``` + +Add this after `const isClientMode = state?.mode === 'client';` if present, or near equivalent mode checks: + +```jsx + const isWindowsMode = state?.mode === 'windows'; +``` + +Update the client-mode redirect effect to include Windows: + +```jsx + useEffect(() => { + if ((state?.mode === 'client' || state?.mode === 'windows') && page !== 'overview') { + navigate('overview'); + } + }, [state?.mode, page]); +``` + +In the page rendering section, render Windows overview before generic overview: + +```jsx + {page === 'overview' && isWindowsMode && ( + + )} + {page === 'overview' && isClientMode && !isWindowsMode && ( + + )} +``` + +Keep existing gateway overview rendering under `!isClientMode && !isWindowsMode`. + +- [ ] **Step 4: Update sidebar/topbar labels** + +In `src/web/components/Sidebar.jsx`, make Windows nav compact: + +```jsx +const WINDOWS_ITEMS = [ + { id: 'overview', label: 'Overview', ico: 'O' }, + { id: 'logs', label: 'Logs', ico: 'L' }, + { id: 'settings', label: 'Settings', ico: 'S' }, +]; +``` + +Then select items: + +```jsx + const items = mode === 'windows' + ? WINDOWS_ITEMS + : mode === 'client' + ? CLIENT_ITEMS + : ITEMS; +``` + +In `src/web/components/Topbar.jsx`, display `VPN Proxy Windows` when `state?.mode === 'windows'`. + +- [ ] **Step 5: Add CSS** + +Append to `src/web/styles.css`: + +```css +.windows-page { + display: grid; + gap: 16px; +} + +.windows-status-panel { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 16px; + align-items: center; + padding: 16px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--panel); +} + +.windows-status-main { + display: flex; + gap: 12px; + align-items: flex-start; +} + +.windows-status-dot { + width: 12px; + height: 12px; + border-radius: 999px; + background: var(--success); + margin-top: 8px; + box-shadow: 0 0 0 5px rgba(34, 197, 94, 0.12); +} + +.windows-status-panel h1 { + margin: 0 0 6px; + font-size: 24px; +} + +.windows-status-panel p { + margin: 0; + color: var(--muted); +} + +.windows-route-line { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + color: var(--muted); + font-size: 13px; +} + +.windows-route-line span { + border: 1px solid var(--border); + border-radius: 999px; + padding: 6px 10px; + background: var(--bg-soft); +} + +.windows-workspace { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(320px, 420px); + gap: 16px; +} + +.win-profile-list { + display: grid; + gap: 8px; + padding: 10px; +} + +.win-profile-row { + display: grid; + grid-template-columns: 26px minmax(0, 1fr) auto; + gap: 10px; + align-items: center; + width: 100%; + text-align: left; + border: 1px solid transparent; + border-radius: 8px; + padding: 10px; + background: transparent; + color: var(--text); +} + +.win-profile-row.active { + border-color: var(--border-strong); + background: var(--bg-soft); +} + +.win-profile-row strong, +.win-profile-row small { + display: block; +} + +.win-profile-row small, +.win-profile-row em { + color: var(--muted); + font-size: 12px; + font-style: normal; +} + +.win-profile-check { + width: 22px; + height: 22px; + border-radius: 999px; + display: grid; + place-items: center; + background: var(--border); + color: var(--muted); + font-size: 12px; +} + +.win-profile-check.on { + background: var(--accent); + color: #fff; +} + +.win-detail { + display: grid; + gap: 12px; + padding: 14px; +} + +.win-detail label { + display: grid; + gap: 6px; + color: var(--muted); + font-size: 12px; +} + +.win-add-item { + display: grid; + grid-template-columns: 110px minmax(0, 1fr) auto; + gap: 8px; +} + +.win-items { + display: grid; + gap: 8px; +} + +.win-item { + display: grid; + grid-template-columns: minmax(0, 1fr) auto auto; + gap: 8px; + align-items: center; + border: 1px solid var(--border); + border-radius: 7px; + padding: 8px; +} + +.win-item small { + color: var(--muted); +} + +.windows-activity-row { + display: grid; + grid-template-columns: 130px minmax(0, 1fr) auto; + gap: 10px; + padding: 10px 14px; + border-top: 1px solid var(--border); + color: var(--muted); + font-size: 13px; +} + +.windows-activity-row strong { + color: var(--info); +} + +@media (max-width: 960px) { + .windows-status-panel, + .windows-workspace, + .windows-activity-row { + grid-template-columns: 1fr; + } + + .win-add-item { + grid-template-columns: 1fr; + } +} +``` + +- [ ] **Step 6: Build** + +Run: + +```bash +npm run build +``` + +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add src/web/api.js src/web/App.jsx src/web/components/WindowsOverviewPage.jsx src/web/components/Sidebar.jsx src/web/components/Topbar.jsx src/web/styles.css +git commit -m "feat: add windows client UI" +``` + +--- + +### Task 6: PowerShell Helper Scripts + +**Files:** +- Create: `scripts/windows/VpnProxy.Windows.psm1` +- Create: `scripts/windows/helper.ps1` +- Create: `scripts/windows/manage.ps1` + +- [ ] **Step 1: Create helper module** + +Create `scripts/windows/VpnProxy.Windows.psm1`: + +```powershell +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +$script:InstallRoot = $env:VPN_PROXY_WINDOWS_ROOT +if ([string]::IsNullOrWhiteSpace($script:InstallRoot)) { + $script:InstallRoot = "C:\Tools\vpn-proxy-windows" +} +$script:ProxiFyreRoot = $env:PROXIFYRE_ROOT +if ([string]::IsNullOrWhiteSpace($script:ProxiFyreRoot)) { + $script:ProxiFyreRoot = "C:\Tools\ProxiFyre" +} + +function New-VpnProxyResult { + param( + [string]$Action, + [bool]$Success, + [object]$Result = $null, + [string]$Message = "", + [string]$ErrorMessage = "" + ) + $value = [ordered]@{ + success = $Success + action = $Action + } + if ($null -ne $Result) { $value.result = $Result } + if ($Message) { $value.message = $Message } + if ($ErrorMessage) { $value.error = $ErrorMessage } + return $value +} + +function Get-VpnProxyStatus { + $task = Get-ScheduledTask -TaskName "SingBoxProxy" -ErrorAction SilentlyContinue + $singboxProcess = Get-Process -Name "sing-box" -ErrorAction SilentlyContinue + $proxifyre = Get-Service -Name "ProxiFyreService" -ErrorAction SilentlyContinue + return [ordered]@{ + singbox = if ($singboxProcess) { "Running" } elseif ($task) { [string]$task.State } else { "NotInstalled" } + proxifyre = if ($proxifyre) { [string]$proxifyre.Status } else { "NotInstalled" } + installRoot = $script:InstallRoot + proxifyreRoot = $script:ProxiFyreRoot + } +} + +function Write-ProxiFyreConfig { + param( + [Parameter(Mandatory=$true)][string]$ConfigPath, + [Parameter(Mandatory=$true)][object]$Config + ) + $dir = Split-Path -Parent $ConfigPath + New-Item -ItemType Directory -Force -Path $dir | Out-Null + if (Test-Path $ConfigPath) { + $backup = "$ConfigPath.bak" + Copy-Item $ConfigPath $backup -Force + } + $Config | ConvertTo-Json -Depth 20 | Set-Content -Path $ConfigPath -Encoding UTF8 +} + +function Restart-ProxiFyre { + $exe = Join-Path $script:ProxiFyreRoot "ProxiFyre.exe" + if (-not (Test-Path $exe)) { + throw "ProxiFyre.exe not found at $exe" + } + & $exe stop 2>$null | Out-Null + & $exe install 2>$null | Out-Null + & $exe start 2>$null | Out-Null +} + +function Invoke-ProxiFyreApply { + param([object]$Payload) + Write-ProxiFyreConfig -ConfigPath $Payload.configPath -Config $Payload.config + Restart-ProxiFyre + return New-VpnProxyResult -Action "proxifyre.apply" -Success $true -Message "ProxiFyre config applied and service restarted" +} + +function Invoke-ServiceControl { + param([object]$Payload) + $service = [string]$Payload.service + $action = [string]$Payload.action + if ($service -eq "proxifyre") { + if ($action -eq "restart") { Restart-ProxiFyre } + elseif ($action -eq "start") { Start-Service -Name "ProxiFyreService" } + elseif ($action -eq "stop") { Stop-Service -Name "ProxiFyreService" -Force } + } elseif ($service -eq "sing-box") { + if ($action -eq "restart") { + Stop-ScheduledTask -TaskName "SingBoxProxy" -ErrorAction SilentlyContinue + Start-ScheduledTask -TaskName "SingBoxProxy" + } elseif ($action -eq "start") { + Start-ScheduledTask -TaskName "SingBoxProxy" + } elseif ($action -eq "stop") { + Stop-ScheduledTask -TaskName "SingBoxProxy" + } + } elseif ($service -eq "ui") { + return New-VpnProxyResult -Action "service.control" -Success $true -Message "UI is controlled by manage.ps1 -OpenUi" + } else { + throw "Unknown service: $service" + } + return New-VpnProxyResult -Action "service.control" -Success $true -Message "$service $action complete" +} + +function Get-VpnProxyLogs { + $paths = @( + (Join-Path $script:InstallRoot "runtime\sing-box\singbox.log"), + (Join-Path $script:ProxiFyreRoot "ProxiFyre.log") + ) + $logs = @() + foreach ($path in $paths) { + if (Test-Path $path) { + $logs += [ordered]@{ + path = $path + lines = @(Get-Content $path -Tail 120 -ErrorAction SilentlyContinue) + } + } + } + return $logs +} + +function Invoke-VpnProxyAction { + param( + [Parameter(Mandatory=$true)][string]$Action, + [object]$Payload = @{} + ) + switch ($Action) { + "status.get" { + return New-VpnProxyResult -Action $Action -Success $true -Result (Get-VpnProxyStatus) + } + "proxifyre.apply" { + return Invoke-ProxiFyreApply -Payload $Payload + } + "service.control" { + return Invoke-ServiceControl -Payload $Payload + } + "logs.get" { + return New-VpnProxyResult -Action $Action -Success $true -Result (Get-VpnProxyLogs) + } + default { + throw "Unknown action: $Action" + } + } +} + +Export-ModuleMember -Function Invoke-VpnProxyAction, Get-VpnProxyStatus +``` + +- [ ] **Step 2: Create JSON wrapper** + +Create `scripts/windows/helper.ps1`: + +```powershell +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +Import-Module (Join-Path $ScriptDir "VpnProxy.Windows.psm1") -Force + +try { + $raw = [Console]::In.ReadToEnd() + if ([string]::IsNullOrWhiteSpace($raw)) { + throw "Missing JSON input" + } + $request = $raw | ConvertFrom-Json + $payload = if ($request.PSObject.Properties.Name -contains "payload") { $request.payload } else { @{} } + $result = Invoke-VpnProxyAction -Action ([string]$request.action) -Payload $payload + $result | ConvertTo-Json -Depth 30 -Compress + exit 0 +} catch { + $errorResult = [ordered]@{ + success = $false + action = "error" + error = $_.Exception.Message + } + $errorResult | ConvertTo-Json -Depth 10 -Compress + exit 1 +} +``` + +- [ ] **Step 3: Create management script** + +Create `scripts/windows/manage.ps1`: + +```powershell +param( + [switch]$OpenUi, + [switch]$Status, + [switch]$RestartServices +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +$Root = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path) +$env:APP_MODE = "windows" +$env:DATA_DIR = Join-Path $Root "data" +$env:DIST_DIR = Join-Path $Root "app\dist" +$env:PROXY_PORT = "1080" +$env:PROXY_BIND_IP = "127.0.0.1" +$env:SING_BOX_CONFIG = Join-Path $Root "runtime\sing-box\config.json" +$env:SING_BOX_CACHE = Join-Path $Root "runtime\sing-box\cache.db" +$env:WINDOWS_HELPER = Join-Path $Root "app\scripts\windows\helper.ps1" + +function Get-NodeCommand { + $portable = Join-Path $Root "runtime\node\node.exe" + if (Test-Path $portable) { return $portable } + return "node" +} + +if ($Status) { + $inputJson = @{ action = "status.get"; payload = @{} } | ConvertTo-Json -Compress + $inputJson | & (Join-Path $Root "app\scripts\windows\helper.ps1") + exit $LASTEXITCODE +} + +if ($RestartServices) { + $helper = Join-Path $Root "app\scripts\windows\helper.ps1" + (@{ action = "service.control"; payload = @{ service = "proxifyre"; action = "restart" } } | ConvertTo-Json -Compress) | & $helper + (@{ action = "service.control"; payload = @{ service = "sing-box"; action = "restart" } } | ConvertTo-Json -Compress) | & $helper + exit 0 +} + +if ($OpenUi) { + $node = Get-NodeCommand + Start-Process "http://127.0.0.1:3456" + & $node (Join-Path $Root "app\src\server\index.js") + exit $LASTEXITCODE +} + +Write-Host "VPN Proxy Windows" +Write-Host " -OpenUi Start local UI" +Write-Host " -Status Print JSON status" +Write-Host " -RestartServices Restart ProxiFyre and sing-box" +``` + +- [ ] **Step 4: Syntax-check PowerShell scripts when `pwsh` exists** + +Run: + +```bash +pwsh -NoProfile -Command "\$errors = \$null; [System.Management.Automation.Language.Parser]::ParseFile('scripts/windows/helper.ps1', [ref]\$null, [ref]\$errors) > \$null; if (\$errors) { \$errors; exit 1 }" +pwsh -NoProfile -Command "\$errors = \$null; [System.Management.Automation.Language.Parser]::ParseFile('scripts/windows/manage.ps1', [ref]\$null, [ref]\$errors) > \$null; if (\$errors) { \$errors; exit 1 }" +``` + +Expected: exit code 0. If `pwsh` is unavailable on macOS, record that syntax check was skipped and run `npm test` before commit. + +- [ ] **Step 5: Commit** + +```bash +git add scripts/windows/VpnProxy.Windows.psm1 scripts/windows/helper.ps1 scripts/windows/manage.ps1 +git commit -m "feat: add windows helper scripts" +``` + +--- + +### Task 7: Curl-Friendly Windows Installer + +**Files:** +- Create: `scripts/install-windows-client.ps1` +- Modify: `README.md` + +- [ ] **Step 1: Create installer script** + +Create `scripts/install-windows-client.ps1`: + +```powershell +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" +[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 + +$InstallRoot = $env:VPN_PROXY_WINDOWS_ROOT +if ([string]::IsNullOrWhiteSpace($InstallRoot)) { $InstallRoot = "C:\Tools\vpn-proxy-windows" } +$AppDir = Join-Path $InstallRoot "app" +$DataDir = Join-Path $InstallRoot "data" +$RuntimeDir = Join-Path $InstallRoot "runtime" +$SingBoxDir = Join-Path $RuntimeDir "sing-box" +$RepoZipUrl = "https://git.dokops.ru/dokril/vpn-proxy/archive/master.zip" +$SingBoxVersion = "1.12.13" +$SingBoxUrl = "https://github.com/SagerNet/sing-box/releases/download/v$SingBoxVersion/sing-box-$SingBoxVersion-windows-amd64.zip" + +function Assert-Admin { + $principal = New-Object Security.Principal.WindowsPrincipal([Security.Principal.WindowsIdentity]::GetCurrent()) + if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { + throw "Run PowerShell 7 as Administrator" + } +} + +function Assert-PowerShell7 { + if ($PSVersionTable.PSVersion.Major -lt 7) { + throw "PowerShell 7 is required" + } +} + +function Download-File { + param([string]$Url, [string]$Destination) + Invoke-WebRequest -Uri $Url -OutFile $Destination -UseBasicParsing + Unblock-File -Path $Destination -ErrorAction SilentlyContinue +} + +function Install-AppFiles { + New-Item -ItemType Directory -Force -Path $InstallRoot, $DataDir, $RuntimeDir | Out-Null + $zip = Join-Path $env:TEMP "vpn-proxy-windows.zip" + $extract = Join-Path $env:TEMP "vpn-proxy-windows-extract" + Remove-Item $zip -Force -ErrorAction SilentlyContinue + Remove-Item $extract -Recurse -Force -ErrorAction SilentlyContinue + Download-File -Url $RepoZipUrl -Destination $zip + Expand-Archive -Path $zip -DestinationPath $extract -Force + $source = Get-ChildItem $extract -Directory | Where-Object { $_.Name -match "vpn-proxy-(master|main)" } | Select-Object -First 1 + if (-not $source) { throw "Downloaded archive layout is not recognized" } + if (Test-Path $AppDir) { + $backup = "$AppDir.backup" + Remove-Item $backup -Recurse -Force -ErrorAction SilentlyContinue + Move-Item $AppDir $backup + } + Move-Item $source.FullName $AppDir + Remove-Item $zip -Force -ErrorAction SilentlyContinue + Remove-Item $extract -Recurse -Force -ErrorAction SilentlyContinue +} + +function Install-SingBox { + New-Item -ItemType Directory -Force -Path $SingBoxDir | Out-Null + if (Test-Path (Join-Path $SingBoxDir "sing-box.exe")) { return } + $zip = Join-Path $env:TEMP "sing-box-windows.zip" + $extract = Join-Path $env:TEMP "sing-box-windows-extract" + Remove-Item $zip -Force -ErrorAction SilentlyContinue + Remove-Item $extract -Recurse -Force -ErrorAction SilentlyContinue + Download-File -Url $SingBoxUrl -Destination $zip + Expand-Archive -Path $zip -DestinationPath $extract -Force + $exe = Get-ChildItem $extract -Recurse -Filter "sing-box.exe" | Select-Object -First 1 + if (-not $exe) { throw "sing-box.exe was not found in archive" } + Copy-Item $exe.FullName (Join-Path $SingBoxDir "sing-box.exe") -Force +} + +function Select-InstallMode { + Write-Host "" + Write-Host "Choose install mode:" + Write-Host " [1] Full install: local sing-box + ProxiFyre" + Write-Host " [2] ProxiFyre only: use existing proxy target" + $choice = Read-Host "Mode [1]" + if ($choice -eq "2") { return "proxifyre-only" } + return "full" +} + +function Write-InitialTargets { + param([string]$Mode) + $targetsPath = Join-Path $DataDir "proxy-targets.json" + if (Test-Path $targetsPath) { return } + if ($Mode -eq "proxifyre-only") { + $target = Read-Host "Existing SOCKS5 proxy target host:port" + if ($target -notmatch "^([^:]+):(\d+)$") { throw "Expected host:port" } + @(@{ id = "existing-proxy"; name = "Existing proxy"; protocol = "socks5"; host = $matches[1]; port = [int]$matches[2] }) | + ConvertTo-Json -Depth 5 | + Set-Content $targetsPath -Encoding UTF8 + } +} + +function Install-NodeDependencies { + Push-Location $AppDir + try { + npm install + npm run build + } finally { + Pop-Location + } +} + +function Start-Ui { + $manage = Join-Path $AppDir "scripts\windows\manage.ps1" + Start-Process pwsh -ArgumentList "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", "`"$manage`"", "-OpenUi" +} + +Assert-Admin +Assert-PowerShell7 +$mode = Select-InstallMode +Install-AppFiles +if ($mode -eq "full") { Install-SingBox } +Write-InitialTargets -Mode $mode +Install-NodeDependencies +Set-Content -Path (Join-Path $DataDir "windows-state.json") -Encoding UTF8 -Value (@{ installMode = $mode } | ConvertTo-Json) +Start-Ui + +Write-Host "" +Write-Host "VPN Proxy Windows is installed." +Write-Host "UI: http://127.0.0.1:3456" +Write-Host "Recovery:" +Write-Host "& `"$AppDir\scripts\windows\manage.ps1`" -OpenUi" +Write-Host "& `"$AppDir\scripts\windows\manage.ps1`" -Status" +``` + +- [ ] **Step 2: Add README Windows section** + +In `README.md`, add this section after the macOS Docker client section: + +```markdown +## Windows: app proxy client + +Windows mode restores the native workflow for Discord, Vesktop, games, and other apps that do not expose proxy settings. + +Run PowerShell 7 as Administrator: + +```powershell +irm https://git.dokops.ru/dokril/vpn-proxy/raw/branch/master/scripts/install-windows-client.ps1 | iex +``` + +Installer modes: + +- `Full install`: local native `sing-box.exe` on `127.0.0.1:1080` plus ProxiFyre/WinPacketFilter. +- `ProxiFyre only`: ProxiFyre/WinPacketFilter only, pointed at an existing SOCKS5 proxy such as `127.0.0.1:8080` or `192.168.50.111:8080`. + +Local UI: + +```text +http://127.0.0.1:3456 +``` + +Recovery commands: + +```powershell +& "C:\Tools\vpn-proxy-windows\app\scripts\windows\manage.ps1" -OpenUi +& "C:\Tools\vpn-proxy-windows\app\scripts\windows\manage.ps1" -Status +& "C:\Tools\vpn-proxy-windows\app\scripts\windows\manage.ps1" -RestartServices +``` + +The UI manages profiles made of process names, folders, and explicit `.exe` files. It generates ProxiFyre config and restarts ProxiFyre only when the user applies changes. +``` + +- [ ] **Step 3: Syntax-check installer when `pwsh` exists** + +Run: + +```bash +pwsh -NoProfile -Command "\$errors = \$null; [System.Management.Automation.Language.Parser]::ParseFile('scripts/install-windows-client.ps1', [ref]\$null, [ref]\$errors) > \$null; if (\$errors) { \$errors; exit 1 }" +``` + +Expected: exit code 0. If `pwsh` is unavailable, record that the syntax check was skipped. + +- [ ] **Step 4: Commit** + +```bash +git add scripts/install-windows-client.ps1 README.md +git commit -m "feat: add windows client installer" +``` + +--- + +### Task 8: Final Verification And Polish + +**Files:** +- Modify as needed based on verification failures. + +- [ ] **Step 1: Run full automated tests** + +Run: + +```bash +npm test +``` + +Expected: PASS. + +- [ ] **Step 2: Build frontend** + +Run: + +```bash +npm run build +``` + +Expected: PASS. + +- [ ] **Step 3: Check compose config still works for macOS client** + +Run: + +```bash +docker compose -f docker-compose.client.yml config +``` + +Expected: command exits 0 and still shows `APP_MODE: client`. + +- [ ] **Step 4: Inspect generated diff** + +Run: + +```bash +git status --short +git diff --check +git diff --stat +``` + +Expected: + +- no whitespace errors; +- only Windows-client, UI, installer, README, and planned config files changed; +- no changes under `_archive/`. + +- [ ] **Step 5: Commit verification fixes if needed** + +If Step 1, Step 2, or Step 3 required code changes, commit them: + +```bash +git add src/server src/web scripts README.md test +git commit -m "fix: polish windows client implementation" +``` + +- [ ] **Step 6: Prepare Windows manual verification checklist** + +Add the final manual checklist to the implementation summary: + +```text +Windows manual checks: +1. Fresh Full install in PowerShell 7 as Administrator. +2. Fresh ProxiFyre-only install with target 127.0.0.1:8080. +3. Re-run installer over an existing install and confirm profiles survive. +4. Add process profile for Discord and apply. +5. Add folder profile for Vesktop and apply. +6. Add explicit exe profile for a test program and apply. +7. Switch a profile to external gateway target and apply. +8. Restart ProxiFyre from UI and confirm status. +9. Copy diagnostics after entering an unreachable proxy target. +```