Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ tauri-build = "2"
tauri-plugin-dialog = "2.7.1"
tempfile = "3"
thiserror = "2"
unicode-casefold = "0.2"
unicode-normalization = "0.1"
ureq = "2.12"
webp = "0.3.1"
windows = "0.61"
Expand Down
11 changes: 10 additions & 1 deletion apps/desktop/src-tauri/src/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,16 @@ pub(crate) fn with_store<T>(
.store
.lock()
.map_err(|_| api_error_message("store_lock_poisoned", "store lock poisoned"))?;
f(&store).map_err(api_error)
f(&store).map_err(store_api_error)
}

fn store_api_error(error: GuiError) -> ApiError {
let code = match &error {
GuiError::Profile(error) => error.code(),
GuiError::ProfileNotFound(_) => "profile_not_found",
_ => return api_error(error),
};
api_error_message(code, error)
}

pub(crate) fn portable_root() -> Result<PathBuf, std::io::Error> {
Expand Down
40 changes: 29 additions & 11 deletions apps/desktop/src/api/mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,19 +36,37 @@ let mockCaptureWinDivertBackendEnabled = false;
let mockWindDivertInstalled = false;
const mockDiagnosticSessions = new Map<string, { polls: number; stopped: boolean; duration: number; mode: DiagnosticMode }>();

function validateMockProfileName(name: string) {
const profileName = name.trim();
const upper = profileName.toUpperCase();
const reserved = ["CON", "PRN", "AUX", "NUL"].includes(upper) || /^(COM|LPT)[1-9]$/.test(upper);
if (!profileName || profileName.length > 40) throw new Error("profile name length must be 1..40");
if (!/^[A-Za-z0-9_-]+$/.test(profileName)) throw new Error("profile name must use ASCII letters, digits, _ or -");
if (reserved) throw new Error("profile name must not use a reserved Windows device name");
if (mockProfiles.some((profile) => profile.name.toLowerCase() === profileName.toLowerCase())) {
throw new Error(`profile already exists: ${profileName}`);
function validateMockProfileName(name: string, exceptName?: string) {
const profileName = name.trim().normalize("NFC");
const stem = profileName.split(".", 1)[0].toUpperCase();
const reserved = ["CON", "PRN", "AUX", "NUL", "CLOCK$", "CONIN$", "CONOUT$"].includes(stem)
|| /^(COM|LPT)([1-9]|[¹²³])$/.test(stem);
if (!profileName) throw mockProfileError("profile_name_empty", "profile name is required");
if (profileName.length > 255) {
throw mockProfileError("profile_name_too_long", "profile name exceeds the Windows filename limit");
}
if (
profileName === "."
|| profileName === ".."
|| profileName.endsWith(" ")
|| profileName.endsWith(".")
|| /[<>:"/\\|?*\u0000-\u001f\u007f-\u009f\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/u.test(profileName)
) {
throw mockProfileError("profile_name_unsafe", "profile name contains a path-unsafe character");
}
if (reserved) {
throw mockProfileError("profile_name_reserved", "profile name uses a reserved Windows device name");
}
const key = profileName.toUpperCase();
if (mockProfiles.some((profile) => profile.name !== exceptName && profile.name.normalize("NFC").toUpperCase() === key)) {
throw mockProfileError("profile_already_exists", `profile already exists: ${profileName}`);
}
return profileName;
}

function mockProfileError(code: string, message: string) {
return { code, message };
}

function mockSettings() {
return {
Expand Down Expand Up @@ -116,9 +134,9 @@ export const mockApi: AppApi = {
const profile = mockProfiles.find((item) => item.name === oldName);
if (!profile) throw new Error(`profile not found: ${oldName}`);
if (oldName === newName) return { ...profile, active: profile.name === mockActiveProfileName };
profile.name = validateMockProfileName(newName);
profile.name = validateMockProfileName(newName, oldName);
profile.updated_at = "0";
if (mockActiveProfileName === oldName) mockActiveProfileName = newName;
if (mockActiveProfileName === oldName) mockActiveProfileName = profile.name;
return { ...profile, active: profile.name === mockActiveProfileName };
},
async deleteProfile(profileName: string) {
Expand Down
8 changes: 6 additions & 2 deletions apps/desktop/src/app/dataOperations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { Ref } from "vue";
import { api, type BackupReport, type ImportReport, type RestoreReport, type Settings } from "../api";
import type { I18nKey } from "./i18n";
import type { ExportMode, ImportMode } from "./options";
import { profileDefaultFilename } from "./profileNames";

export type DataOperationKind = "import" | "export" | "backup" | "restore";

Expand Down Expand Up @@ -62,7 +63,10 @@ export function createDataOperations(deps: DataOperationDeps) {
deps.exportMode.value = mode;
const selected = await save({
title: mode === "json" ? deps.t("import.publicJson") : "CSV",
defaultPath: mode === "json" ? `${deps.activeProfileName.value}-history.json` : `${deps.activeProfileName.value}-history.csv`,
defaultPath: profileDefaultFilename(
deps.activeProfileName.value,
mode === "json" ? "-history.json" : "-history.csv",
),
filters:
mode === "json"
? [{ name: "Public JSON", extensions: ["json"] }]
Expand Down Expand Up @@ -90,7 +94,7 @@ export function createDataOperations(deps: DataOperationDeps) {
async function pickBackupFile() {
const selected = await save({
title: deps.t("import.createBackup"),
defaultPath: `${deps.activeProfileName.value}-nte-data-backup.zip`,
defaultPath: profileDefaultFilename(deps.activeProfileName.value, "-nte-data-backup.zip"),
filters: [{ name: "Backup zip", extensions: ["zip"] }],
});
if (typeof selected === "string") {
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/app/formatters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ export function createAppFormatters(t: Translator) {
forkWinRate,
captureRecordName,
captureRecordMeta,
formatError,
formatError: (error: unknown) => formatError(error, t),
formatCaptureState: (value?: string | null) => formatCaptureState(value, t),
formatCaptureMode: (value?: string | null) => formatCaptureMode(value, t),
};
Expand Down
24 changes: 24 additions & 0 deletions apps/desktop/src/app/profileNames.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
const WINDOWS_FILENAME_UTF16_LIMIT = 255;

export type ProfileAgentAction = "row" | "select" | "menu" | "rename" | "delete" | "delete-confirm";

export function profileAgentId(action: ProfileAgentAction, name: string) {
return `profile-${action}-${encodeURIComponent(name.normalize("NFC"))}`;
}

export function profileDefaultFilename(name: string, suffix: string) {
const normalized = name.normalize("NFC").trim() || "profile";
const availableUnits = Math.max(1, WINDOWS_FILENAME_UTF16_LIMIT - suffix.length);
return `${truncateUtf16(normalized, availableUnits)}${suffix}`;
}

function truncateUtf16(value: string, maxUnits: number) {
let result = "";
let units = 0;
for (const character of value) {
if (units + character.length > maxUnits) break;
result += character;
units += character.length;
}
return result;
}
18 changes: 17 additions & 1 deletion apps/desktop/src/app/viewHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,9 +191,25 @@ export function captureRecordMeta(record: Record<string, unknown>) {
return String(record.pool_name ?? record.pool_id ?? record.record_type ?? "-");
}

export function formatError(error: unknown) {
const errorTranslationKeys: Partial<Record<string, I18nKey>> = {
profile_name_empty: "error.profileNameEmpty",
profile_name_too_long: "error.profileNameTooLong",
profile_name_unsafe: "error.profileNameUnsafe",
profile_name_reserved: "error.profileNameReserved",
profile_already_exists: "error.profileAlreadyExists",
profile_last_required: "error.profileLastRequired",
profile_directory_unsupported: "error.profileDirectoryUnsupported",
profile_storage_denied: "error.profileStorageDenied",
profile_name_unsupported: "error.profileNameUnsupported",
profile_storage_failed: "error.profileStorageFailed",
profile_not_found: "error.profileNotFound",
};

export function formatError(error: unknown, t?: Translator) {
if (typeof error === "object" && error !== null && "message" in error) {
const apiError = error as { code?: string; message?: string };
const translationKey = apiError.code ? errorTranslationKeys[apiError.code] : undefined;
if (translationKey && t) return t(translationKey);
return apiError.code ? `${apiError.code}: ${apiError.message ?? ""}` : (apiError.message ?? String(error));
}
return error instanceof Error ? error.message : String(error);
Expand Down
11 changes: 11 additions & 0 deletions apps/desktop/src/assets/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,17 @@
"profile.rename": "Rename profile",
"profile.renameCancel": "Cancel rename",
"profile.renameSave": "Save profile name",
"error.profileNameEmpty": "Enter a profile name.",
"error.profileNameTooLong": "This profile name is too long for Windows.",
"error.profileNameUnsafe": "This profile name contains a character that cannot be used in a Windows folder.",
"error.profileNameReserved": "This profile name is reserved by Windows.",
"error.profileAlreadyExists": "A profile with this name already exists.",
"error.profileLastRequired": "At least one profile is required.",
"error.profileDirectoryUnsupported": "This profile folder contains unsupported files and cannot be changed.",
"error.profileStorageDenied": "Windows denied access to the profile folder.",
"error.profileNameUnsupported": "Windows cannot use this profile name.",
"error.profileStorageFailed": "The profile folder operation failed.",
"error.profileNotFound": "The profile could not be found.",
"status.backupCreated": "Backup created",
"status.backupRestored": "Backup restored",
"status.captureCompleted": "Live capture completed",
Expand Down
11 changes: 11 additions & 0 deletions apps/desktop/src/assets/i18n/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,17 @@
"profile.rename": "重命名个人文件",
"profile.renameCancel": "取消重命名",
"profile.renameSave": "保存个人文件名称",
"error.profileNameEmpty": "请输入个人文件名称。",
"error.profileNameTooLong": "该名称超过 Windows 文件名长度限制。",
"error.profileNameUnsafe": "该名称包含无法用于 Windows 文件夹的字符。",
"error.profileNameReserved": "该名称是 Windows 保留名称。",
"error.profileAlreadyExists": "已存在同名个人文件。",
"error.profileLastRequired": "至少需要保留一个个人文件。",
"error.profileDirectoryUnsupported": "该个人文件夹包含不受支持的文件,无法修改。",
"error.profileStorageDenied": "Windows 拒绝访问个人文件夹。",
"error.profileNameUnsupported": "Windows 无法使用该个人文件名称。",
"error.profileStorageFailed": "个人文件夹操作失败。",
"error.profileNotFound": "找不到该个人文件。",
"status.backupCreated": "备份已创建",
"status.backupRestored": "备份已还原",
"status.captureCompleted": "即时撷取已完成",
Expand Down
11 changes: 11 additions & 0 deletions apps/desktop/src/assets/i18n/zh-Hant.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,17 @@
"profile.rename": "重新命名個人檔案",
"profile.renameCancel": "取消重新命名",
"profile.renameSave": "儲存個人檔案名稱",
"error.profileNameEmpty": "請輸入個人檔案名稱。",
"error.profileNameTooLong": "此名稱超過 Windows 檔案名稱長度限制。",
"error.profileNameUnsafe": "此名稱包含無法用於 Windows 資料夾的字元。",
"error.profileNameReserved": "此名稱是 Windows 保留名稱。",
"error.profileAlreadyExists": "已存在同名個人檔案。",
"error.profileLastRequired": "至少需要保留一個個人檔案。",
"error.profileDirectoryUnsupported": "此個人檔案資料夾包含不支援的檔案,無法修改。",
"error.profileStorageDenied": "Windows 拒絕存取個人檔案資料夾。",
"error.profileNameUnsupported": "Windows 無法使用此個人檔案名稱。",
"error.profileStorageFailed": "個人檔案資料夾操作失敗。",
"error.profileNotFound": "找不到此個人檔案。",
"status.backupCreated": "備份已建立",
"status.backupRestored": "備份已還原",
"status.captureCompleted": "即時擷取已完成",
Expand Down
15 changes: 8 additions & 7 deletions apps/desktop/src/components/AppSidebar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Circle, CircleCheck, MoreHorizontal, Pencil, Plus, Trash2 } from "lucid
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
import type { Profile } from "../api";
import { useAppContext } from "../app/context";
import { profileAgentId } from "../app/profileNames";
import ProfileDialog from "./ProfileDialog.vue";

type ProfileDialogMode = "create" | "rename" | "delete" | null;
Expand Down Expand Up @@ -121,7 +122,7 @@ async function closeDialog() {
return;
}
const activeRow = [...(profileListEl.value?.querySelectorAll<HTMLElement>(".sidebar-profile-row") ?? [])]
.find((row) => row.dataset.agentId === `profile-row-${app.activeProfileName}`);
.find((row) => row.dataset.agentId === profileAgentId("row", app.activeProfileName));
const activeSelect = activeRow?.querySelector<HTMLButtonElement>(".profile-select");
if (activeSelect) activeSelect.focus();
else createButtonEl.value?.focus();
Expand All @@ -135,7 +136,7 @@ async function selectProfile(profileName: string) {
async function scrollActiveProfileIntoView() {
await nextTick();
const activeRow = [...(profileListEl.value?.querySelectorAll<HTMLElement>(".sidebar-profile-row") ?? [])]
.find((row) => row.dataset.agentId === `profile-row-${app.activeProfileName}`);
.find((row) => row.dataset.agentId === profileAgentId("row", app.activeProfileName));
activeRow?.scrollIntoView({ block: "nearest", inline: "nearest" });
}

Expand Down Expand Up @@ -199,12 +200,12 @@ onBeforeUnmount(() => {
:key="profile.name"
class="sidebar-profile-row"
:class="{ active: profile.active }"
:data-agent-id="`profile-row-${profile.name}`"
:data-agent-id="profileAgentId('row', profile.name)"
>
<button
class="profile-select"
type="button"
:data-agent-id="`profile-select-${profile.name}`"
:data-agent-id="profileAgentId('select', profile.name)"
:disabled="app.isWorkflowBusy"
:aria-current="profile.active ? 'true' : undefined"
:aria-label="profileSelectionLabel(profile)"
Expand All @@ -218,7 +219,7 @@ onBeforeUnmount(() => {
<button
class="profile-more-button"
type="button"
:data-agent-id="`profile-menu-${profile.name}`"
:data-agent-id="profileAgentId('menu', profile.name)"
:disabled="app.isWorkflowBusy"
:title="app.t('profile.actionsFor', { name: profile.name })"
:aria-label="app.t('profile.actionsFor', { name: profile.name })"
Expand Down Expand Up @@ -263,7 +264,7 @@ onBeforeUnmount(() => {
<button
type="button"
role="menuitem"
:data-agent-id="`profile-rename-${menuProfile.name}`"
:data-agent-id="profileAgentId('rename', menuProfile.name)"
:disabled="app.isWorkflowBusy"
@click="openMenuDialog('rename')"
>
Expand All @@ -274,7 +275,7 @@ onBeforeUnmount(() => {
class="danger-item"
type="button"
role="menuitem"
:data-agent-id="`profile-delete-${menuProfile.name}`"
:data-agent-id="profileAgentId('delete', menuProfile.name)"
:disabled="app.isWorkflowBusy || app.profiles.length <= 1"
:title="app.profiles.length <= 1 ? app.t('profile.deleteLastDisabled') : app.t('profile.delete')"
@click="openMenuDialog('delete')"
Expand Down
6 changes: 3 additions & 3 deletions apps/desktop/src/components/ProfileDialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Check, Plus, Trash2, X } from "lucide-vue-next";
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
import type { Profile } from "../api";
import { useAppContext } from "../app/context";
import { profileAgentId } from "../app/profileNames";

type ProfileDialogMode = "create" | "rename" | "delete" | null;

Expand Down Expand Up @@ -130,8 +131,7 @@ onBeforeUnmount(() => document.removeEventListener("keydown", onDocumentKeydown)
:data-agent-id="mode === 'create' ? 'profile-create-input' : 'profile-rename-input'"
name="profile-name"
required
maxlength="40"
pattern="[A-Za-z0-9_\-]+"
maxlength="255"
placeholder="new_profile"
autocomplete="off"
:disabled="app.isWorkflowBusy"
Expand All @@ -151,7 +151,7 @@ onBeforeUnmount(() => document.removeEventListener("keydown", onDocumentKeydown)
<button
type="submit"
:class="{ primary: mode !== 'delete', danger: mode === 'delete' }"
:data-agent-id="mode === 'create' ? 'profile-create-submit' : mode === 'rename' ? 'profile-rename-save' : `profile-delete-confirm-${profile?.name ?? ''}`"
:data-agent-id="mode === 'create' ? 'profile-create-submit' : mode === 'rename' ? 'profile-rename-save' : profileAgentId('delete-confirm', profile?.name ?? '')"
:disabled="!canSubmit"
>
<Plus v-if="mode === 'create'" :size="16" />
Expand Down
7 changes: 4 additions & 3 deletions apps/desktop/tests/e2e/latest-five-cost-distance.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { expect, test, type Page } from "@playwright/test";
import { profileAgentId } from "../../src/app/profileNames";

const mockScenarioKey = "nte.mockScenario";
const defaultProfile = "default";
Expand Down Expand Up @@ -119,8 +120,8 @@ test("1130px keeps same-ten hits full-size and filters x5 to x2", async ({ page
group("80", "mock-fork-cost-80"),
], "actual");

await page.locator(`[data-agent-id="profile-select-${defaultProfile}"]`).click();
await expect(page.locator(`[data-agent-id="profile-row-${defaultProfile}"]`)).toHaveClass(/active/);
await page.locator(`[data-agent-id="${profileAgentId("select", defaultProfile)}"]`).click();
await expect(page.locator(`[data-agent-id="${profileAgentId("row", defaultProfile)}"]`)).toHaveClass(/active/);
await selectForkPool(page);
await expect(distanceToggle).toContainText("成本抽數");
await expect(distanceToggle).toHaveAttribute("aria-pressed", "true");
Expand Down Expand Up @@ -303,7 +304,7 @@ async function createProfile(page: Page, profileName: string) {
await page.locator('[data-agent-id="profile-create-open"]').click();
await page.locator('[data-agent-id="profile-create-input"]').fill(profileName);
await page.locator('[data-agent-id="profile-create-submit"]').click();
await expect(page.locator(`[data-agent-id="profile-row-${profileName}"]`)).toHaveClass(/active/);
await expect(page.locator(`[data-agent-id="${profileAgentId("row", profileName)}"]`)).toHaveClass(/active/);
}

async function expectWallGroups(page: Page, expected: ExpectedWallGroup[], mode: "actual" | "cost") {
Expand Down
Loading