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

63 KiB

Windows Client Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Restore the Windows proxy workflow as a script-first product with two install modes: full local sing-box + ProxiFyre, or ProxiFyre-only routing to an existing proxy, controlled by a clean local web UI.

Architecture: Reuse the current Node API, React/Vite UI, subscription parser, sing-box config generation, and log surfaces. Add APP_MODE=windows, a Windows profile domain module, a ProxiFyre config generator, a structured PowerShell helper boundary, Windows-specific API routes, and a Windows overview UI that follows the approved clean mockup.

Tech Stack: Node.js ESM, node:test, React 19/Vite 7, plain Node HTTP server, PowerShell 7, native sing-box.exe, ProxiFyre/WinPacketFilter.


File Structure

  • Create src/server/windowsProfiles.js: profile normalization, proxy target normalization, executable resolution, ProxiFyre config generation, activity helpers.
  • Create src/server/windowsHelper.js: structured bridge from Node to PowerShell helper with mockable runner.
  • Modify src/server/config.js: 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:

import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import test from "node:test";

process.env.APP_MODE = "windows";
process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "vpn-proxy-windows-test-"));
process.env.SING_BOX_CACHE = path.join(process.env.DATA_DIR, "cache.db");
process.env.PROXY_PORT = "1080";
process.env.PROXY_BIND_IP = "127.0.0.1";

const { settings } = await import(
  `../../src/server/config.js?windows-mode=${Date.now()}`
);
const { buildGatewayConfig } = await import(
  `../../src/server/singbox.js?windows-mode=${Date.now()}`
);

const subscriptionConfig = {
  outbounds: [
    {
      type: "vless",
      tag: "win-vpn",
      server: "vpn.example.test",
      server_port: 443,
      uuid: "00000000-0000-4000-8000-000000000000",
      tls: { enabled: true },
    },
  ],
  customRules: [],
};

test("settings accepts APP_MODE=windows", () => {
  assert.equal(settings.appMode, "windows");
  assert.equal(settings.proxyPort, 1080);
  assert.equal(settings.bindIp, "127.0.0.1");
});

test("windows mode exposes only local mixed proxy inbound", () => {
  const config = buildGatewayConfig(subscriptionConfig, "win-vpn");

  assert.deepEqual(config.inbounds.map((inbound) => inbound.tag), ["mixed-in"]);
  assert.equal(config.inbounds[0].type, "mixed");
  assert.equal(config.inbounds[0].listen, "127.0.0.1");
  assert.equal(config.inbounds[0].listen_port, 1080);
});

test("windows mode routes mixed proxy to selected VPN outbound", () => {
  const config = buildGatewayConfig(subscriptionConfig, "win-vpn");

  assert.deepEqual(config.route.rule_set, []);
  assert.deepEqual(config.route.rules, [
    { inbound: ["mixed-in"], outbound: "win-vpn" },
  ]);
  assert.deepEqual(config.outbounds.map((outbound) => outbound.tag), [
    "win-vpn",
    "direct",
    "block",
  ]);
});
  • Step 2: Run test to verify it fails

Run:

npm test -- test/server/singbox-windows-mode.test.js

Expected: FAIL because settings.appMode is gateway and generated config still includes tproxy-in.

  • Step 3: Add Windows settings

In src/server/config.js, replace the current settings construction with this app-mode helper and added Windows paths:

import path from "node:path";

const dataDir = process.env.DATA_DIR || path.resolve(".vpn-proxy");
const rawAppMode = String(process.env.APP_MODE || "gateway").toLowerCase();
const appMode = ["gateway", "client", "windows"].includes(rawAppMode)
  ? rawAppMode
  : "gateway";

export const settings = {
  appMode,
  port: Number(process.env.PORT || 3456),
  proxyPort: Number(process.env.PROXY_PORT || 8080),
  clientProxyPortStart: Number(process.env.CLIENT_PROXY_PORT_START || 8080),
  clientProxyPortEnd: Number(process.env.CLIENT_PROXY_PORT_END || 8090),
  tproxyPort: Number(process.env.TPROXY_PORT || 7895),
  bindIp: process.env.PROXY_BIND_IP || "0.0.0.0",
  dataDir,
  distDir: process.env.DIST_DIR || "/app/dist",
  configPath:
    process.env.SING_BOX_CONFIG || path.join(dataDir, "sing-box-config.json"),
  cachePath: process.env.SING_BOX_CACHE || "/var/lib/sing-box/cache.db",
  statePath: path.join(dataDir, "state.json"),
  customRulesPath: path.join(dataDir, "custom-rules.json"),
  customRuleSetsPath: path.join(dataDir, "custom-rule-sets.json"),
  clientSettingsPath: path.join(dataDir, "client-settings.json"),
  devicesPath: path.join(dataDir, "devices.json"),
  deviceRulesPath: path.join(dataDir, "device-rules.json"),
  subscriptionCachePath: path.join(dataDir, "subscription-cache.json"),
  windowsProfilesPath: path.join(dataDir, "windows-profiles.json"),
  windowsTargetsPath: path.join(dataDir, "proxy-targets.json"),
  windowsStatePath: path.join(dataDir, "windows-state.json"),
  windowsActivityPath: path.join(dataDir, "windows-activity.json"),
  windowsHelperPath:
    process.env.WINDOWS_HELPER ||
    path.resolve("scripts/windows/helper.ps1"),
  proxifyreConfigPath:
    process.env.PROXIFYRE_CONFIG ||
    "C:\\Tools\\ProxiFyre\\app-config.json",
  sharedProxyHost: process.env.SHARED_PROXY_HOST || "",
  hwidPath: path.join(dataDir, "hwid"),
  routingRuDirect: String(process.env.ROUTING_RU_DIRECT || "true") !== "false",
  ruleSetDownloadDetour: process.env.RULE_SET_DOWNLOAD_DETOUR || "vpn",
  logLevel: process.env.LOG_LEVEL || "info",
  appName:
    appMode === "windows"
      ? "VPN Proxy Windows"
      : appMode === "client"
        ? "VPN Proxy Client"
        : "VPN Proxy Gateway",
};
  • Step 4: Make singbox.js proxy-only for Windows

In src/server/singbox.js, update buildGatewayConfig() around the existing client-mode logic:

  const proxyOnlyMode = settings.appMode === "client" || settings.appMode === "windows";
  const clientMode = settings.appMode === "client";
  const clientSettings = clientMode ? readClientSettings() : null;

Then replace uses that currently mean "no tproxy" from clientMode to proxyOnlyMode:

    ...(proxyOnlyMode
      ? []
      : [
          {
            type: "tproxy",
            tag: "tproxy-in",
            listen: "::",
            listen_port: settings.tproxyPort,
            sniff: true,
            sniff_override_destination: true,
          },
        ]),

and:

      rule_set: bypassAll || proxyOnlyMode ? [] : ruleSets(customRuleSets, vpnOutbound.tag),
      rules: bypassAll
        ? [{ ip_is_private: true, outbound: "direct" }]
        : proxyOnlyMode
          ? proxyOnlyRules
          : routeRules(subscriptionConfig.customRules, vpnOutbound.tag, {
              includeTransparent: !proxyOnlyMode,
            }),

Keep clientSettings behavior only for client mode so Windows uses settings.proxyPort and selected VPN outbound.

  • Step 5: Run test to verify it passes

Run:

npm test -- test/server/singbox-windows-mode.test.js

Expected: PASS.

  • Step 6: Run existing config tests

Run:

npm test -- test/server/singbox-client-mode.test.js

Expected: PASS.

  • Step 7: Commit
git add src/server/config.js src/server/singbox.js test/server/singbox-windows-mode.test.js
git commit -m "feat: add windows proxy-only app mode"

Task 2: Windows Profile Domain Model

