Refactor application structure and simplify implementation

This commit is contained in:
2026-07-22 00:08:09 +03:00
parent dbba3806cc
commit 90b2eb507c
74 changed files with 11362 additions and 6814 deletions
+29 -27
View File
@@ -1,43 +1,45 @@
import { describe, expect, it } from 'vitest';
import { parseProxy } from './parseProxy';
import { describe, expect, it } from "vitest";
import { parseProxy } from "./parseProxy";
describe('parseProxy', () => {
it('parses host and port without explicit protocol', () => {
expect(parseProxy('proxy.example.test:1080')).toEqual({
protocol: 'socks5',
host: 'proxy.example.test',
describe("parseProxy", () => {
it("parses host and port without explicit protocol", () => {
expect(parseProxy("proxy.example.test:1080")).toEqual({
protocol: "socks5",
host: "proxy.example.test",
port: 1080,
});
});
it('parses socks5 URLs', () => {
expect(parseProxy('socks5://127.0.0.1:1080')).toEqual({
protocol: 'socks5',
host: '127.0.0.1',
it("parses socks5 URLs", () => {
expect(parseProxy("socks5://127.0.0.1:1080")).toEqual({
protocol: "socks5",
host: "127.0.0.1",
port: 1080,
});
});
it('parses bracketed IPv6 hosts', () => {
expect(parseProxy('socks5://[::1]:1080')).toEqual({
protocol: 'socks5',
host: '::1',
it("parses bracketed IPv6 hosts", () => {
expect(parseProxy("socks5://[::1]:1080")).toEqual({
protocol: "socks5",
host: "::1",
port: 1080,
});
});
it('rejects unsupported schemes', () => {
expect(() => parseProxy('http://proxy.example.test:8080')).toThrow('SOCKS5');
});
it('rejects missing or invalid ports', () => {
expect(() => parseProxy('proxy.example.test')).toThrow('хост и порт');
expect(() => parseProxy('proxy.example.test:70000')).toThrow('Формат');
});
it('rejects userinfo credentials', () => {
expect(() => parseProxy('socks5://user:password@proxy.example.test:1080')).toThrow(
'логином и паролем',
it("rejects unsupported schemes", () => {
expect(() => parseProxy("http://proxy.example.test:8080")).toThrow(
"SOCKS5",
);
});
it("rejects missing or invalid ports", () => {
expect(() => parseProxy("proxy.example.test")).toThrow("хост и порт");
expect(() => parseProxy("proxy.example.test:70000")).toThrow("Формат");
});
it("rejects userinfo credentials", () => {
expect(() =>
parseProxy("socks5://user:password@proxy.example.test:1080"),
).toThrow("логином и паролем");
});
});
+13 -11
View File
@@ -1,34 +1,36 @@
export interface ParsedProxy {
protocol: 'socks5';
protocol: "socks5";
host: string;
port: number;
}
export function parseProxy(rawValue: string): ParsedProxy {
const value = rawValue.trim();
if (!value) throw new Error('Введи адрес прокси.');
if (!value) throw new Error("Введи адрес прокси.");
const withProtocol = /^[a-z][a-z0-9+.-]*:\/\//i.test(value) ? value : `socks5://${value}`;
const withProtocol = /^[a-z][a-z0-9+.-]*:\/\//i.test(value)
? value
: `socks5://${value}`;
let parsed: URL;
try {
parsed = new URL(withProtocol);
} catch {
throw new Error('Формат: socks5://host:port или host:port.');
throw new Error("Формат: socks5://host:port или host:port.");
}
const protocol = parsed.protocol.replace(':', '').toLowerCase();
if (protocol !== 'socks5') {
throw new Error('Сейчас поддерживается только SOCKS5.');
const protocol = parsed.protocol.replace(":", "").toLowerCase();
if (protocol !== "socks5") {
throw new Error("Сейчас поддерживается только SOCKS5.");
}
if (parsed.username || parsed.password) {
throw new Error('Прокси с логином и паролем пока не поддерживаются.');
throw new Error("Прокси с логином и паролем пока не поддерживаются.");
}
const host = parsed.hostname.replace(/^\[|\]$/g, '');
const host = parsed.hostname.replace(/^\[|\]$/g, "");
const port = Number(parsed.port);
if (!host || !Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error('Укажи хост и порт прокси.');
throw new Error("Укажи хост и порт прокси.");
}
return { protocol: 'socks5', host, port };
return { protocol: "socks5", host, port };
}
+26
View File
@@ -0,0 +1,26 @@
import type { ProfileItemType } from "../../domain/types";
export type DraftItemType = Extract<
ProfileItemType,
"process" | "folder" | "exe"
>;
export function normalizeItemValue(value: string, type: DraftItemType) {
const clean = value.trim().replace(/^"|"$/g, "");
if (!clean) return "";
if (type === "folder" || type === "exe") return clean;
return (
clean
.split(/[\\/]/)
.pop()
?.replace(/\.exe$/i, "")
.trim() ?? ""
);
}
export function itemTypeLabel(type: DraftItemType) {
if (type === "process") return "процесс";
if (type === "folder") return "папка";
return "EXE-файл";
}
+61
View File
@@ -0,0 +1,61 @@
import { describe, expect, it } from "vitest";
import {
configChangeRows,
configSnapshotFromUi,
sameConfigSnapshot,
} from "./snapshots";
describe("configuration snapshots", () => {
it("normalizes proxy and Windows app values", () => {
const snapshot = configSnapshotFromUi(
"external",
" SOCKS5://Proxy.Example.Test:1080 ",
[
{ type: "process", value: "C:\\Apps\\Discord.exe" },
{ type: "folder", value: " C:\\Games " },
],
);
expect(snapshot.proxy).toBe("socks5://proxy.example.test:1080");
expect(snapshot.items).toEqual([
{ type: "folder", value: "c:\\games" },
{ type: "process", value: "discord" },
]);
});
it("detects a server change by stable id even when tags match", () => {
const applied = configSnapshotFromUi(
"local-singbox",
"",
[],
"server-a",
"Same tag",
);
const current = configSnapshotFromUi(
"local-singbox",
"",
[],
"server-b",
"Same tag",
);
expect(sameConfigSnapshot(applied, current)).toBe(false);
expect(configChangeRows(applied, current).map((row) => row.id)).toContain(
"vpn-server",
);
});
it("reports added and removed app items independent of input order", () => {
const applied = configSnapshotFromUi("external", "proxy.test:1080", [
{ type: "process", value: "Discord.exe" },
]);
const current = configSnapshotFromUi("external", "proxy.test:1080", [
{ type: "process", value: "Telegram.exe" },
]);
expect(configChangeRows(applied, current).map((row) => row.tone)).toEqual([
"added",
"removed",
]);
});
});
+201
View File
@@ -0,0 +1,201 @@
import { parseProxy } from "./parseProxy";
import {
itemTypeLabel,
normalizeItemValue,
type DraftItemType,
} from "./profileItems";
export type RouteMode = "external" | "local-singbox";
export interface ConfigSnapshotItem {
type: DraftItemType;
value: string;
}
export interface ConfigSnapshot {
routeMode: RouteMode;
proxy: string;
selectedServerId: string;
selectedServerTag: string;
items: ConfigSnapshotItem[];
}
export interface PendingChangeRow {
id: string;
label: string;
before?: string;
after: string;
tone?: "added" | "removed" | "changed";
}
export function configSnapshotFromUi(
routeMode: RouteMode,
proxyInput: string,
items: Array<{ type: DraftItemType; value: string }>,
selectedServerId?: string,
selectedServerTag?: string,
): ConfigSnapshot {
return {
routeMode,
proxy: routeMode === "external" ? normalizeProxySnapshot(proxyInput) : "",
selectedServerId:
routeMode === "local-singbox" ? (selectedServerId?.trim() ?? "") : "",
selectedServerTag:
routeMode === "local-singbox" ? (selectedServerTag?.trim() ?? "") : "",
items: normalizeSnapshotItems(items),
};
}
export function configChangeRows(
applied: ConfigSnapshot,
current: ConfigSnapshot,
): PendingChangeRow[] {
if (sameConfigSnapshot(applied, current)) return [];
const rows: PendingChangeRow[] = [];
if (applied.routeMode !== current.routeMode) {
rows.push({
id: "route-mode",
label: "Маршрут",
before: routeModeLabel(applied.routeMode),
after: routeModeLabel(current.routeMode),
});
}
if (
applied.proxy !== current.proxy &&
(applied.routeMode === "external" || current.routeMode === "external")
) {
rows.push({
id: "external-proxy",
label: "SOCKS5",
before: snapshotProxyChangeText(applied),
after: snapshotProxyChangeText(current),
});
}
if (
applied.selectedServerId !== current.selectedServerId &&
(applied.routeMode === "local-singbox" ||
current.routeMode === "local-singbox")
) {
rows.push({
id: "vpn-server",
label: "VPN сервер",
before: snapshotServerChangeText(applied),
after: snapshotServerChangeText(current),
});
}
rows.push(...snapshotItemChangeRows(applied.items, current.items));
return rows;
}
export function sameConfigSnapshot(
left: ConfigSnapshot,
right: ConfigSnapshot,
) {
return (
left.routeMode === right.routeMode &&
left.proxy === right.proxy &&
left.selectedServerId === right.selectedServerId &&
left.selectedServerTag === right.selectedServerTag &&
left.items.length === right.items.length &&
left.items.every((item, index) => {
const other = right.items[index];
return item.type === other.type && item.value === other.value;
})
);
}
export function routeModeLabel(routeMode: RouteMode) {
return routeMode === "local-singbox" ? "Локальный прокси" : "Внешний прокси";
}
export function displaySnapshotProxy(proxy: string) {
return proxy.replace(/^socks5:\/\//, "") || "не указан";
}
export function displayServerTag(tag: string) {
const withoutFlags = tag
.replace(/[\u{1f1e6}-\u{1f1ff}]/gu, "")
.replace(/\s*->\s*/g, " -> ")
.replace(/\s*->\s*$/g, "")
.replace(/^\s*->\s*/g, "")
.replace(/\s{2,}/g, " ")
.trim();
return withoutFlags || tag;
}
function normalizeProxySnapshot(value: string) {
try {
const parsed = parseProxy(value);
return `${parsed.protocol}://${parsed.host.trim().toLowerCase()}:${parsed.port}`;
} catch {
return value.trim().toLowerCase();
}
}
function normalizeSnapshotItems(
items: Array<{ type: DraftItemType; value: string }>,
): ConfigSnapshotItem[] {
return items
.map((item) => ({
type: item.type,
value: normalizeItemValue(item.value, item.type).toLowerCase(),
}))
.filter((item) => item.value)
.sort((left, right) =>
`${left.type}:${left.value}`.localeCompare(
`${right.type}:${right.value}`,
),
);
}
function snapshotProxyChangeText(snapshot: ConfigSnapshot) {
return snapshot.routeMode === "external"
? displaySnapshotProxy(snapshot.proxy)
: "не используется";
}
function snapshotServerChangeText(snapshot: ConfigSnapshot) {
if (snapshot.routeMode !== "local-singbox") return "не используется";
return snapshot.selectedServerTag
? displayServerTag(snapshot.selectedServerTag)
: "сервер не выбран";
}
function snapshotItemChangeRows(
appliedItems: ConfigSnapshotItem[],
currentItems: ConfigSnapshotItem[],
): PendingChangeRow[] {
const appliedKeys = new Set(appliedItems.map(snapshotItemKey));
const currentKeys = new Set(currentItems.map(snapshotItemKey));
const added = currentItems.filter(
(item) => !appliedKeys.has(snapshotItemKey(item)),
);
const removed = appliedItems.filter(
(item) => !currentKeys.has(snapshotItemKey(item)),
);
return [
...added.map((item) => ({
id: `app-add-${snapshotItemKey(item)}`,
label: "Добавлено",
after: `+ ${formatSnapshotItem(item)}`,
tone: "added" as const,
})),
...removed.map((item) => ({
id: `app-remove-${snapshotItemKey(item)}`,
label: "Удалено",
after: `- ${formatSnapshotItem(item)}`,
tone: "removed" as const,
})),
];
}
function snapshotItemKey(item: ConfigSnapshotItem) {
return `${item.type}:${item.value}`;
}
function formatSnapshotItem(item: ConfigSnapshotItem) {
return `${itemTypeLabel(item.type)} ${item.value}`;
}