Files
vpn-proxy/docs/superpowers/plans/2026-05-21-windows-client.md
Dmitriy Petrov b5d4c61783
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 10s
Build and Deploy Gateway / deploy (push) Successful in 0s
docs: add windows client implementation plan
2026-05-21 20:04:51 +03:00

2191 lines
63 KiB
Markdown

# 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 (
<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>-&gt;</b><span>ProxiFyre</span><b>-&gt;</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:
```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 && (
<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:
```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.
```