# 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. ```