| import { Stronghold, type Client } from '@tauri-apps/plugin-stronghold'; | |
| import { appDataDir, join } from '@tauri-apps/api/path'; | |
| export interface CredentialRecord { | |
| id: string; | |
| origin: string; | |
| username: string; | |
| password: string; | |
| label?: string; | |
| createdAt: number; | |
| updatedAt: number; | |
| } | |
| export interface CredentialSummary { | |
| id: string; | |
| origin: string; | |
| username: string; | |
| label?: string; | |
| updatedAt: number; | |
| } | |
| const CLIENT_NAME = 'musealpha-credentials'; | |
| const INDEX_KEY = 'cred:index'; | |
| let stronghold: Stronghold | null = null; | |
| let client: Client | null = null; | |
| const enc = new TextEncoder(); | |
| const dec = new TextDecoder(); | |
| function bytes(v: string): number[] { return Array.from(enc.encode(v)); } | |
| function text(v: number[] | Uint8Array): string { return dec.decode(new Uint8Array(v)); } | |
| async function vaultPath() { | |
| return await join(await appDataDir(), 'credentials.stronghold'); | |
| } | |
| export async function unlockVault(masterPassword: string) { | |
| const path = await vaultPath(); | |
| stronghold = await Stronghold.load(path, masterPassword); | |
| try { | |
| client = await stronghold.loadClient(CLIENT_NAME); | |
| } catch { | |
| client = await stronghold.createClient(CLIENT_NAME); | |
| await stronghold.save(); | |
| } | |
| } | |
| export function isVaultUnlocked() { return !!stronghold && !!client; } | |
| export function lockVault() { stronghold = null; client = null; } | |
| function ensureClient(): Client { | |
| if (!client || !stronghold) throw new Error('Password vault is locked'); | |
| return client; | |
| } | |
| async function loadIndex(): Promise<string[]> { | |
| const store = ensureClient().getStore(); | |
| const raw = await store.get(INDEX_KEY); | |
| if (!raw) return []; | |
| try { return JSON.parse(text(raw)); } catch { return []; } | |
| } | |
| async function saveIndex(ids: string[]) { | |
| const store = ensureClient().getStore(); | |
| await store.insert(INDEX_KEY, bytes(JSON.stringify([...new Set(ids)]))); | |
| await stronghold!.save(); | |
| } | |
| function key(id: string) { return `cred:${id}`; } | |
| export async function saveCredential(input: Omit<CredentialRecord, 'id' | 'createdAt' | 'updatedAt'> & { id?: string }) { | |
| const store = ensureClient().getStore(); | |
| const now = Date.now(); | |
| const id = input.id || crypto.randomUUID(); | |
| const existing = input.id ? await getCredential(input.id).catch(() => null) : null; | |
| const record: CredentialRecord = { | |
| id, | |
| origin: normalizeOrigin(input.origin), | |
| username: input.username, | |
| password: input.password, | |
| label: input.label, | |
| createdAt: existing?.createdAt || now, | |
| updatedAt: now, | |
| }; | |
| await store.insert(key(id), bytes(JSON.stringify(record))); | |
| const idx = await loadIndex(); | |
| await saveIndex([...idx, id]); | |
| await stronghold!.save(); | |
| return record; | |
| } | |
| export async function getCredential(id: string): Promise<CredentialRecord | null> { | |
| const store = ensureClient().getStore(); | |
| const raw = await store.get(key(id)); | |
| if (!raw) return null; | |
| return JSON.parse(text(raw)); | |
| } | |
| export async function listCredentials(origin?: string): Promise<CredentialSummary[]> { | |
| const idx = await loadIndex(); | |
| const normalized = origin ? normalizeOrigin(origin) : null; | |
| const out: CredentialSummary[] = []; | |
| for (const id of idx) { | |
| const r = await getCredential(id).catch(() => null); | |
| if (!r) continue; | |
| if (normalized && r.origin !== normalized) continue; | |
| out.push({ id: r.id, origin: r.origin, username: r.username, label: r.label, updatedAt: r.updatedAt }); | |
| } | |
| return out.sort((a, b) => b.updatedAt - a.updatedAt); | |
| } | |
| export async function deleteCredential(id: string) { | |
| const store = ensureClient().getStore(); | |
| await store.remove(key(id)); | |
| const idx = await loadIndex(); | |
| await saveIndex(idx.filter(x => x !== id)); | |
| await stronghold!.save(); | |
| } | |
| export function normalizeOrigin(input: string): string { | |
| try { | |
| const u = new URL(input.includes('://') ? input : `https://${input}`); | |
| return `${u.protocol}//${u.host}`.toLowerCase(); | |
| } catch { | |
| return input.trim().toLowerCase(); | |
| } | |
| } | |