Skip to content
Merged
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
1 change: 0 additions & 1 deletion src/profiles/web/dist/assets/index-BPqTLQSc.css

This file was deleted.

1 change: 1 addition & 0 deletions src/profiles/web/dist/assets/index-C1MMTlfW.css

Large diffs are not rendered by default.

448 changes: 0 additions & 448 deletions src/profiles/web/dist/assets/index-CY2LGDSY.js

This file was deleted.

448 changes: 448 additions & 0 deletions src/profiles/web/dist/assets/index-SVUzfHxO.js

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions src/profiles/web/dist/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ProFiles</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<script type="module" crossorigin src="/assets/index-CY2LGDSY.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BPqTLQSc.css">
<script type="module" crossorigin src="/assets/index-SVUzfHxO.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-C1MMTlfW.css">
<link rel="manifest" href="/manifest.webmanifest"><script id="vite-plugin-pwa:register-sw" src="/registerSW.js"></script></head>
<body class="antialiased selection:bg-indigo-500 selection:text-white">
<div id="root"></div>
Expand Down
2 changes: 1 addition & 1 deletion src/profiles/web/dist/sw.js

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

112 changes: 103 additions & 9 deletions src/profiles/web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,14 @@ export default function App() {
const [showBarcodeModal, setShowBarcodeModal] = useState<boolean>(false);
const [barcodeFilePath, setBarcodeFilePath] = useState<string | undefined>(undefined);

// Verify-hash manual entry — clipboard reads are refused on some webviews,
// so the expected hash is typed in instead (issue #141).
const [verifyHashPrompt, setVerifyHashPrompt] = useState<{
file: ScannedFileItem;
algorithm: "md5" | "sha256";
} | null>(null);
const [verifyHashInput, setVerifyHashInput] = useState<string>("");

// Camera Barcode Scanner & Single match prompt state
const [showCameraScanner, setShowCameraScanner] = useState<boolean>(false);
const [singleMatchFile, setSingleMatchFile] = useState<ScannedFileItem | null>(null);
Expand Down Expand Up @@ -900,16 +908,15 @@ export default function App() {
}
};

