Expand README with architecture and setup details

This commit is contained in:
2026-07-08 09:58:26 +03:00
parent 81be7e186c
commit c5120669d2
109 changed files with 22311 additions and 0 deletions

235
src/api/tauriCommands.ts Normal file
View File

@@ -0,0 +1,235 @@
import { invoke } from '@tauri-apps/api/core';
import type {
ActivityEntry,
ComponentStatus,
LocalSingBoxConfig,
Profile,
ProfileInput,
SubscriptionCache,
SubscriptionServer,
Target,
TargetInput,
} from '../domain/types';
export interface CommandError {
code: string;
message: string;
details?: Array<{
field: string;
message: string;
}>;
}
export interface StatusResponse {
routeLine: string;
activeProfileCount: number;
routedAppCount: number;
activeTarget?: Target;
components: ComponentStatus[];
recentActivity: ActivityEntry[];
generatedConfigPath: string;
}
export interface SavedStateResponse {
profiles: Profile[];
targets: Target[];
generatedConfigPath: string;
}
export interface ProxiFyreSetupItem {
id: string;
name: string;
installed: boolean;
version?: string;
details: string;
}
export interface ProxiFyreSetupStatus {
ready: boolean;
missingCount: number;
items: ProxiFyreSetupItem[];
}
export type SingBoxSetupItem = ProxiFyreSetupItem;
export interface SingBoxSetupStatus {
ready: boolean;
missingCount: number;
items: SingBoxSetupItem[];
}
export interface LocalSingBoxStatusResponse {
config: LocalSingBoxConfig;
cache?: SubscriptionCache;
component: ComponentStatus;
generatedConfigPath: string;
lanListenHost?: string;
}
export interface PingServerResponse {
tag: string;
server: string;
serverPort: number;
ok: boolean;
latency?: number;
error?: string;
}
export interface GenerateSingBoxConfigResponse {
success: boolean;
message: string;
adapterId: string;
generatedConfigPath: string;
selectedServerTag: string;
listenHost: string;
listenPort: number;
check?: {
checked: boolean;
success: boolean;
message: string;
};
activity: ActivityEntry;
}
export interface HelperApplyResult {
success: boolean;
changed: boolean;
action: string;
message: string;
}
export interface ApplyProfilesResponse {
success: boolean;
changed: boolean;
message: string;
adapterId: string;
generatedConfigPath: string;
enabledProfiles: number;
routedApps: number;
helper: HelperApplyResult;
activity: ActivityEntry;
}
export function getStatus(): Promise<StatusResponse> {
return invoke<StatusResponse>('get_status');
}
export function getSavedState(): Promise<SavedStateResponse> {
return invoke<SavedStateResponse>('get_saved_state');
}
export function getProfiles(): Promise<Profile[]> {
return invoke<Profile[]>('get_profiles');
}
export function saveProfile(input: ProfileInput): Promise<Profile> {
return invoke<Profile>('save_profile', { input });
}
export function getTargets(): Promise<Target[]> {
return invoke<Target[]>('get_targets');
}
export function saveTarget(input: TargetInput): Promise<Target> {
return invoke<Target>('save_target', { input });
}
export function getComponents(): Promise<ComponentStatus[]> {
return invoke<ComponentStatus[]>('get_components');
}
export function getProxiFyreSetupStatus(): Promise<ProxiFyreSetupStatus> {
return invoke<ProxiFyreSetupStatus>('get_proxifyre_setup_status');
}
export function getSingBoxStatus(): Promise<LocalSingBoxStatusResponse> {
return invoke<LocalSingBoxStatusResponse>('get_singbox_status');
}
export function getSingBoxSetupStatus(): Promise<SingBoxSetupStatus> {
return invoke<SingBoxSetupStatus>('get_singbox_setup_status');
}
export function saveSingBoxSubscription(subscriptionUrl: string): Promise<LocalSingBoxStatusResponse> {
return invoke<LocalSingBoxStatusResponse>('save_singbox_subscription', {
input: { subscriptionUrl },
});
}
export function fetchSingBoxSubscription(): Promise<LocalSingBoxStatusResponse> {
return invoke<LocalSingBoxStatusResponse>('fetch_singbox_subscription');
}
export function forgetSingBoxSubscription(): Promise<LocalSingBoxStatusResponse> {
return invoke<LocalSingBoxStatusResponse>('forget_singbox_subscription');
}
export function selectSingBoxServer(server: SubscriptionServer): Promise<LocalSingBoxStatusResponse> {
return invoke<LocalSingBoxStatusResponse>('select_singbox_server', {
input: {
tag: server.tag,
server: server.server,
serverPort: server.serverPort,
},
});
}
export function pingSingBoxServer(tag: string): Promise<PingServerResponse> {
return invoke<PingServerResponse>('ping_singbox_server', {
input: { tag },
});
}
export function pingAllSingBoxServers(): Promise<PingServerResponse[]> {
return invoke<PingServerResponse[]>('ping_all_singbox_servers');
}
export function pingProxyTarget(host: string, port: number): Promise<PingServerResponse> {
return invoke<PingServerResponse>('ping_proxy_target', {
input: { host, port },
});
}
export function generateSingBoxConfig(): Promise<GenerateSingBoxConfigResponse> {
return invoke<GenerateSingBoxConfigResponse>('generate_singbox_config');
}
export function applyProfiles(): Promise<ApplyProfilesResponse> {
return invoke<ApplyProfilesResponse>('apply_profiles');
}
export function openConfigLocation(): Promise<string> {
return invoke<string>('open_config_location');
}
export function startProxiFyreService(): Promise<ComponentStatus> {
return invoke<ComponentStatus>('start_proxifyre_service');
}
export function stopProxiFyreService(): Promise<ComponentStatus> {
return invoke<ComponentStatus>('stop_proxifyre_service');
}
export function installProxiFyre(): Promise<ComponentStatus> {
return invoke<ComponentStatus>('install_proxifyre');
}
export function uninstallProxiFyre(): Promise<ComponentStatus> {
return invoke<ComponentStatus>('uninstall_proxifyre');
}
export function startSingBoxService(): Promise<ComponentStatus> {
return invoke<ComponentStatus>('start_singbox_service');
}
export function stopSingBoxService(): Promise<ComponentStatus> {
return invoke<ComponentStatus>('stop_singbox_service');
}
export function installSingBox(): Promise<ComponentStatus> {
return invoke<ComponentStatus>('install_singbox');
}
export function uninstallSingBox(): Promise<ComponentStatus> {
return invoke<ComponentStatus>('uninstall_singbox');
}

