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
33 changes: 33 additions & 0 deletions apps/desktop/__tests__/backup.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { encrypt } from '@decryption/core';
import * as fs from 'fs/promises';
import * as os from 'os';
import * as path from 'path';
import { afterAll, describe, expect, it } from 'vitest';

import { assertEnvelope, backupName } from '../src/main/backup';

const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'dcrypt-backup-'));

afterAll(async () => {
await fs.rm(dir, { recursive: true, force: true });
});

describe('backupName', () => {
it('stamps the file so copies sort chronologically', () => {
expect(backupName(new Date(2026, 7, 7, 14, 3))).toBe('dcrypt-vault-2026-08-07-1403.dcrypt');
});
});

describe('assertEnvelope', () => {
it('accepts a dcrypt envelope', async () => {
const file = path.join(dir, 'good.dcrypt');
await fs.writeFile(file, encrypt(new TextEncoder().encode('vault'), 'pw', 'interactive'));
await expect(assertEnvelope(file)).resolves.toBeUndefined();
}, 30_000);

it('refuses to restore anything that is not one', async () => {
const file = path.join(dir, 'holiday.jpg');
await fs.writeFile(file, Buffer.alloc(512, 7));
await expect(assertEnvelope(file)).rejects.toThrow(/not a dcrypt envelope/);
});
});
89 changes: 89 additions & 0 deletions apps/desktop/src/main/backup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { parseHeader } from '@decryption/core';
import { BrowserWindow, dialog } from 'electron';
import * as fs from 'fs/promises';
import * as path from 'path';

import type { BackupResult } from '../shared/api';
import { vaultFilePath,VaultService } from './vault-service';

/** `dcrypt-vault-2026-08-07-1432.dcrypt` — sorts chronologically in a folder. */
export const backupName = (now = new Date()): string => {
const pad = (value: number): string => String(value).padStart(2, '0');
const stamp = [
now.getFullYear(),
pad(now.getMonth() + 1),
pad(now.getDate()),
`${pad(now.getHours())}${pad(now.getMinutes())}`,
].join('-');
return `dcrypt-vault-${stamp}.dcrypt`;
};

/** Rejects anything that is not a dcrypt envelope before it can replace a vault. */
export const assertEnvelope = async (file: string): Promise<void> => {
parseHeader(new Uint8Array(await fs.readFile(file)));
};

/**
* Copies the encrypted vault file wherever the user points. The copy is the
* same sealed envelope as the original: it carries no key material, and only
* the master password opens it.
*/
export const backupVault = async (
service: VaultService,
window: BrowserWindow | null
): Promise<BackupResult> => {
const source = vaultFilePath();
// flush pending edits so the copy is not a moment behind the UI
await service.flush();
const options = {
title: 'Back up vault',
defaultPath: backupName(),
filters: [{ name: 'Encrypted vault', extensions: ['dcrypt'] }],
message: 'The backup is encrypted with your master password.',
};
const { canceled, filePath } = window
? await dialog.showSaveDialog(window, options)
: await dialog.showSaveDialog(options);
if (canceled || !filePath) return { path: null };
await fs.copyFile(source, filePath);
return { path: filePath };
};

/**
* Replaces the vault with a chosen backup. The vault is locked first and the
* file it replaces is kept alongside it, so a mistaken restore is recoverable.
*/
export const restoreVault = async (
service: VaultService,
window: BrowserWindow | null
): Promise<BackupResult> => {
const options: Electron.OpenDialogOptions = {
title: 'Restore from backup',
properties: ['openFile'],
filters: [{ name: 'Encrypted vault', extensions: ['dcrypt'] }],
message: 'Choose a vault backup. Your current vault is kept as a copy.',
};
const { canceled, filePaths } = window
? await dialog.showOpenDialog(window, options)
: await dialog.showOpenDialog(options);
const chosen = filePaths[0];
if (canceled || !chosen) return { path: null };
await assertEnvelope(chosen);

const target = vaultFilePath();
await service.lock();
await fs.mkdir(path.dirname(target), { recursive: true });
const kept = path.join(path.dirname(target), `replaced-${backupName()}`);
const replaced = await fs
.rename(target, kept)
.then(() => true)
.catch(() => false);
try {
await fs.copyFile(chosen, target);
} catch (err) {
// put the original back rather than leaving no vault at all
if (replaced) await fs.rename(kept, target).catch(() => undefined);
throw err;
}
return { path: chosen, replaced: replaced ? kept : null };
};
9 changes: 8 additions & 1 deletion apps/desktop/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import * as path from 'path';

