2191 lines
63 KiB
Markdown
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>-></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:
|
|
|
|
```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.
|
|
```
|