diff --git a/CHANGELOG.md b/CHANGELOG.md
index a643c233..ed18037a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,20 @@ build-pipeline tweaks, and dependency bumps that don't change how the app
behaves are intentionally omitted. Releases that contained only such
changes do not appear here.
+## Unreleased
+
+### Changed
+
+- **Setting up a backup no longer waits for the first backup.** The dialog
+ closes once your passphrase is checked, and the first backup runs in the
+ background while you keep working. Settings → Backup shows its progress.
+
+### Fixed
+
+- **Leaving an organization during its first backup no longer keeps backing
+ up to the organization's folder.** That folder could become your own
+ backup folder.
+
## v1.13.0
### 2026-10-24
diff --git a/src/renderer/components/modals/BackupSetupModal.tsx b/src/renderer/components/modals/BackupSetupModal.tsx
index 311c3a94..d7f8087d 100644
--- a/src/renderer/components/modals/BackupSetupModal.tsx
+++ b/src/renderer/components/modals/BackupSetupModal.tsx
@@ -146,10 +146,10 @@ export default function BackupSetupModal({ isOpen, onClose }: BackupSetupModalPr
onConfirmation={setConfirmation}
/>
{t('backupSetup.keepSafeBody')}
- {busy &&
{t('backupSetup.working')}
}
+ {busy &&
{t('backupSetup.working')}
}
-
+
>
)}
diff --git a/src/worker/backup-service.js b/src/worker/backup-service.js
index caaf78ad..5aed663f 100644
--- a/src/worker/backup-service.js
+++ b/src/worker/backup-service.js
@@ -262,7 +262,7 @@ export class Backup extends Subsystem {
stopAt: () => this.cutoffAt,
})
this.lastRunAt = Date.now()
- if (this.config.folder !== folder) return
+ if (!this.stillNames(folder)) return
this.failures = 0
if (result.repoId !== repoId) await this.remember({ folder, repoId: result.repoId })
await this.keepKeyInFolder(folder)
@@ -280,13 +280,20 @@ export class Backup extends Subsystem {
} catch (err) {
this.lastRunAt = Date.now()
this.dirty = mergeDirty(taken, this.dirty)
- if (this.config.folder !== folder) return
+ if (!this.stillNames(folder)) return
this.failures += 1
this.report({ state: 'error', lastError: err.code || CODES.UNKNOWN })
this.log.warn('backup', reason, 'failed:', err.code || err.message)
}
}
+ // A run's outcome is recorded only while its folder is still the one in force. The organization's
+ // folder can go mid-run, before reconsider() catches config up, and recording then would make it
+ // the person's own.
+ stillNames(folder) {
+ return this.config.folder === folder && this.effectiveConfig(this.state).folder === folder
+ }
+
// The key this device keeps goes back into the folder when the folder lacks it. A newer key of this
// identity in the folder, or one on a device that keeps none (a restored one), is taken instead of
// overwritten. Another identity's key is never replaced.
@@ -324,7 +331,8 @@ export class Backup extends Subsystem {
}
// Set up in one go: the folder, then the recovery key sealed under the passphrase, written into the
- // folder and opened back from there with the same passphrase, then the first backup.
+ // folder and opened back from there with the same passphrase. The first backup starts then and runs
+ // in the background like any other, so setup answers before it ends; its status reads as running.
async setUp(folder, passphrase) {
assertPassphrase(passphrase)
this.assertFolder(folder)
@@ -337,7 +345,8 @@ export class Backup extends Subsystem {
const chosen = folderChosen(this.state, now)
await this.persist(managed ? { ...chosen, managed: { repoId: null, ...this.state.managed, folder, setupAt: now } } : chosen)
await this.configure(folder)
- return this.run('setup')
+ this.runSoon('setup')
+ return this.status()
}
async newKey(passphrase) {
diff --git a/test/frontend/scenarios/s160-backup-setup.mjs b/test/frontend/scenarios/s160-backup-setup.mjs
index f4f71850..1dd23929 100644
--- a/test/frontend/scenarios/s160-backup-setup.mjs
+++ b/test/frontend/scenarios/s160-backup-setup.mjs
@@ -58,6 +58,8 @@ export default async function s160({ runDir, bootstrap }) {
await r.ok('setup takes a folder and a passphrase in two steps', async () => {
await A.click({ role: 'button', name: 'Set up backup' })
await setUpBackup(A, backupDir, PASS)
+ // The dialog closes once the passphrase is checked; the first backup runs on behind it.
+ await A.waitText('The first backup is running', 8000)
await A.waitText("You're protected", 120000)
})
await r.ok('the one screen holds the status, the folder and the passphrase', async () => {
diff --git a/test/integration/backup-protect.test.js b/test/integration/backup-protect.test.js
index 1f94599b..9498097c 100644
--- a/test/integration/backup-protect.test.js
+++ b/test/integration/backup-protect.test.js
@@ -59,6 +59,13 @@ async function start(t, d, { masterSecret, folder = null, repoId = null, profile
return { root, fake }
}
+// Setup answers before its first backup ends; the tests that need that backup wait for it.
+async function setUpBackup(fake, folder) {
+ await fake.call('backup:setup', { folder, passphrase: PASS })
+ await waitFor(async () => (await fake.call('backup:status', {})).lastSuccessAt !== null, 20000, { label: 'the first backup' })
+ return fake.call('backup:status', {})
+}
+
async function codeOf(promise) {
try {
await promise
@@ -83,12 +90,19 @@ test('setup writes the folder, the key it can open, and the first backup', async
const { fake } = await start(t, d, { masterSecret: crypto.randomBytes(32) })
t.is((await fake.call('backup:status', {})).verdict, 'at-risk', 'nothing backed up yet')
- const status = await fake.call('backup:setup', { folder, passphrase: PASS })
+ const answered = await fake.call('backup:setup', { folder, passphrase: PASS })
+ t.is(answered.state, 'running', 'setup answers while the first backup runs')
+ t.is(answered.lastSuccessAt, null)
+ t.is(answered.verdictReason, 'first-backup')
+ t.ok(answered.key.checkedAt, 'the key was opened back before setup answered')
+ t.is(keyFiles(folder).length, 1)
+
+ await waitFor(async () => (await fake.call('backup:status', {})).lastSuccessAt !== null, 20000, { label: 'the first backup' })
+ const status = await fake.call('backup:status', {})
t.is(status.verdict, 'protected')
t.ok(status.key.reminders)
t.is(status.folder, folder)
t.is(status.state, 'idle')
- t.ok(status.lastSuccessAt, 'the first backup ran')
t.ok(status.key.createdAt)
t.ok(status.key.inFolder)
t.ok(status.key.checkedAt, 'the key was opened back with the passphrase')
@@ -112,7 +126,7 @@ test('the passphrase check and a new passphrase', async (t) => {
const d = dirs(t, 'keys')
const folder = backupFolder(t)
const { fake } = await start(t, d, { masterSecret: crypto.randomBytes(32) })
- const first = await fake.call('backup:setup', { folder, passphrase: PASS })
+ const first = await setUpBackup(fake, folder)
t.is(await codeOf(fake.call('backup:check-key', { passphrase: 'not the passphrase at all' })), 'WRONG_PASSPHRASE')
const checked = await fake.call('backup:check-key', { passphrase: PASS })
@@ -147,7 +161,7 @@ test('the last success and the key survive a restart, and a deleted key is put b
const folder = backupFolder(t)
const masterSecret = crypto.randomBytes(32)
const first = await start(t, d, { masterSecret })
- const setUp = await first.fake.call('backup:setup', { folder, passphrase: PASS })
+ const setUp = await setUpBackup(first.fake, folder)
await first.root.close()
fs.rmSync(path.join(folder, KEYS_DIR), { recursive: true, force: true })
@@ -167,7 +181,7 @@ test('REGRESSION (restored device asked for a passphrase): a device with no reco
const folder = backupFolder(t)
const masterSecret = crypto.randomBytes(32)
const first = await start(t, d, { masterSecret })
- const setUp = await first.fake.call('backup:setup', { folder, passphrase: PASS })
+ const setUp = await setUpBackup(first.fake, folder)
await first.root.close()
fs.rmSync(path.join(d.home, BACKUP_STATE_FILE))
@@ -184,7 +198,7 @@ test('a new device restores from the folder with only the passphrase', async (t)
const folder = backupFolder(t)
const source = dirs(t, 'source')
const original = await start(t, source, { masterSecret: crypto.randomBytes(32) })
- const setUp = await original.fake.call('backup:setup', { folder, passphrase: PASS })
+ const setUp = await setUpBackup(original.fake, folder)
await original.root.close()
const device = await start(t, dirs(t, 'device'), { masterSecret: crypto.randomBytes(32), profile: false })
@@ -209,7 +223,7 @@ test('a new device restores from the folder with only the passphrase', async (t)
test('a folder that holds another identity\'s backup is refused, and its key is left alone', async (t) => {
const folder = backupFolder(t)
const other = await start(t, dirs(t, 'other'), { masterSecret: crypto.randomBytes(32) })
- await other.fake.call('backup:setup', { folder, passphrase: PASS })
+ await setUpBackup(other.fake, folder)
await other.root.close()
const theirKey = keyFiles(folder)
@@ -223,7 +237,7 @@ test('a key kept for another identity is dropped after an identity change', asyn
const d = dirs(t, 'switch')
const folder = backupFolder(t)
const first = await start(t, d, { masterSecret: crypto.randomBytes(32) })
- const setUp = await first.fake.call('backup:setup', { folder, passphrase: PASS })
+ const setUp = await setUpBackup(first.fake, folder)
await first.root.close()
const other = await start(t, d, { masterSecret: crypto.randomBytes(32), folder, repoId: setUp.repoId, profile: false })
@@ -235,7 +249,7 @@ test('a key kept for another identity is dropped after an identity change', asyn
test('a folder keeping only another identity\'s key is refused, and the key stays', async (t) => {
const theirs = backupFolder(t)
const other = await start(t, dirs(t, 'keyonly-other'), { masterSecret: crypto.randomBytes(32) })
- await other.fake.call('backup:setup', { folder: theirs, passphrase: PASS })
+ await setUpBackup(other.fake, theirs)
await other.root.close()
const folder = backupFolder(t)
@@ -252,7 +266,7 @@ test('a newer key of this identity in the folder is taken, not overwritten', asy
const d = dirs(t, 'newer')
const folder = backupFolder(t)
const { fake } = await start(t, d, { masterSecret: crypto.randomBytes(32) })
- await fake.call('backup:setup', { folder, passphrase: PASS })
+ await setUpBackup(fake, folder)
const createdAt = new Date(Date.now() + 60000).toISOString()
const content = await sealRecoveryKey('a newer long passphrase', { createdAt })
@@ -279,7 +293,7 @@ test('the passphrase reminder can be turned off and on, and the verdict follows
const folder = backupFolder(t)
const masterSecret = crypto.randomBytes(32)
const first = await start(t, d, { masterSecret })
- const setUp = await first.fake.call('backup:setup', { folder, passphrase: PASS })
+ const setUp = await setUpBackup(first.fake, folder)
t.absent((await first.fake.call('backup:reminders', { enabled: false })).key.reminders)
t.ok((await first.fake.call('backup:reminders', { enabled: true })).key.reminders)
await first.fake.call('backup:reminders', { enabled: false })
@@ -298,7 +312,7 @@ test('after setup the verdict says why it is at risk: a new folder, then a folde
const d = dirs(t, 'risk')
const folder = backupFolder(t)
const { fake } = await start(t, d, { masterSecret: crypto.randomBytes(32) })
- await fake.call('backup:setup', { folder, passphrase: PASS })
+ await setUpBackup(fake, folder)
const other = backupFolder(t)
const moved = await fake.call('backup:configure', { folder: other })
diff --git a/test/integration/managed-backup.test.js b/test/integration/managed-backup.test.js
index f6798431..993cb22d 100644
--- a/test/integration/managed-backup.test.js
+++ b/test/integration/managed-backup.test.js
@@ -83,6 +83,17 @@ test('leaving the organization brings back the person\'s own (empty) backup sett
t.ok(fs.existsSync(p.view.backupFolder), 'the backup on the share stays')
})
+test('REGRESSION (FIX-5: left mid-run): leaving during the first backup never makes the organization\'s folder the person\'s own', async (t) => {
+ const share = tmpDir('managed-share-midrun', t)
+ const p = await setup(t, { share, required: false })
+ const started = await p.fake.call('backup:setup', { folder: p.view.backupFolder, passphrase: PASS })
+ t.is(started.state, 'running', 'the first backup is still running')
+ await p.fake.call('managed:leave', {})
+ await waitFor(async () => (await p.fake.call('backup:status', {})).state !== 'running', 20000, { label: 'the first backup ended' })
+ t.alike(p.ownSettings(), { folder: null, repoId: null }, 'the person\'s own setting stays empty')
+ t.is((await p.fake.call('backup:status', {})).folder, null)
+})
+
test('REGRESSION (FIX-4: inherited setup): a device backing up to its own folder is not set up on the organization\'s', async (t) => {
const share = tmpDir('managed-share-own', t)
const peer = await freshPeer(t)
@@ -91,6 +102,7 @@ test('REGRESSION (FIX-4: inherited setup): a device backing up to its own folder
setRuntimeConfig({ ...getRuntimeConfig(), managedTestSigningKey: SIGNER.publicHex, managedAllowHttpLoopback: true, managedPreviewTimeoutMs: 1500, managedHttpsPollMs: 60_000 })
await createSpace('Projects')
await peer.fake.call('backup:setup', { folder: tmpDir('managed-own-folder', t), passphrase: PASS })
+ await waitFor(async () => (await peer.fake.call('backup:status', {})).lastSuccessAt !== null, 20000, { label: 'a backup on the own folder' })
const api = await startManagedConsole(t)
const org = organization({ apiUrl: api.apiUrl })
const settings = { [backupFolderKey(devicePlatform())]: `${share}/{user}/{install}` }