2162
src/app/App.tsx Normal file

File diff suppressed because it is too large Load Diff

83
src/app/readiness.ts Normal file
View File

@@ -0,0 +1,83 @@
export type RouteMode = 'external' | 'local-singbox';
export interface ApplyReadinessInput {
routeMode: RouteMode;
appCount: number;
proxiFyreInstalled: boolean;
singBoxInstalled: boolean;
selectedServerTag?: string;
externalProxyValue: string;
externalProxyError?: string | null;
busy: boolean;
}
export interface ApplyReadiness {
ready: boolean;
title?: string;
text?: string;
}
export function getApplyReadiness(input: ApplyReadinessInput): ApplyReadiness {
if (input.busy) {
return {
ready: false,
title: 'Операция уже выполняется',
text: 'Дождись завершения текущего действия перед повторным применением.',
};
}
if (!input.proxiFyreInstalled) {
return {
ready: false,
title: 'ProxiFyre не установлен',
text: 'Установи ProxiFyre, чтобы маршрутизировать выбранные приложения.',
};
}
if (input.appCount < 1) {
return {
ready: false,
title: 'Нет приложений',
text: 'Добавь хотя бы один процесс, EXE-файл или папку.',
};
}
if (input.routeMode === 'external') {
if (!input.externalProxyValue.trim()) {
return {
ready: false,
title: 'Прокси не указан',
text: 'Введи адрес SOCKS5 прокси в формате host:port или socks5://host:port.',
};
}
if (input.externalProxyError) {
return {
ready: false,
title: 'Проверь формат прокси',
text: input.externalProxyError,
};
}
}
if (input.routeMode === 'local-singbox') {
if (!input.singBoxInstalled) {
return {
ready: false,
title: 'Local sing-box не установлен',
text: 'Установи Local sing-box, чтобы применить локальный маршрут.',
};
}
if (!input.selectedServerTag) {
return {
ready: false,
title: 'Сервер не выбран',
text: 'Выбери сервер Local sing-box перед применением маршрута.',
};
}
}
return { ready: true };
}