Files:

  • Create: src/server/windowsProfiles.js

  • Test: test/server/windows-profiles.test.js

  • Step 1: Write failing profile tests

Create test/server/windows-profiles.test.js:

import assert from "node:assert/strict";
import test from "node:test";
import {
  buildProxiFyreConfig,
  normalizeProxyTargets,
  normalizeWindowsProfiles,
  resolveProfileItems,
} from "../../src/server/windowsProfiles.js";

test("normalizeWindowsProfiles keeps process folder and exe source items", () => {
  const profiles = normalizeWindowsProfiles([
    {
      id: "Discord + Vesktop",
      name: "Discord + Vesktop",
      enabled: true,
      proxyTargetId: "local-singbox",
      protocols: ["TCP", "UDP", "bad"],
      items: [
        { type: "process", value: "Discord.exe" },
        { type: "folder", value: "%LOCALAPPDATA%\\vesktop", recursive: true },
        { type: "exe", value: "C:\\Games\\SomeGame\\game.exe" },
        { type: "bad", value: "ignored" },
      ],
    },
  ]);

  assert.deepEqual(profiles, [
    {
      id: "discord-vesktop",
      name: "Discord + Vesktop",
      enabled: true,
      proxyTargetId: "local-singbox",
      protocols: ["TCP", "UDP"],
      items: [
        { type: "process", value: "Discord", recursive: false },
        { type: "folder", value: "%LOCALAPPDATA%\\vesktop", recursive: true },
        { type: "exe", value: "C:\\Games\\SomeGame\\game.exe", recursive: false },
      ],
    },
  ]);
});

test("normalizeProxyTargets always includes local-singbox", () => {
  const targets = normalizeProxyTargets([
    { id: "gateway", name: "Home gateway", host: "192.168.50.111", port: 8080 },
  ]);

  assert.deepEqual(targets, [
    {
      id: "local-singbox",
      name: "Local sing-box",
      protocol: "socks5",
      host: "127.0.0.1",
      port: 1080,
      managed: true,
    },
    {
      id: "gateway",
      name: "Home gateway",
      protocol: "socks5",
      host: "192.168.50.111",
      port: 8080,
      managed: false,
    },
  ]);
});

test("resolveProfileItems expands folders and exe paths into process names", () => {
  const files = new Map([
    ["C:\\Users\\me\\App\\a.exe", true],
    ["C:\\Users\\me\\App\\nested\\b.exe", true],
    ["C:\\Games\\Game\\game.exe", true],
  ]);
  const dirs = new Map([
    ["C:\\Users\\me\\App", ["a.exe", "nested", "note.txt"]],
    ["C:\\Users\\me\\App\\nested", ["b.exe"]],
  ]);
  const fsAdapter = {
    existsSync: (value) => files.has(value) || dirs.has(value),
    statSync: (value) => ({
      isDirectory: () => dirs.has(value),
      isFile: () => files.has(value),
    }),
    readdirSync: (value, options) =>
      dirs.get(value).map((name) => ({
        name,
        isDirectory: () => dirs.has(`${value}\\${name}`),
        isFile: () => files.has(`${value}\\${name}`),
      })),
  };

  const resolved = resolveProfileItems(
    [
      { type: "process", value: "Discord", recursive: false },
      { type: "folder", value: "C:\\Users\\me\\App", recursive: true },
      { type: "exe", value: "C:\\Games\\Game\\game.exe", recursive: false },
    ],
    { fsAdapter, pathSep: "\\" },
  );

  assert.deepEqual(resolved.map((item) => item.appName), [
    "Discord",
    "a",
    "b",
    "game",
  ]);
});

test("buildProxiFyreConfig groups enabled profiles by target", () => {
  const profiles = normalizeWindowsProfiles([
    {
      id: "discord",
      name: "Discord",
      enabled: true,
      proxyTargetId: "local-singbox",
      protocols: ["TCP", "UDP"],
      items: [{ type: "process", value: "Discord" }],
    },
    {
      id: "work",
      name: "Work",
      enabled: true,
      proxyTargetId: "gateway",
      protocols: ["TCP"],
      items: [{ type: "process", value: "Code" }],
    },
    {
      id: "off",
      name: "Disabled",
      enabled: false,
      proxyTargetId: "local-singbox",
      items: [{ type: "process", value: "Ignored" }],
    },
  ]);
  const targets = normalizeProxyTargets([
    { id: "gateway", name: "Gateway", host: "192.168.50.111", port: 8080 },
  ]);

  const config = buildProxiFyreConfig(profiles, targets);

  assert.deepEqual(config, {
    logLevel: "Info",
    proxies: [
      {
        appNames: ["Discord"],
        socks5ProxyEndpoint: "127.0.0.1:1080",
        supportedProtocols: ["TCP", "UDP"],
      },
      {
        appNames: ["Code"],
        socks5ProxyEndpoint: "192.168.50.111:8080",
        supportedProtocols: ["TCP"],
      },
    ],
    excludes: [],
  });
});
  • Step 2: Run test to verify it fails

Run:

npm test -- test/server/windows-profiles.test.js

Expected: FAIL with module not found for src/server/windowsProfiles.js.

  • Step 3: Implement windowsProfiles.js

Create src/server/windowsProfiles.js:

import fs from "node:fs";
import path from "node:path";

const ITEM_TYPES = new Set(["process", "folder", "exe"]);
const PROTOCOLS = new Set(["TCP", "UDP"]);

function slug(value, fallback) {
  const cleaned = String(value || "")
    .trim()
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, "-")
    .replace(/^-+|-+$/g, "");
  return cleaned || fallback;
}

function cleanString(value) {
  return String(value || "").trim();
}

function processName(value) {
  const base = cleanString(value).split(/[\\/]/).pop() || "";
  return base.replace(/\.exe$/i, "").trim();
}

function unique(values) {
  return Array.from(new Set(values.filter(Boolean)));
}

export function normalizeWindowsProfiles(input) {
  return (Array.isArray(input) ? input : []).map((profile, index) => {
    const name = cleanString(profile.name) || `Profile ${index + 1}`;
    const items = (Array.isArray(profile.items) ? profile.items : [])
      .filter((item) => ITEM_TYPES.has(item?.type))
      .map((item) => ({
        type: item.type,
        value:
          item.type === "process"
            ? processName(item.value)
            : cleanString(item.value),
        recursive: item.type === "folder" ? item.recursive !== false : false,
      }))
      .filter((item) => item.value);

    return {
      id: slug(profile.id || name, `profile-${index + 1}`),
      name,
      enabled: profile.enabled !== false,
      proxyTargetId: cleanString(profile.proxyTargetId) || "local-singbox",
      protocols: unique(
        (Array.isArray(profile.protocols) ? profile.protocols : ["TCP", "UDP"])
          .map((protocol) => cleanString(protocol).toUpperCase())
          .filter((protocol) => PROTOCOLS.has(protocol)),
      ),
      items,
    };
  }).map((profile) => ({
    ...profile,
    protocols: profile.protocols.length ? profile.protocols : ["TCP", "UDP"],
  }));
}

export function normalizeProxyTargets(input) {
  const local = {
    id: "local-singbox",
    name: "Local sing-box",
    protocol: "socks5",
    host: "127.0.0.1",
    port: 1080,
    managed: true,
  };
  const seen = new Set([local.id]);
  const custom = (Array.isArray(input) ? input : [])
    .map((target, index) => ({
      id: slug(target.id || target.name, `target-${index + 1}`),
      name: cleanString(target.name) || `Proxy target ${index + 1}`,
      protocol: cleanString(target.protocol || "socks5").toLowerCase() === "http"
        ? "http"
        : "socks5",
      host: cleanString(target.host),
      port: Number.parseInt(target.port, 10),
      managed: false,
    }))
    .filter((target) => {
      if (!target.host || !Number.isInteger(target.port)) return false;
      if (target.port <= 0 || target.port > 65535) return false;
      if (seen.has(target.id)) return false;
      seen.add(target.id);
      return true;
    });
  return [local, ...custom];
}

