63 KiB
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: acceptAPP_MODE=windows, add Windows data/helper paths, bind defaults used by installer. - Modify
src/server/singbox.js: treat Windows as proxy-only localsing-boxmode. - 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=windowsconfig 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: addapi.windows. - Modify
src/web/components/Sidebar.jsxandsrc/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:
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:
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:
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.jsproxy-only for Windows
In src/server/singbox.js, update buildGatewayConfig() around the existing client-mode logic:
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:
...(proxyOnlyMode
? []
: [
{
type: "tproxy",
tag: "tproxy-in",
listen: "::",
listen_port: settings.tproxyPort,
sniff: true,
sniff_override_destination: true,
},
]),
and:
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:
npm test -- test/server/singbox-windows-mode.test.js
Expected: PASS.
- Step 6: Run existing config tests
Run:
npm test -- test/server/singbox-client-mode.test.js
Expected: PASS.
- Step 7: Commit
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:
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:
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:
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:
npm test -- test/server/windows-profiles.test.js
Expected: PASS.
- Step 5: Commit
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:
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:
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:
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:
npm test -- test/server/windows-helper.test.js
Expected: PASS.
- Step 5: Commit
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:
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:
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:
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:
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():
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:
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:
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:
npm test
Expected: PASS.
- Step 9: Commit
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:
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:
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 (
<div className="win-profile-list">
{profiles.map((profile) => (
<button
key={profile.id}
className={`win-profile-row ${profile.id === selectedId ? 'active' : ''}`}
onClick={() => onSelect(profile.id)}
>
<span className={`win-profile-check ${profile.enabled ? 'on' : ''}`}>on</span>
<span>
<strong>{profile.name}</strong>
<small>{profile.items.length} items - target: {profile.proxyTargetId}</small>
</span>
<em>{profile.resolvedCount ?? profile.items.length}</em>
</button>
))}
</div>
);
}
function ProfileDetails({ profile, targets, onChange }) {
const [newItem, setNewItem] = useState('');
const [newType, setNewType] = useState('process');
if (!profile) {
return <div className="win-profile-empty">Select or add a profile.</div>;
}
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 (
<div className="win-detail">
<label>
<span>Name</span>
<input
className="input"
value={profile.name}
onChange={(event) => patch({ name: event.target.value })}
/>
</label>
<label>
<span>Proxy target</span>
<select
className="select"
value={profile.proxyTargetId}
onChange={(event) => patch({ proxyTargetId: event.target.value })}
>
{targets.map((target) => (
<option key={target.id} value={target.id}>{targetLabel(target)}</option>
))}
</select>
</label>
<div className="win-add-item">
<select className="select" value={newType} onChange={(event) => setNewType(event.target.value)}>
<option value="process">Process</option>
<option value="folder">Folder</option>
<option value="exe">EXE file</option>
</select>
<input
className="input"
value={newItem}
placeholder="Discord, %LOCALAPPDATA%\\vesktop, or C:\\Games\\game.exe"
onChange={(event) => setNewItem(event.target.value)}
onKeyDown={(event) => event.key === 'Enter' && addItem()}
/>
<button className="btn btn-secondary" onClick={addItem}>Add</button>
</div>
<div className="win-items">
{profile.items.map((item, index) => (
<div key={`${item.type}-${item.value}-${index}`} className="win-item">
<span>{item.value}</span>
<small>{item.type}</small>
<button
className="btn btn-link sm"
onClick={() => patch({ items: profile.items.filter((_, i) => i !== index) })}
>
Remove
</button>
</div>
))}
</div>
</div>
);
}
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 (
<div className="windows-page">
<section className="windows-status-panel">
<div className="windows-status-main">
<span className="windows-status-dot" />
<div>
<h1>{routeTitle(status)}</h1>
<p>Profiles send selected apps through ProxiFyre to local sing-box or an existing proxy target.</p>
</div>
</div>
<div className="windows-route-line">
<span>Selected apps</span><b>-></b><span>ProxiFyre</span><b>-></b><span>Proxy target</span>
</div>
</section>
<section className="windows-workspace">
<div className="panel">
<div className="panel-head">
<div>
<h2>Profiles</h2>
<small>{profiles.filter((profile) => profile.enabled).length} enabled</small>
</div>
<button className="btn btn-secondary" onClick={addProfile}>Add profile</button>
</div>
<ProfileList profiles={profiles} selectedId={selectedId} onSelect={setSelectedId} />
</div>
<div className="panel">
<div className="panel-head">
<div>
<h2>{selected?.name || 'Profile'}</h2>
<small>{selected ? targetLabel(targets.find((target) => target.id === selected.proxyTargetId)) : 'No selection'}</small>
</div>
<button className="btn btn-primary" disabled={busy} onClick={applyProfiles}>Apply changes</button>
</div>
<ProfileDetails profile={selected} targets={targets} onChange={replaceProfile} />
</div>
</section>
<section className="panel windows-activity">
<div className="panel-head">
<h2>Recent activity</h2>
<button className="btn btn-secondary" disabled={busy} onClick={() => saveProfiles()}>Save only</button>
</div>
{(status?.activity || []).slice(0, 5).map((entry) => (
<div key={entry.id} className="windows-activity-row">
<strong>{entry.type}</strong>
<span>{entry.message}</span>
<small>{entry.ts}</small>
</div>
))}
</section>
</div>
);
}
- Step 3: Wire Windows page in
App.jsx
Add import:
import { WindowsOverviewPage } from './components/WindowsOverviewPage.jsx';
Add this after const isClientMode = state?.mode === 'client'; if present, or near equivalent mode checks:
const isWindowsMode = state?.mode === 'windows';
Update the client-mode redirect effect to include Windows:
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:
{page === 'overview' && isWindowsMode && (
<WindowsOverviewPage pushToast={pushToast} />
)}
{page === 'overview' && isClientMode && !isWindowsMode && (
<ClientOverviewPage
state={state}
activeServer={activeServer}
busy={busy}
subscriptionUrl={subscriptionUrl}
setSubscriptionUrl={setSubscriptionUrl}
servers={servers}
pendingTag={pendingTag}
setPendingTag={setPendingTag}
clientSettings={clientSettings}
onSaveClientSettings={saveClientSettings}
onCheckSharedProxy={checkSharedProxy}
onFetchSubscription={fetchSubscription}
onApply={applyServer}
/>
)}
Keep existing gateway overview rendering under !isClientMode && !isWindowsMode.
- Step 4: Update sidebar/topbar labels
In src/web/components/Sidebar.jsx, make Windows nav compact:
const WINDOWS_ITEMS = [
{ id: 'overview', label: 'Overview', ico: 'O' },
{ id: 'logs', label: 'Logs', ico: 'L' },
{ id: 'settings', label: 'Settings', ico: 'S' },
];
Then select items:
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:
.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:
npm run build
Expected: PASS.
- Step 7: Commit
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:
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:
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:
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
pwshexists
Run:
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
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:
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:
## 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 nativesing-box.exeon127.0.0.1:1080plus ProxiFyre/WinPacketFilter.ProxiFyre only: ProxiFyre/WinPacketFilter only, pointed at an existing SOCKS5 proxy such as127.0.0.1:8080or192.168.50.111:8080.
Local UI:
http://127.0.0.1:3456
Recovery commands:
& "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
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:
npm test
Expected: PASS.
- Step 2: Build frontend
Run:
npm run build
Expected: PASS.
- Step 3: Check compose config still works for macOS client
Run:
docker compose -f docker-compose.client.yml config
Expected: command exits 0 and still shows APP_MODE: client.
- Step 4: Inspect generated diff
Run:
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:
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:
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.