15
src/app/viewModel.ts Normal file
View File

@@ -0,0 +1,15 @@
import type { ComponentStatus } from '../domain/types';
import type { ServiceControlState } from '../ui';
export function serviceControlState(
component: ComponentStatus | undefined,
checking: boolean,
): ServiceControlState {
if (checking) return 'checking';
if (!component) return 'missing';
if (component.state === 'error') return 'error';
if (component.running) return 'running';
if (component.installed) return 'stopped';
return 'missing';
}

101
src/domain/types.ts Normal file
View File

@@ -0,0 +1,101 @@
export type Protocol = 'TCP' | 'UDP';
export type ProfileItemType = 'process' | 'folder' | 'exe';
export type TargetKind = 'local' | 'external';
export type ProxyProtocol = 'socks5' | 'http';
export type ComponentId = 'control-app' | 'proxyfier' | 'singbox';
export type ComponentState = 'installed' | 'missing' | 'stopped' | 'running' | 'error';
export type ActivityLevel = 'info' | 'warning' | 'error' | 'success';
export interface ProfileItemInput {
type: ProfileItemType | string;
value: string;
recursive?: boolean;
}
export interface ProfileInput {
id?: string;
name: string;
enabled?: boolean;
targetId?: string;
protocols?: string[];
items?: ProfileItemInput[];
}
export interface ProfileItem {
type: ProfileItemType;
value: string;
recursive: boolean;
}
export interface Profile {
id: string;
name: string;
enabled: boolean;
targetId: string;
protocols: Protocol[];
items: ProfileItem[];
}
export interface TargetInput {
id?: string;
name: string;
kind?: TargetKind | string;
protocol?: ProxyProtocol | string;
host: string;
port: number;
requiresComponent?: ComponentId | string;
}
export interface Target {
id: string;
name: string;
kind: TargetKind;
protocol: ProxyProtocol;
host: string;
port: number;
requiresComponent?: ComponentId;
}
export interface ComponentStatus {
id: ComponentId;
name: string;
state: ComponentState;
installed: boolean;
running: boolean;
version?: string;
path?: string;
problems: string[];
actions: string[];
}
export interface LocalSingBoxConfig {
subscriptionDisplayUrl?: string;
hasSubscription: boolean;
selectedServerTag?: string;
listenHost: string;
listenPort: number;
serviceName: string;
installRoot: string;
updatedAt?: string;
}
export interface SubscriptionServer {
tag: string;
type: string;
server: string;
serverPort: number;
}
export interface SubscriptionCache {
servers: SubscriptionServer[];
userInfo: Record<string, number | string | boolean | null>;
fetchedAt: string;
}
export interface ActivityEntry {
id: string;
at: string;
level: ActivityLevel;
title: string;
message: string;
}

11
src/main.tsx Normal file
View File

@@ -0,0 +1,11 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import { App } from './app/App';
import './styles/app.css';
createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);

2668
src/styles/app.css Normal file

File diff suppressed because it is too large Load Diff

54
src/ui/ActionMenu.tsx Normal file
View File

@@ -0,0 +1,54 @@
import { MoreHorizontal } from 'lucide-react';
import { IconButton } from './IconButton';
export interface ActionMenuItem {
label: string;
onClick: () => void;
danger?: boolean;
disabled?: boolean;
}
export interface ActionMenuProps {
open: boolean;
onOpenChange: (open: boolean) => void;
label: string;
items: ActionMenuItem[];
disabled?: boolean;
}
export function ActionMenu({
open,
onOpenChange,
label,
items,
disabled,
}: ActionMenuProps) {
return (
<div className="ui-action-menu">
<IconButton
label={label}
icon={<MoreHorizontal size={20} strokeWidth={2} />}
onClick={() => onOpenChange(!open)}
disabled={disabled}
aria-expanded={open}
/>
{open ? (
<div className="ui-action-menu-popover" role="menu">
{items.map((item) => (
<button
type="button"
role="menuitem"
className={item.danger ? 'is-danger' : ''}
onClick={item.onClick}
disabled={item.disabled}
key={item.label}
>
{item.label}
</button>
))}
</div>
) : null}
</div>
);
}

