Skip to content

Commit ccb47cc

Browse files
vveerrggclaude
andcommitted
feat: Safari compat — locked sheet, read-only bypass, cached pubKey
Safari MV3 requires non-persistent background pages, which lose in-memory session state (locked, sessionKeys) on unload. This caused "locked" errors even after unlocking. - getPubKey/getRelays bypass lock check (no private key needed) - getPubKey uses cached profile.pubKey before private key fallback - Locked bottom sheet notifies users to unlock (repeatable, auto-dismiss) - Content script try/catch prevents silent timeouts on sendMessage failure - nostrAccessWhileLocked defaults to true - MainView.swift: fallback for Safari extension preferences button Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 41a6bdc commit ccb47cc

3 files changed

Lines changed: 177 additions & 12 deletions

File tree

dev/apple/Shared (App)/MainView.swift

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -188,7 +188,16 @@ struct MainView: View {
188188
#if os(macOS)
189189
private func openSafariPreferences() {
190190
SFSafariApplication.showPreferencesForExtension(withIdentifier: extensionBundleIdentifier) { error in
191-
guard error == nil else { return }
191+
if let error = error {
192+
print("[NostrKey] showPreferencesForExtension failed: \(error.localizedDescription)")
193+
// Fallback: open Safari Extensions preferences via system URL
194+
DispatchQueue.main.async {
195+
if let url = URL(string: "x-apple.systempreferences:com.apple.Safari.Extensions") {
196+
NSWorkspace.shared.open(url)
197+
}
198+
}
199+
return
200+
}
192201
DispatchQueue.main.async {
193202
NSApp.hide(nil)
194203
}

src/background.js

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -102,13 +102,14 @@ let locked = true; // start locked; determined on first isLocked check
102102
let encryptionEnabled = false; // cached encryption state for fast lookups
103103
let autoLockTimeout = 15 * 60 * 1000; // 15 minutes default
104104
let autoLockTimer = null;
105-
let nostrAccessWhileLocked = false;
105+
let nostrAccessWhileLocked = true;
106+
106107
let blockCrossOriginFrames = true;
107108

108109
// Load persisted state on startup
109110
(async () => {
110111
log('[STARTUP] Reading persisted state...');
111-
const data = await storage.get({ autoLockMinutes: 15, isEncrypted: false, passwordHash: null, nostrAccessWhileLocked: false, blockCrossOriginFrames: true });
112+
const data = await storage.get({ autoLockMinutes: 15, isEncrypted: false, passwordHash: null, nostrAccessWhileLocked: true, blockCrossOriginFrames: true });
112113
log(`[STARTUP] isEncrypted=${data.isEncrypted}, passwordHash=${data.passwordHash ? 'EXISTS' : 'null'}, autoLockMinutes=${data.autoLockMinutes}`);
113114
autoLockTimeout = data.autoLockMinutes * 60 * 1000;
114115
// Defensive: if passwordHash exists but flag is stale, self-heal
@@ -487,7 +488,7 @@ api.runtime.onMessage.addListener((message, _sender, sendResponse) => {
487488
sessionPassword = null;
488489
locked = false;
489490
encryptionEnabled = false;
490-
nostrAccessWhileLocked = false;
491+
nostrAccessWhileLocked = true;
491492
blockCrossOriginFrames = true;
492493
// Re-initialize with default profile
493494
await storage.set({
@@ -1051,12 +1052,24 @@ async function ask(uuid, { kind, host, payload }) {
10511052
const profile = await getProfile(pi);
10521053
const isBunker = profile?.type === 'bunker';
10531054

1055+
// Read-only operations (getPubKey, getRelays) work from cached data and
1056+
// don't need the private key, so they bypass the lock check entirely.
1057+
// This also fixes Safari's non-persistent background page losing session
1058+
// keys on reload — these operations still work without re-unlocking.
1059+
const needsPrivateKey = kind !== 'getPubKey' && kind !== 'getRelays';
1060+
10541061
// If the extension is locked, reject signing/encryption requests (local profiles only)
1055-
if (!isBunker) {
1062+
if (!isBunker && needsPrivateKey) {
10561063
const isLocked = await checkLockState();
10571064
if (isLocked) {
10581065
if (!(nostrAccessWhileLocked && sessionKeys.has(pi))) {
1059-
// No keys available — reject as before
1066+
// No keys available — show locked notification and reject
1067+
try {
1068+
const [activeTab] = await api.tabs.query({ active: true, currentWindow: true });
1069+
if (activeTab?.id) {
1070+
api.tabs.sendMessage(activeTab.id, { kind: 'showLockedSheet' }).catch(() => {});
1071+
}
1072+
} catch (_) {}
10601073
const sendResponse = validations[uuid];
10611074
delete validations[uuid];
10621075
sendResponse?.({ error: 'locked', message: 'Extension is locked. Please unlock with your master password.' });
@@ -1374,6 +1387,10 @@ async function getPubKey() {
13741387
return pubkey;
13751388
}
13761389

1390+
// Use cached pubKey if available (works even when locked)
1391+
if (profile.pubKey) return profile.pubKey;
1392+
1393+
// Fallback: derive from private key (requires unlocked state)
13771394
let privKey = await getPrivKey();
13781395
let pubKey = getPublicKeySync(bytesToHex(privKey));
13791396
return pubKey;

src/content.js

Lines changed: 145 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -211,14 +211,149 @@ function showPermissionSheet(host, kind, queuePosition, queueTotal) {
211211
});
212212
}
213213

214-
// Listen for permission requests from background
214+
// Locked notification sheet — shown when a site needs the private key
215+
// but the extension is locked. Shows every time until unlocked.
216+
let lockedSheetEl = null;
217+
let lockedSheetTimer = null;
218+
219+
function showLockedSheet() {
220+
// If already visible, reset the auto-dismiss timer
221+
if (lockedSheetEl && lockedSheetEl.classList.contains('active')) {
222+
if (lockedSheetTimer) clearTimeout(lockedSheetTimer);
223+
lockedSheetTimer = setTimeout(dismissLockedSheet, 5000);
224+
return;
225+
}
226+
227+
// Remove any stale sheet
228+
if (lockedSheetEl) lockedSheetEl.remove();
229+
230+
const sheet = document.createElement('div');
231+
sheet.id = 'nostrkey-locked-sheet';
232+
sheet.innerHTML = `
233+
<style>
234+
#nostrkey-locked-sheet {
235+
position: fixed;
236+
bottom: 0;
237+
left: 0;
238+
right: 0;
239+
z-index: 2147483647;
240+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
241+
pointer-events: auto;
242+
}
243+
#nostrkey-locked-sheet .nk-backdrop {
244+
position: fixed;
245+
inset: 0;
246+
background: rgba(0,0,0,0.5);
247+
opacity: 0;
248+
transition: opacity 0.2s ease;
249+
}
250+
#nostrkey-locked-sheet.active .nk-backdrop {
251+
opacity: 1;
252+
}
253+
#nostrkey-locked-sheet .nk-sheet {
254+
position: relative;
255+
background: #3e3d32;
256+
border-radius: 16px 16px 0 0;
257+
padding: 24px;
258+
transform: translateY(100%);
259+
transition: transform 0.3s ease;
260+
box-shadow: 0 -4px 20px rgba(0,0,0,0.3);
261+
}
262+
#nostrkey-locked-sheet.active .nk-sheet {
263+
transform: translateY(0);
264+
}
265+
#nostrkey-locked-sheet .nk-handle {
266+
width: 40px;
267+
height: 4px;
268+
background: #8f908a;
269+
border-radius: 2px;
270+
margin: 0 auto 16px;
271+
}
272+
#nostrkey-locked-sheet .nk-icon {
273+
font-size: 32px;
274+
text-align: center;
275+
margin-bottom: 12px;
276+
}
277+
#nostrkey-locked-sheet .nk-title {
278+
color: #e6db74;
279+
font-size: 18px;
280+
font-weight: 600;
281+
text-align: center;
282+
margin-bottom: 8px;
283+
}
284+
#nostrkey-locked-sheet .nk-text {
285+
color: #f8f8f2;
286+
font-size: 14px;
287+
text-align: center;
288+
line-height: 1.5;
289+
margin-bottom: 4px;
290+
}
291+
#nostrkey-locked-sheet .nk-muted {
292+
color: #8f908a;
293+
font-size: 13px;
294+
text-align: center;
295+
}
296+
#nostrkey-locked-sheet .nk-btn {
297+
display: block;
298+
width: 100%;
299+
padding: 14px;
300+
border-radius: 8px;
301+
border: 1px solid #a6e22e;
302+
background: rgba(166,226,46,0.1);
303+
color: #a6e22e;
304+
font-size: 16px;
305+
font-weight: 500;
306+
cursor: pointer;
307+
margin-top: 20px;
308+
transition: background 0.15s ease;
309+
}
310+
#nostrkey-locked-sheet .nk-btn:hover {
311+
background: rgba(166,226,46,0.2);
312+
}
313+
</style>
314+
<div class="nk-backdrop"></div>
315+
<div class="nk-sheet">
316+
<div class="nk-handle"></div>
317+
<div class="nk-icon">&#x1F512;</div>
318+
<div class="nk-title">NostrKey is Locked</div>
319+
<div class="nk-text">This site needs your key to sign or encrypt.</div>
320+
<div class="nk-muted">Click the NostrKey icon in your toolbar and enter your master password.</div>
321+
<button class="nk-btn">Got it</button>
322+
</div>
323+
`;
324+
document.body.appendChild(sheet);
325+
lockedSheetEl = sheet;
326+
requestAnimationFrame(() => sheet.classList.add('active'));
327+
328+
sheet.querySelector('.nk-btn').addEventListener('click', dismissLockedSheet);
329+
sheet.querySelector('.nk-backdrop').addEventListener('click', dismissLockedSheet);
330+
331+
// Auto-dismiss after 5 seconds
332+
lockedSheetTimer = setTimeout(dismissLockedSheet, 5000);
333+
}
334+
335+
function dismissLockedSheet() {
336+
if (lockedSheetTimer) { clearTimeout(lockedSheetTimer); lockedSheetTimer = null; }
337+
if (!lockedSheetEl) return;
338+
lockedSheetEl.classList.remove('active');
339+
const el = lockedSheetEl;
340+
lockedSheetEl = null;
341+
setTimeout(() => el.remove(), 300);
342+
}
343+
344+
// Listen for requests from background
215345
api.runtime.onMessage.addListener((message, sender, sendResponse) => {
216346
if (message.kind === 'showPermissionSheet') {
217347
showPermissionSheet(message.host, message.permissionKind, message.queuePosition, message.queueTotal).then(result => {
218348
sendResponse(result);
219349
});
220350
return true; // Keep channel open for async response
221351
}
352+
if (message.kind === 'showLockedSheet') {
353+
showLockedSheet();
354+
sendResponse(true);
355+
return true;
356+
}
222357
});
223358

224359
window.addEventListener('message', async message => {
@@ -238,11 +373,15 @@ window.addEventListener('message', async message => {
238373
let { kind, reqId, payload } = message.data;
239374
if (!validEvents.includes(kind)) return;
240375

241-
payload = await api.runtime.sendMessage({
242-
kind,
243-
payload,
244-
host: window.location.host,
245-
});
376+
try {
377+
payload = await api.runtime.sendMessage({
378+
kind,
379+
payload,
380+
host: window.location.host,
381+
});
382+
} catch (e) {
383+
payload = { error: 'connection_error', message: e.message || 'Failed to reach extension background' };
384+
}
246385

247386
kind = `return_${kind}`;
248387

0 commit comments

Comments
 (0)