const handleVerifyHash = async (file: ScannedFileItem, algorithm: "md5" | "sha256") => {
const runHashVerification = async (
file: ScannedFileItem,
algorithm: "md5" | "sha256",
expectedHash: string,
) => {
showToast(`Computing ${algorithm.toUpperCase()}...`);
try {
const clipboardText = await navigator.clipboard.readText();
if (!clipboardText.trim()) {
showToast("Clipboard is empty — copy a hash first, then try again.");
return;
}
showToast(`Computing ${algorithm.toUpperCase()}...`);
const res = await computeHash(file.path, algorithm);
const match = res.hash.toLowerCase() === clipboardText.trim().toLowerCase();
const match = res.hash.toLowerCase() === expectedHash.trim().toLowerCase();
if (match) {
showToast(`✅ ${algorithm.toUpperCase()} matches!`);
} else {
Expand All @@ -920,6 +927,25 @@ export default function App() {
}
};

const handleVerifyHash = async (file: ScannedFileItem, algorithm: "md5" | "sha256") => {
let clipboardText: string;
try {
clipboardText = await navigator.clipboard.readText();
} catch {
// Clipboard reads are commonly refused inside the desktop webviews
// (WKWebView / WebKitGTK) — fall back to manual entry instead of dying
// with an "empty clipboard"-style error (issue #141).
setVerifyHashInput("");
setVerifyHashPrompt({ file, algorithm });
return;
}
if (!clipboardText.trim()) {
showToast("Clipboard is empty — copy a hash first, then try again.");
return;
}
await runHashVerification(file, algorithm, clipboardText);
};

const handleProperties = (file: ScannedFileItem) => {
setPropertiesFile(file);
setShowPropertiesModal(true);
Expand Down Expand Up @@ -1669,7 +1695,14 @@ export default function App() {
isOpen={showLogViewer}
onClose={() => setShowLogViewer(false)}
onOpenExternally={() => {
openLogFile().then((r) => showToast(r.message));
openLogFile()
.then((r) => showToast(r.message))
.catch((err: unknown) =>
showToast(
err instanceof Error ? err.message : "Failed to open the log file",
"error",
),
);
}}
/>

Expand Down Expand Up @@ -1700,6 +1733,67 @@ export default function App() {
initialFilePath={barcodeFilePath}
/>

{/* Verify-hash manual entry (issue #141) — the clipboard-based path is
unavailable inside webviews that refuse clipboard reads. */}
{verifyHashPrompt && (
<div
className="animate-overlay-in fixed inset-0 z-[var(--z-modal)] flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm"
onClick={() => setVerifyHashPrompt(null)}
>
<div
role="dialog"
aria-modal="true"
aria-labelledby="verify-hash-title"
onKeyDown={(event) => {
if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
setVerifyHashPrompt(null);
}
}}
onClick={(event) => event.stopPropagation()}
className="animate-modal-in w-[440px] max-w-full rounded-2xl border border-frame-line glass-panel p-5"
>
<h2 id="verify-hash-title" className="mb-1 text-sm font-semibold text-ink">
Verify {verifyHashPrompt.algorithm.toUpperCase()}
</h2>
<p className="mb-3 text-xs text-ink-muted">
Clipboard access is unavailable here — paste the expected hash below.
</p>
<input
autoFocus
type="text"
value={verifyHashInput}
onChange={(event) => setVerifyHashInput(event.target.value)}
placeholder="Expected hash"
aria-label="Expected hash"
className="mb-4 w-full rounded-lg border border-line bg-elevated px-3 py-2 font-mono text-xs text-ink placeholder:text-ink-faint focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400"
/>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => setVerifyHashPrompt(null)}
className="min-h-9 rounded-lg border border-line bg-elevated px-3.5 text-sm text-ink-secondary transition-colors hover:bg-surface-strong hover:text-ink focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400"
>
Cancel
</button>
<button
type="button"
disabled={!verifyHashInput.trim()}
onClick={() => {
const { file, algorithm } = verifyHashPrompt;
setVerifyHashPrompt(null);
void runHashVerification(file, algorithm, verifyHashInput);
}}
className="min-h-9 rounded-lg border border-transparent bg-indigo-600 px-4 text-sm font-semibold text-white transition-colors hover:bg-indigo-500 disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400"
>
Verify
</button>
</div>
</div>
</div>
)}

{/* Camera Barcode Scanner Modal */}
<CameraBarcodeModal
isOpen={showCameraScanner}
Expand Down
9 changes: 8 additions & 1 deletion src/profiles/web/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -382,7 +382,14 @@ export async function launchFile(params: {
return res.json();
}

export async function revealFile(filePath: string): Promise<{ success: boolean }> {
/** Shape returned by the `/api/actions/*` endpoints (ActionResult serialised). */
export interface ActionResponse {
status: string;
message: string;
path?: string | null;
}

export async function revealFile(filePath: string): Promise<ActionResponse> {
const res = await fetch("/api/actions/reveal", {
method: "POST",
headers: { "Content-Type": "application/json" },
Expand Down
111 changes: 111 additions & 0 deletions src/profiles/web/src/components/BarcodeModal.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { BarcodeModal } from "./BarcodeModal";
import { generateBarcode } from "../api/client";

vi.mock("../api/client", () => ({
generateBarcode: vi.fn(),
saveImageToDisk: vi.fn(),
}));

const generateSpy = vi.mocked(generateBarcode);

/** Wait out the modal's 250 ms generate debounce. */
const settleDebounce = () => new Promise((resolve) => setTimeout(resolve, 320));

function renderModal(props: Partial<Parameters<typeof BarcodeModal>[0]> = {}) {
return render(
<BarcodeModal isOpen onClose={() => {}} {...props} />,
);
}

/** The key/value inputs exist per pair row — seed one empty pair first. */
async function addEmptyPair(user: ReturnType<typeof userEvent.setup>) {
await user.click(screen.getByRole("button", { name: /add field/i }));
return screen.getByPlaceholderText("key");
}

beforeEach(() => {
vi.clearAllMocks();
generateSpy.mockResolvedValue({ image: "data:image/png;base64,NEW", content: "" });
});

afterEach(() => {
cleanup();
document.body.innerHTML = "";
});

describe("BarcodeModal", () => {
it("test_edits_debounce_into_single_generate", async () => {
const user = userEvent.setup();
renderModal();

// Typing several characters into a key field schedules one debounced
// generate per edit burst, not one per keystroke (issue #140).
const keyInput = await addEmptyPair(user);
await user.type(keyInput, "abc");
await waitFor(() => expect(generateSpy).toHaveBeenCalled());
expect(generateSpy).toHaveBeenCalledTimes(1);
expect(generateSpy).toHaveBeenCalledWith({ content: "abc=" });

// The field keeps the newest content — the preview mirrors it.
expect(keyInput).toHaveValue("abc");
expect(screen.getByText("abc=")).toBeTruthy();
});

it("test_stale_generate_response_is_dropped", async () => {
const user = userEvent.setup();

let resolveFirst: (value: { image: string; content: string }) => void = () => {};
generateSpy
.mockImplementationOnce(() => new Promise((resolve) => { resolveFirst = resolve; }))
.mockResolvedValueOnce({ image: "data:image/png;base64,SECOND", content: "" });

renderModal();
const keyInput = await addEmptyPair(user);
await user.type(keyInput, "a");
await waitFor(() => expect(generateSpy).toHaveBeenCalledTimes(1));

// A second edit fires a newer generate that resolves before the first.
await user.type(screen.getByPlaceholderText("value"), "b");
await waitFor(() => expect(generateSpy).toHaveBeenCalledTimes(2));
const img = await screen.findByAltText("QR Code");
await waitFor(() => expect(img.getAttribute("src")).toBe("data:image/png;base64,SECOND"));

// The stale first response lands last — it must not win (issue #140).
resolveFirst({ image: "data:image/png;base64,STALE", content: "" });
await settleDebounce();
expect(img.getAttribute("src")).toBe("data:image/png;base64,SECOND");
});

it("test_generate_failure_is_surfaced", async () => {
const user = userEvent.setup();
generateSpy.mockRejectedValue(new Error("barcode engine exploded"));
renderModal();

const keyInput = await addEmptyPair(user);
await user.type(keyInput, "a");
// Failure must appear in the modal's alert area, not only the console
// (issue #140 — No Silent Failures).
const alert = await screen.findByRole("alert");
expect(alert.textContent).toMatch(/barcode engine exploded/);
});

it("test_empty_content_clears_image_and_error", async () => {
const user = userEvent.setup();
renderModal();

const keyInput = await addEmptyPair(user);
await user.type(keyInput, "a");
await screen.findByAltText("QR Code");

// Removing the field empties the payload: the image and any previous
// generate error go away, with no further request.
await user.clear(keyInput);
await waitFor(() => expect(screen.queryByAltText("QR Code")).toBeNull());
expect(screen.queryByRole("alert")).toBeNull();
expect(generateSpy).toHaveBeenCalledTimes(1);
});
});
Loading
Loading