Refactor application structure and simplify implementation
This commit is contained in:
@@ -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
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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-файл";
|
||||
}
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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}`;
|
||||
}
|
||||
Reference in New Issue
Block a user