import { CHANNELS } from '../shared/api';
import { registerIpc } from './ipc';
import { buildMenu } from './menu';
import { VaultService } from './vault-service';

const DEV_SERVER_URL = process.env.ELECTRON_RENDERER_URL;
Expand Down Expand Up @@ -30,7 +31,8 @@ const createWindow = (): BrowserWindow => {
minWidth: 960,
minHeight: 600,
show: false,
autoHideMenuBar: true,
// the menu carries Back Up Vault, so it stays visible on Windows and Linux
autoHideMenuBar: false,
icon: APP_ICON,
webPreferences: {
preload: path.join(__dirname, '../preload/index.js'),
Expand Down Expand Up @@ -107,6 +109,11 @@ app.whenReady().then(() => {
});

registerIpc(service);
buildMenu(service, () => mainWindow, () => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send(CHANNELS.lockedEvent);
}
});
mainWindow = createWindow();
watchIdle();

Expand Down
21 changes: 19 additions & 2 deletions apps/desktop/src/main/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,15 @@ import { decryptFromString, encryptToString } from '@decryption/core';
import { decrypt as legacyDecrypt } from '@decryption/cosmology-compat';
import { combineToString, splitToStrings } from '@decryption/shamir';
import { createWallet, deriveAccounts, WordCount } from '@decryption/wallet';
import { ipcMain } from 'electron';
import { BrowserWindow, ipcMain, shell } from 'electron';
import { existsSync } from 'fs';
import * as path from 'path';

import { CHANNELS, FieldPurpose, ItemKind } from '../shared/api';
import { parseOtpauthUri } from '../shared/otpauth';
import { backupVault, restoreVault } from './backup';
import { lookupBrandIcons } from './brand-icons';
import { VaultService } from './vault-service';
import { vaultFilePath,VaultService } from './vault-service';

const WORD_COUNTS: WordCount[] = [12, 15, 18, 21, 24];

Expand Down Expand Up @@ -134,6 +137,20 @@ export const registerIpc = (service: VaultService): void => {
service.scheduleSave();
});

// ─── backup ───
handle(CHANNELS.backupCreate, (): unknown =>
backupVault(service, BrowserWindow.getFocusedWindow())
);
handle(CHANNELS.backupRestore, (): unknown =>
restoreVault(service, BrowserWindow.getFocusedWindow())
);
handle(CHANNELS.backupRevealVault, async (): Promise<void> => {
const file = vaultFilePath();
// an absent vault has no item to select, so fall back to its folder
if (existsSync(file)) shell.showItemInFolder(file);
else await shell.openPath(path.dirname(file));
});

// ─── brand icons (bundled, offline) ───
handle(CHANNELS.iconsLookup, (names: string[]) => lookupBrandIcons(assertStringArray(names)));

Expand Down
151 changes: 151 additions & 0 deletions apps/desktop/src/main/menu.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import { app, BrowserWindow, dialog, Menu, MenuItemConstructorOptions, shell } from 'electron';

import { backupVault, restoreVault } from './backup';
import { VaultService } from './vault-service';

const isMac = process.platform === 'darwin';