49
src/ui/Button.tsx Normal file
View File

@@ -0,0 +1,49 @@
import type { ButtonHTMLAttributes, ReactNode } from 'react';
export type ButtonVariant = 'primary' | 'neutral' | 'add' | 'danger';
export type ButtonSize = 'sm' | 'md' | 'lg';
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant;
size?: ButtonSize;
loading?: boolean;
loadingLabel?: string;
leftIcon?: ReactNode;
rightIcon?: ReactNode;
}
export function Button({
variant = 'neutral',
size = 'md',
loading = false,
loadingLabel,
leftIcon,
rightIcon,
className,
children,
disabled,
...props
}: ButtonProps) {
const classes = [
'ui-button',
`ui-button--${variant}`,
`ui-button--${size}`,
loading ? 'is-loading' : '',
className ?? '',
].filter(Boolean).join(' ');
return (
<button
{...props}
className={classes}
disabled={disabled || loading}
>
{loading ? <span className="ui-button-spinner" aria-hidden="true" /> : leftIcon ? (
<span className="ui-button-icon" aria-hidden="true">{leftIcon}</span>
) : null}
<span className="ui-button-label">{loading && loadingLabel ? loadingLabel : children}</span>
{!loading && rightIcon ? <span className="ui-button-icon" aria-hidden="true">{rightIcon}</span> : null}
</button>
);
}

38
src/ui/Field.tsx Normal file
View File

@@ -0,0 +1,38 @@
import type { InputHTMLAttributes, ReactNode } from 'react';
export interface FieldProps extends InputHTMLAttributes<HTMLInputElement> {
label: string;
error?: string | null;
hint?: string;
action?: ReactNode;
}
export function Field({
label,
error,
hint,
action,
className,
id,
...props
}: FieldProps) {
const inputId = id ?? `field-${label.toLowerCase().replace(/\s+/g, '-')}`;
const helpId = `${inputId}-help`;
return (
<label className={`ui-field ${className ?? ''}`.trim()} htmlFor={inputId}>
<span className="ui-field-label">{label}</span>
<div className="ui-field-row">
<input
{...props}
id={inputId}
aria-invalid={Boolean(error)}
aria-describedby={error || hint ? helpId : undefined}
/>
{action}
</div>
{error || hint ? <span id={helpId} className={`ui-field-help ${error ? 'is-error' : ''}`.trim()}>{error ?? hint}</span> : null}
</label>
);
}

42
src/ui/IconButton.tsx Normal file
View File

@@ -0,0 +1,42 @@
import type { ButtonHTMLAttributes, ReactNode } from 'react';
export type IconButtonVariant = 'neutral' | 'add' | 'danger';
export interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
label: string;
icon: ReactNode;
variant?: IconButtonVariant;
loading?: boolean;
}
export function IconButton({
label,
icon,
variant = 'neutral',
loading = false,
className,
disabled,
title,
...props
}: IconButtonProps) {
const classes = [
'ui-icon-button',
`ui-icon-button--${variant}`,
loading ? 'is-loading' : '',
className ?? '',
].filter(Boolean).join(' ');
return (
<button
{...props}
type={props.type ?? 'button'}
className={classes}
aria-label={label}
title={title ?? label}
disabled={disabled || loading}
>
{loading ? <span className="ui-button-spinner" aria-hidden="true" /> : icon}
</button>
);
}

82
src/ui/LogDock.tsx Normal file
View File

