Thisper/src/main.ts
stan44 b7de938919 Protect API keys with native credential storage
- Move Windows storage to DPAPI-backed local app data
- Keep non-Windows storage on the native credential store
- Update docs and UI copy to describe the new behavior
2026-04-05 17:09:38 -05:00

256 lines
7.5 KiB
TypeScript

import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import * as diff from "diff";
type RewriteMetrics = {
duration_ms: number;
input_length: number;
model: string;
success: boolean;
error_category?: string | null;
};
type RuntimeStatus = {
hotkey_registered: boolean;
background_mode: boolean;
active_model: string;
api_key_configured: boolean;
last_error?: string | null;
rewrite_attempts: number;
rewrite_successes: number;
rewrite_failures: number;
last_metrics?: RewriteMetrics | null;
};
function requireElement<T extends HTMLElement>(id: string): T {
const element = document.getElementById(id);
if (!element) {
throw new Error(`Missing required element: ${id}`);
}
return element as T;
}
const inputEl = requireElement<HTMLTextAreaElement>("input-text");
const outputEl = requireElement<HTMLTextAreaElement>("output-text");
const modelSelect = requireElement<HTMLSelectElement>("model-select");
const modeSelect = requireElement<HTMLSelectElement>("mode-select");
const rewriteBtn = requireElement<HTMLButtonElement>("rewrite-btn");
const copyBtn = requireElement<HTMLButtonElement>("copy-btn");
const toggleDiffBtn = requireElement<HTMLButtonElement>("toggle-diff-btn");
const diffView = requireElement<HTMLDivElement>("diff-view");
const loadingMsg = requireElement<HTMLDivElement>("loading");
const settingsBtn = requireElement<HTMLButtonElement>("settings-btn");
const settingsModal = requireElement<HTMLDivElement>("settings-modal");
const closeSettingsBtn = requireElement<HTMLButtonElement>("close-settings-btn");
const saveSettingsBtn = requireElement<HTMLButtonElement>("save-settings-btn");
const apiKeyInput = requireElement<HTMLInputElement>("api-key-input");
const runtimeStatusEl = requireElement<HTMLDivElement>("runtime-status");
const helperBanner = requireElement<HTMLDivElement>("helper-banner");
const helperBannerText = requireElement<HTMLParagraphElement>("helper-banner-text");
const hideToTrayBtn = requireElement<HTMLButtonElement>("hide-to-tray-btn");
const dismissBannerBtn = requireElement<HTMLButtonElement>("dismiss-banner-btn");
let showDiff = false;
function renderRuntimeStatus(status: RuntimeStatus) {
const metricParts: string[] = [];
if (status.last_metrics) {
metricParts.push(`${status.last_metrics.duration_ms} ms`);
metricParts.push(`${status.last_metrics.input_length} chars`);
metricParts.push(status.last_metrics.success ? "last run ok" : "last run failed");
if (status.last_metrics.error_category) {
metricParts.push(status.last_metrics.error_category);
}
}
const lines = [
`Hotkey: ${status.hotkey_registered ? "Ctrl + Alt + R active" : "not registered"}`,
`Window: ${status.background_mode ? "hidden to tray" : "visible"}`,
`Model: ${status.active_model}`,
`API key: ${status.api_key_configured ? "configured" : "missing"}`,
`Rewrites: ${status.rewrite_successes}/${status.rewrite_attempts} successful`,
];
if (metricParts.length > 0) {
lines.push(`Last run: ${metricParts.join(" | ")}`);
}
if (status.last_error) {
lines.push(`Last error: ${status.last_error}`);
}
runtimeStatusEl.textContent = lines.join(" | ");
}
async function refreshRuntimeStatus() {
try {
const status = await invoke<RuntimeStatus>("get_runtime_status");
renderRuntimeStatus(status);
} catch (error) {
console.error("Failed to load runtime status", error);
}
}
function renderDiff(original: string, rewritten: string) {
const diffResult = diff.diffWords(original, rewritten);
diffView.innerHTML = "";
diffResult.forEach((part) => {
const span = document.createElement("span");
span.textContent = part.value;
if (part.added) {
span.className = "diff-add";
} else if (part.removed) {
span.className = "diff-del";
}
diffView.appendChild(span);
});
}
function showBanner(message: string) {
helperBannerText.textContent = message;
helperBanner.classList.remove("hidden");
}
function hideBanner() {
helperBanner.classList.add("hidden");
}
modelSelect.addEventListener("change", async () => {
try {
await invoke("set_active_model", { model: modelSelect.value });
await refreshRuntimeStatus();
} catch (error) {
console.error("Failed to sync active model", error);
}
});
void invoke("set_active_model", { model: modelSelect.value })
.then(refreshRuntimeStatus)
.catch((error) => {
console.error("Failed to initialize active model", error);
});
settingsBtn.addEventListener("click", () => {
settingsModal.classList.remove("hidden");
});
closeSettingsBtn.addEventListener("click", () => {
settingsModal.classList.add("hidden");
});
dismissBannerBtn.addEventListener("click", hideBanner);
hideToTrayBtn.addEventListener("click", async () => {
try {
await invoke("hide_main_window");
hideBanner();
await refreshRuntimeStatus();
} catch (error) {
console.error("Failed to hide main window", error);
}
});
saveSettingsBtn.addEventListener("click", async () => {
const key = apiKeyInput.value.trim();
if (!key) return;
try {
saveSettingsBtn.disabled = true;
saveSettingsBtn.textContent = "Saving...";
await invoke("save_api_key", { key });
settingsModal.classList.add("hidden");
apiKeyInput.value = "";
await refreshRuntimeStatus();
alert("API key saved using native platform protection.");
} catch (err) {
alert("Error saving API key: " + err);
} finally {
saveSettingsBtn.disabled = false;
saveSettingsBtn.textContent = "Save Settings";
}
});
async function checkStatus() {
try {
const isSet = await invoke<boolean>("get_api_key_status");
if (!isSet) {
settingsModal.classList.remove("hidden");
}
} catch (error) {
console.error("Status check failed", error);
}
}
rewriteBtn.addEventListener("click", async () => {
const text = inputEl.value;
if (!text.trim()) return;
loadingMsg.classList.remove("hidden");
outputEl.classList.add("hidden");
diffView.classList.add("hidden");
rewriteBtn.disabled = true;
try {
const mode = modeSelect.value;
const model = modelSelect.value;
const result = await invoke<string>("rewrite_text", {
text,
modeStr: mode,
modelStr: model,
});
outputEl.value = result;
renderDiff(text, result);
if (showDiff) {
diffView.classList.remove("hidden");
} else {
outputEl.classList.remove("hidden");
}
} catch (error) {
console.error("Rewrite failed:", error);
outputEl.value = `Error: ${error}`;
outputEl.classList.remove("hidden");
} finally {
loadingMsg.classList.add("hidden");
rewriteBtn.disabled = false;
await refreshRuntimeStatus();
}
});
toggleDiffBtn.addEventListener("click", () => {
showDiff = !showDiff;
if (showDiff) {
toggleDiffBtn.textContent = "Show Text";
outputEl.classList.add("hidden");
diffView.classList.remove("hidden");
} else {
toggleDiffBtn.textContent = "Show Diff";
diffView.classList.add("hidden");
outputEl.classList.remove("hidden");
}
});
copyBtn.addEventListener("click", async () => {
try {
await navigator.clipboard.writeText(outputEl.value);
const originalText = copyBtn.textContent;
copyBtn.textContent = "Copied!";
setTimeout(() => {
copyBtn.textContent = originalText;
}, 2000);
} catch (error) {
console.error("Failed to copy", error);
}
});
void listen<string>("tray-hint", (event) => {
showBanner(event.payload);
});
void listen<RuntimeStatus>("runtime-status", (event) => {
renderRuntimeStatus(event.payload);
});
void Promise.all([checkStatus(), refreshRuntimeStatus()]);