function joinPath(base, name, pathSep) {
  return base.endsWith(pathSep) ? `${base}${name}` : `${base}${pathSep}${name}`;
}

function walkExeFiles(dir, { fsAdapter, recursive, pathSep }) {
  const entries = fsAdapter.readdirSync(dir, { withFileTypes: true });
  const results = [];
  for (const entry of entries) {
    const fullPath = joinPath(dir, entry.name, pathSep);
    if (entry.isFile() && /\.exe$/i.test(entry.name)) results.push(fullPath);
    if (recursive && entry.isDirectory()) {
      results.push(...walkExeFiles(fullPath, { fsAdapter, recursive, pathSep }));
    }
  }
  return results;
}

export function resolveProfileItems(items, options = {}) {
  const fsAdapter = options.fsAdapter || fs;
  const pathSep = options.pathSep || path.sep;
  const resolved = [];
  for (const item of Array.isArray(items) ? items : []) {
    if (item.type === "process") {
      const appName = processName(item.value);
      if (appName) resolved.push({ ...item, appName, source: item.value });
    }
    if (item.type === "exe") {
      const appName = processName(item.value);
      if (appName) resolved.push({ ...item, appName, source: item.value });
    }
    if (item.type === "folder" && fsAdapter.existsSync(item.value)) {
      const stat = fsAdapter.statSync(item.value);
      if (stat.isDirectory()) {
        for (const filePath of walkExeFiles(item.value, {
          fsAdapter,
          recursive: item.recursive !== false,
          pathSep,
        })) {
          resolved.push({ ...item, appName: processName(filePath), source: filePath });
        }
      }
    }
  }
  const byName = new Map();
  for (const item of resolved) if (!byName.has(item.appName)) byName.set(item.appName, item);
  return Array.from(byName.values());
}

export function buildProxiFyreConfig(profiles, targets, options = {}) {
  const normalizedTargets = normalizeProxyTargets(targets);
  const targetById = new Map(normalizedTargets.map((target) => [target.id, target]));
  const groups = new Map();
  for (const profile of normalizeWindowsProfiles(profiles).filter((item) => item.enabled)) {
    const target = targetById.get(profile.proxyTargetId) || targetById.get("local-singbox");
    const resolved = resolveProfileItems(profile.items, options);
    if (!target || resolved.length === 0) continue;
    const key = `${target.id}|${profile.protocols.join(",")}`;
    const existing = groups.get(key) || {
      appNames: [],
      socks5ProxyEndpoint: `${target.host}:${target.port}`,
      supportedProtocols: profile.protocols,
    };
    existing.appNames.push(...resolved.map((item) => item.appName));
    existing.appNames = unique(existing.appNames).sort((a, b) => a.localeCompare(b));
    groups.set(key, existing);
  }
  return {
    logLevel: "Info",
    proxies: Array.from(groups.values()),
    excludes: [],
  };
}
  • Step 4: Run test to verify it passes

Run:

npm test -- test/server/windows-profiles.test.js

Expected: PASS.

  • Step 5: Commit
git add src/server/windowsProfiles.js test/server/windows-profiles.test.js
git commit -m "feat: add windows profile model"

Task 3: Windows Helper Bridge

Files:

  • Create: src/server/windowsHelper.js

  • Test: test/server/windows-helper.test.js

  • Step 1: Write failing helper bridge tests

Create test/server/windows-helper.test.js:

import assert from "node:assert/strict";
import test from "node:test";
import { createWindowsHelper } from "../../src/server/windowsHelper.js";

test("windows helper sends action and payload as JSON", async () => {
  const calls = [];
  const helper = createWindowsHelper({
    helperPath: "scripts/windows/helper.ps1",
    runner: async (command, args, options) => {
      calls.push({ command, args, input: options.input });
      return {
        status: 0,
        stdout: JSON.stringify({
          success: true,
          action: "status.get",
          result: { proxifyre: "Running" },
        }),
        stderr: "",
      };
    },
  });

  const result = await helper.run("status.get", { service: "ProxiFyre" });

  assert.deepEqual(result, {
    success: true,
    action: "status.get",
    result: { proxifyre: "Running" },
  });
  assert.equal(calls[0].command, "pwsh");
  assert.deepEqual(calls[0].args, [
    "-NoProfile",
    "-ExecutionPolicy",
    "Bypass",
    "-File",
    "scripts/windows/helper.ps1",
  ]);
  assert.deepEqual(JSON.parse(calls[0].input), {
    action: "status.get",
    payload: { service: "ProxiFyre" },
  });
});

test("windows helper normalizes non-zero exit into structured error", async () => {
  const helper = createWindowsHelper({
    helperPath: "scripts/windows/helper.ps1",
    runner: async () => ({
      status: 1,
      stdout: "",
      stderr: "service failed",
    }),
  });

  await assert.rejects(
    () => helper.run("service.restart", { name: "proxifyre" }),
    /Windows helper failed: service failed/,
  );
});

test("windows helper rejects invalid JSON stdout", async () => {
  const helper = createWindowsHelper({
    helperPath: "scripts/windows/helper.ps1",
    runner: async () => ({
      status: 0,
      stdout: "not-json",
      stderr: "",
    }),
  });

  await assert.rejects(
    () => helper.run("status.get", {}),
    /Windows helper returned invalid JSON/,
  );
});
  • Step 2: Run test to verify it fails

Run:

npm test -- test/server/windows-helper.test.js

Expected: FAIL with module not found for src/server/windowsHelper.js.

  • Step 3: Implement helper bridge

Create src/server/windowsHelper.js:

import { spawn } from "node:child_process";
import { settings } from "./config.js";

function defaultRunner(command, args, options = {}) {
  return new Promise((resolve) => {
    const child = spawn(command, args, {
      stdio: ["pipe", "pipe", "pipe"],
      windowsHide: true,
    });
    let stdout = "";
    let stderr = "";
    child.stdout.on("data", (chunk) => {
      stdout += chunk.toString("utf8");
    });
    child.stderr.on("data", (chunk) => {
      stderr += chunk.toString("utf8");
    });
    child.on("error", (error) => {
      resolve({ status: 1, stdout, stderr: error.message });
    });
    child.on("close", (status) => {
      resolve({ status, stdout, stderr });
    });
    child.stdin.end(options.input || "");
  });
}

export function createWindowsHelper(options = {}) {
  const helperPath = options.helperPath || settings.windowsHelperPath;
  const command = options.command || "pwsh";
  const runner = options.runner || defaultRunner;
  return {
    async run(action, payload = {}) {
      const input = JSON.stringify({ action, payload });
      const result = await runner(
        command,
        ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", helperPath],
        { input },
      );
      if (result.status !== 0) {
        throw new Error(
          `Windows helper failed: ${(result.stderr || result.stdout || "helper exited without stderr").trim()}`,
        );
      }
      try {
        return JSON.parse(result.stdout);
      } catch {
        throw new Error(`Windows helper returned invalid JSON: ${result.stdout}`);
      }
    },
  };
}

export const windowsHelper = createWindowsHelper();
  • Step 4: Run test to verify it passes

Run:

npm test -- test/server/windows-helper.test.js

Expected: PASS.

  • Step 5: Commit
git add src/server/windowsHelper.js test/server/windows-helper.test.js
git commit -m "feat: add windows helper bridge"

Task 4: Windows API Endpoints

Files:

  • Modify: src/server/index.js

  • Modify: src/server/config.js

  • Test: test/server/windows-api-model.test.js

  • Step 1: Extract Windows model functions for API use

Before editing routes, add these exports to src/server/windowsProfiles.js below existing exports:

