Release v2.0.0
CI / Windows baseline (push) Canceled after 0s

This commit is contained in:
2026-09-10 20:59:52 +03:00
parent 9c987df6e9
commit efda8eb98f
142 changed files with 68308 additions and 9333 deletions
+95 -30
View File
@@ -1,10 +1,19 @@
import { invoke } from "@tauri-apps/api/core";
import type {
ActivityEntry,
ComponentCutoverResponse,
ComponentCutoverStatus,
ComponentLifecycleResponse,
ComponentPackageStatus,
ComponentStatus,
ComponentUpdateCheckResponse,
ComponentUpdateDownloadResponse,
ComponentUpdateResponse,
LocalSingBoxConfig,
ManagedPackageComponentId,
Profile,
ProfileInput,
StorageMigrationStatus,
SubscriptionCache,
SubscriptionServer,
Target,
@@ -14,7 +23,7 @@ import type {
export interface CommandError {
code: string;
message: string;
details?: Array<{
details: Array<{
field: string;
message: string;
}>;
@@ -27,7 +36,16 @@ export interface AdminStatusResponse {
message: string;
}
export interface ArtifactStatus {
component: "proxyfier" | "singbox";
sourceMatchesPrepared: boolean;
generatedExists: boolean;
activation: "unknown" | "stopped" | "restart-required" | "confirmed";
}
export interface SavedStateResponse {
artifacts: ArtifactStatus[];
revision: string;
profiles: Profile[];
targets: Target[];
generatedConfigPath: string;
@@ -35,6 +53,7 @@ export interface SavedStateResponse {
export interface StartupSnapshotResponse {
adminStatus: AdminStatusResponse;
migrationStatus: StorageMigrationStatus;
savedState: SavedStateResponse;
components: ComponentStatus[];
proxifyreSetupStatus: ProxiFyreSetupStatus;
@@ -56,15 +75,6 @@ export interface ProxiFyreSetupStatus {
items: ProxiFyreSetupItem[];
}
export interface ProxiFyreSetupProgress {
operation: "idle" | "install" | "uninstall" | string;
status: "idle" | "running" | "succeeded" | "failed" | string;
activeStep?: string;
percent: number;
message: string;
updatedAt?: string;
}
export type SingBoxSetupItem = ProxiFyreSetupItem;
export interface SingBoxSetupStatus {
@@ -74,6 +84,7 @@ export interface SingBoxSetupStatus {
}
export interface LocalSingBoxStatusResponse {
savedState: SavedStateResponse;
config: LocalSingBoxConfig;
cache?: SubscriptionCache;
component: ComponentStatus;
@@ -111,6 +122,7 @@ export interface ApplyPhase {
}
export interface ApplyConfigurationInput {
expectedRevision?: string;
routeMode: "external" | "local-singbox";
profile: ProfileInput;
externalTarget?: TargetInput;
@@ -118,6 +130,7 @@ export interface ApplyConfigurationInput {
}
export interface ApplyConfigurationResult {
savedState?: SavedStateResponse;
success: boolean;
changed: boolean;
partialState: boolean;
@@ -182,12 +195,68 @@ export function getComponents(): Promise<ComponentStatus[]> {
return invoke<ComponentStatus[]>("get_components");
}
export function getProxiFyreSetupStatus(): Promise<ProxiFyreSetupStatus> {
return invoke<ProxiFyreSetupStatus>("get_proxifyre_setup_status");
export function getComponentPackageStatuses(): Promise<
ComponentPackageStatus[]
> {
return invoke<ComponentPackageStatus[]>("get_component_package_statuses");
}
export function getProxiFyreSetupProgress(): Promise<ProxiFyreSetupProgress> {
return invoke<ProxiFyreSetupProgress>("get_proxifyre_setup_progress");
export function checkComponentUpdate(
componentId: ManagedPackageComponentId,
): Promise<ComponentUpdateCheckResponse> {
return invoke<ComponentUpdateCheckResponse>("check_component_update", {
input: { componentId },
});
}
export function downloadComponentUpdate(
componentId: ManagedPackageComponentId,
): Promise<ComponentUpdateDownloadResponse> {
return invoke<ComponentUpdateDownloadResponse>("download_component_update", {
input: { componentId },
});
}
export function updateComponent(
componentId: ManagedPackageComponentId,
): Promise<ComponentUpdateResponse> {
return invoke<ComponentUpdateResponse>("update_component", {
input: { componentId },
});
}
export function getComponentCutoverStatuses(): Promise<
ComponentCutoverStatus[]
> {
return invoke<ComponentCutoverStatus[]>("get_component_cutover_statuses");
}
export function cutoverComponent(
componentId: ManagedPackageComponentId,
): Promise<ComponentCutoverResponse> {
return invoke<ComponentCutoverResponse>("cutover_component", {
input: { componentId },
});
}
export function confirmComponentRouteSmoke(
componentId: ManagedPackageComponentId,
): Promise<ComponentCutoverStatus> {
return invoke<ComponentCutoverStatus>("confirm_component_route_smoke", {
input: { componentId, confirmed: true },
});
}
export function cleanupComponentQuarantine(
componentId: ManagedPackageComponentId,
): Promise<ComponentCutoverResponse> {
return invoke<ComponentCutoverResponse>("cleanup_component_quarantine", {
input: { componentId },
});
}
export function getProxiFyreSetupStatus(): Promise<ProxiFyreSetupStatus> {
return invoke<ProxiFyreSetupStatus>("get_proxifyre_setup_status");
}
export function getSingBoxStatus(): Promise<LocalSingBoxStatusResponse> {
@@ -198,18 +267,14 @@ export function getSingBoxSetupStatus(): Promise<SingBoxSetupStatus> {
return invoke<SingBoxSetupStatus>("get_singbox_setup_status");
}
export function saveSingBoxSubscription(
subscriptionUrl: string,
export function fetchSingBoxSubscription(
subscriptionUrl?: string,
): Promise<LocalSingBoxStatusResponse> {
return invoke<LocalSingBoxStatusResponse>("save_singbox_subscription", {
input: { subscriptionUrl },
return invoke<LocalSingBoxStatusResponse>("fetch_singbox_subscription", {
subscriptionUrl: subscriptionUrl || null,
});
}
export function fetchSingBoxSubscription(): Promise<LocalSingBoxStatusResponse> {
return invoke<LocalSingBoxStatusResponse>("fetch_singbox_subscription");
}
export function forgetSingBoxSubscription(): Promise<LocalSingBoxStatusResponse> {
return invoke<LocalSingBoxStatusResponse>("forget_singbox_subscription");
}
@@ -266,16 +331,16 @@ export function stopProxiFyreService(): Promise<ComponentStatus> {
return invoke<ComponentStatus>("stop_proxifyre_service");
}
export function installProxiFyre(): Promise<ComponentStatus> {
return invoke<ComponentStatus>("install_proxifyre");
export function installProxiFyre(): Promise<ComponentLifecycleResponse> {
return invoke<ComponentLifecycleResponse>("install_proxifyre");
}
export function configureProxiFyreFirewallRules(): Promise<void> {
return invoke<void>("configure_proxifyre_firewall_rules");
}
export function uninstallProxiFyre(): Promise<ComponentStatus> {
return invoke<ComponentStatus>("uninstall_proxifyre");
export function uninstallProxiFyre(): Promise<ComponentLifecycleResponse> {
return invoke<ComponentLifecycleResponse>("uninstall_proxifyre");
}
export function startSingBoxService(): Promise<ComponentStatus> {
@@ -286,10 +351,10 @@ export function stopSingBoxService(): Promise<ComponentStatus> {
return invoke<ComponentStatus>("stop_singbox_service");
}
export function installSingBox(): Promise<ComponentStatus> {
return invoke<ComponentStatus>("install_singbox");
export function installSingBox(): Promise<ComponentLifecycleResponse> {
return invoke<ComponentLifecycleResponse>("install_singbox");
}
export function uninstallSingBox(): Promise<ComponentStatus> {
return invoke<ComponentStatus>("uninstall_singbox");
export function uninstallSingBox(): Promise<ComponentLifecycleResponse> {
return invoke<ComponentLifecycleResponse>("uninstall_singbox");
}
+926 -465
View File
File diff suppressed because it is too large Load Diff
+11 -28
View File
@@ -1,39 +1,22 @@
import { describe, expect, it } from "vitest";
import type { DraftItem } from "../viewModel";
import { groupDraftItems, sortDraftItems } from "./AppList";
import { appItemDetail, appItemName } from "./AppList";
const items: DraftItem[] = [
{ id: "folder", type: "folder", value: "C:\\Games" },
{ id: "process-z", type: "process", value: "Zoom" },
{ id: "process", type: "process", value: "Discord" },
{ id: "exe", type: "exe", value: "C:\\Apps\\Browser.exe" },
{ id: "process-a", type: "process", value: "Discord" },
{ id: "folder", type: "folder", value: "C:\\Games" },
];
describe("sortDraftItems", () => {
it("keeps the saved order by default", () => {
expect(sortDraftItems(items, "added")).toBe(items);
describe("application item labels", () => {
it("keeps process labels compact", () => {
expect(appItemName(items[0]!)).toBe("Discord");
expect(appItemDetail(items[0]!)).toBe("Процесс");
});
it("groups by item type and sorts values within each group", () => {
const groups = groupDraftItems(items, "grouped");
expect(groups.map((group) => group.label)).toEqual([
"Процессы",
"EXE-файлы",
"Папки",
]);
expect(
groups.flatMap((group) => group.items.map((item) => item.id)),
).toEqual(["process-a", "process-z", "exe", "folder"]);
});
it("sorts all display values without mutating the source", () => {
expect(sortDraftItems(items, "name").map((item) => item.id)).toEqual([
"exe",
"folder",
"process-a",
"process-z",
]);
expect(items[0]?.id).toBe("folder");
it("uses a short name while preserving the path in details", () => {
expect(appItemName(items[1]!)).toBe("Browser");
expect(appItemDetail(items[1]!)).toBe("EXE-файл · C:\\Apps\\Browser.exe");
expect(appItemName(items[2]!)).toBe("Games");
});
});
+280 -128
View File
@@ -1,154 +1,306 @@
import { useMemo, useState } from "react";
import { Cpu, FileCode2, FolderOpen } from "lucide-react";
import { Button } from "../../ui";
import { itemTypeLabel, type DraftItemType } from "../lib/profileItems";
import {
useEffect,
useId,
useLayoutEffect,
useRef,
useState,
type CSSProperties,
} from "react";
import { createPortal } from "react-dom";
import { Cpu, FileCode2, FolderOpen, Trash2 } from "lucide-react";
import { Button, IconButton } from "../../ui";
import type { DraftItemType } from "../lib/profileItems";
import type { DraftItem } from "../viewModel";
export type ItemSortMode = "added" | "grouped" | "name";
const ITEM_TYPES = ["process", "exe", "folder"] as const;
const SORT_OPTIONS: Array<{ value: ItemSortMode; label: string }> = [
{ value: "added", label: "Добавлены" },
{ value: "grouped", label: "Группы" },
{ value: "name", label: "А–Я" },
];
const TYPE_LABEL: Record<DraftItemType, string> = {
process: "Процесс",
exe: "EXE-файл",
folder: "Папка",
};
const TYPE_ORDER: Record<DraftItemType, number> = {
process: 0,
exe: 1,
folder: 2,
const TYPE_ADD_LABEL: Record<DraftItemType, string> = {
process: "Добавить процесс",
exe: "Добавить EXE-файл",
folder: "Добавить папку",
};
interface AppListProps {
profileEnabled?: boolean;
onProfileEnabledChange?: (enabled: boolean) => void;
items: DraftItem[];
loading: boolean;
addingPathType: Extract<DraftItemType, "exe" | "folder"> | null;
onAddProcess: (value: string) => boolean;
onAddPath: (type: Extract<DraftItemType, "exe" | "folder">) => void;
onRemove: (id: string) => void;
}
export function AppList({ items, loading, onRemove }: AppListProps) {
const [sortMode, setSortMode] = useState<ItemSortMode>("added");
const itemGroups = useMemo(
() => groupDraftItems(items, sortMode),
[items, sortMode],
);
export function AppList({
profileEnabled,
onProfileEnabledChange,
items,
loading,
addingPathType,
onAddProcess,
onAddPath,
onRemove,
}: AppListProps) {
const [isProcessInputOpen, setIsProcessInputOpen] = useState(false);
const [processInput, setProcessInput] = useState("");
const processAnchorRef = useRef<HTMLDivElement>(null);
const processPopoverRef = useRef<HTMLFormElement>(null);
const processPopoverId = useId();
const [processPopoverPosition, setProcessPopoverPosition] = useState({
top: 0,
left: 0,
width: 300,
placement: "bottom" as "top" | "bottom",
});
function closeProcessPopover(returnFocus = true) {
setIsProcessInputOpen(false);
setProcessInput("");
if (returnFocus) {
window.requestAnimationFrame(() => {
processAnchorRef.current
?.querySelector<HTMLButtonElement>("button")
?.focus();
});
}
}
function submitProcess() {
if (onAddProcess(processInput)) {
closeProcessPopover();
}
}
useLayoutEffect(() => {
if (!isProcessInputOpen) return;
const updatePosition = () => {
const anchor = processAnchorRef.current;
if (!anchor) return;
const rect = anchor.getBoundingClientRect();
const width = Math.min(300, window.innerWidth - 24);
const height = processPopoverRef.current?.offsetHeight ?? 0;
const left = Math.max(
12,
Math.min(
rect.left + rect.width / 2 - width / 2,
window.innerWidth - width - 12,
),
);
const fitsBelow = rect.bottom + 8 + height <= window.innerHeight - 12;
setProcessPopoverPosition({
top: fitsBelow ? rect.bottom + 8 : Math.max(12, rect.top - height - 8),
left,
width,
placement: fitsBelow ? "bottom" : "top",
});
};
updatePosition();
const frame = window.requestAnimationFrame(updatePosition);
window.addEventListener("resize", updatePosition);
window.addEventListener("scroll", updatePosition, true);
return () => {
window.cancelAnimationFrame(frame);
window.removeEventListener("resize", updatePosition);
window.removeEventListener("scroll", updatePosition, true);
};
}, [isProcessInputOpen]);
useEffect(() => {
if (!isProcessInputOpen) return;
const closeOnOutsidePointer = (event: PointerEvent) => {
const target = event.target as Node;
if (processAnchorRef.current?.contains(target)) return;
if (processPopoverRef.current?.contains(target)) return;
closeProcessPopover(false);
};
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") closeProcessPopover();
};
document.addEventListener("pointerdown", closeOnOutsidePointer);
document.addEventListener("keydown", closeOnEscape);
return () => {
document.removeEventListener("pointerdown", closeOnOutsidePointer);
document.removeEventListener("keydown", closeOnEscape);
};
}, [isProcessInputOpen]);
return (
<div className="app-list-shell">
{!loading && items.length > 1 ? (
<div className="app-sort-row">
<span>Порядок</span>
<div
className="app-sort"
role="group"
aria-label="Сортировка приложений"
>
{SORT_OPTIONS.map((option) => (
<Button
type="button"
variant="neutral"
size="sm"
className="app-sort-option"
aria-pressed={sortMode === option.value}
key={option.value}
onClick={() => setSortMode(option.value)}
>
{option.label}
</Button>
))}
</div>
</div>
) : null}
<div className="app-list" key={sortMode} aria-live="polite">
{loading ? (
<div className="list-skeleton" aria-label="Загрузка приложений">
<span />
<span />
</div>
) : itemGroups.length ? (
itemGroups.map((group) => (
<section className="app-item-group" key={group.id}>
{group.label ? (
<div className="app-item-group-heading">
<strong>{group.label}</strong>
<span>{group.items.length}</span>
</div>
<>
<div className="application-stage">
<section className="application-panel" aria-label="Список приложений">
<div className="application-list-toolbar">
<div className="application-list-heading">
<strong>Приложения</strong>
<span>{items.length}</span>
{profileEnabled !== undefined ? (
<Button
type="button"
variant="neutral"
size="sm"
className="profile-toggle"
role="switch"
aria-checked={profileEnabled}
aria-label="Профиль включён"
onClick={() => onProfileEnabledChange?.(!profileEnabled)}
>
<span className="profile-toggle-track" aria-hidden="true" />
<span>Профиль {profileEnabled ? "включён" : "выключен"}</span>
</Button>
) : null}
{group.items.map((item) => (
<div className="app-row" key={item.id}>
<div className="app-row-main">
<span className="item-icon" aria-hidden="true">
{itemIcon(item.type)}
</span>
<div>
<strong>{item.value}</strong>
<span>{itemTypeLabel(item.type)}</span>
</div>
</div>
<Button
type="button"
variant="danger"
onClick={() => onRemove(item.id)}
aria-label={`Удалить ${item.value}`}
>
Удалить
</Button>
</div>
</div>
<div
className="application-add-actions"
aria-label="Добавить приложение"
>
<div
className="application-process-anchor"
ref={processAnchorRef}
>
<IconButton
variant="add"
disabled={Boolean(addingPathType)}
label={TYPE_ADD_LABEL.process}
icon={itemIcon("process", 18)}
aria-controls={
isProcessInputOpen ? processPopoverId : undefined
}
aria-expanded={isProcessInputOpen}
aria-haspopup="dialog"
onClick={() => {
if (isProcessInputOpen) closeProcessPopover();
else setIsProcessInputOpen(true);
}}
/>
</div>
{ITEM_TYPES.filter((type) => type !== "process").map((type) => (
<IconButton
variant="add"
loading={addingPathType === type}
disabled={Boolean(addingPathType)}
label={TYPE_ADD_LABEL[type]}
icon={itemIcon(type, 18)}
key={type}
onClick={() => onAddPath(type)}
/>
))}
</section>
))
) : (
<div className="empty-state">
Список пуст. Добавь первое приложение сверху.
</div>
</div>
)}
<div className="application-list" aria-live="polite">
{loading ? (
<div className="list-skeleton" aria-label="Загрузка приложений">
<span />
<span />
</div>
) : items.length ? (
items.map((item) => (
<div className="application-list-row" key={item.id}>
<span
className="application-list-row-icon"
aria-hidden="true"
>
{itemIcon(item.type, 18)}
</span>
<div className="application-list-row-copy">
<strong>{appItemName(item)}</strong>
<span>{appItemDetail(item)}</span>
</div>
<IconButton
variant="danger"
className="application-row-remove"
label={`Удалить ${appItemName(item)}`}
icon={<Trash2 size={17} strokeWidth={1.8} />}
onClick={() => onRemove(item.id)}
/>
</div>
))
) : (
<div className="application-empty-state">
Добавь процесс, EXE-файл или папку.
</div>
)}
</div>
</section>
</div>
</div>
{isProcessInputOpen && typeof document !== "undefined"
? createPortal(
<form
className="application-process-popover"
data-placement={processPopoverPosition.placement}
id={processPopoverId}
ref={processPopoverRef}
role="dialog"
aria-label="Добавить процесс"
style={
{
top: processPopoverPosition.top,
left: processPopoverPosition.left,
width: processPopoverPosition.width,
} as CSSProperties
}
onSubmit={(event) => {
event.preventDefault();
submitProcess();
}}
>
<label htmlFor={`${processPopoverId}-input`}>Процесс</label>
<input
id={`${processPopoverId}-input`}
value={processInput}
onChange={(event) => setProcessInput(event.target.value)}
aria-label="Имя процесса"
placeholder="Discord"
spellCheck={false}
autoFocus
/>
<div className="application-process-popover-actions">
<Button type="submit" variant="primary" size="sm">
Добавить
</Button>
<Button
type="button"
variant="neutral"
size="sm"
onClick={() => closeProcessPopover()}
>
Отмена
</Button>
</div>
</form>,
document.body,
)
: null}
</>
);
}
export function sortDraftItems(items: DraftItem[], mode: ItemSortMode) {
if (mode === "added") return items;
return [...items].sort((left, right) => {
if (mode === "grouped") {
const byType = TYPE_ORDER[left.type] - TYPE_ORDER[right.type];
if (byType !== 0) return byType;
}
return left.value.localeCompare(right.value, "ru", {
numeric: true,
sensitivity: "base",
});
});
export function appItemName(item: DraftItem) {
const value = item.value.replace(/[\\/]+$/, "");
const name = value.split(/[\\/]/).pop() || item.value;
return item.type === "exe" ? name.replace(/\.exe$/i, "") : name;
}
export function groupDraftItems(items: DraftItem[], mode: ItemSortMode) {
const sortedItems = sortDraftItems(items, mode);
if (mode !== "grouped")
return [{ id: mode, label: null, items: sortedItems }];
return (["process", "exe", "folder"] as const).flatMap((type) => {
const typeItems = sortedItems.filter((item) => item.type === type);
return typeItems.length
? [
{
id: type,
label: groupLabel(type),
items: typeItems,
},
]
: [];
});
export function appItemDetail(item: DraftItem) {
const type = TYPE_LABEL[item.type];
return item.type === "process" ? type : `${type} · ${item.value}`;
}
function groupLabel(type: DraftItemType) {
if (type === "process") return "Процессы";
if (type === "folder") return "Папки";
return "EXE-файлы";
}
function itemIcon(type: DraftItemType) {
if (type === "process") return <Cpu size={18} strokeWidth={1.9} />;
if (type === "folder") return <FolderOpen size={18} strokeWidth={1.9} />;
return <FileCode2 size={18} strokeWidth={1.9} />;
function itemIcon(type: DraftItemType, size: number) {
if (type === "process") return <Cpu size={size} strokeWidth={1.75} />;
if (type === "folder") return <FolderOpen size={size} strokeWidth={1.75} />;
return <FileCode2 size={size} strokeWidth={1.75} />;
}
@@ -0,0 +1,296 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import type { ComponentPackageStatus as ComponentPackageStatusDto } from "../../domain/types";
import {
ComponentPackageStatus,
ComponentPackageStatusLoading,
ComponentPackageStatusUnavailable,
isNumericVersionNewer,
offlineUpdateDisabledReason,
} from "./ComponentPackageStatus";
function packageStatus(
overrides: Partial<ComponentPackageStatusDto> = {},
): ComponentPackageStatusDto {
return {
componentId: "proxifyre",
installedVersion: "2.2.1",
bundledVersion: "2.2.1",
availableOfflineVersion: "2.2.1",
latestKnownVersion: null,
lastCheckedAt: null,
freshness: "never_checked",
updateState: "unknown_offline",
installSource: "bundled",
offlinePackageSource: "bundled",
canInstallOffline: true,
offlineUnavailableReason: null,
canDownload: false,
...overrides,
};
}
function renderStatus(
status: ComponentPackageStatusDto,
uacCancelled = false,
updateBlockedReason: string | null = null,
externalBusyReason: string | null = null,
) {
return renderToStaticMarkup(
<ComponentPackageStatus
status={status}
busyAction={null}
updateBlockedReason={updateBlockedReason}
externalBusyReason={externalBusyReason}
uacCancelled={uacCancelled}
onCheck={() => undefined}
onDownload={() => undefined}
onUpdate={() => undefined}
/>,
);
}
function openingButtonTag(markup: string, label: string) {
const labelIndex = markup.indexOf(label);
const start = markup.lastIndexOf("<button", labelIndex);
return markup.slice(start, markup.indexOf(">", start) + 1);
}
describe("ComponentPackageStatus", () => {
it("compares only numeric versions for offline update", () => {
expect(isNumericVersionNewer("2.10.0", "2.9.9")).toBe(true);
expect(isNumericVersionNewer("2.2.1", "2.2.1.0")).toBe(false);
expect(isNumericVersionNewer("2.2.0", "2.2.1")).toBe(false);
expect(isNumericVersionNewer("v2.3.0", "2.2.1")).toBe(false);
});
it("enables offline update only for a newer verified local version", () => {
const enabledStatus = packageStatus({
availableOfflineVersion: "2.3.0",
offlinePackageSource: "cache",
});
const enabledMarkup = renderStatus(enabledStatus);
const disabledMarkup = renderStatus(packageStatus());
expect(offlineUpdateDisabledReason(enabledStatus)).toBeNull();
expect(openingButtonTag(enabledMarkup, "Обновить компонент")).not.toContain(
"disabled",
);
expect(enabledMarkup).toContain("повторно проверяется в сети");
expect(openingButtonTag(disabledMarkup, "Обновить компонент")).toContain(
"disabled",
);
});
it("does not offer the cache-only update action for a bundled package", () => {
const markup = renderStatus(
packageStatus({
updateState: "update_available",
installedVersion: "2.2.1",
availableOfflineVersion: "2.4.0",
offlinePackageSource: "bundled",
}),
);
expect(markup).toContain("отдельно скачанного и проверенного пакета");
expect(markup).toContain("Встроенная версия новее");
expect(openingButtonTag(markup, "Обновить компонент")).toContain(
"disabled",
);
});
it("shows local truth without claiming an offline update", () => {
const markup = renderStatus(packageStatus());
expect(markup).toContain("Только локальные данные");
expect(markup).toContain("это не означает, что компонент устарел");
expect(markup).toContain("Установлена");
expect(markup).toContain("Встроена");
expect(markup).toContain("Последняя известная");
expect(markup.match(/<button/g)).toHaveLength(3);
expect(markup).not.toContain(">Установить<");
expect(markup).not.toContain(">Запустить<");
expect(markup).not.toContain(">Остановить<");
});
it.each([
["current", "Актуально", "новая версия не требуется"],
["update_available", "Есть обновление", "доступна для скачивания"],
["check_stale", "Проверка устарела", "стоит повторить"],
] as const)("renders the %s package state", (updateState, label, summary) => {
const markup = renderStatus(
packageStatus({
updateState,
canDownload: updateState === "update_available",
}),
);
expect(markup).toContain(label);
expect(markup).toContain(summary);
});
it("describes an already-downloaded update as local", () => {
const markup = renderStatus(
packageStatus({
updateState: "update_available",
installedVersion: "2.2.1",
latestKnownVersion: "2.4.0",
availableOfflineVersion: "2.4.0",
offlinePackageSource: "cache",
canDownload: false,
}),
);
expect(markup).toContain("сохранено локально");
expect(markup).toContain("уже сохранено локально");
expect(markup).not.toContain("Сначала выполни явную проверку");
});
it("does not call an unverified remote version downloadable", () => {
const markup = renderStatus(
packageStatus({
updateState: "update_available",
latestKnownVersion: "2.4.0",
availableOfflineVersion: "2.2.1",
canDownload: false,
}),
);
expect(markup).toContain("независимая проверка пакета недоступна");
expect(markup).toContain("Скачивание заблокировано");
expect(markup).not.toContain("доступна для скачивания");
});
it("keeps a verified local update usable when observed latest differs", () => {
const markup = renderStatus(
packageStatus({
updateState: "update_available",
installedVersion: "2.2.1",
availableOfflineVersion: "2.4.0",
latestKnownVersion: "2.5.0",
offlinePackageSource: "cache",
canInstallOffline: true,
canDownload: false,
}),
);
expect(markup).toContain("Проверенное локальное обновление готово");
expect(markup).toContain("обнаруженная в сети версия требует");
expect(openingButtonTag(markup, "Обновить компонент")).not.toContain(
"disabled",
);
});
it("describes both verified local and downloadable newer updates truthfully", () => {
const markup = renderStatus(
packageStatus({
updateState: "update_available",
installedVersion: "2.2.1",
availableOfflineVersion: "2.4.0",
latestKnownVersion: "2.5.0",
offlinePackageSource: "cache",
canInstallOffline: true,
canDownload: true,
}),
);
expect(markup).toContain("Проверенное локальное обновление готово");
expect(markup).toContain("более новая проверенная версия доступна");
expect(markup).not.toContain("требует отдельной проверки");
expect(openingButtonTag(markup, "Скачать обновление")).not.toContain(
"disabled",
);
expect(openingButtonTag(markup, "Обновить компонент")).not.toContain(
"disabled",
);
});
it("does not claim the network was skipped after a fresh incomparable check", () => {
const markup = renderStatus(
packageStatus({
installedVersion: null,
latestKnownVersion: "2.4.0",
lastCheckedAt: 1_788_000_000,
freshness: "fresh",
updateState: "unknown_offline",
}),
);
expect(markup).toContain("Проверка выполнена");
expect(markup).toContain("нельзя подтвердить");
expect(markup).not.toContain("Сеть не проверялась");
});
it("does not render path-like or fingerprint-like version values", () => {
const fingerprint = "a".repeat(64);
const markup = renderStatus(
packageStatus({
installedVersion: "C:\\private\\ProxiFyre.exe",
bundledVersion: fingerprint,
latestKnownVersion: "https://secret.example/version",
}),
);
expect(markup).not.toContain("private");
expect(markup).not.toContain(fingerprint);
expect(markup).not.toContain("secret.example");
});
it("presents UAC cancellation as neutral preserved state", () => {
const markup = renderStatus(packageStatus(), true);
expect(markup).toContain("Запрос прав администратора отменён");
expect(markup).toContain("Данные не изменены");
expect(markup).not.toContain('class="error"');
});
it("blocks component mutation while cutover is nonterminal", () => {
const markup = renderStatus(
packageStatus({
installedVersion: "2.2.1",
availableOfflineVersion: "2.4.0",
canInstallOffline: true,
}),
false,
"Сначала заверши перенос компонента.",
);
expect(markup).toContain("Сначала заверши перенос компонента");
expect(openingButtonTag(markup, "Обновить компонент")).toContain(
"disabled",
);
});
it("blocks every package action while another lifecycle action runs", () => {
const markup = renderStatus(
packageStatus({ availableOfflineVersion: "2.4.0" }),
false,
null,
"Дождись завершения операции со службой.",
);
expect(markup.match(/<button[^>]*disabled=""/g)).toHaveLength(3);
expect(markup).toContain("Дождись завершения операции со службой");
});
it("keeps local loading and failure states visible without actions", () => {
const loading = renderToStaticMarkup(
<ComponentPackageStatusLoading componentId="proxifyre" />,
);
const failed = renderToStaticMarkup(
<ComponentPackageStatusUnavailable
componentId="proxifyre"
error={{
code: "component_package_status_failed",
message: "Локальный каталог временно недоступен.",
details: [],
}}
/>,
);
expect(loading).toContain("Читаю только локальный каталог");
expect(failed).toContain("Установка и обновление оставлены");
expect(failed).toContain("Локальный каталог временно недоступен");
expect(`${loading}${failed}`).not.toContain("<button");
});
});
@@ -0,0 +1,389 @@
import { useId } from "react";
import type { CommandError } from "../../api/tauriCommands";
import type {
ComponentPackageStatus as ComponentPackageStatusDto,
ManagedPackageComponentId,
} from "../../domain/types";
import { Button, StatusPill, type StatusPillTone } from "../../ui";
import type { ComponentPackageBusyAction } from "../hooks/useComponentPackages";
export type ComponentPackageActionHandler = (
componentId: ManagedPackageComponentId,
) => void | Promise<unknown>;
export interface ComponentPackageStatusProps {
status: ComponentPackageStatusDto;
busyAction: ComponentPackageBusyAction | null;
externalBusyReason?: string | null;
updateBlockedReason?: string | null;
error?: CommandError | null;
uacCancelled?: boolean;
onCheck: ComponentPackageActionHandler;
onDownload: ComponentPackageActionHandler;
onUpdate: ComponentPackageActionHandler;
}
const NUMERIC_VERSION = /^\d+(?:\.\d+)*$/;
const PUBLIC_VERSION = /^\d+(?:\.\d+)*(?:[-+][0-9A-Za-z.-]+)?$/;
function numericVersionParts(value: string | null) {
if (!value || !NUMERIC_VERSION.test(value)) return null;
const parts = value.split(".").map(Number);
return parts.every(Number.isSafeInteger) ? parts : null;
}
export function isNumericVersionNewer(
availableVersion: string,
installedVersion: string | null,
) {
const available = numericVersionParts(availableVersion);
const installed = numericVersionParts(installedVersion);
if (!available || !installed) return false;
const length = Math.max(available.length, installed.length);
for (let index = 0; index < length; index += 1) {
const availablePart = available[index] ?? 0;
const installedPart = installed[index] ?? 0;
if (availablePart !== installedPart) {
return availablePart > installedPart;
}
}
return false;
}
export function offlineUpdateDisabledReason(status: ComponentPackageStatusDto) {
if (!status.installedVersion) {
return "Сначала установи компонент отдельным действием.";
}
if (!status.canInstallOffline) {
return "Проверенный локальный пакет недоступен.";
}
if (status.offlinePackageSource !== "cache") {
return "Обновление требует отдельно скачанного и проверенного пакета.";
}
if (
!numericVersionParts(status.installedVersion) ||
!numericVersionParts(status.availableOfflineVersion)
) {
return "Версии нельзя безопасно сравнить автоматически.";
}
if (
!isNumericVersionNewer(
status.availableOfflineVersion,
status.installedVersion,
)
) {
return "Локальный пакет не новее установленной версии.";
}
return null;
}
function publicVersion(value: string | null, fallback: string) {
return value && value.length <= 32 && PUBLIC_VERSION.test(value)
? value
: fallback;
}
function componentName(componentId: ManagedPackageComponentId) {
return componentId === "proxifyre" ? "ProxiFyre" : "Local sing-box";
}
function verifiedLocalUpdateAvailable(status: ComponentPackageStatusDto) {
return offlineUpdateDisabledReason(status) === null;
}
function updateAvailableSummary(status: ComponentPackageStatusDto) {
if (verifiedLocalUpdateAvailable(status)) {
return status.latestKnownVersion &&
status.latestKnownVersion !== status.availableOfflineVersion
? status.canDownload
? "Проверенное локальное обновление готово; более новая проверенная версия доступна для скачивания."
: "Проверенное локальное обновление готово; обнаруженная в сети версия требует отдельной проверки."
: "Проверенное обновление сохранено локально и готово к явной установке.";
}
if (
status.offlinePackageSource === "bundled" &&
status.canInstallOffline &&
isNumericVersionNewer(
status.availableOfflineVersion,
status.installedVersion,
)
) {
return "Встроенная версия новее, но обновление требует отдельно скачанного и проверенного пакета.";
}
if (status.canDownload) {
return "Новая проверенная версия доступна для скачивания.";
}
return "Новая версия обнаружена, но независимая проверка пакета недоступна.";
}
function packageStateView(status: ComponentPackageStatusDto): {
label: string;
summary: string;
tone: StatusPillTone;
} {
switch (status.updateState) {
case "current":
return {
label: "Актуально",
summary: "По последней проверке новая версия не требуется.",
tone: "ok",
};
case "update_available":
return {
label: "Есть обновление",
summary: updateAvailableSummary(status),
tone: "warning",
};
case "check_stale":
return {
label: "Проверка устарела",
summary:
"Локальные версии известны, но сетевую проверку стоит повторить.",
tone: "warning",
};
case "unsupported":
return {
label: "Проверка недоступна",
summary:
"Для этого компонента автоматическая проверка не поддерживается.",
tone: "muted",
};
case "unknown_offline":
return {
label: "Только локальные данные",
summary:
status.freshness === "never_checked"
? "Сеть не проверялась; это не означает, что компонент устарел."
: "Проверка выполнена, но установленную версию нельзя подтвердить и безопасно сравнить.",
tone: "muted",
};
}
}
function lastCheckedLabel(lastCheckedAt: number | null) {
if (
lastCheckedAt === null ||
!Number.isSafeInteger(lastCheckedAt) ||
lastCheckedAt < 0
) {
return "Никогда";
}
const checkedAt = new Date(lastCheckedAt * 1000);
if (Number.isNaN(checkedAt.getTime())) return "Неизвестно";
return checkedAt.toLocaleString("ru-RU", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
function downloadDisabledReason(status: ComponentPackageStatusDto) {
if (status.canDownload) return null;
if (
status.updateState === "update_available" &&
verifiedLocalUpdateAvailable(status)
) {
return "Проверенное обновление уже сохранено локально.";
}
if (status.updateState === "current") {
return "Новой проверенной версии нет.";
}
if (status.updateState === "unsupported") {
return "Скачивание для этого компонента не поддерживается.";
}
if (status.updateState === "update_available") {
return "Скачивание заблокировано: независимая проверка пакета недоступна.";
}
return "Сначала выполни явную проверку обновлений в сети.";
}
export function ComponentPackageStatus({
status,
busyAction,
externalBusyReason = null,
updateBlockedReason = null,
error = null,
uacCancelled = false,
onCheck,
onDownload,
onUpdate,
}: ComponentPackageStatusProps) {
const titleId = useId();
const reasonsId = useId();
const state = packageStateView(status);
const anyBusy = busyAction !== null || externalBusyReason !== null;
const matchingBusy =
busyAction?.componentId === status.componentId ? busyAction.kind : null;
const checkReason =
status.updateState === "unsupported"
? "Автоматическая проверка для этого компонента недоступна."
: null;
const downloadReason = downloadDisabledReason(status);
const updateReason =
updateBlockedReason ?? offlineUpdateDisabledReason(status);
const disabledReasons = anyBusy
? [
externalBusyReason ??
"Дождись завершения текущего действия с компонентом.",
]
: [
checkReason ? `Проверка: ${checkReason}` : null,
downloadReason ? `Скачивание: ${downloadReason}` : null,
updateReason ? `Обновление: ${updateReason}` : null,
updateReason === null
? "Обновление: источник пакета повторно проверяется в сети; запуск службы выполняется отдельно."
: null,
].filter((reason): reason is string => Boolean(reason));
return (
<section className="component-package-status" aria-labelledby={titleId}>
<header className="component-package-head">
<div>
<span>Пакет компонента</span>
<h3 id={titleId}>{componentName(status.componentId)}</h3>
</div>
<StatusPill tone={state.tone}>{state.label}</StatusPill>
</header>
<p className="component-package-summary">{state.summary}</p>
<dl className="component-package-versions">
<div>
<dt>Установлена</dt>
<dd>{publicVersion(status.installedVersion, "Не установлена")}</dd>
</div>
<div>
<dt>Встроена</dt>
<dd>{publicVersion(status.bundledVersion, "Недоступна")}</dd>
</div>
<div>
<dt>Последняя известная</dt>
<dd>{publicVersion(status.latestKnownVersion, "Не проверялась")}</dd>
</div>
<div>
<dt>Проверено</dt>
<dd>{lastCheckedLabel(status.lastCheckedAt)}</dd>
</div>
</dl>
<div className="component-package-actions">
<Button
type="button"
size="sm"
variant="neutral"
disabled={anyBusy || Boolean(checkReason)}
loading={matchingBusy === "check"}
loadingLabel="Проверяю"
aria-describedby={reasonsId}
onClick={() => void onCheck(status.componentId)}
>
Проверить в сети
</Button>
<Button
type="button"
size="sm"
variant="neutral"
disabled={anyBusy || Boolean(downloadReason)}
loading={matchingBusy === "download"}
loadingLabel="Скачиваю"
aria-describedby={reasonsId}
onClick={() => void onDownload(status.componentId)}
>
Скачать обновление
</Button>
<Button
type="button"
size="sm"
variant="primary"
disabled={anyBusy || Boolean(updateReason)}
loading={matchingBusy === "update"}
loadingLabel="Обновляю"
aria-describedby={reasonsId}
onClick={() => void onUpdate(status.componentId)}
>
Обновить компонент
</Button>
</div>
<p className="component-package-reasons" id={reasonsId}>
{disabledReasons.join(" ") ||
"Все доступные действия можно запускать отдельно."}
</p>
<div className="component-package-feedback" aria-live="polite">
{uacCancelled ? (
<p className="neutral">
Запрос прав администратора отменён. Данные не изменены.
</p>
) : error ? (
<p className="error" role="alert">
{error.message}
</p>
) : null}
</div>
</section>
);
}
export function ComponentPackageStatusUnavailable({
componentId,
error,
}: {
componentId: ManagedPackageComponentId;
error: CommandError;
}) {
const titleId = useId();
return (
<section
className="component-package-status component-package-status--error"
aria-labelledby={titleId}
>
<header className="component-package-head">
<div>
<span>Пакет компонента</span>
<h3 id={titleId}>{componentName(componentId)}</h3>
</div>
<StatusPill tone="error">Проверка недоступна</StatusPill>
</header>
<p className="component-package-summary">
Локальное состояние пакета не прочитано. Установка и обновление
оставлены заблокированными.
</p>
<div className="component-package-feedback" aria-live="polite">
<p className="error" role="alert">
{error.message}
</p>
</div>
</section>
);
}
export function ComponentPackageStatusLoading({
componentId,
}: {
componentId: ManagedPackageComponentId;
}) {
const titleId = useId();
return (
<section
className="component-package-status component-package-status--loading"
aria-labelledby={titleId}
role="status"
>
<header className="component-package-head">
<div>
<span>Пакет компонента</span>
<h3 id={titleId}>{componentName(componentId)}</h3>
</div>
<StatusPill tone="checking">Проверяю</StatusPill>
</header>
<p className="component-package-summary">
Читаю только локальный каталог и сохранённое состояние обновлений.
</p>
<div className="component-package-feedback" aria-hidden="true" />
</section>
);
}
+174
View File
@@ -0,0 +1,174 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import type { CommandError } from "../../api/tauriCommands";
import type {
ComponentCutoverState,
ComponentCutoverStatus,
StorageMigrationStatus,
} from "../../domain/types";
import { MigrationNotice, migrationNoticeView } from "./MigrationNotice";
function cutoverStatus(
state: ComponentCutoverState,
overrides: Partial<ComponentCutoverStatus> = {},
): ComponentCutoverStatus {
return {
componentId: "proxifyre",
state,
mode: "service_switch",
legacyVersion: "2.2.1",
currentVersion: null,
bundledVersion: "2.2.1",
originalServiceState: "running",
legacyPathLabel: "C:\\private\\legacy",
currentPathLabel: "C:\\private\\current",
steps: ["secret-fingerprint"],
nextStartVerified: false,
routeSmokeConfirmed: false,
canCutover: ["ready", "in_progress", "recovery_required"].includes(state),
canConfirmRouteSmoke: state === "awaiting_route_smoke",
canCleanup: ["cleanup_ready", "cleanup_pending"].includes(state),
disabledCode: null,
disabledMessage: null,
...overrides,
};
}
function renderNotice(
status: ComponentCutoverStatus | null,
migrationStatus: StorageMigrationStatus | null = null,
uacCancelled = false,
error: CommandError | null = null,
actionBlockedReason: string | null = null,
) {
return renderToStaticMarkup(
<MigrationNotice
migrationStatus={migrationStatus}
cutoverStatus={status}
busyAction={null}
uacCancelled={uacCancelled}
error={error}
actionBlockedReason={actionBlockedReason}
onCutover={() => undefined}
onConfirmRouteSmoke={() => undefined}
onCleanup={() => undefined}
/>,
);
}
describe("MigrationNotice", () => {
it("renders nothing for not-needed and complete component migration", () => {
expect(renderNotice(cutoverStatus("not_needed"))).toBe("");
expect(renderNotice(cutoverStatus("complete"))).toBe("");
expect(migrationNoticeView("not_needed")).toBeNull();
expect(migrationNoticeView("complete")).toBeNull();
});
it.each([
["ready", "Перенести старую установку"],
["manual_migration_required", "Нужен ручной перенос"],
["in_progress", "Продолжить перенос"],
["awaiting_next_start", "Нужен новый запуск"],
["awaiting_route_smoke", "Маршрут проверен"],
["cleanup_pending", "Повторить очистку"],
["recovery_required", "Восстановить состояние"],
["rolled_back", "Старая установка восстановлена"],
["blocked", "Автоматический перенос заблокирован"],
] as const)("renders the %s state", (state, expected) => {
expect(renderNotice(cutoverStatus(state))).toContain(expected);
});
it("keeps cleanup separate, explicit, and destructive", () => {
const markup = renderNotice(cutoverStatus("cleanup_ready"));
expect(markup).toContain("Отдельное удаление");
expect(markup).toContain("Удалить старую копию");
expect(markup).toContain("ui-button--danger");
expect(markup).not.toContain("Перенести старую установку");
});
it("keeps sealed recovery reachable beside a potentially stale cleanup hint", () => {
const markup = renderNotice(
cutoverStatus("cleanup_pending", { canCutover: true }),
);
expect(markup).toContain("Повторить очистку");
expect(markup).toContain("Восстановить состояние");
expect(markup).toContain("ui-button--danger");
expect(markup).toContain("ui-button--neutral");
});
it.each(["awaiting_next_start", "awaiting_route_smoke"] as const)(
"keeps sealed recovery reachable beside a potentially stale %s hint",
(state) => {
const markup = renderNotice(cutoverStatus(state, { canCutover: true }));
expect(markup).toContain("Восстановить состояние");
expect(markup).toContain("ui-button--neutral");
},
);
it("never renders internal paths, steps, or disabled backend text", () => {
const markup = renderNotice(
cutoverStatus("blocked", {
disabledCode: "unknown_private_code",
disabledMessage: "C:\\private\\do-not-display",
}),
);
expect(markup).not.toContain("private");
expect(markup).not.toContain("secret-fingerprint");
expect(markup).toContain("Продолжение возможно только после проверки");
});
it("shows a one-time storage migration result with no cutover action", () => {
const migrationStatus: StorageMigrationStatus = {
storageSchemaVersion: 1,
componentLayoutVersion: null,
outcome: "imported_legacy_config",
changed: true,
blocking: false,
noticeCode: null,
message: "C:\\private\\must-not-render",
};
const markup = renderNotice(cutoverStatus("not_needed"), migrationStatus);
expect(markup).toContain("Старый конфиг безопасно перенесён");
expect(markup).not.toContain("private");
expect(markup).not.toContain("ui-button");
});
it("presents UAC cancellation as neutral preserved state", () => {
const markup = renderNotice(cutoverStatus("ready"), null, true);
expect(markup).toContain("Запрос прав администратора отменён");
expect(markup).toContain("Текущее состояние сохранено");
expect(markup).not.toContain('class="error"');
});
it("shows a fail-closed local status error even without a cutover record", () => {
const markup = renderNotice(null, null, false, {
code: "component_status_unavailable",
message: "Локальный статус временно недоступен.",
details: [],
});
expect(markup).toContain("Не удалось проверить перенос");
expect(markup).toContain("Изменения оставлены заблокированными");
expect(markup).toContain("Локальный статус временно недоступен");
});
it("requires a stopped service before route-smoke confirmation", () => {
const markup = renderNotice(
cutoverStatus("awaiting_route_smoke"),
null,
false,
null,
"Сначала явно останови службу.",
);
expect(markup).toContain("Сначала явно останови службу");
expect(markup).toContain('disabled=""');
expect(markup).toContain("Маршрут проверен");
});
});
+404
View File
@@ -0,0 +1,404 @@
import { useId } from "react";
import type { CommandError } from "../../api/tauriCommands";
import type {
ComponentCutoverState,
ComponentCutoverStatus as ComponentCutoverStatusDto,
ManagedPackageComponentId,
StorageMigrationStatus,
} from "../../domain/types";
import {
Button,
StatusPill,
type ButtonVariant,
type StatusPillTone,
} from "../../ui";
import type { ComponentPackageBusyAction } from "../hooks/useComponentPackages";
import type { ComponentPackageActionHandler } from "./ComponentPackageStatus";
type MigrationAction = "cutover" | "confirm-route-smoke" | "cleanup";
export interface MigrationNoticeView {
title: string;
detail: string;
label: string;
tone: StatusPillTone;
action: {
kind: MigrationAction;
label: string;
loadingLabel: string;
variant: ButtonVariant;
} | null;
disabledReason: string | null;
}
export interface MigrationNoticeProps {
migrationStatus?: StorageMigrationStatus | null;
cutoverStatus: ComponentCutoverStatusDto | null;
busyAction: ComponentPackageBusyAction | null;
externalBusyReason?: string | null;
actionBlockedReason?: string | null;
error?: CommandError | null;
uacCancelled?: boolean;
onCutover: ComponentPackageActionHandler;
onConfirmRouteSmoke: ComponentPackageActionHandler;
onCleanup: ComponentPackageActionHandler;
}
export function migrationNoticeView(
state: ComponentCutoverState,
): MigrationNoticeView | null {
switch (state) {
case "not_needed":
case "complete":
return null;
case "ready":
return {
title: "Найдена старая установка",
detail:
"ProxyWarden может перенести подтверждённую установку отдельным действием с UAC.",
label: "Готово к переносу",
tone: "warning",
action: {
kind: "cutover",
label: "Перенести старую установку",
loadingLabel: "Открываю UAC",
variant: "primary",
},
disabledReason: null,
};
case "manual_migration_required":
return {
title: "Нужен ручной перенос",
detail:
"Эта установка не входит в доказанный автоматический сценарий и оставлена без изменений.",
label: "Только вручную",
tone: "warning",
action: null,
disabledReason:
"Автоматическое действие отключено: принадлежность установки недостаточно подтверждена.",
};
case "in_progress":
return {
title: "Перенос не завершён",
detail:
"Состояние сохранено. Следующий запуск действия безопасно продолжит или откатит перенос.",
label: "Требует продолжения",
tone: "checking",
action: {
kind: "cutover",
label: "Продолжить перенос",
loadingLabel: "Продолжаю",
variant: "primary",
},
disabledReason: null,
};
case "awaiting_next_start":
return {
title: "Нужен новый запуск",
detail:
"Закрой и снова открой ProxyWarden: новый запуск должен подтвердить текущую установку.",
label: "Ожидает запуска",
tone: "warning",
action: null,
disabledReason: null,
};
case "awaiting_route_smoke":
return {
title: "Проверь реальный маршрут",
detail:
"Запусти выбранное приложение и убедись, что оно действительно выходит через настроенный прокси.",
label: "Нужна проверка",
tone: "warning",
action: {
kind: "confirm-route-smoke",
label: "Маршрут проверен",
loadingLabel: "Подтверждаю",
variant: "primary",
},
disabledReason: null,
};
case "cleanup_ready":
return {
title: "Старая копия готова к удалению",
detail:
"Новая установка и реальный маршрут подтверждены. Удаление старой копии остаётся отдельным действием.",
label: "Можно очистить",
tone: "warning",
action: {
kind: "cleanup",
label: "Удалить старую копию",
loadingLabel: "Открываю UAC",
variant: "danger",
},
disabledReason: null,
};
case "cleanup_pending":
return {
title: "Очистка не завершена",
detail:
"Часть старой копии ещё сохранена. Повторное действие продолжит только проверенную очистку.",
label: "Нужен повтор",
tone: "warning",
action: {
kind: "cleanup",
label: "Повторить очистку",
loadingLabel: "Продолжаю очистку",
variant: "danger",
},
disabledReason: null,
};
case "rolled_back":
return {
title: "Старая установка восстановлена",
detail:
"Перенос отменён безопасным откатом. Рабочая старая установка оставлена на месте.",
label: "Выполнен откат",
tone: "muted",
action: null,
disabledReason: null,
};
case "recovery_required":
return {
title: "Требуется безопасное восстановление",
detail:
"Перенос остановлен в защищённом состоянии. Другие изменения заблокированы до восстановления.",
label: "Нужно восстановление",
tone: "error",
action: {
kind: "cutover",
label: "Восстановить состояние",
loadingLabel: "Восстанавливаю",
variant: "neutral",
},
disabledReason: null,
};
case "blocked":
return {
title: "Автоматический перенос заблокирован",
detail:
"Проверки безопасности не разрешили изменять найденную установку. Она оставлена без изменений.",
label: "Заблокировано",
tone: "error",
action: null,
disabledReason:
"Продолжение возможно только после проверки установки и локального пакета.",
};
}
}
function storageMigrationCopy(
status: StorageMigrationStatus | null | undefined,
) {
if (!status || (!status.changed && !status.blocking && !status.noticeCode)) {
return null;
}
if (status.blocking) {
return "Автоматическое изменение настроек заблокировано; исходные данные оставлены без изменений.";
}
switch (status.outcome) {
case "initialized_empty":
return "Хранилище настроек подготовлено.";
case "adopted_without_legacy_import":
return "Существующие настройки приняты без смешивания со старым конфигом.";
case "imported_legacy_config":
return "Старый конфиг безопасно перенесён в новое хранилище.";
default:
return "Настройки безопасно подготовлены для текущей версии.";
}
}
function componentName(componentId: ManagedPackageComponentId) {
return componentId === "proxifyre" ? "ProxiFyre" : "Local sing-box";
}
function actionAllowed(
status: ComponentCutoverStatusDto,
action: MigrationAction,
) {
if (action === "cutover") return status.canCutover;
if (action === "confirm-route-smoke") {
return status.canConfirmRouteSmoke;
}
return status.canCleanup;
}
function fixedDisabledReason(status: ComponentCutoverStatusDto) {
switch (status.disabledCode) {
case "component_package_unavailable":
return "Проверенный встроенный пакет недоступен.";
case "manual_migration_required":
return "Для этой установки разрешён только ручной перенос.";
case "component_identity_unconfirmed":
return "Принадлежность установки не подтверждена.";
case "component_cutover_recovery_required":
return "Сначала требуется безопасно восстановить перенос.";
default:
return "Действие пока недоступно по результатам проверки безопасности.";
}
}
export function MigrationNotice({
migrationStatus = null,
cutoverStatus,
busyAction,
externalBusyReason = null,
actionBlockedReason = null,
error = null,
uacCancelled = false,
onCutover,
onConfirmRouteSmoke,
onCleanup,
}: MigrationNoticeProps) {
const titleId = useId();
const reasonId = useId();
const storageCopy = storageMigrationCopy(migrationStatus);
const cutoverView = cutoverStatus
? migrationNoticeView(cutoverStatus.state)
: null;
if (!cutoverView && !storageCopy && !error && !uacCancelled) return null;
const view: MigrationNoticeView = cutoverView ?? {
title: error
? "Не удалось проверить перенос"
: migrationStatus?.blocking
? "Проверь состояние настроек"
: "Настройки подготовлены",
detail:
storageCopy ??
(error
? "Локальное состояние компонентов недоступно. Изменения оставлены заблокированными."
: "Настройки не изменялись."),
label: error
? "Проверка недоступна"
: migrationStatus?.blocking
? "Требует внимания"
: "Готово",
tone: error ? "error" : migrationStatus?.blocking ? "error" : "ok",
action: null,
disabledReason: null,
};
const action = view.action;
const anyBusy = busyAction !== null || externalBusyReason !== null;
const matchingBusy =
cutoverStatus && busyAction?.componentId === cutoverStatus.componentId
? busyAction.kind
: null;
const allowed =
cutoverStatus && action
? actionAllowed(cutoverStatus, action.kind) && !actionBlockedReason
: false;
const disabledReason = anyBusy
? (externalBusyReason ??
"Дождись завершения текущего действия с компонентом.")
: actionBlockedReason
? actionBlockedReason
: action && cutoverStatus && !allowed
? fixedDisabledReason(cutoverStatus)
: view.disabledReason;
function runAction() {
if (!cutoverStatus || !action) return;
if (action.kind === "cutover") {
void onCutover(cutoverStatus.componentId);
} else if (action.kind === "confirm-route-smoke") {
void onConfirmRouteSmoke(cutoverStatus.componentId);
} else {
void onCleanup(cutoverStatus.componentId);
}
}
const actionButton = action ? (
<Button
type="button"
size="sm"
variant={action.variant}
disabled={anyBusy || !allowed}
loading={matchingBusy === action.kind}
loadingLabel={action.loadingLabel}
aria-describedby={reasonId}
onClick={runAction}
>
{action.label}
</Button>
) : null;
const recoveryButton =
cutoverStatus?.canCutover && action?.kind !== "cutover" ? (
<Button
type="button"
size="sm"
variant="neutral"
disabled={anyBusy}
loading={matchingBusy === "cutover"}
loadingLabel="Восстанавливаю"
aria-describedby={reasonId}
onClick={() => void onCutover(cutoverStatus.componentId)}
>
Восстановить состояние
</Button>
) : null;
return (
<aside
className={`migration-notice migration-notice--${view.tone}`}
aria-labelledby={titleId}
>
<header className="migration-notice-head">
<div>
<span>
{cutoverStatus
? `Перенос ${componentName(cutoverStatus.componentId)}`
: "Миграция настроек"}
</span>
<h3 id={titleId}>{view.title}</h3>
</div>
<StatusPill tone={view.tone}>{view.label}</StatusPill>
</header>
<p className="migration-notice-detail">{view.detail}</p>
{storageCopy && cutoverView ? (
<p className="migration-storage-result">Настройки: {storageCopy}</p>
) : null}
{action?.kind === "cleanup" ? (
<div className="migration-cleanup-action">
<div>
<strong>Отдельное удаление</strong>
<span>Старая копия удаляется только этим явным действием.</span>
</div>
<div className="migration-cleanup-buttons">
{actionButton}
{recoveryButton}
</div>
</div>
) : (
<div className="migration-action-slot">
{recoveryButton ? (
<div className="migration-cleanup-buttons">
{actionButton}
{recoveryButton}
</div>
) : (
actionButton
)}
</div>
)}
<p className="migration-disabled-reason" id={reasonId}>
{disabledReason ?? "Каждое изменение запускается только вручную."}
</p>
<div className="migration-notice-feedback" aria-live="polite">
{uacCancelled ? (
<p className="neutral">
Запрос прав администратора отменён. Текущее состояние сохранено.
</p>
) : error ? (
<p className="error" role="alert">
{error.message}
</p>
) : null}
</div>
</aside>
);
}
-143
View File
@@ -1,143 +0,0 @@
import type {
ProxiFyreSetupProgress,
ProxiFyreSetupStatus,
} from "../../api/tauriCommands";
interface ProxiFyreSetupStripProps {
setupStatus: ProxiFyreSetupStatus | null;
progress: ProxiFyreSetupProgress | null;
}
const SETUP_PLACEHOLDERS: ProxiFyreSetupStatus["items"] = [
{
id: "vc-runtime",
name: "Среда запуска",
installed: false,
details: "Проверяю",
},
{
id: "packet-filter",
name: "Сетевой драйвер",
installed: false,
details: "Проверяю",
},
{
id: "proxifyre",
name: "Клиент ProxiFyre",
installed: false,
details: "Проверяю",
},
];
export function ProxiFyreSetupStrip({
setupStatus,
progress,
}: ProxiFyreSetupStripProps) {
const stripItems = setupStatus?.items ?? SETUP_PLACEHOLDERS;
const visibleProgress = isVisibleProgress(progress) ? progress : null;
const progressTone =
visibleProgress?.status === "failed" ? "failed" : "running";
const percent = clampPercent(visibleProgress?.percent ?? 0);
return (
<div
className={`setup-strip ${setupStatus?.ready ? "ready" : "attention"} ${visibleProgress ? "with-progress" : ""}`}
aria-label="Состав ProxiFyre"
>
<span className="setup-strip-title">Состав</span>
<div className="setup-strip-items">
{stripItems.map((item) => (
<div
className={`setup-strip-item ${setupItemClass(item, visibleProgress)}`}
key={item.id}
>
<span className="setup-strip-dot" aria-hidden="true" />
<strong>{setupItemUserName(item.id, item.name)}</strong>
<span>{setupItemShortStatus(item, visibleProgress)}</span>
</div>
))}
</div>
{visibleProgress ? (
<div className={`setup-progress setup-progress--${progressTone}`}>
<div
className="setup-progress-track"
role="progressbar"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={percent}
aria-label={visibleProgress.message}
>
<span
className="setup-progress-fill"
style={{ width: `${percent}%` }}
/>
</div>
<span className="setup-progress-message">
{visibleProgress.message}
</span>
</div>
) : null}
</div>
);
}
function setupItemClass(
item: ProxiFyreSetupStatus["items"][number],
progress: ProxiFyreSetupProgress | null,
) {
if (progress?.activeStep === item.id) {
if (progress.status === "failed") return "failed";
return "active";
}
if (item.installed) return "installed";
return "missing";
}
function setupItemUserName(id: string, fallbackName: string) {
if (id === "vc-runtime") return "Среда запуска";
if (id === "packet-filter") return "Сетевой драйвер";
if (id === "proxifyre") return "Клиент ProxiFyre";
return fallbackName;
}
function setupItemShortStatus(
item: ProxiFyreSetupStatus["items"][number],
progress: ProxiFyreSetupProgress | null,
) {
if (progress?.activeStep === item.id) {
if (progress.status === "failed") return "ошибка";
if (progress.status === "succeeded")
return progress.operation === "uninstall" ? "удалено" : "готово";
return "в процессе";
}
if (item.details === "Проверяю") return "проверяю";
if (!item.installed)
return progress?.operation === "uninstall" &&
progress.status === "succeeded"
? "удалено"
: "нужно установить";
if (item.id === "proxifyre")
return proxifyreSetupServiceSummary(item.version);
return "готово";
}
function proxifyreSetupServiceSummary(version: string | undefined) {
const normalized = version?.trim().toLowerCase() ?? "";
if (normalized.includes("не установлена")) return "служба не установлена";
if (normalized.includes("остановлена") || normalized.includes("не запущена"))
return "служба остановлена";
if (normalized.includes("запущена")) return "служба запущена";
return "готово";
}
function isVisibleProgress(
progress: ProxiFyreSetupProgress | null,
): progress is ProxiFyreSetupProgress {
if (!progress || progress.status === "idle") return false;
return progress.status === "running" || progress.status === "failed";
}
function clampPercent(value: number) {
if (!Number.isFinite(value)) return 0;
return Math.max(0, Math.min(100, Math.round(value)));
}
@@ -0,0 +1,51 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { SummaryStatusControl } from "./SummaryStatusControl";
describe("SummaryStatusControl", () => {
it("does not infer a missing component when startup data is unavailable", () => {
const markup = renderToStaticMarkup(
<SummaryStatusControl
unavailable
installed={false}
running={false}
working={false}
checking={false}
tone="warning"
/>,
);
expect(markup).toContain("ProxyWarden: Нет данных");
expect(markup).not.toContain("Не установлен");
});
it("renders an accessible status without a service mutation control", () => {
const markup = renderToStaticMarkup(
<SummaryStatusControl
installed
running
working={false}
checking={false}
tone="ok"
/>,
);
expect(markup).toContain('role="status"');
expect(markup).toContain("ProxyWarden: Службы запущены");
expect(markup).not.toContain("<button");
expect(markup).not.toContain("aria-pressed");
});
it("reports a missing component without presenting a disabled action", () => {
const markup = renderToStaticMarkup(
<SummaryStatusControl
installed={false}
running={false}
working={false}
checking={false}
tone="warning"
/>,
);
expect(markup).toContain("ProxyWarden: Не установлен");
expect(markup).not.toContain("disabled");
});
});
+19 -21
View File
@@ -3,49 +3,47 @@ import { BusyRing } from "../../ui";
import type { StatusTone } from "../viewModel";
interface SummaryStatusControlProps {
unavailable?: boolean;
installed: boolean;
running: boolean;
working: boolean;
checking: boolean;
tone: StatusTone;
onToggle: (running: boolean) => void;
}
export function SummaryStatusControl({
unavailable = false,
installed,
running,
working,
checking,
tone,
onToggle,
}: SummaryStatusControlProps) {
const stateLabel =
working || tone === "checking"
const stateLabel = unavailable
? "Нет данных"
: working || checking || tone === "checking"
? "Проверяю"
: tone === "ok"
? "Работает"
: "Не работает";
const buttonAriaLabel = !installed
? "ProxiFyre не установлен"
: running
? "Отключить ProxyWarden"
: "Включить ProxyWarden";
: !installed
? "Не установлен"
: tone === "ok"
? "Службы запущены"
: "Требует внимания";
return (
<div className={`summary-status-control ${tone} ${running ? "on" : "off"}`}>
<button
type="button"
className="summary-toggle-button"
onClick={() => onToggle(!running)}
disabled={!installed || checking || working}
aria-label={buttonAriaLabel}
aria-pressed={installed ? running : undefined}
<div
className={`summary-status-control ${tone} ${running ? "on" : "off"}`}
role="status"
aria-label={`ProxyWarden: ${stateLabel}`}
>
<span
className="summary-toggle-button summary-toggle-indicator"
aria-hidden="true"
>
{tone === "checking" || working ? <BusyRing /> : null}
<span className="summary-toggle-face" aria-hidden="true">
<Power size={88} strokeWidth={1.45} />
</span>
</button>
</span>
<strong className={`summary-state-label ${tone}`}>{stateLabel}</strong>
</div>
);
+35
View File
@@ -0,0 +1,35 @@
import { describe, expect, it } from "vitest";
import { commitDraft } from "./useApplyFlow";
import type {
ApplyConfigurationInput,
ApplyConfigurationResult,
} from "../../api/tauriCommands";
const input = {} as ApplyConfigurationInput;
const success = { success: true } as ApplyConfigurationResult;
describe("committed draft", () => {
it("keeps the committed outcome when the runtime refresh fails", async () => {
const accepted: unknown[] = [];
const result = await commitDraft(
input,
async () => success,
(value) => accepted.push(value),
async () => {
throw new Error("offline");
},
);
expect(accepted).toEqual([success]);
expect(result.result.success).toBe(true);
expect(result.refreshError).toBeInstanceOf(Error);
});
it("does not accept a failed transaction or refresh over a retained draft", async () => {
const calls: string[] = [];
await commitDraft(
input,
async () => ({ success: false }) as ApplyConfigurationResult,
() => calls.push("accept"),
async () => calls.push("refresh"),
);
expect(calls).toEqual([]);
});
});
+37
View File
@@ -0,0 +1,37 @@
import { useState } from "react";
import type {
ApplyConfigurationInput,
ApplyConfigurationResult,
} from "../../api/tauriCommands";
export async function commitDraft(
input: ApplyConfigurationInput,
command: (
input: ApplyConfigurationInput,
) => Promise<ApplyConfigurationResult>,
accept: (result: ApplyConfigurationResult) => void,
refresh: () => Promise<unknown>,
) {
const result = await command(input);
if (!result.success) return { result };
accept(result);
try {
await refresh();
return { result };
} catch (refreshError) {
return { result, refreshError };
}
}
export function useApplyFlow() {
const [isApplying, setApplying] = useState(false);
async function submit(...args: Parameters<typeof commitDraft>) {
setApplying(true);
try {
return await commitDraft(...args);
} finally {
setApplying(false);
}
}
return { isApplying, submit };
}
+172
View File
@@ -0,0 +1,172 @@
import { describe, expect, it, vi } from "vitest";
import { packageComponentIdFor } from "../../domain/types";
import type {
ComponentCutoverStatus,
ComponentPackageStatus,
} from "../../domain/types";
import {
componentStateIsInitializing,
componentActionRequiresReboot,
executeComponentCommand,
isPrivilegedUacCancellation,
localComponentSnapshot,
localComponentStateError,
loadLocalComponentState,
sanitizeComponentCommandError,
} from "./useComponentPackages";
const packageStatus: ComponentPackageStatus = {
componentId: "proxifyre",
installedVersion: null,
bundledVersion: "2.2.1",
availableOfflineVersion: "2.2.1",
latestKnownVersion: null,
lastCheckedAt: null,
freshness: "never_checked",
updateState: "unknown_offline",
installSource: "none",
offlinePackageSource: "bundled",
canInstallOffline: true,
offlineUnavailableReason: null,
canDownload: false,
};
const cutoverStatus: ComponentCutoverStatus = {
componentId: "proxifyre",
state: "ready",
mode: "service_switch",
legacyVersion: "2.2.1",
currentVersion: null,
bundledVersion: "2.2.1",
originalServiceState: "running",
legacyPathLabel: "Старая установка ProxiFyre",
currentPathLabel: "Установка ProxyWarden",
steps: ["Перенос выполняется отдельным действием."],
nextStartVerified: false,
routeSmokeConfirmed: false,
canCutover: true,
canConfirmRouteSmoke: false,
canCleanup: false,
disabledCode: null,
disabledMessage: null,
};
describe("component package orchestration", () => {
it("shows initialization immediately on the first startup-ready paint", () => {
expect(componentStateIsInitializing(true, false, false)).toBe(true);
expect(componentStateIsInitializing(true, true, true)).toBe(true);
expect(componentStateIsInitializing(true, true, false)).toBe(false);
expect(componentStateIsInitializing(false, false, false)).toBe(false);
});
it("maps inventory ids to the exact package ids", () => {
expect(packageComponentIdFor("proxyfier")).toBe("proxifyre");
expect(packageComponentIdFor("singbox")).toBe("sing-box");
expect(packageComponentIdFor("control-app")).toBeNull();
});
it("loads only the two local status sources and keeps partial results", async () => {
const getPackageStatuses = vi.fn().mockResolvedValue([packageStatus]);
const getCutoverStatuses = vi.fn().mockRejectedValue({
code: "component_cutover_status_failed",
message: "Статус переноса временно недоступен.",
details: [],
});
const result = await loadLocalComponentState({
getPackageStatuses,
getCutoverStatuses,
});
expect(getPackageStatuses).toHaveBeenCalledOnce();
expect(getCutoverStatuses).toHaveBeenCalledOnce();
expect(result.packageStatuses).toEqual([packageStatus]);
expect(result.cutoverStatuses).toBeNull();
expect(result.packageError).toBeNull();
expect(result.cutoverError?.code).toBe("component_cutover_status_failed");
});
it("returns UAC cancellation as a neutral outcome", async () => {
const result = await executeComponentCommand(() =>
Promise.reject({
code: "privileged_uac_cancelled",
message: "Запрос прав администратора отменен пользователем.",
details: [],
}),
);
expect(result).toEqual({ status: "cancelled" });
expect(
isPrivilegedUacCancellation({ code: "privileged_uac_cancelled" }),
).toBe(true);
expect(isPrivilegedUacCancellation({ code: "operation_failed" })).toBe(
false,
);
});
it("surfaces only an explicit reboot-required result", () => {
expect(componentActionRequiresReboot({ rebootRequired: true })).toBe(true);
expect(componentActionRequiresReboot({ rebootRequired: false })).toBe(
false,
);
expect(componentActionRequiresReboot({ changed: true })).toBe(false);
});
it("keeps path and credential-bearing text out of displayed errors", () => {
const result = sanitizeComponentCommandError({
code: "component_update_failed",
message: "C:\\Users\\person\\secret.txt",
details: [
{
field: "package",
message: "token=do-not-display",
},
],
debugPath: "C:\\ProgramData\\ProxyWarden",
});
expect(result.code).toBe("component_update_failed");
expect(result.message).not.toContain("person");
expect(result.details[0]?.message).not.toContain("do-not-display");
expect(JSON.stringify(result)).not.toContain("ProgramData");
});
it("returns both local status groups when available", async () => {
const result = await loadLocalComponentState({
getPackageStatuses: async () => [packageStatus],
getCutoverStatuses: async () => [cutoverStatus],
});
expect(result).toEqual({
packageStatuses: [packageStatus],
cutoverStatuses: [cutoverStatus],
packageError: null,
cutoverError: null,
});
});
it("clears stale authority when a local status source becomes unreadable", () => {
const snapshot = localComponentSnapshot({
packageStatuses: [packageStatus],
cutoverStatuses: null,
packageError: null,
cutoverError: {
code: "component_cutover_status_failed",
message: "Статус переноса временно недоступен.",
details: [],
},
});
expect(snapshot.packageStatuses).toEqual([packageStatus]);
expect(snapshot.cutoverStatuses).toEqual([]);
expect(snapshot.cutoverError?.code).toBe("component_cutover_status_failed");
expect(
localComponentStateError({
packageStatuses: [packageStatus],
cutoverStatuses: null,
packageError: null,
cutoverError: snapshot.cutoverError,
})?.code,
).toBe("component_cutover_status_failed");
});
});
+364
View File
@@ -0,0 +1,364 @@
import { useEffect, useRef, useState } from "react";
import {
checkComponentUpdate as checkComponentUpdateCommand,
cleanupComponentQuarantine as cleanupComponentQuarantineCommand,
confirmComponentRouteSmoke as confirmComponentRouteSmokeCommand,
cutoverComponent as cutoverComponentCommand,
downloadComponentUpdate as downloadComponentUpdateCommand,
getComponentCutoverStatuses,
getComponentPackageStatuses,
updateComponent as updateComponentCommand,
type CommandError,
} from "../../api/tauriCommands";
import type {
ComponentCutoverResponse,
ComponentCutoverStatus,
ComponentPackageStatus,
ComponentUpdateCheckResponse,
ComponentUpdateDownloadResponse,
ComponentUpdateResponse,
ManagedPackageComponentId,
} from "../../domain/types";
const FALLBACK_ERROR_MESSAGE =
"Не удалось выполнить действие с компонентом. Повтори попытку.";
const SAFE_ERROR_CODE = /^[a-z0-9_]{1,64}$/;
const SAFE_DETAIL_FIELD = /^[a-zA-Z0-9_.-]{1,64}$/;
const SENSITIVE_TEXT =
/(?:[a-z][a-z0-9+.-]*:\/\/|[a-z]:[\\/]|(?:password|passwd|token|secret|authorization|userinfo)\s*[:=])/i;
export type ComponentPackageActionKind =
| "check"
| "download"
| "update"
| "cutover"
| "confirm-route-smoke"
| "cleanup";
export interface ComponentPackageBusyAction {
kind: ComponentPackageActionKind;
componentId: ManagedPackageComponentId;
}
export type ComponentPackageActionResult<T> =
| { status: "succeeded"; value: T }
| { status: "cancelled" }
| { status: "busy" }
| { status: "failed"; error: CommandError };
interface LocalStatusCommands {
getPackageStatuses: () => Promise<ComponentPackageStatus[]>;
getCutoverStatuses: () => Promise<ComponentCutoverStatus[]>;
}
export interface LocalComponentState {
packageStatuses: ComponentPackageStatus[] | null;
cutoverStatuses: ComponentCutoverStatus[] | null;
packageError: CommandError | null;
cutoverError: CommandError | null;
}
export interface LocalComponentSnapshot {
packageStatuses: ComponentPackageStatus[];
cutoverStatuses: ComponentCutoverStatus[];
packageError: CommandError | null;
cutoverError: CommandError | null;
}
const localStatusCommands: LocalStatusCommands = {
getPackageStatuses: getComponentPackageStatuses,
getCutoverStatuses: getComponentCutoverStatuses,
};
function sanitizedText(value: unknown, fallback: string) {
if (typeof value !== "string") return fallback;
const normalized = value.replace(/\s+/g, " ").trim();
if (!normalized || SENSITIVE_TEXT.test(normalized)) return fallback;
return normalized.slice(0, 320);
}
export function sanitizeComponentCommandError(error: unknown): CommandError {
const record =
error && typeof error === "object"
? (error as Record<string, unknown>)
: null;
const rawCode = record?.code;
const code =
typeof rawCode === "string" && SAFE_ERROR_CODE.test(rawCode)
? rawCode
: "component_action_failed";
const messageSource =
error instanceof Error ? error.message : (record?.message ?? error);
const rawDetails = Array.isArray(record?.details) ? record.details : [];
const details = rawDetails.flatMap((detail) => {
if (!detail || typeof detail !== "object") return [];
const item = detail as Record<string, unknown>;
const field =
typeof item.field === "string" && SAFE_DETAIL_FIELD.test(item.field)
? item.field
: "component";
return [
{
field,
message: sanitizedText(item.message, FALLBACK_ERROR_MESSAGE),
},
];
});
return {
code,
message: sanitizedText(messageSource, FALLBACK_ERROR_MESSAGE),
details: details.slice(0, 8),
};
}
export function isPrivilegedUacCancellation(error: unknown) {
return Boolean(
error &&
typeof error === "object" &&
(error as Record<string, unknown>).code === "privileged_uac_cancelled",
);
}
export async function executeComponentCommand<T>(
command: () => Promise<T>,
): Promise<ComponentPackageActionResult<T>> {
try {
return { status: "succeeded", value: await command() };
} catch (error) {
const sanitized = sanitizeComponentCommandError(error);
return isPrivilegedUacCancellation(sanitized)
? { status: "cancelled" }
: { status: "failed", error: sanitized };
}
}
export function componentActionRequiresReboot(value: unknown) {
return Boolean(
value &&
typeof value === "object" &&
(value as Record<string, unknown>).rebootRequired === true,
);
}
export async function loadLocalComponentState(
commands: LocalStatusCommands = localStatusCommands,
): Promise<LocalComponentState> {
const [packages, cutovers] = await Promise.allSettled([
commands.getPackageStatuses(),
commands.getCutoverStatuses(),
]);
return {
packageStatuses: packages.status === "fulfilled" ? packages.value : null,
cutoverStatuses: cutovers.status === "fulfilled" ? cutovers.value : null,
packageError:
packages.status === "rejected"
? sanitizeComponentCommandError(packages.reason)
: null,
cutoverError:
cutovers.status === "rejected"
? sanitizeComponentCommandError(cutovers.reason)
: null,
};
}
export function localComponentSnapshot(
state: LocalComponentState,
): LocalComponentSnapshot {
return {
packageStatuses: state.packageStatuses ?? [],
cutoverStatuses: state.cutoverStatuses ?? [],
packageError: state.packageError,
cutoverError: state.cutoverError,
};
}
export function localComponentStateError(state: LocalComponentState) {
return state.cutoverError ?? state.packageError;
}
export function componentStateIsInitializing(
startupReady: boolean,
initializationStarted: boolean,
initializing: boolean,
) {
return startupReady && (!initializationStarted || initializing);
}
function upsertByComponentId<
T extends { componentId: ManagedPackageComponentId },
>(values: T[], value: T) {
const index = values.findIndex(
(current) => current.componentId === value.componentId,
);
if (index === -1) return [...values, value];
return [...values.slice(0, index), value, ...values.slice(index + 1)];
}
export function useComponentPackages(startupReady: boolean) {
const [packageStatuses, setPackageStatuses] = useState<
ComponentPackageStatus[]
>([]);
const [cutoverStatuses, setCutoverStatuses] = useState<
ComponentCutoverStatus[]
>([]);
const [isInitializing, setIsInitializing] = useState(false);
const [busyAction, setBusyAction] =
useState<ComponentPackageBusyAction | null>(null);
const [error, setError] = useState<CommandError | null>(null);
const [packageStatusError, setPackageStatusError] =
useState<CommandError | null>(null);
const [cutoverStatusError, setCutoverStatusError] =
useState<CommandError | null>(null);
const initializationRef = useRef<Promise<LocalComponentState> | null>(null);
const busyRef = useRef<ComponentPackageBusyAction | null>(null);
useEffect(() => {
if (!startupReady) {
initializationRef.current = null;
return undefined;
}
const initialization =
initializationRef.current ??
(initializationRef.current = loadLocalComponentState());
let active = true;
setIsInitializing(true);
void initialization.then((state) => {
if (!active) return;
const snapshot = localComponentSnapshot(state);
setPackageStatuses(snapshot.packageStatuses);
setCutoverStatuses(snapshot.cutoverStatuses);
setPackageStatusError(snapshot.packageError);
setCutoverStatusError(snapshot.cutoverError);
setIsInitializing(false);
});
return () => {
active = false;
};
}, [startupReady]);
async function refreshLocal() {
setIsInitializing(true);
const state = await loadLocalComponentState();
const snapshot = localComponentSnapshot(state);
setPackageStatuses(snapshot.packageStatuses);
setCutoverStatuses(snapshot.cutoverStatuses);
setPackageStatusError(snapshot.packageError);
setCutoverStatusError(snapshot.cutoverError);
setIsInitializing(false);
return state;
}
async function runAction<T>(
action: ComponentPackageBusyAction,
command: () => Promise<T>,
apply: (value: T) => void,
): Promise<ComponentPackageActionResult<T>> {
if (busyRef.current) return { status: "busy" };
busyRef.current = action;
setBusyAction(action);
setError(null);
const result = await executeComponentCommand(command);
if (result.status === "succeeded") apply(result.value);
if (result.status === "failed") setError(result.error);
if (busyRef.current === action) {
busyRef.current = null;
setBusyAction(null);
}
return result;
}
function check(componentId: ManagedPackageComponentId) {
return runAction<ComponentUpdateCheckResponse>(
{ kind: "check", componentId },
() => checkComponentUpdateCommand(componentId),
(response) =>
setPackageStatuses((current) =>
upsertByComponentId(current, response.status),
),
);
}
function download(componentId: ManagedPackageComponentId) {
return runAction<ComponentUpdateDownloadResponse>(
{ kind: "download", componentId },
() => downloadComponentUpdateCommand(componentId),
(response) =>
setPackageStatuses((current) =>
upsertByComponentId(current, response.status),
),
);
}
function update(componentId: ManagedPackageComponentId) {
return runAction<ComponentUpdateResponse>(
{ kind: "update", componentId },
() => updateComponentCommand(componentId),
(response) =>
setPackageStatuses((current) =>
upsertByComponentId(current, response.package),
),
);
}
function cutover(componentId: ManagedPackageComponentId) {
return runAction<ComponentCutoverResponse>(
{ kind: "cutover", componentId },
() => cutoverComponentCommand(componentId),
(response) =>
setCutoverStatuses((current) =>
upsertByComponentId(current, response.status),
),
);
}
function confirmRouteSmoke(componentId: ManagedPackageComponentId) {
return runAction<ComponentCutoverStatus>(
{ kind: "confirm-route-smoke", componentId },
() => confirmComponentRouteSmokeCommand(componentId),
(status) =>
setCutoverStatuses((current) => upsertByComponentId(current, status)),
);
}
function cleanup(componentId: ManagedPackageComponentId) {
return runAction<ComponentCutoverResponse>(
{ kind: "cleanup", componentId },
() => cleanupComponentQuarantineCommand(componentId),
(response) =>
setCutoverStatuses((current) =>
upsertByComponentId(current, response.status),
),
);
}
const effectiveInitializing = componentStateIsInitializing(
startupReady,
initializationRef.current !== null,
isInitializing,
);
return {
packageStatuses,
cutoverStatuses,
isInitializing: effectiveInitializing,
busyAction,
error,
packageStatusError,
cutoverStatusError,
clearError: () => setError(null),
refreshLocal,
check,
download,
update,
cutover,
confirmRouteSmoke,
cleanup,
};
}
+114
View File
@@ -0,0 +1,114 @@
import { describe, expect, it } from "vitest";
import type { Profile, Target } from "../../domain/types";
import { configurationDraft } from "./useConfigurationDraft";
import { formatProxy, profileItemInput } from "../viewModel";
import { parseProxy } from "../lib/parseProxy";
import { configChangeRows, configSnapshotFromUi } from "../lib/snapshots";
const targets: Target[] = [
{
id: "main-proxy",
name: "A",
kind: "external",
protocol: "socks5",
host: "a.example.test",
port: 1080,
},
{
id: "b",
name: "B",
kind: "external",
protocol: "socks5",
host: "2001:db8::1",
port: 1080,
},
];
const old: Profile = {
id: "old",
name: "Старые правила",
enabled: false,
targetId: "b",
protocols: ["TCP"],
items: [{ type: "folder", value: "C:\\Games", recursive: false }],
};
describe("existing configuration draft", () => {
it("detects remove and re-add of a nonrecursive folder as a rule change", () => {
const before = configSnapshotFromUi(
"external",
"a.example.test:1080",
old.items,
);
const after = configSnapshotFromUi("external", "a.example.test:1080", [
{ type: "folder", value: "C:\\Games" },
]);
expect(configChangeRows(before, after).map((row) => row.after)).toEqual([
"+ папка c:\\games (с подпапками)",
"- папка c:\\games (без подпапок)",
]);
});
it("preserves the selected profile and its exact target and hidden rule fields", () => {
const other = {
...old,
id: "other",
enabled: true,
targetId: "main-proxy",
};
const draft = configurationDraft([other, old], targets, "old");
expect(draft.profile).toEqual(old);
expect(draft.targetId).toBe("b");
expect(parseProxy(draft.proxyInput)).toEqual({
protocol: "socks5",
host: targets[1].host,
port: 1080,
});
expect(draft.items.map(profileItemInput)).toEqual(old.items);
});
it("does not merge profiles when main-profile is absent", () => {
const second = {
...old,
id: "second",
items: [
{ type: "process" as const, value: "second.exe", recursive: false },
],
};
expect(
configurationDraft([old, second], targets).items.map(profileItemInput),
).toEqual(old.items);
});
it("refuses a missing target instead of substituting main-proxy", () => {
expect(() =>
configurationDraft([{ ...old, targetId: "missing" }], targets),
).toThrow("не найдена");
});
it("formats IPv6 and rejects address suffixes instead of discarding them", () => {
expect(parseProxy(formatProxy(targets[1])).host).toBe("2001:db8::1");
for (const suffix of ["/path", "?token=fixture", "#fragment"]) {
expect(() => parseProxy("socks5://a.example.test:1080" + suffix)).toThrow(
"без пути",
);
}
expect(parseProxy("socks5://a.example.test:1080/").host).toBe(
"a.example.test",
);
});
it("shows an explicit enable/disable change even if the app list is unchanged", () => {
const before = configSnapshotFromUi(
"external",
"a.example.test:1080",
[],
undefined,
undefined,
false,
);
const after = { ...before, enabled: true };
expect(configChangeRows(before, after)).toEqual([
{
id: "profile-enabled",
label: "Профиль",
before: "Выключен",
after: "Включён",
},
]);
});
});
+99
View File
@@ -0,0 +1,99 @@
import { useRef, useState } from "react";
import type { Profile, Target } from "../../domain/types";
import {
formatProxy,
itemsForProfiles,
targetForExternalProxy,
} from "../viewModel";
import type { RouteMode } from "../lib/snapshots";
export function configurationDraft(
profiles: Profile[],
targets: Target[],
preferredId?: string,
) {
const profile =
profiles.find((entry) => entry.id === preferredId) ??
profiles.find((entry) => entry.id === "main-profile") ??
profiles.find((entry) => entry.enabled) ??
profiles[0];
const activeTarget = profile
? targets.find((entry) => entry.id === profile.targetId)
: undefined;
if (profile && !activeTarget)
throw new Error(
"Цель сохранённого профиля не найдена. Настройки не изменены.",
);
if (
profile?.items.some(
(item) => !["process", "folder", "exe"].includes(item.type),
)
) {
throw new Error(
"Профиль содержит неподдерживаемые правила. Редактирование без потери данных невозможно.",
);
}
const local = activeTarget?.id === "local-singbox";
if (
activeTarget &&
!local &&
(activeTarget.kind !== "external" || activeTarget.protocol !== "socks5")
) {
throw new Error(
"Этот тип маршрута нельзя редактировать в SOCKS5-редакторе. Настройки не изменены.",
);
}
const externalTarget =
local || !profile ? targetForExternalProxy(targets) : activeTarget;
return {
profile,
externalTarget,
profileId: profile?.id ?? "main-profile",
targetId: externalTarget?.id ?? "main-proxy",
routeMode: (local ? "local-singbox" : "external") as RouteMode,
proxyInput: externalTarget ? formatProxy(externalTarget) : "",
items: itemsForProfiles(profile ? [profile] : []),
};
}
export function useConfigurationDraft() {
const generation = useRef(0);
const revision = useRef<string | undefined>(undefined);
function edited() {
generation.current += 1;
}
const [enabled, updateEnabled] = useState(true);
function setEnabled(value: boolean) {
edited();
updateEnabled(value);
}
const [source, setSource] = useState<{
profiles: Profile[];
targets: Target[];
}>({ profiles: [], targets: [] });
const [selection, setSelection] = useState<ReturnType<
typeof configurationDraft
> | null>(null);
function load(
profiles: Profile[],
targets: Target[],
preferredId?: string,
hydrate = true,
) {
const next = configurationDraft(profiles, targets, preferredId);
setSource({ profiles, targets });
setSelection(next);
if (hydrate) updateEnabled(next.profile?.enabled ?? true);
return next;
}
return {
...source,
selection,
load,
enabled,
setEnabled,
generation,
revision,
edited,
};
}
+28
View File
@@ -0,0 +1,28 @@
import { useRef, useState } from "react";
// Input edits and subscription requests have different lifetimes: editing the
// next URL must not cancel a valid response, and a response must not erase it.
export function useSubscription() {
const [input, updateInput] = useState("");
const inputVersion = useRef(0);
const requestVersion = useRef(0);
function setInput(value: string) {
inputVersion.current += 1;
updateInput(value);
}
function begin() {
const request = ++requestVersion.current;
const draft = inputVersion.current;
return {
current: () => request === requestVersion.current,
clearSubmittedInput: () => {
if (
request === requestVersion.current &&
draft === inputVersion.current
)
updateInput("");
},
};
}
return { input, setInput, begin, requestVersion };
}
+9
View File
@@ -25,6 +25,15 @@ export function parseProxy(rawValue: string): ParsedProxy {
if (parsed.username || parsed.password) {
throw new Error("Прокси с логином и паролем пока не поддерживаются.");
}
if (
(parsed.pathname && parsed.pathname !== "/") ||
parsed.search ||
parsed.hash
) {
throw new Error(
"Адрес SOCKS5 должен содержать только хост и порт, без пути, параметров и фрагмента.",
);
}
const host = parsed.hostname.replace(/^\[|\]$/g, "");
const port = Number(parsed.port);
+2 -2
View File
@@ -18,8 +18,8 @@ describe("configuration snapshots", () => {
expect(snapshot.proxy).toBe("socks5://proxy.example.test:1080");
expect(snapshot.items).toEqual([
{ type: "folder", value: "c:\\games" },
{ type: "process", value: "discord" },
{ type: "folder", value: "c:\\games", recursive: true },
{ type: "process", value: "discord", recursive: false },
]);
});
+20 -8
View File
@@ -10,9 +10,11 @@ export type RouteMode = "external" | "local-singbox";
export interface ConfigSnapshotItem {
type: DraftItemType;
value: string;
recursive?: boolean;
}
export interface ConfigSnapshot {
enabled?: boolean;
routeMode: RouteMode;
proxy: string;
selectedServerId: string;
@@ -31,11 +33,13 @@ export interface PendingChangeRow {
export function configSnapshotFromUi(
routeMode: RouteMode,
proxyInput: string,
items: Array<{ type: DraftItemType; value: string }>,
items: Array<{ type: DraftItemType; value: string; recursive?: boolean }>,
selectedServerId?: string,
selectedServerTag?: string,
enabled = true,
): ConfigSnapshot {
return {
enabled,
routeMode,
proxy: routeMode === "external" ? normalizeProxySnapshot(proxyInput) : "",
selectedServerId:
@@ -53,6 +57,14 @@ export function configChangeRows(
if (sameConfigSnapshot(applied, current)) return [];
const rows: PendingChangeRow[] = [];
if ((applied.enabled ?? true) !== (current.enabled ?? true)) {
rows.push({
id: "profile-enabled",
label: "Профиль",
before: applied.enabled === false ? "Выключен" : "Включён",
after: current.enabled === false ? "Выключен" : "Включён",
});
}
if (applied.routeMode !== current.routeMode) {
rows.push({
id: "route-mode",
@@ -93,6 +105,7 @@ export function sameConfigSnapshot(
right: ConfigSnapshot,
) {
return (
(left.enabled ?? true) === (right.enabled ?? true) &&
left.routeMode === right.routeMode &&
left.proxy === right.proxy &&
left.selectedServerId === right.selectedServerId &&
@@ -100,7 +113,7 @@ export function sameConfigSnapshot(
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;
return snapshotItemKey(item) === snapshotItemKey(other);
})
);
}
@@ -135,18 +148,17 @@ function normalizeProxySnapshot(value: string) {
}
function normalizeSnapshotItems(
items: Array<{ type: DraftItemType; value: string }>,
items: Array<{ type: DraftItemType; value: string; recursive?: boolean }>,
): ConfigSnapshotItem[] {
return items
.map((item) => ({
type: item.type,
value: normalizeItemValue(item.value, item.type).toLowerCase(),
recursive: item.type === "folder" ? (item.recursive ?? true) : false,
}))
.filter((item) => item.value)
.sort((left, right) =>
`${left.type}:${left.value}`.localeCompare(
`${right.type}:${right.value}`,
),
snapshotItemKey(left).localeCompare(snapshotItemKey(right)),
);
}
@@ -193,9 +205,9 @@ function snapshotItemChangeRows(
}
function snapshotItemKey(item: ConfigSnapshotItem) {
return `${item.type}:${item.value}`;
return `${item.type}:${item.value}:${item.type === "folder" ? (item.recursive ?? true) : false}`;
}
function formatSnapshotItem(item: ConfigSnapshotItem) {
return `${itemTypeLabel(item.type)} ${item.value}`;
return `${itemTypeLabel(item.type)} ${item.value}${item.type === "folder" ? (item.recursive === false ? " (без подпапок)" : " (с подпапками)") : ""}`;
}
+104 -1
View File
@@ -1,5 +1,13 @@
import { describe, expect, it } from "vitest";
import { getApplyReadiness, type ApplyReadinessInput } from "./readiness";
import type { ComponentCutoverStatus } from "../domain/types";
import {
canUseStoppedProxiFyreRouteSmoke,
getComponentUpdateBlockReason,
getApplyReadiness,
isCutoverLifecycleComplete,
isComponentInstallPackageReady,
type ApplyReadinessInput,
} from "./readiness";
const base: ApplyReadinessInput = {
routeMode: "external",
@@ -10,6 +18,8 @@ const base: ApplyReadinessInput = {
selectedServerTag: undefined,
externalProxyValue: "proxy.example.test:1080",
externalProxyError: null,
proxiFyreCutoverState: "not_needed",
singBoxCutoverState: "not_needed",
busy: false,
};
@@ -44,4 +54,97 @@ describe("getApplyReadiness", () => {
}),
).toEqual({ ready: true });
});
it.each([
"ready",
"manual_migration_required",
"in_progress",
"recovery_required",
] as const)("blocks apply while ProxiFyre cutover is %s", (state) => {
const readiness = getApplyReadiness({
...base,
proxiFyreCutoverState: state,
});
expect(readiness.ready).toBe(false);
expect(readiness.title).toContain("ProxiFyre");
});
it("fails closed until local cutover status is loaded", () => {
expect(
getApplyReadiness({ ...base, proxiFyreCutoverState: null }),
).toMatchObject({
ready: false,
title: "Проверяю перенос ProxiFyre",
});
});
it.each(["not_needed", "complete"] as const)(
"preserves normal readiness when cutover is %s",
(state) => {
expect(
getApplyReadiness({ ...base, proxiFyreCutoverState: state }),
).toEqual({ ready: true });
},
);
it("blocks a local route while Local sing-box migration is unresolved", () => {
const readiness = getApplyReadiness({
...base,
routeMode: "local-singbox",
singBoxInstalled: true,
singBoxRunning: true,
selectedServerTag: "nl-1",
externalProxyValue: "",
singBoxCutoverState: "manual_migration_required",
});
expect(readiness.ready).toBe(false);
expect(readiness.title).toContain("Local sing-box");
});
});
describe("cutover lifecycle permissions", () => {
it("allows ordinary component mutations only after managed cutover states", () => {
expect(isCutoverLifecycleComplete("not_needed")).toBe(true);
expect(isCutoverLifecycleComplete("complete")).toBe(true);
expect(isCutoverLifecycleComplete("rolled_back")).toBe(false);
expect(isCutoverLifecycleComplete("awaiting_route_smoke")).toBe(false);
expect(isCutoverLifecycleComplete(null)).toBe(false);
});
it("allows only the stopped ProxiFyre route-smoke service exception", () => {
const status = {
state: "awaiting_route_smoke",
originalServiceState: "stopped",
} as ComponentCutoverStatus;
expect(canUseStoppedProxiFyreRouteSmoke(status)).toBe(true);
expect(
canUseStoppedProxiFyreRouteSmoke({
...status,
originalServiceState: "running",
}),
).toBe(false);
expect(
canUseStoppedProxiFyreRouteSmoke({ ...status, state: "complete" }),
).toBe(false);
});
it("requires terminal cutover and an explicitly stopped service for update", () => {
expect(getComponentUpdateBlockReason("ready", false)).toContain("заверши");
expect(getComponentUpdateBlockReason("complete", true)).toContain(
"останови службу",
);
expect(getComponentUpdateBlockReason("complete", false)).toBeNull();
expect(getComponentUpdateBlockReason("rolled_back", false)).toContain(
"заверши",
);
});
it("fails closed when the install package status is missing or unreadable", () => {
expect(isComponentInstallPackageReady(true, false)).toBe(true);
expect(isComponentInstallPackageReady(undefined, false)).toBe(false);
expect(isComponentInstallPackageReady(true, true)).toBe(false);
});
});
+102 -1
View File
@@ -1,14 +1,22 @@
import type {
ComponentCutoverState,
ComponentCutoverStatus,
} from "../domain/types";
export type RouteMode = "external" | "local-singbox";
export interface ApplyReadinessInput {
routeMode: RouteMode;
appCount: number;
canClearProfile?: boolean;
proxiFyreInstalled: boolean;
singBoxInstalled: boolean;
singBoxRunning: boolean;
selectedServerTag?: string;
externalProxyValue: string;
externalProxyError?: string | null;
proxiFyreCutoverState: ComponentCutoverState | null;
singBoxCutoverState: ComponentCutoverState | null;
busy: boolean;
}
@@ -18,6 +26,86 @@ export interface ApplyReadiness {
text?: string;
}
export function isCutoverLifecycleComplete(
state: ComponentCutoverState | null,
) {
return state === "not_needed" || state === "complete";
}
export function canUseStoppedProxiFyreRouteSmoke(
status: ComponentCutoverStatus | null,
) {
return Boolean(
status?.state === "awaiting_route_smoke" &&
status.originalServiceState === "stopped",
);
}
export function getComponentUpdateBlockReason(
state: ComponentCutoverState | null,
serviceRunning: boolean,
) {
if (!isCutoverLifecycleComplete(state)) {
return "Сначала заверши или восстанови перенос компонента.";
}
if (serviceRunning) {
return "Сначала явно останови службу; запуск после обновления выполняется отдельно.";
}
return null;
}
export function isComponentInstallPackageReady(
canInstallOffline: boolean | null | undefined,
hasStatusError: boolean,
) {
return canInstallOffline === true && !hasStatusError;
}
export function getCutoverLifecycleBlock(
state: ComponentCutoverState | null,
componentLabel: string,
): ApplyReadiness | null {
if (isCutoverLifecycleComplete(state)) return null;
if (state === null) {
return {
ready: false,
title: `Проверяю перенос ${componentLabel}`,
text: "Дождись локальной проверки состояния компонента.",
};
}
if (state === "ready") {
return {
ready: false,
title: `Требуется перенос ${componentLabel}`,
text: "Сначала выполни отдельный перенос компонента, затем примени конфигурацию.",
};
}
if (state === "manual_migration_required" || state === "blocked") {
return {
ready: false,
title: `Нужен ручной перенос ${componentLabel}`,
text: "Автоматическое изменение этого экземпляра запрещено до ручного переноса.",
};
}
if (state === "recovery_required") {
return {
ready: false,
title: `Нужно восстановление ${componentLabel}`,
text: "Продолжи или откати незавершённый перенос перед изменением маршрута.",
};
}
return {
ready: false,
title: `Перенос ${componentLabel} не завершён`,
text: "Заверши проверку нового запуска и очистку старой установки перед применением.",
};
}
export function getApplyReadiness(input: ApplyReadinessInput): ApplyReadiness {
if (input.busy) {
return {
@@ -27,6 +115,12 @@ export function getApplyReadiness(input: ApplyReadinessInput): ApplyReadiness {
};
}
const proxiFyreCutover = getCutoverLifecycleBlock(
input.proxiFyreCutoverState,
"ProxiFyre",
);
if (proxiFyreCutover) return proxiFyreCutover;
if (!input.proxiFyreInstalled) {
return {
ready: false,
@@ -36,10 +130,11 @@ export function getApplyReadiness(input: ApplyReadinessInput): ApplyReadiness {
}
if (input.appCount < 1) {
if (input.canClearProfile) return { ready: true };
return {
ready: false,
title: "Нет приложений",
text: обавь хотя бы один процесс, EXE-файл или папку.",
text: ля очистки сохранённых правил сначала явно останови ProxiFyre. Для нового маршрута добавь приложение.",
};
}
@@ -62,6 +157,12 @@ export function getApplyReadiness(input: ApplyReadinessInput): ApplyReadiness {
}
if (input.routeMode === "local-singbox") {
const singBoxCutover = getCutoverLifecycleBlock(
input.singBoxCutoverState,
"Local sing-box",
);
if (singBoxCutover) return singBoxCutover;
if (!input.singBoxInstalled) {
return {
ready: false,
+38 -24
View File
@@ -3,7 +3,6 @@ import type {
LocalSingBoxStatusResponse,
PingServerResponse,
ProxyTargetCheckResponse,
ProxiFyreSetupProgress,
ProxiFyreSetupStatus,
SingBoxSetupStatus,
} from "../api/tauriCommands";
@@ -21,8 +20,10 @@ import { displayServerTag, type RouteMode } from "./lib/snapshots";
export type StatusTone = "ok" | "warning" | "error" | "checking" | "muted";
export type SummaryRouteFlow = "proxy" | "direct" | "idle";
type ProxiFyreAction = "start" | "stop" | "restart" | "install" | "uninstall";
type ProxiFyreAction =
"start" | "stop" | "restart" | "install" | "uninstall" | "firewall";
type SingBoxAction =
| "select"
| "start"
| "stop"
| "install"
@@ -39,6 +40,7 @@ export interface DraftItem {
id: string;
type: DraftItemType;
value: string;
recursive?: boolean;
}
export interface Notice {
kind: "success" | "error" | "info";
@@ -116,6 +118,7 @@ export function serviceControlState(
}
export interface SummaryStateInput {
artifacts?: import("../api/tauriCommands").ArtifactStatus[];
isLoading: boolean;
isDetectingComponents: boolean;
proxyfier: ComponentStatus | undefined;
@@ -180,6 +183,32 @@ export function systemSummaryState(input: SummaryStateInput): SummaryState {
}
}
const required = [
"proxyfier",
...(input.routeMode === "local-singbox" ? ["singbox"] : []),
];
const records = required.map((component) =>
input.artifacts?.find((record) => record.component === component),
);
if (records.some((record) => !record?.sourceMatchesPrepared))
return {
tone: "warning",
title: "Настройки не подготовлены",
text: "Сохранённые настройки ещё не подтверждены сгенерированными конфигами. Нажмите «Применить» в настройках.",
};
if (records.some((record) => record?.activation === "restart-required"))
return {
tone: "warning",
title: "Нужен перезапуск",
text: "Служба запущена с другой конфигурацией. Явно остановите и запустите её после применения настроек.",
};
if (records.some((record) => record?.activation !== "confirmed"))
return {
tone: "warning",
title: "Запуск не подтверждён",
text: "Службы работают, но конфигурация этого запуска не подтверждена. Проверка доступности прокси не проверяет маршрутизацию приложений.",
};
if (input.proxyCheck && !input.proxyCheck.ok) {
return {
tone: "warning",
@@ -190,8 +219,8 @@ export function systemSummaryState(input: SummaryStateInput): SummaryState {
return {
tone: "ok",
title: "Работает",
text: "Сохраненная конфигурация выглядит готовой к маршрутизации выбранных приложений.",
title: "Конфиг передан службам",
text: "Подтверждён запуск с подготовленными файлами. Фактическая маршрутизация приложений требует отдельной проверки.",
};
}
@@ -697,13 +726,14 @@ export function itemsForProfiles(profiles: Profile[]): DraftItem[] {
)
continue;
const key = `${item.type}:${item.value.trim().toLowerCase()}`;
const key = `${item.type}:${item.value.trim().toLowerCase()}:${item.recursive}`;
if (seen.has(key)) continue;
seen.add(key);
items.push({
id: `${item.type}-${items.length}-${item.value}`,
type: item.type,
value: item.value,
recursive: item.recursive,
});
}
}
@@ -713,15 +743,15 @@ export function itemsForProfiles(profiles: Profile[]): DraftItem[] {
export function formatProxy(target: Target) {
return target.protocol === "socks5"
? `${target.host}:${target.port}`
: `${target.protocol}://${target.host}:${target.port}`;
? formatHostPort(target.host, target.port)
: `${target.protocol}://${formatHostPort(target.host, target.port)}`;
}
export function profileItemInput(item: DraftItem): ProfileItemInput {
return {
type: item.type,
value: item.value,
recursive: item.type === "folder",
recursive: item.recursive ?? item.type === "folder",
};
}
@@ -731,22 +761,6 @@ export function emptyItemMessage(type: DraftItemType) {
return "Введи путь к EXE-файлу.";
}
export function localSetupProgress(
operation: "install" | "uninstall",
activeStep: string,
percent: number,
message: string,
): ProxiFyreSetupProgress {
return {
operation,
status: "running",
activeStep,
percent,
message,
updatedAt: new Date().toISOString(),
};
}
export function setupItemShortStatus(
item: ProxiFyreSetupStatus["items"][number],
) {
+122
View File
@@ -3,10 +3,24 @@ 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 ManagedPackageComponentId = "proxifyre" | "sing-box";
export type ComponentState =
"installed" | "missing" | "stopped" | "running" | "error";
export type ActivityLevel = "info" | "warning" | "error" | "success";
export const packageComponentIdByComponentId = {
proxyfier: "proxifyre",
singbox: "sing-box",
} as const satisfies Partial<Record<ComponentId, ManagedPackageComponentId>>;
export function packageComponentIdFor(
componentId: ComponentId,
): ManagedPackageComponentId | null {
return componentId === "control-app"
? null
: packageComponentIdByComponentId[componentId];
}
export interface ProfileItemInput {
type: ProfileItemType | string;
value: string;
@@ -71,6 +85,114 @@ export interface ComponentStatus {
actions: string[];
}
export interface StorageMigrationStatus {
storageSchemaVersion: number;
componentLayoutVersion: number | null;
outcome: string;
changed: boolean;
blocking: boolean;
noticeCode: string | null;
message: string;
}
export type ComponentUpdateFreshness = "never_checked" | "fresh" | "stale";
export type ComponentUpdateState =
| "current"
| "update_available"
| "check_stale"
| "unknown_offline"
| "unsupported";
export type ComponentInstallSource = "bundled" | "cache" | "external" | "none";
export type ComponentPackageSource = "bundled" | "cache";
export type ComponentUpdateTrust =
| "trusted"
| "missing_independent_digest"
| "malformed_independent_digest"
| "unsupported";
export interface ComponentPackageStatus {
componentId: ManagedPackageComponentId;
installedVersion: string | null;
bundledVersion: string;
availableOfflineVersion: string;
latestKnownVersion: string | null;
lastCheckedAt: number | null;
freshness: ComponentUpdateFreshness;
updateState: ComponentUpdateState;
installSource: ComponentInstallSource;
offlinePackageSource: ComponentPackageSource;
canInstallOffline: boolean;
offlineUnavailableReason: string | null;
canDownload: boolean;
}
export interface ComponentUpdateCheckResponse {
trust: ComponentUpdateTrust;
updateAvailable: boolean;
status: ComponentPackageStatus;
}
export interface ComponentUpdateDownloadResponse {
downloadedVersion: string;
source: ComponentPackageSource;
status: ComponentPackageStatus;
}
export interface ComponentUpdateResponse {
component: ComponentStatus;
package: ComponentPackageStatus;
changed: boolean;
rebootRequired: boolean;
}
export interface ComponentLifecycleResponse {
component: ComponentStatus;
changed: boolean;
rebootRequired: boolean;
}
export type ComponentCutoverState =
| "not_needed"
| "ready"
| "manual_migration_required"
| "in_progress"
| "awaiting_next_start"
| "awaiting_route_smoke"
| "cleanup_ready"
| "cleanup_pending"
| "complete"
| "rolled_back"
| "recovery_required"
| "blocked";
export type ComponentCutoverMode = "service_switch" | "manual_only";
export type ComponentCutoverServiceState = "running" | "stopped";
export interface ComponentCutoverStatus {
componentId: ManagedPackageComponentId;
state: ComponentCutoverState;
mode: ComponentCutoverMode;
legacyVersion: string | null;
currentVersion: string | null;
bundledVersion: string | null;
originalServiceState: ComponentCutoverServiceState | null;
legacyPathLabel: string | null;
currentPathLabel: string | null;
steps: string[];
nextStartVerified: boolean;
routeSmokeConfirmed: boolean;
canCutover: boolean;
canConfirmRouteSmoke: boolean;
canCleanup: boolean;
disabledCode: string | null;
disabledMessage: string | null;
}
export interface ComponentCutoverResponse {
status: ComponentCutoverStatus;
changed: boolean;
rebootRequired: boolean;
}
export interface LocalSingBoxConfig {
subscriptionDisplayUrl?: string;
hasSubscription: boolean;
+1
View File
@@ -3,6 +3,7 @@ import { createRoot } from "react-dom/client";
import "@fontsource-variable/jetbrains-mono";
import { App } from "./app/App";
import "./styles/app.css";
import "./styles/proxifyre.css";
createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>
+393 -198
View File
@@ -1342,6 +1342,47 @@ button:disabled {
padding: 0 18px clamp(34px, 10vh, 92px);
}
.summary-state-copy {
grid-column: 1 / -1;
margin: 0;
color: var(--text-secondary);
font-size: 12px;
line-height: 1.5;
text-align: center;
}
.summary-main:has(.summary-state-copy) {
grid-template-rows: minmax(0, 1fr) auto;
row-gap: 12px;
padding-bottom: 24px;
}
.simple-panel:has(.startup-error) {
grid-template-rows: auto minmax(0, 1fr);
gap: 12px;
}
.startup-error {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 10px 14px;
border: 1px solid var(--border-strong);
border-radius: 8px;
background: var(--surface-raised);
color: var(--text-secondary);
font-size: 12px;
line-height: 1.5;
}
.startup-error p {
margin: 0;
}
.startup-error button {
flex-shrink: 0;
}
.summary-status-control {
position: relative;
isolation: isolate;
@@ -1812,130 +1853,6 @@ button.summary-card:hover {
box-shadow: 0 0 0 4px rgba(96, 165, 250, 0.14);
}
.setup-strip {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 10px;
align-items: center;
min-height: 42px;
border: 0;
border-radius: 8px;
background: var(--surface-panel);
padding: 7px 10px;
}
.setup-strip.with-progress {
row-gap: 8px;
}
.setup-strip-title {
color: #8d99ae;
font-size: 12px;
font-weight: 800;
}
.setup-strip-items {
display: flex;
gap: 7px;
min-width: 0;
overflow-x: auto;
scrollbar-width: thin;
}
.setup-strip-item {
display: inline-flex;
flex: 0 0 auto;
gap: 7px;
align-items: center;
min-height: 28px;
border: 1px solid #2b3342;
border-radius: 999px;
background: #151b25;
color: #dbeafe;
padding: 5px 9px;
}
.setup-strip-dot {
width: 8px;
height: 8px;
border-radius: 999px;
background: #f59e0b;
box-shadow: 0 0 0 3px rgba(245, 158, 11, 0.11);
}
.setup-strip-item.installed .setup-strip-dot {
background: #22c55e;
box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.11);
}
.setup-strip-item.active {
border-color: #60a5fa;
background: #122033;
}
.setup-strip-item.active .setup-strip-dot {
background: #60a5fa;
box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.14);
}
.setup-strip-item.failed {
border-color: #ef4444;
background: #26151a;
}
.setup-strip-item.failed .setup-strip-dot {
background: #ef4444;
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.12);
}
.setup-strip-item strong {
font-size: 12px;
white-space: nowrap;
}
.setup-strip-item span:not(.setup-strip-dot) {
color: #9aa8bd;
font-size: 12px;
white-space: nowrap;
}
.setup-progress {
display: grid;
grid-column: 1 / -1;
grid-template-columns: minmax(96px, 160px) minmax(0, 1fr);
gap: 9px;
align-items: center;
}
.setup-progress-track {
overflow: hidden;
height: 6px;
border-radius: 999px;
background: #202a38;
}
.setup-progress-fill {
display: block;
width: 0;
height: 100%;
border-radius: inherit;
background: #60a5fa;
transition: width 180ms ease;
}
.setup-progress--failed .setup-progress-fill {
background: #ef4444;
}
.setup-progress-message {
min-width: 0;
overflow: hidden;
color: #9aa8bd;
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.service-actions {
position: relative;
display: flex;
@@ -2597,9 +2514,8 @@ button.summary-card:hover {
}
.changes-actions .ui-button-label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
white-space: normal;
overflow-wrap: anywhere;
}
.section-head span {
@@ -3031,12 +2947,18 @@ button.summary-card:hover {
.log-history {
position: absolute;
right: 0;
bottom: 100%;
bottom: calc(100% + var(--app-change-dock-height));
left: 0;
display: grid;
grid-column: 1 / -1;
gap: 6px;
max-height: 230px;
max-height: min(
230px,
calc(
100vh - var(--app-header-height) - var(--app-footer-height) -
var(--app-change-dock-height) - 12px
)
);
overflow: auto;
border: 1px solid #2b3342;
border-bottom: 0;
@@ -3395,8 +3317,6 @@ button.summary-card:hover {
.finder-card,
.ui-service-row,
.connection-check,
.setup-strip,
.setup-strip-item,
.external-proxy-card,
.singbox-workspace,
.subscription-icon,
@@ -3454,7 +3374,6 @@ button.summary-card:hover {
.finder-card,
.ui-service-row,
.connection-check,
.setup-strip,
.external-proxy-card,
.apps-section,
.app-add-skeleton,
@@ -3586,7 +3505,6 @@ button.summary-card:hover {
.ui-service-dot,
.status-light,
.setup-strip-dot,
.route-chain-dot,
.server-select-dot {
border: 0;
@@ -3605,58 +3523,6 @@ button.summary-card:hover {
transform: scale(1.22);
}
.setup-strip {
padding: 8px 2px;
}
.setup-strip-items {
gap: 12px;
}
.setup-strip-item {
position: relative;
gap: 6px;
min-height: 28px;
border-radius: 0;
background: transparent;
padding: 4px 2px;
animation: float-item-in 520ms var(--ease-out) both;
transition:
color 600ms var(--ease-out),
filter 600ms var(--ease-out),
opacity 240ms var(--ease-out),
transform 240ms var(--ease-out);
}
.setup-strip-item:nth-child(2) {
animation-delay: 70ms;
}
.setup-strip-item:nth-child(3) {
animation-delay: 140ms;
}
.setup-strip-item:hover {
filter: drop-shadow(0 0 12px oklch(0.68 0.11 185 / 0.12));
transform: translateY(-1px);
}
.setup-strip-item.active,
.setup-strip-item.failed {
border: 0;
background: transparent;
}
.setup-progress-track {
background: oklch(0.68 0.11 185 / 0.08);
}
.setup-progress-fill {
background: var(--accent);
box-shadow: 0 0 16px oklch(0.68 0.11 185 / 0.42);
transition: width 900ms var(--ease-out);
}
.connection-check {
display: grid;
gap: 0;
@@ -4562,7 +4428,7 @@ button.summary-card:hover {
}
.log-history {
background: color-mix(in oklch, var(--surface-raised) 86%, transparent);
background: var(--surface-raised);
box-shadow: 0 -18px 48px oklch(0.08 0.012 145 / 0.3);
backdrop-filter: blur(18px);
animation: dock-arrive 240ms var(--ease-out);
@@ -4577,6 +4443,58 @@ button.summary-card:hover {
transform 240ms var(--ease-out);
}
.log-history-row > div {
min-width: 0;
}
.profile-toggle.ui-button {
min-width: 180px;
min-height: 40px;
padding: 6px 10px;
color: var(--text-secondary);
background: transparent;
}
.profile-toggle .ui-button-label {
display: inline-flex;
align-items: center;
gap: 9px;
font-size: 12px;
}
.profile-toggle-track {
position: relative;
display: inline-block;
flex: 0 0 28px;
height: 16px;
border-radius: 8px;
background: color-mix(
in oklch,
var(--text-secondary) 25%,
var(--surface-raised)
);
}
.profile-toggle-track::after {
content: "";
position: absolute;
top: 3px;
left: 3px;
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--text-secondary);
}
.profile-toggle[aria-checked="true"] .profile-toggle-track {
background: oklch(0.68 0.11 185 / 0.25);
}
.profile-toggle[aria-checked="true"] .profile-toggle-track::after {
transform: translateX(12px);
background: oklch(0.78 0.065 185);
}
.log-history-row:hover {
background: color-mix(in oklch, var(--surface-panel) 58%, transparent);
transform: translateX(2px);
@@ -4904,7 +4822,6 @@ button.summary-card:hover {
.ui-tab:hover,
.ui-service-row:hover,
.finder-card:hover,
.setup-strip-item:hover,
.connection-probe-row:hover,
.connection-result:hover,
.connection-result:focus-visible,
@@ -5096,11 +5013,6 @@ button.summary-card:hover {
width: min(202px, 64vw);
}
.setup-strip {
grid-template-columns: 1fr;
align-items: stretch;
}
.apps-header {
grid-template-columns: 1fr;
min-height: 0;
@@ -5242,11 +5154,6 @@ button.summary-card:hover {
grid-column: 1 / -1;
}
.setup-strip-items {
flex-wrap: wrap;
overflow: visible;
}
.service-button {
flex: 1;
}
@@ -5320,7 +5227,6 @@ button.summary-card:hover {
}
.log-history {
max-height: 220px;
padding: 7px 10px;
}
@@ -5398,3 +5304,292 @@ button.summary-card:hover {
width: 100%;
}
}
/* Local component packages and explicit legacy cutover. */
.component-management-stack {
display: grid;
gap: 10px;
min-width: 0;
}
.component-package-status,
.migration-notice {
min-width: 0;
border-radius: 14px;
background: color-mix(in oklch, var(--surface-panel) 44%, transparent);
padding: 14px;
}
.component-package-status {
display: grid;
gap: 10px;
min-block-size: 340px;
}
.component-package-head,
.migration-notice-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 14px;
min-width: 0;
}
.component-package-head > div,
.migration-notice-head > div {
min-width: 0;
}
.component-package-head > div > span,
.migration-notice-head > div > span,
.component-package-versions dt {
color: var(--text-muted);
font-size: 10px;
font-weight: 750;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.component-package-head h3,
.migration-notice-head h3 {
margin: 3px 0 0;
color: var(--text-primary);
font-size: 15px;
line-height: 1.3;
overflow-wrap: anywhere;
}
.component-package-summary,
.migration-notice-detail,
.migration-storage-result {
color: var(--text-secondary);
font-size: 12px;
line-height: 1.5;
margin: 0;
overflow-wrap: anywhere;
}
.component-package-summary {
block-size: 44px;
overflow: auto;
}
.component-package-versions {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
margin: 0;
}
.component-package-versions > div {
display: grid;
align-content: start;
gap: 4px;
min-width: 0;
block-size: 64px;
border-radius: 10px;
background: color-mix(in oklch, var(--surface-inset) 48%, transparent);
padding: 8px 9px;
}
.component-package-versions dd {
display: -webkit-box;
min-width: 0;
min-height: 32px;
max-height: 32px;
color: var(--text-primary);
font-size: 12px;
font-variant-numeric: tabular-nums;
line-height: 1.35;
margin: 0;
overflow: hidden;
overflow-wrap: anywhere;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.component-package-actions {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.component-package-actions .ui-button {
width: 100%;
min-width: 0;
min-height: 40px;
max-height: 40px;
white-space: normal;
}
.component-package-reasons,
.component-package-feedback,
.migration-disabled-reason,
.migration-notice-feedback {
min-width: 0;
color: var(--text-muted);
font-size: 10px;
line-height: 1.45;
overflow-wrap: anywhere;
}
.component-package-reasons,
.migration-disabled-reason {
min-height: 36px;
max-height: 36px;
overflow: auto;
margin: 0;
}
.component-package-feedback,
.migration-notice-feedback {
min-height: 36px;
max-height: 36px;
overflow: auto;
}
.component-package-feedback p,
.migration-notice-feedback p {
margin: 0;
}
.component-package-feedback .neutral,
.migration-notice-feedback .neutral {
color: var(--text-secondary);
}
.component-package-feedback .error,
.migration-notice-feedback .error {
color: oklch(0.78 0.1 28);
}
.migration-notice {
--migration-tone: var(--text-muted);
position: relative;
isolation: isolate;
display: grid;
gap: 10px;
overflow: hidden;
}
.migration-notice::before {
position: absolute;
top: 14px;
bottom: 14px;
left: 0;
width: 2px;
border-radius: 999px;
background: var(--migration-tone);
box-shadow: 0 0 14px
color-mix(in oklch, var(--migration-tone) 42%, transparent);
content: "";
}
.migration-notice--ok {
--migration-tone: oklch(0.76 0.12 155);
}
.migration-notice--warning {
--migration-tone: oklch(0.8 0.12 82);
}
.migration-notice--error {
--migration-tone: oklch(0.78 0.1 28);
}
.migration-notice--checking {
--migration-tone: oklch(0.8 0.09 215);
}
.migration-storage-result {
min-height: 18px;
color: var(--text-muted);
}
.migration-action-slot {
display: flex;
align-items: center;
justify-content: flex-end;
min-height: 36px;
}
.migration-action-slot .ui-button {
min-width: min(100%, 236px);
}
.migration-cleanup-action {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 14px;
align-items: center;
min-height: 66px;
border: 1px solid oklch(0.62 0.16 28 / 0.24);
border-radius: 10px;
background: oklch(0.62 0.16 28 / 0.06);
padding: 10px 11px;
}
.migration-cleanup-action > div {
display: grid;
gap: 3px;
min-width: 0;
}
.migration-cleanup-action strong {
color: oklch(0.84 0.09 28);
font-size: 11px;
}
.migration-cleanup-action span {
color: var(--text-secondary);
font-size: 10px;
line-height: 1.4;
overflow-wrap: anywhere;
}
.migration-cleanup-action .ui-button {
min-width: 184px;
}
.migration-cleanup-action > .migration-cleanup-buttons {
display: grid;
gap: 6px;
}
.summary-toggle-button.summary-toggle-indicator {
cursor: default;
}
.summary-toggle-button.summary-toggle-indicator:hover:not(:disabled),
.summary-toggle-button.summary-toggle-indicator:active:not(:disabled) {
filter: none;
transform: none;
}
@media (max-width: 680px) {
.component-package-versions {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.component-package-actions {
grid-template-columns: 1fr;
}
.migration-cleanup-action {
grid-template-columns: 1fr;
}
.migration-cleanup-action .ui-button,
.migration-action-slot .ui-button {
width: 100%;
min-width: 0;
}
}
@media (max-width: 480px) {
.component-package-head,
.migration-notice-head {
align-items: flex-start;
flex-direction: column;
}
}
+334
View File
@@ -0,0 +1,334 @@
/* ProxiFyre: a compact, readable application list. */
#panel-proxifyre {
gap: 18px;
padding-block: 8px 24px;
}
.proxifyre-card {
grid-template-columns: auto minmax(0, 1fr) auto;
min-height: 48px;
border-radius: 12px;
background: color-mix(in oklch, var(--surface-panel) 32%, transparent);
padding: 8px 10px;
}
.proxifyre-card .ui-service-dot {
width: 8px;
height: 8px;
}
.proxifyre-card .ui-service-title-line > strong {
font-size: 13px;
font-weight: 720;
}
.proxifyre-card .ui-service-text > span {
color: var(--text-muted);
font-size: 10px;
line-height: 1.4;
}
.proxifyre-card .ui-service-actions > .ui-button {
min-height: 32px;
padding: 6px 10px;
font-size: 11px;
}
.apps-section {
margin-top: 0;
padding: 0 4px;
}
.application-stage {
width: min(100%, 720px);
margin-inline: auto;
}
.application-panel {
position: relative;
z-index: 2;
display: grid;
gap: 8px;
width: min(100%, 720px);
margin-inline: auto;
border-radius: 14px;
background: var(--surface-panel);
box-shadow: 0 18px 48px oklch(0.08 0.012 145 / 0.14);
padding: 10px 12px 12px;
}
.application-list-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 42px;
padding: 0 4px 2px 8px;
}
.application-list-heading {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
min-width: 0;
}
.application-list-heading strong {
color: var(--text-secondary);
font-size: 12px;
font-weight: 720;
}
.application-list-heading > span {
color: var(--text-muted);
font-size: 10px;
font-variant-numeric: tabular-nums;
}
.application-add-actions {
display: flex;
align-items: center;
gap: 4px;
}
.application-add-actions .ui-icon-button {
width: 40px;
min-width: 40px;
min-height: 40px;
border: 0;
border-radius: 50%;
background: transparent;
color: var(--text-muted);
}
.application-add-actions .ui-icon-button:hover:not(:disabled) {
background: var(--accent-soft);
color: oklch(0.82 0.08 185);
}
.application-process-anchor {
display: flex;
}
.application-process-popover {
position: fixed;
z-index: 95;
display: grid;
gap: 9px;
border-radius: 10px;
background: var(--surface-raised);
box-shadow: 0 16px 42px oklch(0.08 0.012 145 / 0.42);
padding: 11px;
transform-origin: top center;
animation: process-popover-in 160ms var(--ease-out);
}
.application-process-popover[data-placement="top"] {
transform-origin: bottom center;
}
.application-process-popover label {
color: var(--text-secondary);
font-size: 10px;
font-weight: 720;
}
.application-process-popover input {
min-width: 0;
min-height: 36px;
border: 0;
border-radius: 8px 8px 3px 3px;
background: color-mix(in oklch, var(--surface-inset) 82%, transparent);
box-shadow: inset 0 -1px var(--border-strong);
color: var(--text-primary);
outline: 0;
padding: 7px 10px;
transition:
background-color 240ms var(--ease-out),
box-shadow 600ms var(--ease-out);
}
.application-process-popover input:focus {
box-shadow:
inset 0 -1px var(--accent),
0 9px 24px oklch(0.68 0.11 185 / 0.08);
}
.application-process-popover-actions {
display: flex;
justify-content: flex-end;
gap: 6px;
}
@keyframes process-popover-in {
from {
opacity: 0;
transform: translateY(-4px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.application-list {
display: grid;
gap: 2px;
min-width: 0;
}
.application-list-row {
position: relative;
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 10px;
min-height: 50px;
border-radius: 10px;
padding: 5px 4px 5px 8px;
transition:
background-color 300ms var(--ease-out),
transform 300ms var(--ease-out);
}
.application-list-row::before {
position: absolute;
top: 12px;
bottom: 12px;
left: 0;
width: 2px;
border-radius: 999px;
background: var(--text-muted);
content: "";
opacity: 0;
transform: scaleY(0.35);
transition:
opacity 200ms var(--ease-out),
transform 260ms var(--ease-out);
}
.application-list-row:hover {
background: color-mix(in oklch, var(--surface-raised) 28%, transparent);
transform: translateX(3px);
}
.application-list-row:hover::before {
opacity: 0.58;
transform: scaleY(1);
}
.application-list-row-icon {
display: grid;
place-items: center;
width: 30px;
height: 30px;
color: var(--text-muted);
transition: color 420ms var(--ease-out);
}
.application-list-row:hover .application-list-row-icon {
color: var(--text-primary);
}
.application-list-row-copy {
display: grid;
gap: 2px;
min-width: 0;
}
.application-list-row-copy strong,
.application-list-row-copy span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.application-list-row-copy strong {
color: var(--text-secondary);
font-size: 11px;
font-weight: 680;
}
.application-list-row-copy span {
color: var(--text-muted);
font-size: 9px;
}
.application-list-row .ui-icon-button {
width: 40px;
min-width: 40px;
min-height: 40px;
border: 0;
border-radius: 50%;
background: transparent;
color: var(--text-muted);
opacity: 0.54;
}
.application-list-row:hover .ui-icon-button,
.application-list-row .ui-icon-button:focus-visible {
opacity: 1;
}
.application-list-row .ui-icon-button:hover:not(:disabled) {
background: oklch(0.58 0.13 28 / 0.12);
color: oklch(0.8 0.08 28);
}
.application-empty-state {
min-height: 78px;
display: grid;
place-items: center;
color: var(--text-muted);
font-size: 10px;
text-align: center;
}
@media (max-width: 680px) {
#panel-proxifyre {
gap: 14px;
}
.proxifyre-card {
grid-template-columns: auto minmax(0, 1fr) auto;
}
.proxifyre-card .ui-service-actions {
grid-column: auto;
display: flex;
width: auto;
}
.proxifyre-card .ui-service-actions > .ui-button {
width: auto;
}
}
@media (max-width: 430px) {
.proxifyre-card {
grid-template-columns: auto minmax(0, 1fr);
}
.proxifyre-card .ui-service-actions {
grid-column: 1 / -1;
justify-content: flex-end;
}
}
@media (prefers-reduced-motion: reduce) {
.application-process-popover,
.application-list-row,
.application-list-row::before,
.application-list-row-icon {
animation: none;
transition: none;
}
.application-list-row:hover {
transform: none;
}
}