Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ebc96e7201 | ||
|
|
1f8d478cd7 | ||
|
|
3750e99e61 | ||
|
|
2c1f3265b7 | ||
|
|
7ff8e257dd | ||
|
|
8b6ead1f81 | ||
|
|
38d9a9ef9f | ||
|
|
b55a3f01b6 |
@@ -28,7 +28,7 @@ The repository is configured as monorepo to hold the extensions for Chrome and F
|
||||
To build and run the Chrome extension from this code:
|
||||
|
||||
```
|
||||
git clone https://git.mleku.dev/mleku/plebeian-signer
|
||||
git clone https://github.com/PlebeianApp/plebeian-signer.git
|
||||
cd plebeian-signer
|
||||
npm ci
|
||||
npm run build:chrome
|
||||
@@ -46,7 +46,7 @@ then
|
||||
To build and run the Firefox extension from this code:
|
||||
|
||||
```
|
||||
git clone https://git.mleku.dev/mleku/plebeian-signer
|
||||
git clone https://github.com/PlebeianApp/plebeian-signer.git
|
||||
cd plebeian-signer
|
||||
npm ci
|
||||
npm run build:firefox
|
||||
|
||||
@@ -51,8 +51,8 @@
|
||||
},
|
||||
{
|
||||
"type": "anyComponentStyle",
|
||||
"maximumWarning": "4kB",
|
||||
"maximumError": "8kB"
|
||||
"maximumWarning": "20kB",
|
||||
"maximumError": "25kB"
|
||||
}
|
||||
],
|
||||
"optimization": {
|
||||
@@ -154,8 +154,8 @@
|
||||
},
|
||||
{
|
||||
"type": "anyComponentStyle",
|
||||
"maximumWarning": "4kB",
|
||||
"maximumError": "8kB"
|
||||
"maximumWarning": "20kB",
|
||||
"maximumError": "25kB"
|
||||
}
|
||||
],
|
||||
"optimization": {
|
||||
|
||||
112
docs/store/PRIVACY_POLICY.md
Normal file
112
docs/store/PRIVACY_POLICY.md
Normal file
@@ -0,0 +1,112 @@
|
||||
# Plebeian Signer Privacy Policy
|
||||
|
||||
**Last Updated:** December 20, 2025
|
||||
|
||||
## Overview
|
||||
|
||||
Plebeian Signer is a browser extension for managing Nostr identities and signing cryptographic events. This privacy policy explains how we handle your data.
|
||||
|
||||
## Data Collection
|
||||
|
||||
**We do not collect any personal data.**
|
||||
|
||||
Plebeian Signer operates entirely locally within your browser. We do not:
|
||||
- Collect analytics or telemetry
|
||||
- Track your usage or behavior
|
||||
- Send your data to any external servers
|
||||
- Use cookies or tracking technologies
|
||||
- Share any information with third parties
|
||||
|
||||
## Data Storage
|
||||
|
||||
All data is stored locally in your browser using the browser's built-in storage APIs:
|
||||
|
||||
### What We Store Locally
|
||||
|
||||
1. **Encrypted Vault Data**
|
||||
- Your Nostr private keys (encrypted with Argon2id + AES-256-GCM)
|
||||
- Identity nicknames and metadata
|
||||
- Relay configurations
|
||||
- Site permissions
|
||||
|
||||
2. **Session Data**
|
||||
- Temporary decryption keys (cleared when browser closes or vault locks)
|
||||
- Cached profile metadata
|
||||
|
||||
3. **Extension Settings**
|
||||
- Sync preferences
|
||||
- Reckless mode settings
|
||||
- Whitelisted hosts
|
||||
|
||||
### Encryption
|
||||
|
||||
Your private keys are never stored in plaintext. The vault uses:
|
||||
- **Argon2id** for password-based key derivation (256MB memory, 4 threads, 8 iterations)
|
||||
- **AES-256-GCM** for authenticated encryption
|
||||
- **Random salt and IV** generated for each vault
|
||||
|
||||
## Network Communications
|
||||
|
||||
Plebeian Signer makes the following network requests:
|
||||
|
||||
1. **Nostr Relay Connections**
|
||||
- To fetch your profile metadata (kind 0 events)
|
||||
- To fetch relay lists (kind 10002 events)
|
||||
- Only connects to relays you have configured
|
||||
|
||||
2. **NIP-05 Verification**
|
||||
- Fetches `.well-known/nostr.json` from domains in NIP-05 identifiers
|
||||
- Used only to verify identity claims
|
||||
|
||||
**We do not operate any servers.** All relay connections are made directly to the Nostr network.
|
||||
|
||||
## Permissions Explained
|
||||
|
||||
The extension requests these browser permissions:
|
||||
|
||||
- **`storage`**: To save your encrypted vault and settings
|
||||
- **`activeTab`**: To inject the NIP-07 interface into web pages
|
||||
- **`scripting`**: To enable communication between pages and the extension
|
||||
|
||||
## Data Sharing
|
||||
|
||||
We do not share any data with third parties. The extension:
|
||||
- Has no backend servers
|
||||
- Does not use analytics services
|
||||
- Does not include advertising
|
||||
- Does not sell or monetize your data in any way
|
||||
|
||||
## Your Control
|
||||
|
||||
You have full control over your data:
|
||||
- **Export**: You can export your encrypted vault at any time
|
||||
- **Delete**: Use the "Reset Extension" feature to delete all local data
|
||||
- **Lock**: Lock your vault to clear session data immediately
|
||||
|
||||
## Open Source
|
||||
|
||||
Plebeian Signer is open source software. You can audit the code yourself:
|
||||
- Repository: https://github.com/PlebeianApp/plebeian-signer
|
||||
|
||||
## Children's Privacy
|
||||
|
||||
This extension is not intended for children under 13 years of age. We do not knowingly collect any information from children.
|
||||
|
||||
## Changes to This Policy
|
||||
|
||||
If we make changes to this privacy policy, we will update the "Last Updated" date at the top of this document. Significant changes will be noted in the extension's release notes.
|
||||
|
||||
## Contact
|
||||
|
||||
For privacy-related questions or concerns, please open an issue on our repository:
|
||||
https://github.com/PlebeianApp/plebeian-signer/issues
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
- All data stays in your browser
|
||||
- Private keys are encrypted with strong cryptography
|
||||
- No analytics, tracking, or data collection
|
||||
- No external servers (except Nostr relays you configure)
|
||||
- Fully open source and auditable
|
||||
293
docs/store/PUBLISHING_GUIDE.md
Normal file
293
docs/store/PUBLISHING_GUIDE.md
Normal file
@@ -0,0 +1,293 @@
|
||||
# Extension Store Publishing Guide
|
||||
|
||||
This guide walks you through publishing Plebeian Signer to the Chrome Web Store and Firefox Add-ons.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Assets You Need to Create](#assets-you-need-to-create)
|
||||
2. [Chrome Web Store](#chrome-web-store)
|
||||
3. [Firefox Add-ons](#firefox-add-ons)
|
||||
4. [Ongoing Maintenance](#ongoing-maintenance)
|
||||
|
||||
---
|
||||
|
||||
## Assets You Need to Create
|
||||
|
||||
Before submitting to either store, prepare these assets:
|
||||
|
||||
### Screenshots (Required for both stores)
|
||||
|
||||
Create 3-5 screenshots showing the extension in action:
|
||||
|
||||
1. **Main popup view** - Show the identity card with profile info
|
||||
2. **Permission prompt** - Show a signing request popup
|
||||
3. **Identity management** - Show the identity list/switching
|
||||
4. **Permissions page** - Show the permissions management
|
||||
5. **Settings page** - Show vault settings and options
|
||||
|
||||
**Specifications:**
|
||||
- Chrome: 1280x800 or 640x400 pixels (PNG or JPEG)
|
||||
- Firefox: 1280x800 recommended (PNG or JPEG)
|
||||
|
||||
**Tips:**
|
||||
- Use a clean browser profile
|
||||
- Show realistic data (not "test" or placeholder text)
|
||||
- Capture the full popup or relevant UI area
|
||||
- Consider adding captions/annotations
|
||||
|
||||
### Promotional Images (Chrome only)
|
||||
|
||||
Chrome Web Store uses promotional tiles:
|
||||
|
||||
| Size | Name | Required |
|
||||
|------|------|----------|
|
||||
| 440x280 | Small promo tile | Optional but recommended |
|
||||
| 920x680 | Large promo tile | Optional |
|
||||
| 1400x560 | Marquee promo tile | Optional |
|
||||
|
||||
**Design tips:**
|
||||
- Include the extension icon/logo
|
||||
- Add a tagline like "Secure Nostr Identity Manager"
|
||||
- Use brand colors
|
||||
- Keep text minimal and readable
|
||||
|
||||
### Icon (Already exists)
|
||||
|
||||
You already have icons in the extension:
|
||||
- `icon-48.png` - 48x48
|
||||
- `icon-128.png` - 128x128
|
||||
|
||||
Chrome also wants a 128x128 icon for the store listing (can use the same one).
|
||||
|
||||
### Privacy Policy URL
|
||||
|
||||
You need to host the privacy policy at a public URL. Options:
|
||||
|
||||
1. **GitHub/Gitea Pages** - Host `PRIVACY_POLICY.md` as a webpage
|
||||
2. **Simple webpage** - Create a basic HTML page
|
||||
3. **Gist** - Create a public GitHub gist
|
||||
|
||||
Example URL format: `https://github.com/PlebeianApp/plebeian-signer/blob/main/docs/store/PRIVACY_POLICY.md`
|
||||
|
||||
---
|
||||
|
||||
## Chrome Web Store
|
||||
|
||||
### Step 1: Create Developer Account
|
||||
|
||||
1. Go to https://chrome.google.com/webstore/devconsole
|
||||
2. Sign in with a Google account
|
||||
3. Pay the one-time $5 USD registration fee
|
||||
4. Accept the developer agreement
|
||||
|
||||
### Step 2: Create New Item
|
||||
|
||||
1. Click **"New Item"** button
|
||||
2. Upload `releases/plebeian-signer-chrome-v1.0.5.zip`
|
||||
3. Wait for the upload to process
|
||||
|
||||
### Step 3: Fill Store Listing
|
||||
|
||||
**Product Details:**
|
||||
- **Name:** Plebeian Signer
|
||||
- **Summary:** Copy from `STORE_DESCRIPTION.md` (short description, 132 chars max)
|
||||
- **Description:** Copy from `STORE_DESCRIPTION.md` (full description)
|
||||
- **Category:** Productivity
|
||||
- **Language:** English
|
||||
|
||||
**Graphic Assets:**
|
||||
- Upload your screenshots (at least 1 required, up to 5)
|
||||
- Upload promotional tiles if you have them
|
||||
|
||||
**Additional Fields:**
|
||||
- **Official URL:** `https://github.com/PlebeianApp/plebeian-signer`
|
||||
- **Support URL:** `https://github.com/PlebeianApp/plebeian-signer/issues`
|
||||
|
||||
### Step 4: Privacy Tab
|
||||
|
||||
- **Single Purpose:** "Manage Nostr identities and sign cryptographic events for web applications"
|
||||
- **Permission Justifications:**
|
||||
- `storage`: "Store encrypted vault containing user's Nostr identities and extension settings"
|
||||
- `activeTab`: "Inject NIP-07 interface into the active tab when user visits Nostr applications"
|
||||
- `scripting`: "Enable communication between web pages and the extension for signing requests"
|
||||
- **Data Usage:** Check "I do not sell or transfer user data to third parties"
|
||||
- **Privacy Policy URL:** Your hosted privacy policy URL
|
||||
|
||||
### Step 5: Distribution
|
||||
|
||||
- **Visibility:** Public
|
||||
- **Distribution:** All regions (or select specific ones)
|
||||
|
||||
### Step 6: Submit for Review
|
||||
|
||||
1. Review all sections show green checkmarks
|
||||
2. Click **"Submit for Review"**
|
||||
3. Wait 1-3 business days (can take longer for first submission)
|
||||
|
||||
### Chrome Review Notes
|
||||
|
||||
Google may ask about:
|
||||
- Why you need each permission
|
||||
- How you handle user data
|
||||
- Your identity/organization
|
||||
|
||||
Be prepared to respond to reviewer questions via the dashboard.
|
||||
|
||||
---
|
||||
|
||||
## Firefox Add-ons
|
||||
|
||||
### Step 1: Create Developer Account
|
||||
|
||||
1. Go to https://addons.mozilla.org/developers/
|
||||
2. Sign in with a Firefox account (create one if needed)
|
||||
3. No fee required
|
||||
|
||||
### Step 2: Submit New Add-on
|
||||
|
||||
1. Click **"Submit a New Add-on"**
|
||||
2. Select **"On this site"** for hosting
|
||||
3. Upload `releases/plebeian-signer-firefox-v1.0.5.zip`
|
||||
4. Wait for automated validation
|
||||
|
||||
### Step 3: Source Code Submission
|
||||
|
||||
Firefox may request source code because the extension uses bundled/minified JavaScript.
|
||||
|
||||
**If prompted:**
|
||||
1. Create a source code zip (exclude `node_modules`):
|
||||
```bash
|
||||
cd /home/mleku/src/git.mleku.dev/mleku/plebeian-signer
|
||||
zip -r plebeian-signer-source.zip . -x "node_modules/*" -x "dist/*" -x ".git/*"
|
||||
```
|
||||
2. Upload this zip when asked
|
||||
3. Include build instructions (point to CLAUDE.md or add a note):
|
||||
```
|
||||
Build Instructions:
|
||||
1. npm ci
|
||||
2. npm run build:firefox
|
||||
3. Output is in dist/firefox/
|
||||
```
|
||||
|
||||
### Step 4: Fill Listing Details
|
||||
|
||||
**Basic Information:**
|
||||
- **Name:** Plebeian Signer
|
||||
- **Add-on URL:** `plebeian-signer` (creates addons.mozilla.org/addon/plebeian-signer)
|
||||
- **Summary:** Copy short description from `STORE_DESCRIPTION.md`
|
||||
- **Description:** Copy full description (supports some HTML/Markdown)
|
||||
- **Categories:** Privacy & Security
|
||||
|
||||
**Additional Details:**
|
||||
- **Homepage:** `https://github.com/PlebeianApp/plebeian-signer`
|
||||
- **Support URL:** `https://github.com/PlebeianApp/plebeian-signer/issues`
|
||||
- **License:** Select appropriate license
|
||||
- **Privacy Policy:** Paste URL to hosted privacy policy
|
||||
|
||||
**Media:**
|
||||
- **Icon:** Already in the extension manifest
|
||||
- **Screenshots:** Upload your screenshots
|
||||
|
||||
### Step 5: Submit for Review
|
||||
|
||||
1. Ensure all required fields are complete
|
||||
2. Click **"Submit Version"**
|
||||
3. Wait for review (usually hours to a few days)
|
||||
|
||||
### Firefox Review Notes
|
||||
|
||||
Firefox reviewers are generally faster but thorough. They may:
|
||||
- Ask for source code (see Step 3)
|
||||
- Question specific code patterns
|
||||
- Request changes for policy compliance
|
||||
|
||||
---
|
||||
|
||||
## Ongoing Maintenance
|
||||
|
||||
### Updating the Extension
|
||||
|
||||
**For new releases:**
|
||||
|
||||
1. Build new version: `/release patch` (or `minor`/`major`)
|
||||
2. Upload the new zip to each store
|
||||
3. Add release notes describing changes
|
||||
4. Submit for review
|
||||
|
||||
**Chrome:**
|
||||
- Go to Developer Dashboard → Your extension → Package → Upload new package
|
||||
|
||||
**Firefox:**
|
||||
- Go to Developer Hub → Your extension → Upload a New Version
|
||||
|
||||
### Responding to Reviews
|
||||
|
||||
Both stores may contact you with:
|
||||
- Policy violation notices
|
||||
- User reports
|
||||
- Review questions
|
||||
|
||||
Monitor your developer email and respond promptly.
|
||||
|
||||
### Version Numbering
|
||||
|
||||
Both stores extract the version from `manifest.json`. Your current setup with `v1.0.5` in `package.json` feeds into the manifests correctly.
|
||||
|
||||
---
|
||||
|
||||
## Checklist
|
||||
|
||||
### Before First Submission
|
||||
|
||||
- [ ] Create 3-5 screenshots
|
||||
- [ ] Create promotional images (Chrome, optional but recommended)
|
||||
- [ ] Host privacy policy at a public URL
|
||||
- [ ] Test the extension zip by loading it unpacked
|
||||
- [ ] Prepare source code zip for Firefox
|
||||
|
||||
### Chrome Web Store
|
||||
|
||||
- [ ] Register developer account ($5)
|
||||
- [ ] Upload extension zip
|
||||
- [ ] Fill all required listing fields
|
||||
- [ ] Add screenshots
|
||||
- [ ] Add privacy policy URL
|
||||
- [ ] Justify all permissions
|
||||
- [ ] Submit for review
|
||||
|
||||
### Firefox Add-ons
|
||||
|
||||
- [ ] Register developer account (free)
|
||||
- [ ] Upload extension zip
|
||||
- [ ] Upload source code if requested
|
||||
- [ ] Fill all required listing fields
|
||||
- [ ] Add screenshots
|
||||
- [ ] Add privacy policy URL
|
||||
- [ ] Submit for review
|
||||
|
||||
---
|
||||
|
||||
## Helpful Links
|
||||
|
||||
- Chrome Developer Dashboard: https://chrome.google.com/webstore/devconsole
|
||||
- Chrome Publishing Docs: https://developer.chrome.com/docs/webstore/publish/
|
||||
- Firefox Developer Hub: https://addons.mozilla.org/developers/
|
||||
- Firefox Extension Workshop: https://extensionworkshop.com/documentation/publish/
|
||||
|
||||
---
|
||||
|
||||
## Estimated Timeline
|
||||
|
||||
| Task | Time |
|
||||
|------|------|
|
||||
| Create screenshots | 30 min - 1 hour |
|
||||
| Create promotional images | 1-2 hours (optional) |
|
||||
| Host privacy policy | 15 min |
|
||||
| Chrome submission | 30 min |
|
||||
| Chrome review | 1-3 business days |
|
||||
| Firefox submission | 30 min |
|
||||
| Firefox review | Hours to 2 days |
|
||||
|
||||
**Total:** You can have both submissions done in an afternoon, with approvals coming within a week.
|
||||
88
docs/store/STORE_DESCRIPTION.md
Normal file
88
docs/store/STORE_DESCRIPTION.md
Normal file
@@ -0,0 +1,88 @@
|
||||
# Plebeian Signer - Store Description
|
||||
|
||||
Use this content for Chrome Web Store and Firefox Add-ons listings.
|
||||
|
||||
---
|
||||
|
||||
## Short Description (132 characters max for Chrome)
|
||||
|
||||
Secure Nostr identity manager. Sign events without exposing private keys. Multi-identity support with NIP-07 compatibility.
|
||||
|
||||
---
|
||||
|
||||
## Full Description
|
||||
|
||||
**Plebeian Signer** is a secure browser extension for managing your Nostr identities and signing events without exposing your private keys to web applications.
|
||||
|
||||
### Key Features
|
||||
|
||||
**Multi-Identity Management**
|
||||
- Create and manage multiple Nostr identities from a single extension
|
||||
- Easily switch between identities with one click
|
||||
- Import existing keys or generate new ones
|
||||
|
||||
**Bank-Grade Security**
|
||||
- Private keys never leave the extension
|
||||
- Vault encrypted with Argon2id + AES-256-GCM (the same algorithms used by password managers)
|
||||
- Automatic vault locking for protection
|
||||
|
||||
**NIP-07 Compatible**
|
||||
- Works with all Nostr web applications that support NIP-07
|
||||
- Supports NIP-04 and NIP-44 encryption/decryption
|
||||
- Relay configuration per identity
|
||||
|
||||
**Permission Control**
|
||||
- Fine-grained permission management per application
|
||||
- Approve or deny signing requests on a per-site basis
|
||||
- Optional "Reckless Mode" for trusted applications
|
||||
- Whitelist trusted hosts for automatic approval
|
||||
|
||||
**User-Friendly Interface**
|
||||
- Clean, intuitive design
|
||||
- Profile metadata display with avatar and banner
|
||||
- NIP-05 verification support
|
||||
- Bookmark your favorite Nostr apps
|
||||
|
||||
### How It Works
|
||||
|
||||
1. Create a password-protected vault
|
||||
2. Add your Nostr identities (import existing or generate new)
|
||||
3. Visit any NIP-07 compatible Nostr application
|
||||
4. Approve signing requests through the extension popup
|
||||
|
||||
### Privacy First
|
||||
|
||||
Plebeian Signer is open source and respects your privacy:
|
||||
- No telemetry or analytics
|
||||
- No external servers (except for profile metadata from Nostr relays)
|
||||
- All cryptographic operations happen locally in your browser
|
||||
- Your private keys are encrypted and never transmitted
|
||||
|
||||
### Supported NIPs
|
||||
|
||||
- NIP-07: Browser Extension for Nostr
|
||||
- NIP-04: Encrypted Direct Messages
|
||||
- NIP-44: Versioned Encryption
|
||||
|
||||
### Links
|
||||
|
||||
- Source Code: https://github.com/PlebeianApp/plebeian-signer
|
||||
- Report Issues: https://github.com/PlebeianApp/plebeian-signer/issues
|
||||
|
||||
---
|
||||
|
||||
## Category Suggestions
|
||||
|
||||
**Chrome Web Store:**
|
||||
- Primary: Productivity
|
||||
- Secondary: Developer Tools
|
||||
|
||||
**Firefox Add-ons:**
|
||||
- Primary: Privacy & Security
|
||||
- Secondary: Other
|
||||
|
||||
---
|
||||
|
||||
## Tags/Keywords
|
||||
|
||||
nostr, nip-07, signing, identity, privacy, encryption, decentralized, keys, wallet, security
|
||||
129
docs/store/publishing.md
Normal file
129
docs/store/publishing.md
Normal file
@@ -0,0 +1,129 @@
|
||||
# Publishing Checklist
|
||||
|
||||
Developer accounts are set up. This document covers the remaining steps.
|
||||
|
||||
## Privacy Policy URL
|
||||
|
||||
```
|
||||
https://github.com/PlebeianApp/plebeian-signer/blob/main/docs/store/PRIVACY_POLICY.md
|
||||
```
|
||||
|
||||
## Screenshots Needed
|
||||
|
||||
Take 3-5 screenshots (1280x800 or 640x400 PNG/JPEG):
|
||||
|
||||
1. **Identity view** - Main popup showing profile card with avatar/banner
|
||||
2. **Permission prompt** - A signing request popup from a Nostr app
|
||||
3. **Identity list** - Multiple identities with switching UI
|
||||
4. **Permissions page** - Managing site permissions
|
||||
5. **Settings** - Vault/reckless mode settings
|
||||
|
||||
**Tips:**
|
||||
- Load the extension in a clean browser profile
|
||||
- Use real-looking test data, not "test123"
|
||||
- Crop to show just the popup/relevant UI
|
||||
|
||||
---
|
||||
|
||||
## Chrome Web Store Submission
|
||||
|
||||
1. Go to https://chrome.google.com/webstore/devconsole
|
||||
2. Click **"New Item"**
|
||||
3. Upload: `releases/plebeian-signer-chrome-v1.0.5.zip`
|
||||
|
||||
### Store Listing Tab
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| Name | Plebeian Signer |
|
||||
| Summary | Secure Nostr identity manager. Sign events without exposing private keys. Multi-identity support with NIP-07 compatibility. |
|
||||
| Description | Copy from `docs/store/STORE_DESCRIPTION.md` (full description section) |
|
||||
| Category | Productivity |
|
||||
| Language | English |
|
||||
|
||||
Upload your screenshots.
|
||||
|
||||
### Privacy Tab
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| Single Purpose | Manage Nostr identities and sign cryptographic events for web applications |
|
||||
| Privacy Policy URL | `https://github.com/PlebeianApp/plebeian-signer/blob/main/docs/store/PRIVACY_POLICY.md` |
|
||||
|
||||
**Permission Justifications:**
|
||||
|
||||
| Permission | Justification |
|
||||
|------------|---------------|
|
||||
| storage | Store encrypted vault containing user's Nostr identities and extension settings |
|
||||
| activeTab | Inject NIP-07 interface into the active tab when user visits Nostr applications |
|
||||
| scripting | Enable communication between web pages and the extension for signing requests |
|
||||
|
||||
Check: "I do not sell or transfer user data to third parties"
|
||||
|
||||
### Distribution Tab
|
||||
|
||||
- Visibility: Public
|
||||
- Regions: All
|
||||
|
||||
Click **"Submit for Review"**
|
||||
|
||||
---
|
||||
|
||||
## Firefox Add-ons Submission
|
||||
|
||||
1. Go to https://addons.mozilla.org/developers/
|
||||
2. Click **"Submit a New Add-on"**
|
||||
3. Select **"On this site"**
|
||||
4. Upload: `releases/plebeian-signer-firefox-v1.0.5.zip`
|
||||
|
||||
### If Asked for Source Code
|
||||
|
||||
Run this to create source zip:
|
||||
```bash
|
||||
cd /home/mleku/src/git.mleku.dev/mleku/plebeian-signer
|
||||
zip -r plebeian-signer-source.zip . -x "node_modules/*" -x "dist/*" -x ".git/*" -x "releases/*"
|
||||
```
|
||||
|
||||
Build instructions to provide:
|
||||
```
|
||||
1. npm ci
|
||||
2. npm run build:firefox
|
||||
3. Output is in dist/firefox/
|
||||
```
|
||||
|
||||
### Listing Details
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| Name | Plebeian Signer |
|
||||
| Add-on URL | plebeian-signer |
|
||||
| Summary | Secure Nostr identity manager. Sign events without exposing private keys. Multi-identity support with NIP-07 compatibility. |
|
||||
| Description | Copy from `docs/store/STORE_DESCRIPTION.md` |
|
||||
| Categories | Privacy & Security |
|
||||
| Homepage | `https://github.com/PlebeianApp/plebeian-signer` |
|
||||
| Support URL | `https://github.com/PlebeianApp/plebeian-signer/issues` |
|
||||
| Privacy Policy | `https://github.com/PlebeianApp/plebeian-signer/blob/main/docs/store/PRIVACY_POLICY.md` |
|
||||
|
||||
Upload your screenshots.
|
||||
|
||||
Click **"Submit Version"**
|
||||
|
||||
---
|
||||
|
||||
## After Submission
|
||||
|
||||
- **Chrome:** 1-3 business days review
|
||||
- **Firefox:** Hours to 2 days review
|
||||
|
||||
Check your email for reviewer questions. Both dashboards show review status.
|
||||
|
||||
---
|
||||
|
||||
## Updating Later
|
||||
|
||||
When you release a new version:
|
||||
|
||||
1. Run `/release patch` (or minor/major)
|
||||
2. Chrome: Dashboard → Your extension → Package → Upload new package
|
||||
3. Firefox: Developer Hub → Your extension → Upload a New Version
|
||||
4. Add release notes, submit for review
|
||||
325
package-lock.json
generated
325
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "plebeian-signer",
|
||||
"version": "v0.0.9",
|
||||
"version": "v1.0.8",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "plebeian-signer",
|
||||
"version": "v0.0.9",
|
||||
"version": "v1.0.8",
|
||||
"dependencies": {
|
||||
"@angular/animations": "^19.0.0",
|
||||
"@angular/common": "^19.0.0",
|
||||
@@ -16,13 +16,16 @@
|
||||
"@angular/platform-browser": "^19.0.0",
|
||||
"@angular/platform-browser-dynamic": "^19.0.0",
|
||||
"@angular/router": "^19.0.0",
|
||||
"@cashu/cashu-ts": "^3.2.0",
|
||||
"@nostr-dev-kit/ndk": "^2.11.0",
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"bootstrap": "^5.3.3",
|
||||
"bootstrap-icons": "^1.11.3",
|
||||
"buffer": "^6.0.3",
|
||||
"hash-wasm": "^4.11.0",
|
||||
"nostr-tools": "^2.10.4",
|
||||
"qrcode": "^1.5.4",
|
||||
"rxjs": "~7.8.0",
|
||||
"tslib": "^2.3.0",
|
||||
"webextension-polyfill": "^0.12.0",
|
||||
@@ -36,6 +39,7 @@
|
||||
"@types/bootstrap": "^5.2.10",
|
||||
"@types/chrome": "^0.0.293",
|
||||
"@types/jasmine": "~5.1.0",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@types/webextension-polyfill": "^0.12.1",
|
||||
"angular-eslint": "19.0.2",
|
||||
"eslint": "^9.16.0",
|
||||
@@ -4712,6 +4716,70 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@cashu/cashu-ts": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@cashu/cashu-ts/-/cashu-ts-3.2.0.tgz",
|
||||
"integrity": "sha512-wOdqenmPs92+5feU2GIg92QcdNmCdg4AIau7Lq6G/uw1t+t/osjygupr2dmDzdQx7JBWHHNoVaUDSJm1G8phYg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/curves": "^2.0.1",
|
||||
"@noble/hashes": "^2.0.1",
|
||||
"@scure/bip32": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@cashu/cashu-ts/node_modules/@noble/curves": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.0.1.tgz",
|
||||
"integrity": "sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/hashes": "2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@cashu/cashu-ts/node_modules/@noble/hashes": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz",
|
||||
"integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@cashu/cashu-ts/node_modules/@scure/base": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@scure/base/-/base-2.0.0.tgz",
|
||||
"integrity": "sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@cashu/cashu-ts/node_modules/@scure/bip32": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-2.0.1.tgz",
|
||||
"integrity": "sha512-4Md1NI5BzoVP+bhyJaY3K6yMesEFzNS1sE/cP+9nuvE7p/b0kx9XbpDHHFl8dHtufcbdHRUUQdRqLIPHN/s7yA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/curves": "2.0.1",
|
||||
"@noble/hashes": "2.0.1",
|
||||
"@scure/base": "2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@colors/colors": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz",
|
||||
@@ -8157,7 +8225,6 @@
|
||||
"version": "22.13.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.0.tgz",
|
||||
"integrity": "sha512-ClIbNe36lawluuvq3+YYhnIN2CELi+6q8NpnM7PYp4hBn/TatfboPgVSm2rwKRfnV2M+Ty9GWDFI64KEe+kysA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.20.0"
|
||||
@@ -8173,6 +8240,15 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/qrcode": {
|
||||
"version": "1.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
|
||||
"integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/qs": {
|
||||
"version": "6.9.18",
|
||||
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.18.tgz",
|
||||
@@ -8237,6 +8313,13 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/uuid": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
|
||||
"integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/webextension-polyfill": {
|
||||
"version": "0.12.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/webextension-polyfill/-/webextension-polyfill-0.12.1.tgz",
|
||||
@@ -9245,7 +9328,6 @@
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1"
|
||||
@@ -9925,6 +10007,15 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/camelcase": {
|
||||
"version": "5.3.1",
|
||||
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
|
||||
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001696",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001696.tgz",
|
||||
@@ -10192,7 +10283,6 @@
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-name": "~1.1.4"
|
||||
@@ -10205,7 +10295,6 @@
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/colorette": {
|
||||
@@ -10643,6 +10732,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/decamelize": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
|
||||
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/deep-is": {
|
||||
"version": "0.1.4",
|
||||
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
||||
@@ -10772,6 +10870,12 @@
|
||||
"node": ">=0.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/dijkstrajs": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
|
||||
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dns-packet": {
|
||||
"version": "5.6.1",
|
||||
"resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-5.6.1.tgz",
|
||||
@@ -12083,7 +12187,6 @@
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "6.* || 8.* || >= 10.*"
|
||||
@@ -16124,7 +16227,6 @@
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
|
||||
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
@@ -16273,7 +16375,6 @@
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -16495,6 +16596,15 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/pngjs": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
|
||||
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.4.49",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.49.tgz",
|
||||
@@ -16744,6 +16854,177 @@
|
||||
"node": ">=0.9"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode": {
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
|
||||
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dijkstrajs": "^1.0.1",
|
||||
"pngjs": "^5.0.0",
|
||||
"yargs": "^15.3.1"
|
||||
},
|
||||
"bin": {
|
||||
"qrcode": "bin/qrcode"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/cliui": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
|
||||
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"string-width": "^4.2.0",
|
||||
"strip-ansi": "^6.0.0",
|
||||
"wrap-ansi": "^6.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/qrcode/node_modules/find-up": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
|
||||
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"locate-path": "^5.0.0",
|
||||
"path-exists": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/is-fullwidth-code-point": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/locate-path": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
|
||||
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-locate": "^4.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/p-limit": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
|
||||
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-try": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/p-locate": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
|
||||
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-limit": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
"strip-ansi": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/strip-ansi": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/y18n": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
|
||||
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/qrcode/node_modules/yargs": {
|
||||
"version": "15.4.1",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
|
||||
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cliui": "^6.0.0",
|
||||
"decamelize": "^1.2.0",
|
||||
"find-up": "^4.1.0",
|
||||
"get-caller-file": "^2.0.1",
|
||||
"require-directory": "^2.1.1",
|
||||
"require-main-filename": "^2.0.0",
|
||||
"set-blocking": "^2.0.0",
|
||||
"string-width": "^4.2.0",
|
||||
"which-module": "^2.0.0",
|
||||
"y18n": "^4.0.0",
|
||||
"yargs-parser": "^18.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/yargs-parser": {
|
||||
"version": "18.1.3",
|
||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
|
||||
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"camelcase": "^5.0.0",
|
||||
"decamelize": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.13.0",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz",
|
||||
@@ -16952,7 +17233,6 @@
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -16968,6 +17248,12 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-main-filename": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
|
||||
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/requires-port": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
|
||||
@@ -17646,6 +17932,12 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/set-blocking": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
|
||||
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/setprototypeof": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||
@@ -19024,7 +19316,6 @@
|
||||
"version": "6.20.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz",
|
||||
"integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unicode-canonical-property-names-ecmascript": {
|
||||
@@ -19856,6 +20147,12 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/which-module": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
|
||||
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/wildcard": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/wildcard/-/wildcard-2.0.1.tgz",
|
||||
@@ -19877,7 +20174,6 @@
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
|
||||
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.0.0",
|
||||
@@ -19966,7 +20262,6 @@
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -19976,14 +20271,12 @@
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/wrap-ansi/node_modules/is-fullwidth-code-point": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -19993,7 +20286,6 @@
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
@@ -20008,7 +20300,6 @@
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
|
||||
15
package.json
15
package.json
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "plebeian-signer",
|
||||
"version": "v1.0.5",
|
||||
"version": "v1.0.9",
|
||||
"custom": {
|
||||
"chrome": {
|
||||
"version": "v1.0.5"
|
||||
"version": "v1.0.9"
|
||||
},
|
||||
"firefox": {
|
||||
"version": "v1.0.5"
|
||||
"version": "v1.0.9"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
@@ -15,10 +15,11 @@
|
||||
"clean:firefox": "rimraf dist/firefox",
|
||||
"start:chrome": "ng serve chrome",
|
||||
"start:firefox": "ng serve firefox",
|
||||
"fetch-kinds": "node scripts/fetch-kinds.js",
|
||||
"prepare:chrome": "./chrome_prepare_manifest.sh",
|
||||
"prepare:firefox": "./firefox_prepare_manifest.sh",
|
||||
"build:chrome": "npm run prepare:chrome && ng build chrome",
|
||||
"build:firefox": "npm run prepare:firefox && ng build firefox",
|
||||
"build:chrome": "npm run fetch-kinds && npm run prepare:chrome && ng build chrome",
|
||||
"build:firefox": "npm run fetch-kinds && npm run prepare:firefox && ng build firefox",
|
||||
"watch:chrome": "npm run prepare:chrome && ng build chrome --watch --configuration development",
|
||||
"watch:firefox": "npm run prepare:firefox && ng build firefox --watch --configuration development",
|
||||
"test": "ng test",
|
||||
@@ -35,13 +36,16 @@
|
||||
"@angular/platform-browser": "^19.0.0",
|
||||
"@angular/platform-browser-dynamic": "^19.0.0",
|
||||
"@angular/router": "^19.0.0",
|
||||
"@cashu/cashu-ts": "^3.2.0",
|
||||
"@nostr-dev-kit/ndk": "^2.11.0",
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"bootstrap": "^5.3.3",
|
||||
"bootstrap-icons": "^1.11.3",
|
||||
"buffer": "^6.0.3",
|
||||
"hash-wasm": "^4.11.0",
|
||||
"nostr-tools": "^2.10.4",
|
||||
"qrcode": "^1.5.4",
|
||||
"rxjs": "~7.8.0",
|
||||
"tslib": "^2.3.0",
|
||||
"webextension-polyfill": "^0.12.0",
|
||||
@@ -55,6 +59,7 @@
|
||||
"@types/bootstrap": "^5.2.10",
|
||||
"@types/chrome": "^0.0.293",
|
||||
"@types/jasmine": "~5.1.0",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@types/webextension-polyfill": "^0.12.1",
|
||||
"angular-eslint": "19.0.2",
|
||||
"eslint": "^9.16.0",
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
"manifest_version": 3,
|
||||
"name": "Plebeian Signer - Nostr Identity Manager & Signer",
|
||||
"description": "Manage and switch between multiple identities while interacting with Nostr apps",
|
||||
"version": "1.0.5",
|
||||
"homepage_url": "https://git.mleku.dev/mleku/plebeian-signer",
|
||||
"version": "1.0.9",
|
||||
"homepage_url": "https://github.com/PlebeianApp/plebeian-signer",
|
||||
"options_page": "options.html",
|
||||
"permissions": [
|
||||
"windows",
|
||||
|
||||
@@ -11,6 +11,8 @@ import { InfoComponent } from './components/home/info/info.component';
|
||||
import { SettingsComponent } from './components/home/settings/settings.component';
|
||||
import { LogsComponent } from './components/home/logs/logs.component';
|
||||
import { BookmarksComponent } from './components/home/bookmarks/bookmarks.component';
|
||||
import { WalletComponent } from './components/home/wallet/wallet.component';
|
||||
import { BackupsComponent } from './components/home/backups/backups.component';
|
||||
import { NewIdentityComponent } from './components/new-identity/new-identity.component';
|
||||
import { EditIdentityComponent } from './components/edit-identity/edit-identity.component';
|
||||
import { HomeComponent as EditIdentityHomeComponent } from './components/edit-identity/home/home.component';
|
||||
@@ -76,6 +78,14 @@ export const routes: Routes = [
|
||||
path: 'bookmarks',
|
||||
component: BookmarksComponent,
|
||||
},
|
||||
{
|
||||
path: 'wallet',
|
||||
component: WalletComponent,
|
||||
},
|
||||
{
|
||||
path: 'backups',
|
||||
component: BackupsComponent,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
import {
|
||||
BrowserSyncData,
|
||||
BrowserSyncHandler,
|
||||
CashuMint_ENCRYPTED,
|
||||
Identity_ENCRYPTED,
|
||||
NwcConnection_ENCRYPTED,
|
||||
Permission_ENCRYPTED,
|
||||
Relay_ENCRYPTED,
|
||||
} from '@common';
|
||||
@@ -57,6 +59,20 @@ export class ChromeSyncNoHandler extends BrowserSyncHandler {
|
||||
this.setPartialData_Relays(data);
|
||||
}
|
||||
|
||||
async saveAndSetPartialData_NwcConnections(data: {
|
||||
nwcConnections: NwcConnection_ENCRYPTED[];
|
||||
}): Promise<void> {
|
||||
await chrome.storage.local.set(data);
|
||||
this.setPartialData_NwcConnections(data);
|
||||
}
|
||||
|
||||
async saveAndSetPartialData_CashuMints(data: {
|
||||
cashuMints: CashuMint_ENCRYPTED[];
|
||||
}): Promise<void> {
|
||||
await chrome.storage.local.set(data);
|
||||
this.setPartialData_CashuMints(data);
|
||||
}
|
||||
|
||||
async clearData(): Promise<void> {
|
||||
const props = Object.keys(await this.loadUnmigratedData());
|
||||
await chrome.storage.local.remove(props);
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import {
|
||||
BrowserSyncData,
|
||||
CashuMint_ENCRYPTED,
|
||||
Identity_ENCRYPTED,
|
||||
NwcConnection_ENCRYPTED,
|
||||
Permission_ENCRYPTED,
|
||||
BrowserSyncHandler,
|
||||
Relay_ENCRYPTED,
|
||||
@@ -49,6 +51,20 @@ export class ChromeSyncYesHandler extends BrowserSyncHandler {
|
||||
this.setPartialData_Relays(data);
|
||||
}
|
||||
|
||||
async saveAndSetPartialData_NwcConnections(data: {
|
||||
nwcConnections: NwcConnection_ENCRYPTED[];
|
||||
}): Promise<void> {
|
||||
await chrome.storage.sync.set(data);
|
||||
this.setPartialData_NwcConnections(data);
|
||||
}
|
||||
|
||||
async saveAndSetPartialData_CashuMints(data: {
|
||||
cashuMints: CashuMint_ENCRYPTED[];
|
||||
}): Promise<void> {
|
||||
await chrome.storage.sync.set(data);
|
||||
this.setPartialData_CashuMints(data);
|
||||
}
|
||||
|
||||
async clearData(): Promise<void> {
|
||||
await chrome.storage.sync.clear();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
<div class="sam-text-header">
|
||||
<button class="lock-btn" title="Lock" (click)="onClickLock()">
|
||||
<span class="emoji">🔒</span>
|
||||
</button>
|
||||
<button class="back-btn" title="Go Back" (click)="goBack()">
|
||||
<span class="emoji">←</span>
|
||||
</button>
|
||||
<span>Backups</span>
|
||||
</div>
|
||||
|
||||
<div class="backup-settings">
|
||||
<div class="setting-row">
|
||||
<label for="maxBackups">Max Auto Backups:</label>
|
||||
<input
|
||||
id="maxBackups"
|
||||
type="number"
|
||||
[value]="maxBackups"
|
||||
min="1"
|
||||
max="20"
|
||||
(change)="onMaxBackupsChange($event)"
|
||||
/>
|
||||
</div>
|
||||
<p class="setting-note">
|
||||
Automatic backups are created when significant changes are made.
|
||||
Manual and pre-restore backups are not counted toward this limit.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary create-btn" (click)="createManualBackup()">
|
||||
Create Backup Now
|
||||
</button>
|
||||
|
||||
<div class="backups-list">
|
||||
@if (backups.length === 0) {
|
||||
<div class="empty-state">
|
||||
<span>No backups yet</span>
|
||||
</div>
|
||||
}
|
||||
@for (backup of backups; track backup.id) {
|
||||
<div class="backup-item">
|
||||
<div class="backup-info">
|
||||
<span class="backup-date">{{ formatDate(backup.createdAt) }}</span>
|
||||
<div class="backup-meta">
|
||||
<span class="backup-reason" [class]="getReasonClass(backup.reason)">
|
||||
{{ getReasonLabel(backup.reason) }}
|
||||
</span>
|
||||
<span class="backup-identities">{{ backup.identityCount }} identity(ies)</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="backup-actions">
|
||||
<button
|
||||
class="btn btn-sm btn-secondary"
|
||||
(click)="
|
||||
confirm.show(
|
||||
'Restore this backup? A backup of your current state will be created first.',
|
||||
restoreBackup.bind(this, backup.id)
|
||||
)
|
||||
"
|
||||
[disabled]="restoringBackupId !== null"
|
||||
>
|
||||
{{ restoringBackupId === backup.id ? 'Restoring...' : 'Restore' }}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm btn-danger"
|
||||
(click)="
|
||||
confirm.show(
|
||||
'Delete this backup? This cannot be undone.',
|
||||
deleteBackup.bind(this, backup.id)
|
||||
)
|
||||
"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<lib-confirm #confirm></lib-confirm>
|
||||
@@ -0,0 +1,192 @@
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
padding: 8px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.sam-text-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.lock-btn,
|
||||
.back-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
background: var(--muted);
|
||||
}
|
||||
|
||||
.emoji {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.backup-settings {
|
||||
background: var(--muted);
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.setting-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
label {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
input[type="number"] {
|
||||
width: 60px;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
}
|
||||
}
|
||||
|
||||
.setting-note {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.create-btn {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.backups-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100px;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.backup-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.backup-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.backup-date {
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.backup-meta {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.backup-reason {
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
|
||||
&.reason-auto {
|
||||
background: var(--muted);
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
&.reason-manual {
|
||||
background: rgba(34, 197, 94, 0.2);
|
||||
color: rgb(34, 197, 94);
|
||||
}
|
||||
|
||||
&.reason-prerestore {
|
||||
background: rgba(234, 179, 8, 0.2);
|
||||
color: rgb(234, 179, 8);
|
||||
}
|
||||
}
|
||||
|
||||
.backup-identities {
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.backup-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
transition: background-color 0.2s;
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--primary);
|
||||
color: var(--primary-foreground);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--secondary);
|
||||
color: var(--secondary-foreground);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--muted);
|
||||
}
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
color: rgb(239, 68, 68);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { Component, inject, OnInit } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import {
|
||||
ConfirmComponent,
|
||||
LoggerService,
|
||||
SignerMetaData_VaultSnapshot,
|
||||
StartupService,
|
||||
StorageService,
|
||||
} from '@common';
|
||||
import { getNewStorageServiceConfig } from '../../../common/data/get-new-storage-service-config';
|
||||
|
||||
@Component({
|
||||
selector: 'app-backups',
|
||||
templateUrl: './backups.component.html',
|
||||
styleUrl: './backups.component.scss',
|
||||
imports: [ConfirmComponent],
|
||||
})
|
||||
export class BackupsComponent implements OnInit {
|
||||
readonly #router = inject(Router);
|
||||
readonly #storage = inject(StorageService);
|
||||
readonly #startup = inject(StartupService);
|
||||
readonly #logger = inject(LoggerService);
|
||||
|
||||
backups: SignerMetaData_VaultSnapshot[] = [];
|
||||
maxBackups = 5;
|
||||
restoringBackupId: string | null = null;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadBackups();
|
||||
this.maxBackups = this.#storage.getSignerMetaHandler().getMaxBackups();
|
||||
}
|
||||
|
||||
loadBackups(): void {
|
||||
this.backups = this.#storage.getSignerMetaHandler().getBackups();
|
||||
}
|
||||
|
||||
async onMaxBackupsChange(event: Event): Promise<void> {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const value = parseInt(input.value, 10);
|
||||
if (!isNaN(value) && value >= 1 && value <= 20) {
|
||||
this.maxBackups = value;
|
||||
await this.#storage.getSignerMetaHandler().setMaxBackups(value);
|
||||
}
|
||||
}
|
||||
|
||||
async createManualBackup(): Promise<void> {
|
||||
const browserSyncData = this.#storage.getBrowserSyncHandler().browserSyncData;
|
||||
if (browserSyncData) {
|
||||
await this.#storage.getSignerMetaHandler().createBackup(browserSyncData, 'manual');
|
||||
this.loadBackups();
|
||||
}
|
||||
}
|
||||
|
||||
async restoreBackup(backupId: string): Promise<void> {
|
||||
this.restoringBackupId = backupId;
|
||||
try {
|
||||
// First, create a pre-restore backup of current state
|
||||
const currentData = this.#storage.getBrowserSyncHandler().browserSyncData;
|
||||
if (currentData) {
|
||||
await this.#storage.getSignerMetaHandler().createBackup(currentData, 'pre-restore');
|
||||
}
|
||||
|
||||
// Get the backup data
|
||||
const backupData = this.#storage.getSignerMetaHandler().getBackupData(backupId);
|
||||
if (!backupData) {
|
||||
throw new Error('Backup not found');
|
||||
}
|
||||
|
||||
// Import the backup
|
||||
await this.#storage.deleteVault(true);
|
||||
await this.#storage.importVault(backupData);
|
||||
this.#logger.logVaultImport('Backup Restore');
|
||||
this.#storage.isInitialized = false;
|
||||
this.#startup.startOver(getNewStorageServiceConfig());
|
||||
} catch (error) {
|
||||
console.error('Failed to restore backup:', error);
|
||||
this.restoringBackupId = null;
|
||||
}
|
||||
}
|
||||
|
||||
async deleteBackup(backupId: string): Promise<void> {
|
||||
await this.#storage.getSignerMetaHandler().deleteBackup(backupId);
|
||||
this.loadBackups();
|
||||
}
|
||||
|
||||
formatDate(isoDate: string): string {
|
||||
const date = new Date(isoDate);
|
||||
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString();
|
||||
}
|
||||
|
||||
getReasonLabel(reason?: string): string {
|
||||
switch (reason) {
|
||||
case 'auto':
|
||||
return 'Auto';
|
||||
case 'manual':
|
||||
return 'Manual';
|
||||
case 'pre-restore':
|
||||
return 'Pre-Restore';
|
||||
default:
|
||||
return 'Unknown';
|
||||
}
|
||||
}
|
||||
|
||||
getReasonClass(reason?: string): string {
|
||||
switch (reason) {
|
||||
case 'auto':
|
||||
return 'reason-auto';
|
||||
case 'manual':
|
||||
return 'reason-manual';
|
||||
case 'pre-restore':
|
||||
return 'reason-prerestore';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
goBack(): void {
|
||||
this.#router.navigateByUrl('/home/settings');
|
||||
}
|
||||
|
||||
async onClickLock(): Promise<void> {
|
||||
this.#logger.logVaultLock();
|
||||
await this.#storage.lockVault();
|
||||
this.#router.navigateByUrl('/vault-login');
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
<!-- eslint-disable @angular-eslint/template/interactive-supports-focus -->
|
||||
<!-- eslint-disable @angular-eslint/template/click-events-have-key-events -->
|
||||
<div class="bookmarks-header">
|
||||
<span class="bookmarks-title">Bookmarks</span>
|
||||
<button class="btn btn-primary btn-sm" (click)="onBookmarkThisPage()">
|
||||
<span class="emoji">🔖</span> Bookmark This Page
|
||||
<div class="sam-text-header">
|
||||
<button class="lock-btn" title="Lock" (click)="onClickLock()">
|
||||
<span class="emoji">🔒</span>
|
||||
</button>
|
||||
<span>Bookmarks</span>
|
||||
<button class="add-btn" title="Bookmark This Page" (click)="onBookmarkThisPage()">
|
||||
<span class="emoji">➕</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -2,21 +2,41 @@
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: var(--size);
|
||||
padding-top: var(--size);
|
||||
padding-bottom: var(--size);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bookmarks-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--size);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
> *:not(.sam-text-header) {
|
||||
margin-left: var(--size);
|
||||
margin-right: var(--size);
|
||||
}
|
||||
|
||||
.bookmarks-title {
|
||||
font-weight: 600;
|
||||
font-size: 1.1rem;
|
||||
.sam-text-header {
|
||||
margin-bottom: var(--size);
|
||||
flex-shrink: 0;
|
||||
|
||||
.add-btn {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 8px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.2s;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--background-light);
|
||||
}
|
||||
|
||||
.emoji {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.bookmarks-container {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Component, inject, OnInit } from '@angular/core';
|
||||
import { Bookmark, LoggerService, SignerMetaData } from '@common';
|
||||
import { Router } from '@angular/router';
|
||||
import { Bookmark, LoggerService, SignerMetaData, StorageService } from '@common';
|
||||
import { ChromeMetaHandler } from '../../../common/data/chrome-meta-handler';
|
||||
|
||||
@Component({
|
||||
@@ -11,6 +12,8 @@ import { ChromeMetaHandler } from '../../../common/data/chrome-meta-handler';
|
||||
export class BookmarksComponent implements OnInit {
|
||||
readonly #logger = inject(LoggerService);
|
||||
readonly #metaHandler = new ChromeMetaHandler();
|
||||
readonly #storage = inject(StorageService);
|
||||
readonly #router = inject(Router);
|
||||
|
||||
bookmarks: Bookmark[] = [];
|
||||
isLoading = true;
|
||||
@@ -87,4 +90,10 @@ export class BookmarksComponent implements OnInit {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
async onClickLock() {
|
||||
this.#logger.logVaultLock();
|
||||
await this.#storage.lockVault();
|
||||
this.#router.navigateByUrl('/vault-login');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,46 +3,23 @@
|
||||
</div>
|
||||
|
||||
<div class="tabs">
|
||||
<a
|
||||
class="tab"
|
||||
routerLink="/home/identity"
|
||||
routerLinkActive="active"
|
||||
title="Your selected identity"
|
||||
>
|
||||
<a class="tab" routerLink="/home/identity" routerLinkActive="active" title="You">
|
||||
<span class="emoji">👤</span>
|
||||
</a>
|
||||
|
||||
<a
|
||||
class="tab"
|
||||
routerLink="/home/identities"
|
||||
routerLinkActive="active"
|
||||
title="Identities"
|
||||
>
|
||||
<a class="tab" routerLink="/home/identities" routerLinkActive="active" title="Identities">
|
||||
<span class="emoji">👥</span>
|
||||
</a>
|
||||
|
||||
<a
|
||||
class="tab"
|
||||
routerLink="/home/settings"
|
||||
routerLinkActive="active"
|
||||
title="Settings"
|
||||
>
|
||||
<span class="emoji">⚙️</span>
|
||||
<a class="tab" routerLink="/home/wallet" routerLinkActive="active" title="Wallet">
|
||||
<span class="emoji">💰</span>
|
||||
</a>
|
||||
|
||||
<a class="tab" routerLink="/home/bookmarks" routerLinkActive="active" title="Bookmarks">
|
||||
<span class="emoji">🔖</span>
|
||||
</a>
|
||||
|
||||
<a class="tab" routerLink="/home/logs" routerLinkActive="active" title="Logs">
|
||||
<span class="emoji">🪵</span>
|
||||
<a class="tab" routerLink="/home/settings" routerLinkActive="active" title="Settings">
|
||||
<span class="emoji">⚙️</span>
|
||||
</a>
|
||||
|
||||
<a class="tab" routerLink="/home/info" routerLinkActive="active" title="Info">
|
||||
<span class="emoji">💡</span>
|
||||
</a>
|
||||
|
||||
<button class="tab" (click)="onClickLock()" title="Lock">
|
||||
<span class="emoji">🔒</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -4,12 +4,12 @@
|
||||
flex-direction: column;
|
||||
|
||||
.tab-content {
|
||||
height: calc(100% - 40px);
|
||||
height: calc(100% - 48px);
|
||||
}
|
||||
|
||||
.tabs {
|
||||
height: 40px;
|
||||
min-height: 40px;
|
||||
height: 48px;
|
||||
min-height: 48px;
|
||||
background: var(--background-light);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
@@ -23,7 +23,6 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
|
||||
color: var(--muted-foreground);
|
||||
border-top: 2px solid transparent;
|
||||
@@ -31,6 +30,10 @@
|
||||
|
||||
cursor: pointer;
|
||||
|
||||
.emoji {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--background-light-hover);
|
||||
color: var(--foreground);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { Router, RouterModule, RouterOutlet } from '@angular/router';
|
||||
import { LoggerService, StorageService } from '@common';
|
||||
import { Component } from '@angular/core';
|
||||
import { RouterModule, RouterOutlet } from '@angular/router';
|
||||
|
||||
@Component({
|
||||
selector: 'app-home',
|
||||
@@ -8,14 +7,4 @@ import { LoggerService, StorageService } from '@common';
|
||||
styleUrl: './home.component.scss',
|
||||
imports: [RouterOutlet, RouterModule],
|
||||
})
|
||||
export class HomeComponent {
|
||||
readonly #storage = inject(StorageService);
|
||||
readonly #router = inject(Router);
|
||||
readonly #logger = inject(LoggerService);
|
||||
|
||||
async onClickLock() {
|
||||
this.#logger.logVaultLock();
|
||||
await this.#storage.lockVault();
|
||||
this.#router.navigateByUrl('/vault-login');
|
||||
}
|
||||
}
|
||||
export class HomeComponent {}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
<!-- eslint-disable @angular-eslint/template/interactive-supports-focus -->
|
||||
<!-- eslint-disable @angular-eslint/template/click-events-have-key-events -->
|
||||
<div class="custom-header" style="position: sticky; top: 0">
|
||||
<button class="lock-btn" title="Lock" (click)="onClickLock()">
|
||||
<span class="emoji">🔒</span>
|
||||
</button>
|
||||
<span class="text">Identities</span>
|
||||
|
||||
<button class="button btn btn-primary btn-sm" (click)="onClickNewIdentity()">
|
||||
<div class="sam-flex-row gap-h">
|
||||
<i class="bi bi-plus-lg"></i>
|
||||
<span>New</span>
|
||||
</div>
|
||||
<button class="add-btn" title="New Identity" (click)="onClickNewIdentity()">
|
||||
<span class="emoji">➕</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -3,37 +3,58 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
padding-left: var(--size);
|
||||
padding-right: var(--size);
|
||||
|
||||
> *:not(.custom-header) {
|
||||
margin-left: var(--size);
|
||||
margin-right: var(--size);
|
||||
}
|
||||
|
||||
.custom-header {
|
||||
padding-top: var(--size);
|
||||
padding-bottom: var(--size);
|
||||
height: 48px;
|
||||
min-height: 48px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: auto;
|
||||
align-items: center;
|
||||
background: var(--background);
|
||||
position: relative;
|
||||
|
||||
.button {
|
||||
grid-column-start: 1;
|
||||
grid-column-end: 2;
|
||||
grid-row-start: 1;
|
||||
grid-row-end: 2;
|
||||
justify-self: end;
|
||||
.lock-btn,
|
||||
.add-btn {
|
||||
position: absolute;
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 8px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.2s;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--background-light);
|
||||
}
|
||||
|
||||
.emoji {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.lock-btn {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.add-btn {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.text {
|
||||
grid-column-start: 1;
|
||||
grid-column-end: 2;
|
||||
grid-row-start: 1;
|
||||
grid-row-end: 2;
|
||||
font-family: var(--font-heading);
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1rem;
|
||||
justify-self: center;
|
||||
height: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Router } from '@angular/router';
|
||||
import {
|
||||
IconButtonComponent,
|
||||
Identity_DECRYPTED,
|
||||
LoggerService,
|
||||
NostrHelper,
|
||||
ProfileMetadata,
|
||||
ProfileMetadataService,
|
||||
@@ -20,6 +21,7 @@ export class IdentitiesComponent implements OnInit {
|
||||
readonly storage = inject(StorageService);
|
||||
readonly #router = inject(Router);
|
||||
readonly #profileMetadata = inject(ProfileMetadataService);
|
||||
readonly #logger = inject(LoggerService);
|
||||
|
||||
// Cache of pubkey -> profile for quick lookup
|
||||
#profileCache = new Map<string, ProfileMetadata | null>();
|
||||
@@ -73,4 +75,10 @@ export class IdentitiesComponent implements OnInit {
|
||||
onClickWhitelistedApps() {
|
||||
this.#router.navigateByUrl('/whitelisted-apps');
|
||||
}
|
||||
|
||||
async onClickLock() {
|
||||
this.#logger.logVaultLock();
|
||||
await this.storage.lockVault();
|
||||
this.#router.navigateByUrl('/vault-login');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
<!-- eslint-disable @angular-eslint/template/interactive-supports-focus -->
|
||||
<!-- eslint-disable @angular-eslint/template/click-events-have-key-events -->
|
||||
<div class="sam-text-header">
|
||||
<button class="lock-btn" title="Lock" (click)="onClickLock()">
|
||||
<span class="emoji">🔒</span>
|
||||
</button>
|
||||
<span>You</span>
|
||||
<button class="edit-btn" title="Edit profile" (click)="onClickEditProfile()">
|
||||
<img src="edit.svg" alt="Edit" class="edit-icon" />
|
||||
<span class="emoji">📝</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -70,4 +73,12 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- About section -->
|
||||
@if (aboutText) {
|
||||
<div class="about-section">
|
||||
<div class="about-header">About</div>
|
||||
<div class="about-content">{{ aboutText }}</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<lib-toast #toast></lib-toast>
|
||||
|
||||
@@ -4,14 +4,12 @@
|
||||
flex-direction: column;
|
||||
|
||||
.sam-text-header {
|
||||
position: relative;
|
||||
|
||||
.edit-btn {
|
||||
position: absolute;
|
||||
right: var(--size);
|
||||
right: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 4px;
|
||||
padding: 8px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -23,10 +21,8 @@
|
||||
background-color: var(--background-light);
|
||||
}
|
||||
|
||||
.edit-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
filter: brightness(0) invert(1);
|
||||
.emoji {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -189,4 +185,33 @@
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.about-section {
|
||||
margin: var(--size);
|
||||
margin-top: 0;
|
||||
flex-shrink: 0;
|
||||
max-height: 150px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.about-header {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--muted-foreground);
|
||||
margin-bottom: var(--size-h);
|
||||
}
|
||||
|
||||
.about-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
color: var(--foreground);
|
||||
background: var(--background-light);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: var(--size);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +52,10 @@ export class IdentityComponent implements OnInit {
|
||||
return this.profile?.banner;
|
||||
}
|
||||
|
||||
get aboutText(): string | undefined {
|
||||
return this.profile?.about;
|
||||
}
|
||||
|
||||
copyToClipboard(pubkey: string | undefined) {
|
||||
if (!pubkey) {
|
||||
return;
|
||||
@@ -76,6 +80,12 @@ export class IdentityComponent implements OnInit {
|
||||
this.#router.navigateByUrl('/profile-edit');
|
||||
}
|
||||
|
||||
async onClickLock() {
|
||||
this.#logger.logVaultLock();
|
||||
await this.#storage.lockVault();
|
||||
this.#router.navigateByUrl('/vault-login');
|
||||
}
|
||||
|
||||
async #loadData() {
|
||||
try {
|
||||
const selectedIdentityId =
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
<div class="sam-text-header">
|
||||
<button class="lock-btn" title="Lock" (click)="onClickLock()">
|
||||
<span class="emoji">🔒</span>
|
||||
</button>
|
||||
<span> Plebeian Signer </span>
|
||||
</div>
|
||||
|
||||
@@ -8,9 +11,9 @@
|
||||
|
||||
<span> Source code</span>
|
||||
<a
|
||||
href="https://git.mleku.dev/mleku/plebeian-signer"
|
||||
href="https://github.com/PlebeianApp/plebeian-signer"
|
||||
target="_blank"
|
||||
>
|
||||
git.mleku.dev/mleku/plebeian-signer
|
||||
github.com/PlebeianApp/plebeian-signer
|
||||
</a>
|
||||
|
||||
|
||||
@@ -4,6 +4,13 @@
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
overflow-y: auto;
|
||||
padding-left: var(--size);
|
||||
padding-right: var(--size);
|
||||
|
||||
> *:not(.sam-text-header) {
|
||||
margin-left: var(--size);
|
||||
margin-right: var(--size);
|
||||
}
|
||||
|
||||
.sam-text-header {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { LoggerService, StorageService } from '@common';
|
||||
import packageJson from '../../../../../../../package.json';
|
||||
|
||||
@Component({
|
||||
@@ -7,5 +9,15 @@ import packageJson from '../../../../../../../package.json';
|
||||
styleUrl: './info.component.scss',
|
||||
})
|
||||
export class InfoComponent {
|
||||
readonly #logger = inject(LoggerService);
|
||||
readonly #storage = inject(StorageService);
|
||||
readonly #router = inject(Router);
|
||||
|
||||
version = packageJson.custom.chrome.version;
|
||||
|
||||
async onClickLock() {
|
||||
this.#logger.logVaultLock();
|
||||
await this.#storage.lockVault();
|
||||
this.#router.navigateByUrl('/vault-login');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
<div class="logs-header">
|
||||
<span class="logs-title">Logs</span>
|
||||
<div class="sam-text-header">
|
||||
<button class="lock-btn" title="Lock" (click)="onClickLock()">
|
||||
<span class="emoji">🔒</span>
|
||||
</button>
|
||||
<span>Logs</span>
|
||||
<div class="logs-actions">
|
||||
<button class="btn btn-sm btn-secondary" (click)="onRefresh()">Refresh</button>
|
||||
<button class="btn btn-sm btn-secondary" (click)="onClear()">Clear</button>
|
||||
<button class="btn btn-sm btn-secondary" title="Refresh logs" (click)="onRefresh()">Refresh</button>
|
||||
<button class="btn btn-sm btn-secondary" title="Clear logs" (click)="onClear()">Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -2,26 +2,26 @@
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: var(--size);
|
||||
padding-top: var(--size);
|
||||
padding-bottom: var(--size);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.logs-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--size);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
> *:not(.sam-text-header) {
|
||||
margin-left: var(--size);
|
||||
margin-right: var(--size);
|
||||
}
|
||||
|
||||
.logs-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.sam-text-header {
|
||||
margin-bottom: var(--size);
|
||||
flex-shrink: 0;
|
||||
|
||||
.logs-title {
|
||||
font-weight: 600;
|
||||
font-size: 1.1rem;
|
||||
.logs-actions {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.logs-container {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Component, inject, OnInit } from '@angular/core';
|
||||
import { LoggerService, LogEntry } from '@common';
|
||||
import { Router } from '@angular/router';
|
||||
import { LoggerService, LogEntry, StorageService } from '@common';
|
||||
import { DatePipe } from '@angular/common';
|
||||
|
||||
@Component({
|
||||
@@ -10,6 +11,8 @@ import { DatePipe } from '@angular/common';
|
||||
})
|
||||
export class LogsComponent implements OnInit {
|
||||
readonly #logger = inject(LoggerService);
|
||||
readonly #storage = inject(StorageService);
|
||||
readonly #router = inject(Router);
|
||||
|
||||
get logs(): LogEntry[] {
|
||||
return this.#logger.logs;
|
||||
@@ -40,4 +43,10 @@ export class LogsComponent implements OnInit {
|
||||
return 'log-info';
|
||||
}
|
||||
}
|
||||
|
||||
async onClickLock() {
|
||||
this.#logger.logVaultLock();
|
||||
await this.#storage.lockVault();
|
||||
this.#router.navigateByUrl('/vault-login');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
<div class="sam-text-header">
|
||||
<button class="lock-btn" title="Lock" (click)="onClickLock()">
|
||||
<span class="emoji">🔒</span>
|
||||
</button>
|
||||
<span> Settings </span>
|
||||
</div>
|
||||
|
||||
<span>SYNC: {{ syncFlow }}</span>
|
||||
<div class="sync-info">
|
||||
<span class="sync-label">SYNC: {{ syncFlow }}</span>
|
||||
<p class="sync-note">
|
||||
To change sync mode, export your vault, reset the extension,
|
||||
and re-import with the desired sync setting.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary" (click)="onClickExportVault()">
|
||||
Export Vault
|
||||
@@ -12,6 +21,10 @@
|
||||
Import Vault
|
||||
</button>
|
||||
|
||||
<lib-nav-item text="💾 Backups" (click)="navigate('/home/backups')"></lib-nav-item>
|
||||
<lib-nav-item text="🪵 Logs" (click)="navigate('/home/logs')"></lib-nav-item>
|
||||
<lib-nav-item text="💡 Info" (click)="navigate('/home/info')"></lib-nav-item>
|
||||
|
||||
<div class="sam-flex-grow"></div>
|
||||
|
||||
<button
|
||||
|
||||
@@ -4,11 +4,28 @@
|
||||
flex-direction: column;
|
||||
row-gap: var(--size);
|
||||
overflow-y: auto;
|
||||
padding-left: var(--size);
|
||||
padding-right: var(--size);
|
||||
|
||||
> *:not(.sam-text-header) {
|
||||
margin-left: var(--size);
|
||||
margin-right: var(--size);
|
||||
}
|
||||
|
||||
.file-input {
|
||||
position: absolute;
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.sync-info {
|
||||
.sync-label {
|
||||
display: block;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.sync-note {
|
||||
margin: var(--size-h) 0 0 0;
|
||||
font-size: 0.85rem;
|
||||
color: var(--muted-foreground);
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Component, inject, OnInit } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import {
|
||||
BrowserSyncData,
|
||||
BrowserSyncFlow,
|
||||
@@ -6,6 +7,7 @@ import {
|
||||
DateHelper,
|
||||
LoggerService,
|
||||
NavComponent,
|
||||
NavItemComponent,
|
||||
StartupService,
|
||||
StorageService,
|
||||
} from '@common';
|
||||
@@ -13,11 +15,12 @@ import { getNewStorageServiceConfig } from '../../../common/data/get-new-storage
|
||||
|
||||
@Component({
|
||||
selector: 'app-settings',
|
||||
imports: [ConfirmComponent],
|
||||
imports: [ConfirmComponent, NavItemComponent],
|
||||
templateUrl: './settings.component.html',
|
||||
styleUrl: './settings.component.scss',
|
||||
})
|
||||
export class SettingsComponent extends NavComponent implements OnInit {
|
||||
readonly #router = inject(Router);
|
||||
syncFlow: string | undefined;
|
||||
|
||||
readonly #storage = inject(StorageService);
|
||||
@@ -101,4 +104,10 @@ export class SettingsComponent extends NavComponent implements OnInit {
|
||||
downloadAnchorNode.click();
|
||||
downloadAnchorNode.remove();
|
||||
}
|
||||
|
||||
async onClickLock() {
|
||||
this.#logger.logVaultLock();
|
||||
await this.#storage.lockVault();
|
||||
this.#router.navigateByUrl('/vault-login');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,653 @@
|
||||
<div class="sam-text-header">
|
||||
<button class="lock-btn" title="Lock" (click)="onClickLock()">
|
||||
<span class="emoji">🔒</span>
|
||||
</button>
|
||||
@if (showBackButton) {
|
||||
<button class="back-btn" title="Go Back" (click)="goBack()">
|
||||
<span class="emoji">←</span>
|
||||
</button>
|
||||
}
|
||||
<span>{{ title }}</span>
|
||||
<div class="section-btns">
|
||||
<button
|
||||
class="section-btn"
|
||||
[class.active]="activeSection.startsWith('cashu')"
|
||||
title="Cashu"
|
||||
(click)="setSection('cashu')"
|
||||
>
|
||||
<span class="emoji">🥜</span>
|
||||
</button>
|
||||
<button
|
||||
class="section-btn"
|
||||
[class.active]="activeSection.startsWith('lightning')"
|
||||
title="Lightning"
|
||||
(click)="setSection('lightning')"
|
||||
>
|
||||
<span class="emoji">⚡</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="wallet-container">
|
||||
<!-- Main wallet menu -->
|
||||
@if (activeSection === 'main') {
|
||||
<div class="wallet-menu">
|
||||
<button class="wallet-menu-item" (click)="setSection('cashu')">
|
||||
<span class="emoji">🥜</span>
|
||||
<span class="label">Cashu</span>
|
||||
<span class="balance">{{ formatCashuBalance(totalCashuBalance) }} sats</span>
|
||||
</button>
|
||||
<button class="wallet-menu-item" (click)="setSection('lightning')">
|
||||
<span class="emoji">⚡</span>
|
||||
<span class="label">Lightning</span>
|
||||
<span class="balance">{{ formatBalance(totalLightningBalance) }} sats</span>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Cashu mint list -->
|
||||
@else if (activeSection === 'cashu') {
|
||||
<div class="lightning-section">
|
||||
@if (mints.length === 0) {
|
||||
<div class="cashu-onboarding">
|
||||
@if (showCashuInfo) {
|
||||
<div class="info-panel">
|
||||
<h3>Welcome to Cashu Wallet</h3>
|
||||
|
||||
<div class="info-section">
|
||||
<h4>Storage Considerations</h4>
|
||||
@if (currentSyncFlow === BrowserSyncFlow.BROWSER_SYNC) {
|
||||
<div class="warning-box">
|
||||
<p><strong>Browser Sync is enabled</strong></p>
|
||||
<p>
|
||||
Sync storage is limited to ~100KB shared across all your vault data
|
||||
(identities, permissions, relays, and Cashu tokens). This limits
|
||||
your Cashu wallet to approximately 300-400 tokens.
|
||||
</p>
|
||||
<p>
|
||||
For larger Cashu holdings, consider disabling browser sync which
|
||||
provides ~5MB of local storage (~18,000+ tokens).
|
||||
</p>
|
||||
<button class="link-btn" (click)="navigateToSettings()">
|
||||
Change Sync Settings
|
||||
</button>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="success-box">
|
||||
<p><strong>Local Storage Mode</strong></p>
|
||||
<p>
|
||||
You have ~5MB of local storage available, which can hold
|
||||
thousands of Cashu tokens. Your data stays on this device only.
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="info-section">
|
||||
<h4>Backup Your Wallet</h4>
|
||||
<p>
|
||||
<strong>Important:</strong> Cashu tokens are bearer assets.
|
||||
If you lose your vault backup, you lose your tokens permanently.
|
||||
</p>
|
||||
<p>
|
||||
Vault exports are saved to your browser's downloads folder.
|
||||
Configure this to point to either:
|
||||
</p>
|
||||
<ul>
|
||||
<li>Your backup storage device (external drive, NAS)</li>
|
||||
<li>A folder synced by your backup tool (Syncthing, rsync, etc.)</li>
|
||||
</ul>
|
||||
<p class="browser-url">
|
||||
<code>{{ browserDownloadSettingsUrl }}</code>
|
||||
</p>
|
||||
<button class="link-btn" (click)="navigateToSettings()">
|
||||
Go to Backup Settings
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button class="dismiss-btn" (click)="dismissCashuInfo()">
|
||||
Got it, let me add a mint
|
||||
</button>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="empty-state">
|
||||
<span class="sam-text-muted">No mints connected yet.</span>
|
||||
<button class="show-info-btn" (click)="showCashuInfo = true">
|
||||
Show storage info
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<div class="wallet-list">
|
||||
@for (mint of mints; track mint.id) {
|
||||
<button class="wallet-list-item" (click)="selectMint(mint.id)">
|
||||
<span class="wallet-name">{{ mint.name }}</span>
|
||||
<span class="wallet-balance">{{ formatCashuBalance(mint.cachedBalance) }} sats</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<button class="add-wallet-btn" (click)="showAddMint()">
|
||||
<span class="emoji">+</span>
|
||||
<span>Add Mint</span>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Cashu mint detail -->
|
||||
@else if (activeSection === 'cashu-detail' && selectedMint) {
|
||||
<div class="wallet-detail">
|
||||
<div class="balance-row">
|
||||
<div class="balance-display compact">
|
||||
<span class="balance-value">{{ formatCashuBalance(selectedMintBalance) }}</span>
|
||||
<span class="balance-unit">sats</span>
|
||||
</div>
|
||||
<button
|
||||
class="refresh-icon-btn"
|
||||
(click)="refreshMint()"
|
||||
[disabled]="refreshingMint"
|
||||
title="Refresh"
|
||||
>
|
||||
<span class="emoji" [class.spinning]="refreshingMint">🔄</span>
|
||||
</button>
|
||||
</div>
|
||||
@if (refreshError) {
|
||||
<div class="error-message small">{{ refreshError }}</div>
|
||||
}
|
||||
<div class="action-buttons">
|
||||
<button class="action-btn deposit-btn" (click)="showDeposit()">
|
||||
Deposit
|
||||
</button>
|
||||
<button class="action-btn receive-btn" (click)="showReceive()">
|
||||
Receive
|
||||
</button>
|
||||
<button class="action-btn send-btn" (click)="showSend()" [disabled]="selectedMintBalance === 0">
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Token viewer section -->
|
||||
<div class="token-section">
|
||||
<div class="section-title">Tokens ({{ selectedMintProofs.length }})</div>
|
||||
@if (selectedMintProofs.length === 0) {
|
||||
<div class="empty-text">No tokens stored</div>
|
||||
} @else {
|
||||
<div class="token-list">
|
||||
@for (proof of selectedMintProofs; track proof.secret) {
|
||||
<div class="token-item">
|
||||
<span class="token-amount">{{ proof.amount }}</span>
|
||||
<span class="token-time">{{ formatProofTime(proof.receivedAt) }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="wallet-info">
|
||||
<div class="info-row">
|
||||
<span class="info-label">Mint URL</span>
|
||||
<span class="info-value">{{ selectedMint.mintUrl }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Unit</span>
|
||||
<span class="info-value">{{ selectedMint.unit }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="delete-btn" (click)="deleteMint()">
|
||||
Delete Mint
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Cashu add mint form -->
|
||||
@else if (activeSection === 'cashu-add') {
|
||||
<div class="add-wallet-form">
|
||||
<div class="form-group">
|
||||
<label for="mintName">Mint Name</label>
|
||||
<input
|
||||
id="mintName"
|
||||
type="text"
|
||||
[(ngModel)]="newMintName"
|
||||
placeholder="My Mint"
|
||||
[disabled]="addingMint"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="mintUrl">Mint URL</label>
|
||||
<input
|
||||
id="mintUrl"
|
||||
type="text"
|
||||
[(ngModel)]="newMintUrl"
|
||||
placeholder="https://mint.example.com"
|
||||
[disabled]="addingMint"
|
||||
/>
|
||||
</div>
|
||||
@if (mintError) {
|
||||
<div class="error-message">{{ mintError }}</div>
|
||||
}
|
||||
@if (mintTestResult) {
|
||||
<div class="success-message">{{ mintTestResult }}</div>
|
||||
}
|
||||
<div class="form-actions">
|
||||
<button
|
||||
class="test-btn"
|
||||
(click)="testMint()"
|
||||
[disabled]="testingMint || addingMint"
|
||||
>
|
||||
{{ testingMint ? 'Testing...' : 'Test Connection' }}
|
||||
</button>
|
||||
<button
|
||||
class="add-btn"
|
||||
(click)="addMint()"
|
||||
[disabled]="addingMint"
|
||||
>
|
||||
{{ addingMint ? 'Adding...' : 'Add Mint' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Cashu receive token -->
|
||||
@else if (activeSection === 'cashu-receive') {
|
||||
<div class="add-wallet-form">
|
||||
<div class="form-group">
|
||||
<label for="receiveToken">Paste Cashu Token</label>
|
||||
<textarea
|
||||
id="receiveToken"
|
||||
[(ngModel)]="receiveToken"
|
||||
placeholder="cashuB..."
|
||||
rows="5"
|
||||
[disabled]="receivingToken"
|
||||
></textarea>
|
||||
</div>
|
||||
@if (receiveError) {
|
||||
<div class="error-message">{{ receiveError }}</div>
|
||||
}
|
||||
@if (receiveResult) {
|
||||
<div class="success-message">{{ receiveResult }}</div>
|
||||
}
|
||||
<div class="form-actions">
|
||||
<button
|
||||
class="add-btn full-width"
|
||||
(click)="receiveTokens()"
|
||||
[disabled]="receivingToken"
|
||||
>
|
||||
{{ receivingToken ? 'Receiving...' : 'Receive Tokens' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Cashu send token -->
|
||||
@else if (activeSection === 'cashu-send') {
|
||||
<div class="add-wallet-form">
|
||||
<div class="balance-info">
|
||||
Available: {{ formatCashuBalance(selectedMintBalance) }} sats
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="sendAmount">Amount (sats)</label>
|
||||
<input
|
||||
id="sendAmount"
|
||||
type="number"
|
||||
[(ngModel)]="sendAmount"
|
||||
placeholder="0"
|
||||
min="1"
|
||||
[max]="selectedMintBalance"
|
||||
[disabled]="sendingToken"
|
||||
/>
|
||||
</div>
|
||||
@if (sendError) {
|
||||
<div class="error-message">{{ sendError }}</div>
|
||||
}
|
||||
@if (sendResult) {
|
||||
<div class="token-result">
|
||||
<span class="token-label">Token to Share</span>
|
||||
<textarea readonly rows="4">{{ sendResult }}</textarea>
|
||||
<button class="copy-btn" (click)="copyToken()">
|
||||
Copy Token
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
@if (!sendResult) {
|
||||
<div class="form-actions">
|
||||
<button
|
||||
class="add-btn full-width"
|
||||
(click)="sendTokens()"
|
||||
[disabled]="sendingToken || sendAmount <= 0"
|
||||
>
|
||||
{{ sendingToken ? 'Creating...' : 'Create Token' }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Cashu deposit (mint via Lightning) -->
|
||||
@else if (activeSection === 'cashu-mint' && selectedMint) {
|
||||
<div class="add-wallet-form">
|
||||
@if (!depositInvoice) {
|
||||
<div class="form-group">
|
||||
<label for="depositAmount">Amount (sats)</label>
|
||||
<input
|
||||
id="depositAmount"
|
||||
type="number"
|
||||
[(ngModel)]="depositAmount"
|
||||
placeholder="1000"
|
||||
min="1"
|
||||
[disabled]="creatingDepositQuote"
|
||||
/>
|
||||
</div>
|
||||
@if (depositError) {
|
||||
<div class="error-message">{{ depositError }}</div>
|
||||
}
|
||||
<div class="form-actions">
|
||||
<button
|
||||
class="add-btn full-width"
|
||||
(click)="createDepositInvoice()"
|
||||
[disabled]="creatingDepositQuote || depositAmount <= 0"
|
||||
>
|
||||
{{ creatingDepositQuote ? 'Creating...' : 'Create Invoice' }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
@if (depositInvoice) {
|
||||
<div class="invoice-result">
|
||||
@if (depositInvoiceQr) {
|
||||
<img [src]="depositInvoiceQr" alt="Invoice QR Code" class="qr-code" />
|
||||
}
|
||||
<div class="deposit-status">
|
||||
@if (depositQuoteState === 'UNPAID') {
|
||||
<span class="status-waiting">Waiting for payment...</span>
|
||||
@if (checkingDepositPayment) {
|
||||
<span class="status-checking">checking</span>
|
||||
}
|
||||
} @else if (depositQuoteState === 'PAID') {
|
||||
<span class="status-paid">Payment received! Claiming tokens...</span>
|
||||
} @else if (depositQuoteState === 'ISSUED') {
|
||||
<span class="status-issued">✓ Tokens received!</span>
|
||||
}
|
||||
</div>
|
||||
@if (depositError) {
|
||||
<div class="error-message">{{ depositError }}</div>
|
||||
}
|
||||
@if (depositSuccess) {
|
||||
<div class="success-message">{{ depositSuccess }}</div>
|
||||
}
|
||||
@if (depositQuoteState === 'UNPAID') {
|
||||
<div class="invoice-text">{{ depositInvoice }}</div>
|
||||
<button class="copy-btn" (click)="copyDepositInvoice()">
|
||||
Copy Invoice
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Lightning wallet list -->
|
||||
@else if (activeSection === 'lightning') {
|
||||
<div class="lightning-section">
|
||||
@if (connections.length === 0) {
|
||||
<div class="empty-state">
|
||||
<span class="sam-text-muted">
|
||||
No wallets connected yet.
|
||||
</span>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="wallet-list">
|
||||
@for (conn of connections; track conn.id) {
|
||||
<button class="wallet-list-item" (click)="selectConnection(conn.id)">
|
||||
<span class="wallet-name">{{ conn.name }}</span>
|
||||
<span class="wallet-balance">{{ formatBalance(conn.cachedBalance) }} sats</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<button class="add-wallet-btn" (click)="showAddConnection()">
|
||||
<span class="emoji">+</span>
|
||||
<span>Add NWC Connection</span>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Lightning wallet detail -->
|
||||
@else if (activeSection === 'lightning-detail' && selectedConnection) {
|
||||
<div class="wallet-detail">
|
||||
<div class="balance-row">
|
||||
<div class="balance-display compact">
|
||||
<span class="balance-value">{{ formatBalance(selectedConnection.cachedBalance) }}</span>
|
||||
<span class="balance-unit">sats</span>
|
||||
</div>
|
||||
<button class="refresh-icon-btn" (click)="refreshWallet()" title="Refresh">
|
||||
<span class="emoji">🔄</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="action-buttons">
|
||||
<button class="action-btn receive-btn" (click)="showLnReceive()">
|
||||
Receive
|
||||
</button>
|
||||
<button class="action-btn send-btn" (click)="showLnPay()">
|
||||
Pay
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="wallet-info">
|
||||
<div class="info-row">
|
||||
<span class="info-label">Relay</span>
|
||||
<span class="info-value">{{ selectedConnection.relayUrl }}</span>
|
||||
</div>
|
||||
@if (selectedConnection.lud16) {
|
||||
<button class="info-row-btn" (click)="copyLightningAddress()">
|
||||
<span class="info-label">Lightning Address</span>
|
||||
<span class="info-value">
|
||||
{{ selectedConnection.lud16 }}
|
||||
<span class="copy-hint">{{ addressCopied ? '✓ Copied' : '(tap to copy)' }}</span>
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Transaction History -->
|
||||
<div class="transaction-section">
|
||||
<div class="section-title">Transactions</div>
|
||||
@if (loadingTransactions) {
|
||||
<div class="loading-text">Loading...</div>
|
||||
} @else if (transactionsNotSupported) {
|
||||
<div class="not-supported-text">Transaction history not supported by this wallet</div>
|
||||
} @else if (transactionsError) {
|
||||
<div class="error-text">{{ transactionsError }}</div>
|
||||
} @else if (transactions.length === 0) {
|
||||
<div class="empty-text">No transactions yet</div>
|
||||
} @else {
|
||||
<div class="transaction-list">
|
||||
@for (tx of transactions; track tx.payment_hash) {
|
||||
<div class="transaction-item" [class.incoming]="tx.type === 'incoming'" [class.outgoing]="tx.type === 'outgoing'">
|
||||
<span class="tx-icon">{{ tx.type === 'incoming' ? '⬇' : '⬆' }}</span>
|
||||
<span class="tx-type">{{ tx.type === 'incoming' ? 'Received' : 'Sent' }}</span>
|
||||
<span class="tx-amount">{{ formatBalance(tx.amount) }}</span>
|
||||
<span class="tx-time">{{ formatTransactionTime(tx.created_at) }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<button class="delete-btn-small" (click)="deleteConnection()">
|
||||
Delete Wallet
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Lightning receive invoice -->
|
||||
@else if (activeSection === 'lightning-receive' && selectedConnection) {
|
||||
<div class="add-wallet-form">
|
||||
<div class="form-group">
|
||||
<label for="lnReceiveAmount">Amount (sats)</label>
|
||||
<input
|
||||
id="lnReceiveAmount"
|
||||
type="number"
|
||||
[(ngModel)]="lnReceiveAmount"
|
||||
placeholder="1000"
|
||||
min="1"
|
||||
[disabled]="generatingInvoice"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="lnReceiveDescription">Description (optional)</label>
|
||||
<input
|
||||
id="lnReceiveDescription"
|
||||
type="text"
|
||||
[(ngModel)]="lnReceiveDescription"
|
||||
placeholder="Payment for..."
|
||||
[disabled]="generatingInvoice"
|
||||
/>
|
||||
</div>
|
||||
@if (lnReceiveError) {
|
||||
<div class="error-message">{{ lnReceiveError }}</div>
|
||||
}
|
||||
@if (!generatedInvoice) {
|
||||
<div class="form-actions">
|
||||
<button
|
||||
class="add-btn full-width"
|
||||
(click)="createReceiveInvoice()"
|
||||
[disabled]="generatingInvoice || lnReceiveAmount <= 0"
|
||||
>
|
||||
{{ generatingInvoice ? 'Generating...' : 'Generate Invoice' }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
@if (generatedInvoice) {
|
||||
<div class="invoice-result">
|
||||
@if (generatedInvoiceQr) {
|
||||
<img [src]="generatedInvoiceQr" alt="Invoice QR Code" class="qr-code" />
|
||||
}
|
||||
<div class="invoice-text">{{ generatedInvoice }}</div>
|
||||
<button class="copy-btn" (click)="copyInvoice()">
|
||||
{{ invoiceCopied ? 'Copied!' : 'Copy Invoice' }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Pay Modal Overlay -->
|
||||
@if (showPayModal && selectedConnection) {
|
||||
<div class="modal-overlay" role="dialog" aria-modal="true" tabindex="-1" (click)="closePayModal()" (keydown.escape)="closePayModal()">
|
||||
<div class="modal-content" role="document" (click)="$event.stopPropagation()" (keydown)="$event.stopPropagation()">
|
||||
<div class="modal-header">
|
||||
<span>Pay Invoice</span>
|
||||
<button class="modal-close" (click)="closePayModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label for="payInput">Lightning Address or Invoice</label>
|
||||
<textarea
|
||||
id="payInput"
|
||||
[(ngModel)]="payInput"
|
||||
placeholder="user@domain.com or lnbc1..."
|
||||
rows="3"
|
||||
[disabled]="paying"
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="payAmount">Amount (sats) - required for addresses</label>
|
||||
<input
|
||||
id="payAmount"
|
||||
type="number"
|
||||
[(ngModel)]="payAmount"
|
||||
placeholder="Optional for invoices"
|
||||
min="1"
|
||||
[disabled]="paying"
|
||||
/>
|
||||
</div>
|
||||
@if (paymentError) {
|
||||
<div class="error-message">{{ paymentError }}</div>
|
||||
}
|
||||
@if (paymentSuccess) {
|
||||
<div class="success-message payment-success">Payment Successful!</div>
|
||||
}
|
||||
@if (!paymentSuccess) {
|
||||
<div class="form-actions">
|
||||
<button class="test-btn" (click)="closePayModal()" [disabled]="paying">
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
class="add-btn"
|
||||
(click)="payInvoiceOrAddress()"
|
||||
[disabled]="paying || !payInput.trim()"
|
||||
>
|
||||
{{ paying ? 'Paying...' : 'Pay' }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Add wallet form -->
|
||||
@else if (activeSection === 'lightning-add') {
|
||||
<div class="add-wallet-form">
|
||||
<div class="form-group">
|
||||
<label for="walletName">Wallet Name</label>
|
||||
<input
|
||||
id="walletName"
|
||||
type="text"
|
||||
[(ngModel)]="newWalletName"
|
||||
placeholder="My Lightning Wallet"
|
||||
[disabled]="addingConnection"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="walletUrl">NWC Connection URL</label>
|
||||
<textarea
|
||||
id="walletUrl"
|
||||
[(ngModel)]="newWalletUrl"
|
||||
placeholder="nostr+walletconnect://..."
|
||||
rows="3"
|
||||
[disabled]="addingConnection"
|
||||
></textarea>
|
||||
</div>
|
||||
@if (connectionError) {
|
||||
<div class="error-message">{{ connectionError }}</div>
|
||||
}
|
||||
@if (connectionTestResult) {
|
||||
<div class="success-message">{{ connectionTestResult }}</div>
|
||||
}
|
||||
@if (nwcService.logs.length > 0) {
|
||||
<div class="nwc-log">
|
||||
<div class="log-header">
|
||||
<span>Connection Log</span>
|
||||
<button class="log-clear-btn" (click)="nwcService.clearLogs()">Clear</button>
|
||||
</div>
|
||||
<div class="log-entries">
|
||||
@for (entry of nwcService.logs; track entry.timestamp) {
|
||||
<div class="log-entry" [class.log-warn]="entry.level === 'warn'" [class.log-error]="entry.level === 'error'">
|
||||
<span class="log-time">{{ entry.timestamp | date:'HH:mm:ss' }}</span>
|
||||
<span class="log-message">{{ entry.message }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<div class="form-actions">
|
||||
<button
|
||||
class="test-btn"
|
||||
(click)="testConnection()"
|
||||
[disabled]="testingConnection || addingConnection"
|
||||
>
|
||||
{{ testingConnection ? 'Testing...' : 'Test Connection' }}
|
||||
</button>
|
||||
<button
|
||||
class="add-btn"
|
||||
(click)="addConnection()"
|
||||
[disabled]="addingConnection"
|
||||
>
|
||||
{{ addingConnection ? 'Adding...' : 'Add Wallet' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
1136
projects/chrome/src/app/components/home/wallet/wallet.component.scss
Normal file
1136
projects/chrome/src/app/components/home/wallet/wallet.component.scss
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,952 @@
|
||||
import { Component, inject, OnInit, OnDestroy } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
LoggerService,
|
||||
StorageService,
|
||||
NwcService,
|
||||
NwcConnection_DECRYPTED,
|
||||
CashuService,
|
||||
CashuMint_DECRYPTED,
|
||||
CashuProof,
|
||||
NwcLookupInvoiceResult,
|
||||
BrowserSyncFlow,
|
||||
} from '@common';
|
||||
import * as QRCode from 'qrcode';
|
||||
|
||||
type WalletSection =
|
||||
| 'main'
|
||||
| 'cashu'
|
||||
| 'cashu-detail'
|
||||
| 'cashu-add'
|
||||
| 'cashu-receive'
|
||||
| 'cashu-send'
|
||||
| 'cashu-mint'
|
||||
| 'lightning'
|
||||
| 'lightning-detail'
|
||||
| 'lightning-add'
|
||||
| 'lightning-receive'
|
||||
| 'lightning-pay';
|
||||
|
||||
@Component({
|
||||
selector: 'app-wallet',
|
||||
templateUrl: './wallet.component.html',
|
||||
styleUrl: './wallet.component.scss',
|
||||
imports: [CommonModule, FormsModule],
|
||||
})
|
||||
export class WalletComponent implements OnInit, OnDestroy {
|
||||
readonly #logger = inject(LoggerService);
|
||||
readonly #storage = inject(StorageService);
|
||||
readonly #router = inject(Router);
|
||||
readonly nwcService = inject(NwcService);
|
||||
readonly cashuService = inject(CashuService);
|
||||
|
||||
activeSection: WalletSection = 'main';
|
||||
selectedConnectionId: string | null = null;
|
||||
selectedMintId: string | null = null;
|
||||
|
||||
// Form fields for adding new NWC connection
|
||||
newWalletName = '';
|
||||
newWalletUrl = '';
|
||||
addingConnection = false;
|
||||
testingConnection = false;
|
||||
connectionError = '';
|
||||
connectionTestResult = '';
|
||||
|
||||
// Form fields for adding new Cashu mint
|
||||
newMintName = '';
|
||||
newMintUrl = '';
|
||||
addingMint = false;
|
||||
testingMint = false;
|
||||
mintError = '';
|
||||
mintTestResult = '';
|
||||
|
||||
// Cashu receive/send fields
|
||||
receiveToken = '';
|
||||
receivingToken = false;
|
||||
receiveError = '';
|
||||
receiveResult = '';
|
||||
sendAmount = 0;
|
||||
sendingToken = false;
|
||||
sendError = '';
|
||||
sendResult = '';
|
||||
|
||||
// Cashu mint (deposit) fields
|
||||
depositAmount = 0;
|
||||
creatingDepositQuote = false;
|
||||
depositQuoteId = '';
|
||||
depositInvoice = '';
|
||||
depositInvoiceQr = '';
|
||||
depositError = '';
|
||||
depositSuccess = '';
|
||||
checkingDepositPayment = false;
|
||||
depositQuoteState: 'UNPAID' | 'PAID' | 'ISSUED' = 'UNPAID';
|
||||
private depositPollingInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
// Loading states
|
||||
loadingBalances = false;
|
||||
balanceError = '';
|
||||
|
||||
// Lightning transaction history
|
||||
transactions: NwcLookupInvoiceResult[] = [];
|
||||
loadingTransactions = false;
|
||||
transactionsError = '';
|
||||
transactionsNotSupported = false;
|
||||
|
||||
// Lightning receive
|
||||
lnReceiveAmount = 0;
|
||||
lnReceiveDescription = '';
|
||||
generatingInvoice = false;
|
||||
generatedInvoice = '';
|
||||
generatedInvoiceQr = '';
|
||||
lnReceiveError = '';
|
||||
invoiceCopied = false;
|
||||
|
||||
// Lightning pay
|
||||
showPayModal = false;
|
||||
payInput = '';
|
||||
payAmount = 0;
|
||||
paying = false;
|
||||
paymentSuccess = false;
|
||||
paymentError = '';
|
||||
|
||||
// Clipboard feedback
|
||||
addressCopied = false;
|
||||
|
||||
// Cashu onboarding info
|
||||
showCashuInfo = true;
|
||||
currentSyncFlow: BrowserSyncFlow = BrowserSyncFlow.NO_SYNC;
|
||||
readonly BrowserSyncFlow = BrowserSyncFlow; // Expose enum to template
|
||||
readonly browserDownloadSettingsUrl = 'chrome://settings/downloads';
|
||||
|
||||
// Cashu mint refresh
|
||||
refreshingMint = false;
|
||||
refreshError = '';
|
||||
|
||||
get title(): string {
|
||||
switch (this.activeSection) {
|
||||
case 'cashu':
|
||||
return 'Cashu';
|
||||
case 'cashu-detail':
|
||||
return this.selectedMint?.name ?? 'Mint';
|
||||
case 'cashu-add':
|
||||
return 'Add Mint';
|
||||
case 'cashu-receive':
|
||||
return 'Receive';
|
||||
case 'cashu-send':
|
||||
return 'Send';
|
||||
case 'cashu-mint':
|
||||
return 'Deposit';
|
||||
case 'lightning':
|
||||
return 'Lightning';
|
||||
case 'lightning-detail':
|
||||
return this.selectedConnection?.name ?? 'Wallet';
|
||||
case 'lightning-add':
|
||||
return 'Add Wallet';
|
||||
case 'lightning-receive':
|
||||
return 'Receive';
|
||||
case 'lightning-pay':
|
||||
return 'Pay';
|
||||
default:
|
||||
return 'Wallet';
|
||||
}
|
||||
}
|
||||
|
||||
get showBackButton(): boolean {
|
||||
return this.activeSection !== 'main';
|
||||
}
|
||||
|
||||
get connections(): NwcConnection_DECRYPTED[] {
|
||||
return this.nwcService.getConnections();
|
||||
}
|
||||
|
||||
get selectedConnection(): NwcConnection_DECRYPTED | undefined {
|
||||
if (!this.selectedConnectionId) return undefined;
|
||||
return this.nwcService.getConnection(this.selectedConnectionId);
|
||||
}
|
||||
|
||||
get totalLightningBalance(): number {
|
||||
return this.nwcService.getCachedTotalBalance();
|
||||
}
|
||||
|
||||
get mints(): CashuMint_DECRYPTED[] {
|
||||
return this.cashuService.getMints();
|
||||
}
|
||||
|
||||
get selectedMint(): CashuMint_DECRYPTED | undefined {
|
||||
if (!this.selectedMintId) return undefined;
|
||||
return this.cashuService.getMint(this.selectedMintId);
|
||||
}
|
||||
|
||||
get totalCashuBalance(): number {
|
||||
return this.cashuService.getCachedTotalBalance();
|
||||
}
|
||||
|
||||
get selectedMintBalance(): number {
|
||||
if (!this.selectedMintId) return 0;
|
||||
return this.cashuService.getBalance(this.selectedMintId);
|
||||
}
|
||||
|
||||
get selectedMintProofs(): CashuProof[] {
|
||||
if (!this.selectedMintId) return [];
|
||||
return this.cashuService.getProofs(this.selectedMintId);
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
// Load current sync flow setting
|
||||
this.currentSyncFlow = this.#storage.getSyncFlow();
|
||||
|
||||
// Refresh balances on init if we have connections
|
||||
if (this.connections.length > 0) {
|
||||
this.refreshAllBalances();
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.nwcService.disconnectAll();
|
||||
this.stopDepositPolling();
|
||||
}
|
||||
|
||||
setSection(section: WalletSection) {
|
||||
this.activeSection = section;
|
||||
this.connectionError = '';
|
||||
this.connectionTestResult = '';
|
||||
}
|
||||
|
||||
goBack() {
|
||||
switch (this.activeSection) {
|
||||
case 'lightning-detail':
|
||||
case 'lightning-add':
|
||||
this.activeSection = 'lightning';
|
||||
this.selectedConnectionId = null;
|
||||
this.resetAddForm();
|
||||
this.resetLightningForms();
|
||||
break;
|
||||
case 'lightning-receive':
|
||||
case 'lightning-pay':
|
||||
this.activeSection = 'lightning-detail';
|
||||
this.resetLightningForms();
|
||||
break;
|
||||
case 'cashu-detail':
|
||||
case 'cashu-add':
|
||||
this.activeSection = 'cashu';
|
||||
this.selectedMintId = null;
|
||||
this.resetAddMintForm();
|
||||
break;
|
||||
case 'cashu-receive':
|
||||
case 'cashu-send':
|
||||
case 'cashu-mint':
|
||||
this.activeSection = 'cashu-detail';
|
||||
this.resetReceiveSendForm();
|
||||
this.resetDepositForm();
|
||||
break;
|
||||
case 'lightning':
|
||||
case 'cashu':
|
||||
this.activeSection = 'main';
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
selectConnection(connectionId: string) {
|
||||
this.selectedConnectionId = connectionId;
|
||||
this.activeSection = 'lightning-detail';
|
||||
this.loadTransactions(connectionId);
|
||||
}
|
||||
|
||||
private resetLightningForms() {
|
||||
this.lnReceiveAmount = 0;
|
||||
this.lnReceiveDescription = '';
|
||||
this.generatingInvoice = false;
|
||||
this.generatedInvoice = '';
|
||||
this.generatedInvoiceQr = '';
|
||||
this.lnReceiveError = '';
|
||||
this.invoiceCopied = false;
|
||||
this.payInput = '';
|
||||
this.payAmount = 0;
|
||||
this.paying = false;
|
||||
this.paymentSuccess = false;
|
||||
this.paymentError = '';
|
||||
this.showPayModal = false;
|
||||
}
|
||||
|
||||
showAddConnection() {
|
||||
this.resetAddForm();
|
||||
this.activeSection = 'lightning-add';
|
||||
}
|
||||
|
||||
private resetAddForm() {
|
||||
this.newWalletName = '';
|
||||
this.newWalletUrl = '';
|
||||
this.connectionError = '';
|
||||
this.connectionTestResult = '';
|
||||
this.addingConnection = false;
|
||||
this.testingConnection = false;
|
||||
}
|
||||
|
||||
async testConnection() {
|
||||
if (!this.newWalletUrl.trim()) {
|
||||
this.connectionError = 'Please enter an NWC URL';
|
||||
return;
|
||||
}
|
||||
|
||||
this.testingConnection = true;
|
||||
this.connectionError = '';
|
||||
this.connectionTestResult = '';
|
||||
this.nwcService.clearLogs();
|
||||
|
||||
try {
|
||||
const info = await this.nwcService.testConnection(this.newWalletUrl);
|
||||
this.connectionTestResult = `Connected! ${info.alias ? 'Wallet: ' + info.alias : ''}`;
|
||||
// Hide logs on success
|
||||
this.nwcService.clearLogs();
|
||||
} catch (error) {
|
||||
this.connectionError =
|
||||
error instanceof Error ? error.message : 'Connection test failed';
|
||||
// Keep logs visible on failure for debugging
|
||||
} finally {
|
||||
this.testingConnection = false;
|
||||
}
|
||||
}
|
||||
|
||||
async addConnection() {
|
||||
if (!this.newWalletName.trim()) {
|
||||
this.connectionError = 'Please enter a wallet name';
|
||||
return;
|
||||
}
|
||||
if (!this.newWalletUrl.trim()) {
|
||||
this.connectionError = 'Please enter an NWC URL';
|
||||
return;
|
||||
}
|
||||
|
||||
this.addingConnection = true;
|
||||
this.connectionError = '';
|
||||
|
||||
try {
|
||||
await this.nwcService.addConnection(
|
||||
this.newWalletName.trim(),
|
||||
this.newWalletUrl.trim()
|
||||
);
|
||||
|
||||
// Refresh the balance for the new connection
|
||||
const connections = this.nwcService.getConnections();
|
||||
const newConnection = connections[connections.length - 1];
|
||||
if (newConnection) {
|
||||
try {
|
||||
await this.nwcService.getBalance(newConnection.id);
|
||||
} catch {
|
||||
// Ignore balance fetch error
|
||||
}
|
||||
}
|
||||
|
||||
this.goBack();
|
||||
} catch (error) {
|
||||
this.connectionError =
|
||||
error instanceof Error ? error.message : 'Failed to add connection';
|
||||
} finally {
|
||||
this.addingConnection = false;
|
||||
}
|
||||
}
|
||||
|
||||
async deleteConnection() {
|
||||
if (!this.selectedConnectionId) return;
|
||||
|
||||
const connection = this.selectedConnection;
|
||||
if (
|
||||
!confirm(`Delete wallet "${connection?.name}"? This cannot be undone.`)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.nwcService.deleteConnection(this.selectedConnectionId);
|
||||
this.goBack();
|
||||
} catch (error) {
|
||||
console.error('Failed to delete connection:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Cashu methods
|
||||
|
||||
selectMint(mintId: string) {
|
||||
this.selectedMintId = mintId;
|
||||
this.activeSection = 'cashu-detail';
|
||||
// Auto-refresh to check for spent proofs
|
||||
this.refreshMint();
|
||||
}
|
||||
|
||||
async refreshMint() {
|
||||
if (!this.selectedMintId || this.refreshingMint) return;
|
||||
|
||||
this.refreshingMint = true;
|
||||
this.refreshError = '';
|
||||
|
||||
try {
|
||||
const removedAmount = await this.cashuService.checkProofsSpent(this.selectedMintId);
|
||||
if (removedAmount > 0) {
|
||||
// Balance was updated, proofs were spent
|
||||
console.log(`Removed ${removedAmount} sats of spent proofs`);
|
||||
}
|
||||
} catch (error) {
|
||||
this.refreshError = error instanceof Error ? error.message : 'Failed to refresh';
|
||||
console.error('Failed to refresh mint:', error);
|
||||
} finally {
|
||||
this.refreshingMint = false;
|
||||
}
|
||||
}
|
||||
|
||||
showAddMint() {
|
||||
this.resetAddMintForm();
|
||||
this.activeSection = 'cashu-add';
|
||||
}
|
||||
|
||||
showReceive() {
|
||||
this.resetReceiveSendForm();
|
||||
this.activeSection = 'cashu-receive';
|
||||
}
|
||||
|
||||
showSend() {
|
||||
this.resetReceiveSendForm();
|
||||
this.activeSection = 'cashu-send';
|
||||
}
|
||||
|
||||
private resetAddMintForm() {
|
||||
this.newMintName = '';
|
||||
this.newMintUrl = '';
|
||||
this.mintError = '';
|
||||
this.mintTestResult = '';
|
||||
this.addingMint = false;
|
||||
this.testingMint = false;
|
||||
}
|
||||
|
||||
private resetReceiveSendForm() {
|
||||
this.receiveToken = '';
|
||||
this.receivingToken = false;
|
||||
this.receiveError = '';
|
||||
this.receiveResult = '';
|
||||
this.sendAmount = 0;
|
||||
this.sendingToken = false;
|
||||
this.sendError = '';
|
||||
this.sendResult = '';
|
||||
}
|
||||
|
||||
private resetDepositForm() {
|
||||
this.depositAmount = 0;
|
||||
this.creatingDepositQuote = false;
|
||||
this.depositQuoteId = '';
|
||||
this.depositInvoice = '';
|
||||
this.depositInvoiceQr = '';
|
||||
this.depositError = '';
|
||||
this.depositSuccess = '';
|
||||
this.checkingDepositPayment = false;
|
||||
this.depositQuoteState = 'UNPAID';
|
||||
this.stopDepositPolling();
|
||||
}
|
||||
|
||||
private stopDepositPolling() {
|
||||
if (this.depositPollingInterval) {
|
||||
clearInterval(this.depositPollingInterval);
|
||||
this.depositPollingInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
async testMint() {
|
||||
if (!this.newMintUrl.trim()) {
|
||||
this.mintError = 'Please enter a mint URL';
|
||||
return;
|
||||
}
|
||||
|
||||
this.testingMint = true;
|
||||
this.mintError = '';
|
||||
this.mintTestResult = '';
|
||||
|
||||
try {
|
||||
const info = await this.cashuService.testMintConnection(
|
||||
this.newMintUrl.trim()
|
||||
);
|
||||
this.mintTestResult = `Connected! ${info.name ? 'Mint: ' + info.name : ''}`;
|
||||
} catch (error) {
|
||||
this.mintError =
|
||||
error instanceof Error ? error.message : 'Connection test failed';
|
||||
} finally {
|
||||
this.testingMint = false;
|
||||
}
|
||||
}
|
||||
|
||||
async addMint() {
|
||||
if (!this.newMintName.trim()) {
|
||||
this.mintError = 'Please enter a mint name';
|
||||
return;
|
||||
}
|
||||
if (!this.newMintUrl.trim()) {
|
||||
this.mintError = 'Please enter a mint URL';
|
||||
return;
|
||||
}
|
||||
|
||||
this.addingMint = true;
|
||||
this.mintError = '';
|
||||
|
||||
try {
|
||||
await this.cashuService.addMint(
|
||||
this.newMintName.trim(),
|
||||
this.newMintUrl.trim()
|
||||
);
|
||||
this.goBack();
|
||||
} catch (error) {
|
||||
this.mintError =
|
||||
error instanceof Error ? error.message : 'Failed to add mint';
|
||||
} finally {
|
||||
this.addingMint = false;
|
||||
}
|
||||
}
|
||||
|
||||
async deleteMint() {
|
||||
if (!this.selectedMintId) return;
|
||||
|
||||
const mint = this.selectedMint;
|
||||
if (!confirm(`Delete mint "${mint?.name}"? Any tokens stored will be lost. This cannot be undone.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.cashuService.deleteMint(this.selectedMintId);
|
||||
this.goBack();
|
||||
} catch (error) {
|
||||
console.error('Failed to delete mint:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async receiveTokens() {
|
||||
if (!this.receiveToken.trim()) {
|
||||
this.receiveError = 'Please paste a Cashu token';
|
||||
return;
|
||||
}
|
||||
|
||||
this.receivingToken = true;
|
||||
this.receiveError = '';
|
||||
this.receiveResult = '';
|
||||
|
||||
try {
|
||||
const result = await this.cashuService.receive(this.receiveToken.trim());
|
||||
this.receiveResult = `Received ${result.amount} sats!`;
|
||||
this.receiveToken = '';
|
||||
} catch (error) {
|
||||
this.receiveError =
|
||||
error instanceof Error ? error.message : 'Failed to receive token';
|
||||
} finally {
|
||||
this.receivingToken = false;
|
||||
}
|
||||
}
|
||||
|
||||
async sendTokens() {
|
||||
if (!this.selectedMintId) return;
|
||||
|
||||
if (this.sendAmount <= 0) {
|
||||
this.sendError = 'Please enter a valid amount';
|
||||
return;
|
||||
}
|
||||
|
||||
const balance = this.selectedMintBalance;
|
||||
if (this.sendAmount > balance) {
|
||||
this.sendError = `Insufficient balance. You have ${balance} sats`;
|
||||
return;
|
||||
}
|
||||
|
||||
this.sendingToken = true;
|
||||
this.sendError = '';
|
||||
this.sendResult = '';
|
||||
|
||||
try {
|
||||
const result = await this.cashuService.send(
|
||||
this.selectedMintId,
|
||||
this.sendAmount
|
||||
);
|
||||
this.sendResult = result.token;
|
||||
this.sendAmount = 0;
|
||||
} catch (error) {
|
||||
this.sendError =
|
||||
error instanceof Error ? error.message : 'Failed to create token';
|
||||
} finally {
|
||||
this.sendingToken = false;
|
||||
}
|
||||
}
|
||||
|
||||
copyToken() {
|
||||
if (this.sendResult) {
|
||||
navigator.clipboard.writeText(this.sendResult);
|
||||
}
|
||||
}
|
||||
|
||||
async checkProofs() {
|
||||
if (!this.selectedMintId) return;
|
||||
|
||||
try {
|
||||
const removedAmount = await this.cashuService.checkProofsSpent(
|
||||
this.selectedMintId
|
||||
);
|
||||
if (removedAmount > 0) {
|
||||
alert(`Removed ${removedAmount} sats of spent proofs.`);
|
||||
} else {
|
||||
alert('All proofs are valid.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to check proofs:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Cashu deposit (mint) methods
|
||||
|
||||
showDeposit() {
|
||||
this.resetDepositForm();
|
||||
this.activeSection = 'cashu-mint';
|
||||
}
|
||||
|
||||
async createDepositInvoice() {
|
||||
if (!this.selectedMintId) return;
|
||||
|
||||
if (this.depositAmount <= 0) {
|
||||
this.depositError = 'Please enter an amount';
|
||||
return;
|
||||
}
|
||||
|
||||
this.creatingDepositQuote = true;
|
||||
this.depositError = '';
|
||||
this.depositInvoice = '';
|
||||
this.depositInvoiceQr = '';
|
||||
|
||||
try {
|
||||
const quote = await this.cashuService.createMintQuote(
|
||||
this.selectedMintId,
|
||||
this.depositAmount
|
||||
);
|
||||
|
||||
this.depositQuoteId = quote.quoteId;
|
||||
this.depositInvoice = quote.invoice;
|
||||
this.depositQuoteState = quote.state;
|
||||
|
||||
// Generate QR code
|
||||
this.depositInvoiceQr = await QRCode.toDataURL(quote.invoice, {
|
||||
width: 200,
|
||||
margin: 2,
|
||||
color: {
|
||||
dark: '#000000',
|
||||
light: '#ffffff',
|
||||
},
|
||||
});
|
||||
|
||||
// Start polling for payment
|
||||
this.startDepositPolling();
|
||||
} catch (error) {
|
||||
this.depositError =
|
||||
error instanceof Error ? error.message : 'Failed to create invoice';
|
||||
} finally {
|
||||
this.creatingDepositQuote = false;
|
||||
}
|
||||
}
|
||||
|
||||
private startDepositPolling() {
|
||||
// Poll every 3 seconds for payment confirmation
|
||||
this.depositPollingInterval = setInterval(async () => {
|
||||
await this.checkDepositPayment();
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
async checkDepositPayment() {
|
||||
if (!this.selectedMintId || !this.depositQuoteId) return;
|
||||
|
||||
this.checkingDepositPayment = true;
|
||||
|
||||
try {
|
||||
const quote = await this.cashuService.checkMintQuote(
|
||||
this.selectedMintId,
|
||||
this.depositQuoteId
|
||||
);
|
||||
|
||||
this.depositQuoteState = quote.state;
|
||||
|
||||
if (quote.state === 'PAID') {
|
||||
// Invoice is paid, claim the tokens
|
||||
this.stopDepositPolling();
|
||||
await this.claimDepositTokens();
|
||||
} else if (quote.state === 'ISSUED') {
|
||||
// Already claimed
|
||||
this.stopDepositPolling();
|
||||
this.depositSuccess = 'Tokens already claimed!';
|
||||
}
|
||||
} catch (error) {
|
||||
// Don't show error for polling failures, just log
|
||||
console.error('Failed to check payment:', error);
|
||||
} finally {
|
||||
this.checkingDepositPayment = false;
|
||||
}
|
||||
}
|
||||
|
||||
async claimDepositTokens() {
|
||||
if (!this.selectedMintId || !this.depositQuoteId) return;
|
||||
|
||||
try {
|
||||
const result = await this.cashuService.mintTokens(
|
||||
this.selectedMintId,
|
||||
this.depositAmount,
|
||||
this.depositQuoteId
|
||||
);
|
||||
|
||||
this.depositSuccess = `Received ${result.amount} sats!`;
|
||||
this.depositQuoteState = 'ISSUED';
|
||||
} catch (error) {
|
||||
this.depositError =
|
||||
error instanceof Error ? error.message : 'Failed to claim tokens';
|
||||
}
|
||||
}
|
||||
|
||||
async copyDepositInvoice() {
|
||||
if (this.depositInvoice) {
|
||||
await navigator.clipboard.writeText(this.depositInvoice);
|
||||
}
|
||||
}
|
||||
|
||||
formatCashuBalance(sats: number | undefined): string {
|
||||
return this.cashuService.formatBalance(sats);
|
||||
}
|
||||
|
||||
async refreshBalance(connectionId: string) {
|
||||
try {
|
||||
await this.nwcService.getBalance(connectionId);
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh balance:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async refreshAllBalances() {
|
||||
this.loadingBalances = true;
|
||||
this.balanceError = '';
|
||||
|
||||
try {
|
||||
await this.nwcService.getAllBalances();
|
||||
} catch {
|
||||
this.balanceError = 'Failed to refresh some balances';
|
||||
} finally {
|
||||
this.loadingBalances = false;
|
||||
}
|
||||
}
|
||||
|
||||
formatBalance(millisats: number | undefined): string {
|
||||
if (millisats === undefined) return '—';
|
||||
// Convert millisats to sats with 3 decimal places
|
||||
const sats = millisats / 1000;
|
||||
return sats.toLocaleString('en-US', {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 3,
|
||||
});
|
||||
}
|
||||
|
||||
// Lightning transaction methods
|
||||
|
||||
async loadTransactions(connectionId: string) {
|
||||
this.loadingTransactions = true;
|
||||
this.transactionsError = '';
|
||||
this.transactionsNotSupported = false;
|
||||
|
||||
try {
|
||||
this.transactions = await this.nwcService.listTransactions(connectionId, {
|
||||
limit: 20,
|
||||
});
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
|
||||
if (errorMsg.includes('NOT_IMPLEMENTED') || errorMsg.includes('not supported')) {
|
||||
this.transactionsNotSupported = true;
|
||||
} else {
|
||||
this.transactionsError = errorMsg;
|
||||
}
|
||||
this.transactions = [];
|
||||
} finally {
|
||||
this.loadingTransactions = false;
|
||||
}
|
||||
}
|
||||
|
||||
async refreshWallet() {
|
||||
if (!this.selectedConnectionId) return;
|
||||
|
||||
// Refresh balance and transactions in parallel
|
||||
await Promise.all([
|
||||
this.refreshBalance(this.selectedConnectionId),
|
||||
this.loadTransactions(this.selectedConnectionId),
|
||||
]);
|
||||
}
|
||||
|
||||
showLnReceive() {
|
||||
this.resetLightningForms();
|
||||
this.activeSection = 'lightning-receive';
|
||||
}
|
||||
|
||||
showLnPay() {
|
||||
this.resetLightningForms();
|
||||
this.showPayModal = true;
|
||||
}
|
||||
|
||||
closePayModal() {
|
||||
this.showPayModal = false;
|
||||
this.resetLightningForms();
|
||||
}
|
||||
|
||||
async createReceiveInvoice() {
|
||||
if (!this.selectedConnectionId) return;
|
||||
|
||||
if (this.lnReceiveAmount <= 0) {
|
||||
this.lnReceiveError = 'Please enter an amount';
|
||||
return;
|
||||
}
|
||||
|
||||
this.generatingInvoice = true;
|
||||
this.lnReceiveError = '';
|
||||
this.generatedInvoice = '';
|
||||
this.generatedInvoiceQr = '';
|
||||
|
||||
try {
|
||||
const result = await this.nwcService.makeInvoice(
|
||||
this.selectedConnectionId,
|
||||
this.lnReceiveAmount * 1000, // Convert sats to millisats
|
||||
this.lnReceiveDescription || undefined
|
||||
);
|
||||
this.generatedInvoice = result.invoice;
|
||||
|
||||
// Generate QR code
|
||||
this.generatedInvoiceQr = await QRCode.toDataURL(result.invoice, {
|
||||
width: 200,
|
||||
margin: 2,
|
||||
color: {
|
||||
dark: '#000000',
|
||||
light: '#ffffff',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
this.lnReceiveError =
|
||||
error instanceof Error ? error.message : 'Failed to create invoice';
|
||||
} finally {
|
||||
this.generatingInvoice = false;
|
||||
}
|
||||
}
|
||||
|
||||
async copyInvoice() {
|
||||
if (this.generatedInvoice) {
|
||||
await navigator.clipboard.writeText(this.generatedInvoice);
|
||||
this.invoiceCopied = true;
|
||||
setTimeout(() => (this.invoiceCopied = false), 2000);
|
||||
}
|
||||
}
|
||||
|
||||
async copyLightningAddress() {
|
||||
const lud16 = this.selectedConnection?.lud16;
|
||||
if (lud16) {
|
||||
await navigator.clipboard.writeText(lud16);
|
||||
this.addressCopied = true;
|
||||
setTimeout(() => (this.addressCopied = false), 2000);
|
||||
}
|
||||
}
|
||||
|
||||
async payInvoiceOrAddress() {
|
||||
if (!this.selectedConnectionId || !this.payInput.trim()) {
|
||||
this.paymentError = 'Please enter a lightning address or invoice';
|
||||
return;
|
||||
}
|
||||
|
||||
this.paying = true;
|
||||
this.paymentError = '';
|
||||
this.paymentSuccess = false;
|
||||
|
||||
try {
|
||||
let invoice = this.payInput.trim();
|
||||
|
||||
// Check if it's a lightning address
|
||||
if (this.nwcService.isLightningAddress(invoice)) {
|
||||
if (this.payAmount <= 0) {
|
||||
this.paymentError = 'Please enter an amount for lightning address payments';
|
||||
this.paying = false;
|
||||
return;
|
||||
}
|
||||
// Resolve lightning address to invoice
|
||||
invoice = await this.nwcService.resolveLightningAddress(
|
||||
invoice,
|
||||
this.payAmount * 1000 // Convert sats to millisats
|
||||
);
|
||||
}
|
||||
|
||||
// Pay the invoice
|
||||
await this.nwcService.payInvoice(
|
||||
this.selectedConnectionId,
|
||||
invoice,
|
||||
this.payAmount > 0 ? this.payAmount * 1000 : undefined
|
||||
);
|
||||
|
||||
this.paymentSuccess = true;
|
||||
|
||||
// Refresh balance and transactions after payment
|
||||
await this.refreshWallet();
|
||||
|
||||
// Close modal after a delay
|
||||
setTimeout(() => {
|
||||
this.closePayModal();
|
||||
}, 2000);
|
||||
} catch (error) {
|
||||
this.paymentError =
|
||||
error instanceof Error ? error.message : 'Payment failed';
|
||||
} finally {
|
||||
this.paying = false;
|
||||
}
|
||||
}
|
||||
|
||||
formatTransactionTime(timestamp: number): string {
|
||||
const date = new Date(timestamp * 1000);
|
||||
const now = new Date();
|
||||
const isToday = date.toDateString() === now.toDateString();
|
||||
|
||||
if (isToday) {
|
||||
return date.toLocaleTimeString('en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
formatProofTime(isoTimestamp: string | undefined): string {
|
||||
if (!isoTimestamp) return '—';
|
||||
|
||||
const date = new Date(isoTimestamp);
|
||||
const now = new Date();
|
||||
const isToday = date.toDateString() === now.toDateString();
|
||||
|
||||
if (isToday) {
|
||||
return date.toLocaleTimeString('en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
async onClickLock() {
|
||||
this.#logger.logVaultLock();
|
||||
await this.#storage.lockVault();
|
||||
this.#router.navigateByUrl('/vault-login');
|
||||
}
|
||||
|
||||
// Cashu onboarding methods
|
||||
dismissCashuInfo() {
|
||||
this.showCashuInfo = false;
|
||||
}
|
||||
|
||||
navigateToSettings() {
|
||||
this.#router.navigateByUrl('/home/settings');
|
||||
}
|
||||
}
|
||||
@@ -43,23 +43,22 @@
|
||||
<span>Sign in</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="sam-mt"
|
||||
(click)="
|
||||
confirm.show(
|
||||
'Do you really want to reset the extension? All data will be lost.',
|
||||
onClickResetExtension.bind(this)
|
||||
)
|
||||
"
|
||||
type="button"
|
||||
class="btn btn-link"
|
||||
>
|
||||
Reset Extension
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="reset-btn"
|
||||
(click)="
|
||||
confirm.show(
|
||||
'Do you really want to reset the extension? All data will be lost.',
|
||||
onClickResetExtension.bind(this)
|
||||
)
|
||||
"
|
||||
type="button"
|
||||
>
|
||||
Reset Extension
|
||||
</button>
|
||||
|
||||
<!----------->
|
||||
<!-- ALERT -->
|
||||
<!----------->
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-items: center;
|
||||
position: relative;
|
||||
|
||||
.logo-frame {
|
||||
border: 2px solid var(--secondary);
|
||||
@@ -16,4 +17,21 @@
|
||||
justify-content: center;
|
||||
padding: 0 var(--size) var(--size) var(--size);
|
||||
}
|
||||
|
||||
.reset-btn {
|
||||
position: absolute;
|
||||
bottom: var(--size);
|
||||
right: var(--size);
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
|
||||
&:hover {
|
||||
color: var(--foreground);
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,26 @@
|
||||
<span> Sync OFF</span>
|
||||
</button>
|
||||
|
||||
<div class="storage-info">
|
||||
<details>
|
||||
<summary>Important for Cashu wallet users</summary>
|
||||
<p>
|
||||
Browser sync storage is limited to ~100KB shared across all data
|
||||
(identities, permissions, relays, and Cashu tokens).
|
||||
</p>
|
||||
<p>
|
||||
If you plan to use the Cashu ecash wallet with significant balances,
|
||||
choose <strong>"Sync OFF"</strong> which provides ~5MB of local storage
|
||||
(enough for ~18,000+ tokens vs ~300-400 with sync).
|
||||
</p>
|
||||
<p>
|
||||
<strong>Note:</strong> Cashu tokens are bearer assets. If you lose your
|
||||
vault backup, you lose your tokens permanently. Make sure to configure
|
||||
regular backups.
|
||||
</p>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<div class="sam-flex-grow"></div>
|
||||
|
||||
<span class="sam-text-muted sam-text-md sam-mb">
|
||||
|
||||
@@ -6,3 +6,41 @@
|
||||
padding-left: var(--size);
|
||||
padding-right: var(--size);
|
||||
}
|
||||
|
||||
.storage-info {
|
||||
margin-top: 1rem;
|
||||
width: 100%;
|
||||
|
||||
details {
|
||||
background: rgba(255, 193, 7, 0.1);
|
||||
border: 1px solid var(--warning, #ffc107);
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem;
|
||||
|
||||
summary {
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
font-size: 0.9rem;
|
||||
color: var(--warning, #ffc107);
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0.75rem 0 0 0;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.4;
|
||||
color: var(--text-muted, #6c757d);
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
strong {
|
||||
color: var(--text, #212529);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
} from '@common';
|
||||
import './app/common/extensions/array';
|
||||
import browser from 'webextension-polyfill';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
//
|
||||
// Functions
|
||||
@@ -105,8 +106,12 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
}
|
||||
|
||||
newSnapshots.push({
|
||||
id: uuidv4(),
|
||||
fileName: file.name,
|
||||
createdAt: new Date().toISOString(),
|
||||
data: vault,
|
||||
identityCount: vault.identities?.length ?? 0,
|
||||
reason: 'manual',
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ body {
|
||||
background: var(--background);
|
||||
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
// Button styling to match market
|
||||
@@ -128,3 +129,35 @@ button {
|
||||
.modal-body {
|
||||
color: #fafafa;
|
||||
}
|
||||
|
||||
// Custom scrollbar styling for Chrome
|
||||
* {
|
||||
// Thin scrollbar
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
// Track - black background, transparent by default
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
// Thumb - white, transparent by default
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: transparent;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
// Show scrollbar on hover over scrollable area
|
||||
&:hover::-webkit-scrollbar-track {
|
||||
background: #1a1a1a;
|
||||
}
|
||||
|
||||
&:hover::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
}
|
||||
|
||||
1756
projects/common/src/lib/constants/event-kinds.ts
Normal file
1756
projects/common/src/lib/constants/event-kinds.ts
Normal file
File diff suppressed because it is too large
Load Diff
450
projects/common/src/lib/services/cashu/cashu.service.ts
Normal file
450
projects/common/src/lib/services/cashu/cashu.service.ts
Normal file
@@ -0,0 +1,450 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import {
|
||||
Mint,
|
||||
Wallet,
|
||||
getDecodedToken,
|
||||
getEncodedTokenV4,
|
||||
Token,
|
||||
Proof,
|
||||
CheckStateEnum,
|
||||
} from '@cashu/cashu-ts';
|
||||
import { StorageService, CashuMint_DECRYPTED, CashuProof } from '@common';
|
||||
import {
|
||||
CashuReceiveResult,
|
||||
CashuSendResult,
|
||||
DecodedCashuToken,
|
||||
CashuMintInfo,
|
||||
CashuMintQuote,
|
||||
CashuMintResult,
|
||||
MintQuoteState,
|
||||
} from './types';
|
||||
|
||||
interface CachedWallet {
|
||||
wallet: Wallet;
|
||||
mint: Mint;
|
||||
mintId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Angular service for managing Cashu ecash wallets
|
||||
*/
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class CashuService {
|
||||
private wallets = new Map<string, CachedWallet>();
|
||||
|
||||
constructor(private storageService: StorageService) {}
|
||||
|
||||
/**
|
||||
* Get all Cashu mints from storage
|
||||
*/
|
||||
getMints(): CashuMint_DECRYPTED[] {
|
||||
const sessionData =
|
||||
this.storageService.getBrowserSessionHandler().browserSessionData;
|
||||
return sessionData?.cashuMints ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single Cashu mint by ID
|
||||
*/
|
||||
getMint(mintId: string): CashuMint_DECRYPTED | undefined {
|
||||
return this.getMints().find((m) => m.id === mintId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a mint by URL
|
||||
*/
|
||||
getMintByUrl(mintUrl: string): CashuMint_DECRYPTED | undefined {
|
||||
const normalizedUrl = mintUrl.replace(/\/$/, '');
|
||||
return this.getMints().find((m) => m.mintUrl === normalizedUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new Cashu mint connection
|
||||
*/
|
||||
async addMint(name: string, mintUrl: string): Promise<CashuMint_DECRYPTED> {
|
||||
// Test the mint connection first
|
||||
await this.testMintConnection(mintUrl);
|
||||
|
||||
// Add to storage
|
||||
return await this.storageService.addCashuMint({
|
||||
name,
|
||||
mintUrl,
|
||||
unit: 'sat',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a Cashu mint connection
|
||||
*/
|
||||
async deleteMint(mintId: string): Promise<void> {
|
||||
// Remove from cache
|
||||
this.wallets.delete(mintId);
|
||||
await this.storageService.deleteCashuMint(mintId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create a wallet for a mint
|
||||
*/
|
||||
private async getWallet(mintId: string): Promise<CachedWallet> {
|
||||
// Check cache
|
||||
const cached = this.wallets.get(mintId);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Get mint data from storage
|
||||
const mintData = this.getMint(mintId);
|
||||
if (!mintData) {
|
||||
throw new Error('Mint not found');
|
||||
}
|
||||
|
||||
// Create mint and wallet instances
|
||||
const mint = new Mint(mintData.mintUrl);
|
||||
const wallet = new Wallet(mint, { unit: mintData.unit || 'sat' });
|
||||
|
||||
// Load mint keys
|
||||
await wallet.loadMint();
|
||||
|
||||
// Cache it
|
||||
const cachedWallet: CachedWallet = {
|
||||
wallet,
|
||||
mint,
|
||||
mintId,
|
||||
};
|
||||
this.wallets.set(mintId, cachedWallet);
|
||||
|
||||
return cachedWallet;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test a mint connection by fetching its info
|
||||
*/
|
||||
async testMintConnection(mintUrl: string): Promise<CashuMintInfo> {
|
||||
const normalizedUrl = mintUrl.replace(/\/$/, '');
|
||||
const mint = new Mint(normalizedUrl);
|
||||
const info = await mint.getInfo();
|
||||
return {
|
||||
name: info.name,
|
||||
description: info.description,
|
||||
version: info.version,
|
||||
contact: info.contact?.map((c) => ({ method: c.method, info: c.info })),
|
||||
nuts: info.nuts,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a Cashu token without claiming it
|
||||
*/
|
||||
decodeToken(token: string): DecodedCashuToken | null {
|
||||
try {
|
||||
const decoded = getDecodedToken(token);
|
||||
const proofs = decoded.proofs;
|
||||
const amount = proofs.reduce((sum, p) => sum + p.amount, 0);
|
||||
|
||||
return {
|
||||
mint: decoded.mint,
|
||||
unit: decoded.unit || 'sat',
|
||||
amount,
|
||||
proofs,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Receive a Cashu token
|
||||
* This validates and claims the proofs, then stores them
|
||||
*/
|
||||
async receive(token: string): Promise<CashuReceiveResult> {
|
||||
// Decode the token
|
||||
const decoded = this.decodeToken(token);
|
||||
if (!decoded) {
|
||||
throw new Error('Invalid token format');
|
||||
}
|
||||
|
||||
// Check if we have this mint
|
||||
let mintData = this.getMintByUrl(decoded.mint);
|
||||
|
||||
// If we don't have this mint, add it automatically
|
||||
if (!mintData) {
|
||||
// Use the mint URL as the name initially
|
||||
const urlObj = new URL(decoded.mint);
|
||||
mintData = await this.storageService.addCashuMint({
|
||||
name: urlObj.hostname,
|
||||
mintUrl: decoded.mint,
|
||||
unit: decoded.unit || 'sat',
|
||||
});
|
||||
}
|
||||
|
||||
// Get the wallet for this mint
|
||||
const { wallet } = await this.getWallet(mintData.id);
|
||||
|
||||
// Receive the token (this swaps proofs with the mint)
|
||||
const receivedProofs = await wallet.receive(token);
|
||||
|
||||
// Convert to our proof format with timestamp
|
||||
const now = new Date().toISOString();
|
||||
const newProofs: CashuProof[] = receivedProofs.map((p: Proof) => ({
|
||||
id: p.id,
|
||||
amount: p.amount,
|
||||
secret: p.secret,
|
||||
C: p.C,
|
||||
receivedAt: now,
|
||||
}));
|
||||
|
||||
// Merge with existing proofs
|
||||
const existingProofs = mintData!.proofs || [];
|
||||
const allProofs = [...existingProofs, ...newProofs];
|
||||
|
||||
// Update storage
|
||||
await this.storageService.updateCashuMintProofs(mintData!.id, allProofs);
|
||||
|
||||
// Calculate received amount
|
||||
const amount = newProofs.reduce((sum, p) => sum + p.amount, 0);
|
||||
|
||||
return {
|
||||
amount,
|
||||
mintUrl: decoded.mint,
|
||||
mintId: mintData!.id,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Send Cashu tokens
|
||||
* Creates an encoded token from existing proofs
|
||||
*/
|
||||
async send(mintId: string, amount: number): Promise<CashuSendResult> {
|
||||
const mintData = this.getMint(mintId);
|
||||
if (!mintData) {
|
||||
throw new Error('Mint not found');
|
||||
}
|
||||
|
||||
// Check we have enough balance
|
||||
const balance = this.getBalance(mintId);
|
||||
if (balance < amount) {
|
||||
throw new Error(`Insufficient balance. Have ${balance} sats, need ${amount} sats`);
|
||||
}
|
||||
|
||||
// Get the wallet
|
||||
const { wallet } = await this.getWallet(mintId);
|
||||
|
||||
// Convert our proofs to the format cashu-ts expects
|
||||
const proofs: Proof[] = mintData.proofs.map((p) => ({
|
||||
id: p.id,
|
||||
amount: p.amount,
|
||||
secret: p.secret,
|
||||
C: p.C,
|
||||
}));
|
||||
|
||||
// Send - this returns send proofs and keep proofs (change)
|
||||
const { send, keep } = await wallet.send(amount, proofs);
|
||||
|
||||
// Create the token to share
|
||||
const token: Token = {
|
||||
mint: mintData.mintUrl,
|
||||
proofs: send,
|
||||
unit: mintData.unit || 'sat',
|
||||
};
|
||||
const encodedToken = getEncodedTokenV4(token);
|
||||
|
||||
// Update our stored proofs to only keep the change (new proofs from mint)
|
||||
const now = new Date().toISOString();
|
||||
const keepProofs: CashuProof[] = keep.map((p: Proof) => ({
|
||||
id: p.id,
|
||||
amount: p.amount,
|
||||
secret: p.secret,
|
||||
C: p.C,
|
||||
receivedAt: now,
|
||||
}));
|
||||
|
||||
await this.storageService.updateCashuMintProofs(mintId, keepProofs);
|
||||
|
||||
return {
|
||||
token: encodedToken,
|
||||
amount,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if any proofs have been spent
|
||||
* Removes spent proofs from storage
|
||||
*/
|
||||
async checkProofsSpent(mintId: string): Promise<number> {
|
||||
const mintData = this.getMint(mintId);
|
||||
if (!mintData) {
|
||||
throw new Error('Mint not found');
|
||||
}
|
||||
|
||||
if (mintData.proofs.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const { wallet } = await this.getWallet(mintId);
|
||||
|
||||
// Only the secret field is needed for checking proof states
|
||||
const proofsToCheck = mintData.proofs.map((p) => ({ secret: p.secret }));
|
||||
|
||||
// Check which proofs are spent using v3 API
|
||||
const proofStates = await wallet.checkProofsStates(proofsToCheck);
|
||||
|
||||
// Filter out spent proofs
|
||||
const unspentProofs: CashuProof[] = [];
|
||||
let removedAmount = 0;
|
||||
|
||||
for (let i = 0; i < mintData.proofs.length; i++) {
|
||||
if (proofStates[i].state !== CheckStateEnum.SPENT) {
|
||||
unspentProofs.push(mintData.proofs[i]);
|
||||
} else {
|
||||
removedAmount += mintData.proofs[i].amount;
|
||||
}
|
||||
}
|
||||
|
||||
// Update storage if any were spent
|
||||
if (removedAmount > 0) {
|
||||
await this.storageService.updateCashuMintProofs(mintId, unspentProofs);
|
||||
}
|
||||
|
||||
return removedAmount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a mint quote (Lightning invoice) for depositing sats
|
||||
* Returns a Lightning invoice that when paid will allow minting tokens
|
||||
*/
|
||||
async createMintQuote(mintId: string, amount: number): Promise<CashuMintQuote> {
|
||||
const mintData = this.getMint(mintId);
|
||||
if (!mintData) {
|
||||
throw new Error('Mint not found');
|
||||
}
|
||||
|
||||
if (amount <= 0) {
|
||||
throw new Error('Amount must be greater than 0');
|
||||
}
|
||||
|
||||
const { wallet } = await this.getWallet(mintId);
|
||||
|
||||
// Create a mint quote - this returns a Lightning invoice
|
||||
const quote = await wallet.createMintQuote(amount);
|
||||
|
||||
return {
|
||||
quoteId: quote.quote,
|
||||
invoice: quote.request,
|
||||
amount: amount,
|
||||
state: quote.state as MintQuoteState,
|
||||
expiry: quote.expiry,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check the status of a mint quote
|
||||
* Returns the current state (UNPAID, PAID, ISSUED)
|
||||
*/
|
||||
async checkMintQuote(mintId: string, quoteId: string): Promise<CashuMintQuote> {
|
||||
const mintData = this.getMint(mintId);
|
||||
if (!mintData) {
|
||||
throw new Error('Mint not found');
|
||||
}
|
||||
|
||||
const { wallet } = await this.getWallet(mintId);
|
||||
|
||||
// Check the quote status
|
||||
const quote = await wallet.checkMintQuote(quoteId);
|
||||
|
||||
return {
|
||||
quoteId: quote.quote,
|
||||
invoice: quote.request,
|
||||
amount: 0, // Amount not returned in check response
|
||||
state: quote.state as MintQuoteState,
|
||||
expiry: quote.expiry,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Mint tokens after paying the Lightning invoice
|
||||
* This claims the tokens and stores them
|
||||
*/
|
||||
async mintTokens(mintId: string, amount: number, quoteId: string): Promise<CashuMintResult> {
|
||||
const mintData = this.getMint(mintId);
|
||||
if (!mintData) {
|
||||
throw new Error('Mint not found');
|
||||
}
|
||||
|
||||
const { wallet } = await this.getWallet(mintId);
|
||||
|
||||
// Mint the proofs
|
||||
const mintedProofs = await wallet.mintProofs(amount, quoteId);
|
||||
|
||||
// Convert to our proof format with timestamp
|
||||
const now = new Date().toISOString();
|
||||
const newProofs: CashuProof[] = mintedProofs.map((p: Proof) => ({
|
||||
id: p.id,
|
||||
amount: p.amount,
|
||||
secret: p.secret,
|
||||
C: p.C,
|
||||
receivedAt: now,
|
||||
}));
|
||||
|
||||
// Merge with existing proofs
|
||||
const existingProofs = mintData.proofs || [];
|
||||
const allProofs = [...existingProofs, ...newProofs];
|
||||
|
||||
// Update storage
|
||||
await this.storageService.updateCashuMintProofs(mintId, allProofs);
|
||||
|
||||
// Calculate minted amount
|
||||
const mintedAmount = newProofs.reduce((sum, p) => sum + p.amount, 0);
|
||||
|
||||
return {
|
||||
amount: mintedAmount,
|
||||
mintId: mintId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get balance for a specific mint (in satoshis)
|
||||
*/
|
||||
getBalance(mintId: string): number {
|
||||
const mintData = this.getMint(mintId);
|
||||
if (!mintData) {
|
||||
return 0;
|
||||
}
|
||||
return mintData.proofs.reduce((sum, p) => sum + p.amount, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get proofs for a specific mint
|
||||
*/
|
||||
getProofs(mintId: string): CashuProof[] {
|
||||
const mintData = this.getMint(mintId);
|
||||
if (!mintData) {
|
||||
return [];
|
||||
}
|
||||
return mintData.proofs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get total balance across all mints (in satoshis)
|
||||
*/
|
||||
getTotalBalance(): number {
|
||||
const mints = this.getMints();
|
||||
return mints.reduce((sum, m) => sum + this.getBalance(m.id), 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached total balance (same as getTotalBalance for Cashu since it's all local)
|
||||
*/
|
||||
getCachedTotalBalance(): number {
|
||||
return this.getTotalBalance();
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a balance for display (Cashu uses satoshis, not millisatoshis)
|
||||
*/
|
||||
formatBalance(sats: number | undefined): string {
|
||||
if (sats === undefined) return '—';
|
||||
return sats.toLocaleString('en-US');
|
||||
}
|
||||
}
|
||||
71
projects/common/src/lib/services/cashu/types.ts
Normal file
71
projects/common/src/lib/services/cashu/types.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import type { Proof } from '@cashu/cashu-ts';
|
||||
|
||||
/**
|
||||
* Result from receiving a Cashu token
|
||||
*/
|
||||
export interface CashuReceiveResult {
|
||||
amount: number; // Amount received in satoshis
|
||||
mintUrl: string; // Mint the tokens were from
|
||||
mintId: string; // ID of the mint in our storage
|
||||
}
|
||||
|
||||
/**
|
||||
* Result from sending Cashu tokens
|
||||
*/
|
||||
export interface CashuSendResult {
|
||||
token: string; // Encoded token to share (cashuB...)
|
||||
amount: number; // Amount in satoshis
|
||||
}
|
||||
|
||||
/**
|
||||
* Information about a decoded Cashu token
|
||||
*/
|
||||
export interface DecodedCashuToken {
|
||||
mint: string; // Mint URL
|
||||
unit: string; // Unit (usually 'sat')
|
||||
amount: number; // Total amount in the token
|
||||
proofs: Proof[]; // The individual proofs
|
||||
}
|
||||
|
||||
/**
|
||||
* Mint contact info
|
||||
*/
|
||||
export interface MintContact {
|
||||
method: string;
|
||||
info: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mint information returned when testing a connection
|
||||
*/
|
||||
export interface CashuMintInfo {
|
||||
name?: string;
|
||||
description?: string;
|
||||
version?: string;
|
||||
contact?: MintContact[];
|
||||
nuts: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* State of a mint quote
|
||||
*/
|
||||
export type MintQuoteState = 'UNPAID' | 'PAID' | 'ISSUED';
|
||||
|
||||
/**
|
||||
* Result from creating a mint quote (Lightning invoice to deposit)
|
||||
*/
|
||||
export interface CashuMintQuote {
|
||||
quoteId: string; // Quote ID for checking status and claiming
|
||||
invoice: string; // Lightning invoice to pay
|
||||
amount: number; // Amount in satoshis
|
||||
state: MintQuoteState; // Current state of the quote
|
||||
expiry?: number; // Expiry timestamp (unix seconds)
|
||||
}
|
||||
|
||||
/**
|
||||
* Result from minting tokens after paying the invoice
|
||||
*/
|
||||
export interface CashuMintResult {
|
||||
amount: number; // Amount minted in satoshis
|
||||
mintId: string; // ID of the mint
|
||||
}
|
||||
541
projects/common/src/lib/services/nwc/nwc-client.ts
Normal file
541
projects/common/src/lib/services/nwc/nwc-client.ts
Normal file
@@ -0,0 +1,541 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { NostrHelper } from '@common';
|
||||
import { finalizeEvent, nip04, nip44, getPublicKey } from 'nostr-tools';
|
||||
import {
|
||||
NwcRequest,
|
||||
NwcResponse,
|
||||
NwcGetBalanceResult,
|
||||
NwcGetInfoResult,
|
||||
NwcPayInvoiceParams,
|
||||
NwcPayInvoiceResult,
|
||||
NwcMakeInvoiceParams,
|
||||
NwcMakeInvoiceResult,
|
||||
NwcListTransactionsParams,
|
||||
NwcListTransactionsResult,
|
||||
NWC_METHODS,
|
||||
} from './types';
|
||||
|
||||
export interface NwcConnectionData {
|
||||
walletPubkey: string;
|
||||
relayUrl: string;
|
||||
secret: string;
|
||||
}
|
||||
|
||||
export type NwcLogLevel = 'info' | 'warn' | 'error';
|
||||
export type NwcLogCallback = (level: NwcLogLevel, message: string) => void;
|
||||
|
||||
interface PendingRequest {
|
||||
resolve: (value: NwcResponse) => void;
|
||||
reject: (reason: Error) => void;
|
||||
timeout: ReturnType<typeof setTimeout>;
|
||||
request: NwcRequest;
|
||||
isRetry: boolean;
|
||||
}
|
||||
|
||||
type EncryptionMode = 'nip44' | 'nip04';
|
||||
|
||||
/**
|
||||
* NWC Client for communicating with NIP-47 wallet services
|
||||
*/
|
||||
export class NwcClient {
|
||||
private ws: WebSocket | null = null;
|
||||
private connected = false;
|
||||
private pendingRequests = new Map<string, PendingRequest>();
|
||||
private subscriptionId: string | null = null;
|
||||
private conversationKey: Uint8Array;
|
||||
private clientPubkey: string;
|
||||
private encryptionMode: EncryptionMode = 'nip44';
|
||||
private logCallback: NwcLogCallback | null = null;
|
||||
|
||||
constructor(
|
||||
private connectionData: NwcConnectionData,
|
||||
logCallback?: NwcLogCallback
|
||||
) {
|
||||
this.logCallback = logCallback ?? null;
|
||||
// Derive the conversation key for NIP-44 encryption
|
||||
this.conversationKey = nip44.v2.utils.getConversationKey(
|
||||
NostrHelper.hex2bytes(connectionData.secret),
|
||||
connectionData.walletPubkey
|
||||
);
|
||||
// Derive our public key from the secret
|
||||
this.clientPubkey = getPublicKey(
|
||||
NostrHelper.hex2bytes(connectionData.secret)
|
||||
);
|
||||
}
|
||||
|
||||
private log(level: NwcLogLevel, message: string): void {
|
||||
if (this.logCallback) {
|
||||
this.logCallback(level, message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to the NWC relay
|
||||
*/
|
||||
async connect(): Promise<void> {
|
||||
if (this.connected) {
|
||||
return;
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
this.log('info', `Connecting to ${this.connectionData.relayUrl}...`);
|
||||
this.ws = new WebSocket(this.connectionData.relayUrl);
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
this.log('error', 'Connection timeout');
|
||||
reject(new Error('Connection timeout'));
|
||||
this.disconnect();
|
||||
}, 10000);
|
||||
|
||||
this.ws.onopen = () => {
|
||||
clearTimeout(timeout);
|
||||
this.connected = true;
|
||||
this.log('info', 'Connected to relay');
|
||||
this.subscribe();
|
||||
resolve();
|
||||
};
|
||||
|
||||
this.ws.onerror = () => {
|
||||
clearTimeout(timeout);
|
||||
this.log('error', 'WebSocket error');
|
||||
reject(new Error('WebSocket error'));
|
||||
};
|
||||
|
||||
this.ws.onclose = () => {
|
||||
this.connected = false;
|
||||
this.subscriptionId = null;
|
||||
// Reject all pending requests
|
||||
for (const [, pending] of this.pendingRequests) {
|
||||
clearTimeout(pending.timeout);
|
||||
pending.reject(new Error('Connection closed'));
|
||||
}
|
||||
this.pendingRequests.clear();
|
||||
};
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
this.handleMessage(event.data);
|
||||
};
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from the relay
|
||||
*/
|
||||
disconnect(): void {
|
||||
if (this.ws) {
|
||||
if (this.subscriptionId) {
|
||||
this.ws.send(JSON.stringify(['CLOSE', this.subscriptionId]));
|
||||
}
|
||||
this.ws.close();
|
||||
this.ws = null;
|
||||
}
|
||||
this.connected = false;
|
||||
this.subscriptionId = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if connected
|
||||
*/
|
||||
isConnected(): boolean {
|
||||
return this.connected && this.ws?.readyState === WebSocket.OPEN;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get wallet info
|
||||
*/
|
||||
async getInfo(): Promise<NwcGetInfoResult> {
|
||||
const response = await this.sendRequest({
|
||||
method: NWC_METHODS.GET_INFO,
|
||||
});
|
||||
|
||||
if (response.error) {
|
||||
throw new Error(response.error.message);
|
||||
}
|
||||
|
||||
return response.result as unknown as NwcGetInfoResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get wallet balance
|
||||
*/
|
||||
async getBalance(): Promise<NwcGetBalanceResult> {
|
||||
const response = await this.sendRequest({
|
||||
method: NWC_METHODS.GET_BALANCE,
|
||||
});
|
||||
|
||||
if (response.error) {
|
||||
throw new Error(response.error.message);
|
||||
}
|
||||
|
||||
return response.result as unknown as NwcGetBalanceResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pay a Lightning invoice
|
||||
*/
|
||||
async payInvoice(params: NwcPayInvoiceParams): Promise<NwcPayInvoiceResult> {
|
||||
const response = await this.sendRequest({
|
||||
method: NWC_METHODS.PAY_INVOICE,
|
||||
params: params as unknown as Record<string, unknown>,
|
||||
});
|
||||
|
||||
if (response.error) {
|
||||
throw new Error(response.error.message);
|
||||
}
|
||||
|
||||
return response.result as unknown as NwcPayInvoiceResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Lightning invoice
|
||||
*/
|
||||
async makeInvoice(
|
||||
params: NwcMakeInvoiceParams
|
||||
): Promise<NwcMakeInvoiceResult> {
|
||||
const response = await this.sendRequest({
|
||||
method: NWC_METHODS.MAKE_INVOICE,
|
||||
params: params as unknown as Record<string, unknown>,
|
||||
});
|
||||
|
||||
if (response.error) {
|
||||
throw new Error(response.error.message);
|
||||
}
|
||||
|
||||
return response.result as unknown as NwcMakeInvoiceResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* List transaction history
|
||||
*/
|
||||
async listTransactions(
|
||||
params?: NwcListTransactionsParams
|
||||
): Promise<NwcListTransactionsResult> {
|
||||
const response = await this.sendRequest({
|
||||
method: NWC_METHODS.LIST_TRANSACTIONS,
|
||||
params: params as unknown as Record<string, unknown>,
|
||||
});
|
||||
|
||||
if (response.error) {
|
||||
throw new Error(response.error.message);
|
||||
}
|
||||
|
||||
return response.result as unknown as NwcListTransactionsResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt content using current encryption mode
|
||||
*/
|
||||
private async encryptContent(plaintext: string): Promise<string> {
|
||||
if (this.encryptionMode === 'nip04') {
|
||||
return nip04.encrypt(
|
||||
this.connectionData.secret,
|
||||
this.connectionData.walletPubkey,
|
||||
plaintext
|
||||
);
|
||||
} else {
|
||||
return nip44.v2.encrypt(plaintext, this.conversationKey);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a request to the wallet
|
||||
*/
|
||||
private async sendRequest(
|
||||
request: NwcRequest,
|
||||
timeoutMs = 30000,
|
||||
isRetry = false
|
||||
): Promise<NwcResponse> {
|
||||
if (!this.isConnected()) {
|
||||
await this.connect();
|
||||
}
|
||||
|
||||
// Encrypt the request content
|
||||
const plaintext = JSON.stringify(request);
|
||||
this.log(
|
||||
'info',
|
||||
`Sending ${request.method} request (using ${this.encryptionMode.toUpperCase()})`
|
||||
);
|
||||
const ciphertext = await this.encryptContent(plaintext);
|
||||
|
||||
// Create the NIP-47 request event (kind 23194)
|
||||
const eventTemplate = {
|
||||
kind: 23194,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [['p', this.connectionData.walletPubkey]],
|
||||
content: ciphertext,
|
||||
};
|
||||
|
||||
// Sign with the client secret
|
||||
const signedEvent = finalizeEvent(
|
||||
eventTemplate,
|
||||
NostrHelper.hex2bytes(this.connectionData.secret)
|
||||
);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
this.pendingRequests.delete(signedEvent.id);
|
||||
this.log('error', `Request timeout for ${request.method}`);
|
||||
reject(new Error('Request timeout'));
|
||||
}, timeoutMs);
|
||||
|
||||
this.pendingRequests.set(signedEvent.id, {
|
||||
resolve,
|
||||
reject,
|
||||
timeout,
|
||||
request,
|
||||
isRetry,
|
||||
});
|
||||
|
||||
// Send the event
|
||||
this.ws!.send(JSON.stringify(['EVENT', signedEvent]));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry a request with NIP-04 encryption
|
||||
*/
|
||||
private async retryWithNip04(request: NwcRequest): Promise<NwcResponse> {
|
||||
this.log('warn', 'Retrying with NIP-04 encryption...');
|
||||
this.encryptionMode = 'nip04';
|
||||
return this.sendRequest(request, 30000, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to response events from the wallet
|
||||
*/
|
||||
private subscribe(): void {
|
||||
if (!this.ws || !this.connected) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate a subscription ID
|
||||
this.subscriptionId = Math.random().toString(36).substring(2, 15);
|
||||
|
||||
// Subscribe to kind 23195 (response) events addressed to us
|
||||
const filter = {
|
||||
kinds: [23195],
|
||||
'#p': [this.clientPubkey],
|
||||
since: Math.floor(Date.now() / 1000) - 10, // Last 10 seconds
|
||||
};
|
||||
|
||||
this.ws.send(JSON.stringify(['REQ', this.subscriptionId, filter]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming WebSocket messages
|
||||
*/
|
||||
private handleMessage(data: string): void {
|
||||
try {
|
||||
const message = JSON.parse(data);
|
||||
|
||||
if (!Array.isArray(message)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [type, ...rest] = message;
|
||||
|
||||
switch (type) {
|
||||
case 'EVENT':
|
||||
this.handleEvent(rest[1]);
|
||||
break;
|
||||
case 'OK':
|
||||
// Event was received by relay
|
||||
break;
|
||||
case 'EOSE':
|
||||
// End of stored events
|
||||
break;
|
||||
case 'NOTICE':
|
||||
this.log('warn', `Relay notice: ${rest[0]}`);
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
this.log('error', `Error parsing message: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an error indicates a decryption/encryption problem
|
||||
*/
|
||||
private isEncryptionError(errorMsg: string): boolean {
|
||||
const lowerMsg = errorMsg.toLowerCase();
|
||||
return (
|
||||
lowerMsg.includes('decrypt') ||
|
||||
lowerMsg.includes('initialization vector') ||
|
||||
lowerMsg.includes('iv') ||
|
||||
lowerMsg.includes('encrypt') ||
|
||||
lowerMsg.includes('cipher') ||
|
||||
lowerMsg.includes('parse')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an incoming event (response from wallet)
|
||||
*/
|
||||
private async handleEvent(event: any): Promise<void> {
|
||||
if (!event || event.kind !== 23195) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if this event is from the wallet
|
||||
if (event.pubkey !== this.connectionData.walletPubkey) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the request ID from the 'e' tag
|
||||
const eTag = event.tags?.find((t: string[]) => t[0] === 'e');
|
||||
if (!eTag) {
|
||||
return;
|
||||
}
|
||||
|
||||
const requestId = eTag[1];
|
||||
const pending = this.pendingRequests.get(requestId);
|
||||
|
||||
if (!pending) {
|
||||
// Response for unknown request (might be old or from another session)
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear the timeout and remove from pending
|
||||
clearTimeout(pending.timeout);
|
||||
this.pendingRequests.delete(requestId);
|
||||
|
||||
try {
|
||||
// Try to decrypt the response
|
||||
let decrypted: string;
|
||||
|
||||
// First, check if content looks like plain JSON (unencrypted error)
|
||||
if (
|
||||
event.content.startsWith('{') ||
|
||||
event.content.startsWith('"')
|
||||
) {
|
||||
// Might be unencrypted error response
|
||||
try {
|
||||
const parsed = JSON.parse(event.content);
|
||||
// If it has an error field, this is an unencrypted error response
|
||||
if (parsed.error) {
|
||||
this.log(
|
||||
'error',
|
||||
`Wallet error: ${parsed.error.message || JSON.stringify(parsed.error)}`
|
||||
);
|
||||
|
||||
// Check if it's an encryption error and we haven't retried yet
|
||||
const errorMsg =
|
||||
parsed.error.message || JSON.stringify(parsed.error);
|
||||
if (
|
||||
!pending.isRetry &&
|
||||
this.encryptionMode === 'nip44' &&
|
||||
this.isEncryptionError(errorMsg)
|
||||
) {
|
||||
this.log(
|
||||
'warn',
|
||||
'Wallet returned encryption error, switching to NIP-04'
|
||||
);
|
||||
try {
|
||||
const retryResponse = await this.retryWithNip04(pending.request);
|
||||
pending.resolve(retryResponse);
|
||||
return;
|
||||
} catch (retryError) {
|
||||
pending.reject(retryError as Error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
pending.resolve(parsed as NwcResponse);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Not valid JSON, continue with decryption
|
||||
}
|
||||
}
|
||||
|
||||
// Detect encryption format and decrypt
|
||||
// NIP-04 format contains "?iv=" in the ciphertext
|
||||
if (event.content.includes('?iv=')) {
|
||||
this.log('info', 'Decrypting response (NIP-04 format)');
|
||||
decrypted = await nip04.decrypt(
|
||||
this.connectionData.secret,
|
||||
this.connectionData.walletPubkey,
|
||||
event.content
|
||||
);
|
||||
} else {
|
||||
this.log('info', 'Decrypting response (NIP-44 format)');
|
||||
try {
|
||||
decrypted = nip44.v2.decrypt(event.content, this.conversationKey);
|
||||
} catch (nip44Error) {
|
||||
// NIP-44 decryption failed, maybe it's NIP-04 without standard format?
|
||||
// Try NIP-04 as fallback
|
||||
this.log(
|
||||
'warn',
|
||||
`NIP-44 decryption failed: ${(nip44Error as Error).message}, trying NIP-04...`
|
||||
);
|
||||
try {
|
||||
decrypted = await nip04.decrypt(
|
||||
this.connectionData.secret,
|
||||
this.connectionData.walletPubkey,
|
||||
event.content
|
||||
);
|
||||
} catch {
|
||||
// Both failed, throw original error
|
||||
throw nip44Error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const response = JSON.parse(decrypted) as NwcResponse;
|
||||
|
||||
// Check if the decrypted response contains an encryption error
|
||||
if (response.error) {
|
||||
const errorMsg = response.error.message || '';
|
||||
if (
|
||||
!pending.isRetry &&
|
||||
this.encryptionMode === 'nip44' &&
|
||||
this.isEncryptionError(errorMsg)
|
||||
) {
|
||||
this.log(
|
||||
'warn',
|
||||
`Wallet returned encryption error: ${errorMsg}, retrying with NIP-04`
|
||||
);
|
||||
try {
|
||||
const retryResponse = await this.retryWithNip04(pending.request);
|
||||
pending.resolve(retryResponse);
|
||||
return;
|
||||
} catch (retryError) {
|
||||
pending.reject(retryError as Error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
this.log('error', `Wallet error: ${errorMsg}`);
|
||||
} else {
|
||||
this.log('info', 'Request successful');
|
||||
}
|
||||
|
||||
pending.resolve(response);
|
||||
} catch (error) {
|
||||
const errorMsg = (error as Error).message;
|
||||
this.log('error', `Failed to decrypt response: ${errorMsg}`);
|
||||
|
||||
// If this is an encryption error and we haven't retried, try NIP-04
|
||||
if (
|
||||
!pending.isRetry &&
|
||||
this.encryptionMode === 'nip44' &&
|
||||
this.isEncryptionError(errorMsg)
|
||||
) {
|
||||
this.log('warn', 'Decryption failed, retrying with NIP-04 encryption');
|
||||
try {
|
||||
const retryResponse = await this.retryWithNip04(pending.request);
|
||||
pending.resolve(retryResponse);
|
||||
return;
|
||||
} catch (retryError) {
|
||||
pending.reject(retryError as Error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
pending.reject(new Error(`Failed to decrypt response: ${errorMsg}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
416
projects/common/src/lib/services/nwc/nwc.service.ts
Normal file
416
projects/common/src/lib/services/nwc/nwc.service.ts
Normal file
@@ -0,0 +1,416 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
import { StorageService, NwcConnection_DECRYPTED } from '@common';
|
||||
import { NwcClient, NwcConnectionData, NwcLogLevel, NwcLogCallback } from './nwc-client';
|
||||
import {
|
||||
NwcGetInfoResult,
|
||||
NwcPayInvoiceResult,
|
||||
NwcMakeInvoiceResult,
|
||||
NwcListTransactionsParams,
|
||||
NwcLookupInvoiceResult,
|
||||
} from './types';
|
||||
import { parseNwcUrl } from '../storage/related/nwc';
|
||||
|
||||
export interface NwcLogEntry {
|
||||
timestamp: Date;
|
||||
level: NwcLogLevel;
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface CachedClient {
|
||||
client: NwcClient;
|
||||
connectionId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Angular service for managing NWC wallet connections
|
||||
*/
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class NwcService {
|
||||
private clients = new Map<string, CachedClient>();
|
||||
private _logs$ = new BehaviorSubject<NwcLogEntry[]>([]);
|
||||
private maxLogs = 100;
|
||||
|
||||
/** Observable stream of NWC log entries */
|
||||
readonly logs$ = this._logs$.asObservable();
|
||||
|
||||
constructor(private storageService: StorageService) {}
|
||||
|
||||
/** Get current logs */
|
||||
get logs(): NwcLogEntry[] {
|
||||
return this._logs$.value;
|
||||
}
|
||||
|
||||
/** Clear all logs */
|
||||
clearLogs(): void {
|
||||
this._logs$.next([]);
|
||||
}
|
||||
|
||||
/** Add a log entry */
|
||||
private addLog(level: NwcLogLevel, message: string): void {
|
||||
const entry: NwcLogEntry = {
|
||||
timestamp: new Date(),
|
||||
level,
|
||||
message,
|
||||
};
|
||||
const logs = [entry, ...this._logs$.value].slice(0, this.maxLogs);
|
||||
this._logs$.next(logs);
|
||||
}
|
||||
|
||||
/** Create a log callback for the NWC client */
|
||||
private createLogCallback(): NwcLogCallback {
|
||||
return (level: NwcLogLevel, message: string) => {
|
||||
this.addLog(level, message);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and validate an NWC URL
|
||||
*/
|
||||
parseNwcUrl(url: string): {
|
||||
walletPubkey: string;
|
||||
relayUrl: string;
|
||||
secret: string;
|
||||
lud16?: string;
|
||||
} | null {
|
||||
return parseNwcUrl(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all NWC connections from storage
|
||||
*/
|
||||
getConnections(): NwcConnection_DECRYPTED[] {
|
||||
const sessionData =
|
||||
this.storageService.getBrowserSessionHandler().browserSessionData;
|
||||
return sessionData?.nwcConnections ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single NWC connection by ID
|
||||
*/
|
||||
getConnection(connectionId: string): NwcConnection_DECRYPTED | undefined {
|
||||
return this.getConnections().find((c) => c.id === connectionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new NWC connection
|
||||
*/
|
||||
async addConnection(name: string, connectionUrl: string): Promise<void> {
|
||||
await this.storageService.addNwcConnection({ name, connectionUrl });
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an NWC connection
|
||||
*/
|
||||
async deleteConnection(connectionId: string): Promise<void> {
|
||||
// Disconnect and remove the client if it exists
|
||||
this.disconnectClient(connectionId);
|
||||
await this.storageService.deleteNwcConnection(connectionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a connected client for a connection, creating it if necessary
|
||||
*/
|
||||
private async getClient(connectionId: string): Promise<NwcClient> {
|
||||
// Check if we have a cached client
|
||||
const cached = this.clients.get(connectionId);
|
||||
if (cached && cached.client.isConnected()) {
|
||||
return cached.client;
|
||||
}
|
||||
|
||||
// Get the connection data
|
||||
const connection = this.getConnection(connectionId);
|
||||
if (!connection) {
|
||||
throw new Error('Connection not found');
|
||||
}
|
||||
|
||||
// Create a new client
|
||||
const connectionData: NwcConnectionData = {
|
||||
walletPubkey: connection.walletPubkey,
|
||||
relayUrl: connection.relayUrl,
|
||||
secret: connection.secret,
|
||||
};
|
||||
|
||||
const client = new NwcClient(connectionData, this.createLogCallback());
|
||||
await client.connect();
|
||||
|
||||
// Cache the client
|
||||
this.clients.set(connectionId, {
|
||||
client,
|
||||
connectionId,
|
||||
});
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect a client
|
||||
*/
|
||||
private disconnectClient(connectionId: string): void {
|
||||
const cached = this.clients.get(connectionId);
|
||||
if (cached) {
|
||||
cached.client.disconnect();
|
||||
this.clients.delete(connectionId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect all clients
|
||||
*/
|
||||
disconnectAll(): void {
|
||||
for (const cached of this.clients.values()) {
|
||||
cached.client.disconnect();
|
||||
}
|
||||
this.clients.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get wallet info for a connection
|
||||
*/
|
||||
async getInfo(connectionId: string): Promise<NwcGetInfoResult> {
|
||||
const client = await this.getClient(connectionId);
|
||||
return client.getInfo();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get balance for a connection (in millisatoshis)
|
||||
*/
|
||||
async getBalance(connectionId: string): Promise<number> {
|
||||
const client = await this.getClient(connectionId);
|
||||
const result = await client.getBalance();
|
||||
|
||||
// Update the cached balance in storage
|
||||
await this.storageService.updateNwcConnectionBalance(
|
||||
connectionId,
|
||||
result.balance
|
||||
);
|
||||
|
||||
return result.balance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get balances for all connections
|
||||
* Returns a map of connectionId -> balance in millisatoshis
|
||||
*/
|
||||
async getAllBalances(): Promise<Map<string, number>> {
|
||||
const balances = new Map<string, number>();
|
||||
const connections = this.getConnections();
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
connections.map(async (conn) => {
|
||||
try {
|
||||
const balance = await this.getBalance(conn.id);
|
||||
return { id: conn.id, balance };
|
||||
} catch (error) {
|
||||
// Return cached balance if available
|
||||
if (conn.cachedBalance !== undefined) {
|
||||
return { id: conn.id, balance: conn.cachedBalance };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
for (const result of results) {
|
||||
if (result.status === 'fulfilled') {
|
||||
balances.set(result.value.id, result.value.balance);
|
||||
}
|
||||
}
|
||||
|
||||
return balances;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get total balance across all connections (in millisatoshis)
|
||||
*/
|
||||
async getTotalBalance(): Promise<number> {
|
||||
const balances = await this.getAllBalances();
|
||||
let total = 0;
|
||||
for (const balance of balances.values()) {
|
||||
total += balance;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached total balance (without making network requests)
|
||||
*/
|
||||
getCachedTotalBalance(): number {
|
||||
const connections = this.getConnections();
|
||||
let total = 0;
|
||||
for (const conn of connections) {
|
||||
if (conn.cachedBalance !== undefined) {
|
||||
total += conn.cachedBalance;
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pay a Lightning invoice
|
||||
*/
|
||||
async payInvoice(
|
||||
connectionId: string,
|
||||
invoice: string,
|
||||
amountMsat?: number
|
||||
): Promise<NwcPayInvoiceResult> {
|
||||
const client = await this.getClient(connectionId);
|
||||
const result = await client.payInvoice({
|
||||
invoice,
|
||||
amount: amountMsat,
|
||||
});
|
||||
|
||||
// Refresh balance after payment
|
||||
try {
|
||||
await this.getBalance(connectionId);
|
||||
} catch {
|
||||
// Ignore balance refresh errors
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Lightning invoice
|
||||
*/
|
||||
async makeInvoice(
|
||||
connectionId: string,
|
||||
amountMsat: number,
|
||||
description?: string
|
||||
): Promise<NwcMakeInvoiceResult> {
|
||||
const client = await this.getClient(connectionId);
|
||||
return client.makeInvoice({
|
||||
amount: amountMsat,
|
||||
description,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* List transaction history
|
||||
*/
|
||||
async listTransactions(
|
||||
connectionId: string,
|
||||
params?: NwcListTransactionsParams
|
||||
): Promise<NwcLookupInvoiceResult[]> {
|
||||
const client = await this.getClient(connectionId);
|
||||
const result = await client.listTransactions(params);
|
||||
return result.transactions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a Lightning Address (user@domain.com) to a bolt11 invoice
|
||||
* Uses LNURL-pay protocol
|
||||
*/
|
||||
async resolveLightningAddress(
|
||||
address: string,
|
||||
amountMsat: number
|
||||
): Promise<string> {
|
||||
// Parse lightning address
|
||||
const match = address.match(/^([^@]+)@([^@]+)$/);
|
||||
if (!match) {
|
||||
throw new Error('Invalid lightning address format');
|
||||
}
|
||||
|
||||
const [, name, domain] = match;
|
||||
|
||||
// Fetch LNURL-pay endpoint
|
||||
const lnurlpUrl = `https://${domain}/.well-known/lnurlp/${name}`;
|
||||
this.addLog('info', `Fetching LNURL-pay from ${domain}...`);
|
||||
|
||||
const response = await fetch(lnurlpUrl);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch LNURL-pay: ${response.status}`);
|
||||
}
|
||||
|
||||
const lnurlpData = await response.json();
|
||||
|
||||
// Validate response
|
||||
if (lnurlpData.status === 'ERROR') {
|
||||
throw new Error(lnurlpData.reason || 'LNURL-pay error');
|
||||
}
|
||||
|
||||
if (!lnurlpData.callback) {
|
||||
throw new Error('Invalid LNURL-pay response: missing callback');
|
||||
}
|
||||
|
||||
// Check amount bounds
|
||||
const minSendable = lnurlpData.minSendable || 1000;
|
||||
const maxSendable = lnurlpData.maxSendable || 100000000000;
|
||||
|
||||
if (amountMsat < minSendable) {
|
||||
throw new Error(
|
||||
`Amount too small. Minimum: ${Math.ceil(minSendable / 1000)} sats`
|
||||
);
|
||||
}
|
||||
|
||||
if (amountMsat > maxSendable) {
|
||||
throw new Error(
|
||||
`Amount too large. Maximum: ${Math.floor(maxSendable / 1000)} sats`
|
||||
);
|
||||
}
|
||||
|
||||
// Request invoice from callback
|
||||
const callbackUrl = new URL(lnurlpData.callback);
|
||||
callbackUrl.searchParams.set('amount', amountMsat.toString());
|
||||
|
||||
this.addLog('info', 'Requesting invoice...');
|
||||
const invoiceResponse = await fetch(callbackUrl.toString());
|
||||
if (!invoiceResponse.ok) {
|
||||
throw new Error(`Failed to get invoice: ${invoiceResponse.status}`);
|
||||
}
|
||||
|
||||
const invoiceData = await invoiceResponse.json();
|
||||
|
||||
if (invoiceData.status === 'ERROR') {
|
||||
throw new Error(invoiceData.reason || 'Failed to get invoice');
|
||||
}
|
||||
|
||||
if (!invoiceData.pr) {
|
||||
throw new Error('Invalid invoice response: missing payment request');
|
||||
}
|
||||
|
||||
this.addLog('info', 'Invoice received');
|
||||
return invoiceData.pr;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a string is a lightning address (user@domain)
|
||||
*/
|
||||
isLightningAddress(input: string): boolean {
|
||||
return /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(input);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a string is a bolt11 invoice
|
||||
*/
|
||||
isBolt11Invoice(input: string): boolean {
|
||||
return /^ln(bc|tb|tbs)[0-9a-z]+$/i.test(input.toLowerCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* Test a connection by getting wallet info
|
||||
*/
|
||||
async testConnection(connectionUrl: string): Promise<NwcGetInfoResult> {
|
||||
this.addLog('info', 'Testing NWC connection...');
|
||||
const parsed = this.parseNwcUrl(connectionUrl);
|
||||
if (!parsed) {
|
||||
this.addLog('error', 'Invalid NWC URL');
|
||||
throw new Error('Invalid NWC URL');
|
||||
}
|
||||
|
||||
const client = new NwcClient(parsed, this.createLogCallback());
|
||||
try {
|
||||
await client.connect();
|
||||
const info = await client.getInfo();
|
||||
this.addLog('info', `Connection test successful: ${info.alias || 'wallet'}`);
|
||||
return info;
|
||||
} catch (error) {
|
||||
this.addLog('error', `Connection test failed: ${(error as Error).message}`);
|
||||
throw error;
|
||||
} finally {
|
||||
client.disconnect();
|
||||
}
|
||||
}
|
||||
}
|
||||
130
projects/common/src/lib/services/nwc/types.ts
Normal file
130
projects/common/src/lib/services/nwc/types.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* NIP-47 NWC Protocol Types
|
||||
*/
|
||||
|
||||
export interface NwcRequest {
|
||||
method: string;
|
||||
params?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface NwcResponse {
|
||||
result_type: string;
|
||||
error?: {
|
||||
code: string;
|
||||
message: string;
|
||||
};
|
||||
result?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface NwcGetInfoResult {
|
||||
alias?: string;
|
||||
color?: string;
|
||||
pubkey?: string;
|
||||
network?: string;
|
||||
block_height?: number;
|
||||
block_hash?: string;
|
||||
methods?: string[];
|
||||
}
|
||||
|
||||
export interface NwcGetBalanceResult {
|
||||
balance: number; // Balance in millisatoshis
|
||||
}
|
||||
|
||||
export interface NwcPayInvoiceParams {
|
||||
invoice: string;
|
||||
amount?: number; // Optional amount in millisatoshis (for zero-amount invoices)
|
||||
}
|
||||
|
||||
export interface NwcPayInvoiceResult {
|
||||
preimage: string;
|
||||
}
|
||||
|
||||
export interface NwcMakeInvoiceParams {
|
||||
amount: number; // Amount in millisatoshis
|
||||
description?: string;
|
||||
description_hash?: string;
|
||||
expiry?: number; // Expiry in seconds
|
||||
}
|
||||
|
||||
export interface NwcMakeInvoiceResult {
|
||||
type: 'incoming';
|
||||
invoice: string;
|
||||
description?: string;
|
||||
description_hash?: string;
|
||||
preimage?: string;
|
||||
payment_hash: string;
|
||||
amount: number;
|
||||
fees_paid?: number;
|
||||
created_at: number;
|
||||
expires_at: number;
|
||||
settled_at?: number;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface NwcLookupInvoiceParams {
|
||||
payment_hash?: string;
|
||||
invoice?: string;
|
||||
}
|
||||
|
||||
export interface NwcLookupInvoiceResult {
|
||||
type: 'incoming' | 'outgoing';
|
||||
invoice?: string;
|
||||
description?: string;
|
||||
description_hash?: string;
|
||||
preimage?: string;
|
||||
payment_hash: string;
|
||||
amount: number;
|
||||
fees_paid?: number;
|
||||
created_at: number;
|
||||
expires_at?: number;
|
||||
settled_at?: number;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface NwcListTransactionsParams {
|
||||
from?: number;
|
||||
until?: number;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
unpaid?: boolean;
|
||||
type?: 'incoming' | 'outgoing';
|
||||
}
|
||||
|
||||
export interface NwcListTransactionsResult {
|
||||
transactions: NwcLookupInvoiceResult[];
|
||||
}
|
||||
|
||||
/**
|
||||
* NWC Error Codes
|
||||
*/
|
||||
export const NWC_ERROR_CODES = {
|
||||
RATE_LIMITED: 'RATE_LIMITED',
|
||||
NOT_IMPLEMENTED: 'NOT_IMPLEMENTED',
|
||||
INSUFFICIENT_BALANCE: 'INSUFFICIENT_BALANCE',
|
||||
QUOTA_EXCEEDED: 'QUOTA_EXCEEDED',
|
||||
RESTRICTED: 'RESTRICTED',
|
||||
UNAUTHORIZED: 'UNAUTHORIZED',
|
||||
INTERNAL: 'INTERNAL',
|
||||
OTHER: 'OTHER',
|
||||
PAYMENT_FAILED: 'PAYMENT_FAILED',
|
||||
NOT_FOUND: 'NOT_FOUND',
|
||||
} as const;
|
||||
|
||||
export type NwcErrorCode = (typeof NWC_ERROR_CODES)[keyof typeof NWC_ERROR_CODES];
|
||||
|
||||
/**
|
||||
* NWC Method names (from NIP-47)
|
||||
*/
|
||||
export const NWC_METHODS = {
|
||||
GET_INFO: 'get_info',
|
||||
GET_BALANCE: 'get_balance',
|
||||
PAY_INVOICE: 'pay_invoice',
|
||||
MAKE_INVOICE: 'make_invoice',
|
||||
LOOKUP_INVOICE: 'lookup_invoice',
|
||||
LIST_TRANSACTIONS: 'list_transactions',
|
||||
PAY_KEYSEND: 'pay_keysend',
|
||||
MULTI_PAY_INVOICE: 'multi_pay_invoice',
|
||||
MULTI_PAY_KEYSEND: 'multi_pay_keysend',
|
||||
} as const;
|
||||
|
||||
export type NwcMethod = (typeof NWC_METHODS)[keyof typeof NWC_METHODS];
|
||||
@@ -1,7 +1,9 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import {
|
||||
BrowserSyncData,
|
||||
CashuMint_ENCRYPTED,
|
||||
Identity_ENCRYPTED,
|
||||
NwcConnection_ENCRYPTED,
|
||||
Permission_ENCRYPTED,
|
||||
Relay_ENCRYPTED,
|
||||
} from './types';
|
||||
@@ -104,6 +106,38 @@ export abstract class BrowserSyncHandler {
|
||||
this.#browserSyncData.relays = Array.from(data.relays);
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the NWC connections to the sync data storage.
|
||||
*
|
||||
* ATTENTION: In your implementation, make sure to call "setPartialData_NwcConnections(..)" at the end to update the in-memory data.
|
||||
*/
|
||||
abstract saveAndSetPartialData_NwcConnections(data: {
|
||||
nwcConnections: NwcConnection_ENCRYPTED[];
|
||||
}): Promise<void>;
|
||||
setPartialData_NwcConnections(data: {
|
||||
nwcConnections: NwcConnection_ENCRYPTED[];
|
||||
}) {
|
||||
if (!this.#browserSyncData) {
|
||||
return;
|
||||
}
|
||||
this.#browserSyncData.nwcConnections = Array.from(data.nwcConnections);
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the Cashu mints to the sync data storage.
|
||||
*
|
||||
* ATTENTION: In your implementation, make sure to call "setPartialData_CashuMints(..)" at the end to update the in-memory data.
|
||||
*/
|
||||
abstract saveAndSetPartialData_CashuMints(data: {
|
||||
cashuMints: CashuMint_ENCRYPTED[];
|
||||
}): Promise<void>;
|
||||
setPartialData_CashuMints(data: { cashuMints: CashuMint_ENCRYPTED[] }) {
|
||||
if (!this.#browserSyncData) {
|
||||
return;
|
||||
}
|
||||
this.#browserSyncData.cashuMints = Array.from(data.cashuMints);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all data from the sync data storage.
|
||||
*/
|
||||
|
||||
361
projects/common/src/lib/services/storage/related/cashu.ts
Normal file
361
projects/common/src/lib/services/storage/related/cashu.ts
Normal file
@@ -0,0 +1,361 @@
|
||||
import {
|
||||
CryptoHelper,
|
||||
CashuMint_DECRYPTED,
|
||||
CashuMint_ENCRYPTED,
|
||||
CashuProof,
|
||||
StorageService,
|
||||
} from '@common';
|
||||
import { LockedVaultContext } from './identity';
|
||||
|
||||
/**
|
||||
* Validate a Cashu mint URL
|
||||
*/
|
||||
export function isValidMintUrl(url: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return parsed.protocol === 'https:' || parsed.protocol === 'http:';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export const addCashuMint = async function (
|
||||
this: StorageService,
|
||||
data: {
|
||||
name: string;
|
||||
mintUrl: string;
|
||||
unit?: string;
|
||||
}
|
||||
): Promise<CashuMint_DECRYPTED> {
|
||||
this.assureIsInitialized();
|
||||
|
||||
// Validate the mint URL
|
||||
if (!isValidMintUrl(data.mintUrl)) {
|
||||
throw new Error('Invalid mint URL format');
|
||||
}
|
||||
|
||||
// Normalize URL (remove trailing slash)
|
||||
const normalizedUrl = data.mintUrl.replace(/\/$/, '');
|
||||
|
||||
// Check if a mint with the same URL already exists
|
||||
const existingMint = (
|
||||
this.getBrowserSessionHandler().browserSessionData?.cashuMints ?? []
|
||||
).find((x) => x.mintUrl === normalizedUrl);
|
||||
if (existingMint) {
|
||||
throw new Error(
|
||||
`A connection to this mint already exists: ${existingMint.name}`
|
||||
);
|
||||
}
|
||||
|
||||
const browserSessionData = this.getBrowserSessionHandler().browserSessionData;
|
||||
if (!browserSessionData) {
|
||||
throw new Error('Browser session data is undefined.');
|
||||
}
|
||||
|
||||
const decryptedMint: CashuMint_DECRYPTED = {
|
||||
id: CryptoHelper.v4(),
|
||||
name: data.name,
|
||||
mintUrl: normalizedUrl,
|
||||
unit: data.unit ?? 'sat',
|
||||
createdAt: new Date().toISOString(),
|
||||
proofs: [], // Start with no proofs
|
||||
cachedBalance: 0,
|
||||
cachedBalanceAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// Initialize array if needed
|
||||
if (!browserSessionData.cashuMints) {
|
||||
browserSessionData.cashuMints = [];
|
||||
}
|
||||
|
||||
// Add the new mint to the session data
|
||||
browserSessionData.cashuMints.push(decryptedMint);
|
||||
this.getBrowserSessionHandler().saveFullData(browserSessionData);
|
||||
|
||||
// Encrypt the new mint and add it to the sync data
|
||||
const encryptedMint = await encryptCashuMint.call(this, decryptedMint);
|
||||
const encryptedMints = [
|
||||
...(this.getBrowserSyncHandler().browserSyncData?.cashuMints ?? []),
|
||||
encryptedMint,
|
||||
];
|
||||
|
||||
await this.getBrowserSyncHandler().saveAndSetPartialData_CashuMints({
|
||||
cashuMints: encryptedMints,
|
||||
});
|
||||
|
||||
return decryptedMint;
|
||||
};
|
||||
|
||||
export const deleteCashuMint = async function (
|
||||
this: StorageService,
|
||||
mintId: string
|
||||
): Promise<void> {
|
||||
this.assureIsInitialized();
|
||||
|
||||
if (!mintId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const browserSessionData = this.getBrowserSessionHandler().browserSessionData;
|
||||
const browserSyncData = this.getBrowserSyncHandler().browserSyncData;
|
||||
if (!browserSessionData || !browserSyncData) {
|
||||
throw new Error('Browser session or sync data is undefined.');
|
||||
}
|
||||
|
||||
// Remove from session data
|
||||
browserSessionData.cashuMints = (browserSessionData.cashuMints ?? []).filter(
|
||||
(x) => x.id !== mintId
|
||||
);
|
||||
await this.getBrowserSessionHandler().saveFullData(browserSessionData);
|
||||
|
||||
// Handle Sync data
|
||||
const encryptedMintId = await this.encrypt(mintId);
|
||||
await this.getBrowserSyncHandler().saveAndSetPartialData_CashuMints({
|
||||
cashuMints: (browserSyncData.cashuMints ?? []).filter(
|
||||
(x) => x.id !== encryptedMintId
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Update the proofs for a Cashu mint
|
||||
* This is called after send/receive operations
|
||||
*/
|
||||
export const updateCashuMintProofs = async function (
|
||||
this: StorageService,
|
||||
mintId: string,
|
||||
proofs: CashuProof[]
|
||||
): Promise<void> {
|
||||
this.assureIsInitialized();
|
||||
|
||||
const browserSessionData = this.getBrowserSessionHandler().browserSessionData;
|
||||
const browserSyncData = this.getBrowserSyncHandler().browserSyncData;
|
||||
if (!browserSessionData || !browserSyncData) {
|
||||
throw new Error('Browser session or sync data is undefined.');
|
||||
}
|
||||
|
||||
const sessionMint = (browserSessionData.cashuMints ?? []).find(
|
||||
(x) => x.id === mintId
|
||||
);
|
||||
const encryptedMintId = await this.encrypt(mintId);
|
||||
const syncMint = (browserSyncData.cashuMints ?? []).find(
|
||||
(x) => x.id === encryptedMintId
|
||||
);
|
||||
|
||||
if (!sessionMint || !syncMint) {
|
||||
throw new Error('Cashu mint not found for proofs update.');
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
// Calculate balance from proofs (sum of all proof amounts in satoshis)
|
||||
const balance = proofs.reduce((sum, p) => sum + p.amount, 0);
|
||||
|
||||
// Update session data
|
||||
sessionMint.proofs = proofs;
|
||||
sessionMint.cachedBalance = balance;
|
||||
sessionMint.cachedBalanceAt = now;
|
||||
await this.getBrowserSessionHandler().saveFullData(browserSessionData);
|
||||
|
||||
// Update sync data
|
||||
syncMint.proofs = await this.encrypt(JSON.stringify(proofs));
|
||||
syncMint.cachedBalance = await this.encrypt(balance.toString());
|
||||
syncMint.cachedBalanceAt = await this.encrypt(now);
|
||||
await this.getBrowserSyncHandler().saveAndSetPartialData_CashuMints({
|
||||
cashuMints: browserSyncData.cashuMints ?? [],
|
||||
});
|
||||
};
|
||||
|
||||
export const encryptCashuMint = async function (
|
||||
this: StorageService,
|
||||
mint: CashuMint_DECRYPTED
|
||||
): Promise<CashuMint_ENCRYPTED> {
|
||||
const encrypted: CashuMint_ENCRYPTED = {
|
||||
id: await this.encrypt(mint.id),
|
||||
name: await this.encrypt(mint.name),
|
||||
mintUrl: await this.encrypt(mint.mintUrl),
|
||||
unit: await this.encrypt(mint.unit),
|
||||
createdAt: await this.encrypt(mint.createdAt),
|
||||
proofs: await this.encrypt(JSON.stringify(mint.proofs)),
|
||||
};
|
||||
|
||||
if (mint.cachedBalance !== undefined) {
|
||||
encrypted.cachedBalance = await this.encrypt(mint.cachedBalance.toString());
|
||||
}
|
||||
if (mint.cachedBalanceAt) {
|
||||
encrypted.cachedBalanceAt = await this.encrypt(mint.cachedBalanceAt);
|
||||
}
|
||||
|
||||
return encrypted;
|
||||
};
|
||||
|
||||
export const decryptCashuMint = async function (
|
||||
this: StorageService,
|
||||
mint: CashuMint_ENCRYPTED,
|
||||
withLockedVault: LockedVaultContext | undefined = undefined
|
||||
): Promise<CashuMint_DECRYPTED> {
|
||||
if (typeof withLockedVault === 'undefined') {
|
||||
// Normal decryption with unlocked vault
|
||||
const proofsJson = await this.decrypt(mint.proofs, 'string');
|
||||
const decrypted: CashuMint_DECRYPTED = {
|
||||
id: await this.decrypt(mint.id, 'string'),
|
||||
name: await this.decrypt(mint.name, 'string'),
|
||||
mintUrl: await this.decrypt(mint.mintUrl, 'string'),
|
||||
unit: await this.decrypt(mint.unit, 'string'),
|
||||
createdAt: await this.decrypt(mint.createdAt, 'string'),
|
||||
proofs: JSON.parse(proofsJson) as CashuProof[],
|
||||
};
|
||||
|
||||
if (mint.cachedBalance) {
|
||||
decrypted.cachedBalance = await this.decrypt(mint.cachedBalance, 'number');
|
||||
}
|
||||
if (mint.cachedBalanceAt) {
|
||||
decrypted.cachedBalanceAt = await this.decrypt(
|
||||
mint.cachedBalanceAt,
|
||||
'string'
|
||||
);
|
||||
}
|
||||
|
||||
return decrypted;
|
||||
}
|
||||
|
||||
// v2: Use pre-derived key
|
||||
if (withLockedVault.keyBase64) {
|
||||
const proofsJson = await this.decryptWithLockedVaultV2(
|
||||
mint.proofs,
|
||||
'string',
|
||||
withLockedVault.iv,
|
||||
withLockedVault.keyBase64
|
||||
);
|
||||
const decrypted: CashuMint_DECRYPTED = {
|
||||
id: await this.decryptWithLockedVaultV2(
|
||||
mint.id,
|
||||
'string',
|
||||
withLockedVault.iv,
|
||||
withLockedVault.keyBase64
|
||||
),
|
||||
name: await this.decryptWithLockedVaultV2(
|
||||
mint.name,
|
||||
'string',
|
||||
withLockedVault.iv,
|
||||
withLockedVault.keyBase64
|
||||
),
|
||||
mintUrl: await this.decryptWithLockedVaultV2(
|
||||
mint.mintUrl,
|
||||
'string',
|
||||
withLockedVault.iv,
|
||||
withLockedVault.keyBase64
|
||||
),
|
||||
unit: await this.decryptWithLockedVaultV2(
|
||||
mint.unit,
|
||||
'string',
|
||||
withLockedVault.iv,
|
||||
withLockedVault.keyBase64
|
||||
),
|
||||
createdAt: await this.decryptWithLockedVaultV2(
|
||||
mint.createdAt,
|
||||
'string',
|
||||
withLockedVault.iv,
|
||||
withLockedVault.keyBase64
|
||||
),
|
||||
proofs: JSON.parse(proofsJson) as CashuProof[],
|
||||
};
|
||||
|
||||
if (mint.cachedBalance) {
|
||||
decrypted.cachedBalance = await this.decryptWithLockedVaultV2(
|
||||
mint.cachedBalance,
|
||||
'number',
|
||||
withLockedVault.iv,
|
||||
withLockedVault.keyBase64
|
||||
);
|
||||
}
|
||||
if (mint.cachedBalanceAt) {
|
||||
decrypted.cachedBalanceAt = await this.decryptWithLockedVaultV2(
|
||||
mint.cachedBalanceAt,
|
||||
'string',
|
||||
withLockedVault.iv,
|
||||
withLockedVault.keyBase64
|
||||
);
|
||||
}
|
||||
|
||||
return decrypted;
|
||||
}
|
||||
|
||||
// v1: Use password (PBKDF2)
|
||||
const proofsJson = await this.decryptWithLockedVault(
|
||||
mint.proofs,
|
||||
'string',
|
||||
withLockedVault.iv,
|
||||
withLockedVault.password!
|
||||
);
|
||||
const decrypted: CashuMint_DECRYPTED = {
|
||||
id: await this.decryptWithLockedVault(
|
||||
mint.id,
|
||||
'string',
|
||||
withLockedVault.iv,
|
||||
withLockedVault.password!
|
||||
),
|
||||
name: await this.decryptWithLockedVault(
|
||||
mint.name,
|
||||
'string',
|
||||
withLockedVault.iv,
|
||||
withLockedVault.password!
|
||||
),
|
||||
mintUrl: await this.decryptWithLockedVault(
|
||||
mint.mintUrl,
|
||||
'string',
|
||||
withLockedVault.iv,
|
||||
withLockedVault.password!
|
||||
),
|
||||
unit: await this.decryptWithLockedVault(
|
||||
mint.unit,
|
||||
'string',
|
||||
withLockedVault.iv,
|
||||
withLockedVault.password!
|
||||
),
|
||||
createdAt: await this.decryptWithLockedVault(
|
||||
mint.createdAt,
|
||||
'string',
|
||||
withLockedVault.iv,
|
||||
withLockedVault.password!
|
||||
),
|
||||
proofs: JSON.parse(proofsJson) as CashuProof[],
|
||||
};
|
||||
|
||||
if (mint.cachedBalance) {
|
||||
decrypted.cachedBalance = await this.decryptWithLockedVault(
|
||||
mint.cachedBalance,
|
||||
'number',
|
||||
withLockedVault.iv,
|
||||
withLockedVault.password!
|
||||
);
|
||||
}
|
||||
if (mint.cachedBalanceAt) {
|
||||
decrypted.cachedBalanceAt = await this.decryptWithLockedVault(
|
||||
mint.cachedBalanceAt,
|
||||
'string',
|
||||
withLockedVault.iv,
|
||||
withLockedVault.password!
|
||||
);
|
||||
}
|
||||
|
||||
return decrypted;
|
||||
};
|
||||
|
||||
export const decryptCashuMints = async function (
|
||||
this: StorageService,
|
||||
mints: CashuMint_ENCRYPTED[],
|
||||
withLockedVault: LockedVaultContext | undefined = undefined
|
||||
): Promise<CashuMint_DECRYPTED[]> {
|
||||
const decryptedMints: CashuMint_DECRYPTED[] = [];
|
||||
|
||||
for (const mint of mints) {
|
||||
const decryptedMint = await decryptCashuMint.call(
|
||||
this,
|
||||
mint,
|
||||
withLockedVault
|
||||
);
|
||||
decryptedMints.push(decryptedMint);
|
||||
}
|
||||
|
||||
return decryptedMints;
|
||||
};
|
||||
419
projects/common/src/lib/services/storage/related/nwc.ts
Normal file
419
projects/common/src/lib/services/storage/related/nwc.ts
Normal file
@@ -0,0 +1,419 @@
|
||||
import {
|
||||
CryptoHelper,
|
||||
NwcConnection_DECRYPTED,
|
||||
NwcConnection_ENCRYPTED,
|
||||
StorageService,
|
||||
} from '@common';
|
||||
import { LockedVaultContext } from './identity';
|
||||
|
||||
/**
|
||||
* Parse a nostr+walletconnect:// URL into its components
|
||||
*/
|
||||
export function parseNwcUrl(url: string): {
|
||||
walletPubkey: string;
|
||||
relayUrl: string;
|
||||
secret: string;
|
||||
lud16?: string;
|
||||
} | null {
|
||||
try {
|
||||
// Format: nostr+walletconnect://<pubkey>?relay=<url>&secret=<hex>&lud16=<optional>
|
||||
const match = url.match(/^nostr\+walletconnect:\/\/([a-f0-9]{64})\?(.+)$/i);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const walletPubkey = match[1].toLowerCase();
|
||||
const params = new URLSearchParams(match[2]);
|
||||
|
||||
const relayUrl = params.get('relay');
|
||||
const secret = params.get('secret');
|
||||
const lud16 = params.get('lud16') || undefined;
|
||||
|
||||
if (!relayUrl || !secret) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Validate secret is 64-char hex
|
||||
if (!/^[a-f0-9]{64}$/i.test(secret)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
walletPubkey,
|
||||
relayUrl: decodeURIComponent(relayUrl),
|
||||
secret: secret.toLowerCase(),
|
||||
lud16,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export const addNwcConnection = async function (
|
||||
this: StorageService,
|
||||
data: {
|
||||
name: string;
|
||||
connectionUrl: string;
|
||||
}
|
||||
): Promise<void> {
|
||||
this.assureIsInitialized();
|
||||
|
||||
// Parse the NWC URL
|
||||
const parsed = parseNwcUrl(data.connectionUrl);
|
||||
if (!parsed) {
|
||||
throw new Error('Invalid NWC URL format');
|
||||
}
|
||||
|
||||
// Check if a connection with the same wallet pubkey already exists
|
||||
const existingConnection = (
|
||||
this.getBrowserSessionHandler().browserSessionData?.nwcConnections ?? []
|
||||
).find((x) => x.walletPubkey === parsed.walletPubkey);
|
||||
if (existingConnection) {
|
||||
throw new Error(
|
||||
`A connection to this wallet already exists: ${existingConnection.name}`
|
||||
);
|
||||
}
|
||||
|
||||
const browserSessionData = this.getBrowserSessionHandler().browserSessionData;
|
||||
if (!browserSessionData) {
|
||||
throw new Error('Browser session data is undefined.');
|
||||
}
|
||||
|
||||
const decryptedConnection: NwcConnection_DECRYPTED = {
|
||||
id: CryptoHelper.v4(),
|
||||
name: data.name,
|
||||
connectionUrl: data.connectionUrl,
|
||||
walletPubkey: parsed.walletPubkey,
|
||||
relayUrl: parsed.relayUrl,
|
||||
secret: parsed.secret,
|
||||
lud16: parsed.lud16,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// Initialize array if needed
|
||||
if (!browserSessionData.nwcConnections) {
|
||||
browserSessionData.nwcConnections = [];
|
||||
}
|
||||
|
||||
// Add the new connection to the session data
|
||||
browserSessionData.nwcConnections.push(decryptedConnection);
|
||||
this.getBrowserSessionHandler().saveFullData(browserSessionData);
|
||||
|
||||
// Encrypt the new connection and add it to the sync data
|
||||
const encryptedConnection = await encryptNwcConnection.call(
|
||||
this,
|
||||
decryptedConnection
|
||||
);
|
||||
const encryptedConnections = [
|
||||
...(this.getBrowserSyncHandler().browserSyncData?.nwcConnections ?? []),
|
||||
encryptedConnection,
|
||||
];
|
||||
|
||||
await this.getBrowserSyncHandler().saveAndSetPartialData_NwcConnections({
|
||||
nwcConnections: encryptedConnections,
|
||||
});
|
||||
};
|
||||
|
||||
export const deleteNwcConnection = async function (
|
||||
this: StorageService,
|
||||
connectionId: string
|
||||
): Promise<void> {
|
||||
this.assureIsInitialized();
|
||||
|
||||
if (!connectionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const browserSessionData = this.getBrowserSessionHandler().browserSessionData;
|
||||
const browserSyncData = this.getBrowserSyncHandler().browserSyncData;
|
||||
if (!browserSessionData || !browserSyncData) {
|
||||
throw new Error('Browser session or sync data is undefined.');
|
||||
}
|
||||
|
||||
// Remove from session data
|
||||
browserSessionData.nwcConnections = (
|
||||
browserSessionData.nwcConnections ?? []
|
||||
).filter((x) => x.id !== connectionId);
|
||||
await this.getBrowserSessionHandler().saveFullData(browserSessionData);
|
||||
|
||||
// Handle Sync data
|
||||
const encryptedConnectionId = await this.encrypt(connectionId);
|
||||
await this.getBrowserSyncHandler().saveAndSetPartialData_NwcConnections({
|
||||
nwcConnections: (browserSyncData.nwcConnections ?? []).filter(
|
||||
(x) => x.id !== encryptedConnectionId
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
export const updateNwcConnectionBalance = async function (
|
||||
this: StorageService,
|
||||
connectionId: string,
|
||||
balanceMillisats: number
|
||||
): Promise<void> {
|
||||
this.assureIsInitialized();
|
||||
|
||||
const browserSessionData = this.getBrowserSessionHandler().browserSessionData;
|
||||
const browserSyncData = this.getBrowserSyncHandler().browserSyncData;
|
||||
if (!browserSessionData || !browserSyncData) {
|
||||
throw new Error('Browser session or sync data is undefined.');
|
||||
}
|
||||
|
||||
const sessionConnection = (browserSessionData.nwcConnections ?? []).find(
|
||||
(x) => x.id === connectionId
|
||||
);
|
||||
const encryptedConnectionId = await this.encrypt(connectionId);
|
||||
const syncConnection = (browserSyncData.nwcConnections ?? []).find(
|
||||
(x) => x.id === encryptedConnectionId
|
||||
);
|
||||
|
||||
if (!sessionConnection || !syncConnection) {
|
||||
throw new Error('NWC connection not found for balance update.');
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
|
||||
// Update session data
|
||||
sessionConnection.cachedBalance = balanceMillisats;
|
||||
sessionConnection.cachedBalanceAt = now;
|
||||
await this.getBrowserSessionHandler().saveFullData(browserSessionData);
|
||||
|
||||
// Update sync data
|
||||
syncConnection.cachedBalance = await this.encrypt(balanceMillisats.toString());
|
||||
syncConnection.cachedBalanceAt = await this.encrypt(now);
|
||||
await this.getBrowserSyncHandler().saveAndSetPartialData_NwcConnections({
|
||||
nwcConnections: browserSyncData.nwcConnections ?? [],
|
||||
});
|
||||
};
|
||||
|
||||
export const encryptNwcConnection = async function (
|
||||
this: StorageService,
|
||||
connection: NwcConnection_DECRYPTED
|
||||
): Promise<NwcConnection_ENCRYPTED> {
|
||||
const encrypted: NwcConnection_ENCRYPTED = {
|
||||
id: await this.encrypt(connection.id),
|
||||
name: await this.encrypt(connection.name),
|
||||
connectionUrl: await this.encrypt(connection.connectionUrl),
|
||||
walletPubkey: await this.encrypt(connection.walletPubkey),
|
||||
relayUrl: await this.encrypt(connection.relayUrl),
|
||||
secret: await this.encrypt(connection.secret),
|
||||
createdAt: await this.encrypt(connection.createdAt),
|
||||
};
|
||||
|
||||
if (connection.lud16) {
|
||||
encrypted.lud16 = await this.encrypt(connection.lud16);
|
||||
}
|
||||
if (connection.cachedBalance !== undefined) {
|
||||
encrypted.cachedBalance = await this.encrypt(
|
||||
connection.cachedBalance.toString()
|
||||
);
|
||||
}
|
||||
if (connection.cachedBalanceAt) {
|
||||
encrypted.cachedBalanceAt = await this.encrypt(connection.cachedBalanceAt);
|
||||
}
|
||||
|
||||
return encrypted;
|
||||
};
|
||||
|
||||
export const decryptNwcConnection = async function (
|
||||
this: StorageService,
|
||||
connection: NwcConnection_ENCRYPTED,
|
||||
withLockedVault: LockedVaultContext | undefined = undefined
|
||||
): Promise<NwcConnection_DECRYPTED> {
|
||||
if (typeof withLockedVault === 'undefined') {
|
||||
// Normal decryption with unlocked vault
|
||||
const decrypted: NwcConnection_DECRYPTED = {
|
||||
id: await this.decrypt(connection.id, 'string'),
|
||||
name: await this.decrypt(connection.name, 'string'),
|
||||
connectionUrl: await this.decrypt(connection.connectionUrl, 'string'),
|
||||
walletPubkey: await this.decrypt(connection.walletPubkey, 'string'),
|
||||
relayUrl: await this.decrypt(connection.relayUrl, 'string'),
|
||||
secret: await this.decrypt(connection.secret, 'string'),
|
||||
createdAt: await this.decrypt(connection.createdAt, 'string'),
|
||||
};
|
||||
|
||||
if (connection.lud16) {
|
||||
decrypted.lud16 = await this.decrypt(connection.lud16, 'string');
|
||||
}
|
||||
if (connection.cachedBalance) {
|
||||
decrypted.cachedBalance = await this.decrypt(
|
||||
connection.cachedBalance,
|
||||
'number'
|
||||
);
|
||||
}
|
||||
if (connection.cachedBalanceAt) {
|
||||
decrypted.cachedBalanceAt = await this.decrypt(
|
||||
connection.cachedBalanceAt,
|
||||
'string'
|
||||
);
|
||||
}
|
||||
|
||||
return decrypted;
|
||||
}
|
||||
|
||||
// v2: Use pre-derived key
|
||||
if (withLockedVault.keyBase64) {
|
||||
const decrypted: NwcConnection_DECRYPTED = {
|
||||
id: await this.decryptWithLockedVaultV2(
|
||||
connection.id,
|
||||
'string',
|
||||
withLockedVault.iv,
|
||||
withLockedVault.keyBase64
|
||||
),
|
||||
name: await this.decryptWithLockedVaultV2(
|
||||
connection.name,
|
||||
'string',
|
||||
withLockedVault.iv,
|
||||
withLockedVault.keyBase64
|
||||
),
|
||||
connectionUrl: await this.decryptWithLockedVaultV2(
|
||||
connection.connectionUrl,
|
||||
'string',
|
||||
withLockedVault.iv,
|
||||
withLockedVault.keyBase64
|
||||
),
|
||||
walletPubkey: await this.decryptWithLockedVaultV2(
|
||||
connection.walletPubkey,
|
||||
'string',
|
||||
withLockedVault.iv,
|
||||
withLockedVault.keyBase64
|
||||
),
|
||||
relayUrl: await this.decryptWithLockedVaultV2(
|
||||
connection.relayUrl,
|
||||
'string',
|
||||
withLockedVault.iv,
|
||||
withLockedVault.keyBase64
|
||||
),
|
||||
secret: await this.decryptWithLockedVaultV2(
|
||||
connection.secret,
|
||||
'string',
|
||||
withLockedVault.iv,
|
||||
withLockedVault.keyBase64
|
||||
),
|
||||
createdAt: await this.decryptWithLockedVaultV2(
|
||||
connection.createdAt,
|
||||
'string',
|
||||
withLockedVault.iv,
|
||||
withLockedVault.keyBase64
|
||||
),
|
||||
};
|
||||
|
||||
if (connection.lud16) {
|
||||
decrypted.lud16 = await this.decryptWithLockedVaultV2(
|
||||
connection.lud16,
|
||||
'string',
|
||||
withLockedVault.iv,
|
||||
withLockedVault.keyBase64
|
||||
);
|
||||
}
|
||||
if (connection.cachedBalance) {
|
||||
decrypted.cachedBalance = await this.decryptWithLockedVaultV2(
|
||||
connection.cachedBalance,
|
||||
'number',
|
||||
withLockedVault.iv,
|
||||
withLockedVault.keyBase64
|
||||
);
|
||||
}
|
||||
if (connection.cachedBalanceAt) {
|
||||
decrypted.cachedBalanceAt = await this.decryptWithLockedVaultV2(
|
||||
connection.cachedBalanceAt,
|
||||
'string',
|
||||
withLockedVault.iv,
|
||||
withLockedVault.keyBase64
|
||||
);
|
||||
}
|
||||
|
||||
return decrypted;
|
||||
}
|
||||
|
||||
// v1: Use password (PBKDF2)
|
||||
const decrypted: NwcConnection_DECRYPTED = {
|
||||
id: await this.decryptWithLockedVault(
|
||||
connection.id,
|
||||
'string',
|
||||
withLockedVault.iv,
|
||||
withLockedVault.password!
|
||||
),
|
||||
name: await this.decryptWithLockedVault(
|
||||
connection.name,
|
||||
'string',
|
||||
withLockedVault.iv,
|
||||
withLockedVault.password!
|
||||
),
|
||||
connectionUrl: await this.decryptWithLockedVault(
|
||||
connection.connectionUrl,
|
||||
'string',
|
||||
withLockedVault.iv,
|
||||
withLockedVault.password!
|
||||
),
|
||||
walletPubkey: await this.decryptWithLockedVault(
|
||||
connection.walletPubkey,
|
||||
'string',
|
||||
withLockedVault.iv,
|
||||
withLockedVault.password!
|
||||
),
|
||||
relayUrl: await this.decryptWithLockedVault(
|
||||
connection.relayUrl,
|
||||
'string',
|
||||
withLockedVault.iv,
|
||||
withLockedVault.password!
|
||||
),
|
||||
secret: await this.decryptWithLockedVault(
|
||||
connection.secret,
|
||||
'string',
|
||||
withLockedVault.iv,
|
||||
withLockedVault.password!
|
||||
),
|
||||
createdAt: await this.decryptWithLockedVault(
|
||||
connection.createdAt,
|
||||
'string',
|
||||
withLockedVault.iv,
|
||||
withLockedVault.password!
|
||||
),
|
||||
};
|
||||
|
||||
if (connection.lud16) {
|
||||
decrypted.lud16 = await this.decryptWithLockedVault(
|
||||
connection.lud16,
|
||||
'string',
|
||||
withLockedVault.iv,
|
||||
withLockedVault.password!
|
||||
);
|
||||
}
|
||||
if (connection.cachedBalance) {
|
||||
decrypted.cachedBalance = await this.decryptWithLockedVault(
|
||||
connection.cachedBalance,
|
||||
'number',
|
||||
withLockedVault.iv,
|
||||
withLockedVault.password!
|
||||
);
|
||||
}
|
||||
if (connection.cachedBalanceAt) {
|
||||
decrypted.cachedBalanceAt = await this.decryptWithLockedVault(
|
||||
connection.cachedBalanceAt,
|
||||
'string',
|
||||
withLockedVault.iv,
|
||||
withLockedVault.password!
|
||||
);
|
||||
}
|
||||
|
||||
return decrypted;
|
||||
};
|
||||
|
||||
export const decryptNwcConnections = async function (
|
||||
this: StorageService,
|
||||
connections: NwcConnection_ENCRYPTED[],
|
||||
withLockedVault: LockedVaultContext | undefined = undefined
|
||||
): Promise<NwcConnection_DECRYPTED[]> {
|
||||
const decryptedConnections: NwcConnection_DECRYPTED[] = [];
|
||||
|
||||
for (const connection of connections) {
|
||||
const decryptedConnection = await decryptNwcConnection.call(
|
||||
this,
|
||||
connection,
|
||||
withLockedVault
|
||||
);
|
||||
decryptedConnections.push(decryptedConnection);
|
||||
}
|
||||
|
||||
return decryptedConnections;
|
||||
};
|
||||
@@ -8,7 +8,9 @@ import {
|
||||
deriveKeyArgon2,
|
||||
} from '@common';
|
||||
import { Buffer } from 'buffer';
|
||||
import { decryptCashuMints, encryptCashuMint } from './cashu';
|
||||
import { decryptIdentities, encryptIdentity, LockedVaultContext } from './identity';
|
||||
import { decryptNwcConnections, encryptNwcConnection } from './nwc';
|
||||
import { decryptPermissions } from './permission';
|
||||
import { decryptRelays, encryptRelay } from './relay';
|
||||
|
||||
@@ -34,6 +36,8 @@ export const createNewVault = async function (
|
||||
identities: [],
|
||||
permissions: [],
|
||||
relays: [],
|
||||
nwcConnections: [],
|
||||
cashuMints: [],
|
||||
selectedIdentityId: null,
|
||||
};
|
||||
await this.getBrowserSessionHandler().saveFullData(sessionData);
|
||||
@@ -47,6 +51,8 @@ export const createNewVault = async function (
|
||||
identities: [],
|
||||
permissions: [],
|
||||
relays: [],
|
||||
nwcConnections: [],
|
||||
cashuMints: [],
|
||||
selectedIdentityId: null,
|
||||
};
|
||||
await this.getBrowserSyncHandler().saveAndSetFullData(syncData);
|
||||
@@ -133,6 +139,22 @@ export const unlockVault = async function (
|
||||
);
|
||||
console.log('[vault] Decrypted', decryptedRelays.length, 'relays');
|
||||
|
||||
console.log('[vault] Decrypting NWC connections...');
|
||||
const decryptedNwcConnections = await decryptNwcConnections.call(
|
||||
this,
|
||||
browserSyncData.nwcConnections ?? [],
|
||||
withLockedVault
|
||||
);
|
||||
console.log('[vault] Decrypted', decryptedNwcConnections.length, 'NWC connections');
|
||||
|
||||
console.log('[vault] Decrypting Cashu mints...');
|
||||
const decryptedCashuMints = await decryptCashuMints.call(
|
||||
this,
|
||||
browserSyncData.cashuMints ?? [],
|
||||
withLockedVault
|
||||
);
|
||||
console.log('[vault] Decrypted', decryptedCashuMints.length, 'Cashu mints');
|
||||
|
||||
console.log('[vault] Decrypting selectedIdentityId...');
|
||||
let decryptedSelectedIdentityId: string | null = null;
|
||||
if (browserSyncData.selectedIdentityId !== null) {
|
||||
@@ -163,6 +185,8 @@ export const unlockVault = async function (
|
||||
identities: decryptedIdentities,
|
||||
selectedIdentityId: decryptedSelectedIdentityId,
|
||||
relays: decryptedRelays,
|
||||
nwcConnections: decryptedNwcConnections,
|
||||
cashuMints: decryptedCashuMints,
|
||||
};
|
||||
|
||||
console.log('[vault] Saving session data...');
|
||||
@@ -234,6 +258,20 @@ async function migrateVaultV1ToV2(
|
||||
encryptedPermissions.push(encryptedPermission);
|
||||
}
|
||||
|
||||
// Re-encrypt NWC connections
|
||||
const encryptedNwcConnections = [];
|
||||
for (const nwcConnection of browserSessionData.nwcConnections ?? []) {
|
||||
const encrypted = await encryptNwcConnection.call(this, nwcConnection);
|
||||
encryptedNwcConnections.push(encrypted);
|
||||
}
|
||||
|
||||
// Re-encrypt Cashu mints
|
||||
const encryptedCashuMints = [];
|
||||
for (const cashuMint of browserSessionData.cashuMints ?? []) {
|
||||
const encrypted = await encryptCashuMint.call(this, cashuMint);
|
||||
encryptedCashuMints.push(encrypted);
|
||||
}
|
||||
|
||||
const encryptedSelectedIdentityId = browserSessionData.selectedIdentityId
|
||||
? await this.encrypt(browserSessionData.selectedIdentityId)
|
||||
: null;
|
||||
@@ -247,6 +285,8 @@ async function migrateVaultV1ToV2(
|
||||
identities: encryptedIdentities,
|
||||
permissions: encryptedPermissions,
|
||||
relays: encryptedRelays,
|
||||
nwcConnections: encryptedNwcConnections,
|
||||
cashuMints: encryptedCashuMints,
|
||||
selectedIdentityId: encryptedSelectedIdentityId,
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { Bookmark, BrowserSyncFlow, SignerMetaData } from './types';
|
||||
import { Bookmark, BrowserSyncData, BrowserSyncFlow, SignerMetaData, SignerMetaData_VaultSnapshot } from './types';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
export abstract class SignerMetaHandler {
|
||||
get signerMetaData(): SignerMetaData | undefined {
|
||||
@@ -8,7 +9,8 @@ export abstract class SignerMetaHandler {
|
||||
|
||||
#signerMetaData?: SignerMetaData;
|
||||
|
||||
readonly metaProperties = ['syncFlow', 'vaultSnapshots', 'recklessMode', 'whitelistedHosts', 'bookmarks'];
|
||||
readonly metaProperties = ['syncFlow', 'vaultSnapshots', 'maxBackups', 'recklessMode', 'whitelistedHosts', 'bookmarks'];
|
||||
readonly DEFAULT_MAX_BACKUPS = 5;
|
||||
/**
|
||||
* Load the full data from the storage. If the storage is used for storing
|
||||
* other data (e.g. browser sync data when the user decided to NOT sync),
|
||||
@@ -111,4 +113,120 @@ export abstract class SignerMetaHandler {
|
||||
getBookmarks(): Bookmark[] {
|
||||
return this.#signerMetaData?.bookmarks ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the maximum number of backups to keep.
|
||||
*/
|
||||
getMaxBackups(): number {
|
||||
return this.#signerMetaData?.maxBackups ?? this.DEFAULT_MAX_BACKUPS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the maximum number of backups to keep and immediately saves it.
|
||||
*/
|
||||
async setMaxBackups(count: number): Promise<void> {
|
||||
const clampedCount = Math.max(1, Math.min(20, count)); // Clamp between 1-20
|
||||
if (!this.#signerMetaData) {
|
||||
this.#signerMetaData = {
|
||||
maxBackups: clampedCount,
|
||||
};
|
||||
} else {
|
||||
this.#signerMetaData.maxBackups = clampedCount;
|
||||
}
|
||||
|
||||
await this.saveFullData(this.#signerMetaData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all vault backups, sorted newest first.
|
||||
*/
|
||||
getBackups(): SignerMetaData_VaultSnapshot[] {
|
||||
const backups = this.#signerMetaData?.vaultSnapshots ?? [];
|
||||
return [...backups].sort((a, b) =>
|
||||
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a specific backup by ID.
|
||||
*/
|
||||
getBackupById(id: string): SignerMetaData_VaultSnapshot | undefined {
|
||||
return this.#signerMetaData?.vaultSnapshots?.find(b => b.id === id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new backup of the vault data.
|
||||
* Automatically removes old backups if exceeding maxBackups.
|
||||
*/
|
||||
async createBackup(
|
||||
browserSyncData: BrowserSyncData,
|
||||
reason: 'manual' | 'auto' | 'pre-restore' = 'manual'
|
||||
): Promise<SignerMetaData_VaultSnapshot> {
|
||||
const now = new Date();
|
||||
const dateTimeString = now.toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
||||
const identityCount = browserSyncData.identities?.length ?? 0;
|
||||
|
||||
const snapshot: SignerMetaData_VaultSnapshot = {
|
||||
id: uuidv4(),
|
||||
fileName: `Vault Backup - ${dateTimeString}`,
|
||||
createdAt: now.toISOString(),
|
||||
data: JSON.parse(JSON.stringify(browserSyncData)), // Deep clone
|
||||
identityCount,
|
||||
reason,
|
||||
};
|
||||
|
||||
if (!this.#signerMetaData) {
|
||||
this.#signerMetaData = {
|
||||
vaultSnapshots: [snapshot],
|
||||
};
|
||||
} else {
|
||||
const existingBackups = this.#signerMetaData.vaultSnapshots ?? [];
|
||||
existingBackups.push(snapshot);
|
||||
|
||||
// Enforce max backups limit (only for auto backups, keep manual and pre-restore)
|
||||
const maxBackups = this.getMaxBackups();
|
||||
const autoBackups = existingBackups.filter(b => b.reason === 'auto');
|
||||
const otherBackups = existingBackups.filter(b => b.reason !== 'auto');
|
||||
|
||||
// Sort auto backups by date (newest first) and keep only maxBackups
|
||||
autoBackups.sort((a, b) =>
|
||||
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||
);
|
||||
const trimmedAutoBackups = autoBackups.slice(0, maxBackups);
|
||||
|
||||
this.#signerMetaData.vaultSnapshots = [...otherBackups, ...trimmedAutoBackups];
|
||||
}
|
||||
|
||||
await this.saveFullData(this.#signerMetaData);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a backup by ID.
|
||||
*/
|
||||
async deleteBackup(backupId: string): Promise<boolean> {
|
||||
if (!this.#signerMetaData?.vaultSnapshots) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const initialLength = this.#signerMetaData.vaultSnapshots.length;
|
||||
this.#signerMetaData.vaultSnapshots = this.#signerMetaData.vaultSnapshots.filter(
|
||||
b => b.id !== backupId
|
||||
);
|
||||
|
||||
if (this.#signerMetaData.vaultSnapshots.length < initialLength) {
|
||||
await this.saveFullData(this.#signerMetaData);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the data from a backup for restoration.
|
||||
* Note: The caller should create a pre-restore backup before calling this.
|
||||
*/
|
||||
getBackupData(backupId: string): BrowserSyncData | undefined {
|
||||
const backup = this.getBackupById(backupId);
|
||||
return backup?.data;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,17 @@ import {
|
||||
import { deletePermission } from './related/permission';
|
||||
import { createNewVault, deleteVault, unlockVault } from './related/vault';
|
||||
import { addRelay, deleteRelay, updateRelay } from './related/relay';
|
||||
import {
|
||||
addNwcConnection,
|
||||
deleteNwcConnection,
|
||||
updateNwcConnectionBalance,
|
||||
} from './related/nwc';
|
||||
import {
|
||||
addCashuMint,
|
||||
deleteCashuMint,
|
||||
updateCashuMintProofs,
|
||||
} from './related/cashu';
|
||||
import { CashuMint_DECRYPTED, CashuProof } from './types';
|
||||
|
||||
export interface StorageServiceConfig {
|
||||
browserSessionHandler: BrowserSessionHandler;
|
||||
@@ -176,6 +187,43 @@ export class StorageService {
|
||||
await updateRelay.call(this, relayClone);
|
||||
}
|
||||
|
||||
async addNwcConnection(data: {
|
||||
name: string;
|
||||
connectionUrl: string;
|
||||
}): Promise<void> {
|
||||
await addNwcConnection.call(this, data);
|
||||
}
|
||||
|
||||
async deleteNwcConnection(connectionId: string): Promise<void> {
|
||||
await deleteNwcConnection.call(this, connectionId);
|
||||
}
|
||||
|
||||
async updateNwcConnectionBalance(
|
||||
connectionId: string,
|
||||
balanceMillisats: number
|
||||
): Promise<void> {
|
||||
await updateNwcConnectionBalance.call(this, connectionId, balanceMillisats);
|
||||
}
|
||||
|
||||
async addCashuMint(data: {
|
||||
name: string;
|
||||
mintUrl: string;
|
||||
unit?: string;
|
||||
}): Promise<CashuMint_DECRYPTED> {
|
||||
return await addCashuMint.call(this, data);
|
||||
}
|
||||
|
||||
async deleteCashuMint(mintId: string): Promise<void> {
|
||||
await deleteCashuMint.call(this, mintId);
|
||||
}
|
||||
|
||||
async updateCashuMintProofs(
|
||||
mintId: string,
|
||||
proofs: CashuProof[]
|
||||
): Promise<void> {
|
||||
await updateCashuMintProofs.call(this, mintId, proofs);
|
||||
}
|
||||
|
||||
exportVault(): string {
|
||||
this.assureIsInitialized();
|
||||
const vaultJson = JSON.stringify(
|
||||
@@ -226,6 +274,17 @@ export class StorageService {
|
||||
return this.#signerMetaHandler;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current browser sync flow setting.
|
||||
* Returns NO_SYNC if not initialized or no setting found.
|
||||
*/
|
||||
getSyncFlow(): BrowserSyncFlow {
|
||||
if (!this.isInitialized || !this.#signerMetaHandler?.signerMetaData) {
|
||||
return BrowserSyncFlow.NO_SYNC;
|
||||
}
|
||||
return this.#signerMetaHandler.signerMetaData.syncFlow ?? BrowserSyncFlow.NO_SYNC;
|
||||
}
|
||||
|
||||
/**
|
||||
* Throws an exception if the service is not initialized.
|
||||
*/
|
||||
|
||||
@@ -43,6 +43,80 @@ export interface Relay_ENCRYPTED {
|
||||
write: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* NWC (Nostr Wallet Connect) connection - Decrypted
|
||||
* Stores NIP-47 wallet connection data
|
||||
*/
|
||||
export interface NwcConnection_DECRYPTED {
|
||||
id: string;
|
||||
name: string; // User-defined wallet name
|
||||
connectionUrl: string; // Full nostr+walletconnect:// URL
|
||||
walletPubkey: string; // Wallet service pubkey
|
||||
relayUrl: string; // Relay URL for NWC communication
|
||||
secret: string; // Client secret key (32-byte hex)
|
||||
lud16?: string; // Optional lightning address
|
||||
createdAt: string; // ISO timestamp
|
||||
cachedBalance?: number; // Balance in millisatoshis
|
||||
cachedBalanceAt?: string; // ISO timestamp when balance was fetched
|
||||
}
|
||||
|
||||
/**
|
||||
* NWC connection - Encrypted for storage
|
||||
*/
|
||||
export interface NwcConnection_ENCRYPTED {
|
||||
id: string;
|
||||
name: string;
|
||||
connectionUrl: string;
|
||||
walletPubkey: string;
|
||||
relayUrl: string;
|
||||
secret: string;
|
||||
lud16?: string;
|
||||
createdAt: string;
|
||||
cachedBalance?: string; // Encrypted as string
|
||||
cachedBalanceAt?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cashu Proof - represents a single ecash token
|
||||
* This is the actual money stored locally
|
||||
*/
|
||||
export interface CashuProof {
|
||||
id: string; // Keyset ID from mint
|
||||
amount: number; // Satoshi amount
|
||||
secret: string; // Blinded secret
|
||||
C: string; // Unblinded signature (commitment)
|
||||
receivedAt?: string; // ISO timestamp when token was received
|
||||
}
|
||||
|
||||
/**
|
||||
* Cashu Mint Connection - Decrypted
|
||||
* Stores NIP-60 Cashu mint connection data with local proofs
|
||||
*/
|
||||
export interface CashuMint_DECRYPTED {
|
||||
id: string;
|
||||
name: string; // User-defined mint name
|
||||
mintUrl: string; // Mint API URL
|
||||
unit: string; // Unit (default: 'sat')
|
||||
createdAt: string; // ISO timestamp
|
||||
proofs: CashuProof[]; // Unspent proofs for this mint
|
||||
cachedBalance?: number; // Sum of proof amounts (sats)
|
||||
cachedBalanceAt?: string; // When balance was calculated
|
||||
}
|
||||
|
||||
/**
|
||||
* Cashu Mint Connection - Encrypted for storage
|
||||
*/
|
||||
export interface CashuMint_ENCRYPTED {
|
||||
id: string;
|
||||
name: string;
|
||||
mintUrl: string;
|
||||
unit: string;
|
||||
createdAt: string;
|
||||
proofs: string; // JSON stringified and encrypted
|
||||
cachedBalance?: string;
|
||||
cachedBalanceAt?: string;
|
||||
}
|
||||
|
||||
export interface BrowserSyncData_PART_Unencrypted {
|
||||
version: number;
|
||||
iv: string;
|
||||
@@ -57,6 +131,8 @@ export interface BrowserSyncData_PART_Encrypted {
|
||||
permissions: Permission_ENCRYPTED[];
|
||||
identities: Identity_ENCRYPTED[];
|
||||
relays: Relay_ENCRYPTED[];
|
||||
nwcConnections?: NwcConnection_ENCRYPTED[];
|
||||
cashuMints?: CashuMint_ENCRYPTED[];
|
||||
}
|
||||
|
||||
export type BrowserSyncData = BrowserSyncData_PART_Unencrypted &
|
||||
@@ -83,11 +159,17 @@ export interface BrowserSessionData {
|
||||
identities: Identity_DECRYPTED[];
|
||||
selectedIdentityId: string | null;
|
||||
relays: Relay_DECRYPTED[];
|
||||
nwcConnections?: NwcConnection_DECRYPTED[];
|
||||
cashuMints?: CashuMint_DECRYPTED[];
|
||||
}
|
||||
|
||||
export interface SignerMetaData_VaultSnapshot {
|
||||
id: string;
|
||||
fileName: string;
|
||||
createdAt: string; // ISO timestamp
|
||||
data: BrowserSyncData;
|
||||
identityCount: number;
|
||||
reason?: 'manual' | 'auto' | 'pre-restore'; // Why was this backup created
|
||||
}
|
||||
|
||||
export const SIGNER_META_DATA_KEY = {
|
||||
@@ -109,6 +191,9 @@ export interface SignerMetaData {
|
||||
|
||||
vaultSnapshots?: SignerMetaData_VaultSnapshot[];
|
||||
|
||||
// Maximum number of automatic backups to keep (default: 5)
|
||||
maxBackups?: number;
|
||||
|
||||
// Reckless mode: auto-approve all actions without prompting
|
||||
recklessMode?: boolean;
|
||||
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
.sam-text-header {
|
||||
background: var(--background);
|
||||
z-index: 20;
|
||||
padding-top: var(--size);
|
||||
padding-bottom: var(--size);
|
||||
height: 48px;
|
||||
min-height: 48px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
|
||||
span {
|
||||
font-family: var(--font-heading);
|
||||
@@ -14,6 +15,28 @@
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1rem;
|
||||
}
|
||||
|
||||
.lock-btn {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 8px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.2s;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--background-light);
|
||||
}
|
||||
|
||||
.emoji {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sam-footer-grid-2 {
|
||||
|
||||
@@ -26,6 +26,13 @@ export * from './lib/services/storage/types';
|
||||
export * from './lib/services/storage/browser-sync-handler';
|
||||
export * from './lib/services/storage/browser-session-handler';
|
||||
export * from './lib/services/storage/signer-meta-handler';
|
||||
export * from './lib/services/storage/related/nwc';
|
||||
export * from './lib/services/storage/related/cashu';
|
||||
export * from './lib/services/nwc/nwc.service';
|
||||
export * from './lib/services/nwc/nwc-client';
|
||||
export * from './lib/services/nwc/types';
|
||||
export * from './lib/services/cashu/cashu.service';
|
||||
export * from './lib/services/cashu/types';
|
||||
export * from './lib/services/logger/logger.service';
|
||||
export * from './lib/services/startup/startup.service';
|
||||
export * from './lib/services/profile-metadata/profile-metadata.service';
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
"manifest_version": 3,
|
||||
"name": "Plebeian Signer",
|
||||
"description": "Nostr Identity Manager & Signer",
|
||||
"version": "1.0.5",
|
||||
"homepage_url": "https://git.mleku.dev/mleku/plebeian-signer",
|
||||
"version": "1.0.9",
|
||||
"homepage_url": "https://github.com/PlebeianApp/plebeian-signer",
|
||||
"options_page": "options.html",
|
||||
"permissions": [
|
||||
"storage"
|
||||
|
||||
@@ -8,6 +8,8 @@ import { InfoComponent } from './components/home/info/info.component';
|
||||
import { SettingsComponent } from './components/home/settings/settings.component';
|
||||
import { LogsComponent } from './components/home/logs/logs.component';
|
||||
import { BookmarksComponent } from './components/home/bookmarks/bookmarks.component';
|
||||
import { WalletComponent } from './components/home/wallet/wallet.component';
|
||||
import { BackupsComponent } from './components/home/backups/backups.component';
|
||||
import { NewIdentityComponent } from './components/new-identity/new-identity.component';
|
||||
import { EditIdentityComponent } from './components/edit-identity/edit-identity.component';
|
||||
import { HomeComponent as EditIdentityHomeComponent } from './components/edit-identity/home/home.component';
|
||||
@@ -76,6 +78,14 @@ export const routes: Routes = [
|
||||
path: 'bookmarks',
|
||||
component: BookmarksComponent,
|
||||
},
|
||||
{
|
||||
path: 'wallet',
|
||||
component: WalletComponent,
|
||||
},
|
||||
{
|
||||
path: 'backups',
|
||||
component: BackupsComponent,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import {
|
||||
BrowserSyncData,
|
||||
CashuMint_ENCRYPTED,
|
||||
Identity_ENCRYPTED,
|
||||
NwcConnection_ENCRYPTED,
|
||||
Permission_ENCRYPTED,
|
||||
BrowserSyncHandler,
|
||||
Relay_ENCRYPTED,
|
||||
@@ -56,6 +58,20 @@ export class FirefoxSyncNoHandler extends BrowserSyncHandler {
|
||||
this.setPartialData_Relays(data);
|
||||
}
|
||||
|
||||
async saveAndSetPartialData_NwcConnections(data: {
|
||||
nwcConnections: NwcConnection_ENCRYPTED[];
|
||||
}): Promise<void> {
|
||||
await browser.storage.local.set(data);
|
||||
this.setPartialData_NwcConnections(data);
|
||||
}
|
||||
|
||||
async saveAndSetPartialData_CashuMints(data: {
|
||||
cashuMints: CashuMint_ENCRYPTED[];
|
||||
}): Promise<void> {
|
||||
await browser.storage.local.set(data);
|
||||
this.setPartialData_CashuMints(data);
|
||||
}
|
||||
|
||||
async clearData(): Promise<void> {
|
||||
const props = Object.keys(await this.loadUnmigratedData());
|
||||
await browser.storage.local.remove(props);
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import {
|
||||
BrowserSyncData,
|
||||
CashuMint_ENCRYPTED,
|
||||
Identity_ENCRYPTED,
|
||||
NwcConnection_ENCRYPTED,
|
||||
Permission_ENCRYPTED,
|
||||
BrowserSyncHandler,
|
||||
Relay_ENCRYPTED,
|
||||
@@ -50,6 +52,20 @@ export class FirefoxSyncYesHandler extends BrowserSyncHandler {
|
||||
this.setPartialData_Relays(data);
|
||||
}
|
||||
|
||||
async saveAndSetPartialData_NwcConnections(data: {
|
||||
nwcConnections: NwcConnection_ENCRYPTED[];
|
||||
}): Promise<void> {
|
||||
await browser.storage.sync.set(data);
|
||||
this.setPartialData_NwcConnections(data);
|
||||
}
|
||||
|
||||
async saveAndSetPartialData_CashuMints(data: {
|
||||
cashuMints: CashuMint_ENCRYPTED[];
|
||||
}): Promise<void> {
|
||||
await browser.storage.sync.set(data);
|
||||
this.setPartialData_CashuMints(data);
|
||||
}
|
||||
|
||||
async clearData(): Promise<void> {
|
||||
await browser.storage.sync.clear();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
<div class="sam-text-header">
|
||||
<button class="lock-btn" title="Lock" (click)="onClickLock()">
|
||||
<span class="emoji">🔒</span>
|
||||
</button>
|
||||
<button class="back-btn" title="Go Back" (click)="goBack()">
|
||||
<span class="emoji">←</span>
|
||||
</button>
|
||||
<span>Backups</span>
|
||||
</div>
|
||||
|
||||
<div class="backup-settings">
|
||||
<div class="setting-row">
|
||||
<label for="maxBackups">Max Auto Backups:</label>
|
||||
<input
|
||||
id="maxBackups"
|
||||
type="number"
|
||||
[value]="maxBackups"
|
||||
min="1"
|
||||
max="20"
|
||||
(change)="onMaxBackupsChange($event)"
|
||||
/>
|
||||
</div>
|
||||
<p class="setting-note">
|
||||
Automatic backups are created when significant changes are made.
|
||||
Manual and pre-restore backups are not counted toward this limit.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary create-btn" (click)="createManualBackup()">
|
||||
Create Backup Now
|
||||
</button>
|
||||
|
||||
<div class="backups-list">
|
||||
@if (backups.length === 0) {
|
||||
<div class="empty-state">
|
||||
<span>No backups yet</span>
|
||||
</div>
|
||||
}
|
||||
@for (backup of backups; track backup.id) {
|
||||
<div class="backup-item">
|
||||
<div class="backup-info">
|
||||
<span class="backup-date">{{ formatDate(backup.createdAt) }}</span>
|
||||
<div class="backup-meta">
|
||||
<span class="backup-reason" [class]="getReasonClass(backup.reason)">
|
||||
{{ getReasonLabel(backup.reason) }}
|
||||
</span>
|
||||
<span class="backup-identities">{{ backup.identityCount }} identity(ies)</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="backup-actions">
|
||||
<button
|
||||
class="btn btn-sm btn-secondary"
|
||||
(click)="
|
||||
confirm.show(
|
||||
'Restore this backup? A backup of your current state will be created first.',
|
||||
restoreBackup.bind(this, backup.id)
|
||||
)
|
||||
"
|
||||
[disabled]="restoringBackupId !== null"
|
||||
>
|
||||
{{ restoringBackupId === backup.id ? 'Restoring...' : 'Restore' }}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm btn-danger"
|
||||
(click)="
|
||||
confirm.show(
|
||||
'Delete this backup? This cannot be undone.',
|
||||
deleteBackup.bind(this, backup.id)
|
||||
)
|
||||
"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<lib-confirm #confirm></lib-confirm>
|
||||
@@ -0,0 +1,192 @@
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
padding: 8px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.sam-text-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.lock-btn,
|
||||
.back-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
background: var(--muted);
|
||||
}
|
||||
|
||||
.emoji {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.backup-settings {
|
||||
background: var(--muted);
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.setting-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
label {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
input[type="number"] {
|
||||
width: 60px;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
}
|
||||
}
|
||||
|
||||
.setting-note {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.create-btn {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.backups-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100px;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.backup-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.backup-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.backup-date {
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.backup-meta {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.backup-reason {
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
|
||||
&.reason-auto {
|
||||
background: var(--muted);
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
&.reason-manual {
|
||||
background: rgba(34, 197, 94, 0.2);
|
||||
color: rgb(34, 197, 94);
|
||||
}
|
||||
|
||||
&.reason-prerestore {
|
||||
background: rgba(234, 179, 8, 0.2);
|
||||
color: rgb(234, 179, 8);
|
||||
}
|
||||
}
|
||||
|
||||
.backup-identities {
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.backup-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
transition: background-color 0.2s;
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--primary);
|
||||
color: var(--primary-foreground);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--secondary);
|
||||
color: var(--secondary-foreground);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--muted);
|
||||
}
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
color: rgb(239, 68, 68);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { Component, inject, OnInit } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import {
|
||||
ConfirmComponent,
|
||||
LoggerService,
|
||||
SignerMetaData_VaultSnapshot,
|
||||
StartupService,
|
||||
StorageService,
|
||||
} from '@common';
|
||||
import { getNewStorageServiceConfig } from '../../../common/data/get-new-storage-service-config';
|
||||
|
||||
@Component({
|
||||
selector: 'app-backups',
|
||||
templateUrl: './backups.component.html',
|
||||
styleUrl: './backups.component.scss',
|
||||
imports: [ConfirmComponent],
|
||||
})
|
||||
export class BackupsComponent implements OnInit {
|
||||
readonly #router = inject(Router);
|
||||
readonly #storage = inject(StorageService);
|
||||
readonly #startup = inject(StartupService);
|
||||
readonly #logger = inject(LoggerService);
|
||||
|
||||
backups: SignerMetaData_VaultSnapshot[] = [];
|
||||
maxBackups = 5;
|
||||
restoringBackupId: string | null = null;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadBackups();
|
||||
this.maxBackups = this.#storage.getSignerMetaHandler().getMaxBackups();
|
||||
}
|
||||
|
||||
loadBackups(): void {
|
||||
this.backups = this.#storage.getSignerMetaHandler().getBackups();
|
||||
}
|
||||
|
||||
async onMaxBackupsChange(event: Event): Promise<void> {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const value = parseInt(input.value, 10);
|
||||
if (!isNaN(value) && value >= 1 && value <= 20) {
|
||||
this.maxBackups = value;
|
||||
await this.#storage.getSignerMetaHandler().setMaxBackups(value);
|
||||
}
|
||||
}
|
||||
|
||||
async createManualBackup(): Promise<void> {
|
||||
const browserSyncData = this.#storage.getBrowserSyncHandler().browserSyncData;
|
||||
if (browserSyncData) {
|
||||
await this.#storage.getSignerMetaHandler().createBackup(browserSyncData, 'manual');
|
||||
this.loadBackups();
|
||||
}
|
||||
}
|
||||
|
||||
async restoreBackup(backupId: string): Promise<void> {
|
||||
this.restoringBackupId = backupId;
|
||||
try {
|
||||
// First, create a pre-restore backup of current state
|
||||
const currentData = this.#storage.getBrowserSyncHandler().browserSyncData;
|
||||
if (currentData) {
|
||||
await this.#storage.getSignerMetaHandler().createBackup(currentData, 'pre-restore');
|
||||
}
|
||||
|
||||
// Get the backup data
|
||||
const backupData = this.#storage.getSignerMetaHandler().getBackupData(backupId);
|
||||
if (!backupData) {
|
||||
throw new Error('Backup not found');
|
||||
}
|
||||
|
||||
// Import the backup
|
||||
await this.#storage.deleteVault(true);
|
||||
await this.#storage.importVault(backupData);
|
||||
this.#logger.logVaultImport('Backup Restore');
|
||||
this.#storage.isInitialized = false;
|
||||
this.#startup.startOver(getNewStorageServiceConfig());
|
||||
} catch (error) {
|
||||
console.error('Failed to restore backup:', error);
|
||||
this.restoringBackupId = null;
|
||||
}
|
||||
}
|
||||
|
||||
async deleteBackup(backupId: string): Promise<void> {
|
||||
await this.#storage.getSignerMetaHandler().deleteBackup(backupId);
|
||||
this.loadBackups();
|
||||
}
|
||||
|
||||
formatDate(isoDate: string): string {
|
||||
const date = new Date(isoDate);
|
||||
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString();
|
||||
}
|
||||
|
||||
getReasonLabel(reason?: string): string {
|
||||
switch (reason) {
|
||||
case 'auto':
|
||||
return 'Auto';
|
||||
case 'manual':
|
||||
return 'Manual';
|
||||
case 'pre-restore':
|
||||
return 'Pre-Restore';
|
||||
default:
|
||||
return 'Unknown';
|
||||
}
|
||||
}
|
||||
|
||||
getReasonClass(reason?: string): string {
|
||||
switch (reason) {
|
||||
case 'auto':
|
||||
return 'reason-auto';
|
||||
case 'manual':
|
||||
return 'reason-manual';
|
||||
case 'pre-restore':
|
||||
return 'reason-prerestore';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
goBack(): void {
|
||||
this.#router.navigateByUrl('/home/settings');
|
||||
}
|
||||
|
||||
async onClickLock(): Promise<void> {
|
||||
this.#logger.logVaultLock();
|
||||
await this.#storage.lockVault();
|
||||
this.#router.navigateByUrl('/vault-login');
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
<!-- eslint-disable @angular-eslint/template/interactive-supports-focus -->
|
||||
<!-- eslint-disable @angular-eslint/template/click-events-have-key-events -->
|
||||
<div class="bookmarks-header">
|
||||
<span class="bookmarks-title">Bookmarks</span>
|
||||
<button class="btn btn-primary btn-sm" (click)="onBookmarkThisPage()">
|
||||
<span class="emoji">🔖</span> Bookmark This Page
|
||||
<div class="sam-text-header">
|
||||
<button class="lock-btn" title="Lock" (click)="onClickLock()">
|
||||
<span class="emoji">🔒</span>
|
||||
</button>
|
||||
<span>Bookmarks</span>
|
||||
<button class="add-btn" title="Bookmark This Page" (click)="onBookmarkThisPage()">
|
||||
<span class="emoji">➕</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -2,21 +2,41 @@
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: var(--size);
|
||||
padding-top: var(--size);
|
||||
padding-bottom: var(--size);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bookmarks-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--size);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
> *:not(.sam-text-header) {
|
||||
margin-left: var(--size);
|
||||
margin-right: var(--size);
|
||||
}
|
||||
|
||||
.bookmarks-title {
|
||||
font-weight: 600;
|
||||
font-size: 1.1rem;
|
||||
.sam-text-header {
|
||||
margin-bottom: var(--size);
|
||||
flex-shrink: 0;
|
||||
|
||||
.add-btn {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 8px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.2s;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--background-light);
|
||||
}
|
||||
|
||||
.emoji {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.bookmarks-container {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Component, inject, OnInit } from '@angular/core';
|
||||
import { Bookmark, LoggerService, SignerMetaData } from '@common';
|
||||
import { Router } from '@angular/router';
|
||||
import { Bookmark, LoggerService, SignerMetaData, StorageService } from '@common';
|
||||
import { FirefoxMetaHandler } from '../../../common/data/firefox-meta-handler';
|
||||
import browser from 'webextension-polyfill';
|
||||
|
||||
@@ -12,6 +13,8 @@ import browser from 'webextension-polyfill';
|
||||
export class BookmarksComponent implements OnInit {
|
||||
readonly #logger = inject(LoggerService);
|
||||
readonly #metaHandler = new FirefoxMetaHandler();
|
||||
readonly #storage = inject(StorageService);
|
||||
readonly #router = inject(Router);
|
||||
|
||||
bookmarks: Bookmark[] = [];
|
||||
isLoading = true;
|
||||
@@ -88,4 +91,10 @@ export class BookmarksComponent implements OnInit {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
async onClickLock() {
|
||||
this.#logger.logVaultLock();
|
||||
await this.#storage.lockVault();
|
||||
this.#router.navigateByUrl('/vault-login');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,46 +3,23 @@
|
||||
</div>
|
||||
|
||||
<div class="tabs">
|
||||
<a
|
||||
class="tab"
|
||||
routerLink="/home/identity"
|
||||
routerLinkActive="active"
|
||||
title="Your selected identity"
|
||||
>
|
||||
<a class="tab" routerLink="/home/identity" routerLinkActive="active" title="You">
|
||||
<span class="emoji">👤</span>
|
||||
</a>
|
||||
|
||||
<a
|
||||
class="tab"
|
||||
routerLink="/home/identities"
|
||||
routerLinkActive="active"
|
||||
title="Identities"
|
||||
>
|
||||
<a class="tab" routerLink="/home/identities" routerLinkActive="active" title="Identities">
|
||||
<span class="emoji">👥</span>
|
||||
</a>
|
||||
|
||||
<a
|
||||
class="tab"
|
||||
routerLink="/home/settings"
|
||||
routerLinkActive="active"
|
||||
title="Settings"
|
||||
>
|
||||
<span class="emoji">⚙️</span>
|
||||
<a class="tab" routerLink="/home/wallet" routerLinkActive="active" title="Wallet">
|
||||
<span class="emoji">💰</span>
|
||||
</a>
|
||||
|
||||
<a class="tab" routerLink="/home/bookmarks" routerLinkActive="active" title="Bookmarks">
|
||||
<span class="emoji">🔖</span>
|
||||
</a>
|
||||
|
||||
<a class="tab" routerLink="/home/logs" routerLinkActive="active" title="Logs">
|
||||
<span class="emoji">🪵</span>
|
||||
<a class="tab" routerLink="/home/settings" routerLinkActive="active" title="Settings">
|
||||
<span class="emoji">⚙️</span>
|
||||
</a>
|
||||
|
||||
<a class="tab" routerLink="/home/info" routerLinkActive="active" title="Info">
|
||||
<span class="emoji">💡</span>
|
||||
</a>
|
||||
|
||||
<button class="tab" (click)="onClickLock()" title="Lock">
|
||||
<span class="emoji">🔒</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -4,12 +4,12 @@
|
||||
flex-direction: column;
|
||||
|
||||
.tab-content {
|
||||
height: calc(100% - 40px);
|
||||
height: calc(100% - 48px);
|
||||
}
|
||||
|
||||
.tabs {
|
||||
height: 40px;
|
||||
min-height: 40px;
|
||||
height: 48px;
|
||||
min-height: 48px;
|
||||
background: var(--background-light);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
@@ -23,7 +23,6 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
|
||||
color: var(--muted-foreground);
|
||||
border-top: 2px solid transparent;
|
||||
@@ -31,6 +30,10 @@
|
||||
|
||||
cursor: pointer;
|
||||
|
||||
.emoji {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--background-light-hover);
|
||||
color: var(--foreground);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { Router, RouterModule, RouterOutlet } from '@angular/router';
|
||||
import { LoggerService, StorageService } from '@common';
|
||||
import { Component } from '@angular/core';
|
||||
import { RouterModule, RouterOutlet } from '@angular/router';
|
||||
|
||||
@Component({
|
||||
selector: 'app-home',
|
||||
@@ -8,14 +7,4 @@ import { LoggerService, StorageService } from '@common';
|
||||
templateUrl: './home.component.html',
|
||||
styleUrl: './home.component.scss',
|
||||
})
|
||||
export class HomeComponent {
|
||||
readonly #storage = inject(StorageService);
|
||||
readonly #router = inject(Router);
|
||||
readonly #logger = inject(LoggerService);
|
||||
|
||||
async onClickLock() {
|
||||
this.#logger.logVaultLock();
|
||||
await this.#storage.lockVault();
|
||||
this.#router.navigateByUrl('/vault-login');
|
||||
}
|
||||
}
|
||||
export class HomeComponent {}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
<!-- eslint-disable @angular-eslint/template/interactive-supports-focus -->
|
||||
<!-- eslint-disable @angular-eslint/template/click-events-have-key-events -->
|
||||
<div class="custom-header" style="position: sticky; top: 0">
|
||||
<button class="lock-btn" title="Lock" (click)="onClickLock()">
|
||||
<span class="emoji">🔒</span>
|
||||
</button>
|
||||
<span class="text">Identities</span>
|
||||
|
||||
<button class="button btn btn-primary btn-sm" (click)="onClickNewIdentity()">
|
||||
<div class="sam-flex-row gap-h">
|
||||
<i class="bi bi-plus-lg"></i>
|
||||
<span>New</span>
|
||||
</div>
|
||||
<button class="add-btn" title="New Identity" (click)="onClickNewIdentity()">
|
||||
<span class="emoji">➕</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -3,37 +3,58 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
padding-left: var(--size);
|
||||
padding-right: var(--size);
|
||||
|
||||
> *:not(.custom-header) {
|
||||
margin-left: var(--size);
|
||||
margin-right: var(--size);
|
||||
}
|
||||
|
||||
.custom-header {
|
||||
padding-top: var(--size);
|
||||
padding-bottom: var(--size);
|
||||
height: 48px;
|
||||
min-height: 48px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: auto;
|
||||
align-items: center;
|
||||
background: var(--background);
|
||||
position: relative;
|
||||
|
||||
.button {
|
||||
grid-column-start: 1;
|
||||
grid-column-end: 2;
|
||||
grid-row-start: 1;
|
||||
grid-row-end: 2;
|
||||
justify-self: end;
|
||||
.lock-btn,
|
||||
.add-btn {
|
||||
position: absolute;
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 8px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.2s;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--background-light);
|
||||
}
|
||||
|
||||
.emoji {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.lock-btn {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.add-btn {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.text {
|
||||
grid-column-start: 1;
|
||||
grid-column-end: 2;
|
||||
grid-row-start: 1;
|
||||
grid-row-end: 2;
|
||||
font-family: var(--font-heading);
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1rem;
|
||||
justify-self: center;
|
||||
height: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Router } from '@angular/router';
|
||||
import {
|
||||
IconButtonComponent,
|
||||
Identity_DECRYPTED,
|
||||
LoggerService,
|
||||
NostrHelper,
|
||||
ProfileMetadata,
|
||||
ProfileMetadataService,
|
||||
@@ -20,6 +21,7 @@ export class IdentitiesComponent implements OnInit {
|
||||
readonly storage = inject(StorageService);
|
||||
readonly #router = inject(Router);
|
||||
readonly #profileMetadata = inject(ProfileMetadataService);
|
||||
readonly #logger = inject(LoggerService);
|
||||
|
||||
// Cache of pubkey -> profile for quick lookup
|
||||
#profileCache = new Map<string, ProfileMetadata | null>();
|
||||
@@ -73,4 +75,10 @@ export class IdentitiesComponent implements OnInit {
|
||||
onClickWhitelistedApps() {
|
||||
this.#router.navigateByUrl('/whitelisted-apps');
|
||||
}
|
||||
|
||||
async onClickLock() {
|
||||
this.#logger.logVaultLock();
|
||||
await this.storage.lockVault();
|
||||
this.#router.navigateByUrl('/vault-login');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
<!-- eslint-disable @angular-eslint/template/interactive-supports-focus -->
|
||||
<!-- eslint-disable @angular-eslint/template/click-events-have-key-events -->
|
||||
<div class="sam-text-header">
|
||||
<button class="lock-btn" title="Lock" (click)="onClickLock()">
|
||||
<span class="emoji">🔒</span>
|
||||
</button>
|
||||
<span>You</span>
|
||||
<button class="edit-btn" title="Edit profile" (click)="onClickEditProfile()">
|
||||
<img src="edit.svg" alt="Edit" class="edit-icon" />
|
||||
<span class="emoji">📝</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -70,4 +73,12 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- About section -->
|
||||
@if (aboutText) {
|
||||
<div class="about-section">
|
||||
<div class="about-header">About</div>
|
||||
<div class="about-content">{{ aboutText }}</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<lib-toast #toast></lib-toast>
|
||||
|
||||
@@ -4,14 +4,12 @@
|
||||
flex-direction: column;
|
||||
|
||||
.sam-text-header {
|
||||
position: relative;
|
||||
|
||||
.edit-btn {
|
||||
position: absolute;
|
||||
right: var(--size);
|
||||
right: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 4px;
|
||||
padding: 8px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -23,10 +21,8 @@
|
||||
background-color: var(--background-light);
|
||||
}
|
||||
|
||||
.edit-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
filter: brightness(0) invert(1);
|
||||
.emoji {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -189,4 +185,33 @@
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.about-section {
|
||||
margin: var(--size);
|
||||
margin-top: 0;
|
||||
flex-shrink: 0;
|
||||
max-height: 150px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.about-header {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--muted-foreground);
|
||||
margin-bottom: var(--size-h);
|
||||
}
|
||||
|
||||
.about-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
color: var(--foreground);
|
||||
background: var(--background-light);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: var(--size);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +52,10 @@ export class IdentityComponent implements OnInit {
|
||||
return this.profile?.banner;
|
||||
}
|
||||
|
||||
get aboutText(): string | undefined {
|
||||
return this.profile?.about;
|
||||
}
|
||||
|
||||
copyToClipboard(pubkey: string | undefined) {
|
||||
if (!pubkey) {
|
||||
return;
|
||||
@@ -76,6 +80,12 @@ export class IdentityComponent implements OnInit {
|
||||
this.#router.navigateByUrl('/profile-edit');
|
||||
}
|
||||
|
||||
async onClickLock() {
|
||||
this.#logger.logVaultLock();
|
||||
await this.#storage.lockVault();
|
||||
this.#router.navigateByUrl('/vault-login');
|
||||
}
|
||||
|
||||
async #loadData() {
|
||||
try {
|
||||
const selectedIdentityId =
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
<div class="sam-text-header">
|
||||
<button class="lock-btn" title="Lock" (click)="onClickLock()">
|
||||
<span class="emoji">🔒</span>
|
||||
</button>
|
||||
<span> Plebeian Signer </span>
|
||||
</div>
|
||||
|
||||
@@ -7,7 +10,7 @@
|
||||
<span> </span>
|
||||
|
||||
<span> Source code</span>
|
||||
<a href="https://git.mleku.dev/mleku/plebeian-signer" target="_blank">
|
||||
git.mleku.dev/mleku/plebeian-signer
|
||||
<a href="https://github.com/PlebeianApp/plebeian-signer" target="_blank">
|
||||
github.com/PlebeianApp/plebeian-signer
|
||||
</a>
|
||||
|
||||
|
||||
@@ -4,6 +4,13 @@
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
overflow-y: auto;
|
||||
padding-left: var(--size);
|
||||
padding-right: var(--size);
|
||||
|
||||
> *:not(.sam-text-header) {
|
||||
margin-left: var(--size);
|
||||
margin-right: var(--size);
|
||||
}
|
||||
|
||||
.sam-text-header {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { LoggerService, StorageService } from '@common';
|
||||
import packageJson from '../../../../../../../package.json';
|
||||
|
||||
@Component({
|
||||
@@ -7,5 +9,15 @@ import packageJson from '../../../../../../../package.json';
|
||||
styleUrl: './info.component.scss',
|
||||
})
|
||||
export class InfoComponent {
|
||||
readonly #logger = inject(LoggerService);
|
||||
readonly #storage = inject(StorageService);
|
||||
readonly #router = inject(Router);
|
||||
|
||||
version = packageJson.custom.firefox.version;
|
||||
|
||||
async onClickLock() {
|
||||
this.#logger.logVaultLock();
|
||||
await this.#storage.lockVault();
|
||||
this.#router.navigateByUrl('/vault-login');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
<div class="logs-header">
|
||||
<span class="logs-title">Logs</span>
|
||||
<div class="sam-text-header">
|
||||
<button class="lock-btn" title="Lock" (click)="onClickLock()">
|
||||
<span class="emoji">🔒</span>
|
||||
</button>
|
||||
<span>Logs</span>
|
||||
<div class="logs-actions">
|
||||
<button class="btn btn-sm btn-secondary" (click)="onRefresh()">Refresh</button>
|
||||
<button class="btn btn-sm btn-secondary" (click)="onClear()">Clear</button>
|
||||
<button class="btn btn-sm btn-secondary" title="Refresh logs" (click)="onRefresh()">Refresh</button>
|
||||
<button class="btn btn-sm btn-secondary" title="Clear logs" (click)="onClear()">Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -2,26 +2,26 @@
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: var(--size);
|
||||
padding-top: var(--size);
|
||||
padding-bottom: var(--size);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.logs-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--size);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
> *:not(.sam-text-header) {
|
||||
margin-left: var(--size);
|
||||
margin-right: var(--size);
|
||||
}
|
||||
|
||||
.logs-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.sam-text-header {
|
||||
margin-bottom: var(--size);
|
||||
flex-shrink: 0;
|
||||
|
||||
.logs-title {
|
||||
font-weight: 600;
|
||||
font-size: 1.1rem;
|
||||
.logs-actions {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.logs-container {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Component, inject, OnInit } from '@angular/core';
|
||||
import { LoggerService, LogEntry } from '@common';
|
||||
import { Router } from '@angular/router';
|
||||
import { LoggerService, LogEntry, StorageService } from '@common';
|
||||
import { DatePipe } from '@angular/common';
|
||||
|
||||
@Component({
|
||||
@@ -10,6 +11,8 @@ import { DatePipe } from '@angular/common';
|
||||
})
|
||||
export class LogsComponent implements OnInit {
|
||||
readonly #logger = inject(LoggerService);
|
||||
readonly #storage = inject(StorageService);
|
||||
readonly #router = inject(Router);
|
||||
|
||||
get logs(): LogEntry[] {
|
||||
return this.#logger.logs;
|
||||
@@ -40,4 +43,10 @@ export class LogsComponent implements OnInit {
|
||||
return 'log-info';
|
||||
}
|
||||
}
|
||||
|
||||
async onClickLock() {
|
||||
this.#logger.logVaultLock();
|
||||
await this.#storage.lockVault();
|
||||
this.#router.navigateByUrl('/vault-login');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
<div class="sam-text-header">
|
||||
<button class="lock-btn" title="Lock" (click)="onClickLock()">
|
||||
<span class="emoji">🔒</span>
|
||||
</button>
|
||||
<span> Settings </span>
|
||||
</div>
|
||||
|
||||
<span>SYNC: {{ syncFlow }}</span>
|
||||
<div class="sync-info">
|
||||
<span class="sync-label">SYNC: {{ syncFlow }}</span>
|
||||
<p class="sync-note">
|
||||
To change sync mode, export your vault, reset the extension,
|
||||
and re-import with the desired sync setting.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary" (click)="onClickExportVault()">
|
||||
Export Vault
|
||||
@@ -12,6 +21,10 @@
|
||||
Import Vault
|
||||
</button>
|
||||
|
||||
<lib-nav-item text="💾 Backups" (click)="navigate('/home/backups')"></lib-nav-item>
|
||||
<lib-nav-item text="🪵 Logs" (click)="navigate('/home/logs')"></lib-nav-item>
|
||||
<lib-nav-item text="💡 Info" (click)="navigate('/home/info')"></lib-nav-item>
|
||||
|
||||
<div class="sam-flex-grow"></div>
|
||||
|
||||
<button
|
||||
|
||||
@@ -4,11 +4,28 @@
|
||||
flex-direction: column;
|
||||
row-gap: var(--size);
|
||||
overflow-y: auto;
|
||||
padding-left: var(--size);
|
||||
padding-right: var(--size);
|
||||
|
||||
> *:not(.sam-text-header) {
|
||||
margin-left: var(--size);
|
||||
margin-right: var(--size);
|
||||
}
|
||||
|
||||
.file-input {
|
||||
position: absolute;
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.sync-info {
|
||||
.sync-label {
|
||||
display: block;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.sync-note {
|
||||
margin: var(--size-h) 0 0 0;
|
||||
font-size: 0.85rem;
|
||||
color: var(--muted-foreground);
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { Component, inject, OnInit } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import {
|
||||
BrowserSyncFlow,
|
||||
ConfirmComponent,
|
||||
DateHelper,
|
||||
LoggerService,
|
||||
NavComponent,
|
||||
NavItemComponent,
|
||||
StartupService,
|
||||
StorageService,
|
||||
} from '@common';
|
||||
@@ -12,11 +14,12 @@ import { getNewStorageServiceConfig } from '../../../common/data/get-new-storage
|
||||
|
||||
@Component({
|
||||
selector: 'app-settings',
|
||||
imports: [ConfirmComponent],
|
||||
imports: [ConfirmComponent, NavItemComponent],
|
||||
templateUrl: './settings.component.html',
|
||||
styleUrl: './settings.component.scss',
|
||||
})
|
||||
export class SettingsComponent extends NavComponent implements OnInit {
|
||||
readonly #router = inject(Router);
|
||||
syncFlow: string | undefined;
|
||||
|
||||
readonly #storage = inject(StorageService);
|
||||
@@ -74,4 +77,10 @@ export class SettingsComponent extends NavComponent implements OnInit {
|
||||
downloadAnchorNode.click();
|
||||
downloadAnchorNode.remove();
|
||||
}
|
||||
|
||||
async onClickLock() {
|
||||
this.#logger.logVaultLock();
|
||||
await this.#storage.lockVault();
|
||||
this.#router.navigateByUrl('/vault-login');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,653 @@
|
||||
<div class="sam-text-header">
|
||||
<button class="lock-btn" title="Lock" (click)="onClickLock()">
|
||||
<span class="emoji">🔒</span>
|
||||
</button>
|
||||
@if (showBackButton) {
|
||||
<button class="back-btn" title="Go Back" (click)="goBack()">
|
||||
<span class="emoji">←</span>
|
||||
</button>
|
||||
}
|
||||
<span>{{ title }}</span>
|
||||
<div class="section-btns">
|
||||
<button
|
||||
class="section-btn"
|
||||
[class.active]="activeSection.startsWith('cashu')"
|
||||
title="Cashu"
|
||||
(click)="setSection('cashu')"
|
||||
>
|
||||
<span class="emoji">🥜</span>
|
||||
</button>
|
||||
<button
|
||||
class="section-btn"
|
||||
[class.active]="activeSection.startsWith('lightning')"
|
||||
title="Lightning"
|
||||
(click)="setSection('lightning')"
|
||||
>
|
||||
<span class="emoji">⚡</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="wallet-container">
|
||||
<!-- Main wallet menu -->
|
||||
@if (activeSection === 'main') {
|
||||
<div class="wallet-menu">
|
||||
<button class="wallet-menu-item" (click)="setSection('cashu')">
|
||||
<span class="emoji">🥜</span>
|
||||
<span class="label">Cashu</span>
|
||||
<span class="balance">{{ formatCashuBalance(totalCashuBalance) }} sats</span>
|
||||
</button>
|
||||
<button class="wallet-menu-item" (click)="setSection('lightning')">
|
||||
<span class="emoji">⚡</span>
|
||||
<span class="label">Lightning</span>
|
||||
<span class="balance">{{ formatBalance(totalLightningBalance) }} sats</span>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Cashu mint list -->
|
||||
@else if (activeSection === 'cashu') {
|
||||
<div class="lightning-section">
|
||||
@if (mints.length === 0) {
|
||||
<div class="cashu-onboarding">
|
||||
@if (showCashuInfo) {
|
||||
<div class="info-panel">
|
||||
<h3>Welcome to Cashu Wallet</h3>
|
||||
|
||||
<div class="info-section">
|
||||
<h4>Storage Considerations</h4>
|
||||
@if (currentSyncFlow === BrowserSyncFlow.BROWSER_SYNC) {
|
||||
<div class="warning-box">
|
||||
<p><strong>Browser Sync is enabled</strong></p>
|
||||
<p>
|
||||
Sync storage is limited to ~100KB shared across all your vault data
|
||||
(identities, permissions, relays, and Cashu tokens). This limits
|
||||
your Cashu wallet to approximately 300-400 tokens.
|
||||
</p>
|
||||
<p>
|
||||
For larger Cashu holdings, consider disabling browser sync which
|
||||
provides ~5MB of local storage (~18,000+ tokens).
|
||||
</p>
|
||||
<button class="link-btn" (click)="navigateToSettings()">
|
||||
Change Sync Settings
|
||||
</button>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="success-box">
|
||||
<p><strong>Local Storage Mode</strong></p>
|
||||
<p>
|
||||
You have ~5MB of local storage available, which can hold
|
||||
thousands of Cashu tokens. Your data stays on this device only.
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="info-section">
|
||||
<h4>Backup Your Wallet</h4>
|
||||
<p>
|
||||
<strong>Important:</strong> Cashu tokens are bearer assets.
|
||||
If you lose your vault backup, you lose your tokens permanently.
|
||||
</p>
|
||||
<p>
|
||||
Vault exports are saved to your browser's downloads folder.
|
||||
Configure this to point to either:
|
||||
</p>
|
||||
<ul>
|
||||
<li>Your backup storage device (external drive, NAS)</li>
|
||||
<li>A folder synced by your backup tool (Syncthing, rsync, etc.)</li>
|
||||
</ul>
|
||||
<p class="browser-url">
|
||||
<code>{{ browserDownloadSettingsUrl }}</code>
|
||||
</p>
|
||||
<button class="link-btn" (click)="navigateToSettings()">
|
||||
Go to Backup Settings
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button class="dismiss-btn" (click)="dismissCashuInfo()">
|
||||
Got it, let me add a mint
|
||||
</button>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="empty-state">
|
||||
<span class="sam-text-muted">No mints connected yet.</span>
|
||||
<button class="show-info-btn" (click)="showCashuInfo = true">
|
||||
Show storage info
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<div class="wallet-list">
|
||||
@for (mint of mints; track mint.id) {
|
||||
<button class="wallet-list-item" (click)="selectMint(mint.id)">
|
||||
<span class="wallet-name">{{ mint.name }}</span>
|
||||
<span class="wallet-balance">{{ formatCashuBalance(mint.cachedBalance) }} sats</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<button class="add-wallet-btn" (click)="showAddMint()">
|
||||
<span class="emoji">+</span>
|
||||
<span>Add Mint</span>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Cashu mint detail -->
|
||||
@else if (activeSection === 'cashu-detail' && selectedMint) {
|
||||
<div class="wallet-detail">
|
||||
<div class="balance-row">
|
||||
<div class="balance-display compact">
|
||||
<span class="balance-value">{{ formatCashuBalance(selectedMintBalance) }}</span>
|
||||
<span class="balance-unit">sats</span>
|
||||
</div>
|
||||
<button
|
||||
class="refresh-icon-btn"
|
||||
(click)="refreshMint()"
|
||||
[disabled]="refreshingMint"
|
||||
title="Refresh"
|
||||
>
|
||||
<span class="emoji" [class.spinning]="refreshingMint">🔄</span>
|
||||
</button>
|
||||
</div>
|
||||
@if (refreshError) {
|
||||
<div class="error-message small">{{ refreshError }}</div>
|
||||
}
|
||||
<div class="action-buttons">
|
||||
<button class="action-btn deposit-btn" (click)="showDeposit()">
|
||||
Deposit
|
||||
</button>
|
||||
<button class="action-btn receive-btn" (click)="showReceive()">
|
||||
Receive
|
||||
</button>
|
||||
<button class="action-btn send-btn" (click)="showSend()" [disabled]="selectedMintBalance === 0">
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Token viewer section -->
|
||||
<div class="token-section">
|
||||
<div class="section-title">Tokens ({{ selectedMintProofs.length }})</div>
|
||||
@if (selectedMintProofs.length === 0) {
|
||||
<div class="empty-text">No tokens stored</div>
|
||||
} @else {
|
||||
<div class="token-list">
|
||||
@for (proof of selectedMintProofs; track proof.secret) {
|
||||
<div class="token-item">
|
||||
<span class="token-amount">{{ proof.amount }}</span>
|
||||
<span class="token-time">{{ formatProofTime(proof.receivedAt) }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="wallet-info">
|
||||
<div class="info-row">
|
||||
<span class="info-label">Mint URL</span>
|
||||
<span class="info-value">{{ selectedMint.mintUrl }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Unit</span>
|
||||
<span class="info-value">{{ selectedMint.unit }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="delete-btn" (click)="deleteMint()">
|
||||
Delete Mint
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Cashu add mint form -->
|
||||
@else if (activeSection === 'cashu-add') {
|
||||
<div class="add-wallet-form">
|
||||
<div class="form-group">
|
||||
<label for="mintName">Mint Name</label>
|
||||
<input
|
||||
id="mintName"
|
||||
type="text"
|
||||
[(ngModel)]="newMintName"
|
||||
placeholder="My Mint"
|
||||
[disabled]="addingMint"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="mintUrl">Mint URL</label>
|
||||
<input
|
||||
id="mintUrl"
|
||||
type="text"
|
||||
[(ngModel)]="newMintUrl"
|
||||
placeholder="https://mint.example.com"
|
||||
[disabled]="addingMint"
|
||||
/>
|
||||
</div>
|
||||
@if (mintError) {
|
||||
<div class="error-message">{{ mintError }}</div>
|
||||
}
|
||||
@if (mintTestResult) {
|
||||
<div class="success-message">{{ mintTestResult }}</div>
|
||||
}
|
||||
<div class="form-actions">
|
||||
<button
|
||||
class="test-btn"
|
||||
(click)="testMint()"
|
||||
[disabled]="testingMint || addingMint"
|
||||
>
|
||||
{{ testingMint ? 'Testing...' : 'Test Connection' }}
|
||||
</button>
|
||||
<button
|
||||
class="add-btn"
|
||||
(click)="addMint()"
|
||||
[disabled]="addingMint"
|
||||
>
|
||||
{{ addingMint ? 'Adding...' : 'Add Mint' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Cashu receive token -->
|
||||
@else if (activeSection === 'cashu-receive') {
|
||||
<div class="add-wallet-form">
|
||||
<div class="form-group">
|
||||
<label for="receiveToken">Paste Cashu Token</label>
|
||||
<textarea
|
||||
id="receiveToken"
|
||||
[(ngModel)]="receiveToken"
|
||||
placeholder="cashuB..."
|
||||
rows="5"
|
||||
[disabled]="receivingToken"
|
||||
></textarea>
|
||||
</div>
|
||||
@if (receiveError) {
|
||||
<div class="error-message">{{ receiveError }}</div>
|
||||
}
|
||||
@if (receiveResult) {
|
||||
<div class="success-message">{{ receiveResult }}</div>
|
||||
}
|
||||
<div class="form-actions">
|
||||
<button
|
||||
class="add-btn full-width"
|
||||
(click)="receiveTokens()"
|
||||
[disabled]="receivingToken"
|
||||
>
|
||||
{{ receivingToken ? 'Receiving...' : 'Receive Tokens' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Cashu send token -->
|
||||
@else if (activeSection === 'cashu-send') {
|
||||
<div class="add-wallet-form">
|
||||
<div class="balance-info">
|
||||
Available: {{ formatCashuBalance(selectedMintBalance) }} sats
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="sendAmount">Amount (sats)</label>
|
||||
<input
|
||||
id="sendAmount"
|
||||
type="number"
|
||||
[(ngModel)]="sendAmount"
|
||||
placeholder="0"
|
||||
min="1"
|
||||
[max]="selectedMintBalance"
|
||||
[disabled]="sendingToken"
|
||||
/>
|
||||
</div>
|
||||
@if (sendError) {
|
||||
<div class="error-message">{{ sendError }}</div>
|
||||
}
|
||||
@if (sendResult) {
|
||||
<div class="token-result">
|
||||
<span class="token-label">Token to Share</span>
|
||||
<textarea readonly rows="4">{{ sendResult }}</textarea>
|
||||
<button class="copy-btn" (click)="copyToken()">
|
||||
Copy Token
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
@if (!sendResult) {
|
||||
<div class="form-actions">
|
||||
<button
|
||||
class="add-btn full-width"
|
||||
(click)="sendTokens()"
|
||||
[disabled]="sendingToken || sendAmount <= 0"
|
||||
>
|
||||
{{ sendingToken ? 'Creating...' : 'Create Token' }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Cashu deposit (mint via Lightning) -->
|
||||
@else if (activeSection === 'cashu-mint' && selectedMint) {
|
||||
<div class="add-wallet-form">
|
||||
@if (!depositInvoice) {
|
||||
<div class="form-group">
|
||||
<label for="depositAmount">Amount (sats)</label>
|
||||
<input
|
||||
id="depositAmount"
|
||||
type="number"
|
||||
[(ngModel)]="depositAmount"
|
||||
placeholder="1000"
|
||||
min="1"
|
||||
[disabled]="creatingDepositQuote"
|
||||
/>
|
||||
</div>
|
||||
@if (depositError) {
|
||||
<div class="error-message">{{ depositError }}</div>
|
||||
}
|
||||
<div class="form-actions">
|
||||
<button
|
||||
class="add-btn full-width"
|
||||
(click)="createDepositInvoice()"
|
||||
[disabled]="creatingDepositQuote || depositAmount <= 0"
|
||||
>
|
||||
{{ creatingDepositQuote ? 'Creating...' : 'Create Invoice' }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
@if (depositInvoice) {
|
||||
<div class="invoice-result">
|
||||
@if (depositInvoiceQr) {
|
||||
<img [src]="depositInvoiceQr" alt="Invoice QR Code" class="qr-code" />
|
||||
}
|
||||
<div class="deposit-status">
|
||||
@if (depositQuoteState === 'UNPAID') {
|
||||
<span class="status-waiting">Waiting for payment...</span>
|
||||
@if (checkingDepositPayment) {
|
||||
<span class="status-checking">checking</span>
|
||||
}
|
||||
} @else if (depositQuoteState === 'PAID') {
|
||||
<span class="status-paid">Payment received! Claiming tokens...</span>
|
||||
} @else if (depositQuoteState === 'ISSUED') {
|
||||
<span class="status-issued">✓ Tokens received!</span>
|
||||
}
|
||||
</div>
|
||||
@if (depositError) {
|
||||
<div class="error-message">{{ depositError }}</div>
|
||||
}
|
||||
@if (depositSuccess) {
|
||||
<div class="success-message">{{ depositSuccess }}</div>
|
||||
}
|
||||
@if (depositQuoteState === 'UNPAID') {
|
||||
<div class="invoice-text">{{ depositInvoice }}</div>
|
||||
<button class="copy-btn" (click)="copyDepositInvoice()">
|
||||
Copy Invoice
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Lightning wallet list -->
|
||||
@else if (activeSection === 'lightning') {
|
||||
<div class="lightning-section">
|
||||
@if (connections.length === 0) {
|
||||
<div class="empty-state">
|
||||
<span class="sam-text-muted">
|
||||
No wallets connected yet.
|
||||
</span>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="wallet-list">
|
||||
@for (conn of connections; track conn.id) {
|
||||
<button class="wallet-list-item" (click)="selectConnection(conn.id)">
|
||||
<span class="wallet-name">{{ conn.name }}</span>
|
||||
<span class="wallet-balance">{{ formatBalance(conn.cachedBalance) }} sats</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<button class="add-wallet-btn" (click)="showAddConnection()">
|
||||
<span class="emoji">+</span>
|
||||
<span>Add NWC Connection</span>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Lightning wallet detail -->
|
||||
@else if (activeSection === 'lightning-detail' && selectedConnection) {
|
||||
<div class="wallet-detail">
|
||||
<div class="balance-row">
|
||||
<div class="balance-display compact">
|
||||
<span class="balance-value">{{ formatBalance(selectedConnection.cachedBalance) }}</span>
|
||||
<span class="balance-unit">sats</span>
|
||||
</div>
|
||||
<button class="refresh-icon-btn" (click)="refreshWallet()" title="Refresh">
|
||||
<span class="emoji">🔄</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="action-buttons">
|
||||
<button class="action-btn receive-btn" (click)="showLnReceive()">
|
||||
Receive
|
||||
</button>
|
||||
<button class="action-btn send-btn" (click)="showLnPay()">
|
||||
Pay
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="wallet-info">
|
||||
<div class="info-row">
|
||||
<span class="info-label">Relay</span>
|
||||
<span class="info-value">{{ selectedConnection.relayUrl }}</span>
|
||||
</div>
|
||||
@if (selectedConnection.lud16) {
|
||||
<button class="info-row-btn" (click)="copyLightningAddress()">
|
||||
<span class="info-label">Lightning Address</span>
|
||||
<span class="info-value">
|
||||
{{ selectedConnection.lud16 }}
|
||||
<span class="copy-hint">{{ addressCopied ? '✓ Copied' : '(tap to copy)' }}</span>
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Transaction History -->
|
||||
<div class="transaction-section">
|
||||
<div class="section-title">Transactions</div>
|
||||
@if (loadingTransactions) {
|
||||
<div class="loading-text">Loading...</div>
|
||||
} @else if (transactionsNotSupported) {
|
||||
<div class="not-supported-text">Transaction history not supported by this wallet</div>
|
||||
} @else if (transactionsError) {
|
||||
<div class="error-text">{{ transactionsError }}</div>
|
||||
} @else if (transactions.length === 0) {
|
||||
<div class="empty-text">No transactions yet</div>
|
||||
} @else {
|
||||
<div class="transaction-list">
|
||||
@for (tx of transactions; track tx.payment_hash) {
|
||||
<div class="transaction-item" [class.incoming]="tx.type === 'incoming'" [class.outgoing]="tx.type === 'outgoing'">
|
||||
<span class="tx-icon">{{ tx.type === 'incoming' ? '⬇' : '⬆' }}</span>
|
||||
<span class="tx-type">{{ tx.type === 'incoming' ? 'Received' : 'Sent' }}</span>
|
||||
<span class="tx-amount">{{ formatBalance(tx.amount) }}</span>
|
||||
<span class="tx-time">{{ formatTransactionTime(tx.created_at) }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<button class="delete-btn-small" (click)="deleteConnection()">
|
||||
Delete Wallet
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Lightning receive invoice -->
|
||||
@else if (activeSection === 'lightning-receive' && selectedConnection) {
|
||||
<div class="add-wallet-form">
|
||||
<div class="form-group">
|
||||
<label for="lnReceiveAmount">Amount (sats)</label>
|
||||
<input
|
||||
id="lnReceiveAmount"
|
||||
type="number"
|
||||
[(ngModel)]="lnReceiveAmount"
|
||||
placeholder="1000"
|
||||
min="1"
|
||||
[disabled]="generatingInvoice"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="lnReceiveDescription">Description (optional)</label>
|
||||
<input
|
||||
id="lnReceiveDescription"
|
||||
type="text"
|
||||
[(ngModel)]="lnReceiveDescription"
|
||||
placeholder="Payment for..."
|
||||
[disabled]="generatingInvoice"
|
||||
/>
|
||||
</div>
|
||||
@if (lnReceiveError) {
|
||||
<div class="error-message">{{ lnReceiveError }}</div>
|
||||
}
|
||||
@if (!generatedInvoice) {
|
||||
<div class="form-actions">
|
||||
<button
|
||||
class="add-btn full-width"
|
||||
(click)="createReceiveInvoice()"
|
||||
[disabled]="generatingInvoice || lnReceiveAmount <= 0"
|
||||
>
|
||||
{{ generatingInvoice ? 'Generating...' : 'Generate Invoice' }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
@if (generatedInvoice) {
|
||||
<div class="invoice-result">
|
||||
@if (generatedInvoiceQr) {
|
||||
<img [src]="generatedInvoiceQr" alt="Invoice QR Code" class="qr-code" />
|
||||
}
|
||||
<div class="invoice-text">{{ generatedInvoice }}</div>
|
||||
<button class="copy-btn" (click)="copyInvoice()">
|
||||
{{ invoiceCopied ? 'Copied!' : 'Copy Invoice' }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Pay Modal Overlay -->
|
||||
@if (showPayModal && selectedConnection) {
|
||||
<div class="modal-overlay" role="dialog" aria-modal="true" tabindex="-1" (click)="closePayModal()" (keydown.escape)="closePayModal()">
|
||||
<div class="modal-content" role="document" (click)="$event.stopPropagation()" (keydown)="$event.stopPropagation()">
|
||||
<div class="modal-header">
|
||||
<span>Pay Invoice</span>
|
||||
<button class="modal-close" (click)="closePayModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label for="payInput">Lightning Address or Invoice</label>
|
||||
<textarea
|
||||
id="payInput"
|
||||
[(ngModel)]="payInput"
|
||||
placeholder="user@domain.com or lnbc1..."
|
||||
rows="3"
|
||||
[disabled]="paying"
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="payAmount">Amount (sats) - required for addresses</label>
|
||||
<input
|
||||
id="payAmount"
|
||||
type="number"
|
||||
[(ngModel)]="payAmount"
|
||||
placeholder="Optional for invoices"
|
||||
min="1"
|
||||
[disabled]="paying"
|
||||
/>
|
||||
</div>
|
||||
@if (paymentError) {
|
||||
<div class="error-message">{{ paymentError }}</div>
|
||||
}
|
||||
@if (paymentSuccess) {
|
||||
<div class="success-message payment-success">Payment Successful!</div>
|
||||
}
|
||||
@if (!paymentSuccess) {
|
||||
<div class="form-actions">
|
||||
<button class="test-btn" (click)="closePayModal()" [disabled]="paying">
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
class="add-btn"
|
||||
(click)="payInvoiceOrAddress()"
|
||||
[disabled]="paying || !payInput.trim()"
|
||||
>
|
||||
{{ paying ? 'Paying...' : 'Pay' }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Add wallet form -->
|
||||
@else if (activeSection === 'lightning-add') {
|
||||
<div class="add-wallet-form">
|
||||
<div class="form-group">
|
||||
<label for="walletName">Wallet Name</label>
|
||||
<input
|
||||
id="walletName"
|
||||
type="text"
|
||||
[(ngModel)]="newWalletName"
|
||||
placeholder="My Lightning Wallet"
|
||||
[disabled]="addingConnection"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="walletUrl">NWC Connection URL</label>
|
||||
<textarea
|
||||
id="walletUrl"
|
||||
[(ngModel)]="newWalletUrl"
|
||||
placeholder="nostr+walletconnect://..."
|
||||
rows="3"
|
||||
[disabled]="addingConnection"
|
||||
></textarea>
|
||||
</div>
|
||||
@if (connectionError) {
|
||||
<div class="error-message">{{ connectionError }}</div>
|
||||
}
|
||||
@if (connectionTestResult) {
|
||||
<div class="success-message">{{ connectionTestResult }}</div>
|
||||
}
|
||||
@if (nwcService.logs.length > 0) {
|
||||
<div class="nwc-log">
|
||||
<div class="log-header">
|
||||
<span>Connection Log</span>
|
||||
<button class="log-clear-btn" (click)="nwcService.clearLogs()">Clear</button>
|
||||
</div>
|
||||
<div class="log-entries">
|
||||
@for (entry of nwcService.logs; track entry.timestamp) {
|
||||
<div class="log-entry" [class.log-warn]="entry.level === 'warn'" [class.log-error]="entry.level === 'error'">
|
||||
<span class="log-time">{{ entry.timestamp | date:'HH:mm:ss' }}</span>
|
||||
<span class="log-message">{{ entry.message }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<div class="form-actions">
|
||||
<button
|
||||
class="test-btn"
|
||||
(click)="testConnection()"
|
||||
[disabled]="testingConnection || addingConnection"
|
||||
>
|
||||
{{ testingConnection ? 'Testing...' : 'Test Connection' }}
|
||||
</button>
|
||||
<button
|
||||
class="add-btn"
|
||||
(click)="addConnection()"
|
||||
[disabled]="addingConnection"
|
||||
>
|
||||
{{ addingConnection ? 'Adding...' : 'Add Wallet' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,952 @@
|
||||
import { Component, inject, OnInit, OnDestroy } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
LoggerService,
|
||||
StorageService,
|
||||
NwcService,
|
||||
NwcConnection_DECRYPTED,
|
||||
CashuService,
|
||||
CashuMint_DECRYPTED,
|
||||
CashuProof,
|
||||
NwcLookupInvoiceResult,
|
||||
BrowserSyncFlow,
|
||||
} from '@common';
|
||||
import * as QRCode from 'qrcode';
|
||||
|
||||
type WalletSection =
|
||||
| 'main'
|
||||
| 'cashu'
|
||||
| 'cashu-detail'
|
||||
| 'cashu-add'
|
||||
| 'cashu-receive'
|
||||
| 'cashu-send'
|
||||
| 'cashu-mint'
|
||||
| 'lightning'
|
||||
| 'lightning-detail'
|
||||
| 'lightning-add'
|
||||
| 'lightning-receive'
|
||||
| 'lightning-pay';
|
||||
|
||||
@Component({
|
||||
selector: 'app-wallet',
|
||||
templateUrl: './wallet.component.html',
|
||||
styleUrl: './wallet.component.scss',
|
||||
imports: [CommonModule, FormsModule],
|
||||
})
|
||||
export class WalletComponent implements OnInit, OnDestroy {
|
||||
readonly #logger = inject(LoggerService);
|
||||
readonly #storage = inject(StorageService);
|
||||
readonly #router = inject(Router);
|
||||
readonly nwcService = inject(NwcService);
|
||||
readonly cashuService = inject(CashuService);
|
||||
|
||||
activeSection: WalletSection = 'main';
|
||||
selectedConnectionId: string | null = null;
|
||||
selectedMintId: string | null = null;
|
||||
|
||||
// Form fields for adding new NWC connection
|
||||
newWalletName = '';
|
||||
newWalletUrl = '';
|
||||
addingConnection = false;
|
||||
testingConnection = false;
|
||||
connectionError = '';
|
||||
connectionTestResult = '';
|
||||
|
||||
// Form fields for adding new Cashu mint
|
||||
newMintName = '';
|
||||
newMintUrl = '';
|
||||
addingMint = false;
|
||||
testingMint = false;
|
||||
mintError = '';
|
||||
mintTestResult = '';
|
||||
|
||||
// Cashu receive/send fields
|
||||
receiveToken = '';
|
||||
receivingToken = false;
|
||||
receiveError = '';
|
||||
receiveResult = '';
|
||||
sendAmount = 0;
|
||||
sendingToken = false;
|
||||
sendError = '';
|
||||
sendResult = '';
|
||||
|
||||
// Cashu mint (deposit) fields
|
||||
depositAmount = 0;
|
||||
creatingDepositQuote = false;
|
||||
depositQuoteId = '';
|
||||
depositInvoice = '';
|
||||
depositInvoiceQr = '';
|
||||
depositError = '';
|
||||
depositSuccess = '';
|
||||
checkingDepositPayment = false;
|
||||
depositQuoteState: 'UNPAID' | 'PAID' | 'ISSUED' = 'UNPAID';
|
||||
private depositPollingInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
// Loading states
|
||||
loadingBalances = false;
|
||||
balanceError = '';
|
||||
|
||||
// Lightning transaction history
|
||||
transactions: NwcLookupInvoiceResult[] = [];
|
||||
loadingTransactions = false;
|
||||
transactionsError = '';
|
||||
transactionsNotSupported = false;
|
||||
|
||||
// Lightning receive
|
||||
lnReceiveAmount = 0;
|
||||
lnReceiveDescription = '';
|
||||
generatingInvoice = false;
|
||||
generatedInvoice = '';
|
||||
generatedInvoiceQr = '';
|
||||
lnReceiveError = '';
|
||||
invoiceCopied = false;
|
||||
|
||||
// Lightning pay
|
||||
showPayModal = false;
|
||||
payInput = '';
|
||||
payAmount = 0;
|
||||
paying = false;
|
||||
paymentSuccess = false;
|
||||
paymentError = '';
|
||||
|
||||
// Clipboard feedback
|
||||
addressCopied = false;
|
||||
|
||||
// Cashu onboarding info
|
||||
showCashuInfo = true;
|
||||
currentSyncFlow: BrowserSyncFlow = BrowserSyncFlow.NO_SYNC;
|
||||
readonly BrowserSyncFlow = BrowserSyncFlow; // Expose enum to template
|
||||
readonly browserDownloadSettingsUrl = 'about:preferences#general';
|
||||
|
||||
// Cashu mint refresh
|
||||
refreshingMint = false;
|
||||
refreshError = '';
|
||||
|
||||
get title(): string {
|
||||
switch (this.activeSection) {
|
||||
case 'cashu':
|
||||
return 'Cashu';
|
||||
case 'cashu-detail':
|
||||
return this.selectedMint?.name ?? 'Mint';
|
||||
case 'cashu-add':
|
||||
return 'Add Mint';
|
||||
case 'cashu-receive':
|
||||
return 'Receive';
|
||||
case 'cashu-send':
|
||||
return 'Send';
|
||||
case 'cashu-mint':
|
||||
return 'Deposit';
|
||||
case 'lightning':
|
||||
return 'Lightning';
|
||||
case 'lightning-detail':
|
||||
return this.selectedConnection?.name ?? 'Wallet';
|
||||
case 'lightning-add':
|
||||
return 'Add Wallet';
|
||||
case 'lightning-receive':
|
||||
return 'Receive';
|
||||
case 'lightning-pay':
|
||||
return 'Pay';
|
||||
default:
|
||||
return 'Wallet';
|
||||
}
|
||||
}
|
||||
|
||||
get showBackButton(): boolean {
|
||||
return this.activeSection !== 'main';
|
||||
}
|
||||
|
||||
get connections(): NwcConnection_DECRYPTED[] {
|
||||
return this.nwcService.getConnections();
|
||||
}
|
||||
|
||||
get selectedConnection(): NwcConnection_DECRYPTED | undefined {
|
||||
if (!this.selectedConnectionId) return undefined;
|
||||
return this.nwcService.getConnection(this.selectedConnectionId);
|
||||
}
|
||||
|
||||
get totalLightningBalance(): number {
|
||||
return this.nwcService.getCachedTotalBalance();
|
||||
}
|
||||
|
||||
get mints(): CashuMint_DECRYPTED[] {
|
||||
return this.cashuService.getMints();
|
||||
}
|
||||
|
||||
get selectedMint(): CashuMint_DECRYPTED | undefined {
|
||||
if (!this.selectedMintId) return undefined;
|
||||
return this.cashuService.getMint(this.selectedMintId);
|
||||
}
|
||||
|
||||
get totalCashuBalance(): number {
|
||||
return this.cashuService.getCachedTotalBalance();
|
||||
}
|
||||
|
||||
get selectedMintBalance(): number {
|
||||
if (!this.selectedMintId) return 0;
|
||||
return this.cashuService.getBalance(this.selectedMintId);
|
||||
}
|
||||
|
||||
get selectedMintProofs(): CashuProof[] {
|
||||
if (!this.selectedMintId) return [];
|
||||
return this.cashuService.getProofs(this.selectedMintId);
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
// Load current sync flow setting
|
||||
this.currentSyncFlow = this.#storage.getSyncFlow();
|
||||
|
||||
// Refresh balances on init if we have connections
|
||||
if (this.connections.length > 0) {
|
||||
this.refreshAllBalances();
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.nwcService.disconnectAll();
|
||||
this.stopDepositPolling();
|
||||
}
|
||||
|
||||
setSection(section: WalletSection) {
|
||||
this.activeSection = section;
|
||||
this.connectionError = '';
|
||||
this.connectionTestResult = '';
|
||||
}
|
||||
|
||||
goBack() {
|
||||
switch (this.activeSection) {
|
||||
case 'lightning-detail':
|
||||
case 'lightning-add':
|
||||
this.activeSection = 'lightning';
|
||||
this.selectedConnectionId = null;
|
||||
this.resetAddForm();
|
||||
this.resetLightningForms();
|
||||
break;
|
||||
case 'lightning-receive':
|
||||
case 'lightning-pay':
|
||||
this.activeSection = 'lightning-detail';
|
||||
this.resetLightningForms();
|
||||
break;
|
||||
case 'cashu-detail':
|
||||
case 'cashu-add':
|
||||
this.activeSection = 'cashu';
|
||||
this.selectedMintId = null;
|
||||
this.resetAddMintForm();
|
||||
break;
|
||||
case 'cashu-receive':
|
||||
case 'cashu-send':
|
||||
case 'cashu-mint':
|
||||
this.activeSection = 'cashu-detail';
|
||||
this.resetReceiveSendForm();
|
||||
this.resetDepositForm();
|
||||
break;
|
||||
case 'lightning':
|
||||
case 'cashu':
|
||||
this.activeSection = 'main';
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
selectConnection(connectionId: string) {
|
||||
this.selectedConnectionId = connectionId;
|
||||
this.activeSection = 'lightning-detail';
|
||||
this.loadTransactions(connectionId);
|
||||
}
|
||||
|
||||
private resetLightningForms() {
|
||||
this.lnReceiveAmount = 0;
|
||||
this.lnReceiveDescription = '';
|
||||
this.generatingInvoice = false;
|
||||
this.generatedInvoice = '';
|
||||
this.generatedInvoiceQr = '';
|
||||
this.lnReceiveError = '';
|
||||
this.invoiceCopied = false;
|
||||
this.payInput = '';
|
||||
this.payAmount = 0;
|
||||
this.paying = false;
|
||||
this.paymentSuccess = false;
|
||||
this.paymentError = '';
|
||||
this.showPayModal = false;
|
||||
}
|
||||
|
||||
showAddConnection() {
|
||||
this.resetAddForm();
|
||||
this.activeSection = 'lightning-add';
|
||||
}
|
||||
|
||||
private resetAddForm() {
|
||||
this.newWalletName = '';
|
||||
this.newWalletUrl = '';
|
||||
this.connectionError = '';
|
||||
this.connectionTestResult = '';
|
||||
this.addingConnection = false;
|
||||
this.testingConnection = false;
|
||||
}
|
||||
|
||||
async testConnection() {
|
||||
if (!this.newWalletUrl.trim()) {
|
||||
this.connectionError = 'Please enter an NWC URL';
|
||||
return;
|
||||
}
|
||||
|
||||
this.testingConnection = true;
|
||||
this.connectionError = '';
|
||||
this.connectionTestResult = '';
|
||||
this.nwcService.clearLogs();
|
||||
|
||||
try {
|
||||
const info = await this.nwcService.testConnection(this.newWalletUrl);
|
||||
this.connectionTestResult = `Connected! ${info.alias ? 'Wallet: ' + info.alias : ''}`;
|
||||
// Hide logs on success
|
||||
this.nwcService.clearLogs();
|
||||
} catch (error) {
|
||||
this.connectionError =
|
||||
error instanceof Error ? error.message : 'Connection test failed';
|
||||
// Keep logs visible on failure for debugging
|
||||
} finally {
|
||||
this.testingConnection = false;
|
||||
}
|
||||
}
|
||||
|
||||
async addConnection() {
|
||||
if (!this.newWalletName.trim()) {
|
||||
this.connectionError = 'Please enter a wallet name';
|
||||
return;
|
||||
}
|
||||
if (!this.newWalletUrl.trim()) {
|
||||
this.connectionError = 'Please enter an NWC URL';
|
||||
return;
|
||||
}
|
||||
|
||||
this.addingConnection = true;
|
||||
this.connectionError = '';
|
||||
|
||||
try {
|
||||
await this.nwcService.addConnection(
|
||||
this.newWalletName.trim(),
|
||||
this.newWalletUrl.trim()
|
||||
);
|
||||
|
||||
// Refresh the balance for the new connection
|
||||
const connections = this.nwcService.getConnections();
|
||||
const newConnection = connections[connections.length - 1];
|
||||
if (newConnection) {
|
||||
try {
|
||||
await this.nwcService.getBalance(newConnection.id);
|
||||
} catch {
|
||||
// Ignore balance fetch error
|
||||
}
|
||||
}
|
||||
|
||||
this.goBack();
|
||||
} catch (error) {
|
||||
this.connectionError =
|
||||
error instanceof Error ? error.message : 'Failed to add connection';
|
||||
} finally {
|
||||
this.addingConnection = false;
|
||||
}
|
||||
}
|
||||
|
||||
async deleteConnection() {
|
||||
if (!this.selectedConnectionId) return;
|
||||
|
||||
const connection = this.selectedConnection;
|
||||
if (
|
||||
!confirm(`Delete wallet "${connection?.name}"? This cannot be undone.`)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.nwcService.deleteConnection(this.selectedConnectionId);
|
||||
this.goBack();
|
||||
} catch (error) {
|
||||
console.error('Failed to delete connection:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Cashu methods
|
||||
|
||||
selectMint(mintId: string) {
|
||||
this.selectedMintId = mintId;
|
||||
this.activeSection = 'cashu-detail';
|
||||
// Auto-refresh to check for spent proofs
|
||||
this.refreshMint();
|
||||
}
|
||||
|
||||
async refreshMint() {
|
||||
if (!this.selectedMintId || this.refreshingMint) return;
|
||||
|
||||
this.refreshingMint = true;
|
||||
this.refreshError = '';
|
||||
|
||||
try {
|
||||
const removedAmount = await this.cashuService.checkProofsSpent(this.selectedMintId);
|
||||
if (removedAmount > 0) {
|
||||
// Balance was updated, proofs were spent
|
||||
console.log(`Removed ${removedAmount} sats of spent proofs`);
|
||||
}
|
||||
} catch (error) {
|
||||
this.refreshError = error instanceof Error ? error.message : 'Failed to refresh';
|
||||
console.error('Failed to refresh mint:', error);
|
||||
} finally {
|
||||
this.refreshingMint = false;
|
||||
}
|
||||
}
|
||||
|
||||
showAddMint() {
|
||||
this.resetAddMintForm();
|
||||
this.activeSection = 'cashu-add';
|
||||
}
|
||||
|
||||
showReceive() {
|
||||
this.resetReceiveSendForm();
|
||||
this.activeSection = 'cashu-receive';
|
||||
}
|
||||
|
||||
showSend() {
|
||||
this.resetReceiveSendForm();
|
||||
this.activeSection = 'cashu-send';
|
||||
}
|
||||
|
||||
private resetAddMintForm() {
|
||||
this.newMintName = '';
|
||||
this.newMintUrl = '';
|
||||
this.mintError = '';
|
||||
this.mintTestResult = '';
|
||||
this.addingMint = false;
|
||||
this.testingMint = false;
|
||||
}
|
||||
|
||||
private resetReceiveSendForm() {
|
||||
this.receiveToken = '';
|
||||
this.receivingToken = false;
|
||||
this.receiveError = '';
|
||||
this.receiveResult = '';
|
||||
this.sendAmount = 0;
|
||||
this.sendingToken = false;
|
||||
this.sendError = '';
|
||||
this.sendResult = '';
|
||||
}
|
||||
|
||||
private resetDepositForm() {
|
||||
this.depositAmount = 0;
|
||||
this.creatingDepositQuote = false;
|
||||
this.depositQuoteId = '';
|
||||
this.depositInvoice = '';
|
||||
this.depositInvoiceQr = '';
|
||||
this.depositError = '';
|
||||
this.depositSuccess = '';
|
||||
this.checkingDepositPayment = false;
|
||||
this.depositQuoteState = 'UNPAID';
|
||||
this.stopDepositPolling();
|
||||
}
|
||||
|
||||
private stopDepositPolling() {
|
||||
if (this.depositPollingInterval) {
|
||||
clearInterval(this.depositPollingInterval);
|
||||
this.depositPollingInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
async testMint() {
|
||||
if (!this.newMintUrl.trim()) {
|
||||
this.mintError = 'Please enter a mint URL';
|
||||
return;
|
||||
}
|
||||
|
||||
this.testingMint = true;
|
||||
this.mintError = '';
|
||||
this.mintTestResult = '';
|
||||
|
||||
try {
|
||||
const info = await this.cashuService.testMintConnection(
|
||||
this.newMintUrl.trim()
|
||||
);
|
||||
this.mintTestResult = `Connected! ${info.name ? 'Mint: ' + info.name : ''}`;
|
||||
} catch (error) {
|
||||
this.mintError =
|
||||
error instanceof Error ? error.message : 'Connection test failed';
|
||||
} finally {
|
||||
this.testingMint = false;
|
||||
}
|
||||
}
|
||||
|
||||
async addMint() {
|
||||
if (!this.newMintName.trim()) {
|
||||
this.mintError = 'Please enter a mint name';
|
||||
return;
|
||||
}
|
||||
if (!this.newMintUrl.trim()) {
|
||||
this.mintError = 'Please enter a mint URL';
|
||||
return;
|
||||
}
|
||||
|
||||
this.addingMint = true;
|
||||
this.mintError = '';
|
||||
|
||||
try {
|
||||
await this.cashuService.addMint(
|
||||
this.newMintName.trim(),
|
||||
this.newMintUrl.trim()
|
||||
);
|
||||
this.goBack();
|
||||
} catch (error) {
|
||||
this.mintError =
|
||||
error instanceof Error ? error.message : 'Failed to add mint';
|
||||
} finally {
|
||||
this.addingMint = false;
|
||||
}
|
||||
}
|
||||
|
||||
async deleteMint() {
|
||||
if (!this.selectedMintId) return;
|
||||
|
||||
const mint = this.selectedMint;
|
||||
if (!confirm(`Delete mint "${mint?.name}"? Any tokens stored will be lost. This cannot be undone.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.cashuService.deleteMint(this.selectedMintId);
|
||||
this.goBack();
|
||||
} catch (error) {
|
||||
console.error('Failed to delete mint:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async receiveTokens() {
|
||||
if (!this.receiveToken.trim()) {
|
||||
this.receiveError = 'Please paste a Cashu token';
|
||||
return;
|
||||
}
|
||||
|
||||
this.receivingToken = true;
|
||||
this.receiveError = '';
|
||||
this.receiveResult = '';
|
||||
|
||||
try {
|
||||
const result = await this.cashuService.receive(this.receiveToken.trim());
|
||||
this.receiveResult = `Received ${result.amount} sats!`;
|
||||
this.receiveToken = '';
|
||||
} catch (error) {
|
||||
this.receiveError =
|
||||
error instanceof Error ? error.message : 'Failed to receive token';
|
||||
} finally {
|
||||
this.receivingToken = false;
|
||||
}
|
||||
}
|
||||
|
||||
async sendTokens() {
|
||||
if (!this.selectedMintId) return;
|
||||
|
||||
if (this.sendAmount <= 0) {
|
||||
this.sendError = 'Please enter a valid amount';
|
||||
return;
|
||||
}
|
||||
|
||||
const balance = this.selectedMintBalance;
|
||||
if (this.sendAmount > balance) {
|
||||
this.sendError = `Insufficient balance. You have ${balance} sats`;
|
||||
return;
|
||||
}
|
||||
|
||||
this.sendingToken = true;
|
||||
this.sendError = '';
|
||||
this.sendResult = '';
|
||||
|
||||
try {
|
||||
const result = await this.cashuService.send(
|
||||
this.selectedMintId,
|
||||
this.sendAmount
|
||||
);
|
||||
this.sendResult = result.token;
|
||||
this.sendAmount = 0;
|
||||
} catch (error) {
|
||||
this.sendError =
|
||||
error instanceof Error ? error.message : 'Failed to create token';
|
||||
} finally {
|
||||
this.sendingToken = false;
|
||||
}
|
||||
}
|
||||
|
||||
copyToken() {
|
||||
if (this.sendResult) {
|
||||
navigator.clipboard.writeText(this.sendResult);
|
||||
}
|
||||
}
|
||||
|
||||
async checkProofs() {
|
||||
if (!this.selectedMintId) return;
|
||||
|
||||
try {
|
||||
const removedAmount = await this.cashuService.checkProofsSpent(
|
||||
this.selectedMintId
|
||||
);
|
||||
if (removedAmount > 0) {
|
||||
alert(`Removed ${removedAmount} sats of spent proofs.`);
|
||||
} else {
|
||||
alert('All proofs are valid.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to check proofs:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Cashu deposit (mint) methods
|
||||
|
||||
showDeposit() {
|
||||
this.resetDepositForm();
|
||||
this.activeSection = 'cashu-mint';
|
||||
}
|
||||
|
||||
async createDepositInvoice() {
|
||||
if (!this.selectedMintId) return;
|
||||
|
||||
if (this.depositAmount <= 0) {
|
||||
this.depositError = 'Please enter an amount';
|
||||
return;
|
||||
}
|
||||
|
||||
this.creatingDepositQuote = true;
|
||||
this.depositError = '';
|
||||
this.depositInvoice = '';
|
||||
this.depositInvoiceQr = '';
|
||||
|
||||
try {
|
||||
const quote = await this.cashuService.createMintQuote(
|
||||
this.selectedMintId,
|
||||
this.depositAmount
|
||||
);
|
||||
|
||||
this.depositQuoteId = quote.quoteId;
|
||||
this.depositInvoice = quote.invoice;
|
||||
this.depositQuoteState = quote.state;
|
||||
|
||||
// Generate QR code
|
||||
this.depositInvoiceQr = await QRCode.toDataURL(quote.invoice, {
|
||||
width: 200,
|
||||
margin: 2,
|
||||
color: {
|
||||
dark: '#000000',
|
||||
light: '#ffffff',
|
||||
},
|
||||
});
|
||||
|
||||
// Start polling for payment
|
||||
this.startDepositPolling();
|
||||
} catch (error) {
|
||||
this.depositError =
|
||||
error instanceof Error ? error.message : 'Failed to create invoice';
|
||||
} finally {
|
||||
this.creatingDepositQuote = false;
|
||||
}
|
||||
}
|
||||
|
||||
private startDepositPolling() {
|
||||
// Poll every 3 seconds for payment confirmation
|
||||
this.depositPollingInterval = setInterval(async () => {
|
||||
await this.checkDepositPayment();
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
async checkDepositPayment() {
|
||||
if (!this.selectedMintId || !this.depositQuoteId) return;
|
||||
|
||||
this.checkingDepositPayment = true;
|
||||
|
||||
try {
|
||||
const quote = await this.cashuService.checkMintQuote(
|
||||
this.selectedMintId,
|
||||
this.depositQuoteId
|
||||
);
|
||||
|
||||
this.depositQuoteState = quote.state;
|
||||
|
||||
if (quote.state === 'PAID') {
|
||||
// Invoice is paid, claim the tokens
|
||||
this.stopDepositPolling();
|
||||
await this.claimDepositTokens();
|
||||
} else if (quote.state === 'ISSUED') {
|
||||
// Already claimed
|
||||
this.stopDepositPolling();
|
||||
this.depositSuccess = 'Tokens already claimed!';
|
||||
}
|
||||
} catch (error) {
|
||||
// Don't show error for polling failures, just log
|
||||
console.error('Failed to check payment:', error);
|
||||
} finally {
|
||||
this.checkingDepositPayment = false;
|
||||
}
|
||||
}
|
||||
|
||||
async claimDepositTokens() {
|
||||
if (!this.selectedMintId || !this.depositQuoteId) return;
|
||||
|
||||
try {
|
||||
const result = await this.cashuService.mintTokens(
|
||||
this.selectedMintId,
|
||||
this.depositAmount,
|
||||
this.depositQuoteId
|
||||
);
|
||||
|
||||
this.depositSuccess = `Received ${result.amount} sats!`;
|
||||
this.depositQuoteState = 'ISSUED';
|
||||
} catch (error) {
|
||||
this.depositError =
|
||||
error instanceof Error ? error.message : 'Failed to claim tokens';
|
||||
}
|
||||
}
|
||||
|
||||
async copyDepositInvoice() {
|
||||
if (this.depositInvoice) {
|
||||
await navigator.clipboard.writeText(this.depositInvoice);
|
||||
}
|
||||
}
|
||||
|
||||
formatCashuBalance(sats: number | undefined): string {
|
||||
return this.cashuService.formatBalance(sats);
|
||||
}
|
||||
|
||||
async refreshBalance(connectionId: string) {
|
||||
try {
|
||||
await this.nwcService.getBalance(connectionId);
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh balance:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async refreshAllBalances() {
|
||||
this.loadingBalances = true;
|
||||
this.balanceError = '';
|
||||
|
||||
try {
|
||||
await this.nwcService.getAllBalances();
|
||||
} catch {
|
||||
this.balanceError = 'Failed to refresh some balances';
|
||||
} finally {
|
||||
this.loadingBalances = false;
|
||||
}
|
||||
}
|
||||
|
||||
formatBalance(millisats: number | undefined): string {
|
||||
if (millisats === undefined) return '—';
|
||||
// Convert millisats to sats with 3 decimal places
|
||||
const sats = millisats / 1000;
|
||||
return sats.toLocaleString('en-US', {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 3,
|
||||
});
|
||||
}
|
||||
|
||||
// Lightning transaction methods
|
||||
|
||||
async loadTransactions(connectionId: string) {
|
||||
this.loadingTransactions = true;
|
||||
this.transactionsError = '';
|
||||
this.transactionsNotSupported = false;
|
||||
|
||||
try {
|
||||
this.transactions = await this.nwcService.listTransactions(connectionId, {
|
||||
limit: 20,
|
||||
});
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
|
||||
if (errorMsg.includes('NOT_IMPLEMENTED') || errorMsg.includes('not supported')) {
|
||||
this.transactionsNotSupported = true;
|
||||
} else {
|
||||
this.transactionsError = errorMsg;
|
||||
}
|
||||
this.transactions = [];
|
||||
} finally {
|
||||
this.loadingTransactions = false;
|
||||
}
|
||||
}
|
||||
|
||||
async refreshWallet() {
|
||||
if (!this.selectedConnectionId) return;
|
||||
|
||||
// Refresh balance and transactions in parallel
|
||||
await Promise.all([
|
||||
this.refreshBalance(this.selectedConnectionId),
|
||||
this.loadTransactions(this.selectedConnectionId),
|
||||
]);
|
||||
}
|
||||
|
||||
showLnReceive() {
|
||||
this.resetLightningForms();
|
||||
this.activeSection = 'lightning-receive';
|
||||
}
|
||||
|
||||
showLnPay() {
|
||||
this.resetLightningForms();
|
||||
this.showPayModal = true;
|
||||
}
|
||||
|
||||
closePayModal() {
|
||||
this.showPayModal = false;
|
||||
this.resetLightningForms();
|
||||
}
|
||||
|
||||
async createReceiveInvoice() {
|
||||
if (!this.selectedConnectionId) return;
|
||||
|
||||
if (this.lnReceiveAmount <= 0) {
|
||||
this.lnReceiveError = 'Please enter an amount';
|
||||
return;
|
||||
}
|
||||
|
||||
this.generatingInvoice = true;
|
||||
this.lnReceiveError = '';
|
||||
this.generatedInvoice = '';
|
||||
this.generatedInvoiceQr = '';
|
||||
|
||||
try {
|
||||
const result = await this.nwcService.makeInvoice(
|
||||
this.selectedConnectionId,
|
||||
this.lnReceiveAmount * 1000, // Convert sats to millisats
|
||||
this.lnReceiveDescription || undefined
|
||||
);
|
||||
this.generatedInvoice = result.invoice;
|
||||
|
||||
// Generate QR code
|
||||
this.generatedInvoiceQr = await QRCode.toDataURL(result.invoice, {
|
||||
width: 200,
|
||||
margin: 2,
|
||||
color: {
|
||||
dark: '#000000',
|
||||
light: '#ffffff',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
this.lnReceiveError =
|
||||
error instanceof Error ? error.message : 'Failed to create invoice';
|
||||
} finally {
|
||||
this.generatingInvoice = false;
|
||||
}
|
||||
}
|
||||
|
||||
async copyInvoice() {
|
||||
if (this.generatedInvoice) {
|
||||
await navigator.clipboard.writeText(this.generatedInvoice);
|
||||
this.invoiceCopied = true;
|
||||
setTimeout(() => (this.invoiceCopied = false), 2000);
|
||||
}
|
||||
}
|
||||
|
||||
async copyLightningAddress() {
|
||||
const lud16 = this.selectedConnection?.lud16;
|
||||
if (lud16) {
|
||||
await navigator.clipboard.writeText(lud16);
|
||||
this.addressCopied = true;
|
||||
setTimeout(() => (this.addressCopied = false), 2000);
|
||||
}
|
||||
}
|
||||
|
||||
async payInvoiceOrAddress() {
|
||||
if (!this.selectedConnectionId || !this.payInput.trim()) {
|
||||
this.paymentError = 'Please enter a lightning address or invoice';
|
||||
return;
|
||||
}
|
||||
|
||||
this.paying = true;
|
||||
this.paymentError = '';
|
||||
this.paymentSuccess = false;
|
||||
|
||||
try {
|
||||
let invoice = this.payInput.trim();
|
||||
|
||||
// Check if it's a lightning address
|
||||
if (this.nwcService.isLightningAddress(invoice)) {
|
||||
if (this.payAmount <= 0) {
|
||||
this.paymentError = 'Please enter an amount for lightning address payments';
|
||||
this.paying = false;
|
||||
return;
|
||||
}
|
||||
// Resolve lightning address to invoice
|
||||
invoice = await this.nwcService.resolveLightningAddress(
|
||||
invoice,
|
||||
this.payAmount * 1000 // Convert sats to millisats
|
||||
);
|
||||
}
|
||||
|
||||
// Pay the invoice
|
||||
await this.nwcService.payInvoice(
|
||||
this.selectedConnectionId,
|
||||
invoice,
|
||||
this.payAmount > 0 ? this.payAmount * 1000 : undefined
|
||||
);
|
||||
|
||||
this.paymentSuccess = true;
|
||||
|
||||
// Refresh balance and transactions after payment
|
||||
await this.refreshWallet();
|
||||
|
||||
// Close modal after a delay
|
||||
setTimeout(() => {
|
||||
this.closePayModal();
|
||||
}, 2000);
|
||||
} catch (error) {
|
||||
this.paymentError =
|
||||
error instanceof Error ? error.message : 'Payment failed';
|
||||
} finally {
|
||||
this.paying = false;
|
||||
}
|
||||
}
|
||||
|
||||
formatTransactionTime(timestamp: number): string {
|
||||
const date = new Date(timestamp * 1000);
|
||||
const now = new Date();
|
||||
const isToday = date.toDateString() === now.toDateString();
|
||||
|
||||
if (isToday) {
|
||||
return date.toLocaleTimeString('en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
formatProofTime(isoTimestamp: string | undefined): string {
|
||||
if (!isoTimestamp) return '—';
|
||||
|
||||
const date = new Date(isoTimestamp);
|
||||
const now = new Date();
|
||||
const isToday = date.toDateString() === now.toDateString();
|
||||
|
||||
if (isToday) {
|
||||
return date.toLocaleTimeString('en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
async onClickLock() {
|
||||
this.#logger.logVaultLock();
|
||||
await this.#storage.lockVault();
|
||||
this.#router.navigateByUrl('/vault-login');
|
||||
}
|
||||
|
||||
// Cashu onboarding methods
|
||||
dismissCashuInfo() {
|
||||
this.showCashuInfo = false;
|
||||
}
|
||||
|
||||
navigateToSettings() {
|
||||
this.#router.navigateByUrl('/home/settings');
|
||||
}
|
||||
}
|
||||
@@ -43,23 +43,22 @@
|
||||
<span>Sign in</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="sam-mt"
|
||||
(click)="
|
||||
confirm.show(
|
||||
'Do you really want to reset the extension? All data will be lost.',
|
||||
onClickResetExtension.bind(this)
|
||||
)
|
||||
"
|
||||
type="button"
|
||||
class="btn btn-link"
|
||||
>
|
||||
Reset Extension
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="reset-btn"
|
||||
(click)="
|
||||
confirm.show(
|
||||
'Do you really want to reset the extension? All data will be lost.',
|
||||
onClickResetExtension.bind(this)
|
||||
)
|
||||
"
|
||||
type="button"
|
||||
>
|
||||
Reset Extension
|
||||
</button>
|
||||
|
||||
<!----------->
|
||||
<!-- ALERT -->
|
||||
<!----------->
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-items: center;
|
||||
position: relative;
|
||||
|
||||
.logo-frame {
|
||||
border: 2px solid var(--secondary);
|
||||
@@ -16,4 +17,21 @@
|
||||
justify-content: center;
|
||||
padding: 0 var(--size) var(--size) var(--size);
|
||||
}
|
||||
|
||||
.reset-btn {
|
||||
position: absolute;
|
||||
bottom: var(--size);
|
||||
right: var(--size);
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
|
||||
&:hover {
|
||||
color: var(--foreground);
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,26 @@
|
||||
<span> Sync OFF</span>
|
||||
</button>
|
||||
|
||||
<div class="storage-info">
|
||||
<details>
|
||||
<summary>Important for Cashu wallet users</summary>
|
||||
<p>
|
||||
Browser sync storage is limited to ~100KB shared across all data
|
||||
(identities, permissions, relays, and Cashu tokens).
|
||||
</p>
|
||||
<p>
|
||||
If you plan to use the Cashu ecash wallet with significant balances,
|
||||
choose <strong>"Sync OFF"</strong> which provides ~5MB of local storage
|
||||
(enough for ~18,000+ tokens vs ~300-400 with sync).
|
||||
</p>
|
||||
<p>
|
||||
<strong>Note:</strong> Cashu tokens are bearer assets. If you lose your
|
||||
vault backup, you lose your tokens permanently. Make sure to configure
|
||||
regular backups.
|
||||
</p>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<div class="sam-flex-grow"></div>
|
||||
|
||||
<span class="sam-text-muted sam-text-md sam-mb">
|
||||
|
||||
@@ -6,3 +6,41 @@
|
||||
padding-left: var(--size);
|
||||
padding-right: var(--size);
|
||||
}
|
||||
|
||||
.storage-info {
|
||||
margin-top: 1rem;
|
||||
width: 100%;
|
||||
|
||||
details {
|
||||
background: rgba(255, 193, 7, 0.1);
|
||||
border: 1px solid var(--warning, #ffc107);
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem;
|
||||
|
||||
summary {
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
font-size: 0.9rem;
|
||||
color: var(--warning, #ffc107);
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0.75rem 0 0 0;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.4;
|
||||
color: var(--text-muted, #6c757d);
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
strong {
|
||||
color: var(--text, #212529);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
} from '@common';
|
||||
import './app/common/extensions/array';
|
||||
import browser from 'webextension-polyfill';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
//
|
||||
// Functions
|
||||
@@ -105,8 +106,12 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
}
|
||||
|
||||
newSnapshots.push({
|
||||
id: uuidv4(),
|
||||
fileName: file.name,
|
||||
createdAt: new Date().toISOString(),
|
||||
data: vault,
|
||||
identityCount: vault.identities?.length ?? 0,
|
||||
reason: 'manual',
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ body {
|
||||
background: var(--background);
|
||||
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
// Button styling to match market
|
||||
@@ -128,3 +129,4 @@ button {
|
||||
.modal-body {
|
||||
color: #fafafa;
|
||||
}
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
147
scripts/fetch-kinds.js
Normal file
147
scripts/fetch-kinds.js
Normal file
@@ -0,0 +1,147 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Fetches kinds.json from the nostr library and generates TypeScript definitions
|
||||
* Run: node scripts/fetch-kinds.js
|
||||
*/
|
||||
|
||||
import { writeFileSync } from 'fs';
|
||||
import { join, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const KINDS_URL = 'https://git.mleku.dev/mleku/nostr/raw/branch/main/encoders/kind/kinds.json';
|
||||
|
||||
async function fetchKinds() {
|
||||
console.log(`Fetching kinds from ${KINDS_URL}...`);
|
||||
|
||||
const response = await fetch(KINDS_URL);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch kinds.json: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log(`Fetched ${Object.keys(data.kinds).length} kinds (version: ${data.version})`);
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
function generateTypeScript(data) {
|
||||
const kinds = [];
|
||||
|
||||
for (const [kindNum, info] of Object.entries(data.kinds)) {
|
||||
const k = parseInt(kindNum, 10);
|
||||
|
||||
// Determine classification
|
||||
let classification = 'regular';
|
||||
if (info.classification) {
|
||||
classification = info.classification;
|
||||
} else if (k === 0 || k === 3 || (k >= data.ranges.replaceable.start && k < data.ranges.replaceable.end)) {
|
||||
classification = 'replaceable';
|
||||
} else if (k >= data.ranges.parameterized.start && k <= data.ranges.parameterized.end) {
|
||||
classification = 'parameterized';
|
||||
} else if (k >= data.ranges.ephemeral.start && k < data.ranges.ephemeral.end) {
|
||||
classification = 'ephemeral';
|
||||
}
|
||||
|
||||
kinds.push({
|
||||
kind: k,
|
||||
name: info.name,
|
||||
description: info.description,
|
||||
nip: info.nip || null,
|
||||
classification,
|
||||
deprecated: info.deprecated || false,
|
||||
spec: info.spec || null
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by kind number
|
||||
kinds.sort((a, b) => a.kind - b.kind);
|
||||
|
||||
return `/**
|
||||
* Nostr Event Kinds Database
|
||||
* Auto-generated from ${KINDS_URL}
|
||||
* Version: ${data.version}
|
||||
* Source: ${data.source}
|
||||
*
|
||||
* DO NOT EDIT - This file is auto-generated by scripts/fetch-kinds.js
|
||||
*/
|
||||
|
||||
export interface KindInfo {
|
||||
kind: number;
|
||||
name: string;
|
||||
description: string;
|
||||
nip: string | null;
|
||||
classification: 'regular' | 'replaceable' | 'ephemeral' | 'parameterized';
|
||||
deprecated: boolean;
|
||||
spec: string | null;
|
||||
}
|
||||
|
||||
export interface KindRanges {
|
||||
regular: { start: number; end: number; description: string };
|
||||
replaceable: { start: number; end: number; description: string };
|
||||
ephemeral: { start: number; end: number; description: string };
|
||||
parameterized: { start: number; end: number; description: string };
|
||||
}
|
||||
|
||||
export const EVENT_KINDS: KindInfo[] = ${JSON.stringify(kinds, null, 2)};
|
||||
|
||||
export const KIND_RANGES: KindRanges = ${JSON.stringify(data.ranges, null, 2)};
|
||||
|
||||
export const PRIVILEGED_KINDS: number[] = ${JSON.stringify(data.privileged)};
|
||||
|
||||
export const DIRECTORY_KINDS: number[] = ${JSON.stringify(data.directory)};
|
||||
|
||||
export const KIND_ALIASES: Record<string, number> = ${JSON.stringify(data.aliases, null, 2)};
|
||||
|
||||
// Lookup map for fast access
|
||||
const kindMap = new Map<number, KindInfo>(EVENT_KINDS.map(k => [k.kind, k]));
|
||||
|
||||
export function getKindInfo(kind: number): KindInfo | undefined {
|
||||
return kindMap.get(kind);
|
||||
}
|
||||
|
||||
export function getKindName(kind: number): string {
|
||||
const info = kindMap.get(kind);
|
||||
return info ? info.name : \`Kind \${kind}\`;
|
||||
}
|
||||
|
||||
export function isReplaceable(kind: number): boolean {
|
||||
if (kind === 0 || kind === 3) return true;
|
||||
return kind >= KIND_RANGES.replaceable.start && kind < KIND_RANGES.replaceable.end;
|
||||
}
|
||||
|
||||
export function isEphemeral(kind: number): boolean {
|
||||
return kind >= KIND_RANGES.ephemeral.start && kind < KIND_RANGES.ephemeral.end;
|
||||
}
|
||||
|
||||
export function isParameterized(kind: number): boolean {
|
||||
return kind >= KIND_RANGES.parameterized.start && kind <= KIND_RANGES.parameterized.end;
|
||||
}
|
||||
|
||||
export function isPrivileged(kind: number): boolean {
|
||||
return PRIVILEGED_KINDS.includes(kind);
|
||||
}
|
||||
|
||||
export function isDirectoryKind(kind: number): boolean {
|
||||
return DIRECTORY_KINDS.includes(kind);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
const data = await fetchKinds();
|
||||
const ts = generateTypeScript(data);
|
||||
|
||||
// Write to common library
|
||||
const outPath = join(__dirname, '..', 'projects', 'common', 'src', 'lib', 'constants', 'event-kinds.ts');
|
||||
|
||||
writeFileSync(outPath, ts);
|
||||
console.log(`Generated ${outPath} with ${Object.keys(data.kinds).length} kinds`);
|
||||
} catch (error) {
|
||||
console.error('Error:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
Reference in New Issue
Block a user