@@ -0,0 +1,82 @@
import { Button } from './Button';
export interface LogDockEntry {
id: string;
kind: 'success' | 'error' | 'info';
title: string;
text: string;
at: number;
}
export interface LogDockProps {
entries: LogDockEntry[];
activeEntry: LogDockEntry | null;
open: boolean;
onToggle: () => void;
formatTime: (timestamp: number) => string;
}
function isNativePreviewError(entry: LogDockEntry | null) {
if (!entry) return false;
return entry.text.includes("reading 'invoke'") || entry.text.includes('undefined (reading');
}
function displayEntry(entry: LogDockEntry | null) {
if (!entry) return null;
if (!isNativePreviewError(entry)) return entry;
return {
...entry,
title: 'Desktop-команды недоступны',
text: 'Запусти клиент через Tauri, чтобы управлять службами и применять конфиг.',
};
}
export function LogDock({
entries,
activeEntry,
open,
onToggle,
formatTime,
}: LogDockProps) {
const current = displayEntry(activeEntry);
return (
<footer className={`log-dock ${current?.kind ?? 'idle'}`} aria-live="polite">
<div className={`log-current ${current ? 'visible' : 'hidden'}`}>
{current ? (
<>
<strong>{current.title}</strong>
<span>{current.text}</span>
</>
) : (
<span className="log-muted">Журнал событий</span>
)}
</div>
<Button type="button" variant="neutral" size="sm" className="log-toggle" onClick={onToggle}>
{open ? 'Скрыть' : 'Посмотреть'} <span className="log-count">{entries.length}</span>
</Button>
{open ? (
<div className="log-history">
{entries.length ? entries.map((entry) => {
const friendly = displayEntry(entry);
return (
<div className={`log-history-row ${entry.kind}`} key={entry.id}>
<time>{formatTime(entry.at)}</time>
<div>
<strong>{friendly?.title ?? entry.title}</strong>
<span>{friendly?.text ?? entry.text}</span>
{isNativePreviewError(entry) ? <span className="log-raw-detail">Детали: {entry.text}</span> : null}
</div>
</div>
);
}) : (
<div className="log-history-row">
<time>--:--:--</time>
<span>Событий пока нет.</span>
</div>
)}
</div>
) : null}
</footer>
);
}

View File

@@ -0,0 +1,93 @@
import type { ReactNode } from 'react';
import { Button, type ButtonVariant } from './Button';
import { ActionMenu, type ActionMenuItem } from './ActionMenu';
export type ServiceControlState = 'checking' | 'missing' | 'installed' | 'running' | 'stopped' | 'error';
export interface ServicePrimaryAction {
label: string;
onClick: () => void;
variant?: ButtonVariant;
loading?: boolean;
loadingLabel?: string;
disabled?: boolean;
}
export interface ServiceControlRowProps {
state: ServiceControlState;
title: string;
detail: string;
primaryAction?: ServicePrimaryAction;
menu?: {
label: string;
open: boolean;
onOpenChange: (open: boolean) => void;
items: ActionMenuItem[];
disabled?: boolean;
};
visualState?: 'working' | 'settling' | null;
className?: string;
inlineActions?: ReactNode;
children?: ReactNode;
}
export function ServiceControlRow({
state,
title,
detail,
primaryAction,
menu,
visualState,
className,
inlineActions,
children,
}: ServiceControlRowProps) {
const classes = [
'ui-service-row',
`ui-service-row--${state}`,
visualState ? `ui-service-row--${visualState}` : '',
className ?? '',
].filter(Boolean).join(' ');
return (
<div className={classes}>
<span className="ui-service-border-glow" aria-hidden="true">
<span className="ui-service-border-glow-segment top" />
<span className="ui-service-border-glow-segment right" />
<span className="ui-service-border-glow-segment bottom" />
<span className="ui-service-border-glow-segment left" />
</span>
<span className="ui-service-dot" aria-hidden="true" />
<div className="ui-service-text">
<strong>{title}</strong>
<span>{detail}</span>
{inlineActions ? <div className="ui-service-inline-actions">{inlineActions}</div> : null}
</div>
<div className="ui-service-actions">
{primaryAction ? (
<Button
type="button"
variant={primaryAction.variant ?? 'neutral'}
onClick={primaryAction.onClick}
disabled={primaryAction.disabled}
loading={primaryAction.loading}
loadingLabel={primaryAction.loadingLabel}
>
{primaryAction.label}
</Button>
) : null}
{menu ? (
<ActionMenu
label={menu.label}
open={menu.open}
onOpenChange={menu.onOpenChange}
items={menu.items}
disabled={menu.disabled}
/>
) : null}
</div>
{children}
</div>
);
}