export function summarizeProfiles(profiles, targets, options = {}) {
  const normalizedTargets = normalizeProxyTargets(targets);
  const targetById = new Map(normalizedTargets.map((target) => [target.id, target]));
  return normalizeWindowsProfiles(profiles).map((profile) => {
    const resolvedItems = resolveProfileItems(profile.items, options);
    const target = targetById.get(profile.proxyTargetId) || targetById.get("local-singbox");
    return {
      ...profile,
      target,
      resolvedCount: resolvedItems.length,
      resolvedItems,
    };
  });
}

export function createActivityEntry(type, message, details = {}) {
  return {
    id: `${Date.now()}-${Math.random().toString(16).slice(2)}`,
    ts: new Date().toISOString(),
    type,
    message,
    details,
  };
}
  • Step 2: Write focused API model test

Create test/server/windows-api-model.test.js:

import assert from "node:assert/strict";
import test from "node:test";
import {
  buildProxiFyreConfig,
  normalizeProxyTargets,
  normalizeWindowsProfiles,
  summarizeProfiles,
} from "../../src/server/windowsProfiles.js";

test("windows API model returns summaries and generated config", () => {
  const profiles = normalizeWindowsProfiles([
    {
      name: "Discord",
      proxyTargetId: "local-singbox",
      items: [{ type: "process", value: "Discord" }],
    },
  ]);
  const targets = normalizeProxyTargets([]);

  const summaries = summarizeProfiles(profiles, targets);
  const config = buildProxiFyreConfig(profiles, targets);

  assert.equal(summaries[0].resolvedCount, 1);
  assert.equal(summaries[0].target.id, "local-singbox");
  assert.deepEqual(config.proxies[0].appNames, ["Discord"]);
});
  • Step 3: Run test to verify it passes

Run:

npm test -- test/server/windows-api-model.test.js

Expected: PASS.

  • Step 4: Add imports to src/server/index.js

At the top of src/server/index.js, add:

import {
  buildProxiFyreConfig,
  createActivityEntry,
  normalizeProxyTargets,
  normalizeWindowsProfiles,
  summarizeProfiles,
} from "./windowsProfiles.js";
import { windowsHelper } from "./windowsHelper.js";
  • Step 5: Add Windows state helpers to src/server/index.js

Place these helpers near normalizeCustomRules():

function readWindowsProfiles() {
  return normalizeWindowsProfiles(readJson(settings.windowsProfilesPath, []));
}

function writeWindowsProfiles(profiles) {
  const normalized = normalizeWindowsProfiles(profiles);
  writeJson(settings.windowsProfilesPath, normalized);
  return normalized;
}

function readProxyTargets() {
  return normalizeProxyTargets(readJson(settings.windowsTargetsPath, []));
}

function writeProxyTargets(targets) {
  const normalized = normalizeProxyTargets(targets);
  writeJson(
    settings.windowsTargetsPath,
    normalized.filter((target) => !target.managed),
  );
  return normalized;
}

function readWindowsActivity() {
  return readJson(settings.windowsActivityPath, []).slice(-100);
}

function pushWindowsActivity(type, message, details = {}) {
  const activity = readWindowsActivity();
  const entry = createActivityEntry(type, message, details);
  writeJson(settings.windowsActivityPath, [...activity, entry].slice(-100));
  return entry;
}

async function getWindowsStatus() {
  let helperStatus = null;
  if (settings.appMode === "windows") {
    try {
      helperStatus = await windowsHelper.run("status.get", {});
    } catch (error) {
      helperStatus = { success: false, error: error.message };
    }
  }
  const profiles = readWindowsProfiles();
  const targets = readProxyTargets();
  return {
    mode: settings.appMode,
    installMode: readJson(settings.windowsStatePath, {}).installMode || "not-configured",
    profiles: summarizeProfiles(profiles, targets),
    targets,
    activity: readWindowsActivity().slice(-20).reverse(),
    helperStatus,
  };
}
  • Step 6: Include Windows status in public state

