diff --git a/js/services/storageService.js b/js/services/storageService.js index 03ba20c9..f4cbdb01 100644 --- a/js/services/storageService.js +++ b/js/services/storageService.js @@ -526,6 +526,23 @@ export class StorageService { return this.masterKeys.has(pubkey); } + /** + * True if an encrypted storage account already exists for this pubkey. Lets + * callers auto-unlock existing storage WITHOUT unlock() creating a brand-new + * account for users who never set storage up (e.g. magnet/URL-only uploaders). + */ + async hasStoredAccount(pubkey) { + if (!pubkey) { + return false; + } + try { + const account = await this._getAccount(pubkey); + return Boolean(account && account.encryptedMasterKey); + } catch (error) { + return false; + } + } + /** * Saves a connection configuration. * Requires unlock() to be called first. diff --git a/js/ui/components/UploadModal.js b/js/ui/components/UploadModal.js index e144adea..e4028689 100644 --- a/js/ui/components/UploadModal.js +++ b/js/ui/components/UploadModal.js @@ -192,6 +192,7 @@ export class UploadModal { // Initial State if (this.storageService) { const pubkey = this.getCurrentPubkey ? this.getCurrentPubkey() : null; + await this.maybeAutoUnlockStorage(pubkey); this.isStorageUnlocked = pubkey ? this.storageService.isUnlocked(pubkey) : false; await this.loadFromStorage(); } @@ -713,6 +714,7 @@ export class UploadModal { async refreshState() { if (this.storageService) { const pubkey = this.getCurrentPubkey ? this.getCurrentPubkey() : null; + await this.maybeAutoUnlockStorage(pubkey); this.isStorageUnlocked = pubkey ? this.storageService.isUnlocked(pubkey) : false; this.updateLockUi(); @@ -720,6 +722,43 @@ export class UploadModal { } } + // Silently unlock existing storage when a decrypt-capable signer is already + // available (e.g. a kept-unlocked nsec restored on refresh, TODO #51) so the + // upload modal opens Unlocked instead of Locked. Guarded on an EXISTING account + // so it never creates storage for magnet/URL-only uploaders, and on the signer + // already being present so it never triggers a fresh permission prompt. + async maybeAutoUnlockStorage(pubkey) { + if (!this.storageService || !pubkey) { + return; + } + if (this.storageService.isUnlocked(pubkey)) { + return; + } + const signer = getActiveSigner(); + if ( + !signer || + (typeof signer.nip44Decrypt !== "function" && + typeof signer.nip04Decrypt !== "function") + ) { + return; + } + try { + const hasAccount = + typeof this.storageService.hasStoredAccount === "function" + ? await this.storageService.hasStoredAccount(pubkey) + : false; + if (!hasAccount) { + return; + } + await this.storageService.unlock(pubkey, { signer }); + } catch (error) { + devLogger?.log?.( + "[UploadModal] Auto-unlock storage skipped:", + error?.message || error, + ); + } + } + async loadFromStorage() { if (!this.storageService) return; const pubkey = this.getCurrentPubkey ? this.getCurrentPubkey() : null; @@ -1222,9 +1261,11 @@ export class UploadModal { this.setGlobalModalState("upload", true); this.isVisible = true; - // Refresh lock state on open + // Refresh lock state on open. Auto-unlock first so a kept-unlocked nsec + // (restored on refresh) opens Unlocked instead of Locked (TODO #51). if (this.storageService) { const pubkey = this.getCurrentPubkey ? this.getCurrentPubkey() : null; + await this.maybeAutoUnlockStorage(pubkey); this.isStorageUnlocked = pubkey ? this.storageService.isUnlocked(pubkey) : false; this.updateLockUi(); // Always attempt load to refresh configuration status (even if locked) diff --git a/tests/storage-service.test.mjs b/tests/storage-service.test.mjs index 10322541..d08f02bd 100644 --- a/tests/storage-service.test.mjs +++ b/tests/storage-service.test.mjs @@ -277,4 +277,29 @@ describe("StorageService", () => { /Storage is locked/, ); }); + + // hasStoredAccount lets the upload modal auto-unlock EXISTING storage on refresh + // without unlock() creating a fresh account for users who never set it up (#51). + test("hasStoredAccount() is false before setup, true after, and never creates an account", async () => { + assert.strictEqual( + await storageService.hasStoredAccount(pubkey), + false, + "no account before any unlock", + ); + // A read-only existence check must not create an account. + assert.strictEqual(await storageService.hasStoredAccount(pubkey), false); + + await storageService.unlock(pubkey, { signer: mockSigner }); + assert.strictEqual( + await storageService.hasStoredAccount(pubkey), + true, + "account exists after unlock persisted the encrypted master key", + ); + + // Survives a lock (existence is on-disk, not the in-memory master key). + storageService.lock(pubkey); + assert.strictEqual(await storageService.hasStoredAccount(pubkey), true); + + assert.strictEqual(await storageService.hasStoredAccount(""), false); + }); });