11
src/ui/StatusPill.tsx Normal file
View File

@@ -0,0 +1,11 @@
export type StatusPillTone = 'ok' | 'warning' | 'error' | 'checking' | 'muted';
export interface StatusPillProps {
tone?: StatusPillTone;
children: string;
}
export function StatusPill({ tone = 'muted', children }: StatusPillProps) {
return <span className={`ui-status-pill ui-status-pill--${tone}`}>{children}</span>;
}

81
src/ui/Tabs.tsx Normal file
View File

@@ -0,0 +1,81 @@
import { useRef, type KeyboardEvent } from 'react';
export interface TabItem<T extends string> {
id: T;
label: string;
}
export interface TabsProps<T extends string> {
items: Array<TabItem<T>>;
activeId: T;
onChange: (id: T) => void;
ariaLabel: string;
}
export function Tabs<T extends string>({
items,
activeId,
onChange,
ariaLabel,
}: TabsProps<T>) {
const refs = useRef<Array<HTMLButtonElement | null>>([]);
function moveFocus(currentId: T, direction: 1 | -1) {
const currentIndex = items.findIndex((item) => item.id === currentId);
const nextIndex = (currentIndex + direction + items.length) % items.length;
const next = items[nextIndex];
if (!next) return;
onChange(next.id);
window.requestAnimationFrame(() => refs.current[nextIndex]?.focus());
}
function handleKeyDown(event: KeyboardEvent<HTMLButtonElement>, id: T) {
if (event.key === 'ArrowRight') {
event.preventDefault();
moveFocus(id, 1);
} else if (event.key === 'ArrowLeft') {
event.preventDefault();
moveFocus(id, -1);
} else if (event.key === 'Home') {
event.preventDefault();
const first = items[0];
if (!first) return;
onChange(first.id);
window.requestAnimationFrame(() => refs.current[0]?.focus());
} else if (event.key === 'End') {
event.preventDefault();
const last = items[items.length - 1];
if (!last) return;
onChange(last.id);
window.requestAnimationFrame(() => refs.current[items.length - 1]?.focus());
}
}
return (
<div className="ui-tabs" role="tablist" aria-label={ariaLabel}>
{items.map((item, index) => {
const active = item.id === activeId;
return (
<button
type="button"
role="tab"
id={`tab-${item.id}`}
aria-controls={`panel-${item.id}`}
aria-selected={active}
tabIndex={active ? 0 : -1}
className={`ui-tab ${active ? 'is-active' : ''}`.trim()}
key={item.id}
ref={(node) => {
refs.current[index] = node;
}}
onClick={() => onChange(item.id)}
onKeyDown={(event) => handleKeyDown(event, item.id)}
>
{item.label}
</button>
);
})}
</div>
);
}

17
src/ui/index.ts Normal file
View File

@@ -0,0 +1,17 @@
export { ActionMenu } from './ActionMenu';
export type { ActionMenuItem } from './ActionMenu';
export { Button } from './Button';
export type { ButtonProps, ButtonSize, ButtonVariant } from './Button';
export { Field } from './Field';
export type { FieldProps } from './Field';
export { IconButton } from './IconButton';
export type { IconButtonProps, IconButtonVariant } from './IconButton';
export { LogDock } from './LogDock';
export type { LogDockEntry, LogDockProps } from './LogDock';
export { ServiceControlRow } from './ServiceControlRow';
export type { ServiceControlRowProps, ServiceControlState } from './ServiceControlRow';
export { StatusPill } from './StatusPill';
export type { StatusPillProps, StatusPillTone } from './StatusPill';
export { Tabs } from './Tabs';
export type { TabItem, TabsProps } from './Tabs';