pragma ComponentBehavior: Bound import QtQuick import Quickshell import Quickshell.Io import "vault.js" as VaultData Item { id: root required property Clipboard clipboardService property string status: "needs-setup" property string message: "" property var profile: ({ "name": "personal", "serverUrl": "", "account": "", "unlockTimeoutSeconds": 900, "clipboardTimeoutSeconds": 30 }) property var cache: ({ "available": false, "ageSeconds": -1, "stale": false }) property var items: [] property var detail: ({}) property var challenge: ({}) property int secondFactorProvider: -1 property string secondFactorDraft: "" readonly property bool verificationPending: status === "second-factor" && message === "verifying" property string query: "" property string selectedId: "" property string browserOrigin: "" property string profileKey: "" property real resultScrollPosition: 0 property real detailScrollPosition: 0 property var expandedSections: ({}) property var revealedFields: ({}) property var faviconPaths: ({}) property string feedback: "" property string repromptError: "" property bool copyPending: false property double currentCopyId: 0 property int lockGeneration: 0 property bool panelOpen: false property bool lockPending: false property bool restoreSyncPending: false readonly property var visibleItems: VaultData.filterItems(items, query, browserOrigin) readonly property var groupedItems: VaultData.groups(visibleItems) readonly property bool unlocked: status === "unlocked" || status === "syncing" || status === "sync-failure" || status === "stale-cache" readonly property string executable: Quickshell.shellPath("vault-native/run.sh") readonly property string configPath: Quickshell.statePath("vault.json") property bool configLoaded: false property bool setupEditing: false property bool setupSaving: false property string setupUrlDraft: "" property string setupAccountDraft: "" property string setupError: "" property var pendingProfile: null property bool persistProfile: false property double configurationId: 0 property bool restartPending: false property bool connectionTesting: false property string connectionResult: "" readonly property bool connectionBusy: connectionTesting || connectionProcess.running function send(request) { if (!vaultProcess.running) { boundaryFailed("native vault boundary unavailable"); return; } vaultProcess.write(JSON.stringify(request) + "\n"); } function boundaryFailed(reason) { status = "unavailable-vault"; message = reason; lockPending = false; pendingProfile = null; restartPending = false; if (setupSaving) { setupEditing = true; setupError = reason; } setupSaving = false; persistProfile = false; clearRetainedState(); } function beginSetup() { setupUrlDraft = String(profile.serverUrl || ""); setupAccountDraft = String(profile.account || ""); setupError = ""; setupEditing = true; lock(); } function cancelSetup() { stopConnectionTest(); setupEditing = false; setupError = ""; setupUrlDraft = String(profile.serverUrl || ""); setupAccountDraft = String(profile.account || ""); } function configureProfile(value, persist) { pendingProfile = value; persistProfile = persist; setupSaving = true; setupError = ""; configurationId++; lockPending = true; status = "locked"; message = ""; browserOrigin = ""; clearRetainedState(); // EOF locks and reaps the old boundary before starting the current executable. // Quickshell otherwise retains the old process across QML reloads and builds. restartPending = vaultProcess.running; if (restartPending) vaultProcess.stdinEnabled = false; else { vaultProcess.stdinEnabled = true; vaultProcess.running = true; } } function boundaryStarted() { if (setupSaving && pendingProfile !== null && !restartPending) send({ "op": "configure-lock", "id": configurationId }); } function boundaryExited() { clipboardService.resumeAfterVaultCopy(); if (restartPending) { restartPending = false; vaultProcess.stdinEnabled = true; vaultProcess.running = true; } else boundaryFailed("native vault boundary stopped"); } function setupTimedOut() { boundaryFailed("vault setup timed out; retry saving"); vaultProcess.stdinEnabled = false; } function loadConfig() { if (configLoaded) return; configLoaded = true; let value; try { value = JSON.parse(cfgFile.text()); if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid profile"); } catch (error) { setupError = "vault settings could not be read"; setupEditing = true; return; } configureProfile(value, false); } function draftProfile() { return { "name": String(profile.name || "personal"), "serverUrl": setupUrlDraft.trim(), "account": setupAccountDraft.trim(), "unlockTimeoutSeconds": Number(profile.unlockTimeoutSeconds || 900), "clipboardTimeoutSeconds": Number(profile.clipboardTimeoutSeconds || 30) }; } function saveSetup() { if (setupSaving || lockPending || connectionBusy) return; configureProfile(draftProfile(), true); } function testConnection() { if (setupSaving || lockPending || connectionBusy) return; setupError = ""; connectionResult = ""; connectionTesting = true; connectionProcess.stdinEnabled = true; connectionProcess.running = true; } function connectionStarted() { if (connectionTesting) connectionProcess.write(JSON.stringify({ "op": "test-connection", "profile": draftProfile() }) + "\n"); } function connectionExited() { if (connectionTesting && !connectionResult) connectionResult = "connection test stopped"; connectionTesting = false; } function stopConnectionTest() { connectionTesting = false; connectionResult = ""; connectionProcess.stdinEnabled = false; } function connectionTimedOut() { connectionResult = "connection test timed out"; connectionTesting = false; connectionProcess.stdinEnabled = false; } function handleConnectionLine(line) { if (!connectionTesting) return; let event; try { event = JSON.parse(line); } catch (error) { connectionResult = "invalid connection test response"; connectionProcess.stdinEnabled = false; return; } if (event.event === "connection-result") connectionResult = String(event.message || "connection test failed"); else if (event.event === "protocol-error") connectionResult = "native vault rejected connection test"; else if (event.event === "state" && event.status === "unavailable-vault") connectionResult = String(event.message || "connection test unavailable"); else return; connectionProcess.stdinEnabled = false; } function unlock(password) { const value = String(password || ""); if (value.length === 0 || lockPending || setupSaving || setupEditing || !profile.serverUrl || !profile.account) return; status = "loading"; send({ "op": "unlock", "password": value }); } function secondFactorHint() { if (secondFactorProvider === 1) return "email code selected; check your inbox"; const provider = (challenge.providers || []).find(value => Number(value.id) === secondFactorProvider); return provider ? String(provider.label) + " selected; enter its verification code" : "choose a verification method"; } function selectSecondFactor(provider) { const value = Number(provider); if (verificationPending || !(challenge.providers || []).some(candidate => Number(candidate.id) === value && !!candidate.codeSupported)) return; if (secondFactorProvider !== value) secondFactorDraft = ""; secondFactorProvider = value; message = ""; } function submitSecondFactor(token) { const value = String(token || ""); if (status !== "second-factor" || value.length === 0 || secondFactorProvider < 0 || verificationPending || lockPending) return; secondFactorDraft = ""; message = "verifying"; send({ "op": "second-factor", "token": value, "provider": secondFactorProvider }); } function cancelUnlock() { lock(); } function reprompt(password) { const value = String(password || ""); if (selectedId.length === 0 || value.length === 0) return; repromptError = ""; send({ "op": "reprompt", "id": selectedId, "password": value }); } function prepareOpen(appId) { send({ "op": "context", "appId": String(appId || "") || null }); if (unlocked) sync(); } function lock(request) { stopConnectionTest(); if (!request && setupSaving) { restartPending = false; pendingProfile = null; persistProfile = false; setupSaving = false; setupEditing = true; setupError = "configuration interrupted by lock"; } lockPending = true; status = "locked"; message = ""; browserOrigin = ""; clearRetainedState(); send(request || { "op": "lock" }); } function sync() { send({ "op": "sync" }); } function select(id) { const value = String(id || ""); if (value.length === 0 || value === selectedId) return; selectedId = value; detail = ({}); repromptError = ""; send({ "op": "detail", "id": selectedId }); } function moveSelection(delta) { const next = VaultData.nextSelection(visibleItems, selectedId, Number(delta)); if (next.length > 0) select(next); } function refreshDetail() { if (unlocked && selectedId.length > 0) send({ "op": "detail", "id": selectedId }); } function copy(target, slot) { if (selectedId.length === 0) return; send({ "op": "copy", "id": selectedId, "target": String(target), "slot": slot === undefined ? null : Number(slot) }); } function open(field) { if (selectedId.length === 0) return; send({ "op": "open", "id": selectedId, "field": String(field) }); } function setFavorite(favorite) { if (selectedId.length === 0) return; send({ "op": "favorite", "id": selectedId, "favorite": !!favorite }); } function sectionExpanded(section) { const key = selectedId + "/" + String(section); return expandedSections[key] === undefined ? true : !!expandedSections[key]; } function toggleSection(section) { const next = Object.assign({}, expandedSections); const key = selectedId + "/" + String(section); next[key] = !sectionExpanded(section); expandedSections = next; } function isRevealed(field) { return !!revealedFields[selectedId + "/" + String(field)]; } function toggleReveal(field) { const next = Object.assign({}, revealedFields); const key = selectedId + "/" + String(field); next[key] = !next[key]; revealedFields = next; } function clearRetainedState() { lockGeneration++; repromptError = ""; feedback = ""; copyPending = false; restoreSyncPending = false; items = []; detail = ({}); challenge = ({}); secondFactorProvider = -1; secondFactorDraft = ""; query = ""; selectedId = ""; resultScrollPosition = 0; detailScrollPosition = 0; expandedSections = ({}); revealedFields = ({}); faviconPaths = ({}); } function applyState(event) { status = String(event.status || "unavailable-vault"); message = String(event.message || ""); profile = event.profile || profile; profileKey = String(event.profileKey || ""); cache = event.cache || cache; challenge = event.challenge || ({}); if (status !== "second-factor") secondFactorDraft = ""; const providers = challenge.providers || []; if (providers.length > 0 && !providers.some(provider => Number(provider.id) === secondFactorProvider && !!provider.codeSupported)) { const supported = providers.find(provider => !!provider.codeSupported); secondFactorProvider = supported ? Number(supported.id) : -1; } browserOrigin = String(event.browserOrigin || ""); items = event.items || []; for (let index = 0; index < items.length; index++) { const path = faviconPaths[String(items[index].id || "")]; if (path) items[index].favicon = path; } if (!unlocked) { if (status === "needs-setup" || status === "locked" || status === "authentication-failure" || status === "unavailable-vault") clearRetainedState(); return; } if (visibleItems.length > 0) { const retained = visibleItems.some(item => { return item.id === selectedId; }); if (retained) refreshDetail(); else select(visibleItems[0].id); } else { selectedId = ""; detail = ({}); } } function handleLine(line) { let event; try { event = JSON.parse(String(line || "")); } catch (error) { boundaryFailed("invalid native vault response"); return; } if (restartPending && event.event !== "copy-ended") return; if (setupSaving && (event.event === "protocol-error" || event.event === "configure-failed")) { boundaryFailed(String(event.message || "native vault rejected settings; retry saving")); return; } if (lockPending) { if (event.event === "state" && (event.status === "needs-setup" || event.status === "locked" || event.status === "unavailable-vault")) lockPending = false; else if (event.event !== "copy-ended" && event.event !== "configure-ready") return; } switch (String(event.event || "")) { case "state": applyState(event); if (event.syncNeeded && !restoreSyncPending) { restoreSyncPending = true; sync(); } else if (!event.syncNeeded) { restoreSyncPending = false; } if (status === "unavailable-vault" && setupSaving) boundaryFailed(message || "native vault boundary unavailable"); if (setupSaving && pendingProfile === null && (status === "locked" || status === "needs-setup")) { setupSaving = false; persistProfile = false; setupEditing = true; setupError = "configuration interrupted by lock"; } break; case "configure-ready": if (pendingProfile !== null && event.id === configurationId) { lockPending = false; const value = pendingProfile; pendingProfile = null; send({ "op": "configure", "profile": value }); } break; case "configured": if (!setupSaving) break; profile = event.profile; setupUrlDraft = String(profile.serverUrl || ""); setupAccountDraft = String(profile.account || ""); setupEditing = false; setupSaving = false; if (persistProfile) cfgFile.setText(JSON.stringify(profile, null, 2)); persistProfile = false; break; case "detail": if (String(event.id || "") === selectedId) { // TOTP ticks must not recreate field delegates and discard keyboard focus or presses. if (detail.id === event.id && JSON.stringify(detail.fields) === JSON.stringify(event.fields)) event.fields = detail.fields; detail = event; } break; case "favicon": { if (!unlocked || !profileKey || event.profileKey !== profileKey) break; const id = String(event.id || ""); const path = String(event.path || ""); faviconPaths[id] = path; const updated = items.slice(); const index = updated.findIndex(item => String(item.id || "") === id); if (index >= 0) { updated[index] = Object.assign({}, updated[index], { "favicon": path }); items = updated; } break; } case "reprompt-succeeded": repromptError = ""; feedback = "item access granted"; feedbackTimer.restart(); break; case "reprompt-failed": repromptError = "master password incorrect"; break; case "copy-ready": copyPending = true; currentCopyId = Number(event.id || 0); clipboardService.pauseForVaultCopy(Number(profile.clipboardTimeoutSeconds || 30)); break; case "copy-ended": if (Number(event.id || 0) === currentCopyId) { copyPending = false; currentCopyId = 0; clipboardService.resumeAfterVaultCopy(); } break; case "copied": feedback = String(event.field || "field") + " copied"; feedbackTimer.restart(); break; case "copy-failed": feedback = "copy failed"; feedbackTimer.restart(); break; case "opened": feedback = "url opened"; feedbackTimer.restart(); break; case "action-failed": feedback = String(event.action || "action") + " failed"; feedbackTimer.restart(); break; } } function panelClosed() { stopConnectionTest(); } function stateJson() { return JSON.stringify({ "status": status, "profile": profile, "cache": cache, "itemCount": items.length, "selectedId": selectedId, "query": query, "feedback": feedback }); } onSetupUrlDraftChanged: connectionResult = "" onSetupAccountDraftChanged: connectionResult = "" onPanelOpenChanged: if (!panelOpen) panelClosed() Component.onCompleted: send({ "op": "hello" }) Component.onDestruction: { connectionProcess.stdinEnabled = false; clipboardService.resumeAfterVaultCopy(); if (vaultProcess.running) vaultProcess.write("{\"op\":\"lock\"}\n"); } Connections { target: root.clipboardService function onVaultCapturePaused() { if (!root.copyPending) return; root.copyPending = false; root.send({ "op": "commit-copy" }); } } Timer { interval: 1000 repeat: true running: root.unlocked && !!root.detail.totp onTriggered: root.refreshDetail() } Timer { interval: 5000 running: root.setupSaving onTriggered: root.setupTimedOut() } Timer { id: feedbackTimer interval: 1800 onTriggered: root.feedback = "" } FileView { id: cfgFile path: root.configPath onLoaded: root.loadConfig() onLoadFailed: error => { root.configLoaded = true; root.setupEditing = true; if (error !== FileViewError.FileNotFound) root.setupError = "vault settings could not be read"; } onSaveFailed: { root.setupEditing = true; root.setupError = "vault settings could not be saved"; } } Connections { target: vaultProcess function onExited(exitCode) { root.boundaryExited(); } } Timer { interval: 5000 running: root.connectionTesting onTriggered: root.connectionTimedOut() } Connections { target: connectionProcess function onExited(exitCode) { root.connectionExited(); } } Process { id: connectionProcess command: [root.executable] workingDirectory: Quickshell.shellDir stdinEnabled: true onStarted: root.connectionStarted() stdout: SplitParser { onRead: data => root.handleConnectionLine(data) } } Process { id: vaultProcess command: [root.executable] workingDirectory: Quickshell.shellDir stdinEnabled: true running: true onStarted: root.boundaryStarted() stdout: SplitParser { onRead: data => root.handleLine(data) } stderr: StdioCollector { waitForEnd: true onStreamFinished: if (this.text.length > 0) root.message = "native vault boundary failed" } } }