diff --git a/.beads/.gitignore b/.beads/.gitignore new file mode 100644 index 000000000..96620144f --- /dev/null +++ b/.beads/.gitignore @@ -0,0 +1,34 @@ +# SQLite databases +*.db +*.db?* +*.db-journal +*.db-wal +*.db-shm + +# Daemon runtime files +daemon.lock +daemon.log +daemon.pid +bd.sock +sync-state.json + +# Local version tracking (prevents upgrade notification spam after git ops) +.local_version + +# Legacy database files +db.sqlite +bd.db + +# Merge artifacts (temporary files from 3-way merge) +beads.base.jsonl +beads.base.meta.json +beads.left.jsonl +beads.left.meta.json +beads.right.jsonl +beads.right.meta.json + +# Keep JSONL exports and config (source of truth for git) +!issues.jsonl +!interactions.jsonl +!metadata.json +!config.json diff --git a/.beads/README.md b/.beads/README.md new file mode 100644 index 000000000..50f281f03 --- /dev/null +++ b/.beads/README.md @@ -0,0 +1,81 @@ +# Beads - AI-Native Issue Tracking + +Welcome to Beads! This repository uses **Beads** for issue tracking - a modern, AI-native tool designed to live directly in your codebase alongside your code. + +## What is Beads? + +Beads is issue tracking that lives in your repo, making it perfect for AI coding agents and developers who want their issues close to their code. No web UI required - everything works through the CLI and integrates seamlessly with git. + +**Learn more:** [github.com/steveyegge/beads](https://github.com/steveyegge/beads) + +## Quick Start + +### Essential Commands + +```bash +# Create new issues +bd create "Add user authentication" + +# View all issues +bd list + +# View issue details +bd show + +# Update issue status +bd update --status in_progress +bd update --status done + +# Sync with git remote +bd sync +``` + +### Working with Issues + +Issues in Beads are: +- **Git-native**: Stored in `.beads/issues.jsonl` and synced like code +- **AI-friendly**: CLI-first design works perfectly with AI coding agents +- **Branch-aware**: Issues can follow your branch workflow +- **Always in sync**: Auto-syncs with your commits + +## Why Beads? + +✨ **AI-Native Design** +- Built specifically for AI-assisted development workflows +- CLI-first interface works seamlessly with AI coding agents +- No context switching to web UIs + +🚀 **Developer Focused** +- Issues live in your repo, right next to your code +- Works offline, syncs when you push +- Fast, lightweight, and stays out of your way + +🔧 **Git Integration** +- Automatic sync with git commits +- Branch-aware issue tracking +- Intelligent JSONL merge resolution + +## Get Started with Beads + +Try Beads in your own projects: + +```bash +# Install Beads +curl -sSL https://raw.githubusercontent.com/steveyegge/beads/main/scripts/install.sh | bash + +# Initialize in your repo +bd init + +# Create your first issue +bd create "Try out Beads" +``` + +## Learn More + +- **Documentation**: [github.com/steveyegge/beads/docs](https://github.com/steveyegge/beads/tree/main/docs) +- **Quick Start Guide**: Run `bd quickstart` +- **Examples**: [github.com/steveyegge/beads/examples](https://github.com/steveyegge/beads/tree/main/examples) + +--- + +*Beads: Issue tracking that moves at the speed of thought* ⚡ diff --git a/.beads/config.yaml b/.beads/config.yaml new file mode 100644 index 000000000..f2427856e --- /dev/null +++ b/.beads/config.yaml @@ -0,0 +1,62 @@ +# Beads Configuration File +# This file configures default behavior for all bd commands in this repository +# All settings can also be set via environment variables (BD_* prefix) +# or overridden with command-line flags + +# Issue prefix for this repository (used by bd init) +# If not set, bd init will auto-detect from directory name +# Example: issue-prefix: "myproject" creates issues like "myproject-1", "myproject-2", etc. +# issue-prefix: "" + +# Use no-db mode: load from JSONL, no SQLite, write back after each command +# When true, bd will use .beads/issues.jsonl as the source of truth +# instead of SQLite database +# no-db: false + +# Disable daemon for RPC communication (forces direct database access) +# no-daemon: false + +# Disable auto-flush of database to JSONL after mutations +# no-auto-flush: false + +# Disable auto-import from JSONL when it's newer than database +# no-auto-import: false + +# Enable JSON output by default +# json: false + +# Default actor for audit trails (overridden by BD_ACTOR or --actor) +# actor: "" + +# Path to database (overridden by BEADS_DB or --db) +# db: "" + +# Auto-start daemon if not running (can also use BEADS_AUTO_START_DAEMON) +# auto-start-daemon: true + +# Debounce interval for auto-flush (can also use BEADS_FLUSH_DEBOUNCE) +# flush-debounce: "5s" + +# Git branch for beads commits (bd sync will commit to this branch) +# IMPORTANT: Set this for team projects so all clones use the same sync branch. +# This setting persists across clones (unlike database config which is gitignored). +# Can also use BEADS_SYNC_BRANCH env var for local override. +# If not set, bd sync will require you to run 'bd config set sync.branch '. +# sync-branch: "beads-sync" + +# Multi-repo configuration (experimental - bd-307) +# Allows hydrating from multiple repositories and routing writes to the correct JSONL +# repos: +# primary: "." # Primary repo (where this database lives) +# additional: # Additional repos to hydrate from (read-only) +# - ~/beads-planning # Personal planning repo +# - ~/work-planning # Work planning repo + +# Integration settings (access with 'bd config get/set') +# These are stored in the database, not in this file: +# - jira.url +# - jira.project +# - linear.url +# - linear.api-key +# - github.org +# - github.repo diff --git a/.beads/interactions.jsonl b/.beads/interactions.jsonl new file mode 100644 index 000000000..e69de29bb diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl new file mode 100644 index 000000000..381a7eebf --- /dev/null +++ b/.beads/issues.jsonl @@ -0,0 +1,16 @@ +{"id":"damus-1aq","title":"NIP-07: Add Safari extension setup UI in Damus settings","description":"Add settings section to guide users to enable extension in Safari settings.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-05T22:49:03.243728-06:00","created_by":"e","updated_at":"2026-01-05T23:28:04.327943-06:00","closed_at":"2026-01-05T23:28:04.327943-06:00","close_reason":"Added SafariExtensionSettingsView with setup instructions and Open Settings button. Added route and navigation link in ConfigView.","labels":["nip-07"]} +{"id":"damus-1ri","title":"Comingle: Support read-only mode with external signer","description":"When using external signer, user doesn't have local keypair. Support login with just npub, then delegate all signing to external signer. Update SignInView to allow npub-only login when external signer is configured.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-05T23:33:14.609065-06:00","created_by":"e","updated_at":"2026-01-05T23:58:31.7734-06:00","closed_at":"2026-01-05T23:58:31.7734-06:00","close_reason":"Updated SignInView to show appropriate footer when using npub with external signer. Added @AppStorage for useExternalSigner preference.","labels":["comingle","dip-05"]} +{"id":"damus-399","title":"NIP-07: Align Damoose App Group with main app (group.com.damus)","description":"Ensure Damoose extension uses same App Group as main app for shared UserDefaults access.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-05T22:48:56.894774-06:00","created_by":"e","updated_at":"2026-01-05T22:56:27.895164-06:00","closed_at":"2026-01-05T22:56:27.895164-06:00","close_reason":"Closed","labels":["nip-07"]} +{"id":"damus-5vq","title":"NIP-07: Wire popup approval UI to SigningPolicyManager","description":"signEvent now works directly in extension. Popup approval UI is functional. TODO: Integrate with SigningPolicyManager for remembered permissions and kind-based policies.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-05T22:48:56.800249-06:00","created_by":"e","updated_at":"2026-01-05T23:25:45.012115-06:00","closed_at":"2026-01-05T23:25:45.012115-06:00","close_reason":"Implemented permission storage and remember checkbox. Popup now checks saved permissions before showing. JS and Swift handlers updated.","labels":["nip-07"]} +{"id":"damus-624","title":"NIP-07: Restore jb55's Damoose extension from origin/extension branch","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-05T22:48:45.202461-06:00","created_by":"e","updated_at":"2026-01-05T22:51:55.963827-06:00","closed_at":"2026-01-05T22:51:55.963827-06:00","close_reason":"Restored Damoose extension from origin/extension, added target to Xcode project, fixed Info.plist","labels":["nip-07"]} +{"id":"damus-77k","title":"Comingle: Register callback URL scheme for signer responses","description":"Register comingle:// (or similar) URL scheme in Info.plist to receive signed event callbacks from Damus signer. Handle incoming URL and complete pending signing requests.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-05T23:33:14.510039-06:00","created_by":"e","updated_at":"2026-01-05T23:56:54.669006-06:00","closed_at":"2026-01-05T23:56:54.669006-06:00","close_reason":"Registered comingle:// URL scheme in Info.plist CFBundleURLTypes. Added handleCallback in ExternalSigner and onOpenURL handler in ComingleApp.","labels":["comingle","dip-05"]} +{"id":"damus-81o","title":"Comingle: Add external signer selection UI in settings","description":"Add settings screen for users to choose between local signing (keypair in keychain) vs external signer (Damus via NIP-55). Store preference in UserDefaults.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-05T23:33:14.305841-06:00","created_by":"e","updated_at":"2026-01-05T23:57:48.881982-06:00","closed_at":"2026-01-05T23:57:48.881982-06:00","close_reason":"Created SignerSettingsView with toggle for external signer and status display. Added navigation link in SettingsView. Uses @AppStorage for persistence.","labels":["comingle","dip-05"]} +{"id":"damus-964","title":"Comingle: Refactor CreateProfileView for external signing","description":"Apply the external signing pattern to CreateProfileView. Add UnsignedEventBuilder methods for metadata and followList events. Use ExternalSigner when no local keypair.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-06T00:01:45.288429-06:00","created_by":"e","updated_at":"2026-01-06T00:13:08.364447-06:00","closed_at":"2026-01-06T00:13:08.364447-06:00","close_reason":"Added UnsignedEventBuilder.metadataEvent and followListEvent for completeness. CreateProfileView is for local keypair creation - external signer users should use SignInView with their npub since they already have keys in Damus.","labels":["comingle","dip-05"]} +{"id":"damus-gtf","title":"NIP-07: Implement signEvent in native handler","description":"signEvent stores request in shared storage for main app to process. Requires JS-side updates to open nostrsigner:// URL and poll via checkResult. Native handler checkResult implemented for polling.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-05T22:48:56.706037-06:00","created_by":"e","updated_at":"2026-01-05T23:00:49.517762-06:00","closed_at":"2026-01-05T23:00:49.517762-06:00","close_reason":"Closed","labels":["nip-07"]} +{"id":"damus-h5x","title":"Comingle: Refactor EventCreating to support async external signing","description":"Current signing is synchronous via signedBy: keypair. Refactor to support async flow: build unsigned event, call nostrsigner://, await callback with signed event. Affects CreateOrModifyCalendarView, CreateOrModifyEventView, EventView, CreateProfileView.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-05T23:33:14.409777-06:00","created_by":"e","updated_at":"2026-01-06T00:00:16.588338-06:00","closed_at":"2026-01-06T00:00:16.588338-06:00","close_reason":"Created EventSigningService and UnsignedEventBuilder utilities. Refactored EventView.createOrUpdateRSVP to support both local and external signing. External signing uses ExternalSigner.requestSignEvent with callback handling.","labels":["comingle","dip-05"]} +{"id":"damus-i7r","title":"Examine Comingle iOS app for gaps against Damus signer interaction","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-05T22:53:43.645549-06:00","created_by":"e","updated_at":"2026-01-05T23:32:12.099974-06:00","closed_at":"2026-01-05T23:32:12.099974-06:00","close_reason":"Analyzed Comingle iOS app. Gaps: No NIP-55 support (nostrsigner://), no external signer UI, hard-coded local signing via NostrSDK EventCreating protocol, no read-only mode for signing operations."} +{"id":"damus-mfz","title":"Comingle: Refactor CreateOrModifyCalendarView for external signing","description":"Apply the external signing pattern to CreateOrModifyCalendarView. Add UnsignedEventBuilder.calendarList and use ExternalSigner for calendar creation when no local keypair.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-06T00:01:45.188278-06:00","created_by":"e","updated_at":"2026-01-06T00:12:17.083002-06:00","closed_at":"2026-01-06T00:12:17.083002-06:00","close_reason":"Added UnsignedEventBuilder.calendarListEvent. Refactored ViewModel with canSign, isSaving, saveError, onSaveSuccess. Updated save to support local and external signing.","labels":["comingle","dip-05"]} +{"id":"damus-qtm","title":"Comingle: Refactor CreateOrModifyEventView for external signing","description":"Apply the external signing pattern from EventView to CreateOrModifyEventView. Use UnsignedEventBuilder.timeBasedCalendarEvent and ExternalSigner for event creation when no local keypair.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-06T00:01:45.075757-06:00","created_by":"e","updated_at":"2026-01-06T00:10:55.545802-06:00","closed_at":"2026-01-06T00:10:55.545802-06:00","close_reason":"Added canSign check, refactored saveEvent to support local and external signing, added isSaving state and error handling, updated toolbar with progress indicator.","labels":["comingle","dip-05"]} +{"id":"damus-qz8","title":"Update Damoose JS to handle signEvent delegation (open URL, poll checkResult)","description":"The native handler now stores signEvent requests. JS side needs to: 1) Detect nil response from signEvent, 2) Open nostrsigner:// URL, 3) Poll checkResult for signed event","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-05T23:01:00.455983-06:00","created_by":"e","updated_at":"2026-01-05T23:17:31.404215-06:00","closed_at":"2026-01-05T23:17:31.404215-06:00","close_reason":"Closed"} +{"id":"damus-s9o","title":"Comingle: Add DIP-05 nostrsigner:// URL scheme handler","description":"Implement nostrsigner:// URL scheme handling to delegate signing to external apps like Damus. Register URL scheme in Info.plist and handle incoming callbacks.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-05T23:32:44.114107-06:00","created_by":"e","updated_at":"2026-01-05T23:56:54.588949-06:00","closed_at":"2026-01-05T23:56:54.588949-06:00","close_reason":"Created ExternalSigner.swift with buildSignEventURL, buildGetPublicKeyURL, requestSignEvent, requestPublicKey methods. Added nostrsigner to LSApplicationQueriesSchemes.","labels":["comingle","dip-05"]} +{"id":"damus-ydw","title":"NIP-07: Wire native handler to SharedKeychainStorage for getPublicKey","description":"Replace hardcoded pubkey in SafariWebExtensionHandler with SharedKeychainStorage.getPublicKey()","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-05T22:48:56.611017-06:00","created_by":"e","updated_at":"2026-01-05T22:56:27.865697-06:00","closed_at":"2026-01-05T22:56:27.865697-06:00","close_reason":"Closed","labels":["nip-07"]} diff --git a/.beads/metadata.json b/.beads/metadata.json new file mode 100644 index 000000000..c787975e1 --- /dev/null +++ b/.beads/metadata.json @@ -0,0 +1,4 @@ +{ + "database": "beads.db", + "jsonl_export": "issues.jsonl" +} \ No newline at end of file diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..807d5983d --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ + +# Use bd merge for beads JSONL files +.beads/issues.jsonl merge=beads diff --git a/AGENTS.md b/AGENTS.md index a9a7d1e8c..504fc62db 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,3 +44,29 @@ Damus is an iOS client built around a local relay model ([damus-io/damus#3204](h 7. Review and follow `pull_request_template.md` when creating PRs for iOS Damus. 8. Ensure nevernesting: favor early returns and guard clauses over deeply nested conditionals; simplify control flow by exiting early instead of wrapping logic in multiple layers of `if` statements. 9. Before proposing changes, please **review and analyze if a change or upgrade to nostrdb** is beneficial to the change at hand. + +## Landing the Plane (Session Completion) + +**When ending a work session**, you MUST complete ALL steps below. Work is NOT complete until `git push` succeeds. + +**MANDATORY WORKFLOW:** + +1. **File issues for remaining work** - Create issues for anything that needs follow-up +2. **Run quality gates** (if code changed) - Tests, linters, builds +3. **Update issue status** - Close finished work, update in-progress items +4. **PUSH TO REMOTE** - This is MANDATORY: + ```bash + git pull --rebase + bd sync + git push + git status # MUST show "up to date with origin" + ``` +5. **Clean up** - Clear stashes, prune remote branches +6. **Verify** - All changes committed AND pushed +7. **Hand off** - Provide context for next session + +**CRITICAL RULES:** +- Work is NOT complete until `git push` succeeds +- NEVER stop before pushing - that leaves work stranded locally +- NEVER say "ready to push when you are" - YOU must push +- If push fails, resolve and retry until it succeeds diff --git a/Damoose/Damoose.entitlements b/Damoose/Damoose.entitlements new file mode 100644 index 000000000..960f2a9b6 --- /dev/null +++ b/Damoose/Damoose.entitlements @@ -0,0 +1,18 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.network.client + + com.apple.security.application-groups + + group.com.damus + + keychain-access-groups + + $(AppIdentifierPrefix)com.jb55.damus2 + + + diff --git a/Damoose/Info.plist b/Damoose/Info.plist new file mode 100644 index 000000000..4e0d33837 --- /dev/null +++ b/Damoose/Info.plist @@ -0,0 +1,31 @@ + + + + + CFBundleDisplayName + Damoose + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + NSExtension + + NSExtensionPointIdentifier + com.apple.Safari.web-extension + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).SafariWebExtensionHandler + + NSHumanReadableCopyright + Copyright 2024 Damus. All rights reserved. + + diff --git a/Damoose/Resources/_locales/en/messages.json b/Damoose/Resources/_locales/en/messages.json new file mode 100644 index 000000000..687db3448 --- /dev/null +++ b/Damoose/Resources/_locales/en/messages.json @@ -0,0 +1,14 @@ +{ + "extension_name": { + "message": "Damoose", + "description": "The display name for the extension." + }, + "extension_description": { + "message": "The Damus nostr safari companion. Protect your key and create web highlights.", + "description": "Description of what the extension does." + }, + "menuHighlight": { + "message": "Highlight", + "description": "The context menu item for the highlight action" + } +} diff --git a/Damoose/Resources/background.js b/Damoose/Resources/background.js new file mode 100644 index 000000000..a97dd7c15 --- /dev/null +++ b/Damoose/Resources/background.js @@ -0,0 +1,43 @@ +browser.runtime.onMessage.addListener((message, _sender, sendResponse) => { + switch (message.kind) { + case 'approve': + // Include remember flag and origin for permission storage + const nativePayload = { + ...message.payload, + remember: message.remember ?? false, + origin: message.origin ?? "" + } + return browser.runtime.sendNativeMessage("damoose", nativePayload) + + case 'deny': + return Promise.resolve() + + case 'checkPermission': + // Check if permission is already saved for this origin+kind + return browser.runtime.sendNativeMessage("damoose", { + kind: 'checkPermission', + payload: message.payload + }) + } +}); + + +function setup_highlighter() { + browser.contextMenus.create({ + id: "damoose-highlighter", + title: browser.i18n.getMessage("menuHighlight"), + contexts: ["selection", "image", "link"], + }); + + browser.contextMenus.onClicked.addListener((info, tab) => { + const { selectionText, srcUrl, mediaType, linkUrl, pageUrl } = info; + const value = mediaType === 'image' ? srcUrl : (linkUrl || selectionText); + + browser.runtime.sendNativeMessage("damoose", { + kind: "highlight", + payload: { mediaType, value, selectionText, pageUrl } + }); + }); +} + +setup_highlighter() diff --git a/Damoose/Resources/content.js b/Damoose/Resources/content.js new file mode 100644 index 000000000..b3af2ead1 --- /dev/null +++ b/Damoose/Resources/content.js @@ -0,0 +1,184 @@ + +let host_state = { + requests: {}, + reqids: 0, + approved: {}, + iframe: null, // Reference to popup iframe for origin validation +} + +function setup_nip07() { + let script = document.createElement('script'); + script.setAttribute('src', browser.runtime.getURL('nostr.js')); + document.body.appendChild(script); + console.log("Added Damoose the nostr helper to the page.") +} + +function queue_request(d, message) { + d.requests[message.reqId] = message +} + +function get_request(d, id) { + return d.requests[id] +} + +function setup_iframe() { + const iframe = document.createElement('iframe'); + // Create an iframe for the secure popup + iframe.src = browser.runtime.getURL('popup.html'); // Load the secure popup HTML + iframe.style.position = 'fixed'; + iframe.style.bottom = '0'; // Align the iframe to the bottom of the screen + iframe.style.left = '0'; + iframe.style.width = '100%'; // Make the iframe span the entire width of the screen + iframe.style.height = '50%'; // Make the iframe cover the bottom half of the screen + iframe.style.borderTop = '2px solid black'; + iframe.style.zIndex = '10000'; // Ensure it's on top of other elements + iframe.style.display = 'none'; // Initially hidden + iframe.style.backgroundColor = 'white'; // Opaque background + + // Add sandbox attributes to prevent host page access + iframe.sandbox = 'allow-scripts allow-same-origin'; + + // Append the iframe to the body + document.body.appendChild(iframe); + + // Store reference for origin validation + host_state.iframe = iframe; + + function show_popup() { + iframe.style.display = 'block' + } + + function hide_popup() { + iframe.style.display = 'none' + } + + // Function to show the iframe popup + function toggle_popup() { + if (iframe.style.display === 'block') + hide_popup() + else + show_popup() + } + + // Example trigger + document.addEventListener('keydown', function(event) { + if (event.key === 'o') { // Press 'o' to show the iframe popup + toggle_popup(); + } + }); + + window.addEventListener('message', async message => { + // NIP-07 events from injected nostr.js (come from window/self) + const nip07Events = [ + 'getPubKey', + 'signEvent', + 'getRelays', + 'nip04.encrypt', + 'nip04.decrypt', + ]; + + // Popup events from the iframe + const popupEvents = [ + 'popup_initialized', + 'approve', + 'deny', + ]; + + let { kind, reqId, payload } = message.data; + + // Validate message origin for security + if (popupEvents.includes(kind)) { + // Popup messages must come from our iframe + if (message.source !== iframe.contentWindow) { + console.warn('Damoose: Ignoring popup message from unauthorized source'); + return; + } + } else if (nip07Events.includes(kind)) { + // NIP-07 messages come from the page's window context (nostr.js) + // message.source === window for same-origin messages + if (message.source !== window) { + console.warn('Damoose: Ignoring NIP-07 message from unauthorized source'); + return; + } + } else { + // Unknown message type + return; + } + + if (kind === 'popup_initialized') { + if (browser.runtime.lastError) + console.log(browser.runtime.lastError) + + if (Object.keys(host_state.requests).length !== 0) { + // send initial requests + + iframe.contentWindow.postMessage({ + kind: 'requests', + host: window.location.host, + payload: host_state.requests, + }, '*') + + show_popup() + } + } else if (kind === "approve" || kind === "deny") { + // response from the iframe that we're approving or denying + // a set of requests + + hide_popup() + + const { remember, origin } = message.data + const result = await browser.runtime.sendMessage({ kind, reqId, payload, remember, origin }) + + console.log("%s %s result:", kind, payload.kind, result); + + kind = `return_${payload.kind}`; + window.postMessage({kind, reqId: payload.reqId, payload: result}, '*'); + + console.log(`${kind} extension result: ${result}`) + } else { + // Check if we have a saved permission for this request type + const requestKind = message.data.payload?.kind + if (message.data.kind === 'signEvent' && requestKind !== undefined) { + // Check permission before showing popup + const permissionResult = await browser.runtime.sendMessage({ + kind: 'checkPermission', + payload: { kind: requestKind, origin: window.location.host } + }) + + if (permissionResult?.approved) { + // Permission already granted, sign directly + const signPayload = { + ...message.data, + remember: false, + origin: window.location.host + } + const result = await browser.runtime.sendMessage({ + kind: 'approve', + payload: signPayload, + remember: false, + origin: window.location.host + }) + const returnKind = `return_${message.data.kind}` + window.postMessage({kind: returnKind, reqId: message.data.reqId, payload: result}, '*') + return + } + } + + // No saved permission, show popup for approval + queue_request(host_state, message.data) + iframe.contentWindow.postMessage({ + kind: 'request', + payload: message.data, + host: window.location.host + }, '*') + + show_popup() + } + }); +} + + +// NIP-07 signer +setup_nip07() +setup_iframe() + diff --git a/Damoose/Resources/images/icon-128.png b/Damoose/Resources/images/icon-128.png new file mode 100644 index 000000000..2139d1139 Binary files /dev/null and b/Damoose/Resources/images/icon-128.png differ diff --git a/Damoose/Resources/images/icon-256.png b/Damoose/Resources/images/icon-256.png new file mode 100644 index 000000000..2c1ad8edb Binary files /dev/null and b/Damoose/Resources/images/icon-256.png differ diff --git a/Damoose/Resources/images/icon-48.png b/Damoose/Resources/images/icon-48.png new file mode 100644 index 000000000..1290bff48 Binary files /dev/null and b/Damoose/Resources/images/icon-48.png differ diff --git a/Damoose/Resources/images/icon-512.png b/Damoose/Resources/images/icon-512.png new file mode 100644 index 000000000..8bee44838 Binary files /dev/null and b/Damoose/Resources/images/icon-512.png differ diff --git a/Damoose/Resources/images/icon-64.png b/Damoose/Resources/images/icon-64.png new file mode 100644 index 000000000..87690a8da Binary files /dev/null and b/Damoose/Resources/images/icon-64.png differ diff --git a/Damoose/Resources/images/icon-96.png b/Damoose/Resources/images/icon-96.png new file mode 100644 index 000000000..68896db6d Binary files /dev/null and b/Damoose/Resources/images/icon-96.png differ diff --git a/Damoose/Resources/images/toolbar-icon.svg b/Damoose/Resources/images/toolbar-icon.svg new file mode 100644 index 000000000..44b3849a0 --- /dev/null +++ b/Damoose/Resources/images/toolbar-icon.svg @@ -0,0 +1,184 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Damoose/Resources/manifest.json b/Damoose/Resources/manifest.json new file mode 100644 index 000000000..23eec1bee --- /dev/null +++ b/Damoose/Resources/manifest.json @@ -0,0 +1,50 @@ +{ + "manifest_version": 3, + "default_locale": "en", + + "name": "__MSG_extension_name__", + "description": "__MSG_extension_description__", + "version": "1.0", + + "icons": { + "48": "images/icon-48.png", + "96": "images/icon-96.png", + "128": "images/icon-128.png", + "256": "images/icon-256.png", + "512": "images/icon-512.png" + }, + + "background": { + "scripts": [ "background.js" ], + "type": "module" + }, + + "content_scripts": [{ + "js": [ "content.js" ], + "matches": [ "*://*/*" ] + }], + + "web_accessible_resources": [ + { + "resources": [ + "nostr.js", + "popup.js", + "popup.html" + ], + "matches": [ + "" + ] + } + ], + + "content_security_policy": { + "extension_pages": "script-src 'self'" + }, + + "action": { + "default_popup": "popup.html", + "default_icon": "images/toolbar-icon.svg" + }, + + "permissions": [ "nativeMessaging", "contextMenus" ] +} diff --git a/Damoose/Resources/nostr.js b/Damoose/Resources/nostr.js new file mode 100644 index 000000000..f071ded9d --- /dev/null +++ b/Damoose/Resources/nostr.js @@ -0,0 +1,70 @@ +// +// nostr.js +// +// Code from nostore +// + + +window.nostr = { + requests: {}, + + async getPublicKey() { + return await this.broadcast('getPubKey'); + }, + + async signEvent(event) { + return await this.broadcast('signEvent', event); + }, + + async getRelays() { + return await this.broadcast('getRelays'); + }, + + // This is here for Alby comatibility. This is not part of the NIP-07 standard. + // I have found at least one site, nostr.band, which expects it to be present. + async enable() { + return { enabled: true }; + }, + + broadcast(kind, payload) { + let reqId = Math.random().toString(); + return new Promise((resolve, _reject) => { + this.requests[reqId] = resolve; + window.postMessage({ kind, reqId, payload }, '*'); + }); + }, + + nip04: { + async encrypt(pubKey, plainText) { + return await window.nostr.broadcast('nip04.encrypt', { + pubKey, + plainText, + }); + }, + + async decrypt(pubKey, cipherText) { + return await window.nostr.broadcast('nip04.decrypt', { + pubKey, + cipherText, + }); + }, + }, +}; + + +window.addEventListener('message', message => { + const validEvents = [ + 'getPubKey', + 'signEvent', + 'getRelays', + 'nip04.encrypt', + 'nip04.decrypt', + ].map(e => `return_${e}`); + let { kind, reqId, payload } = message.data; + + if (!validEvents.includes(kind)) return; + + window.nostr.requests[reqId]?.(payload); + delete window.nostr.requests[reqId]; +}); + diff --git a/Damoose/Resources/popup.css b/Damoose/Resources/popup.css new file mode 100644 index 000000000..4ec624481 --- /dev/null +++ b/Damoose/Resources/popup.css @@ -0,0 +1,14 @@ +:root { + color-scheme: light dark; +} + +body { + /* width: 100px;*/ + padding: 10px; + + font-family: system-ui; +} + +@media (prefers-color-scheme: dark) { + /* Dark Mode styles go here. */ +} diff --git a/Damoose/Resources/popup.html b/Damoose/Resources/popup.html new file mode 100644 index 000000000..3519214b7 --- /dev/null +++ b/Damoose/Resources/popup.html @@ -0,0 +1,14 @@ + + + + + + + + + THE DAMOOSE IS HERE! +
+ +
+ + diff --git a/Damoose/Resources/popup.js b/Damoose/Resources/popup.js new file mode 100644 index 000000000..53363dbfa --- /dev/null +++ b/Damoose/Resources/popup.js @@ -0,0 +1,121 @@ + +let requests = {} +var host + +window.addEventListener('message', message => { + const { kind, payload } = message.data; + if (message.data.host) + host = message.data.host + + if (kind === 'requests') { + // payload IS the requests object directly from content.js + requests = Object.assign({}, requests, payload) + } else if (kind === 'request') { + requests[payload.reqId] = payload + } + + update_view(host, requests) +}); + +function summarize_requests(rs) { + let grouped = {} + for (const key of Object.keys(rs)) { + const {reqId, kind, payload} = rs[key] + if (grouped[kind] == null) { + grouped[kind] = [] + } + + grouped[kind].push({reqId, payload}) + } + + return grouped +} + +function update_view(host, rs) { + const reqs = document.getElementById("requests") + const groups = summarize_requests(rs) + + // Clear existing content safely + reqs.textContent = '' + + // Create elements using DOM APIs to prevent XSS + const pre = document.createElement('pre') + pre.textContent = host + reqs.appendChild(pre) + + reqs.appendChild(document.createTextNode(' is requesting:')) + + const ul = document.createElement('ul') + for (const kind of Object.keys(groups)) { + const li = document.createElement('li') + const num = groups[kind].length > 1 ? ` x${groups[kind].length}` : '' + li.textContent = kind + num + ul.appendChild(li) + } + reqs.appendChild(ul) + + const label = document.createElement('label') + const checkbox = document.createElement('input') + checkbox.type = 'checkbox' + checkbox.id = 'remember' + label.appendChild(checkbox) + label.appendChild(document.createTextNode(' Remember this permission')) + reqs.appendChild(label) + + const buttonDiv = document.createElement('div') + buttonDiv.style.marginTop = '10px' + + const approveBtn = document.createElement('button') + approveBtn.id = 'approve' + approveBtn.textContent = 'Approve' + approveBtn.addEventListener('click', approve) + + const denyBtn = document.createElement('button') + denyBtn.id = 'deny' + denyBtn.textContent = 'Deny' + denyBtn.addEventListener('click', deny) + + buttonDiv.appendChild(approveBtn) + buttonDiv.appendChild(denyBtn) + reqs.appendChild(buttonDiv) +} + +function act(msgKind) { + const remember = document.getElementById("remember")?.checked ?? false + for (const reqId of Object.keys(requests)) { + const payload = requests[reqId] + // Include remember flag and origin for permission storage + const message = { + kind: msgKind, + reqId, + payload, + remember, + origin: host + } + window.parent.postMessage(message, '*') + delete requests[reqId] + } +} + +function approve() { + act("approve") +} + +function deny() { + act("deny") +} + +// let the page know the popup iframe is ready to receive nip07 requests for +// approval/disapproval +function popup_initialized() { + window.parent.postMessage({ kind: "popup_initialized" }, '*'); +} + +function resolve_request(reqId, kind, payload) { + window.parent.postMessage({ kind, reqId, payload }, '*'); +} + +popup_initialized() + + + diff --git a/Damoose/SafariWebExtensionHandler.swift b/Damoose/SafariWebExtensionHandler.swift new file mode 100644 index 000000000..ae099800c --- /dev/null +++ b/Damoose/SafariWebExtensionHandler.swift @@ -0,0 +1,421 @@ +// +// SafariWebExtensionHandler.swift +// Damoose +// +// Created by William Casarin on 8/5/24. +// + +import SafariServices +import os.log +import Foundation +import Security +import CommonCrypto +import secp256k1 + +/// NIP-07 request types from the Safari extension JavaScript. +enum DamooseRequest { + case getPublicKey + case signEvent(SignEventPayload, remember: Bool, origin: String) + case checkPermission(kind: Int, origin: String) + case getRelays + case nip04_encrypt(Nip04EncryptPayload) + case nip04_decrypt(Nip04DecryptPayload) +} + +/// Response types returned to the Safari extension JavaScript. +enum DamooseResponse { + case pubkey(String) + case signedEvent(SignedEvent) + case permissionApproved + + /// The raw value to send back to JavaScript. + var val: Any { + switch self { + case .pubkey(let string): + string + case .signedEvent(let signedEvent): + signedEvent + case .permissionApproved: + ["approved": true] + } + } +} + + + +/// Unsigned event payload for signing requests. +struct SignEventPayload: Codable { + let created_at: Int + let kind: Int + let tags: [[String]] + let content: String +} + +/// Signed nostr event with id, signature, and pubkey. +struct SignedEvent: Codable { + let created_at: Int + let kind: Int + let tags: [[String]] + let content: String + let id: String + let sig: String + let pubkey: String +} + +/// Payload for NIP-04 encryption requests. +struct Nip04EncryptPayload: Codable { + let pubkey: String + let plaintext: String +} + +/// Payload for NIP-04 decryption requests. +struct Nip04DecryptPayload: Codable { + let pubkey: String + let ciphertext: String +} + +// MARK: - Shared Storage Constants + +private let damooseAppGroupId = "group.com.damus" +private let damoosePubkeyDefaultsKey = "pubkey" +private let damooseKeychainService = "damus" +private let damoosePrivkeyAccount = "privkey" + +/// Reads the stored public key from shared UserDefaults. +func getStoredPublicKey() -> String? { + guard let defaults = UserDefaults(suiteName: damooseAppGroupId) else { + os_log(.error, "Failed to access app group UserDefaults") + return nil + } + return defaults.string(forKey: damoosePubkeyDefaultsKey) +} + +/// Reads the stored private key from keychain. +func getStoredPrivateKey() -> String? { + let query: [CFString: Any] = [ + kSecClass: kSecClassGenericPassword, + kSecAttrService: damooseKeychainService, + kSecAttrAccount: damoosePrivkeyAccount, + kSecReturnData: true, + kSecMatchLimit: kSecMatchLimitOne + ] + + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + + guard status == errSecSuccess, + let data = result as? Data, + let hex = String(data: data, encoding: .utf8) else { + return nil + } + + return hex.trimmingCharacters(in: .whitespaces) +} + +// MARK: - Permission Storage + +private let damoosePermissionsKey = "damoose_permissions" + +/// Builds a unique key for a permission (origin + kind). +private func permissionKey(origin: String, kind: Int) -> String { + return "\(origin):\(kind)" +} + +/// Checks if a permission has been approved for a given origin and event kind. +func isPermissionApproved(kind: Int, origin: String) -> Bool { + guard let defaults = UserDefaults(suiteName: damooseAppGroupId) else { + return false + } + let permissions = defaults.dictionary(forKey: damoosePermissionsKey) as? [String: Bool] ?? [:] + let key = permissionKey(origin: origin, kind: kind) + return permissions[key] == true +} + +/// Saves an approved permission for a given origin and event kind. +func savePermission(kind: Int, origin: String) { + guard let defaults = UserDefaults(suiteName: damooseAppGroupId) else { + os_log(.error, "Failed to access app group UserDefaults for saving permission") + return + } + var permissions = defaults.dictionary(forKey: damoosePermissionsKey) as? [String: Bool] ?? [:] + let key = permissionKey(origin: origin, kind: kind) + permissions[key] = true + defaults.set(permissions, forKey: damoosePermissionsKey) + os_log(.info, "Saved permission for %@ kind %d", origin, kind) +} + +// MARK: - Crypto Helpers + +/// Decodes a hex string to bytes. +func hexDecode(_ hex: String) -> [UInt8]? { + guard hex.count % 2 == 0 else { return nil } + var bytes = [UInt8]() + var index = hex.startIndex + while index < hex.endIndex { + let nextIndex = hex.index(index, offsetBy: 2) + guard let byte = UInt8(hex[index.. String { + return bytes.map { String(format: "%02x", $0) }.joined() +} + +/// Computes SHA256 hash of data. +func sha256(_ data: Data) -> Data { + var hash = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH)) + data.withUnsafeBytes { + _ = CC_SHA256($0.baseAddress, CC_LONG(data.count), &hash) + } + return Data(hash) +} + +/// Generates random bytes for schnorr signing. +/// +/// - Parameter count: Number of random bytes to generate. +/// - Returns: Random bytes, or nil if generation failed. +func randomBytes(count: Int) -> [UInt8]? { + var bytes = [UInt8](repeating: 0, count: count) + let status = SecRandomCopyBytes(kSecRandomDefault, count, &bytes) + guard status == errSecSuccess else { + os_log(.error, "SecRandomCopyBytes failed with status: %d", status) + return nil + } + return bytes +} + +/// Computes the nostr event commitment JSON for hashing. +func eventCommitment(pubkey: String, createdAt: Int, kind: Int, tags: [[String]], content: String) -> String { + let encoder = JSONEncoder() + encoder.outputFormatting = .withoutEscapingSlashes + let contentJson = (try? encoder.encode(content)).flatMap { String(data: $0, encoding: .utf8) } ?? "\"\"" + let tagsJson = (try? encoder.encode(tags)).flatMap { String(data: $0, encoding: .utf8) } ?? "[]" + return "[0,\"\(pubkey)\",\(createdAt),\(kind),\(tagsJson),\(contentJson)]" +} + +/// Computes the nostr event ID (SHA256 of commitment). +func calculateEventId(pubkey: String, createdAt: Int, kind: Int, tags: [[String]], content: String) -> String { + let commitment = eventCommitment(pubkey: pubkey, createdAt: createdAt, kind: kind, tags: tags, content: content) + guard let data = commitment.data(using: .utf8) else { return "" } + let hash = sha256(data) + return hexEncode(Array(hash)) +} + +/// Signs an event ID with the private key using schnorr signature. +func signEventId(privkeyHex: String, eventId: String) -> String? { + guard let privkeyBytes = hexDecode(privkeyHex), + let idBytes = hexDecode(eventId) else { + return nil + } + + guard let privateKey = try? secp256k1.Signing.PrivateKey(rawRepresentation: privkeyBytes) else { + os_log(.error, "Failed to create private key from bytes") + return nil + } + + guard var auxRand = randomBytes(count: 64) else { + os_log(.error, "Failed to generate random bytes for signing") + return nil + } + var digest = idBytes + + guard let signature = try? privateKey.schnorr.signature(message: &digest, auxiliaryRand: &auxRand) else { + os_log(.error, "Failed to create schnorr signature") + return nil + } + + return hexEncode(Array(signature.rawRepresentation)) +} + +// MARK: - Request Decoding + +/// Decodes a message from the Safari extension JavaScript into a typed request. +/// +/// - Parameter message: Raw message dictionary from the extension. +/// - Returns: Parsed request, or nil if the message is malformed. +func decode_damoose_request(_ message: Any) -> DamooseRequest? { + guard let dict = message as? [String: Any], + let kind = dict["kind"] as? String, + let payloadDict = dict["payload"] as? [String: Any] else { + os_log(.error, "Failed to decode message or invalid structure") + return nil + } + + switch kind { + case "getPubKey": + // No specific payload to decode + return .getPublicKey + + case "signEvent": + if let createdAt = payloadDict["created_at"] as? Int, + let kind = payloadDict["kind"] as? Int, + let tags = payloadDict["tags"] as? [[String]], + let content = payloadDict["content"] as? String { + // remember and origin come from the outer dict, not payload + let remember = dict["remember"] as? Bool ?? false + let origin = dict["origin"] as? String ?? "" + return .signEvent(SignEventPayload(created_at: createdAt, kind: kind, tags: tags, content: content), remember: remember, origin: origin) + } + + case "checkPermission": + if let kind = payloadDict["kind"] as? Int, + let origin = payloadDict["origin"] as? String { + return .checkPermission(kind: kind, origin: origin) + } + + case "getRelays": + return .getRelays + + case "nip04Encrypt", "nip44Encrypt": + if let pubkey = payloadDict["pubkey"] as? String, + let plaintext = payloadDict["plaintext"] as? String { + return .nip04_encrypt(Nip04EncryptPayload(pubkey: pubkey, plaintext: plaintext)) + } + + case "nip04Decrypt", "nip44Decrypt": + if let pubkey = payloadDict["pubkey"] as? String, + let ciphertext = payloadDict["ciphertext"] as? String { + return .nip04_decrypt(Nip04DecryptPayload(pubkey: pubkey, ciphertext: ciphertext)) + } + + default: + os_log(.error, "Unknown kind: %@", kind) + } + + return nil +} + +/// Handles a decoded request and returns the appropriate response. +/// +/// - Parameter req: The decoded request. +/// - Returns: Response to send back to JavaScript, or nil if no response. +func handle_request(_ req: DamooseRequest) -> DamooseResponse? { + switch req { + case .getPublicKey: + guard let pubkey = getStoredPublicKey() else { + os_log(.error, "No pubkey stored - user not logged in") + return nil + } + return .pubkey(pubkey) + case .signEvent(let payload, let remember, let origin): + return handleSignEvent(payload, remember: remember, origin: origin) + case .getRelays: + return nil + case .nip04_encrypt(_): + return nil + case .nip04_decrypt(_): + return nil + case .checkPermission(let kind, let origin): + let approved = isPermissionApproved(kind: kind, origin: origin) + return approved ? .permissionApproved : nil + } +} + +// MARK: - Direct Event Signing + +/// Signs an event directly using the stored private key. +/// +/// This performs schnorr signing in the extension without app switching: +/// 1. Reads private key from keychain +/// 2. Computes event ID (SHA256 of commitment) +/// 3. Signs with secp256k1 schnorr +/// 4. Optionally saves permission if remember is true +/// 5. Returns complete signed event +/// +/// - Parameters: +/// - payload: The unsigned event to sign. +/// - remember: If true, saves permission for this origin+kind. +/// - origin: The requesting website's origin for permission storage. +func handleSignEvent(_ payload: SignEventPayload, remember: Bool, origin: String) -> DamooseResponse? { + guard let pubkeyHex = getStoredPublicKey() else { + os_log(.error, "No pubkey stored - user not logged in") + return nil + } + + guard let privkeyHex = getStoredPrivateKey() else { + os_log(.error, "No privkey stored - read-only mode") + return nil + } + + // Calculate event ID + let eventId = calculateEventId( + pubkey: pubkeyHex, + createdAt: payload.created_at, + kind: payload.kind, + tags: payload.tags, + content: payload.content + ) + + guard !eventId.isEmpty else { + os_log(.error, "Failed to calculate event ID") + return nil + } + + // Sign the event + guard let signature = signEventId(privkeyHex: privkeyHex, eventId: eventId) else { + os_log(.error, "Failed to sign event") + return nil + } + + // Save permission if requested + if remember && !origin.isEmpty { + savePermission(kind: payload.kind, origin: origin) + } + + let signedEvent = SignedEvent( + created_at: payload.created_at, + kind: payload.kind, + tags: payload.tags, + content: payload.content, + id: eventId, + sig: signature, + pubkey: pubkeyHex + ) + + os_log(.info, "Signed event: %@", eventId) + return .signedEvent(signedEvent) +} + +/// Native message handler for the Damoose NIP-07 Safari extension. +/// +/// Handles messages from the extension JavaScript via `browser.runtime.sendNativeMessage()`. +/// Supports getPublicKey, signEvent (delegated), and checkResult for polling. +class SafariWebExtensionHandler: NSObject, NSExtensionRequestHandling { + + /// Entry point for messages from the Safari extension. + func beginRequest(with context: NSExtensionContext) { + let request = context.inputItems.first as? NSExtensionItem + + let message: Any? + if #available(iOS 15.0, macOS 11.0, *) { + message = request?.userInfo?[SFExtensionMessageKey] + } else { + message = request?.userInfo?["message"] + } + + //os_log(.default, "Received message of kind '%@' with payload: %@", String(describing: payload)) + + guard let message, let request = decode_damoose_request(message) else { + context.completeRequest(returningItems: [], completionHandler: nil) + return + } + + guard let response_payload = handle_request(request) else { + context.completeRequest(returningItems: [], completionHandler: nil) + return + } + + let response = NSExtensionItem() + if #available(iOS 15.0, macOS 11.0, *) { + response.userInfo = [SFExtensionMessageKey: response_payload.val] + } else { + response.userInfo = ["message": response_payload.val] + } + + context.completeRequest(returningItems: [response], completionHandler: nil) + } +} diff --git a/DamusNostrSigner/DamusNostrSigner.entitlements b/DamusNostrSigner/DamusNostrSigner.entitlements new file mode 100644 index 000000000..376bff7ef --- /dev/null +++ b/DamusNostrSigner/DamusNostrSigner.entitlements @@ -0,0 +1,14 @@ + + + + + com.apple.security.application-groups + + group.com.damus + + keychain-access-groups + + $(AppIdentifierPrefix)com.jb55.damus2 + + + diff --git a/DamusNostrSigner/Info.plist b/DamusNostrSigner/Info.plist new file mode 100644 index 000000000..650e92275 --- /dev/null +++ b/DamusNostrSigner/Info.plist @@ -0,0 +1,31 @@ + + + + + CFBundleDisplayName + Damus Nostr Signer + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + NSExtension + + NSExtensionPointIdentifier + com.apple.Safari.web-extension + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).SafariWebExtensionHandler + + NSHumanReadableCopyright + Copyright 2024 Damus. All rights reserved. + + diff --git a/DamusNostrSigner/Resources/background.js b/DamusNostrSigner/Resources/background.js new file mode 100644 index 000000000..b70c151a1 --- /dev/null +++ b/DamusNostrSigner/Resources/background.js @@ -0,0 +1,106 @@ +// Background script - extension context +// Receives messages from content scripts and forwards to native handler + +browser.runtime.onMessage.addListener((message, sender, sendResponse) => { + if (message.type !== 'nostr') { + return false; + } + + // Add origin to params for signEvent requests + const params = { ...message.params }; + if (message.method === 'signEvent' && sender.tab?.url) { + try { + const url = new URL(sender.tab.url); + params.origin = url.hostname; + } catch (e) { + params.origin = 'unknown'; + } + } + + // Forward to native handler + handleNostrRequest(message.method, params) + .then(result => sendResponse(result)) + .catch(error => sendResponse({ error: error.message })); + + // Return true to indicate async response + return true; +}); + +async function handleNostrRequest(method, params) { + // Send to native Swift handler via Safari's native messaging + const response = await browser.runtime.sendNativeMessage('damoose', { + method, + params + }); + + // Check if native handler returned an action + if (response.action === 'openUrl') { + return handleOpenUrlAction(response); + } + + if (response.error) { + throw new Error(response.error); + } + + return response.result; +} + +// Handle openUrl action - opens Damus app and polls for result +async function handleOpenUrlAction(response) { + const { url, requestId } = response; + + if (!url || !requestId) { + throw new Error('Invalid openUrl response'); + } + + // Open the nostrsigner:// URL to switch to Damus + // Note: This may be blocked by Safari. If so, we return the URL for the page to open. + try { + // Try to open via a new tab/window + await browser.tabs.create({ url, active: true }); + } catch (e) { + // If we can't open directly, return instruction for content script + return { + _damusAction: 'openUrl', + url, + requestId + }; + } + + // Poll for result + return await pollForResult(requestId); +} + +// Polls the native handler for a signing result +async function pollForResult(requestId, maxAttempts = 300, intervalMs = 1000) { + for (let i = 0; i < maxAttempts; i++) { + await sleep(intervalMs); + + try { + const response = await browser.runtime.sendNativeMessage('damoose', { + method: 'checkResult', + params: { requestId } + }); + + if (response.pending) { + // Not ready yet, continue polling + continue; + } + + if (response.error) { + throw new Error(response.error); + } + + return response.result; + } catch (e) { + // Native messaging error, might be transient + console.error('Poll error:', e); + } + } + + throw new Error('Signing timed out - please try again'); +} + +function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} diff --git a/DamusNostrSigner/Resources/content.js b/DamusNostrSigner/Resources/content.js new file mode 100644 index 000000000..6af0b6e3f --- /dev/null +++ b/DamusNostrSigner/Resources/content.js @@ -0,0 +1,124 @@ +// Content script - runs in isolated world +// Bridges between injected.js (page context) and background.js (extension context) + +(function() { + 'use strict'; + + // Inject the window.nostr script into page context + const script = document.createElement('script'); + script.src = browser.runtime.getURL('injected.js'); + script.onload = function() { this.remove(); }; + (document.head || document.documentElement).appendChild(script); + + // Track pending polls for when user returns from Damus + const pendingPolls = new Map(); + + // Listen for requests from injected script + window.addEventListener('DAMUS_REQUEST', async (e) => { + const { id, method, params } = e.detail; + + try { + // Forward to background script which talks to native handler + const result = await browser.runtime.sendMessage({ + type: 'nostr', + method, + params + }); + + // Send response back to injected script + window.dispatchEvent(new CustomEvent('DAMUS_RESPONSE', { + detail: { id, result } + })); + } catch (error) { + window.dispatchEvent(new CustomEvent('DAMUS_RESPONSE', { + detail: { id, error: error.message } + })); + } + }); + + // Listen for poll start requests (when page opens Damus URL) + window.addEventListener('DAMUS_START_POLL', (e) => { + const { id, requestId } = e.detail; + pendingPolls.set(requestId, id); + // Store in sessionStorage so poll survives page navigation + try { + const stored = JSON.parse(sessionStorage.getItem('damus_pending_polls') || '{}'); + stored[requestId] = id; + sessionStorage.setItem('damus_pending_polls', JSON.stringify(stored)); + } catch (e) { + // sessionStorage may not be available + } + }); + + // When page becomes visible again (user returned from Damus), check for results + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'visible') { + checkPendingPolls(); + } + }); + + // Also check on page load/focus in case user returned + window.addEventListener('focus', checkPendingPolls); + window.addEventListener('pageshow', checkPendingPolls); + + // Check for any pending polls on load + setTimeout(checkPendingPolls, 100); + + async function checkPendingPolls() { + // Restore pending polls from sessionStorage + try { + const stored = JSON.parse(sessionStorage.getItem('damus_pending_polls') || '{}'); + for (const [requestId, id] of Object.entries(stored)) { + if (!pendingPolls.has(requestId)) { + pendingPolls.set(requestId, id); + } + } + } catch (e) { + // sessionStorage may not be available + } + + if (pendingPolls.size === 0) return; + + // Check each pending poll + for (const [requestId, id] of pendingPolls.entries()) { + try { + const response = await browser.runtime.sendMessage({ + type: 'nostr', + method: 'checkResult', + params: { requestId } + }); + + if (response.pending) { + // Not ready yet, keep polling + continue; + } + + // Got a result, remove from pending + pendingPolls.delete(requestId); + try { + const stored = JSON.parse(sessionStorage.getItem('damus_pending_polls') || '{}'); + delete stored[requestId]; + sessionStorage.setItem('damus_pending_polls', JSON.stringify(stored)); + } catch (e) {} + + // Send result to injected script + if (response.error) { + window.dispatchEvent(new CustomEvent('DAMUS_POLL_RESULT', { + detail: { id, error: response.error } + })); + } else { + window.dispatchEvent(new CustomEvent('DAMUS_POLL_RESULT', { + detail: { id, result: response.result } + })); + } + } catch (error) { + console.error('Poll check error:', error); + } + } + + // If there are still pending polls, schedule another check + if (pendingPolls.size > 0) { + setTimeout(checkPendingPolls, 1000); + } + } +})(); diff --git a/DamusNostrSigner/Resources/injected.js b/DamusNostrSigner/Resources/injected.js new file mode 100644 index 000000000..62212c92a --- /dev/null +++ b/DamusNostrSigner/Resources/injected.js @@ -0,0 +1,117 @@ +// NIP-07 window.nostr - injected into page context +// This script is injected by content.js to run in the MAIN world + +(function() { + 'use strict'; + + if (window.nostr) return; + + let requestId = 0; + const pending = new Map(); + + // Listen for responses from content script + window.addEventListener('DAMUS_RESPONSE', (e) => { + const { id, result, error } = e.detail; + const p = pending.get(id); + if (!p) return; + + // Check if result contains an action we need to handle + if (result && result._damusAction === 'openUrl') { + handleOpenUrlAction(id, result); + return; + } + + pending.delete(id); + error ? p.reject(new Error(error)) : p.resolve(result); + }); + + // Listen for poll results + window.addEventListener('DAMUS_POLL_RESULT', (e) => { + const { id, result, error } = e.detail; + const p = pending.get(id); + if (!p) return; + pending.delete(id); + error ? p.reject(new Error(error)) : p.resolve(result); + }); + + function request(method, params) { + return new Promise((resolve, reject) => { + const id = ++requestId; + pending.set(id, { resolve, reject }); + window.dispatchEvent(new CustomEvent('DAMUS_REQUEST', { + detail: { id, method, params } + })); + // 5 minute timeout for signing (user may be slow) + setTimeout(() => { + if (pending.has(id)) { + pending.delete(id); + reject(new Error('Timeout')); + } + }, 300000); + }); + } + + // Handle URL opening action - extension couldn't open directly + function handleOpenUrlAction(id, actionData) { + const { url, requestId: extRequestId } = actionData; + + // Show user feedback before switching apps + const notification = document.createElement('div'); + notification.style.cssText = ` + position: fixed; + top: 20px; + right: 20px; + background: #1a1a2e; + color: white; + padding: 16px 24px; + border-radius: 12px; + z-index: 999999; + font-family: -apple-system, BlinkMacSystemFont, sans-serif; + box-shadow: 0 4px 12px rgba(0,0,0,0.3); + `; + notification.textContent = 'Opening Damus for signing...'; + document.body.appendChild(notification); + + // Open the URL to switch to Damus + window.location.href = url; + + // Start polling for result when user returns + // The content script will handle this + window.dispatchEvent(new CustomEvent('DAMUS_START_POLL', { + detail: { id, requestId: extRequestId } + })); + + // Remove notification after a delay + setTimeout(() => notification.remove(), 2000); + } + + window.nostr = { + async getPublicKey() { + return request('getPublicKey', {}); + }, + + async signEvent(event) { + return request('signEvent', { event }); + }, + + nip04: { + async encrypt(pubkey, plaintext) { + return request('nip04.encrypt', { pubkey, plaintext }); + }, + async decrypt(pubkey, ciphertext) { + return request('nip04.decrypt', { pubkey, ciphertext }); + } + }, + + nip44: { + async encrypt(pubkey, plaintext) { + return request('nip44.encrypt', { pubkey, plaintext }); + }, + async decrypt(pubkey, ciphertext) { + return request('nip44.decrypt', { pubkey, ciphertext }); + } + }, + + _damus: true + }; +})(); diff --git a/DamusNostrSigner/Resources/manifest.json b/DamusNostrSigner/Resources/manifest.json new file mode 100644 index 000000000..1b76a2890 --- /dev/null +++ b/DamusNostrSigner/Resources/manifest.json @@ -0,0 +1,43 @@ +{ + "manifest_version": 3, + "name": "Damus Nostr Signer", + "version": "1.0", + "description": "NIP-07 signer for Nostr web apps using Damus keys", + + "permissions": [ + "nativeMessaging" + ], + + "background": { + "service_worker": "background.js", + "type": "module" + }, + + "content_scripts": [ + { + "matches": [""], + "js": ["content.js"], + "run_at": "document_start", + "all_frames": true + } + ], + + "web_accessible_resources": [ + { + "resources": ["injected.js"], + "matches": [""] + } + ], + + "icons": { + "48": "images/icon-48.png", + "96": "images/icon-96.png", + "128": "images/icon-128.png" + }, + + "browser_specific_settings": { + "safari": { + "strict_min_version": "15.4" + } + } +} diff --git a/DamusNostrSigner/SafariWebExtensionHandler.swift b/DamusNostrSigner/SafariWebExtensionHandler.swift new file mode 100644 index 000000000..d6c33f094 --- /dev/null +++ b/DamusNostrSigner/SafariWebExtensionHandler.swift @@ -0,0 +1,206 @@ +// +// SafariWebExtensionHandler.swift +// DamusNostrSigner +// +// NIP-07 Safari Web Extension native handler. +// Delegates signing to the main Damus app via DIP-05 URL scheme. +// + +import SafariServices +import os.log + +/// Native message handler for the Damus NIP-07 Safari extension. +/// +/// For `getPublicKey`, reads directly from shared keychain (fast, no app switch). +/// For `signEvent`, delegates to Damus app via DIP-05 URL scheme using shared storage bridge. +class SafariWebExtensionHandler: NSObject, NSExtensionRequestHandling { + + // MARK: - Logging + + private let logger = Logger(subsystem: "io.damus.DamusNostrSigner", category: "handler") + + // MARK: - Shared Storage Access + + private static let appGroup = "group.com.damus" + private static let pubkeyDefaultsKey = "pubkey" + + // MARK: - NSExtensionRequestHandling + + func beginRequest(with context: NSExtensionContext) { + guard let item = context.inputItems.first as? NSExtensionItem, + let message = item.userInfo?[SFExtensionMessageKey] as? [String: Any] else { + logger.error("Invalid extension request") + context.completeRequest(returningItems: nil, completionHandler: nil) + return + } + + logger.info("Received NIP-07 request: \(message["method"] as? String ?? "unknown")") + + let response = handleMessage(message) + + let responseItem = NSExtensionItem() + responseItem.userInfo = [SFExtensionMessageKey: response] + + context.completeRequest(returningItems: [responseItem], completionHandler: nil) + } + + // MARK: - Message Handling + + private func handleMessage(_ message: [String: Any]) -> [String: Any] { + guard let method = message["method"] as? String else { + return ["error": "Missing method"] + } + + let params = message["params"] as? [String: Any] ?? [:] + + switch method { + case "getPublicKey": + return handleGetPublicKey() + + case "signEvent": + return handleSignEvent(params: params) + + case "checkResult": + return handleCheckResult(params: params) + + case "nip04.encrypt", "nip04.decrypt", "nip44.encrypt", "nip44.decrypt": + return ["error": "Encryption requires app approval - use Damus app"] + + default: + return ["error": "Unsupported method: \(method)"] + } + } + + // MARK: - NIP-07 Methods + + /// Returns the user's public key from shared storage. + private func handleGetPublicKey() -> [String: Any] { + guard let sharedDefaults = UserDefaults(suiteName: Self.appGroup), + let pubkeyHex = sharedDefaults.string(forKey: Self.pubkeyDefaultsKey) else { + return ["error": "Not logged in to Damus"] + } + + return ["result": pubkeyHex] + } + + /// Handles signEvent by storing request and returning URL for JS to open. + private func handleSignEvent(params: [String: Any]) -> [String: Any] { + // Get the event to sign + guard let event = params["event"] else { + return ["error": "Missing event parameter"] + } + + // Convert event to JSON string + let eventJson: String + if let eventString = event as? String { + eventJson = eventString + } else if let eventDict = event as? [String: Any] { + guard let jsonData = try? JSONSerialization.data(withJSONObject: eventDict), + let jsonString = String(data: jsonData, encoding: .utf8) else { + return ["error": "Failed to serialize event"] + } + eventJson = jsonString + } else { + return ["error": "Invalid event format"] + } + + // Get origin from params (set by content script) + let origin = params["origin"] as? String ?? "unknown" + + // Store the request in shared storage + guard let requestId = storeRequest(eventJson: eventJson, origin: origin) else { + return ["error": "Failed to store request"] + } + + // Build nostrsigner:// URL + guard let url = buildSignerUrl(eventJson: eventJson, requestId: requestId, origin: origin) else { + return ["error": "Failed to build signer URL"] + } + + logger.info("Created sign request \(requestId) for origin \(origin)") + + // Return action for JS to open the URL + return [ + "action": "openUrl", + "url": url, + "requestId": requestId + ] + } + + /// Checks if a signing result is ready. + private func handleCheckResult(params: [String: Any]) -> [String: Any] { + guard let requestId = params["requestId"] as? String else { + return ["error": "Missing requestId"] + } + + guard let result = getResult(requestId: requestId) else { + // Not ready yet + return ["pending": true] + } + + // Check for error + if let error = result["error"] as? String { + return ["error": error] + } + + // Return the signed event + if let signedEventJson = result["event"] as? String { + // Parse the JSON to return as object + if let data = signedEventJson.data(using: .utf8), + let eventDict = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + return ["result": eventDict] + } + return ["result": signedEventJson] + } + + // Return just signature if no full event + if let signature = result["signature"] as? String { + return ["result": ["sig": signature]] + } + + return ["error": "Invalid result format"] + } + + // MARK: - Shared Storage Bridge + + /// Stores a signing request using SignerBridgeStorage. + private func storeRequest(eventJson: String, origin: String) -> String? { + SignerBridgeStorage.storeRequest(eventJson: eventJson, origin: origin) + } + + /// Retrieves a signing result using SignerBridgeStorage. + private func getResult(requestId: String) -> [String: Any]? { + SignerBridgeStorage.getResult(requestId: requestId) + } + + // MARK: - URL Building + + /// Builds a nostrsigner:// URL for the signing request. + private func buildSignerUrl(eventJson: String, requestId: String, origin: String) -> String? { + // URL-encode the event JSON + guard let encodedEvent = eventJson.addingPercentEncoding( + withAllowedCharacters: .urlPathAllowed + ) else { + return nil + } + + // Build callback URL - we use a special scheme that indicates extension callback + // The main app will store result in shared storage instead of opening URL + let callbackUrl = "damus-extension://callback" + guard let encodedCallback = callbackUrl.addingPercentEncoding( + withAllowedCharacters: .urlQueryAllowed + ) else { + return nil + } + + // Build the nostrsigner URL + // Format: nostrsigner:?type=sign_event&callbackUrl=&extensionRequestId= + var url = "nostrsigner:\(encodedEvent)" + url += "?type=sign_event" + url += "&callbackUrl=\(encodedCallback)" + url += "&extensionRequestId=\(requestId)" + url += "&returnType=event" + + return url + } +} diff --git a/damus.xcodeproj/project.pbxproj b/damus.xcodeproj/project.pbxproj index ae0fd91b2..ceb3320db 100644 --- a/damus.xcodeproj/project.pbxproj +++ b/damus.xcodeproj/project.pbxproj @@ -7,10 +7,16 @@ objects = { /* Begin PBXBuildFile section */ + 05990030808D8F1709B29C54 /* content.js in Resources */ = {isa = PBXBuildFile; fileRef = 4A55A119A636196B066BD23A /* content.js */; }; 0E8A4BB72AE4359200065E81 /* NostrFilter+Hashable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E8A4BB62AE4359200065E81 /* NostrFilter+Hashable.swift */; }; + 19597E310ACD06460F7AAF0A /* popup.html in Resources */ = {isa = PBXBuildFile; fileRef = 92E7A3DCBFF57DE2B680EA4F /* popup.html */; }; + 1F2CA509E1DEFA14222FC6B6 /* NostrSignerApprovalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BEFA9DDC028EAD6CF8F2C1F /* NostrSignerApprovalView.swift */; }; + 255100AAA58739F43D61668E /* SafariWebExtensionHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D56A6CE34E0251027FCCAFA /* SafariWebExtensionHandler.swift */; }; 2710433D2E6BFE340005C3B0 /* PostingTimelineSwitcherView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2710433C2E6BFE2A0005C3B0 /* PostingTimelineSwitcherView.swift */; }; 2710433E2E6BFE340005C3B0 /* PostingTimelineSwitcherView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2710433C2E6BFE2A0005C3B0 /* PostingTimelineSwitcherView.swift */; }; 2710433F2E6BFE340005C3B0 /* PostingTimelineSwitcherView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2710433C2E6BFE2A0005C3B0 /* PostingTimelineSwitcherView.swift */; }; + 29FCAC04D2F32DC0AF2DD0DB /* SharedKeychainStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D63E9B4C14631B286D9378F /* SharedKeychainStorage.swift */; }; + 2FE60245345B4E46001D2428 /* content.js in Resources */ = {isa = PBXBuildFile; fileRef = F8DCE3961C99289AA8F15BD6 /* content.js */; }; 3165648B295B70D500C64604 /* LinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3165648A295B70D500C64604 /* LinkView.swift */; }; 3169CAE6294E69C000EE4006 /* EmptyTimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3169CAE5294E69C000EE4006 /* EmptyTimelineView.swift */; }; 3169CAED294FCCFC00EE4006 /* Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3169CAEC294FCCFC00EE4006 /* Constants.swift */; }; @@ -65,6 +71,8 @@ 3ACF94472DAA006500971A4E /* NIP05DomainEventsModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3ACF94452DAA006500971A4E /* NIP05DomainEventsModel.swift */; }; 3ACF94482DAA006500971A4E /* NIP05DomainEventsModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3ACF94452DAA006500971A4E /* NIP05DomainEventsModel.swift */; }; 3AE45AF6297BB2E700C1D842 /* LibreTranslateServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AE45AF5297BB2E700C1D842 /* LibreTranslateServer.swift */; }; + 3B4E40C70DA17F5E9DA25891 /* SignerBridgeStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4F38BCAC82CF1EF63DAC715 /* SignerBridgeStorage.swift */; }; + 3CBFFAB21483D39472CE9D9C /* NostrSignerRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC5F33E64B399631CC45907D /* NostrSignerRequest.swift */; }; 3CCD1E6A2A874C4E0099A953 /* Nip98HTTPAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CCD1E692A874C4E0099A953 /* Nip98HTTPAuth.swift */; }; 4C011B5E2BD0A56A002F2F9B /* ChatEventView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C011B5C2BD0A56A002F2F9B /* ChatEventView.swift */; }; 4C011B5F2BD0A56A002F2F9B /* ChatroomThreadView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C011B5D2BD0A56A002F2F9B /* ChatroomThreadView.swift */; }; @@ -252,7 +260,8 @@ 4C64305C2A945AFF00B0C0E9 /* MusicController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C64305B2A945AFF00B0C0E9 /* MusicController.swift */; }; 4C64987C286D03E000EAE2B3 /* DirectMessagesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C64987B286D03E000EAE2B3 /* DirectMessagesView.swift */; }; 4C64987E286D082C00EAE2B3 /* DirectMessagesModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C64987D286D082C00EAE2B3 /* DirectMessagesModel.swift */; }; - 4C649881286E0EE300EAE2B3 /* secp256k1 in Frameworks */ = {isa = PBXBuildFile; productRef = 4C649880286E0EE300EAE2B3 /* secp256k1 */; }; + 4C649881286E0EE300EAE2B3 /* secp256k1 in Frameworks */ = {isa = PBXBuildFile; + A14FAA9F3D34738FBCE6392D /* secp256k1 in Frameworks */ = {isa = PBXBuildFile; productRef = 6BAC5FED0B6276CB910C650F /* secp256k1 */; }; productRef = 4C649880286E0EE300EAE2B3 /* secp256k1 */; }; 4C684A552A7E91FE005E6031 /* LargeEventTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C684A542A7E91FE005E6031 /* LargeEventTests.swift */; }; 4C684A572A7FFAE6005E6031 /* UrlTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C684A562A7FFAE6005E6031 /* UrlTests.swift */; }; 4C687C212A5F7ED00092C550 /* DamusBackground.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C687C202A5F7ED00092C550 /* DamusBackground.swift */; }; @@ -481,6 +490,7 @@ 4CFF8F6929CC9ED1008DB934 /* ImageContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CFF8F6829CC9ED1008DB934 /* ImageContainerView.swift */; }; 4CFF8F6B29CD0079008DB934 /* RepostedEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CFF8F6A29CD0079008DB934 /* RepostedEvent.swift */; }; 4CFF8F6D29CD022E008DB934 /* WideEventView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CFF8F6C29CD022E008DB934 /* WideEventView.swift */; }; + 4FA133BC107F2749EA955FF0 /* ContactListPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D40F8593960E80C843C56D7 /* ContactListPolicy.swift */; }; 4FE60CDD295E1C5E00105A1F /* Wallet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FE60CDC295E1C5E00105A1F /* Wallet.swift */; }; 50088DA129E8271A008A1FDF /* WebSocket.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50088DA029E8271A008A1FDF /* WebSocket.swift */; }; 501F8C802A0220E1001AFC1D /* KeychainStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 501F8C7F2A0220E1001AFC1D /* KeychainStorage.swift */; }; @@ -495,6 +505,9 @@ 50B5685329F97CB400A23243 /* CredentialHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50B5685229F97CB400A23243 /* CredentialHandler.swift */; }; 50C3E08A2AA8E3F7006A4BC0 /* AVPlayer+Additions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50C3E0892AA8E3F7006A4BC0 /* AVPlayer+Additions.swift */; }; 50DA11262A16A23F00236234 /* Launch.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 50DA11252A16A23F00236234 /* Launch.storyboard */; }; + 55AF4186A2E0B85D9550F4DD /* SafariServices.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9FD6A85B7BEB1B79F49C5F6F /* SafariServices.framework */; }; + 570A19771BE4FF96AADF17C5 /* background.js in Resources */ = {isa = PBXBuildFile; fileRef = 256227FDEA93182DD9AA96EC /* background.js */; }; + 59102E60D090B047ABC17B74 /* NostrSignerApprovalSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74D4B97BA76DE5AC7BFE2D48 /* NostrSignerApprovalSheet.swift */; }; 5C0567532C8B5F9C0073F23A /* PostingTimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C8711DD2C460C06007879C2 /* PostingTimelineView.swift */; }; 5C0567552C8B60C20073F23A /* OffsetExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C0567542C8B60C20073F23A /* OffsetExtension.swift */; }; 5C0567562C8B60E60073F23A /* OffsetExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C0567542C8B60C20073F23A /* OffsetExtension.swift */; }; @@ -618,10 +631,17 @@ 5CF2DCCC2AA3AF0B00984B8D /* RelayPicView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CF2DCCB2AA3AF0B00984B8D /* RelayPicView.swift */; }; 5CF2DCCE2AABE1A500984B8D /* DamusLightGradient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CF2DCCD2AABE1A500984B8D /* DamusLightGradient.swift */; }; 5CF72FC229B9142F00124A13 /* ShareAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CF72FC129B9142F00124A13 /* ShareAction.swift */; }; + 5E25E297170F1C15B9259470 /* nostr.js in Resources */ = {isa = PBXBuildFile; fileRef = 0EBE2A8A93A5F5CA47407E45 /* nostr.js */; }; + 606F43042338B8DE2FCC3BE9 /* ClientPermissions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB5DC8601708330B96927EAD /* ClientPermissions.swift */; }; + 63674150BF249FCDBE79C904 /* SigningPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5565A535F7D242F2BB775CF /* SigningPolicy.swift */; }; 6439E014296790CF0020672B /* ProfilePicImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6439E013296790CF0020672B /* ProfilePicImageView.swift */; }; 643EA5C8296B764E005081BB /* RelayFilterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 643EA5C7296B764E005081BB /* RelayFilterView.swift */; }; 647D9A8D2968520300A295DE /* SideMenuView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 647D9A8C2968520300A295DE /* SideMenuView.swift */; }; + 64E4B33D2E1D29802194C133 /* popup.css in Resources */ = {isa = PBXBuildFile; fileRef = ED6B4570DFBE54FC8A8B6598 /* popup.css */; }; 64FBD06F296255C400D9D3B2 /* Theme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64FBD06E296255C400D9D3B2 /* Theme.swift */; }; + 6603FDF7C6E06045982AE97F /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1FF6AE40E2192CE8CBD2C56B /* Foundation.framework */; }; + 676E8F8EE47E251878864707 /* injected.js in Resources */ = {isa = PBXBuildFile; fileRef = 522D287AAF3EAFE42A4FA783 /* injected.js */; }; + 69E7EC3BEBDF4DEAEF899293 /* NostrSignerHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09F652082F3612D2D958155A /* NostrSignerHandler.swift */; }; 7527271E2A93FF0100214108 /* Block.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7527271D2A93FF0100214108 /* Block.swift */; }; 75AD872B2AA23A460085EF2C /* Block+Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75AD872A2AA23A460085EF2C /* Block+Tests.swift */; }; 7C60CAEF298471A1009C80D6 /* CoreSVG.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C60CAEE298471A1009C80D6 /* CoreSVG.swift */; }; @@ -1071,10 +1091,15 @@ 82D6FC862CD9A4A600C925F4 /* MarkdownUI in Frameworks */ = {isa = PBXBuildFile; productRef = 82D6FC852CD9A4A600C925F4 /* MarkdownUI */; }; 82D6FC882CD9A4DE00C925F4 /* EmojiPicker in Frameworks */ = {isa = PBXBuildFile; productRef = 82D6FC872CD9A4DE00C925F4 /* EmojiPicker */; }; 82D6FC8A2CD9A54600C925F4 /* SwipeActions in Frameworks */ = {isa = PBXBuildFile; productRef = 82D6FC892CD9A54600C925F4 /* SwipeActions */; }; + 86A8C0F7897EB76139F37AD5 /* SigningPolicyManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A98FBDCB5795D169055EAA1 /* SigningPolicyManager.swift */; }; 9609F058296E220800069BF3 /* BannerImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9609F057296E220800069BF3 /* BannerImageView.swift */; }; + 98B76D31D3F167D9DC581FF3 /* NostrSignerResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 168D00B2CC7643AD5746FAED /* NostrSignerResponse.swift */; }; 9C83F89329A937B900136C08 /* TextViewWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C83F89229A937B900136C08 /* TextViewWrapper.swift */; }; 9CA876E229A00CEA0003B9A3 /* AttachMediaUtility.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9CA876E129A00CE90003B9A3 /* AttachMediaUtility.swift */; }; + A0D4F8CF0780353F18973E20 /* manifest.json in Resources */ = {isa = PBXBuildFile; fileRef = BC8FAE1CCDB3B47CDD7AB12C /* manifest.json */; }; + ABE9EB86264ECCED5297C8AF /* manifest.json in Resources */ = {isa = PBXBuildFile; fileRef = CA33ABC3C52B3945666924A4 /* manifest.json */; }; ADFE73552AD4793100EC7326 /* QRScanNSECView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADFE73542AD4793100EC7326 /* QRScanNSECView.swift */; }; + B483F5146380E12C905D7EE0 /* popup.js in Resources */ = {isa = PBXBuildFile; fileRef = 688E185C4A90830B63F0DB83 /* popup.js */; }; B501062D2B363036003874F5 /* AuthIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B501062C2B363036003874F5 /* AuthIntegrationTests.swift */; }; B51C1CEA2B55A60A00E312A9 /* AddMuteItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B51C1CE82B55A60A00E312A9 /* AddMuteItemView.swift */; }; B51C1CEB2B55A60A00E312A9 /* MuteDurationMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = B51C1CE92B55A60A00E312A9 /* MuteDurationMenu.swift */; }; @@ -1088,6 +1113,7 @@ B5C60C202B530D5100C5ECA7 /* MuteItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5C60C1F2B530D5100C5ECA7 /* MuteItem.swift */; }; B5C60C212B530D5600C5ECA7 /* MuteItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5C60C1F2B530D5100C5ECA7 /* MuteItem.swift */; }; B5C60C232B532A8700C5ECA7 /* DamusDuration.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5C60C222B532A8700C5ECA7 /* DamusDuration.swift */; }; + B614A296FB960D21577BED15 /* Damoose.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = A7C28743E03F8D3B1AA0D68C /* Damoose.appex */; }; BA37598A2ABCCDE40018D73B /* ImageResizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA3759892ABCCDE30018D73B /* ImageResizer.swift */; }; BA37598D2ABCCE500018D73B /* PhotoCaptureProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA37598B2ABCCE500018D73B /* PhotoCaptureProcessor.swift */; }; BA37598E2ABCCE500018D73B /* VideoCaptureProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA37598C2ABCCE500018D73B /* VideoCaptureProcessor.swift */; }; @@ -1096,6 +1122,8 @@ BA3759972ABCCF360018D73B /* CameraPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA3759962ABCCF360018D73B /* CameraPreview.swift */; }; BA693074295D649800ADDB87 /* UserSettingsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA693073295D649800ADDB87 /* UserSettingsStore.swift */; }; BAB68BED29543FA3007BA466 /* SelectWalletView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAB68BEC29543FA3007BA466 /* SelectWalletView.swift */; }; + C6AFF986F9412344F6C88A39 /* background.js in Resources */ = {isa = PBXBuildFile; fileRef = 193470E69DEC0B529B33A722 /* background.js */; }; + CCB9F4B71BCB69EFE8EAA19F /* SafariWebExtensionHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62CC338E2401529E37DB6760 /* SafariWebExtensionHandler.swift */; }; D2277EEA2A089BD5006C3807 /* Router.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2277EE92A089BD5006C3807 /* Router.swift */; }; D5C1AFBF2E5DF7E60092F72F /* ContactCardManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5C1AFBE2E5DF7E60092F72F /* ContactCardManager.swift */; }; D5C1AFC02E5DF7E60092F72F /* ContactCardManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5C1AFBE2E5DF7E60092F72F /* ContactCardManager.swift */; }; @@ -1491,7 +1519,7 @@ D73E5EFB2C6A97F4007EB227 /* ProfilePicturesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C30AC7F29A6A53F00E2BD5A /* ProfilePicturesView.swift */; }; D73E5EFC2C6A97F4007EB227 /* DamusAppNotificationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78CD5972B8990300014D539 /* DamusAppNotificationView.swift */; }; D73E5EFD2C6A97F4007EB227 /* InnerTimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CE0E2B529A3ED5500DB4CA2 /* InnerTimelineView.swift */; }; - D73E5EFE2C6A97F4007EB227 /* (null) in Sources */ = {isa = PBXBuildFile; }; + D73E5EFE2C6A97F4007EB227 /* BuildFile in Sources */ = {isa = PBXBuildFile; }; D73E5EFF2C6A97F4007EB227 /* ZapsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CE879572996C45300F758CC /* ZapsView.swift */; }; D73E5F002C6A97F4007EB227 /* CustomizeZapView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C9F18E129AA9B6C008C55EC /* CustomizeZapView.swift */; }; D73E5F012C6A97F4007EB227 /* ZapTypePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CA3FA0F29F593D000FDB3C3 /* ZapTypePicker.swift */; }; @@ -1916,6 +1944,8 @@ E0EE9DD42B8E5FEA00F3002D /* ImageProcessing.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0EE9DD32B8E5FEA00F3002D /* ImageProcessing.swift */; }; E4FA1C032A24BB7F00482697 /* SearchSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E4FA1C022A24BB7F00482697 /* SearchSettingsView.swift */; }; E990020F2955F837003BBC5A /* EditMetadataView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E990020E2955F837003BBC5A /* EditMetadataView.swift */; }; + F08581200EF8D654150E7208 /* DamusNostrSigner.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 9026E9C3435D4FD77AA73D5F /* DamusNostrSigner.appex */; }; + F2EB6F7685B23EEACDD1DF0F /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1FF6AE40E2192CE8CBD2C56B /* Foundation.framework */; }; F71694EA2A662232001F4053 /* OnboardingSuggestionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F71694E92A662232001F4053 /* OnboardingSuggestionsView.swift */; }; F71694EC2A662292001F4053 /* SuggestedUsersViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F71694EB2A662292001F4053 /* SuggestedUsersViewModel.swift */; }; F71694F22A67314D001F4053 /* SuggestedUserView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F71694F12A67314D001F4053 /* SuggestedUserView.swift */; }; @@ -1954,6 +1984,13 @@ remoteGlobalIDString = 82D6FA962CD9820500C925F4; remoteInfo = "share extension"; }; + 96061C3A0420A16C7C4900B1 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 4CE6DEDB27F7A08100C66700 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 454F3F97A25BAF4386597DA2; + remoteInfo = Damoose; + }; D703D7232C66E47100A400EA /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 4CE6DEDB27F7A08100C66700 /* Project object */; @@ -1968,6 +2005,13 @@ remoteGlobalIDString = D79C4C132AFEB061003A41B4; remoteInfo = DamusNotificationService; }; + F87831C8A2DD3D549559BF82 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 4CE6DEDB27F7A08100C66700 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 13AE3D22F0CF1D41DEE86C10; + remoteInfo = DamusNostrSigner; + }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -1980,19 +2024,38 @@ 82D6FAA12CD9820500C925F4 /* ShareExtension.appex in Embed Foundation Extensions */, D703D7252C66E47100A400EA /* HighlighterActionExtension.appex in Embed Foundation Extensions */, D79C4C1B2AFEB061003A41B4 /* DamusNotificationService.appex in Embed Foundation Extensions */, + B614A296FB960D21577BED15 /* Damoose.appex in Embed Foundation Extensions */, ); name = "Embed Foundation Extensions"; runOnlyForDeploymentPostprocessing = 0; }; + D7B1004E81D79839428CD27E /* Embed App Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + F08581200EF8D654150E7208 /* DamusNostrSigner.appex in Embed App Extensions */, + ); + name = "Embed App Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 09F652082F3612D2D958155A /* NostrSignerHandler.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NostrSignerHandler.swift; sourceTree = ""; }; 0E8A4BB62AE4359200065E81 /* NostrFilter+Hashable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NostrFilter+Hashable.swift"; sourceTree = ""; }; + 0EBE2A8A93A5F5CA47407E45 /* nostr.js */ = {isa = PBXFileReference; includeInIndex = 1; name = nostr.js; path = Damoose/Resources/nostr.js; sourceTree = ""; }; + 168D00B2CC7643AD5746FAED /* NostrSignerResponse.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NostrSignerResponse.swift; sourceTree = ""; }; + 193470E69DEC0B529B33A722 /* background.js */ = {isa = PBXFileReference; includeInIndex = 1; path = background.js; sourceTree = ""; }; + 1FF6AE40E2192CE8CBD2C56B /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; }; + 256227FDEA93182DD9AA96EC /* background.js */ = {isa = PBXFileReference; includeInIndex = 1; name = background.js; path = Damoose/Resources/background.js; sourceTree = ""; }; 2710433C2E6BFE2A0005C3B0 /* PostingTimelineSwitcherView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostingTimelineSwitcherView.swift; sourceTree = ""; }; 3165648A295B70D500C64604 /* LinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkView.swift; sourceTree = ""; }; 3169CAE5294E69C000EE4006 /* EmptyTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmptyTimelineView.swift; sourceTree = ""; }; 3169CAEC294FCCFC00EE4006 /* Constants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = Constants.swift; path = damus/Shared/Utilities/Constants.swift; sourceTree = SOURCE_ROOT; }; 31D2E846295218AF006D67F8 /* Shimmer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Shimmer.swift; sourceTree = ""; }; + 35746FD8655A6221F84CF405 /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = Info.plist; path = Damoose/Info.plist; sourceTree = ""; }; 3A185A04297F2C3800F4BDC0 /* lv-LV */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "lv-LV"; path = "lv-LV.lproj/InfoPlist.strings"; sourceTree = ""; }; 3A185A05297F2C3800F4BDC0 /* lv-LV */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "lv-LV"; path = "lv-LV.lproj/Localizable.strings"; sourceTree = ""; }; 3A185A06297F2C3800F4BDC0 /* lv-LV */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = "lv-LV"; path = "lv-LV.lproj/Localizable.stringsdict"; sourceTree = ""; }; @@ -2112,7 +2175,11 @@ 3AF6336829884C6B0005672A /* pt-PT */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-PT"; path = "pt-PT.lproj/InfoPlist.strings"; sourceTree = ""; }; 3AF6336929884C6B0005672A /* pt-PT */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-PT"; path = "pt-PT.lproj/Localizable.strings"; sourceTree = ""; }; 3AF6336A29884C6B0005672A /* pt-PT */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = "pt-PT"; path = "pt-PT.lproj/Localizable.stringsdict"; sourceTree = ""; }; + 3BEFA9DDC028EAD6CF8F2C1F /* NostrSignerApprovalView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NostrSignerApprovalView.swift; sourceTree = ""; }; 3CCD1E692A874C4E0099A953 /* Nip98HTTPAuth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Nip98HTTPAuth.swift; sourceTree = ""; }; + 3D40F8593960E80C843C56D7 /* ContactListPolicy.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ContactListPolicy.swift; sourceTree = ""; }; + 43ED5497F9FE4C075492DEB6 /* Damoose.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; name = Damoose.entitlements; path = Damoose/Damoose.entitlements; sourceTree = ""; }; + 4A55A119A636196B066BD23A /* content.js */ = {isa = PBXFileReference; includeInIndex = 1; name = content.js; path = Damoose/Resources/content.js; sourceTree = ""; }; 4C011B5C2BD0A56A002F2F9B /* ChatEventView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChatEventView.swift; sourceTree = ""; }; 4C011B5D2BD0A56A002F2F9B /* ChatroomThreadView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChatroomThreadView.swift; sourceTree = ""; }; 4C011B602BD0B25C002F2F9B /* ReplyQuoteView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReplyQuoteView.swift; sourceTree = ""; }; @@ -2620,6 +2687,7 @@ 50B5685229F97CB400A23243 /* CredentialHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CredentialHandler.swift; sourceTree = ""; }; 50C3E0892AA8E3F7006A4BC0 /* AVPlayer+Additions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AVPlayer+Additions.swift"; sourceTree = ""; }; 50DA11252A16A23F00236234 /* Launch.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = Launch.storyboard; sourceTree = ""; }; + 522D287AAF3EAFE42A4FA783 /* injected.js */ = {isa = PBXFileReference; includeInIndex = 1; path = injected.js; sourceTree = ""; }; 5C0567542C8B60C20073F23A /* OffsetExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OffsetExtension.swift; sourceTree = ""; }; 5C0567572C8FBC560073F23A /* NDBSearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NDBSearchView.swift; sourceTree = ""; }; 5C0707D02A1ECB38004E7B51 /* DamusLogoGradient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusLogoGradient.swift; sourceTree = ""; }; @@ -2675,28 +2743,42 @@ 5CF2DCCB2AA3AF0B00984B8D /* RelayPicView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayPicView.swift; sourceTree = ""; }; 5CF2DCCD2AABE1A500984B8D /* DamusLightGradient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusLightGradient.swift; sourceTree = ""; }; 5CF72FC129B9142F00124A13 /* ShareAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareAction.swift; sourceTree = ""; }; + 62CC338E2401529E37DB6760 /* SafariWebExtensionHandler.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SafariWebExtensionHandler.swift; sourceTree = ""; }; 6439E013296790CF0020672B /* ProfilePicImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfilePicImageView.swift; sourceTree = ""; }; 643EA5C7296B764E005081BB /* RelayFilterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayFilterView.swift; sourceTree = ""; }; 647D9A8C2968520300A295DE /* SideMenuView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SideMenuView.swift; sourceTree = ""; }; 64FBD06E296255C400D9D3B2 /* Theme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Theme.swift; sourceTree = ""; }; + 688E185C4A90830B63F0DB83 /* popup.js */ = {isa = PBXFileReference; includeInIndex = 1; name = popup.js; path = Damoose/Resources/popup.js; sourceTree = ""; }; + 74D4B97BA76DE5AC7BFE2D48 /* NostrSignerApprovalSheet.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NostrSignerApprovalSheet.swift; sourceTree = ""; }; 7527271D2A93FF0100214108 /* Block.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Block.swift; sourceTree = ""; }; 75AD872A2AA23A460085EF2C /* Block+Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Block+Tests.swift"; sourceTree = ""; }; + 7A98FBDCB5795D169055EAA1 /* SigningPolicyManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SigningPolicyManager.swift; sourceTree = ""; }; 7C60CAEE298471A1009C80D6 /* CoreSVG.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreSVG.swift; sourceTree = ""; }; 7C902AE22981D55B002AB16E /* ZoomableScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZoomableScrollView.swift; sourceTree = ""; }; 7C95CAED299DCEF1009DCB67 /* KFOptionSetter+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "KFOptionSetter+.swift"; sourceTree = ""; }; 7CFF6316299FEFE5005D382A /* SelectableText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectableText.swift; sourceTree = ""; }; + 7D56A6CE34E0251027FCCAFA /* SafariWebExtensionHandler.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = SafariWebExtensionHandler.swift; path = Damoose/SafariWebExtensionHandler.swift; sourceTree = ""; }; + 7D63E9B4C14631B286D9378F /* SharedKeychainStorage.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SharedKeychainStorage.swift; sourceTree = ""; }; 82D6FA972CD9820500C925F4 /* ShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = ShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 82D6FA992CD9820500C925F4 /* ShareViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = ""; }; 82D6FA9E2CD9820500C925F4 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 82D6FAA62CD9820500C925F4 /* share extension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "share extension.entitlements"; sourceTree = ""; }; + 9026E9C3435D4FD77AA73D5F /* DamusNostrSigner.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = DamusNostrSigner.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + 92E7A3DCBFF57DE2B680EA4F /* popup.html */ = {isa = PBXFileReference; includeInIndex = 1; name = popup.html; path = Damoose/Resources/popup.html; sourceTree = ""; }; 9609F057296E220800069BF3 /* BannerImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BannerImageView.swift; sourceTree = ""; }; 9C83F89229A937B900136C08 /* TextViewWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextViewWrapper.swift; sourceTree = ""; }; 9CA876E129A00CE90003B9A3 /* AttachMediaUtility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachMediaUtility.swift; sourceTree = ""; }; + 9FD6A85B7BEB1B79F49C5F6F /* SafariServices.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SafariServices.framework; path = System/Library/Frameworks/SafariServices.framework; sourceTree = SDKROOT; }; + A0A4D10D75CE8E201B5E42E6 /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + A2F0CD60F688EAA487166BAE /* DamusNostrSigner.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = DamusNostrSigner.entitlements; sourceTree = ""; }; + A7C28743E03F8D3B1AA0D68C /* Damoose.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = Damoose.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + AC5F33E64B399631CC45907D /* NostrSignerRequest.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NostrSignerRequest.swift; sourceTree = ""; }; ADFE73542AD4793100EC7326 /* QRScanNSECView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = QRScanNSECView.swift; sourceTree = ""; }; B501062C2B363036003874F5 /* AuthIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthIntegrationTests.swift; sourceTree = ""; usesTabs = 0; }; B51C1CE82B55A60A00E312A9 /* AddMuteItemView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AddMuteItemView.swift; sourceTree = ""; }; B51C1CE92B55A60A00E312A9 /* MuteDurationMenu.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MuteDurationMenu.swift; sourceTree = ""; }; B533694D2B66D791008A805E /* MutelistManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MutelistManager.swift; sourceTree = ""; usesTabs = 0; }; + B5565A535F7D242F2BB775CF /* SigningPolicy.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SigningPolicy.swift; sourceTree = ""; }; B57B4C612B312BD700A232C0 /* ReconnectRelaysNotify.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReconnectRelaysNotify.swift; sourceTree = ""; }; B57B4C632B312BFA00A232C0 /* RelayAuthenticationDetail.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RelayAuthenticationDetail.swift; sourceTree = ""; }; B57B4C652B312C3700A232C0 /* NostrAuth.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NostrAuth.swift; sourceTree = ""; }; @@ -2712,6 +2794,9 @@ BA3759962ABCCF360018D73B /* CameraPreview.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CameraPreview.swift; sourceTree = ""; }; BA693073295D649800ADDB87 /* UserSettingsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSettingsStore.swift; sourceTree = ""; }; BAB68BEC29543FA3007BA466 /* SelectWalletView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectWalletView.swift; sourceTree = ""; }; + BC8FAE1CCDB3B47CDD7AB12C /* manifest.json */ = {isa = PBXFileReference; includeInIndex = 1; path = manifest.json; sourceTree = ""; }; + C4F38BCAC82CF1EF63DAC715 /* SignerBridgeStorage.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = SignerBridgeStorage.swift; path = NIP07/SignerBridgeStorage.swift; sourceTree = ""; }; + CA33ABC3C52B3945666924A4 /* manifest.json */ = {isa = PBXFileReference; includeInIndex = 1; name = manifest.json; path = Damoose/Resources/manifest.json; sourceTree = ""; }; D2277EE92A089BD5006C3807 /* Router.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Router.swift; sourceTree = ""; }; D5C1AFBE2E5DF7E60092F72F /* ContactCardManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactCardManager.swift; sourceTree = ""; }; D5C1AFC32E5DFF700092F72F /* ContactCardManagerMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactCardManagerMock.swift; sourceTree = ""; }; @@ -2865,6 +2950,7 @@ E0EE9DD32B8E5FEA00F3002D /* ImageProcessing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageProcessing.swift; sourceTree = ""; }; E4FA1C022A24BB7F00482697 /* SearchSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchSettingsView.swift; sourceTree = ""; }; E990020E2955F837003BBC5A /* EditMetadataView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditMetadataView.swift; sourceTree = ""; }; + ED6B4570DFBE54FC8A8B6598 /* popup.css */ = {isa = PBXFileReference; includeInIndex = 1; name = popup.css; path = Damoose/Resources/popup.css; sourceTree = ""; }; F71694E92A662232001F4053 /* OnboardingSuggestionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingSuggestionsView.swift; sourceTree = ""; }; F71694EB2A662292001F4053 /* SuggestedUsersViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestedUsersViewModel.swift; sourceTree = ""; }; F71694F12A67314D001F4053 /* SuggestedUserView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestedUserView.swift; sourceTree = ""; }; @@ -2878,10 +2964,30 @@ F79C7FAC29D5E9620000F946 /* EditPictureControl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditPictureControl.swift; sourceTree = ""; }; F7F0BA24297892BD009531F3 /* SwipeToDismiss.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwipeToDismiss.swift; sourceTree = ""; }; F7F0BA262978E54D009531F3 /* ParticipantsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParticipantsView.swift; sourceTree = ""; }; + F8DCE3961C99289AA8F15BD6 /* content.js */ = {isa = PBXFileReference; includeInIndex = 1; path = content.js; sourceTree = ""; }; F944F56D29EA9CCC0067B3BF /* DamusParseContentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusParseContentTests.swift; sourceTree = ""; }; + FB5DC8601708330B96927EAD /* ClientPermissions.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ClientPermissions.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ + 1CAA67003388224F30381A99 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + F2EB6F7685B23EEACDD1DF0F /* Foundation.framework in Frameworks */, + 55AF4186A2E0B85D9550F4DD /* SafariServices.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 36BAFB6F0B1C1B713CB88FAC /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + A14FAA9F3D34738FBCE6392D /* secp256k1 in Frameworks */, + 6603FDF7C6E06045982AE97F /* Foundation.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 4CE6DEE027F7A08100C66700 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -2961,6 +3067,14 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 1B0292BDC6A10318AEA6B1AA /* images */ = { + isa = PBXGroup; + children = ( + ); + name = images; + path = images; + sourceTree = ""; + }; 3169CAE4294E699400EE4006 /* Empty Views */ = { isa = PBXGroup; children = ( @@ -2989,6 +3103,14 @@ path = Reposts; sourceTree = ""; }; + 4A23636606EA22842FB1A2C7 /* NIP07 */ = { + isa = PBXGroup; + children = ( + C4F38BCAC82CF1EF63DAC715 /* SignerBridgeStorage.swift */, + ); + name = NIP07; + sourceTree = ""; + }; 4C0C03962A61E2670098B3B8 /* Fixtures */ = { isa = PBXGroup; children = ( @@ -3777,6 +3899,8 @@ D703D71A2C66E47100A400EA /* highlighter action extension */, 4CE6DEE427F7A08100C66700 /* Products */, 4CEE2AE62804F57B00AB5EEF /* Frameworks */, + 71870F8E223FD99D1C1EEF87 /* DamusNostrSigner */, + EA1E5B64529194FF4566A699 /* Damoose */, ); indentWidth = 4; sourceTree = ""; @@ -3791,6 +3915,8 @@ D79C4C142AFEB061003A41B4 /* DamusNotificationService.appex */, D703D7172C66E47100A400EA /* HighlighterActionExtension.appex */, 82D6FA972CD9820500C925F4 /* ShareExtension.appex */, + 9026E9C3435D4FD77AA73D5F /* DamusNostrSigner.appex */, + A7C28743E03F8D3B1AA0D68C /* Damoose.appex */, ); name = Products; sourceTree = ""; @@ -3815,6 +3941,7 @@ 4C687C262A6039500092C550 /* TestData.swift */, 50DA11252A16A23F00236234 /* Launch.storyboard */, D71AD8FC2CEC176A002E2C3C /* AppAccessibilityIdentifiers.swift */, + 690ECFF821A856DDDFEF2795 /* Damoose */, ); path = damus; sourceTree = ""; @@ -3927,6 +4054,8 @@ children = ( 4CEE2AE72804F57C00AB5EEF /* libsecp256k1.a */, D703D7182C66E47100A400EA /* UniformTypeIdentifiers.framework */, + EED42668367911BFAD7705D0 /* iOS */, + 9FD6A85B7BEB1B79F49C5F6F /* SafariServices.framework */, ); name = Frameworks; sourceTree = ""; @@ -5096,6 +5225,33 @@ path = Detail; sourceTree = ""; }; + 690ECFF821A856DDDFEF2795 /* Damoose */ = { + isa = PBXGroup; + children = ( + A788F30AB4EECC73595473F9 /* Policies */, + 7D63E9B4C14631B286D9378F /* SharedKeychainStorage.swift */, + B5565A535F7D242F2BB775CF /* SigningPolicy.swift */, + FB5DC8601708330B96927EAD /* ClientPermissions.swift */, + 7A98FBDCB5795D169055EAA1 /* SigningPolicyManager.swift */, + AE7A1C5C36BC7A8C56DC51F0 /* NIP55 */, + 4A23636606EA22842FB1A2C7 /* NIP07 */, + ); + name = Damoose; + path = Damoose; + sourceTree = ""; + }; + 71870F8E223FD99D1C1EEF87 /* DamusNostrSigner */ = { + isa = PBXGroup; + children = ( + 62CC338E2401529E37DB6760 /* SafariWebExtensionHandler.swift */, + A0A4D10D75CE8E201B5E42E6 /* Info.plist */, + A2F0CD60F688EAA487166BAE /* DamusNostrSigner.entitlements */, + A3175E5A3737111893284EB5 /* Resources */, + ); + name = DamusNostrSigner; + path = DamusNostrSigner; + sourceTree = ""; + }; 7C0F392D29B57C8F0039859C /* Extensions */ = { isa = PBXGroup; children = ( @@ -5120,6 +5276,48 @@ path = "share extension"; sourceTree = ""; }; + 9A59C25FBB891ECF67231169 /* _locales */ = { + isa = PBXGroup; + children = ( + ); + name = _locales; + sourceTree = ""; + }; + A3175E5A3737111893284EB5 /* Resources */ = { + isa = PBXGroup; + children = ( + BC8FAE1CCDB3B47CDD7AB12C /* manifest.json */, + F8DCE3961C99289AA8F15BD6 /* content.js */, + 522D287AAF3EAFE42A4FA783 /* injected.js */, + 193470E69DEC0B529B33A722 /* background.js */, + 1B0292BDC6A10318AEA6B1AA /* images */, + ); + name = Resources; + path = Resources; + sourceTree = ""; + }; + A788F30AB4EECC73595473F9 /* Policies */ = { + isa = PBXGroup; + children = ( + 3D40F8593960E80C843C56D7 /* ContactListPolicy.swift */, + ); + name = Policies; + path = Policies; + sourceTree = ""; + }; + AE7A1C5C36BC7A8C56DC51F0 /* NIP55 */ = { + isa = PBXGroup; + children = ( + AC5F33E64B399631CC45907D /* NostrSignerRequest.swift */, + 168D00B2CC7643AD5746FAED /* NostrSignerResponse.swift */, + 09F652082F3612D2D958155A /* NostrSignerHandler.swift */, + 3BEFA9DDC028EAD6CF8F2C1F /* NostrSignerApprovalView.swift */, + 74D4B97BA76DE5AC7BFE2D48 /* NostrSignerApprovalSheet.swift */, + ); + name = NIP55; + path = NIP55; + sourceTree = ""; + }; BA3759952ABCCF360018D73B /* Camera */ = { isa = PBXGroup; children = ( @@ -5130,6 +5328,13 @@ path = Camera; sourceTree = ""; }; + C1C0F8818FA625FA1DC61D15 /* images */ = { + isa = PBXGroup; + children = ( + ); + name = images; + sourceTree = ""; + }; D5C1AFC22E5DFF040092F72F /* ContactCard */ = { isa = PBXGroup; children = ( @@ -5316,6 +5521,41 @@ path = Assets; sourceTree = ""; }; + EA1E5B64529194FF4566A699 /* Damoose */ = { + isa = PBXGroup; + children = ( + F25DD37FCE5241CF1DF679A1 /* Resources */, + 7D56A6CE34E0251027FCCAFA /* SafariWebExtensionHandler.swift */, + 43ED5497F9FE4C075492DEB6 /* Damoose.entitlements */, + 35746FD8655A6221F84CF405 /* Info.plist */, + ); + name = Damoose; + sourceTree = ""; + }; + EED42668367911BFAD7705D0 /* iOS */ = { + isa = PBXGroup; + children = ( + 1FF6AE40E2192CE8CBD2C56B /* Foundation.framework */, + ); + name = iOS; + sourceTree = ""; + }; + F25DD37FCE5241CF1DF679A1 /* Resources */ = { + isa = PBXGroup; + children = ( + 256227FDEA93182DD9AA96EC /* background.js */, + 4A55A119A636196B066BD23A /* content.js */, + 0EBE2A8A93A5F5CA47407E45 /* nostr.js */, + CA33ABC3C52B3945666924A4 /* manifest.json */, + 92E7A3DCBFF57DE2B680EA4F /* popup.html */, + ED6B4570DFBE54FC8A8B6598 /* popup.css */, + 688E185C4A90830B63F0DB83 /* popup.js */, + C1C0F8818FA625FA1DC61D15 /* images */, + 9A59C25FBB891ECF67231169 /* _locales */, + ); + name = Resources; + sourceTree = ""; + }; F71694E82A66221E001F4053 /* Onboarding */ = { isa = PBXGroup; children = ( @@ -5351,6 +5591,43 @@ /* End PBXGroup section */ /* Begin PBXNativeTarget section */ + 13AE3D22F0CF1D41DEE86C10 /* DamusNostrSigner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 2BB7AB7FE0219058B2F32962 /* Build configuration list for PBXNativeTarget "DamusNostrSigner" */; + buildPhases = ( + 5D96B429F0B779FF0EEEB8D2 /* Sources */, + 1CAA67003388224F30381A99 /* Frameworks */, + D85C3B1ACF36594289189AEB /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = DamusNostrSigner; + productName = DamusNostrSigner; + productReference = 9026E9C3435D4FD77AA73D5F /* DamusNostrSigner.appex */; + productType = "com.apple.product-type.app-extension"; + }; + 454F3F97A25BAF4386597DA2 /* Damoose */ = { + isa = PBXNativeTarget; + buildConfigurationList = D8C0D39B7D40EF41D5AE31D3 /* Build configuration list for PBXNativeTarget "Damoose" */; + buildPhases = ( + BE8B85D9C2BD996D04E1525B /* Sources */, + 36BAFB6F0B1C1B713CB88FAC /* Frameworks */, + BF1775C6B0530EE3CD211201 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + packageProductDependencies = ( + 6BAC5FED0B6276CB910C650F /* secp256k1 */, + ); + name = Damoose; + productName = Damoose; + productReference = A7C28743E03F8D3B1AA0D68C /* Damoose.appex */; + productType = "com.apple.product-type.app-extension"; + }; 4CE6DEE227F7A08100C66700 /* damus */ = { isa = PBXNativeTarget; buildConfigurationList = 4CE6DF0727F7A08200C66700 /* Build configuration list for PBXNativeTarget "damus" */; @@ -5360,6 +5637,7 @@ 4C1D4FB22A7965230024F453 /* ShellScript */, 4CE6DEE127F7A08100C66700 /* Resources */, D79C4C1D2AFEB061003A41B4 /* Embed Foundation Extensions */, + D7B1004E81D79839428CD27E /* Embed App Extensions */, ); buildRules = ( ); @@ -5367,6 +5645,8 @@ D79C4C1A2AFEB061003A41B4 /* PBXTargetDependency */, D703D7242C66E47100A400EA /* PBXTargetDependency */, 82D6FAA02CD9820500C925F4 /* PBXTargetDependency */, + A0E1CDFEAEA00E22D5422E98 /* PBXTargetDependency */, + 18241C40156802431B7F399D /* PBXTargetDependency */, ); name = damus; packageProductDependencies = ( @@ -5579,7 +5859,7 @@ ); mainGroup = 4CE6DEDA27F7A08100C66700; packageReferences = ( - 4C64987F286E0EE300EAE2B3 /* XCRemoteSwiftPackageReference "secp256k1" */, + 4C64987F286E0EE300EAE2B3 /* XCRemoteSwiftPackageReference "secp256k1.swift" */, 4C06670228FC7EC500038D2A /* XCRemoteSwiftPackageReference "Kingfisher" */, 4CCF9AB02A1FE80B00E03CFB /* XCRemoteSwiftPackageReference "GSPlayer" */, 4C27C9302A64766F007DBC75 /* XCRemoteSwiftPackageReference "swift-markdown-ui" */, @@ -5601,6 +5881,8 @@ D79C4C132AFEB061003A41B4 /* DamusNotificationService */, D703D7162C66E47100A400EA /* HighlighterActionExtension */, 82D6FA962CD9820500C925F4 /* ShareExtension */, + 13AE3D22F0CF1D41DEE86C10 /* DamusNostrSigner */, + 454F3F97A25BAF4386597DA2 /* Damoose */, ); }; /* End PBXProject section */ @@ -5654,6 +5936,20 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + BF1775C6B0530EE3CD211201 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 570A19771BE4FF96AADF17C5 /* background.js in Resources */, + 05990030808D8F1709B29C54 /* content.js in Resources */, + 5E25E297170F1C15B9259470 /* nostr.js in Resources */, + ABE9EB86264ECCED5297C8AF /* manifest.json in Resources */, + 19597E310ACD06460F7AAF0A /* popup.html in Resources */, + 64E4B33D2E1D29802194C133 /* popup.css in Resources */, + B483F5146380E12C905D7EE0 /* popup.js in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; D703D7152C66E47100A400EA /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -5676,6 +5972,17 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + D85C3B1ACF36594289189AEB /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + A0D4F8CF0780353F18973E20 /* manifest.json in Resources */, + 2FE60245345B4E46001D2428 /* content.js in Resources */, + 676E8F8EE47E251878864707 /* injected.js in Resources */, + C6AFF986F9412344F6C88A39 /* background.js in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ @@ -6261,6 +6568,17 @@ 4C9B0DF32A65C46800CBDA21 /* ProfileEditButton.swift in Sources */, 4C32B95F2A9AD44700DC3548 /* Enum.swift in Sources */, 4C2859622A12A7F0004746F7 /* GoldSupportGradient.swift in Sources */, + 29FCAC04D2F32DC0AF2DD0DB /* SharedKeychainStorage.swift in Sources */, + 63674150BF249FCDBE79C904 /* SigningPolicy.swift in Sources */, + 606F43042338B8DE2FCC3BE9 /* ClientPermissions.swift in Sources */, + 86A8C0F7897EB76139F37AD5 /* SigningPolicyManager.swift in Sources */, + 4FA133BC107F2749EA955FF0 /* ContactListPolicy.swift in Sources */, + 3CBFFAB21483D39472CE9D9C /* NostrSignerRequest.swift in Sources */, + 98B76D31D3F167D9DC581FF3 /* NostrSignerResponse.swift in Sources */, + 69E7EC3BEBDF4DEAEF899293 /* NostrSignerHandler.swift in Sources */, + 1F2CA509E1DEFA14222FC6B6 /* NostrSignerApprovalView.swift in Sources */, + 59102E60D090B047ABC17B74 /* NostrSignerApprovalSheet.swift in Sources */, + 3B4E40C70DA17F5E9DA25891 /* SignerBridgeStorage.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -6338,6 +6656,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 5D96B429F0B779FF0EEEB8D2 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + CCB9F4B71BCB69EFE8EAA19F /* SafariWebExtensionHandler.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 82D6FA932CD9820500C925F4 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -6888,6 +7214,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + BE8B85D9C2BD996D04E1525B /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 255100AAA58739F43D61668E /* SafariWebExtensionHandler.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; D703D7132C66E47100A400EA /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -7174,7 +7508,7 @@ D73E5EFB2C6A97F4007EB227 /* ProfilePicturesView.swift in Sources */, D73E5EFC2C6A97F4007EB227 /* DamusAppNotificationView.swift in Sources */, D73E5EFD2C6A97F4007EB227 /* InnerTimelineView.swift in Sources */, - D73E5EFE2C6A97F4007EB227 /* (null) in Sources */, + D73E5EFE2C6A97F4007EB227 /* BuildFile in Sources */, D7EB00B02CD59C8D00660C07 /* PresentFullScreenItemNotify.swift in Sources */, D73E5EFF2C6A97F4007EB227 /* ZapsView.swift in Sources */, D73E5F002C6A97F4007EB227 /* CustomizeZapView.swift in Sources */, @@ -7604,6 +7938,12 @@ /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ + 18241C40156802431B7F399D /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + name = Damoose; + target = 454F3F97A25BAF4386597DA2 /* Damoose */; + targetProxy = 96061C3A0420A16C7C4900B1 /* PBXContainerItemProxy */; + }; 4CE6DEF527F7A08200C66700 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 4CE6DEE227F7A08100C66700 /* damus */; @@ -7619,6 +7959,12 @@ target = 82D6FA962CD9820500C925F4 /* ShareExtension */; targetProxy = 82D6FA9F2CD9820500C925F4 /* PBXContainerItemProxy */; }; + A0E1CDFEAEA00E22D5422E98 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + name = DamusNostrSigner; + target = 13AE3D22F0CF1D41DEE86C10 /* DamusNostrSigner */; + targetProxy = F87831C8A2DD3D549559BF82 /* PBXContainerItemProxy */; + }; D703D7242C66E47100A400EA /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = D703D7162C66E47100A400EA /* HighlighterActionExtension */; @@ -7760,6 +8106,29 @@ /* End PBXVariantGroup section */ /* Begin XCBuildConfiguration section */ + 12F9B85D578454A1165DFC0D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_WEAK = NO; + CODE_SIGN_ENTITLEMENTS = DamusNostrSigner/DamusNostrSigner.entitlements; + INFOPLIST_FILE = DamusNostrSigner/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.4; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.jb55.damus2.DamusNostrSigner; + PRODUCT_NAME = DamusNostrSigner; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; 4CE6DF0527F7A08200C66700 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -8080,6 +8449,45 @@ }; name = Release; }; + 5048BF14FD7EB52DC4C5A5C5 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ENABLE_OBJC_WEAK = NO; + CODE_SIGN_ENTITLEMENTS = Damoose/Damoose.entitlements; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = Damoose/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.jb55.damus2.Damoose; + PRODUCT_NAME = Damoose; + SDKROOT = iphoneos; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 6EBA3562A32D18C724E1921C /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_WEAK = NO; + CODE_SIGN_ENTITLEMENTS = DamusNostrSigner/DamusNostrSigner.entitlements; + INFOPLIST_FILE = DamusNostrSigner/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.4; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.jb55.damus2.DamusNostrSigner; + PRODUCT_NAME = DamusNostrSigner; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; 82D6FAA22CD9820500C925F4 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -8104,6 +8512,7 @@ "@executable_path/../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + OTHER_SWIFT_FLAGS = "-DEXTENSION"; PRODUCT_BUNDLE_IDENTIFIER = "com.jb55.damus2.share-extension"; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -8136,6 +8545,7 @@ "@executable_path/../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + OTHER_SWIFT_FLAGS = "-DEXTENSION"; PRODUCT_BUNDLE_IDENTIFIER = "com.jb55.damus2.share-extension"; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -8147,6 +8557,24 @@ }; name = Release; }; + C1BAEEE672C21D4D93B64202 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ENABLE_OBJC_WEAK = NO; + CODE_SIGN_ENTITLEMENTS = Damoose/Damoose.entitlements; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = Damoose/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.jb55.damus2.Damoose; + PRODUCT_NAME = Damoose; + SDKROOT = iphoneos; + SWIFT_VERSION = 5.0; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; D703D7282C66E47100A400EA /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -8168,6 +8596,7 @@ "@executable_path/../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + OTHER_SWIFT_FLAGS = "-DEXTENSION"; PRODUCT_BUNDLE_IDENTIFIER = "com.jb55.damus2.highlighter-action-extension"; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -8201,6 +8630,7 @@ "@executable_path/../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + OTHER_SWIFT_FLAGS = "-DEXTENSION"; PRODUCT_BUNDLE_IDENTIFIER = "com.jb55.damus2.highlighter-action-extension"; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -8233,6 +8663,7 @@ "@executable_path/../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + OTHER_SWIFT_FLAGS = "-DEXTENSION"; PRODUCT_BUNDLE_IDENTIFIER = com.jb55.damus2.DamusNotificationService; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -8266,6 +8697,7 @@ "@executable_path/../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + OTHER_SWIFT_FLAGS = "-DEXTENSION"; PRODUCT_BUNDLE_IDENTIFIER = com.jb55.damus2.DamusNotificationService; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -8280,6 +8712,15 @@ /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ + 2BB7AB7FE0219058B2F32962 /* Build configuration list for PBXNativeTarget "DamusNostrSigner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 12F9B85D578454A1165DFC0D /* Release */, + 6EBA3562A32D18C724E1921C /* Debug */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; 4CE6DEDE27F7A08100C66700 /* Build configuration list for PBXProject "damus" */ = { isa = XCConfigurationList; buildConfigurations = ( @@ -8343,6 +8784,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + D8C0D39B7D40EF41D5AE31D3 /* Build configuration list for PBXNativeTarget "Damoose" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + C1BAEEE672C21D4D93B64202 /* Release */, + 5048BF14FD7EB52DC4C5A5C5 /* Debug */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ @@ -8378,7 +8828,7 @@ kind = branch; }; }; - 4C64987F286E0EE300EAE2B3 /* XCRemoteSwiftPackageReference "secp256k1" */ = { + 4C64987F286E0EE300EAE2B3 /* XCRemoteSwiftPackageReference "secp256k1.swift" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/jb55/secp256k1.swift"; requirement = { @@ -8474,12 +8924,17 @@ }; 4C649880286E0EE300EAE2B3 /* secp256k1 */ = { isa = XCSwiftPackageProductDependency; - package = 4C64987F286E0EE300EAE2B3 /* XCRemoteSwiftPackageReference "secp256k1" */; + package = 4C64987F286E0EE300EAE2B3 /* XCRemoteSwiftPackageReference "secp256k1.swift" */; + productName = secp256k1; + }; + 6BAC5FED0B6276CB910C650F /* secp256k1 */ = { + isa = XCSwiftPackageProductDependency; + package = 4C64987F286E0EE300EAE2B3 /* XCRemoteSwiftPackageReference "secp256k1.swift" */; productName = secp256k1; }; 82D6FC802CD99FC500C925F4 /* secp256k1 */ = { isa = XCSwiftPackageProductDependency; - package = 4C64987F286E0EE300EAE2B3 /* XCRemoteSwiftPackageReference "secp256k1" */; + package = 4C64987F286E0EE300EAE2B3 /* XCRemoteSwiftPackageReference "secp256k1.swift" */; productName = secp256k1; }; 82D6FC832CD9A48500C925F4 /* Kingfisher */ = { @@ -8504,7 +8959,7 @@ }; D703D7482C6709B100A400EA /* secp256k1 */ = { isa = XCSwiftPackageProductDependency; - package = 4C64987F286E0EE300EAE2B3 /* XCRemoteSwiftPackageReference "secp256k1" */; + package = 4C64987F286E0EE300EAE2B3 /* XCRemoteSwiftPackageReference "secp256k1.swift" */; productName = secp256k1; }; D703D7AC2C670FA700A400EA /* MarkdownUI */ = { @@ -8549,7 +9004,7 @@ }; D789D11F2AFEFBF20083A7AB /* secp256k1 */ = { isa = XCSwiftPackageProductDependency; - package = 4C64987F286E0EE300EAE2B3 /* XCRemoteSwiftPackageReference "secp256k1" */; + package = 4C64987F286E0EE300EAE2B3 /* XCRemoteSwiftPackageReference "secp256k1.swift" */; productName = secp256k1; }; D78DB8582C1CE9CA00F0AB12 /* SwipeActions */ = { diff --git a/damus.xcodeproj/xcshareddata/xcschemes/Damoose.xcscheme b/damus.xcodeproj/xcshareddata/xcschemes/Damoose.xcscheme new file mode 100644 index 000000000..dd071d578 --- /dev/null +++ b/damus.xcodeproj/xcshareddata/xcschemes/Damoose.xcscheme @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/damus.xcodeproj/xcshareddata/xcschemes/DamusNostrSigner.xcscheme b/damus.xcodeproj/xcshareddata/xcschemes/DamusNostrSigner.xcscheme new file mode 100644 index 000000000..63894da7a --- /dev/null +++ b/damus.xcodeproj/xcshareddata/xcschemes/DamusNostrSigner.xcscheme @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/damus/ContentView.swift b/damus/ContentView.swift index e91754ab9..28835c3c7 100644 --- a/damus/ContentView.swift +++ b/damus/ContentView.swift @@ -33,6 +33,9 @@ enum Sheets: Identifiable { case purple(DamusPurpleURL) case purple_onboarding case error(ErrorView.UserPresentableError) + #if !EXTENSION + case nostrSignerApproval(NostrSignerRequest) + #endif static func zap(target: ZapTarget, lnurl: String) -> Sheets { return .zap(ZapSheet(target: target, lnurl: lnurl)) @@ -56,6 +59,9 @@ enum Sheets: Identifiable { case .purple(let purple_url): return "purple" + purple_url.url_string() case .purple_onboarding: return "purple_onboarding" case .error(_): return "error" + #if !EXTENSION + case .nostrSignerApproval(let request): return "nostrsigner-" + request.clientId + #endif } } } @@ -362,6 +368,10 @@ struct ContentView: View { DamusPurpleNewUserOnboardingView(damus_state: damus_state) case .error(let error): ErrorView(damus_state: damus_state!, error: error) + #if !EXTENSION + case .nostrSignerApproval(let request): + NostrSignerApprovalSheet(request: request, damus_state: damus_state!) + #endif } } .onOpenURL { url in diff --git a/damus/Damoose/ClientPermissions.swift b/damus/Damoose/ClientPermissions.swift new file mode 100644 index 000000000..227f0e6b8 --- /dev/null +++ b/damus/Damoose/ClientPermissions.swift @@ -0,0 +1,194 @@ +// +// ClientPermissions.swift +// damus +// +// Per-client permission storage for Damoose signing policy. +// + +import Foundation + +// MARK: - Trust Level + +/// Trust level assigned to a signing client. +/// +/// Determines the default behavior for signing requests from this client. +enum TrustLevel: String, Codable, CaseIterable { + /// Always require user approval for every request. + case untrusted + + /// Auto-approve safe event kinds only (reactions, basic posts). + case limited + + /// Auto-approve most operations except dangerous ones. + case trusted + + /// Auto-approve everything. Reserved for internal Damus use. + case full + + /// Human-readable description for settings UI. + var description: String { + switch self { + case .untrusted: + return "Always ask" + case .limited: + return "Auto-approve safe actions" + case .trusted: + return "Auto-approve most actions" + case .full: + return "Auto-approve all" + } + } + + /// Event kinds that are auto-approved at this trust level. + var autoApprovedKinds: Set { + switch self { + case .untrusted: + return [] + case .limited: + // Reactions, reposts - low risk + return [6, 7] + case .trusted: + // Posts, reactions, reposts, DMs, zap requests + return [1, 4, 6, 7, 9734] + case .full: + // Everything + return [] // Special case: all kinds approved + } + } +} + +// MARK: - Client Permissions + +/// Permissions granted to a specific signing client. +/// +/// Stored in UserDefaults, keyed by client ID. +struct ClientPermissions: Codable, Equatable { + /// The client these permissions apply to. + let clientId: String + + /// Event kinds explicitly approved for this client. + var approvedKinds: Set + + /// Event kinds explicitly blocked for this client. + var blockedKinds: Set + + /// The trust level for this client. + var trustLevel: TrustLevel + + /// Whether this client is completely blocked. + var isBlocked: Bool + + /// When these permissions were last modified. + var lastModified: Date + + /// When this client last made a signing request. + var lastUsed: Date? + + /// Total number of signing requests from this client. + var requestCount: Int + + /// Creates default permissions for a new client. + init(clientId: String) { + self.clientId = clientId + self.approvedKinds = [] + self.blockedKinds = [] + self.trustLevel = .untrusted + self.isBlocked = false + self.lastModified = Date() + self.lastUsed = nil + self.requestCount = 0 + } + + /// Checks if a specific event kind is approved for this client. + /// + /// - Parameter kind: The event kind to check. + /// - Returns: true if the kind is approved (explicitly or via trust level). + func isKindApproved(_ kind: UInt32) -> Bool { + // Blocked kinds are never approved + guard !blockedKinds.contains(kind) else { + return false + } + + // Check explicit approval + if approvedKinds.contains(kind) { + return true + } + + // Check trust level auto-approval + if trustLevel == .full { + return true + } + + return trustLevel.autoApprovedKinds.contains(kind) + } + + /// Records that this client made a signing request. + mutating func recordRequest() { + lastUsed = Date() + requestCount += 1 + } +} + +// MARK: - Permissions Store + +/// Manages persistent storage of client permissions. +/// +/// Permissions are stored in UserDefaults as JSON, keyed by a common prefix. +enum ClientPermissionsStore { + private static let storageKey = "damoose_client_permissions" + + /// Loads all stored client permissions. + /// + /// - Returns: Dictionary of client ID to permissions. + static func loadAll() -> [String: ClientPermissions] { + guard let data = DamusUserDefaults.standard.object(forKey: storageKey) as? Data else { + return [:] + } + guard let permissions = try? JSONDecoder().decode([String: ClientPermissions].self, from: data) else { + return [:] + } + return permissions + } + + /// Saves all client permissions. + /// + /// - Parameter permissions: Dictionary of client ID to permissions. + static func saveAll(_ permissions: [String: ClientPermissions]) { + guard let data = try? JSONEncoder().encode(permissions) else { + return + } + DamusUserDefaults.standard.set(data, forKey: storageKey) + } + + /// Loads permissions for a specific client. + /// + /// - Parameter clientId: The client ID to look up. + /// - Returns: The client's permissions, or nil if not found. + static func load(clientId: String) -> ClientPermissions? { + let all = loadAll() + return all[clientId] + } + + /// Saves permissions for a specific client. + /// + /// - Parameter permissions: The permissions to save. + static func save(_ permissions: ClientPermissions) { + var all = loadAll() + all[permissions.clientId] = permissions + saveAll(all) + } + + /// Deletes permissions for a specific client. + /// + /// - Parameter clientId: The client ID to delete. + static func delete(clientId: String) { + var all = loadAll() + all.removeValue(forKey: clientId) + saveAll(all) + } + + /// Resets all client permissions. + static func reset() { + DamusUserDefaults.standard.removeObject(forKey: storageKey) + } +} diff --git a/damus/Damoose/DIP-05.md b/damus/Damoose/DIP-05.md new file mode 100644 index 000000000..bac8f2e55 --- /dev/null +++ b/damus/Damoose/DIP-05.md @@ -0,0 +1,149 @@ +# DIP-05 + +## NIP-55 iOS Extension + +This specification adapts NIP-55's signer protocol for iOS, enabling other iOS nostr apps to use Damus as their signer via URL schemes. + +### Background + +NIP-55 defines a signer protocol with two mechanisms: +1. **Android Intents** - Android-only, not applicable to iOS +2. **URL callbacks** - Originally for web apps, adapted here for iOS + +This DIP documents iOS-specific requirements and deviations from NIP-55 web flows. + +### URL Scheme + +The `nostrsigner` URL scheme is used, matching NIP-55 for cross-platform recognition. + +### Request Format + +``` +nostrsigner:?type=&callbackUrl=&compressionType=&returnType=&pubkey= +``` + +#### Parameters + +| Parameter | Required | Values | Description | +|-----------|----------|--------|-------------| +| `type` | Yes | `get_public_key`, `sign_event`, `nip04_encrypt`, `nip04_decrypt`, `nip44_encrypt`, `nip44_decrypt`, `decrypt_zap_event` | Operation to perform | +| `callbackUrl` | **Yes*** | URL-encoded callback | Where to send result (e.g., `primal://nostrsigner`) | +| `compressionType` | No | `none` (default), `gzip` | Encoding for `event` parameter in response | +| `returnType` | No | `signature`, `event` | For sign_event: return just sig or full event | +| `pubkey` | For encrypt/decrypt | hex | Target pubkey for encryption operations | + +**\*iOS Deviation**: `callbackUrl` is required on iOS. NIP-55 allows clipboard fallback for web apps, but iOS does not support clipboard sharing between apps. + +#### Content + +The `` portion is URL-encoded, matching NIP-55. + +| Operation | Content | +|-----------|---------| +| `sign_event` | Unsigned event JSON `{"kind":1,"content":"...","tags":[],"created_at":123}` | +| `nip04_encrypt`, `nip44_encrypt` | Plaintext string to encrypt | +| `nip04_decrypt`, `nip44_decrypt` | NIP-04/44 ciphertext string | +| `get_public_key` | Empty or omitted | +| `decrypt_zap_event` | Encrypted zap event (NIP-04/44 ciphertext) | + +### Response Format + +The signer opens the callback URL with query parameters appended. + +#### Query Parameter Appending + +If the `callbackUrl` already contains query parameters, append with `&`. Otherwise, append with `?`. + +``` +# callbackUrl has no params: +primal://nostrsigner → primal://nostrsigner?result= + +# callbackUrl already has params: +primal://callback?session=123 → primal://callback?session=123&result= +``` + +#### Response Encoding + +The `event` parameter encoding depends on `compressionType`: + +| `compressionType` | `event` parameter | +|-------------------|-------------------| +| `none` (default) | URL-encoded JSON | +| `gzip` | `Signer1` + base64(gzip(JSON)) | + +The `result` parameter encoding depends on operation type: + +| Operation | `result` encoding | +|-----------|-------------------| +| `get_public_key` | Hex pubkey | +| `sign_event` | Hex signature | +| `nip04_encrypt`, `nip44_encrypt` | NIP-04/44 ciphertext string | +| `nip04_decrypt`, `nip44_decrypt` | Decrypted plaintext | +| `decrypt_zap_event` | Decrypted JSON string | + +All response parameter values MUST be URL-encoded. Hex strings are inherently URL-safe. The `event` parameter contains JSON and MUST always be URL-encoded. + +#### Success Response + +| Parameter | Condition | Value | +|-----------|-----------|-------| +| `result` | Always | Operation result (see encoding table above) | +| `event` | `returnType=event` | Full signed event | + +#### Error Response + +| Parameter | Value | +|-----------|-------| +| `error` | Error code | +| `rejected` | `true` if user explicitly rejected | + +Error codes: `user_rejected`, `invalid_content`, `invalid_callback`, `unsupported_type`, `invalid_pubkey`, `internal_error` + +### Example Flow + +``` +1. Primal wants to post a note +2. Primal opens: + nostrsigner:%7B%22kind%22%3A1%2C%22content%22%3A%22Hello%22%7D?type=sign_event&callbackUrl=primal%3A%2F%2Fnostrsigner&returnType=event + +3. iOS switches to Damus +4. Damus parses request, shows approval UI +5. User approves +6. Damus signs event, opens: + primal://nostrsigner?result=abc123...&event=%7B%22id%22%3A%22...%22%2C%22sig%22%3A%22abc123...%22%7D + +7. iOS switches back to Primal with signed event +``` + +### Security Considerations + +1. **Callback URL validation**: Reject dangerous schemes (`file://`, `javascript://`, `data://`, `about://`, `blob://`, `nostrsigner://`) + +2. **Re-entrancy prevention**: Reject requests where `callbackUrl` uses `nostrsigner` scheme to prevent infinite loops. + +3. **User approval**: All signing requests MUST show approval UI before signing. + +### Client Identification + +The client is identified from `callbackUrl` for permission tracking: +1. Use `callbackUrl.host` if present (e.g., `primal` from `primal://nostrsigner`) +2. Fallback to `callbackUrl.scheme` if host is empty +3. Fallback to `"unknown"` if both are empty + +### iOS Implementation Notes + +- Register `nostrsigner` scheme in `Info.plist` +- Handle incoming URLs via SwiftUI's `.onOpenURL` modifier +- Return results using `UIApplication.shared.open(callbackUrl)` +- Requesting apps must add `nostrsigner` to `LSApplicationQueriesSchemes` + +### Differences from NIP-55 + +| Aspect | NIP-55 Web | DIP-05 iOS | +|--------|------------|------------| +| `callbackUrl` | Optional (clipboard fallback) | **Required** | +| Inter-app communication | Browser navigation | iOS URL schemes | + +### References + +- [NIP-55: Android Signer Application](https://github.com/nostr-protocol/nips/blob/master/55.md) diff --git a/damus/Damoose/NIP07/SignerBridgeStorage.swift b/damus/Damoose/NIP07/SignerBridgeStorage.swift new file mode 100644 index 000000000..60a7d200c --- /dev/null +++ b/damus/Damoose/NIP07/SignerBridgeStorage.swift @@ -0,0 +1,201 @@ +// +// SignerBridgeStorage.swift +// damus +// +// Shared storage for Safari extension ↔ Damus app communication. +// Uses App Group UserDefaults for cross-process access. +// + +import Foundation + +/// Shared storage for NIP-07 Safari extension to communicate with Damus app. +/// +/// The Safari extension cannot receive URL callbacks directly. Instead: +/// 1. Extension stores request with unique ID +/// 2. Extension returns URL for JS to open (switches to Damus) +/// 3. Damus processes request, stores result keyed by ID +/// 4. JS polls extension, which checks for result +/// +/// ## Usage (Extension side) +/// ```swift +/// let requestId = SignerBridgeStorage.storeRequest(event: eventJson, origin: "snort.social") +/// return ["action": "openUrl", "url": url, "requestId": requestId] +/// // Later, when polled: +/// if let result = SignerBridgeStorage.getResult(requestId: requestId) { +/// return result +/// } +/// ``` +/// +/// ## Usage (Damus app side) +/// ```swift +/// // After signing +/// if let requestId = request.extensionRequestId { +/// SignerBridgeStorage.storeResult(requestId: requestId, result: signedEvent) +/// } +/// ``` +enum SignerBridgeStorage { + + // MARK: - Constants + + /// App Group identifier for shared storage. + private static let appGroup = "group.com.damus" + + /// Key prefix for pending requests. + private static let requestPrefix = "signer_request_" + + /// Key prefix for results. + private static let resultPrefix = "signer_result_" + + /// How long to keep results before cleanup (5 minutes). + private static let resultTTL: TimeInterval = 300 + + // MARK: - Request Storage (Extension → App) + + /// Stores a signing request and returns its unique ID. + /// + /// - Parameters: + /// - eventJson: The unsigned event JSON to sign. + /// - origin: The website origin requesting the signature. + /// - Returns: Unique request ID, or nil if storage failed. + static func storeRequest(eventJson: String, origin: String) -> String? { + guard let defaults = UserDefaults(suiteName: appGroup) else { + return nil + } + + // Clean up stale entries before storing new request + cleanup() + + let requestId = UUID().uuidString + let request: [String: Any] = [ + "event": eventJson, + "origin": origin, + "timestamp": Date().timeIntervalSince1970 + ] + + defaults.set(request, forKey: requestPrefix + requestId) + defaults.synchronize() + + return requestId + } + + /// Retrieves a pending request by ID. + /// + /// - Parameter requestId: The request ID. + /// - Returns: The request details, or nil if not found. + static func getRequest(requestId: String) -> (eventJson: String, origin: String)? { + guard let defaults = UserDefaults(suiteName: appGroup), + let request = defaults.dictionary(forKey: requestPrefix + requestId), + let eventJson = request["event"] as? String, + let origin = request["origin"] as? String else { + return nil + } + + return (eventJson, origin) + } + + /// Removes a pending request. + /// + /// - Parameter requestId: The request ID to remove. + static func removeRequest(requestId: String) { + guard let defaults = UserDefaults(suiteName: appGroup) else { + return + } + defaults.removeObject(forKey: requestPrefix + requestId) + defaults.synchronize() + } + + // MARK: - Result Storage (App → Extension) + + /// Stores a signing result for the extension to retrieve. + /// + /// - Parameters: + /// - requestId: The request ID this result is for. + /// - signedEventJson: The signed event JSON, or nil if rejected/error. + /// - signature: The signature hex string. + /// - error: Error message if signing failed. + static func storeResult( + requestId: String, + signedEventJson: String? = nil, + signature: String? = nil, + error: String? = nil + ) { + guard let defaults = UserDefaults(suiteName: appGroup) else { + return + } + + var result: [String: Any] = [ + "timestamp": Date().timeIntervalSince1970 + ] + + if let signedEventJson = signedEventJson { + result["event"] = signedEventJson + } + if let signature = signature { + result["signature"] = signature + } + if let error = error { + result["error"] = error + } + + defaults.set(result, forKey: resultPrefix + requestId) + defaults.synchronize() + + // Clean up the original request + removeRequest(requestId: requestId) + } + + /// Retrieves and removes a signing result. + /// + /// - Parameter requestId: The request ID. + /// - Returns: The result dictionary, or nil if not ready. + static func getResult(requestId: String) -> [String: Any]? { + guard let defaults = UserDefaults(suiteName: appGroup), + let result = defaults.dictionary(forKey: resultPrefix + requestId) else { + return nil + } + + // Remove after reading (one-time retrieval) + defaults.removeObject(forKey: resultPrefix + requestId) + defaults.synchronize() + + return result + } + + /// Checks if a result is available without consuming it. + /// + /// - Parameter requestId: The request ID. + /// - Returns: true if a result is available. + static func hasResult(requestId: String) -> Bool { + guard let defaults = UserDefaults(suiteName: appGroup) else { + return false + } + return defaults.object(forKey: resultPrefix + requestId) != nil + } + + // MARK: - Cleanup + + /// Removes stale requests and results older than TTL. + static func cleanup() { + guard let defaults = UserDefaults(suiteName: appGroup) else { + return + } + + let now = Date().timeIntervalSince1970 + let allKeys = defaults.dictionaryRepresentation().keys + + for key in allKeys { + guard key.hasPrefix(requestPrefix) || key.hasPrefix(resultPrefix) else { + continue + } + guard let dict = defaults.dictionary(forKey: key), + let timestamp = dict["timestamp"] as? TimeInterval else { + continue + } + if now - timestamp > resultTTL { + defaults.removeObject(forKey: key) + } + } + + defaults.synchronize() + } +} diff --git a/damus/Damoose/NIP55/NostrSignerApprovalSheet.swift b/damus/Damoose/NIP55/NostrSignerApprovalSheet.swift new file mode 100644 index 000000000..0698d9f35 --- /dev/null +++ b/damus/Damoose/NIP55/NostrSignerApprovalSheet.swift @@ -0,0 +1,178 @@ +// +// NostrSignerApprovalSheet.swift +// damus +// +// NIP-55 iOS Extension: Sheet wrapper that handles the full signing flow. +// + +import SwiftUI + +/// Sheet wrapper for NIP-55 signing requests. +/// +/// This view handles the complete flow: +/// 1. Evaluates the request against SigningPolicyManager +/// 2. If auto-approved, immediately signs and opens callback +/// 3. If requires approval, shows NostrSignerApprovalView +/// 4. On user decision, signs (or rejects) and opens callback +struct NostrSignerApprovalSheet: View { + + let request: NostrSignerRequest + let damus_state: DamusState + + @Environment(\.dismiss) var dismiss + @State private var approvalContext: ApprovalContext? + @State private var isProcessing = true + @State private var errorMessage: String? + + var body: some View { + Group { + if isProcessing { + processingView + } else if let context = approvalContext { + NostrSignerApprovalView( + context: context, + request: request, + onApprove: { rememberChoice in + handleApproval(approved: true, rememberChoice: rememberChoice, context: context) + }, + onDeny: { blockClient in + handleApproval(approved: false, blockClient: blockClient, context: context) + } + ) + } else if let error = errorMessage { + errorView(error) + } + } + .task { + await processRequest() + } + } + + // MARK: - Subviews + + /// Loading/processing view. + private var processingView: some View { + VStack(spacing: 16) { + ProgressView() + .scaleEffect(1.5) + Text("Processing signing request...") + .font(.subheadline) + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + /// Error view when something goes wrong. + private func errorView(_ message: String) -> some View { + VStack(spacing: 16) { + Image(systemName: "exclamationmark.triangle") + .font(.system(size: 48)) + .foregroundColor(.orange) + + Text("Signing Request Failed") + .font(.headline) + + Text(message) + .font(.subheadline) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal) + + Button("Close") { + dismiss() + } + .buttonStyle(.bordered) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + // MARK: - Request Processing + + /// Processes the signing request. + @MainActor + private func processRequest() async { + let result = await NostrSignerHandler.shared.handle(request: request) + + switch result { + case .callback(let url): + // Auto-approved or simple request (like get_public_key) + openCallback(url) + dismiss() + + case .extensionComplete: + // Extension request - result stored in bridge storage, no callback needed + // User will switch back to Safari + dismiss() + + case .requiresApproval(let context, _): + // Need user approval + self.approvalContext = context + self.isProcessing = false + + case .failed: + // Something went wrong, no callback possible + self.errorMessage = "Invalid signing request" + self.isProcessing = false + } + } + + /// Handles user approval or denial. + @MainActor + private func handleApproval(approved: Bool, rememberChoice: Bool = false, blockClient: Bool = false, context: ApprovalContext) { + // Update permissions if needed + if approved && rememberChoice { + SigningPolicyManager.shared.approveKind(context.event.kind, for: context.client) + } + if !approved && blockClient { + SigningPolicyManager.shared.blockClient(context.client) + } + + // Get callback URL (nil for extension requests - result stored in bridge) + let callbackUrl = NostrSignerHandler.shared.completeApproval( + approved: approved, + request: request, + context: context + ) + + // For extension requests, callbackUrl is nil and result is in bridge storage + // User will switch back to Safari manually + if let callbackUrl { + openCallback(callbackUrl) + } + + dismiss() + } + + /// Opens the callback URL to return result to requesting app. + private func openCallback(_ url: URL) { + // Use UIApplication to open the URL + // This will switch to the requesting app + DispatchQueue.main.async { + UIApplication.shared.open(url, options: [:]) { success in + if !success { + print("NostrSigner: Failed to open callback URL: \(url)") + } + } + } + } +} + +// MARK: - Preview + +#if DEBUG +struct NostrSignerApprovalSheet_Previews: PreviewProvider { + static var previews: some View { + let request = NostrSignerRequest( + method: .signEvent, + content: nil, + callbackUrl: URL(string: "example://callback")!, + returnType: .event, + compressionType: .none, + targetPubkey: nil, + extensionRequestId: nil + ) + + Text("Preview requires DamusState") + } +} +#endif diff --git a/damus/Damoose/NIP55/NostrSignerApprovalView.swift b/damus/Damoose/NIP55/NostrSignerApprovalView.swift new file mode 100644 index 000000000..98bbe9597 --- /dev/null +++ b/damus/Damoose/NIP55/NostrSignerApprovalView.swift @@ -0,0 +1,339 @@ +// +// NostrSignerApprovalView.swift +// damus +// +// NIP-55 iOS Extension: Approval UI for external signing requests. +// + +import SwiftUI + +/// Approval sheet for external signing requests. +/// +/// Displays the requesting app, what they want to sign, any detected risks, +/// and approve/deny buttons with optional "remember" checkboxes. +struct NostrSignerApprovalView: View { + + // MARK: - Properties + + let context: ApprovalContext + let request: NostrSignerRequest + let onApprove: (Bool) -> Void // Bool = rememberChoice + let onDeny: (Bool) -> Void // Bool = blockClient + + @State private var rememberChoice = false + @State private var blockClient = false + + @Environment(\.dismiss) var dismiss + @Environment(\.colorScheme) var colorScheme + + // MARK: - Body + + var body: some View { + NavigationView { + ScrollView { + VStack(spacing: 24) { + // App info header + appHeader + + Divider() + + // What they want to sign + requestDetails + + // Risk warnings (if any) + if !context.risks.isEmpty { + riskWarnings + } + + Divider() + + // Options + optionsSection + + // Action buttons + actionButtons + } + .padding() + } + .background(backgroundColor) + .navigationTitle("Signing Request") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + onDeny(blockClient) + dismiss() + } + } + } + } + } + + // MARK: - Subviews + + /// Header showing the requesting app. + private var appHeader: some View { + VStack(spacing: 12) { + // App icon placeholder + Image(systemName: "app.badge") + .font(.system(size: 48)) + .foregroundColor(.accentColor) + + // App name + Text(context.client.name ?? context.client.id) + .font(.headline) + + // Client ID + Text(context.client.id) + .font(.caption) + .foregroundColor(.secondary) + .lineLimit(1) + } + } + + /// Details about the signing request. + private var requestDetails: some View { + VStack(alignment: .leading, spacing: 12) { + Text("wants to:") + .font(.subheadline) + .foregroundColor(.secondary) + + // Summary of what's being signed + HStack { + kindIcon + .font(.title2) + + VStack(alignment: .leading, spacing: 4) { + Text(context.summary) + .font(.body) + .fontWeight(.medium) + + Text("Kind \(context.event.kind)") + .font(.caption) + .foregroundColor(.secondary) + } + } + .padding() + .background(Color.secondary.opacity(0.1)) + .cornerRadius(12) + + // Content preview for text events + if shouldShowContentPreview { + contentPreview + } + } + } + + /// Icon based on the event kind. + private var kindIcon: some View { + let icon: String + switch context.event.kind { + case 0: icon = "person.crop.circle" // Metadata + case 1: icon = "text.bubble" // Text note + case 3: icon = "person.2" // Contacts + case 4: icon = "lock" // DM + case 5: icon = "trash" // Deletion + case 6: icon = "arrow.2.squarepath" // Repost + case 7: icon = "heart" // Reaction + default: icon = "doc.text" + } + return Image(systemName: icon) + } + + /// Whether to show a content preview. + private var shouldShowContentPreview: Bool { + let previewableKinds: Set = [1, 4, 7] // Notes, DMs, reactions + return previewableKinds.contains(context.event.kind) && !context.event.content.isEmpty + } + + /// Preview of the event content. + private var contentPreview: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Content:") + .font(.caption) + .foregroundColor(.secondary) + + Text(context.event.content.prefix(200) + (context.event.content.count > 200 ? "..." : "")) + .font(.body) + .padding() + .background(Color.secondary.opacity(0.05)) + .cornerRadius(8) + } + } + + /// Risk warning section. + private var riskWarnings: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(.orange) + Text("Warnings") + .font(.headline) + } + + ForEach(context.risks.sorted { $0.severity > $1.severity }, id: \.description) { risk in + riskRow(risk) + } + } + .padding() + .background(Color.orange.opacity(0.1)) + .cornerRadius(12) + } + + /// A single risk warning row. + private func riskRow(_ risk: SigningRisk) -> some View { + HStack(alignment: .top, spacing: 12) { + Image(systemName: riskIcon(for: risk)) + .foregroundColor(riskColor(for: risk)) + + Text(risk.description) + .font(.subheadline) + } + } + + /// Icon for a risk type. + private func riskIcon(for risk: SigningRisk) -> String { + switch risk { + case .contactListEmpty, .contactListTruncation: + return "person.2.slash" + case .deletionEvent: + return "trash" + case .encryptedContent: + return "lock.shield" + case .highFrequency: + return "speedometer" + case .unknownClient: + return "questionmark.circle" + } + } + + /// Color for a risk type based on severity. + private func riskColor(for risk: SigningRisk) -> Color { + switch risk.severity { + case 5: return .red + case 4: return .orange + case 3: return .yellow + default: return .secondary + } + } + + /// Options section with checkboxes. + private var optionsSection: some View { + VStack(alignment: .leading, spacing: 12) { + Toggle(isOn: $rememberChoice) { + VStack(alignment: .leading, spacing: 2) { + Text("Remember this choice") + .font(.subheadline) + Text("Auto-approve kind \(context.event.kind) from this app") + .font(.caption) + .foregroundColor(.secondary) + } + } + .toggleStyle(CheckboxToggleStyle()) + } + } + + /// Approve and deny buttons. + private var actionButtons: some View { + VStack(spacing: 12) { + // Approve button + Button { + onApprove(rememberChoice) + dismiss() + } label: { + HStack { + Image(systemName: "checkmark.circle.fill") + Text("Approve") + } + .frame(maxWidth: .infinity) + .padding() + .background(Color.accentColor) + .foregroundColor(.white) + .cornerRadius(12) + } + + // Deny button + Button { + onDeny(blockClient) + dismiss() + } label: { + HStack { + Image(systemName: "xmark.circle.fill") + Text("Deny") + } + .frame(maxWidth: .infinity) + .padding() + .background(Color.red.opacity(0.1)) + .foregroundColor(.red) + .cornerRadius(12) + } + } + } + + /// Background color based on color scheme. + private var backgroundColor: Color { + colorScheme == .dark ? Color.black : Color.white + } +} + +// MARK: - Checkbox Toggle Style + +/// A checkbox-style toggle for the options. +struct CheckboxToggleStyle: ToggleStyle { + func makeBody(configuration: Configuration) -> some View { + Button { + configuration.isOn.toggle() + } label: { + HStack(alignment: .top, spacing: 12) { + Image(systemName: configuration.isOn ? "checkmark.square.fill" : "square") + .foregroundColor(configuration.isOn ? .accentColor : .secondary) + .font(.title3) + + configuration.label + } + } + .buttonStyle(.plain) + } +} + +// MARK: - Preview + +#if DEBUG +struct NostrSignerApprovalView_Previews: PreviewProvider { + static var previews: some View { + let event = UnsignedEvent( + kind: 1, + content: "Hello, world! This is a test post from an external app.", + tags: [] + ) + + let client = SigningClient( + id: "com.example.nostrapp", + name: "Example Nostr App" + ) + + let context = ApprovalContext( + client: client, + event: event, + risks: [.unknownClient] + ) + + let request = NostrSignerRequest( + method: .signEvent, + content: nil, + callbackUrl: URL(string: "example://callback")!, + returnType: .event, + compressionType: .none, + targetPubkey: nil, + extensionRequestId: nil + ) + + NostrSignerApprovalView( + context: context, + request: request, + onApprove: { _ in }, + onDeny: { _ in } + ) + } +} +#endif diff --git a/damus/Damoose/NIP55/NostrSignerHandler.swift b/damus/Damoose/NIP55/NostrSignerHandler.swift new file mode 100644 index 000000000..9b8d5c29c --- /dev/null +++ b/damus/Damoose/NIP55/NostrSignerHandler.swift @@ -0,0 +1,388 @@ +// +// NostrSignerHandler.swift +// damus +// +// NIP-55 iOS Extension: Coordinates the signing flow for external app requests. +// + +import Foundation +import SwiftUI + +/// Result of processing a signing request. +enum SignerHandlerResult { + /// Request was processed, open this callback URL. + case callback(URL) + + /// Request requires user approval, show the approval UI. + case requiresApproval(ApprovalContext, NostrSignerRequest) + + /// Request was from extension and result stored in bridge storage. + /// No callback URL to open - extension will poll for result. + case extensionComplete + + /// Request failed with no callback (malformed request). + case failed +} + +/// Handles NIP-55 signing requests from external apps. +/// +/// This class coordinates the full signing flow: +/// 1. Receives parsed request from URL handler +/// 2. Evaluates request against SigningPolicyManager +/// 3. Either auto-approves, shows approval UI, or rejects +/// 4. Returns callback URL with result +/// +/// ## Usage +/// ```swift +/// let result = await NostrSignerHandler.shared.handle(request: request) +/// switch result { +/// case .callback(let url): +/// UIApplication.shared.open(url) +/// case .requiresApproval(let context, let request): +/// // Show approval sheet +/// case .failed: +/// // Log error, no callback possible +/// } +/// ``` +@MainActor +final class NostrSignerHandler: ObservableObject { + + // MARK: - Singleton + + /// Shared instance of the signer handler. + static let shared = NostrSignerHandler() + + // MARK: - Published State + + /// The pending request awaiting user approval. + @Published var pendingRequest: NostrSignerRequest? + + /// The approval context for the pending request. + @Published var pendingContext: ApprovalContext? + + // MARK: - Private State + + /// Continuation for async approval flow. + private var approvalContinuation: CheckedContinuation? + + // MARK: - Initialization + + private init() {} + + // MARK: - Request Handling + + /// Handles an incoming signing request. + /// + /// - Parameter request: The parsed signing request. + /// - Returns: The result of processing the request. + func handle(request: NostrSignerRequest) async -> SignerHandlerResult { + // Get keypair from shared storage + guard let keypair = SharedKeychainStorage.getKeypair() else { + guard let url = NostrSignerResponse.notLoggedIn(request: request) else { + return .failed + } + return .callback(url) + } + + // Handle based on method type + switch request.method { + case .getPublicKey: + return handleGetPublicKey(request: request, keypair: keypair) + + case .signEvent: + return await handleSignEvent(request: request, keypair: keypair) + + case .nip04Encrypt, .nip04Decrypt, .nip44Encrypt, .nip44Decrypt, .decryptZapEvent: + // TODO: Implement encryption/decryption methods + guard let url = NostrSignerResponse.unsupportedMethod(request: request) else { + return .failed + } + return .callback(url) + } + } + + // MARK: - Method Handlers + + /// Handles get_public_key requests. + private func handleGetPublicKey( + request: NostrSignerRequest, + keypair: Keypair + ) -> SignerHandlerResult { + // Public key requests don't need approval + guard let url = NostrSignerResponse.publicKeySuccess( + request: request, + pubkey: keypair.pubkey + ) else { + return .failed + } + return .callback(url) + } + + /// Handles sign_event requests. + private func handleSignEvent( + request: NostrSignerRequest, + keypair: Keypair + ) async -> SignerHandlerResult { + // Parse the unsigned event from request content + guard let unsignedEvent = request.parseAsUnsignedEvent() else { + guard let url = NostrSignerResponse.invalidContent(request: request) else { + return .failed + } + return .callback(url) + } + + // Build client identifier + let client = SigningClient( + id: request.clientId, + name: request.callbackUrl.host, + iconURL: nil + ) + + // Evaluate against policy manager + let decision = SigningPolicyManager.shared.evaluate( + event: unsignedEvent, + client: client + ) + + switch decision { + case .approve: + return signAndRespond(request: request, unsignedEvent: unsignedEvent, keypair: keypair) + + case .deny(let reason): + guard let url = NostrSignerResponse.error( + request: request, + message: reason, + rejected: true + ) else { + return .failed + } + return .callback(url) + + case .requireApproval(let context): + return .requiresApproval(context, request) + } + } + + // MARK: - Signing + + /// Signs an event and builds the response. + private func signAndRespond( + request: NostrSignerRequest, + unsignedEvent: UnsignedEvent, + keypair: Keypair + ) -> SignerHandlerResult { + // Need private key to sign + guard let privkey = keypair.privkey else { + return handleError( + request: request, + message: "No private key available (read-only mode)", + rejected: false + ) + } + + // Create and sign the event + let createdAt = unsignedEvent.createdAt ?? UInt32(Date().timeIntervalSince1970) + + guard let signedEvent = NostrEvent( + content: unsignedEvent.content, + keypair: keypair, + kind: unsignedEvent.kind, + tags: unsignedEvent.tags, + createdAt: createdAt + ) else { + return handleError( + request: request, + message: "Failed to sign event", + rejected: false + ) + } + + // Build response + let signature = hex_encode(signedEvent.sig.data) + let eventJson = request.returnType == .event ? event_to_json(ev: signedEvent) : nil + + // For extension requests, store result in bridge storage + if let requestId = request.extensionRequestId { + SignerBridgeStorage.storeResult( + requestId: requestId, + signedEventJson: eventJson, + signature: signature + ) + return .extensionComplete + } + + // For normal requests, build callback URL + guard let url = NostrSignerResponse.signEventSuccess( + request: request, + signature: signature, + signedEventJson: eventJson + ) else { + return .failed + } + + return .callback(url) + } + + /// Handles an error response, routing to extension storage or callback URL. + private func handleError( + request: NostrSignerRequest, + message: String, + rejected: Bool + ) -> SignerHandlerResult { + // For extension requests, store error in bridge storage + if let requestId = request.extensionRequestId { + SignerBridgeStorage.storeResult( + requestId: requestId, + error: message + ) + return .extensionComplete + } + + // For normal requests, build callback URL + guard let url = NostrSignerResponse.error( + request: request, + message: message, + rejected: rejected + ) else { + return .failed + } + return .callback(url) + } + + // MARK: - Approval Flow + + /// Called when user approves the pending request. + /// + /// - Parameter rememberChoice: Whether to remember this choice for future requests. + func approveRequest(rememberChoice: Bool = false) { + guard let request = pendingRequest, + let context = pendingContext else { + return + } + + // If remembering, update permissions + if rememberChoice { + SigningPolicyManager.shared.approveKind( + context.event.kind, + for: context.client + ) + } + + // Clear pending state + clearPendingRequest() + + // Resume continuation + approvalContinuation?.resume(returning: true) + approvalContinuation = nil + } + + /// Called when user denies the pending request. + /// + /// - Parameter blockClient: Whether to block this client entirely. + func denyRequest(blockClient: Bool = false) { + guard let context = pendingContext else { + return + } + + // If blocking, update permissions + if blockClient { + SigningPolicyManager.shared.blockClient(context.client) + } + + // Clear pending state + clearPendingRequest() + + // Resume continuation + approvalContinuation?.resume(returning: false) + approvalContinuation = nil + } + + /// Waits for user to approve or deny the pending request. + /// + /// - Parameters: + /// - context: The approval context. + /// - request: The original request. + /// - Returns: True if approved, false if denied. + func waitForApproval(context: ApprovalContext, request: NostrSignerRequest) async -> Bool { + // Store pending request for UI + self.pendingRequest = request + self.pendingContext = context + + // Wait for user decision + return await withCheckedContinuation { continuation in + self.approvalContinuation = continuation + } + } + + /// Completes the approval flow and returns the callback URL. + /// + /// - Parameters: + /// - approved: Whether the request was approved. + /// - request: The original request. + /// - context: The approval context. + /// - Returns: The callback URL to open, or nil for extension requests (result stored in bridge). + func completeApproval( + approved: Bool, + request: NostrSignerRequest, + context: ApprovalContext + ) -> URL? { + guard approved else { + // Handle rejection for extension requests + if let requestId = request.extensionRequestId { + SignerBridgeStorage.storeResult( + requestId: requestId, + error: "User rejected signing request" + ) + return nil + } + return NostrSignerResponse.rejected(request: request) + } + + // Get keypair and sign + guard let keypair = SharedKeychainStorage.getKeypair() else { + if let requestId = request.extensionRequestId { + SignerBridgeStorage.storeResult( + requestId: requestId, + error: "Not logged in to Damus" + ) + return nil + } + return NostrSignerResponse.notLoggedIn(request: request) + } + + let result = signAndRespond( + request: request, + unsignedEvent: context.event, + keypair: keypair + ) + + switch result { + case .callback(let url): + return url + case .extensionComplete: + // Result already stored in bridge storage + return nil + case .requiresApproval, .failed: + if let requestId = request.extensionRequestId { + SignerBridgeStorage.storeResult( + requestId: requestId, + error: "Signing failed" + ) + return nil + } + return NostrSignerResponse.error( + request: request, + message: "Signing failed", + rejected: false + ) + } + } + + // MARK: - Private Helpers + + /// Clears the pending request state. + private func clearPendingRequest() { + pendingRequest = nil + pendingContext = nil + } +} diff --git a/damus/Damoose/NIP55/NostrSignerRequest.swift b/damus/Damoose/NIP55/NostrSignerRequest.swift new file mode 100644 index 000000000..881780a2e --- /dev/null +++ b/damus/Damoose/NIP55/NostrSignerRequest.swift @@ -0,0 +1,231 @@ +// +// NostrSignerRequest.swift +// damus +// +// NIP-55 iOS Extension: Parses incoming nostrsigner:// URLs from external apps. +// + +import Foundation + +/// The return type for sign_event operations. +enum SignerReturnType: String { + /// Return only the signature. + case signature + /// Return the full signed event JSON. + case event +} + +/// The compression type for response content. +enum SignerCompressionType: String { + /// No compression, URL-encoded JSON. + case none + /// Gzip compressed: "Signer1" + base64(gzip(json)). + case gzip +} + +/// A signing request from an external app via NIP-55 URL scheme. +/// +/// Parses URLs of the format: +/// ``` +/// nostrsigner:?type=sign_event&callbackUrl=primal://... +/// ``` +/// +/// ## Supported Methods +/// - `get_public_key`: Returns the user's public key +/// - `sign_event`: Signs a nostr event +/// - `nip04_encrypt`/`nip04_decrypt`: NIP-04 encryption operations +/// - `nip44_encrypt`/`nip44_decrypt`: NIP-44 encryption operations +/// - `decrypt_zap_event`: Decrypts zap events +struct NostrSignerRequest: Equatable { + + // MARK: - Method Enum + + /// The operation type requested by the external app. + enum Method: String, CaseIterable { + case getPublicKey = "get_public_key" + case signEvent = "sign_event" + case nip04Encrypt = "nip04_encrypt" + case nip04Decrypt = "nip04_decrypt" + case nip44Encrypt = "nip44_encrypt" + case nip44Decrypt = "nip44_decrypt" + case decryptZapEvent = "decrypt_zap_event" + + /// Human-readable description for logging/debugging. + var description: String { + switch self { + case .getPublicKey: return "Get Public Key" + case .signEvent: return "Sign Event" + case .nip04Encrypt: return "NIP-04 Encrypt" + case .nip04Decrypt: return "NIP-04 Decrypt" + case .nip44Encrypt: return "NIP-44 Encrypt" + case .nip44Decrypt: return "NIP-44 Decrypt" + case .decryptZapEvent: return "Decrypt Zap" + } + } + } + + // MARK: - Properties + + /// The operation to perform. + let method: Method + + /// The content payload (URL-decoded). + /// For sign_event: JSON-encoded unsigned event. + /// For encrypt: plaintext to encrypt. + /// For decrypt: ciphertext to decrypt. + let content: Data? + + /// The URL to call back with the result. + let callbackUrl: URL + + /// How to return sign_event results. + let returnType: SignerReturnType + + /// Response compression type. + let compressionType: SignerCompressionType + + /// Target pubkey for encryption/decryption operations. + let targetPubkey: Pubkey? + + /// Extension request ID for Safari extension bridge. + /// When present, result should be stored in SignerBridgeStorage instead of opening callback. + let extensionRequestId: String? + + /// Original requesting app identifier (derived from callback URL). + var clientId: String { + callbackUrl.host ?? callbackUrl.scheme ?? "unknown" + } + + /// Whether this request came from the Safari extension. + var isExtensionRequest: Bool { + extensionRequestId != nil + } + + // MARK: - Parsing + + /// Parses a nostrsigner:// URL into a request. + /// + /// - Parameter url: The incoming URL to parse. + /// - Returns: A parsed request, or nil if the URL is invalid. + static func parse(url: URL) -> NostrSignerRequest? { + guard url.scheme == "nostrsigner" else { + return nil + } + + guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + return nil + } + + // Extract query parameters + let queryItems = components.queryItems ?? [] + + // Method is required + guard let typeString = queryItems.first(where: { $0.name == "type" })?.value, + let method = Method(rawValue: typeString) else { + return nil + } + + // Callback URL is required + guard let callbackString = queryItems.first(where: { $0.name == "callbackUrl" })?.value, + let callbackUrl = URL(string: callbackString) else { + return nil + } + + // Validate callback URL scheme (security: no file://, javascript://, etc.) + guard let scheme = callbackUrl.scheme, + isAllowedCallbackScheme(scheme) else { + return nil + } + + // Optional parameters + let returnTypeString = queryItems.first(where: { $0.name == "returnType" })?.value ?? "signature" + let returnType = SignerReturnType(rawValue: returnTypeString) ?? .signature + + let compressionString = queryItems.first(where: { $0.name == "compressionType" })?.value ?? "none" + let compressionType = SignerCompressionType(rawValue: compressionString) ?? .none + + // Target pubkey for encrypt/decrypt operations + var targetPubkey: Pubkey? = nil + if let pubkeyHex = queryItems.first(where: { $0.name == "pubkey" })?.value { + targetPubkey = hex_decode_pubkey(pubkeyHex) + } + + // Extension request ID for Safari extension bridge + let extensionRequestId = queryItems.first(where: { $0.name == "extensionRequestId" })?.value + + // Content is in the URL path (after the scheme:) + // URL format: nostrsigner:?params... + let content = parseContent(from: components) + + return NostrSignerRequest( + method: method, + content: content, + callbackUrl: callbackUrl, + returnType: returnType, + compressionType: compressionType, + targetPubkey: targetPubkey, + extensionRequestId: extensionRequestId + ) + } + + // MARK: - Private Helpers + + /// Extracts and URL-decodes the content from the URL path. + private static func parseContent(from components: URLComponents) -> Data? { + // The content is after "nostrsigner:" + // URLComponents.path will be empty if content is in host position + var rawContent = components.path + + // If path is empty, try the host (some URL parsers put it there) + if rawContent.isEmpty { + rawContent = components.host ?? "" + } + + // Remove any leading slashes + rawContent = rawContent.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + + guard !rawContent.isEmpty else { + return nil + } + + // URL-decode the content (NIP-55 compatible) + guard let urlDecoded = rawContent.removingPercentEncoding, + let data = urlDecoded.data(using: .utf8) else { + return nil + } + return data + } + + /// Checks if a callback URL scheme is allowed. + /// + /// Prevents security issues with dangerous schemes. + private static func isAllowedCallbackScheme(_ scheme: String) -> Bool { + // Block dangerous schemes and nostrsigner (re-entrancy prevention) + let blocked = ["file", "javascript", "data", "about", "blob", "nostrsigner"] + return !blocked.contains(scheme.lowercased()) + } +} + +// MARK: - Content Parsing Helpers + +extension NostrSignerRequest { + + /// Attempts to parse the content as an unsigned event JSON. + /// + /// - Returns: The parsed unsigned event, or nil if parsing fails. + func parseAsUnsignedEvent() -> UnsignedEvent? { + guard let content = content else { + return nil + } + + return try? JSONDecoder().decode(UnsignedEvent.self, from: content) + } + + /// Returns the content as a UTF-8 string (for encrypt/decrypt operations). + var contentString: String? { + guard let content = content else { + return nil + } + return String(data: content, encoding: .utf8) + } +} diff --git a/damus/Damoose/NIP55/NostrSignerResponse.swift b/damus/Damoose/NIP55/NostrSignerResponse.swift new file mode 100644 index 000000000..a6abedc0b --- /dev/null +++ b/damus/Damoose/NIP55/NostrSignerResponse.swift @@ -0,0 +1,169 @@ +// +// NostrSignerResponse.swift +// damus +// +// NIP-55 iOS Extension: Builds callback URLs to return results to external apps. +// + +import Foundation + +/// Builds callback responses for NIP-55 signing requests. +/// +/// After processing a signing request, Damus opens the callback URL with +/// the result appended as query parameters. Existing query parameters are preserved. +/// +/// ## Success Response +/// ``` +/// primal://nostrsigner?result=&event= +/// ``` +/// +/// ## Error Response +/// ``` +/// primal://nostrsigner?error=&rejected=true +/// ``` +enum NostrSignerResponse { + + // MARK: - Success Responses + + /// Builds a success callback URL for a sign_event request. + /// + /// - Parameters: + /// - request: The original signing request. + /// - signature: The signature (hex-encoded). + /// - signedEventJson: The full signed event as JSON (optional). + /// - Returns: The callback URL with result parameters. + static func signEventSuccess( + request: NostrSignerRequest, + signature: String, + signedEventJson: String? + ) -> URL? { + var components = URLComponents(url: request.callbackUrl, resolvingAgainstBaseURL: false) + + // Preserve existing query items + var queryItems = components?.queryItems ?? [] + + // Include the hex signature as result (NIP-55 compatible) + queryItems.append(URLQueryItem(name: "result", value: signature)) + + // Include full event if requested and available + if request.returnType == .event, let eventJson = signedEventJson { + // URL-encode the JSON for NIP-55 compatibility + // compressionType=gzip would use "Signer1" + base64(gzip(json)) but we default to none + queryItems.append(URLQueryItem(name: "event", value: eventJson)) + } + + components?.queryItems = queryItems + return components?.url + } + + /// Builds a success callback URL for a get_public_key request. + /// + /// - Parameters: + /// - request: The original request. + /// - pubkey: The user's public key. + /// - Returns: The callback URL with the public key. + static func publicKeySuccess( + request: NostrSignerRequest, + pubkey: Pubkey + ) -> URL? { + var components = URLComponents(url: request.callbackUrl, resolvingAgainstBaseURL: false) + + // Preserve existing query items + var queryItems = components?.queryItems ?? [] + + // Return pubkey as hex (NIP-55 compatible) + queryItems.append(URLQueryItem(name: "result", value: pubkey.hex())) + + components?.queryItems = queryItems + return components?.url + } + + /// Builds a success callback URL for encrypt/decrypt operations. + /// + /// - Parameters: + /// - request: The original request. + /// - result: The encrypted/decrypted content (ciphertext or plaintext). + /// - Returns: The callback URL with the result. + /// + /// - Note: The result is passed directly as a string. URLComponents handles + /// URL-encoding automatically, matching NIP-55 behavior. + static func cryptoSuccess( + request: NostrSignerRequest, + result: String + ) -> URL? { + var components = URLComponents(url: request.callbackUrl, resolvingAgainstBaseURL: false) + + var queryItems = components?.queryItems ?? [] + + // Pass result directly - URLComponents handles URL-encoding + queryItems.append(URLQueryItem(name: "result", value: result)) + + components?.queryItems = queryItems + return components?.url + } + + // MARK: - Error Responses + + /// Builds an error callback URL. + /// + /// - Parameters: + /// - request: The original request. + /// - message: The error message. + /// - rejected: Whether this was a user rejection (vs. a processing error). + /// - Returns: The callback URL with error parameters. + static func error( + request: NostrSignerRequest, + message: String, + rejected: Bool = false + ) -> URL? { + var components = URLComponents(url: request.callbackUrl, resolvingAgainstBaseURL: false) + + var queryItems = components?.queryItems ?? [] + + // URL-encode the error message + queryItems.append(URLQueryItem(name: "error", value: message)) + + if rejected { + queryItems.append(URLQueryItem(name: "rejected", value: "true")) + } + + components?.queryItems = queryItems + return components?.url + } + + /// Builds a rejection callback URL (user denied the request). + /// + /// - Parameter request: The original request. + /// - Returns: The callback URL indicating rejection. + static func rejected(request: NostrSignerRequest) -> URL? { + return error(request: request, message: "User rejected the request", rejected: true) + } + + /// Builds an error callback for when the user is not logged in. + /// + /// - Parameter request: The original request. + /// - Returns: The callback URL indicating no keypair available. + static func notLoggedIn(request: NostrSignerRequest) -> URL? { + return error(request: request, message: "No keypair available", rejected: false) + } + + /// Builds an error callback for unsupported operations. + /// + /// - Parameter request: The original request. + /// - Returns: The callback URL indicating unsupported method. + static func unsupportedMethod(request: NostrSignerRequest) -> URL? { + return error( + request: request, + message: "Method '\(request.method.rawValue)' is not supported", + rejected: false + ) + } + + /// Builds an error callback for invalid request content. + /// + /// - Parameter request: The original request. + /// - Returns: The callback URL indicating invalid content. + static func invalidContent(request: NostrSignerRequest) -> URL? { + return error(request: request, message: "Invalid request content", rejected: false) + } +} diff --git a/damus/Damoose/Policies/ContactListPolicy.swift b/damus/Damoose/Policies/ContactListPolicy.swift new file mode 100644 index 000000000..7fedf4e75 --- /dev/null +++ b/damus/Damoose/Policies/ContactListPolicy.swift @@ -0,0 +1,131 @@ +// +// ContactListPolicy.swift +// damus +// +// Policy for protecting users from contact list (kind 3) issues. +// +// This is a CRITICAL policy that protects against: +// - Buggy clients that accidentally nuke contact lists +// - Malicious apps that try to remove all follows +// - Accidental mass unfollows +// +// See: https://github.com/nostrability/nostrability/issues/33 +// + +import Foundation + +/// Policy for Kind 3 (contact list) events. +/// +/// Contact list events are high-risk because a buggy or malicious client can +/// easily wipe a user's entire social graph. This policy adds safeguards: +/// +/// - Empty contact lists always require approval with a strong warning +/// - Significant reductions in contact count require approval +/// - New clients always require approval for contact list changes +/// +/// ## Reference +/// This addresses the issues documented in: +/// - https://github.com/damus-io/damus/issues/2238 +/// - https://github.com/nostrability/nostrability/issues/33 +struct ContactListPolicy: KindPolicy { + + // MARK: - KindPolicy Conformance + + let kind: UInt32 = 3 + + func evaluate(event: UnsignedEvent, client: SigningClient) -> PolicyDecision { + let newContactCount = countContacts(in: event) + + // Empty contact list is ALWAYS suspicious - require approval with warning + guard newContactCount > 0 else { + return .requireApproval(context: ApprovalContext( + client: client, + event: event, + risks: [.contactListEmpty], + summary: "This will remove ALL your follows (\(newContactCount) contacts)" + )) + } + + // For now, always require approval for contact list changes from external clients + // In the future, we can compare against the current contact list to detect + // suspicious reductions + // + // TODO: Compare against current contact list from nostrdb + // - Load current kind 3 event for user's pubkey + // - Count existing contacts + // - If new count < existing * 0.5, flag as suspicious truncation + + return .requireApproval(context: ApprovalContext( + client: client, + event: event, + risks: [], + summary: "Update contact list to \(newContactCount) follows" + )) + } + + // MARK: - Private Helpers + + /// Counts the number of contacts (p-tags) in an event. + /// + /// - Parameter event: The unsigned event to analyze. + /// - Returns: The number of p-tags (pubkey references). + private func countContacts(in event: UnsignedEvent) -> Int { + return event.tags.filter { tag in + guard let tagType = tag.first else { return false } + return tagType == "p" + }.count + } +} + +// MARK: - Future Enhancements + +/* + Future improvements for contact list protection: + + 1. Compare against current contact list: + - Load user's current kind 3 event from nostrdb + - Calculate the diff (added/removed contacts) + - Flag if more than N contacts removed + - Show detailed diff in approval UI + + 2. Rate limiting: + - Track frequency of kind 3 updates per client + - Flag unusually high frequency updates + + 3. Contact list backup: + - Before signing any kind 3, store a backup + - Provide recovery UI if user's list gets nuked + + 4. Trusted client exceptions: + - Allow power users to whitelist specific clients + - Internal Damus operations bypass these checks + + Example future implementation: + + func evaluate(event: UnsignedEvent, client: SigningClient) -> PolicyDecision { + let newContacts = Set(extractPubkeys(from: event)) + + // Load current contacts from nostrdb + guard let currentEvent = loadCurrentContactList() else { + // No existing contact list - this is likely a new user + return .approve + } + + let currentContacts = Set(extractPubkeys(from: currentEvent)) + let removed = currentContacts.subtracting(newContacts) + let added = newContacts.subtracting(currentContacts) + + // Check for suspicious truncation + let removalRatio = Double(removed.count) / Double(currentContacts.count) + if removalRatio > 0.5 && removed.count > 10 { + return .requireApproval(context: ApprovalContext( + client: client, + event: event, + risks: [.contactListTruncation(removed: removed.count, remaining: newContacts.count)], + summary: "This will remove \(removed.count) follows" + )) + } + + return .approve + } + */ diff --git a/damus/Damoose/SharedKeychainStorage.swift b/damus/Damoose/SharedKeychainStorage.swift new file mode 100644 index 000000000..078816079 --- /dev/null +++ b/damus/Damoose/SharedKeychainStorage.swift @@ -0,0 +1,115 @@ +// +// SharedKeychainStorage.swift +// damus +// +// Created for Damoose keysigner cross-extension key access. +// + +import Foundation +import Security + +/// Keychain storage explicitly configured for cross-extension access. +/// +/// Used by Damoose signing interfaces (Safari extension, URL scheme handler, NIP-46) +/// to read the user's keypair for signing operations. +/// +/// This provides a clean, read-only API that accesses the same underlying keychain +/// storage as `Keys.privkey`, ensuring consistency with the main app's key management. +/// +/// ## Usage +/// ```swift +/// guard let keypair = SharedKeychainStorage.getKeypair() else { +/// return .error("Not logged in") +/// } +/// let signed = sign(event, with: keypair) +/// ``` +enum SharedKeychainStorage { + + // MARK: - Constants + + /// Service identifier matching the main app's keychain storage. + private static let service = "damus" + + /// Account name for the private key, matching Keys.privkey storage. + private static let privkeyAccount = "privkey" + + /// UserDefaults key for the public key, matching save_pubkey/get_saved_pubkey. + private static let pubkeyDefaultsKey = "pubkey" + + // MARK: - Public API + + /// Retrieves the stored private key, if available. + /// + /// - Returns: The user's private key, or nil if not logged in or no privkey stored. + static func getPrivateKey() -> Privkey? { + guard let hex = readKeychainString(account: privkeyAccount) else { + return nil + } + return hex_decode_privkey(hex) + } + + /// Retrieves the stored public key, if available. + /// + /// - Returns: The user's public key, or nil if not logged in. + static func getPublicKey() -> Pubkey? { + guard let hex = DamusUserDefaults.standard.string(forKey: pubkeyDefaultsKey) else { + return nil + } + guard let bytes = hex_decode(hex) else { + return nil + } + return Pubkey(Data(bytes)) + } + + /// Retrieves the full keypair for signing operations. + /// + /// The keypair may have a nil privkey if the user logged in with only a public key + /// (read-only mode). Callers should check `keypair.privkey != nil` before signing. + /// + /// - Returns: The user's keypair, or nil if not logged in. + static func getKeypair() -> Keypair? { + guard let pubkey = getPublicKey() else { + return nil + } + let privkey = getPrivateKey() + return Keypair(pubkey: pubkey, privkey: privkey) + } + + /// Checks if the user has a private key available for signing. + /// + /// - Returns: true if a private key is stored and can be used for signing. + static func canSign() -> Bool { + return getPrivateKey() != nil + } + + // MARK: - Private Helpers + + /// Reads a string value from the keychain. + /// + /// - Parameter account: The keychain account identifier. + /// - Returns: The stored string value, or nil if not found. + private static func readKeychainString(account: String) -> String? { + let query: [CFString: Any] = [ + kSecClass: kSecClassGenericPassword, + kSecAttrService: service, + kSecAttrAccount: account, + kSecReturnData: true, + kSecMatchLimit: kSecMatchLimitOne + ] + + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + + guard status == errSecSuccess else { + return nil + } + guard let data = result as? Data else { + return nil + } + guard let string = String(data: data, encoding: .utf8) else { + return nil + } + + return string.trimmingCharacters(in: .whitespaces) + } +} diff --git a/damus/Damoose/SigningPolicy.swift b/damus/Damoose/SigningPolicy.swift new file mode 100644 index 000000000..5d45dde2b --- /dev/null +++ b/damus/Damoose/SigningPolicy.swift @@ -0,0 +1,230 @@ +// +// SigningPolicy.swift +// damus +// +// Core types for the Damoose signing policy framework. +// + +import Foundation + +// MARK: - Unsigned Event + +/// An event that has not yet been signed. +/// +/// Contains all the fields needed to create a signed NostrEvent. +/// The pubkey, id, and sig are added during the signing process. +struct UnsignedEvent: Codable, Equatable { + /// The event kind (e.g., 1 for text note, 3 for contacts). + let kind: UInt32 + + /// The event content. + let content: String + + /// The event tags (e.g., p-tags for mentions, e-tags for references). + let tags: [[String]] + + /// The creation timestamp. If nil, will be set to current time when signing. + let createdAt: UInt32? + + /// Creates an unsigned event from its components. + init(kind: UInt32, content: String, tags: [[String]], createdAt: UInt32? = nil) { + self.kind = kind + self.content = content + self.tags = tags + self.createdAt = createdAt + } + + /// Returns a human-readable summary of the event for approval UI. + var summary: String { + switch kind { + case 0: + return "Update profile metadata" + case 1: + let preview = content.prefix(100) + return "Post: \"\(preview)\(content.count > 100 ? "..." : "")\"" + case 3: + let contactCount = tags.filter { $0.first == "p" }.count + return "Update contact list (\(contactCount) follows)" + case 4: + return "Send encrypted direct message" + case 5: + return "Delete event(s)" + case 6: + return "Repost/boost" + case 7: + return "React to post" + default: + return "Event kind \(kind)" + } + } +} + +// MARK: - Signing Client + +/// Identifies the source of a signing request. +/// +/// Each client (app, extension, website) has a unique identifier and optional +/// metadata for display in approval prompts. +struct SigningClient: Codable, Hashable, Equatable { + /// Unique identifier for this client. + /// + /// For Safari extension: the website origin (e.g., "https://highlighter.com") + /// For URL scheme: the app's bundle ID (e.g., "com.primal.ios") + /// For NIP-46: the client's pubkey + let id: String + + /// Human-readable name for display in approval UI. + let name: String? + + /// URL to the client's icon for display in approval UI. + let iconURL: URL? + + /// When this client first connected. + let firstSeen: Date + + /// Creates a signing client identifier. + init(id: String, name: String? = nil, iconURL: URL? = nil, firstSeen: Date = Date()) { + self.id = id + self.name = name + self.iconURL = iconURL + self.firstSeen = firstSeen + } + + /// Creates a client for internal Damus app usage. + static let damusInternal = SigningClient( + id: "io.damus.internal", + name: "Damus", + iconURL: nil, + firstSeen: Date.distantPast + ) +} + +// MARK: - Policy Decision + +/// The result of evaluating a signing request against policies. +enum PolicyDecision: Equatable { + /// The request is approved and can proceed to signing. + case approve + + /// The request is denied with a reason. + case deny(reason: String) + + /// The request requires user approval before proceeding. + case requireApproval(context: ApprovalContext) + + static func == (lhs: PolicyDecision, rhs: PolicyDecision) -> Bool { + switch (lhs, rhs) { + case (.approve, .approve): + return true + case (.deny(let r1), .deny(let r2)): + return r1 == r2 + case (.requireApproval(let c1), .requireApproval(let c2)): + return c1.summary == c2.summary + default: + return false + } + } +} + +// MARK: - Approval Context + +/// Context provided to the user approval UI. +/// +/// Contains all information needed to display a meaningful approval prompt. +struct ApprovalContext { + /// The client requesting the signature. + let client: SigningClient + + /// The event to be signed. + let event: UnsignedEvent + + /// Any risks detected in this signing request. + let risks: [SigningRisk] + + /// Human-readable summary of what this signing will do. + let summary: String + + /// Creates an approval context. + init(client: SigningClient, event: UnsignedEvent, risks: [SigningRisk] = [], summary: String? = nil) { + self.client = client + self.event = event + self.risks = risks + self.summary = summary ?? event.summary + } +} + +// MARK: - Signing Risk + +/// Potential risks detected in a signing request. +/// +/// These are surfaced to the user in the approval UI to help them +/// make informed decisions about potentially dangerous operations. +enum SigningRisk: Equatable { + /// Contact list would be reduced significantly. + case contactListTruncation(removed: Int, remaining: Int) + + /// Contact list would become empty. + case contactListEmpty + + /// This is a deletion event (kind 5). + case deletionEvent + + /// Content is encrypted (user can't verify what they're signing). + case encryptedContent + + /// High frequency of requests from this client. + case highFrequency(count: Int, window: TimeInterval) + + /// Unknown or new client. + case unknownClient + + /// Human-readable description of this risk. + var description: String { + switch self { + case .contactListTruncation(let removed, let remaining): + return "This will remove \(removed) follows (keeping \(remaining))" + case .contactListEmpty: + return "This will remove ALL your follows" + case .deletionEvent: + return "This will permanently delete content" + case .encryptedContent: + return "Content is encrypted - cannot verify what you're signing" + case .highFrequency(let count, let window): + let minutes = Int(window / 60) + return "This client has made \(count) requests in \(minutes) minutes" + case .unknownClient: + return "This is a new/unknown client" + } + } + + /// Severity level for UI display (higher = more dangerous). + var severity: Int { + switch self { + case .contactListEmpty: return 5 + case .contactListTruncation: return 4 + case .deletionEvent: return 3 + case .highFrequency: return 2 + case .encryptedContent: return 2 + case .unknownClient: return 1 + } + } +} + +// MARK: - Kind Policy Protocol + +/// A policy that evaluates signing requests for a specific event kind. +/// +/// Implement this protocol to add custom validation logic for specific +/// event types (e.g., contact list protection, deletion safeguards). +protocol KindPolicy { + /// The event kind this policy applies to. + var kind: UInt32 { get } + + /// Evaluates a signing request. + /// + /// - Parameters: + /// - event: The unsigned event to evaluate. + /// - client: The client requesting the signature. + /// - Returns: A policy decision (approve, deny, or require approval). + func evaluate(event: UnsignedEvent, client: SigningClient) -> PolicyDecision +} diff --git a/damus/Damoose/SigningPolicyManager.swift b/damus/Damoose/SigningPolicyManager.swift new file mode 100644 index 000000000..d156a790f --- /dev/null +++ b/damus/Damoose/SigningPolicyManager.swift @@ -0,0 +1,276 @@ +// +// SigningPolicyManager.swift +// damus +// +// Central manager for evaluating signing requests against policies. +// + +import Foundation + +/// Manages signing policy evaluation for Damoose. +/// +/// This singleton evaluates signing requests from external clients (Safari extension, +/// URL scheme, NIP-46) and determines whether they should be approved, denied, or +/// require user approval. +/// +/// ## Usage +/// ```swift +/// let decision = SigningPolicyManager.shared.evaluate( +/// event: unsignedEvent, +/// client: requestingClient +/// ) +/// +/// switch decision { +/// case .approve: +/// return sign(event) +/// case .deny(let reason): +/// return .error(reason) +/// case .requireApproval(let context): +/// let approved = await showApprovalUI(context) +/// // ... +/// } +/// ``` +@MainActor +final class SigningPolicyManager: ObservableObject { + + // MARK: - Singleton + + /// Shared instance of the policy manager. + static let shared = SigningPolicyManager() + + // MARK: - Published State + + /// All client permissions, keyed by client ID. + @Published private(set) var clientPermissions: [String: ClientPermissions] + + // MARK: - Private State + + /// Kind-specific policy implementations. + private var kindPolicies: [UInt32: KindPolicy] = [:] + + /// Default policy for kinds without specific policies. + private var defaultPolicy: KindPolicy? + + // MARK: - Initialization + + private init() { + self.clientPermissions = ClientPermissionsStore.loadAll() + registerDefaultPolicies() + } + + // MARK: - Policy Registration + + /// Registers a policy for a specific event kind. + /// + /// - Parameter policy: The policy to register. + func registerPolicy(_ policy: KindPolicy) { + kindPolicies[policy.kind] = policy + } + + /// Sets the default policy for kinds without specific policies. + /// + /// - Parameter policy: The default policy to use. + func setDefaultPolicy(_ policy: KindPolicy) { + defaultPolicy = policy + } + + /// Registers the built-in default policies. + private func registerDefaultPolicies() { + // Contact list protection is critical + registerPolicy(ContactListPolicy()) + } + + // MARK: - Policy Evaluation + + /// Evaluates a signing request against all applicable policies. + /// + /// This is the main entry point for the policy system. It checks: + /// 1. Whether the client is blocked + /// 2. Whether the event kind is approved for this client + /// 3. Kind-specific policy rules + /// + /// - Parameters: + /// - event: The unsigned event to evaluate. + /// - client: The client requesting the signature. + /// - Returns: A policy decision (approve, deny, or require approval). + func evaluate(event: UnsignedEvent, client: SigningClient) -> PolicyDecision { + // Internal Damus requests are always approved + guard client.id != SigningClient.damusInternal.id else { + return .approve + } + + // Get or create permissions for this client + let permissions = getOrCreatePermissions(for: client) + + // Check if client is blocked + guard !permissions.isBlocked else { + return .deny(reason: "This app has been blocked") + } + + // Check if kind is explicitly blocked + guard !permissions.blockedKinds.contains(event.kind) else { + return .deny(reason: "This event type has been blocked for this app") + } + + // Run kind-specific policy if available + if let kindPolicy = kindPolicies[event.kind] { + let policyDecision = kindPolicy.evaluate(event: event, client: client) + + // If kind policy denies, respect that + if case .deny = policyDecision { + return policyDecision + } + + // If kind policy requires approval, respect that + if case .requireApproval = policyDecision { + return policyDecision + } + } + + // Check if kind is approved for this client + if permissions.isKindApproved(event.kind) { + recordRequest(for: client) + return .approve + } + + // Unknown kind/client combination - require approval + return .requireApproval(context: buildApprovalContext( + event: event, + client: client, + risks: detectRisks(event: event, client: client, permissions: permissions) + )) + } + + // MARK: - Permission Management + + /// Gets existing permissions or creates default permissions for a client. + /// + /// - Parameter client: The client to get permissions for. + /// - Returns: The client's permissions. + func getOrCreatePermissions(for client: SigningClient) -> ClientPermissions { + if let existing = clientPermissions[client.id] { + return existing + } + + let newPermissions = ClientPermissions(clientId: client.id) + clientPermissions[client.id] = newPermissions + ClientPermissionsStore.save(newPermissions) + return newPermissions + } + + /// Updates permissions for a client. + /// + /// - Parameter permissions: The updated permissions. + func updatePermissions(_ permissions: ClientPermissions) { + var updated = permissions + updated.lastModified = Date() + clientPermissions[permissions.clientId] = updated + ClientPermissionsStore.save(updated) + } + + /// Approves a specific event kind for a client. + /// + /// - Parameters: + /// - kind: The event kind to approve. + /// - client: The client to approve for. + func approveKind(_ kind: UInt32, for client: SigningClient) { + var permissions = getOrCreatePermissions(for: client) + permissions.approvedKinds.insert(kind) + permissions.blockedKinds.remove(kind) + updatePermissions(permissions) + } + + /// Blocks a specific event kind for a client. + /// + /// - Parameters: + /// - kind: The event kind to block. + /// - client: The client to block for. + func blockKind(_ kind: UInt32, for client: SigningClient) { + var permissions = getOrCreatePermissions(for: client) + permissions.blockedKinds.insert(kind) + permissions.approvedKinds.remove(kind) + updatePermissions(permissions) + } + + /// Blocks a client entirely. + /// + /// - Parameter client: The client to block. + func blockClient(_ client: SigningClient) { + var permissions = getOrCreatePermissions(for: client) + permissions.isBlocked = true + updatePermissions(permissions) + } + + /// Unblocks a client. + /// + /// - Parameter client: The client to unblock. + func unblockClient(_ client: SigningClient) { + var permissions = getOrCreatePermissions(for: client) + permissions.isBlocked = false + updatePermissions(permissions) + } + + /// Sets the trust level for a client. + /// + /// - Parameters: + /// - level: The trust level to set. + /// - client: The client to update. + func setTrustLevel(_ level: TrustLevel, for client: SigningClient) { + var permissions = getOrCreatePermissions(for: client) + permissions.trustLevel = level + updatePermissions(permissions) + } + + // MARK: - Private Helpers + + /// Records that a client made a signing request. + private func recordRequest(for client: SigningClient) { + guard var permissions = clientPermissions[client.id] else { + return + } + permissions.recordRequest() + clientPermissions[client.id] = permissions + ClientPermissionsStore.save(permissions) + } + + /// Builds an approval context for the UI. + private func buildApprovalContext( + event: UnsignedEvent, + client: SigningClient, + risks: [SigningRisk] + ) -> ApprovalContext { + ApprovalContext( + client: client, + event: event, + risks: risks.sorted { $0.severity > $1.severity } + ) + } + + /// Detects risks in a signing request. + private func detectRisks( + event: UnsignedEvent, + client: SigningClient, + permissions: ClientPermissions + ) -> [SigningRisk] { + var risks: [SigningRisk] = [] + + // Check for unknown client + if permissions.requestCount == 0 { + risks.append(.unknownClient) + } + + // Check for deletion event + if event.kind == 5 { + risks.append(.deletionEvent) + } + + // Check for encrypted content (kind 4 DMs) + if event.kind == 4 { + risks.append(.encryptedContent) + } + + // Note: Contact list risks are detected by ContactListPolicy + + return risks + } +} diff --git a/damus/Features/Settings/Views/ConfigView.swift b/damus/Features/Settings/Views/ConfigView.swift index d3f5b5e8e..0b3e3b39f 100644 --- a/damus/Features/Settings/Views/ConfigView.swift +++ b/damus/Features/Settings/Views/ConfigView.swift @@ -25,6 +25,7 @@ struct ConfigView: View { // String constants private let DELETE_KEYWORD = "DELETE" private let keysTitle = NSLocalizedString("Keys", comment: "Settings section for managing keys") + private let safariExtensionTitle = NSLocalizedString("Safari Extension", comment: "Settings section for the NIP-07 Safari extension") private let appearanceTitle = NSLocalizedString("Appearance and filters", comment: "Section header for text, appearance, and content filter settings") private let searchUniverseTitle = NSLocalizedString("Search / Universe", comment: "Section header for search/universe settings") private let notificationsTitle = NSLocalizedString("Notifications", comment: "Section header for Damus notifications") @@ -61,6 +62,12 @@ struct ConfigView: View { IconLabel(keysTitle,img_name:"Key",color:.purple) } } + // Safari Extension (Damoose NIP-07) + if showSettingsButton(title: safariExtensionTitle){ + NavigationLink(value:Route.SafariExtensionSettings){ + IconLabel(safariExtensionTitle,img_name:"globe",color:.blue) + } + } // Appearance and filters if showSettingsButton(title: appearanceTitle){ NavigationLink(value:Route.AppearanceSettings(settings: settings)){ diff --git a/damus/Features/Settings/Views/SafariExtensionSettingsView.swift b/damus/Features/Settings/Views/SafariExtensionSettingsView.swift new file mode 100644 index 000000000..2376b8115 --- /dev/null +++ b/damus/Features/Settings/Views/SafariExtensionSettingsView.swift @@ -0,0 +1,79 @@ +// +// SafariExtensionSettingsView.swift +// damus +// +// Created by alltheseas on 2026-01-05. +// + +import SwiftUI + +/// Settings view for the Damoose Safari extension. +/// +/// Guides users through enabling the NIP-07 browser extension +/// in Safari settings. +struct SafariExtensionSettingsView: View { + var body: some View { + Form { + Section(header: Text("About")) { + Text("Damoose is Damus's NIP-07 browser extension for Safari. It allows nostr websites to request signatures from your Damus wallet.") + .font(.body) + .foregroundColor(.secondary) + } + + Section(header: Text("Enable Extension")) { + VStack(alignment: .leading, spacing: 12) { + instructionRow(number: 1, text: "Open the Settings app") + instructionRow(number: 2, text: "Tap Safari") + instructionRow(number: 3, text: "Tap Extensions") + instructionRow(number: 4, text: "Find Damoose and enable it") + instructionRow(number: 5, text: "Set permission to \"Allow\" for all websites") + } + .padding(.vertical, 8) + } + + Section(header: Text("Open Safari Settings")) { + Button(action: openSafariSettings) { + HStack { + Image(systemName: "gear") + Text("Open Settings") + } + } + } + + Section(header: Text("Permissions")) { + VStack(alignment: .leading, spacing: 8) { + Text("When you approve a signing request with \"Remember this permission\" checked, Damoose will automatically approve future requests of the same type from that website.") + .font(.caption) + .foregroundColor(.secondary) + } + .padding(.vertical, 4) + } + } + .navigationTitle("Safari Extension") + .navigationBarTitleDisplayMode(.large) + } + + private func instructionRow(number: Int, text: String) -> some View { + HStack(alignment: .top, spacing: 12) { + Text("\(number).") + .font(.body.weight(.semibold)) + .foregroundColor(.accentColor) + .frame(width: 24, alignment: .trailing) + Text(text) + .font(.body) + } + } + + private func openSafariSettings() { + guard let url = URL(string: UIApplication.openSettingsURLString) else { return } + UIApplication.shared.open(url) + } +} + +struct SafariExtensionSettingsView_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + SafariExtensionSettingsView() + } + } +} diff --git a/damus/Info.plist b/damus/Info.plist index d7032a70f..b9905dc34 100644 --- a/damus/Info.plist +++ b/damus/Info.plist @@ -50,6 +50,16 @@ nostr+walletconnect + + CFBundleTypeRole + Editor + CFBundleURLName + io.damus.nostrsigner + CFBundleURLSchemes + + nostrsigner + + LSApplicationQueriesSchemes diff --git a/damus/Shared/Utilities/Router.swift b/damus/Shared/Utilities/Router.swift index 03685af7e..69111d143 100644 --- a/damus/Shared/Utilities/Router.swift +++ b/damus/Shared/Utilities/Router.swift @@ -32,6 +32,7 @@ enum Route: Hashable { case SearchSettings(settings: UserSettingsStore) case DeveloperSettings(settings: UserSettingsStore) case FirstAidSettings(settings: UserSettingsStore) + case SafariExtensionSettings case Thread(thread: ThreadModel) case LoadableNostrEvent(note_reference: LoadableNostrEventViewModel.NoteReference) case Reposts(reposts: EventsModel) @@ -100,6 +101,8 @@ enum Route: Hashable { DeveloperSettingsView(settings: settings, damus_state: damusState) case .FirstAidSettings(settings: let settings): FirstAidSettingsView(damus_state: damusState, settings: settings) + case .SafariExtensionSettings: + SafariExtensionSettingsView() case .Thread(let thread): ChatroomThreadView(damus: damusState, thread: thread) //ThreadView(state: damusState, thread: thread) @@ -204,6 +207,8 @@ enum Route: Hashable { hasher.combine("developerSettings") case .FirstAidSettings: hasher.combine("firstAidSettings") + case .SafariExtensionSettings: + hasher.combine("safariExtensionSettings") case .Thread(let threadModel): hasher.combine("thread") hasher.combine(threadModel.original_event.id) diff --git a/damus/Shared/Utilities/URLHandler.swift b/damus/Shared/Utilities/URLHandler.swift index 90d8ca62c..088d355f6 100644 --- a/damus/Shared/Utilities/URLHandler.swift +++ b/damus/Shared/Utilities/URLHandler.swift @@ -52,6 +52,10 @@ struct DamusURLHandler { } return .external_url(url) } + #if !EXTENSION + case .nostrSigner(let request): + return .sheet(.nostrSignerApproval(request)) + #endif case nil: break } @@ -69,14 +73,24 @@ struct DamusURLHandler { /// - Parameter url: The URL to be parsed /// - Returns: Structured information about the contents inside the URL. Returns `nil` if URL is not compatible, invalid, or could not be parsed for some reason. static func parse_url(url: URL) -> ParsedURLInfo? { + #if !EXTENSION + // Check for nostrsigner:// URL scheme (NIP-55 iOS extension) + if url.scheme == "nostrsigner" { + if let request = NostrSignerRequest.parse(url: url) { + return .nostrSigner(request) + } + return nil + } + #endif + if let purple_url = DamusPurpleURL(url: url) { return .purple(purple_url) } - + if let nwc = WalletConnectURL(str: url.absoluteString) { return .wallet_connect(nwc) } - + guard let link = decode_nostr_uri(url.absoluteString) else { return nil } @@ -118,5 +132,8 @@ struct DamusURLHandler { case script([UInt8]) case purple(DamusPurpleURL) case invoice(Invoice) + #if !EXTENSION + case nostrSigner(NostrSignerRequest) + #endif } }