+95
-30
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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("Маршрут проверен");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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: "Включён",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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 },
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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],
|
||||
) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user