/**
* The application menu. Backup lives here rather than only in Settings so it
* is findable where people look for it, and so it works while the vault is
* locked — the file is encrypted either way.
*/
export const buildMenu = (
service: VaultService,
getWindow: () => BrowserWindow | null,
onLocked: () => void
): void => {
const report = async (
action: () => Promise<{ path: string | null; replaced?: string | null }>,
describe: (result: { path: string; replaced?: string | null }) => {
message: string;
detail: string;
}
): Promise<void> => {
const window = getWindow();
try {
const result = await action();
if (!result.path) return;
const { message, detail } = describe({ path: result.path, replaced: result.replaced });
const options = { type: 'info' as const, message, detail, buttons: ['OK'] };
if (window) await dialog.showMessageBox(window, options);
else await dialog.showMessageBox(options);
} catch (err) {
dialog.showErrorBox('dcrypt', err instanceof Error ? err.message : String(err));
}
};

const backup: MenuItemConstructorOptions = {
label: 'Back Up Vault…',
accelerator: 'CmdOrCtrl+Shift+B',
click: () =>
void report(
() => backupVault(service, getWindow()),
({ path }) => ({
message: 'Vault backed up',
detail: `Saved an encrypted copy to:\n${path}\n\nIt can only be opened with your master password, so it is safe to keep in iCloud Drive, OneDrive, Dropbox or on a USB stick.`,
})
),
};

const restore: MenuItemConstructorOptions = {
label: 'Restore from Backup…',
click: () =>
void report(
async () => {
const result = await restoreVault(service, getWindow());
if (result.path) onLocked();
return result;
},
({ path, replaced }) => ({
message: 'Vault restored',
detail: [
`Restored from:\n${path}`,
replaced ? `\nThe vault it replaced was kept as:\n${replaced}` : '',
'\nUnlock with the master password that backup was made with.',
].join('\n'),
})
),
};

const lock: MenuItemConstructorOptions = {
label: 'Lock Vault',
accelerator: 'CmdOrCtrl+L',
click: () => {
void service.lock().then(onLocked);
},
};

const template: MenuItemConstructorOptions[] = [
...(isMac
? ([
{
label: app.name,
submenu: [
{ role: 'about' },
{ type: 'separator' },
{ role: 'services' },
{ type: 'separator' },
{ role: 'hide' },
{ role: 'hideOthers' },
{ type: 'separator' },
{ role: 'quit' },
],
},
] satisfies MenuItemConstructorOptions[])
: []),
{
label: 'File',
submenu: [
backup,
restore,
{ type: 'separator' },
lock,
{ type: 'separator' },
isMac ? { role: 'close' } : { role: 'quit' },
],
},
{
label: 'Edit',
submenu: [
{ role: 'undo' },
{ role: 'redo' },
{ type: 'separator' },
{ role: 'cut' },
{ role: 'copy' },
{ role: 'paste' },
{ role: 'selectAll' },
],
},
{
label: 'View',
submenu: [
{ role: 'reload' },
{ role: 'toggleDevTools' },
{ type: 'separator' },
{ role: 'resetZoom' },
{ role: 'zoomIn' },
{ role: 'zoomOut' },
{ type: 'separator' },
{ role: 'togglefullscreen' },
],
},
{
label: 'Window',
submenu: isMac
? [{ role: 'minimize' }, { role: 'zoom' }, { type: 'separator' }, { role: 'front' }]
: [{ role: 'minimize' }, { role: 'close' }],
},
{
role: 'help',
submenu: [
{
label: 'dcrypt on GitHub',
click: () => void shell.openExternal('https://github.com/constructive-io/decryption'),
},
],
},
];

Menu.setApplicationMenu(Menu.buildFromTemplate(template));
};
10 changes: 10 additions & 0 deletions apps/desktop/src/main/vault-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,16 @@ export class VaultService {
}, 2000);
}

/** Writes any debounced edits now, so the file on disk matches the UI. */
async flush(): Promise<void> {
if (this.saveTimer) {
clearTimeout(this.saveTimer);
this.saveTimer = null;
}
await this.locking;
if (this.vault && !this.vault.isLocked) await this.vault.save();
}

current(): Vault {
if (!this.vault || this.vault.isLocked) {
throw new Error('vault is locked');
Expand Down
5 changes: 5 additions & 0 deletions apps/desktop/src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,11 @@ const api: DcryptApi & {
invoke(CHANNELS.wbShamirSplit, secret, shares, threshold),
shamirCombine: (shares) => invoke(CHANNELS.wbShamirCombine, shares),
},
backup: {
create: () => invoke(CHANNELS.backupCreate),
restore: () => invoke(CHANNELS.backupRestore),
revealVault: () => invoke(CHANNELS.backupRevealVault),
},
icons: {
lookup: (names) => invoke(CHANNELS.iconsLookup, names),
},
Expand Down
Loading
Loading