In publicState(), add:

    windows:
      settings.appMode === "windows"
        ? {
            profiles: summarizeProfiles(readWindowsProfiles(), readProxyTargets()),
            targets: readProxyTargets(),
            activity: readWindowsActivity().slice(-20).reverse(),
          }
        : null,
  • Step 7: Add /api/windows/* routes

In the request handler before static serving, add:

  if (req.method === "GET" && req.url === "/api/windows/status") {
    return sendJson(res, 200, { success: true, ...(await getWindowsStatus()) });
  }

  if (req.method === "GET" && req.url === "/api/windows/profiles") {
    const profiles = readWindowsProfiles();
    const targets = readProxyTargets();
    return sendJson(res, 200, {
      success: true,
      profiles,
      summaries: summarizeProfiles(profiles, targets),
    });
  }

  if (req.method === "PUT" && req.url === "/api/windows/profiles") {
    const body = await readBody(req);
    const profiles = writeWindowsProfiles(body.profiles || []);
    pushWindowsActivity("profiles.saved", "Profiles saved", {
      count: profiles.length,
    });
    return sendJson(res, 200, {
      success: true,
      profiles,
      summaries: summarizeProfiles(profiles, readProxyTargets()),
    });
  }

  if (req.method === "POST" && req.url === "/api/windows/profiles/scan") {
    const body = await readBody(req);
    const profiles = normalizeWindowsProfiles(body.profiles || []);
    return sendJson(res, 200, {
      success: true,
      summaries: summarizeProfiles(profiles, readProxyTargets()),
    });
  }

  if (req.method === "POST" && req.url === "/api/windows/profiles/apply") {
    const profiles = readWindowsProfiles();
    const targets = readProxyTargets();
    const proxifyreConfig = buildProxiFyreConfig(profiles, targets);
    const helperResult = await windowsHelper.run("proxifyre.apply", {
      configPath: settings.proxifyreConfigPath,
      config: proxifyreConfig,
    });
    pushWindowsActivity("profiles.applied", "ProxiFyre config applied", {
      proxyGroups: proxifyreConfig.proxies.length,
    });
    return sendJson(res, 200, {
      success: true,
      config: proxifyreConfig,
      helperResult,
    });
  }

  if (req.method === "GET" && req.url === "/api/windows/targets") {
    return sendJson(res, 200, { success: true, targets: readProxyTargets() });
  }

  if (req.method === "PUT" && req.url === "/api/windows/targets") {
    const body = await readBody(req);
    const targets = writeProxyTargets(body.targets || []);
    pushWindowsActivity("targets.saved", "Proxy targets saved", {
      count: targets.length,
    });
    return sendJson(res, 200, { success: true, targets });
  }

  if (req.method === "POST" && req.url === "/api/windows/service") {
    const body = await readBody(req);
    const service = String(body.service || "");
    const action = String(body.action || "");
    if (!["sing-box", "proxifyre", "ui"].includes(service)) {
      return sendJson(res, 400, { success: false, error: "Unknown service" });
    }
    if (!["start", "stop", "restart"].includes(action)) {
      return sendJson(res, 400, { success: false, error: "Unknown action" });
    }
    const helperResult = await windowsHelper.run("service.control", {
      service,
      action,
    });
    pushWindowsActivity("service.control", `${service} ${action}`, {
      service,
      action,
    });
    return sendJson(res, 200, { success: true, helperResult });
  }

  if (req.method === "GET" && req.url === "/api/windows/logs") {
    const helperResult = await windowsHelper.run("logs.get", {});
    return sendJson(res, 200, { success: true, helperResult });
  }
  • Step 8: Run server tests

Run:

npm test

Expected: PASS.

  • Step 9: Commit
git add src/server/index.js src/server/windowsProfiles.js test/server/windows-api-model.test.js
git commit -m "feat: add windows API model"

Task 5: Clean Windows UI

Files:

  • Create: src/web/components/WindowsOverviewPage.jsx

  • Modify: src/web/api.js

  • Modify: src/web/App.jsx

  • Modify: src/web/components/Sidebar.jsx

  • Modify: src/web/components/Topbar.jsx

  • Modify: src/web/styles.css

  • Step 1: Add Windows API client methods

In src/web/api.js, add this object before configValidate:

  windows: {
    status: () => request("/api/windows/status"),
    profiles: {
      get: () => request("/api/windows/profiles"),
      save: (profiles) =>
        request("/api/windows/profiles", {
          method: "PUT",
          body: JSON.stringify({ profiles }),
        }),
      scan: (profiles) =>
        request("/api/windows/profiles/scan", {
          method: "POST",
          body: JSON.stringify({ profiles }),
        }),
      apply: () =>
        request("/api/windows/profiles/apply", {
          method: "POST",
        }),
    },
    targets: {
      get: () => request("/api/windows/targets"),
      save: (targets) =>
        request("/api/windows/targets", {
          method: "PUT",
          body: JSON.stringify({ targets }),
        }),
    },
    service: (service, action) =>
      request("/api/windows/service", {
        method: "POST",
        body: JSON.stringify({ service, action }),
      }),
    logs: () => request("/api/windows/logs"),
  },
  • Step 2: Create Windows overview component

Create src/web/components/WindowsOverviewPage.jsx:

import React, { useEffect, useMemo, useState } from 'react';
import { api } from '../api.js';

function targetLabel(target) {
  if (!target) return 'No proxy target';
  return `${target.name} - ${target.host}:${target.port}`;
}

function routeTitle(status) {
  const helper = status?.helperStatus;
  const proxifyre = helper?.result?.proxifyre || helper?.proxifyre;
  const singbox = helper?.result?.singbox || helper?.singbox;
  if (proxifyre === 'Running' && singbox === 'Running') return 'Apps are routed through local sing-box';
  if (proxifyre === 'Running') return 'Apps are routed through an existing proxy';
  return 'App routing is stopped';
}

function emptyProfile() {
  return {
    id: `profile-${Date.now()}`,
    name: 'New profile',
    enabled: true,
    proxyTargetId: 'local-singbox',
    protocols: ['TCP', 'UDP'],
    items: [],
  };
}

function ProfileList({ profiles, selectedId, onSelect }) {
  return (
    <div className="win-profile-list">
      {profiles.map((profile) => (
        <button
          key={profile.id}
          className={`win-profile-row ${profile.id === selectedId ? 'active' : ''}`}
          onClick={() => onSelect(profile.id)}
        >
          <span className={`win-profile-check ${profile.enabled ? 'on' : ''}`}>on</span>
          <span>
            <strong>{profile.name}</strong>
            <small>{profile.items.length} items - target: {profile.proxyTargetId}</small>
          </span>
          <em>{profile.resolvedCount ?? profile.items.length}</em>
        </button>
      ))}
    </div>
  );
}

function ProfileDetails({ profile, targets, onChange }) {
  const [newItem, setNewItem] = useState('');
  const [newType, setNewType] = useState('process');
  if (!profile) {
    return <div className="win-profile-empty">Select or add a profile.</div>;
  }
  function patch(patchValue) {
    onChange({ ...profile, ...patchValue });
  }
  function addItem() {
    const value = newItem.trim();
    if (!value) return;
    patch({
      items: [
        ...profile.items,
        { type: newType, value, recursive: newType === 'folder' },
      ],
    });
    setNewItem('');
  }
  return (
    <div className="win-detail">
      <label>
        <span>Name</span>
        <input
          className="input"
          value={profile.name}
          onChange={(event) => patch({ name: event.target.value })}
        />
      </label>
      <label>
        <span>Proxy target</span>
        <select
          className="select"
          value={profile.proxyTargetId}
          onChange={(event) => patch({ proxyTargetId: event.target.value })}
        >
          {targets.map((target) => (
            <option key={target.id} value={target.id}>{targetLabel(target)}</option>
          ))}
        </select>
      </label>
      <div className="win-add-item">
        <select className="select" value={newType} onChange={(event) => setNewType(event.target.value)}>
          <option value="process">Process</option>
          <option value="folder">Folder</option>
          <option value="exe">EXE file</option>
        </select>
        <input
          className="input"
          value={newItem}
          placeholder="Discord, %LOCALAPPDATA%\\vesktop, or C:\\Games\\game.exe"
          onChange={(event) => setNewItem(event.target.value)}
          onKeyDown={(event) => event.key === 'Enter' && addItem()}
        />
        <button className="btn btn-secondary" onClick={addItem}>Add</button>
      </div>
      <div className="win-items">
        {profile.items.map((item, index) => (
          <div key={`${item.type}-${item.value}-${index}`} className="win-item">
            <span>{item.value}</span>
            <small>{item.type}</small>
            <button
              className="btn btn-link sm"
              onClick={() => patch({ items: profile.items.filter((_, i) => i !== index) })}
            >
              Remove
            </button>
          </div>
        ))}
      </div>
    </div>
  );
}

export function WindowsOverviewPage({ pushToast }) {
  const [status, setStatus] = useState(null);
  const [profiles, setProfiles] = useState([]);
  const [targets, setTargets] = useState([]);
  const [selectedId, setSelectedId] = useState('');
  const [busy, setBusy] = useState(false);

  async function load() {
    const data = await api.windows.status();
    setStatus(data);
    const nextProfiles = data.profiles || [];
    setProfiles(nextProfiles);
    setTargets(data.targets || []);
    setSelectedId((current) => current || nextProfiles[0]?.id || '');
  }

  useEffect(() => {
    load().catch((error) => pushToast?.({ kind: 'danger', title: 'Windows status failed', message: error.message }));
    const timer = setInterval(() => load().catch(() => {}), 5000);
    return () => clearInterval(timer);
  }, []);

  const selected = useMemo(
    () => profiles.find((profile) => profile.id === selectedId) || null,
    [profiles, selectedId],
  );

  function replaceProfile(nextProfile) {
    setProfiles((prev) => prev.map((profile) => profile.id === nextProfile.id ? nextProfile : profile));
  }

  async function saveProfiles(nextProfiles = profiles) {
    setBusy(true);
    try {
      const data = await api.windows.profiles.save(nextProfiles);
      setProfiles(data.summaries || data.profiles || []);
      pushToast?.({ kind: 'success', title: 'Profiles saved' });
    } finally {
      setBusy(false);
    }
  }

  async function applyProfiles() {
    setBusy(true);
    try {
      await api.windows.profiles.save(profiles);
      await api.windows.profiles.apply();
      await load();
      pushToast?.({ kind: 'success', title: 'ProxiFyre updated' });
    } finally {
      setBusy(false);
    }
  }

  function addProfile() {
    const profile = emptyProfile();
    setProfiles((prev) => [...prev, profile]);
    setSelectedId(profile.id);
  }

  return (
    <div className="windows-page">
      <section className="windows-status-panel">
        <div className="windows-status-main">
          <span className="windows-status-dot" />
          <div>
            <h1>{routeTitle(status)}</h1>
            <p>Profiles send selected apps through ProxiFyre to local sing-box or an existing proxy target.</p>
          </div>
        </div>
        <div className="windows-route-line">
          <span>Selected apps</span><b>-&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:

import { WindowsOverviewPage } from './components/WindowsOverviewPage.jsx';

Add this after const isClientMode = state?.mode === 'client'; if present, or near equivalent mode checks:

  const isWindowsMode = state?.mode === 'windows';

Update the client-mode redirect effect to include Windows:

  useEffect(() => {
    if ((state?.mode === 'client' || state?.mode === 'windows') && page !== 'overview') {
      navigate('overview');
    }
  }, [state?.mode, page]);

In the page rendering section, render Windows overview before generic overview:

          {page === 'overview' && isWindowsMode && (
            <WindowsOverviewPage pushToast={pushToast} />
          )}
          {page === 'overview' && isClientMode && !isWindowsMode && (
            <ClientOverviewPage
              state={state}
              activeServer={activeServer}
              busy={busy}
              subscriptionUrl={subscriptionUrl}
              setSubscriptionUrl={setSubscriptionUrl}
              servers={servers}
              pendingTag={pendingTag}
              setPendingTag={setPendingTag}
              clientSettings={clientSettings}
              onSaveClientSettings={saveClientSettings}
              onCheckSharedProxy={checkSharedProxy}
              onFetchSubscription={fetchSubscription}
              onApply={applyServer}
            />
          )}

Keep existing gateway overview rendering under !isClientMode && !isWindowsMode.

  • Step 4: Update sidebar/topbar labels

In src/web/components/Sidebar.jsx, make Windows nav compact:

const WINDOWS_ITEMS = [
  { id: 'overview', label: 'Overview', ico: 'O' },
  { id: 'logs', label: 'Logs', ico: 'L' },
  { id: 'settings', label: 'Settings', ico: 'S' },
];

Then select items:

  const items = mode === 'windows'
    ? WINDOWS_ITEMS
    : mode === 'client'
      ? CLIENT_ITEMS
      : ITEMS;

In src/web/components/Topbar.jsx, display VPN Proxy Windows when state?.mode === 'windows'.

  • Step 5: Add CSS

Append to src/web/styles.css:

.windows-page {
  display: grid;
  gap: 16px;
}

.windows-status-panel {
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto;
  gap: 16px;
  align-items: center;
  padding: 16px;
  border: 1px solid var(--border);
  border-radius: 8px;
  background: var(--panel);
}

.windows-status-main {
  display: flex;
  gap: 12px;
  align-items: flex-start;
}

.windows-status-dot {
  width: 12px;
  height: 12px;
  border-radius: 999px;
  background: var(--success);
  margin-top: 8px;
  box-shadow: 0 0 0 5px rgba(34, 197, 94, 0.12);
}

.windows-status-panel h1 {
  margin: 0 0 6px;
  font-size: 24px;
}

.windows-status-panel p {
  margin: 0;
  color: var(--muted);
}

.windows-route-line {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  align-items: center;
  color: var(--muted);
  font-size: 13px;
}

.windows-route-line span {
  border: 1px solid var(--border);
  border-radius: 999px;
  padding: 6px 10px;
  background: var(--bg-soft);
}

.windows-workspace {
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(320px, 420px);
  gap: 16px;
}

.win-profile-list {
  display: grid;
  gap: 8px;
  padding: 10px;
}

.win-profile-row {
  display: grid;
  grid-template-columns: 26px minmax(0, 1fr) auto;
  gap: 10px;
  align-items: center;
  width: 100%;
  text-align: left;
  border: 1px solid transparent;
  border-radius: 8px;
  padding: 10px;
  background: transparent;
  color: var(--text);
}

.win-profile-row.active {
  border-color: var(--border-strong);
  background: var(--bg-soft);
}

.win-profile-row strong,
.win-profile-row small {
  display: block;
}

.win-profile-row small,
.win-profile-row em {
  color: var(--muted);
  font-size: 12px;
  font-style: normal;
}

.win-profile-check {
  width: 22px;
  height: 22px;
  border-radius: 999px;
  display: grid;
  place-items: center;
  background: var(--border);
  color: var(--muted);
  font-size: 12px;
}

.win-profile-check.on {
  background: var(--accent);
  color: #fff;
}

.win-detail {
  display: grid;
  gap: 12px;
  padding: 14px;
}

.win-detail label {
  display: grid;
  gap: 6px;
  color: var(--muted);
  font-size: 12px;
}

.win-add-item {
  display: grid;
  grid-template-columns: 110px minmax(0, 1fr) auto;
  gap: 8px;
}

.win-items {
  display: grid;
  gap: 8px;
}

.win-item {
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto auto;
  gap: 8px;
  align-items: center;
  border: 1px solid var(--border);
  border-radius: 7px;
  padding: 8px;
}

.win-item small {
  color: var(--muted);
}

.windows-activity-row {
  display: grid;
  grid-template-columns: 130px minmax(0, 1fr) auto;
  gap: 10px;
  padding: 10px 14px;
  border-top: 1px solid var(--border);
  color: var(--muted);
  font-size: 13px;
}

.windows-activity-row strong {
  color: var(--info);
}

@media (max-width: 960px) {
  .windows-status-panel,
  .windows-workspace,
  .windows-activity-row {
    grid-template-columns: 1fr;
  }

  .win-add-item {
    grid-template-columns: 1fr;
  }
}
  • Step 6: Build

Run:

npm run build

Expected: PASS.

  • Step 7: Commit
git add src/web/api.js src/web/App.jsx src/web/components/WindowsOverviewPage.jsx src/web/components/Sidebar.jsx src/web/components/Topbar.jsx src/web/styles.css
git commit -m "feat: add windows client UI"

Task 6: PowerShell Helper Scripts

Files:

  • Create: scripts/windows/VpnProxy.Windows.psm1

  • Create: scripts/windows/helper.ps1

  • Create: scripts/windows/manage.ps1

  • Step 1: Create helper module

Create scripts/windows/VpnProxy.Windows.psm1:

Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"

$script:InstallRoot = $env:VPN_PROXY_WINDOWS_ROOT
if ([string]::IsNullOrWhiteSpace($script:InstallRoot)) {
  $script:InstallRoot = "C:\Tools\vpn-proxy-windows"
}
$script:ProxiFyreRoot = $env:PROXIFYRE_ROOT
if ([string]::IsNullOrWhiteSpace($script:ProxiFyreRoot)) {
  $script:ProxiFyreRoot = "C:\Tools\ProxiFyre"
}

function New-VpnProxyResult {
  param(
    [string]$Action,
    [bool]$Success,
    [object]$Result = $null,
    [string]$Message = "",
    [string]$ErrorMessage = ""
  )
  $value = [ordered]@{
    success = $Success
    action = $Action
  }
  if ($null -ne $Result) { $value.result = $Result }
  if ($Message) { $value.message = $Message }
  if ($ErrorMessage) { $value.error = $ErrorMessage }
  return $value
}

function Get-VpnProxyStatus {
  $task = Get-ScheduledTask -TaskName "SingBoxProxy" -ErrorAction SilentlyContinue
  $singboxProcess = Get-Process -Name "sing-box" -ErrorAction SilentlyContinue
  $proxifyre = Get-Service -Name "ProxiFyreService" -ErrorAction SilentlyContinue
  return [ordered]@{
    singbox = if ($singboxProcess) { "Running" } elseif ($task) { [string]$task.State } else { "NotInstalled" }
    proxifyre = if ($proxifyre) { [string]$proxifyre.Status } else { "NotInstalled" }
    installRoot = $script:InstallRoot
    proxifyreRoot = $script:ProxiFyreRoot
  }
}

function Write-ProxiFyreConfig {
  param(
    [Parameter(Mandatory=$true)][string]$ConfigPath,
    [Parameter(Mandatory=$true)][object]$Config
  )
  $dir = Split-Path -Parent $ConfigPath
  New-Item -ItemType Directory -Force -Path $dir | Out-Null
  if (Test-Path $ConfigPath) {
    $backup = "$ConfigPath.bak"
    Copy-Item $ConfigPath $backup -Force
  }
  $Config | ConvertTo-Json -Depth 20 | Set-Content -Path $ConfigPath -Encoding UTF8
}

function Restart-ProxiFyre {
  $exe = Join-Path $script:ProxiFyreRoot "ProxiFyre.exe"
  if (-not (Test-Path $exe)) {
    throw "ProxiFyre.exe not found at $exe"
  }
  & $exe stop 2>$null | Out-Null
  & $exe install 2>$null | Out-Null
  & $exe start 2>$null | Out-Null
}

function Invoke-ProxiFyreApply {
  param([object]$Payload)
  Write-ProxiFyreConfig -ConfigPath $Payload.configPath -Config $Payload.config
  Restart-ProxiFyre
  return New-VpnProxyResult -Action "proxifyre.apply" -Success $true -Message "ProxiFyre config applied and service restarted"
}

function Invoke-ServiceControl {
  param([object]$Payload)
  $service = [string]$Payload.service
  $action = [string]$Payload.action
  if ($service -eq "proxifyre") {
    if ($action -eq "restart") { Restart-ProxiFyre }
    elseif ($action -eq "start") { Start-Service -Name "ProxiFyreService" }
    elseif ($action -eq "stop") { Stop-Service -Name "ProxiFyreService" -Force }
  } elseif ($service -eq "sing-box") {
    if ($action -eq "restart") {
      Stop-ScheduledTask -TaskName "SingBoxProxy" -ErrorAction SilentlyContinue
      Start-ScheduledTask -TaskName "SingBoxProxy"
    } elseif ($action -eq "start") {
      Start-ScheduledTask -TaskName "SingBoxProxy"
    } elseif ($action -eq "stop") {
      Stop-ScheduledTask -TaskName "SingBoxProxy"
    }
  } elseif ($service -eq "ui") {
    return New-VpnProxyResult -Action "service.control" -Success $true -Message "UI is controlled by manage.ps1 -OpenUi"
  } else {
    throw "Unknown service: $service"
  }
  return New-VpnProxyResult -Action "service.control" -Success $true -Message "$service $action complete"
}

function Get-VpnProxyLogs {
  $paths = @(
    (Join-Path $script:InstallRoot "runtime\sing-box\singbox.log"),
    (Join-Path $script:ProxiFyreRoot "ProxiFyre.log")
  )
  $logs = @()
  foreach ($path in $paths) {
    if (Test-Path $path) {
      $logs += [ordered]@{
        path = $path
        lines = @(Get-Content $path -Tail 120 -ErrorAction SilentlyContinue)
      }
    }
  }
  return $logs
}

function Invoke-VpnProxyAction {
  param(
    [Parameter(Mandatory=$true)][string]$Action,
    [object]$Payload = @{}
  )
  switch ($Action) {
    "status.get" {
      return New-VpnProxyResult -Action $Action -Success $true -Result (Get-VpnProxyStatus)
    }
    "proxifyre.apply" {
      return Invoke-ProxiFyreApply -Payload $Payload
    }
    "service.control" {
      return Invoke-ServiceControl -Payload $Payload
    }
    "logs.get" {
      return New-VpnProxyResult -Action $Action -Success $true -Result (Get-VpnProxyLogs)
    }
    default {
      throw "Unknown action: $Action"
    }
  }
}

Export-ModuleMember -Function Invoke-VpnProxyAction, Get-VpnProxyStatus
  • Step 2: Create JSON wrapper

Create scripts/windows/helper.ps1:

Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"

$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
Import-Module (Join-Path $ScriptDir "VpnProxy.Windows.psm1") -Force

try {
  $raw = [Console]::In.ReadToEnd()
  if ([string]::IsNullOrWhiteSpace($raw)) {
    throw "Missing JSON input"
  }
  $request = $raw | ConvertFrom-Json
  $payload = if ($request.PSObject.Properties.Name -contains "payload") { $request.payload } else { @{} }
  $result = Invoke-VpnProxyAction -Action ([string]$request.action) -Payload $payload
  $result | ConvertTo-Json -Depth 30 -Compress
  exit 0
} catch {
  $errorResult = [ordered]@{
    success = $false
    action = "error"
    error = $_.Exception.Message
  }
  $errorResult | ConvertTo-Json -Depth 10 -Compress
  exit 1
}
  • Step 3: Create management script

Create scripts/windows/manage.ps1:

param(
  [switch]$OpenUi,
  [switch]$Status,
  [switch]$RestartServices
)

Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"

$Root = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path)
$env:APP_MODE = "windows"
$env:DATA_DIR = Join-Path $Root "data"
$env:DIST_DIR = Join-Path $Root "app\dist"
$env:PROXY_PORT = "1080"
$env:PROXY_BIND_IP = "127.0.0.1"
$env:SING_BOX_CONFIG = Join-Path $Root "runtime\sing-box\config.json"
$env:SING_BOX_CACHE = Join-Path $Root "runtime\sing-box\cache.db"
$env:WINDOWS_HELPER = Join-Path $Root "app\scripts\windows\helper.ps1"

function Get-NodeCommand {
  $portable = Join-Path $Root "runtime\node\node.exe"
  if (Test-Path $portable) { return $portable }
  return "node"
}

if ($Status) {
  $inputJson = @{ action = "status.get"; payload = @{} } | ConvertTo-Json -Compress
  $inputJson | & (Join-Path $Root "app\scripts\windows\helper.ps1")
  exit $LASTEXITCODE
}

if ($RestartServices) {
  $helper = Join-Path $Root "app\scripts\windows\helper.ps1"
  (@{ action = "service.control"; payload = @{ service = "proxifyre"; action = "restart" } } | ConvertTo-Json -Compress) | & $helper
  (@{ action = "service.control"; payload = @{ service = "sing-box"; action = "restart" } } | ConvertTo-Json -Compress) | & $helper
  exit 0
}

if ($OpenUi) {
  $node = Get-NodeCommand
  Start-Process "http://127.0.0.1:3456"
  & $node (Join-Path $Root "app\src\server\index.js")
  exit $LASTEXITCODE
}

Write-Host "VPN Proxy Windows"
Write-Host "  -OpenUi          Start local UI"
Write-Host "  -Status          Print JSON status"
Write-Host "  -RestartServices Restart ProxiFyre and sing-box"
  • Step 4: Syntax-check PowerShell scripts when pwsh exists

Run:

pwsh -NoProfile -Command "\$errors = \$null; [System.Management.Automation.Language.Parser]::ParseFile('scripts/windows/helper.ps1', [ref]\$null, [ref]\$errors) > \$null; if (\$errors) { \$errors; exit 1 }"
pwsh -NoProfile -Command "\$errors = \$null; [System.Management.Automation.Language.Parser]::ParseFile('scripts/windows/manage.ps1', [ref]\$null, [ref]\$errors) > \$null; if (\$errors) { \$errors; exit 1 }"

Expected: exit code 0. If pwsh is unavailable on macOS, record that syntax check was skipped and run npm test before commit.

  • Step 5: Commit
git add scripts/windows/VpnProxy.Windows.psm1 scripts/windows/helper.ps1 scripts/windows/manage.ps1
git commit -m "feat: add windows helper scripts"

Task 7: Curl-Friendly Windows Installer

Files:

  • Create: scripts/install-windows-client.ps1

  • Modify: README.md

  • Step 1: Create installer script

Create scripts/install-windows-client.ps1:

Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8

$InstallRoot = $env:VPN_PROXY_WINDOWS_ROOT
if ([string]::IsNullOrWhiteSpace($InstallRoot)) { $InstallRoot = "C:\Tools\vpn-proxy-windows" }
$AppDir = Join-Path $InstallRoot "app"
$DataDir = Join-Path $InstallRoot "data"
$RuntimeDir = Join-Path $InstallRoot "runtime"
$SingBoxDir = Join-Path $RuntimeDir "sing-box"
$RepoZipUrl = "https://git.dokops.ru/dokril/vpn-proxy/archive/master.zip"
$SingBoxVersion = "1.12.13"
$SingBoxUrl = "https://github.com/SagerNet/sing-box/releases/download/v$SingBoxVersion/sing-box-$SingBoxVersion-windows-amd64.zip"

function Assert-Admin {
  $principal = New-Object Security.Principal.WindowsPrincipal([Security.Principal.WindowsIdentity]::GetCurrent())
  if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
    throw "Run PowerShell 7 as Administrator"
  }
}

function Assert-PowerShell7 {
  if ($PSVersionTable.PSVersion.Major -lt 7) {
    throw "PowerShell 7 is required"
  }
}

function Download-File {
  param([string]$Url, [string]$Destination)
  Invoke-WebRequest -Uri $Url -OutFile $Destination -UseBasicParsing
  Unblock-File -Path $Destination -ErrorAction SilentlyContinue
}

function Install-AppFiles {
  New-Item -ItemType Directory -Force -Path $InstallRoot, $DataDir, $RuntimeDir | Out-Null
  $zip = Join-Path $env:TEMP "vpn-proxy-windows.zip"
  $extract = Join-Path $env:TEMP "vpn-proxy-windows-extract"
  Remove-Item $zip -Force -ErrorAction SilentlyContinue
  Remove-Item $extract -Recurse -Force -ErrorAction SilentlyContinue
  Download-File -Url $RepoZipUrl -Destination $zip
  Expand-Archive -Path $zip -DestinationPath $extract -Force
  $source = Get-ChildItem $extract -Directory | Where-Object { $_.Name -match "vpn-proxy-(master|main)" } | Select-Object -First 1
  if (-not $source) { throw "Downloaded archive layout is not recognized" }
  if (Test-Path $AppDir) {
    $backup = "$AppDir.backup"
    Remove-Item $backup -Recurse -Force -ErrorAction SilentlyContinue
    Move-Item $AppDir $backup
  }
  Move-Item $source.FullName $AppDir
  Remove-Item $zip -Force -ErrorAction SilentlyContinue
  Remove-Item $extract -Recurse -Force -ErrorAction SilentlyContinue
}

function Install-SingBox {
  New-Item -ItemType Directory -Force -Path $SingBoxDir | Out-Null
  if (Test-Path (Join-Path $SingBoxDir "sing-box.exe")) { return }
  $zip = Join-Path $env:TEMP "sing-box-windows.zip"
  $extract = Join-Path $env:TEMP "sing-box-windows-extract"
  Remove-Item $zip -Force -ErrorAction SilentlyContinue
  Remove-Item $extract -Recurse -Force -ErrorAction SilentlyContinue
  Download-File -Url $SingBoxUrl -Destination $zip
  Expand-Archive -Path $zip -DestinationPath $extract -Force
  $exe = Get-ChildItem $extract -Recurse -Filter "sing-box.exe" | Select-Object -First 1
  if (-not $exe) { throw "sing-box.exe was not found in archive" }
  Copy-Item $exe.FullName (Join-Path $SingBoxDir "sing-box.exe") -Force
}

function Select-InstallMode {
  Write-Host ""
  Write-Host "Choose install mode:"
  Write-Host "  [1] Full install: local sing-box + ProxiFyre"
  Write-Host "  [2] ProxiFyre only: use existing proxy target"
  $choice = Read-Host "Mode [1]"
  if ($choice -eq "2") { return "proxifyre-only" }
  return "full"
}

function Write-InitialTargets {
  param([string]$Mode)
  $targetsPath = Join-Path $DataDir "proxy-targets.json"
  if (Test-Path $targetsPath) { return }
  if ($Mode -eq "proxifyre-only") {
    $target = Read-Host "Existing SOCKS5 proxy target host:port"
    if ($target -notmatch "^([^:]+):(\d+)$") { throw "Expected host:port" }
    @(@{ id = "existing-proxy"; name = "Existing proxy"; protocol = "socks5"; host = $matches[1]; port = [int]$matches[2] }) |
      ConvertTo-Json -Depth 5 |
      Set-Content $targetsPath -Encoding UTF8
  }
}

function Install-NodeDependencies {
  Push-Location $AppDir
  try {
    npm install
    npm run build
  } finally {
    Pop-Location
  }
}

function Start-Ui {
  $manage = Join-Path $AppDir "scripts\windows\manage.ps1"
  Start-Process pwsh -ArgumentList "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", "`"$manage`"", "-OpenUi"
}

Assert-Admin
Assert-PowerShell7
$mode = Select-InstallMode
Install-AppFiles
if ($mode -eq "full") { Install-SingBox }
Write-InitialTargets -Mode $mode
Install-NodeDependencies
Set-Content -Path (Join-Path $DataDir "windows-state.json") -Encoding UTF8 -Value (@{ installMode = $mode } | ConvertTo-Json)
Start-Ui

Write-Host ""
Write-Host "VPN Proxy Windows is installed."
Write-Host "UI: http://127.0.0.1:3456"
Write-Host "Recovery:"
Write-Host "& `"$AppDir\scripts\windows\manage.ps1`" -OpenUi"
Write-Host "& `"$AppDir\scripts\windows\manage.ps1`" -Status"
  • Step 2: Add README Windows section

In README.md, add this section after the macOS Docker client section:

## Windows: app proxy client

Windows mode restores the native workflow for Discord, Vesktop, games, and other apps that do not expose proxy settings.

Run PowerShell 7 as Administrator:

```powershell
irm https://git.dokops.ru/dokril/vpn-proxy/raw/branch/master/scripts/install-windows-client.ps1 | iex

Installer modes:

  • Full install: local 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:

http://127.0.0.1:3456

Recovery commands:

& "C:\Tools\vpn-proxy-windows\app\scripts\windows\manage.ps1" -OpenUi
& "C:\Tools\vpn-proxy-windows\app\scripts\windows\manage.ps1" -Status
& "C:\Tools\vpn-proxy-windows\app\scripts\windows\manage.ps1" -RestartServices

The UI manages profiles made of process names, folders, and explicit .exe files. It generates ProxiFyre config and restarts ProxiFyre only when the user applies changes.


- [ ] **Step 3: Syntax-check installer when `pwsh` exists**

Run:

```bash
pwsh -NoProfile -Command "\$errors = \$null; [System.Management.Automation.Language.Parser]::ParseFile('scripts/install-windows-client.ps1', [ref]\$null, [ref]\$errors) > \$null; if (\$errors) { \$errors; exit 1 }"

Expected: exit code 0. If pwsh is unavailable, record that the syntax check was skipped.

  • Step 4: Commit
git add scripts/install-windows-client.ps1 README.md
git commit -m "feat: add windows client installer"

Task 8: Final Verification And Polish

Files:

  • Modify as needed based on verification failures.

  • Step 1: Run full automated tests

Run:

npm test

Expected: PASS.

  • Step 2: Build frontend

Run:

npm run build

Expected: PASS.

  • Step 3: Check compose config still works for macOS client

Run:

docker compose -f docker-compose.client.yml config

Expected: command exits 0 and still shows APP_MODE: client.

  • Step 4: Inspect generated diff

Run:

git status --short
git diff --check
git diff --stat

Expected:

  • no whitespace errors;

  • only Windows-client, UI, installer, README, and planned config files changed;

  • no changes under _archive/.

  • Step 5: Commit verification fixes if needed

If Step 1, Step 2, or Step 3 required code changes, commit them:

git add src/server src/web scripts README.md test
git commit -m "fix: polish windows client implementation"
  • Step 6: Prepare Windows manual verification checklist

Add the final manual checklist to the implementation summary:

Windows manual checks:
1. Fresh Full install in PowerShell 7 as Administrator.
2. Fresh ProxiFyre-only install with target 127.0.0.1:8080.
3. Re-run installer over an existing install and confirm profiles survive.
4. Add process profile for Discord and apply.
5. Add folder profile for Vesktop and apply.
6. Add explicit exe profile for a test program and apply.
7. Switch a profile to external gateway target and apply.
8. Restart ProxiFyre from UI and confirm status.
9. Copy diagnostics after entering an unreachable proxy target.