9 Commits

Author SHA1 Message Date
woikos
3750e99e61 Release v1.0.8 - Add wallet tab and UI improvements
- Add new wallet tab with placeholder for future functionality
- Reorder bottom tabs: You, Identities, Wallet, Bookmarks, Settings
- Move Reset Extension button to bottom-right corner on login page
- Improve header layout consistency across all pages
- Add custom scrollbar styling for Chrome (thin, dark track, light thumb)
- Update edit button to use emoji icon on identity page
- Fix horizontal overflow issues in global styles

Files modified:
- package.json (version bump)
- projects/{chrome,firefox}/src/app/app.routes.ts (wallet route)
- projects/{chrome,firefox}/src/app/components/home/wallet/* (new)
- projects/{chrome,firefox}/src/app/components/home/home.component.* (tabs)
- projects/{chrome,firefox}/src/app/components/vault-login/* (reset btn)
- projects/{chrome,firefox}/src/styles.scss (scrollbar, overflow)
- Various component HTML/SCSS files (header consistency)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-21 07:30:55 +01:00
woikos
2c1f3265b7 Release v1.0.7 - Move lock button to page headers
- Move lock button from bottom navigation bar to each page header
- Add lock button styling to common SCSS with hover effect
- Remove logs and info tabs from bottom navigation
- Standardize header height to 48px across all pages
- Simplify home component by removing lock logic

Files modified:
- package.json, manifest.json (both browsers)
- home.component.html/ts (both browsers)
- identities, identity, bookmarks, logs, info, settings components
- projects/common/src/lib/styles/_common.scss

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-21 05:15:21 +01:00
woikos
7ff8e257dd Add prebuild script to fetch event kinds from nostr library
- Add scripts/fetch-kinds.js to fetch kinds.json from central source
- Add event-kinds.ts with TypeScript types and 184 event kinds
- Update package.json build scripts to fetch kinds before building
- Source: https://git.mleku.dev/mleku/nostr/raw/branch/main/encoders/kind/kinds.json

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-21 05:08:00 +01:00
woikos
8b6ead1f81 Release v1.0.6 - Add store publishing documentation
- Store description for Chrome Web Store and Firefox Add-ons
- Privacy policy (no data collection, local-only storage)
- Comprehensive publishing guide with step-by-step checklists
- Concise publishing checklist for quick reference

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-20 17:32:55 +01:00
woikos
38d9a9ef9f Add concise publishing checklist
Streamlined guide for completing Chrome/Firefox submissions once screenshots are ready.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-20 17:24:40 +01:00
woikos
b55a3f01b6 Add extension store publishing documentation
- Store description for Chrome Web Store and Firefox Add-ons
- Privacy policy (no data collection, local-only storage)
- Comprehensive publishing guide with checklists

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-20 17:17:36 +01:00
b7bedf085a Release v1.0.5 - Update release command to clean old zips
- Updated /release command to delete old zip files before creating new ones
- Ensures releases/ folder only contains the latest version

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-20 12:50:17 +01:00
ff82e41012 Update release command to delete old zips before creating new ones
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-20 12:44:56 +01:00
45b1fb58e9 Release v1.0.4 - Add logging system, lock button, and emoji navigation
- Comprehensive logging system with chrome.storage.session persistence
- NIP-07 action logging in background scripts with standalone functions
- Vault operation logging (unlock, lock, create, reset, import, export)
- Profile and bookmark operation logging
- Logs page with refresh functionality and category icons
- Lock button (🔒) in navigation bar to quickly lock vault
- Reduced nav bar size (40px height, 16px font) with emoji icons
- Reordered navigation: You, Permissions, Bookmarks, Logs, About, Lock
- Bookmarks functionality for saving frequently used Nostr apps
- Fixed lock/unlock flow by properly clearing in-memory session data

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-20 12:42:19 +01:00
87 changed files with 4369 additions and 323 deletions

View File

@@ -45,10 +45,11 @@ This project uses **standard semver with `v` prefix** (e.g., `v0.0.8`, `v1.2.3`)
6. **Create release zip files** in the `releases/` folder:
```
mkdir -p releases
rm -f releases/plebeian-signer-chrome-v*.zip releases/plebeian-signer-firefox-v*.zip
cd dist/chrome && zip -r ../../releases/plebeian-signer-chrome-vX.Y.Z.zip . && cd ../..
cd dist/firefox && zip -r ../../releases/plebeian-signer-firefox-vX.Y.Z.zip . && cd ../..
```
Replace `vX.Y.Z` with the actual version number.
Replace `vX.Y.Z` with the actual version number. Old zip files are deleted to keep only the latest release.
7. **Compose a commit message** following this format:
- First line: 72 chars max, imperative mood summary (e.g., "Release v0.0.8")

View 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://git.mleku.dev/mleku/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://git.mleku.dev/mleku/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

View 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://git.mleku.dev/mleku/plebeian-signer/src/branch/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://git.mleku.dev/mleku/plebeian-signer`
- **Support URL:** `https://git.mleku.dev/mleku/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://git.mleku.dev/mleku/plebeian-signer`
- **Support URL:** `https://git.mleku.dev/mleku/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.

View 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://git.mleku.dev/mleku/plebeian-signer
- Report Issues: https://git.mleku.dev/mleku/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
View File

@@ -0,0 +1,129 @@
# Publishing Checklist
Developer accounts are set up. This document covers the remaining steps.
## Privacy Policy URL
```
https://git.mleku.dev/mleku/plebeian-signer/src/branch/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://git.mleku.dev/mleku/plebeian-signer/src/branch/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://git.mleku.dev/mleku/plebeian-signer` |
| Support URL | `https://git.mleku.dev/mleku/plebeian-signer/issues` |
| Privacy Policy | `https://git.mleku.dev/mleku/plebeian-signer/src/branch/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

View File

@@ -1,12 +1,12 @@
{
"name": "plebeian-signer",
"version": "v1.0.3",
"version": "v1.0.8",
"custom": {
"chrome": {
"version": "v1.0.3"
"version": "v1.0.8"
},
"firefox": {
"version": "v1.0.3"
"version": "v1.0.8"
}
},
"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",

View File

@@ -2,7 +2,7 @@
"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.3",
"version": "1.0.8",
"homepage_url": "https://git.mleku.dev/mleku/plebeian-signer",
"options_page": "options.html",
"permissions": [

View File

@@ -10,6 +10,8 @@ import { IdentityComponent } from './components/home/identity/identity.component
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 { 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';
@@ -71,6 +73,14 @@ export const routes: Routes = [
path: 'logs',
component: LogsComponent,
},
{
path: 'bookmarks',
component: BookmarksComponent,
},
{
path: 'wallet',
component: WalletComponent,
},
],
},
{

View File

@@ -28,7 +28,7 @@
</div>
<div class="info-banner">
<i class="bi bi-info-circle"></i>
<span class="emoji">💡</span>
<span>These relays are fetched from your NIP-65 relay list (kind 10002). To update your relay list, use a Nostr client that supports NIP-65.</span>
</div>

View File

@@ -0,0 +1,39 @@
<!-- 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>Bookmarks</span>
<button class="add-btn" title="Bookmark This Page" (click)="onBookmarkThisPage()">
<span class="emoji"></span>
</button>
</div>
<div class="bookmarks-container">
@if (isLoading) {
<div class="loading-state">Loading...</div>
} @else if (bookmarks.length === 0) {
<div class="empty-state">
<span class="sam-text-muted">
No bookmarks yet. Click "Bookmark This Page" to add the current page.
</span>
</div>
} @else {
@for (bookmark of bookmarks; track bookmark.id) {
<div class="bookmark-item" (click)="openBookmark(bookmark)">
<div class="bookmark-info">
<span class="bookmark-title">{{ bookmark.title }}</span>
<span class="bookmark-url">{{ getDomain(bookmark.url) }}</span>
</div>
<button
class="remove-btn"
title="Remove bookmark"
(click)="onRemoveBookmark(bookmark); $event.stopPropagation()"
>
<span class="emoji"></span>
</button>
</div>
}
}
</div>

View File

@@ -0,0 +1,112 @@
:host {
height: 100%;
display: flex;
flex-direction: column;
padding-top: var(--size);
padding-bottom: var(--size);
overflow: hidden;
> *:not(.sam-text-header) {
margin-left: var(--size);
margin-right: var(--size);
}
.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 {
flex: 1;
overflow-y: auto;
}
.empty-state,
.loading-state {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
color: var(--muted-foreground);
}
.bookmark-item {
display: flex;
align-items: center;
gap: var(--size-h);
padding: var(--size-h) var(--size);
margin-bottom: var(--size-hh);
background: var(--background-light);
border-radius: var(--radius-md);
cursor: pointer;
transition: background-color 0.15s ease;
&:hover {
background: var(--background-light-hover);
}
}
.bookmark-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.bookmark-title {
font-size: 0.9rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.bookmark-url {
font-size: 0.75rem;
color: var(--muted-foreground);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.remove-btn {
all: unset;
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 50%;
cursor: pointer;
color: var(--muted-foreground);
transition: background-color 0.15s ease, color 0.15s ease;
&:hover {
background: var(--destructive);
color: var(--destructive-foreground);
}
}

View File

@@ -0,0 +1,99 @@
import { Component, inject, OnInit } from '@angular/core';
import { Router } from '@angular/router';
import { Bookmark, LoggerService, SignerMetaData, StorageService } from '@common';
import { ChromeMetaHandler } from '../../../common/data/chrome-meta-handler';
@Component({
selector: 'app-bookmarks',
templateUrl: './bookmarks.component.html',
styleUrl: './bookmarks.component.scss',
imports: [],
})
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;
async ngOnInit() {
await this.loadBookmarks();
}
async loadBookmarks() {
this.isLoading = true;
try {
const metaData = await this.#metaHandler.loadFullData() as SignerMetaData;
this.#metaHandler.setFullData(metaData);
this.bookmarks = this.#metaHandler.getBookmarks();
} catch (error) {
console.error('Failed to load bookmarks:', error);
} finally {
this.isLoading = false;
}
}
async onBookmarkThisPage() {
try {
// Get the current tab URL and title
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
if (!tab?.url || !tab?.title) {
console.error('Could not get current tab info');
return;
}
// Check if already bookmarked
if (this.bookmarks.some(b => b.url === tab.url)) {
console.log('Page already bookmarked');
return;
}
const newBookmark: Bookmark = {
id: crypto.randomUUID(),
url: tab.url,
title: tab.title,
createdAt: Date.now(),
};
this.bookmarks = [newBookmark, ...this.bookmarks];
await this.saveBookmarks();
this.#logger.logBookmarkAdded(newBookmark.url, newBookmark.title);
} catch (error) {
console.error('Failed to bookmark page:', error);
}
}
async onRemoveBookmark(bookmark: Bookmark) {
this.bookmarks = this.bookmarks.filter(b => b.id !== bookmark.id);
await this.saveBookmarks();
this.#logger.logBookmarkRemoved(bookmark.url, bookmark.title);
}
async saveBookmarks() {
try {
await this.#metaHandler.setBookmarks(this.bookmarks);
} catch (error) {
console.error('Failed to save bookmarks:', error);
}
}
openBookmark(bookmark: Bookmark) {
chrome.tabs.create({ url: bookmark.url });
}
getDomain(url: string): string {
try {
return new URL(url).hostname;
} catch {
return url;
}
}
async onClickLock() {
this.#logger.logVaultLock();
await this.#storage.lockVault();
this.#router.navigateByUrl('/vault-login');
}
}

View File

@@ -3,38 +3,23 @@
</div>
<div class="tabs">
<a
class="tab"
routerLink="/home/identity"
routerLinkActive="active"
title="Your selected identity"
>
<i class="bi bi-person-circle"></i>
<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"
>
<i class="bi bi-people-fill"></i>
<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"
>
<i class="bi bi-gear"></i>
<a class="tab" routerLink="/home/wallet" routerLinkActive="active" title="Wallet">
<span class="emoji">💰</span>
</a>
<a class="tab" routerLink="/home/info" routerLinkActive="active" title="Info">
<i class="bi bi-info-circle"></i>
<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 style="font-size: 1.2rem">🪵</span>
<a class="tab" routerLink="/home/settings" routerLinkActive="active" title="Settings">
<span class="emoji">⚙️</span>
</a>
</div>

View File

@@ -4,17 +4,17 @@
flex-direction: column;
.tab-content {
height: calc(100% - 60px);
height: calc(100% - 48px);
}
.tabs {
height: 60px;
min-height: 60px;
height: 48px;
min-height: 48px;
background: var(--background-light);
display: flex;
flex-direction: row;
a {
a, button {
all: unset;
}
@@ -23,14 +23,17 @@
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
color: var(--muted-foreground);
border-top: 3px solid transparent;
border-top: 2px solid transparent;
transition: background-color 0.2s ease, color 0.2s ease, border-color 0.2s ease;
cursor: pointer;
.emoji {
font-size: 20px;
}
&:hover {
background: var(--background-light-hover);
color: var(--foreground);
@@ -38,7 +41,7 @@
&.active {
color: var(--foreground);
border-top: 3px solid var(--primary);
border-top: 2px solid var(--primary);
}
}
}

View File

@@ -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>
@@ -31,7 +31,7 @@
title="Manage whitelisted apps"
(click)="onClickWhitelistedApps()"
>
<i class="bi bi-gear"></i>
<span class="emoji">⚙️</span>
</button>
</div>
@@ -62,7 +62,7 @@
/>
<span class="name">{{ getDisplayName(identity) }}</span>
<lib-icon-button
icon="gear"
icon="⚙️"
title="Identity settings"
(click)="onClickEditIdentity(identity.id, $event)"
></lib-icon-button>

View File

@@ -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;
}
}

View File

@@ -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');
}
}

View File

@@ -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>

View File

@@ -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;
}
}
}

View File

@@ -2,6 +2,7 @@ import { Component, inject, OnInit } from '@angular/core';
import { Router } from '@angular/router';
import {
Identity_DECRYPTED,
LoggerService,
NostrHelper,
ProfileMetadata,
ProfileMetadataService,
@@ -29,6 +30,7 @@ export class IdentityComponent implements OnInit {
readonly #storage = inject(StorageService);
readonly #router = inject(Router);
readonly #profileMetadata = inject(ProfileMetadataService);
readonly #logger = inject(LoggerService);
ngOnInit(): void {
this.#loadData();
@@ -74,6 +76,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 =
@@ -136,13 +144,16 @@ export class IdentityComponent implements OnInit {
const result = await validateNip05(nip05, pubkey);
this.nip05isValidated = result.valid;
if (!result.valid) {
console.log('NIP-05 validation failed:', result.error);
if (result.valid) {
this.#logger.logNip05ValidationSuccess(nip05, pubkey);
} else {
this.#logger.logNip05ValidationError(nip05, result.error ?? 'Unknown error');
}
this.validating = false;
} catch (error) {
console.error('NIP-05 validation failed:', error);
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
this.#logger.logNip05ValidationError(nip05, errorMsg);
this.nip05isValidated = false;
this.validating = false;
}

View File

@@ -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>

View File

@@ -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%;
}
}

View File

@@ -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');
}
}

View File

@@ -1,20 +1,23 @@
<div class="logs-header">
<span class="logs-title">Logs</span>
<button class="btn btn-sm btn-secondary" (click)="onClear()">Clear Log</button>
<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" title="Refresh logs" (click)="onRefresh()">Refresh</button>
<button class="btn btn-sm btn-secondary" title="Clear logs" (click)="onClear()">Clear</button>
</div>
</div>
<div class="logs-container">
@if (logs.length === 0) {
<div class="logs-empty">No logs yet</div>
<div class="logs-empty">No activity logged yet</div>
}
@for (log of logs; track log.timestamp) {
<div class="log-entry" [class]="getLevelClass(log.level)">
<span class="log-icon emoji">{{ log.icon }}</span>
<span class="log-time">{{ log.timestamp | date:'HH:mm:ss' }}</span>
<span class="log-level">{{ log.level }}</span>
<span class="log-message">{{ log.message }}</span>
@if (log.data) {
<pre class="log-data">{{ log.data | json }}</pre>
}
</div>
}
</div>

View File

@@ -2,21 +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-title {
font-weight: 600;
font-size: 1.1rem;
.sam-text-header {
margin-bottom: var(--size);
flex-shrink: 0;
.logs-actions {
position: absolute;
right: 0;
display: flex;
gap: 8px;
}
}
}
.logs-container {
@@ -34,15 +39,14 @@
}
.log-entry {
font-family: monospace;
font-family: var(--font-sans);
font-size: 11px;
padding: 4px 8px;
padding: 6px 8px;
border-radius: 4px;
margin-bottom: 2px;
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: flex-start;
align-items: center;
&.log-error {
background: rgba(220, 53, 69, 0.15);
@@ -65,29 +69,20 @@
}
}
.log-icon {
font-size: 14px;
flex-shrink: 0;
width: 18px;
text-align: center;
}
.log-time {
color: var(--muted-foreground);
flex-shrink: 0;
}
.log-level {
font-weight: 600;
text-transform: uppercase;
width: 40px;
flex-shrink: 0;
font-size: 10px;
}
.log-message {
flex: 1;
word-break: break-word;
}
.log-data {
width: 100%;
margin: 4px 0 0 0;
padding: 4px;
background: rgba(0, 0, 0, 0.2);
border-radius: 4px;
font-size: 10px;
overflow-x: auto;
}

View File

@@ -1,22 +1,34 @@
import { Component, inject } from '@angular/core';
import { LoggerService, LogEntry } from '@common';
import { DatePipe, JsonPipe } from '@angular/common';
import { Component, inject, OnInit } from '@angular/core';
import { Router } from '@angular/router';
import { LoggerService, LogEntry, StorageService } from '@common';
import { DatePipe } from '@angular/common';
@Component({
selector: 'app-logs',
templateUrl: './logs.component.html',
styleUrl: './logs.component.scss',
imports: [DatePipe, JsonPipe],
imports: [DatePipe],
})
export class LogsComponent {
export class LogsComponent implements OnInit {
readonly #logger = inject(LoggerService);
readonly #storage = inject(StorageService);
readonly #router = inject(Router);
get logs(): LogEntry[] {
return this.#logger.logs;
}
onClear() {
this.#logger.clear();
ngOnInit() {
// Refresh logs from storage to get background script logs
this.#logger.refreshLogs();
}
async onRefresh() {
await this.#logger.refreshLogs();
}
async onClear() {
await this.#logger.clear();
}
getLevelClass(level: LogEntry['level']): string {
@@ -31,4 +43,10 @@ export class LogsComponent {
return 'log-info';
}
}
async onClickLock() {
this.#logger.logVaultLock();
await this.#storage.lockVault();
this.#router.navigateByUrl('/vault-login');
}
}

View File

@@ -1,4 +1,7 @@
<div class="sam-text-header">
<button class="lock-btn" title="Lock" (click)="onClickLock()">
<span class="emoji">🔒</span>
</button>
<span> Settings </span>
</div>
@@ -12,6 +15,9 @@
Import Vault
</button>
<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

View File

@@ -4,8 +4,11 @@
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;

View File

@@ -1,10 +1,13 @@
import { Component, inject, OnInit } from '@angular/core';
import { Router } from '@angular/router';
import {
BrowserSyncData,
BrowserSyncFlow,
ConfirmComponent,
DateHelper,
LoggerService,
NavComponent,
NavItemComponent,
StartupService,
StorageService,
} from '@common';
@@ -12,15 +15,17 @@ 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);
readonly #startup = inject(StartupService);
readonly #logger = inject(LoggerService);
ngOnInit(): void {
const vault = JSON.stringify(
@@ -44,6 +49,7 @@ export class SettingsComponent extends NavComponent implements OnInit {
async onResetExtension() {
try {
this.#logger.logVaultReset();
await this.#storage.resetExtension();
this.#startup.startOver(getNewStorageServiceConfig());
} catch (error) {
@@ -69,6 +75,7 @@ export class SettingsComponent extends NavComponent implements OnInit {
await this.#storage.deleteVault(true);
await this.#storage.importVault(vault);
this.#logger.logVaultImport(file.name);
this.#storage.isInitialized = false;
this.#startup.startOver(getNewStorageServiceConfig());
} catch (error) {
@@ -84,6 +91,7 @@ export class SettingsComponent extends NavComponent implements OnInit {
const fileName = `Plebeian Signer Chrome - Vault Export - ${dateTimeString}.json`;
this.#downloadJson(jsonVault, fileName);
this.#logger.logVaultExport(fileName);
}
#downloadJson(jsonString: string, fileName: string) {
@@ -96,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');
}
}

View File

@@ -0,0 +1,14 @@
<div class="sam-text-header">
<button class="lock-btn" title="Lock" (click)="onClickLock()">
<span class="emoji">🔒</span>
</button>
<span>Wallet</span>
</div>
<div class="wallet-container">
<div class="empty-state">
<span class="sam-text-muted">
Wallet functionality coming soon.
</span>
</div>
</div>

View File

@@ -0,0 +1,32 @@
:host {
height: 100%;
display: flex;
flex-direction: column;
padding-top: var(--size);
padding-bottom: var(--size);
overflow: hidden;
> *:not(.sam-text-header) {
margin-left: var(--size);
margin-right: var(--size);
}
.sam-text-header {
margin-bottom: var(--size);
flex-shrink: 0;
}
}
.wallet-container {
flex: 1;
overflow-y: auto;
}
.empty-state {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
color: var(--muted-foreground);
}

View File

@@ -0,0 +1,21 @@
import { Component, inject } from '@angular/core';
import { Router } from '@angular/router';
import { LoggerService, StorageService } from '@common';
@Component({
selector: 'app-wallet',
templateUrl: './wallet.component.html',
styleUrl: './wallet.component.scss',
imports: [],
})
export class WalletComponent {
readonly #logger = inject(LoggerService);
readonly #storage = inject(StorageService);
readonly #router = inject(Router);
async onClickLock() {
this.#logger.logVaultLock();
await this.#storage.lockVault();
this.#router.navigateByUrl('/vault-login');
}
}

View File

@@ -1,7 +1,7 @@
import { Component, inject, ViewChild } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { NavComponent, StorageService, DerivingModalComponent } from '@common';
import { LoggerService, NavComponent, StorageService, DerivingModalComponent } from '@common';
@Component({
selector: 'app-new',
@@ -16,6 +16,7 @@ export class NewComponent extends NavComponent {
readonly #router = inject(Router);
readonly #storage = inject(StorageService);
readonly #logger = inject(LoggerService);
toggleType(element: HTMLInputElement) {
if (element.type === 'password') {
@@ -35,6 +36,7 @@ export class NewComponent extends NavComponent {
try {
await this.#storage.createNewVault(this.password);
this.derivingModal.hide();
this.#logger.logVaultCreated();
this.#router.navigateByUrl('/home/identities');
} catch (error) {
this.derivingModal.hide();

View File

@@ -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 -->
<!----------->

View File

@@ -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;
}
}
}

View File

@@ -4,6 +4,7 @@ import { Router } from '@angular/router';
import {
ConfirmComponent,
DerivingModalComponent,
LoggerService,
NostrHelper,
ProfileMetadataService,
StartupService,
@@ -28,6 +29,7 @@ export class VaultLoginComponent implements AfterViewInit {
readonly #router = inject(Router);
readonly #startup = inject(StartupService);
readonly #profileMetadata = inject(ProfileMetadataService);
readonly #logger = inject(LoggerService);
ngAfterViewInit() {
this.passwordInput.nativeElement.focus();
@@ -69,6 +71,7 @@ export class VaultLoginComponent implements AfterViewInit {
// Unlock succeeded - hide modal and navigate
console.log('[login] Hiding modal and navigating');
this.derivingModal.hide();
this.#logger.logVaultUnlock();
// Fetch profile metadata for all identities in the background
this.#fetchAllProfiles();
@@ -102,6 +105,7 @@ export class VaultLoginComponent implements AfterViewInit {
async onClickResetExtension() {
try {
this.#logger.logVaultReset();
await this.#storage.resetExtension();
this.#startup.startOver(getNewStorageServiceConfig());
} catch (error) {

View File

@@ -1,5 +1,9 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { NostrHelper } from '@common';
import {
backgroundLogNip07Action,
backgroundLogPermissionStored,
NostrHelper,
} from '@common';
import {
BackgroundRequestMessage,
checkPermissions,
@@ -109,17 +113,28 @@ browser.runtime.onMessage.addListener(async (message /*, sender*/) => {
});
debug(response);
if (response === 'approve' || response === 'reject') {
const policy = response === 'approve' ? 'allow' : 'deny';
await storePermission(
browserSessionData,
currentIdentity,
req.host,
req.method,
response === 'approve' ? 'allow' : 'deny',
policy,
req.params?.kind
);
await backgroundLogPermissionStored(
req.host,
req.method,
policy,
req.params?.kind
);
}
if (['reject', 'reject-once'].includes(response)) {
await backgroundLogNip07Action(req.method, req.host, false, false, {
kind: req.params?.kind,
peerPubkey: req.params?.peerPubkey,
});
throw new Error('Permission denied');
}
} else {
@@ -128,46 +143,71 @@ browser.runtime.onMessage.addListener(async (message /*, sender*/) => {
}
const relays: Relays = {};
let result: any;
switch (req.method) {
case 'getPublicKey':
return NostrHelper.pubkeyFromPrivkey(currentIdentity.privkey);
result = NostrHelper.pubkeyFromPrivkey(currentIdentity.privkey);
await backgroundLogNip07Action(req.method, req.host, true, recklessApprove);
return result;
case 'signEvent':
return signEvent(req.params, currentIdentity.privkey);
result = signEvent(req.params, currentIdentity.privkey);
await backgroundLogNip07Action(req.method, req.host, true, recklessApprove, {
kind: req.params?.kind,
});
return result;
case 'getRelays':
browserSessionData.relays.forEach((x) => {
relays[x.url] = { read: x.read, write: x.write };
});
await backgroundLogNip07Action(req.method, req.host, true, recklessApprove);
return relays;
case 'nip04.encrypt':
return await nip04Encrypt(
result = await nip04Encrypt(
currentIdentity.privkey,
req.params.peerPubkey,
req.params.plaintext
);
await backgroundLogNip07Action(req.method, req.host, true, recklessApprove, {
peerPubkey: req.params.peerPubkey,
});
return result;
case 'nip44.encrypt':
return await nip44Encrypt(
result = await nip44Encrypt(
currentIdentity.privkey,
req.params.peerPubkey,
req.params.plaintext
);
await backgroundLogNip07Action(req.method, req.host, true, recklessApprove, {
peerPubkey: req.params.peerPubkey,
});
return result;
case 'nip04.decrypt':
return await nip04Decrypt(
result = await nip04Decrypt(
currentIdentity.privkey,
req.params.peerPubkey,
req.params.ciphertext
);
await backgroundLogNip07Action(req.method, req.host, true, recklessApprove, {
peerPubkey: req.params.peerPubkey,
});
return result;
case 'nip44.decrypt':
return await nip44Decrypt(
result = await nip44Decrypt(
currentIdentity.privkey,
req.params.peerPubkey,
req.params.ciphertext
);
await backgroundLogNip07Action(req.method, req.host, true, recklessApprove, {
peerPubkey: req.params.peerPubkey,
});
return result;
default:
throw new Error(`Not supported request method '${req.method}'.`);

View File

@@ -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);
}
}

View File

@@ -1,3 +1,7 @@
<div class="icon-button">
<i [class]="'bi bi-' + icon"></i>
@if (isEmoji) {
<span class="emoji">{{ icon }}</span>
} @else {
<i [class]="'bi bi-' + icon"></i>
}
</div>

View File

@@ -9,4 +9,9 @@ import { Component, Input } from '@angular/core';
})
export class IconButtonComponent {
@Input({ required: true }) icon!: string;
get isEmoji(): boolean {
// Check if the icon is an emoji (starts with a non-ASCII character)
return this.icon.length > 0 && this.icon.charCodeAt(0) > 255;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,13 +1,37 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Injectable } from '@angular/core';
declare const chrome: any;
export type LogCategory =
| 'nip07'
| 'permission'
| 'vault'
| 'profile'
| 'bookmark'
| 'system';
export interface LogEntry {
timestamp: Date;
level: 'log' | 'warn' | 'error' | 'debug';
category: LogCategory;
icon: string;
message: string;
data?: any;
}
// Serializable format for storage
interface StoredLogEntry {
timestamp: string;
level: 'log' | 'warn' | 'error' | 'debug';
category: LogCategory;
icon: string;
message: string;
data?: any;
}
const LOGS_STORAGE_KEY = 'extensionLogs';
@Injectable({
providedIn: 'root',
})
@@ -20,46 +44,351 @@ export class LoggerService {
return this.#logs;
}
initialize(namespace: string): void {
async initialize(namespace: string): Promise<void> {
this.#namespace = namespace;
await this.#loadLogsFromStorage();
}
async #loadLogsFromStorage(): Promise<void> {
try {
if (typeof chrome !== 'undefined' && chrome.storage?.session) {
const result = await chrome.storage.session.get(LOGS_STORAGE_KEY);
if (result[LOGS_STORAGE_KEY]) {
// Convert stored format back to LogEntry with Date objects
this.#logs = (result[LOGS_STORAGE_KEY] as StoredLogEntry[]).map(
(entry) => ({
...entry,
timestamp: new Date(entry.timestamp),
})
);
}
}
} catch (error) {
console.error('Failed to load logs from storage:', error);
}
}
async #saveLogsToStorage(): Promise<void> {
try {
if (typeof chrome !== 'undefined' && chrome.storage?.session) {
// Convert Date to ISO string for storage
const storedLogs: StoredLogEntry[] = this.#logs.map((entry) => ({
...entry,
timestamp: entry.timestamp.toISOString(),
}));
await chrome.storage.session.set({ [LOGS_STORAGE_KEY]: storedLogs });
}
} catch (error) {
console.error('Failed to save logs to storage:', error);
}
}
async refreshLogs(): Promise<void> {
await this.#loadLogsFromStorage();
}
// ============================================
// Generic logging methods
// ============================================
log(value: any, data?: any) {
this.#assureInitialized();
this.#addLog('log', value, data);
const nowString = new Date().toLocaleString();
console.log(`[${this.#namespace} - ${nowString}]`, JSON.stringify(value));
this.#addLog('log', 'system', '📝', value, data);
this.#consoleLog('log', value);
}
warn(value: any, data?: any) {
this.#assureInitialized();
this.#addLog('warn', value, data);
const nowString = new Date().toLocaleString();
console.warn(`[${this.#namespace} - ${nowString}]`, JSON.stringify(value));
this.#addLog('warn', 'system', '⚠️', value, data);
this.#consoleLog('warn', value);
}
error(value: any, data?: any) {
this.#assureInitialized();
this.#addLog('error', value, data);
const nowString = new Date().toLocaleString();
console.error(`[${this.#namespace} - ${nowString}]`, JSON.stringify(value));
this.#addLog('error', 'system', '❌', value, data);
this.#consoleLog('error', value);
}
debug(value: any, data?: any) {
this.#assureInitialized();
this.#addLog('debug', value, data);
const nowString = new Date().toLocaleString();
console.debug(`[${this.#namespace} - ${nowString}]`, JSON.stringify(value));
this.#addLog('debug', 'system', '🔍', value, data);
this.#consoleLog('debug', value);
}
clear() {
// ============================================
// NIP-07 Action Logging
// ============================================
logNip07Action(
method: string,
host: string,
approved: boolean,
autoApproved: boolean,
details?: { kind?: number; peerPubkey?: string }
) {
this.#assureInitialized();
const approvalType = autoApproved ? 'auto-approved' : approved ? 'approved' : 'denied';
const icon = approved ? '✅' : '🚫';
let message = `${method} from ${host} - ${approvalType}`;
if (details?.kind !== undefined) {
message += ` (kind: ${details.kind})`;
}
this.#addLog('log', 'nip07', icon, message, {
method,
host,
approved,
autoApproved,
...details,
});
this.#consoleLog('log', message);
}
logNip07GetPublicKey(host: string, approved: boolean, autoApproved: boolean) {
this.logNip07Action('getPublicKey', host, approved, autoApproved);
}
logNip07SignEvent(
host: string,
kind: number,
approved: boolean,
autoApproved: boolean
) {
this.logNip07Action('signEvent', host, approved, autoApproved, { kind });
}
logNip07Encrypt(
method: 'nip04.encrypt' | 'nip44.encrypt',
host: string,
approved: boolean,
autoApproved: boolean,
peerPubkey?: string
) {
this.logNip07Action(method, host, approved, autoApproved, { peerPubkey });
}
logNip07Decrypt(
method: 'nip04.decrypt' | 'nip44.decrypt',
host: string,
approved: boolean,
autoApproved: boolean,
peerPubkey?: string
) {
this.logNip07Action(method, host, approved, autoApproved, { peerPubkey });
}
logNip07GetRelays(host: string, approved: boolean, autoApproved: boolean) {
this.logNip07Action('getRelays', host, approved, autoApproved);
}
// ============================================
// Permission Logging
// ============================================
logPermissionStored(
host: string,
method: string,
policy: string,
kind?: number
) {
this.#assureInitialized();
const icon = policy === 'allow' ? '🔓' : '🔒';
let message = `Permission stored: ${method} for ${host} - ${policy}`;
if (kind !== undefined) {
message += ` (kind: ${kind})`;
}
this.#addLog('log', 'permission', icon, message, { host, method, policy, kind });
this.#consoleLog('log', message);
}
logPermissionDeleted(host: string, method: string, kind?: number) {
this.#assureInitialized();
let message = `Permission deleted: ${method} for ${host}`;
if (kind !== undefined) {
message += ` (kind: ${kind})`;
}
this.#addLog('log', 'permission', '🗑️', message, { host, method, kind });
this.#consoleLog('log', message);
}
// ============================================
// Vault Operations Logging
// ============================================
logVaultUnlock() {
this.#assureInitialized();
this.#addLog('log', 'vault', '🔓', 'Vault unlocked', undefined);
this.#consoleLog('log', 'Vault unlocked');
}
logVaultLock() {
this.#assureInitialized();
this.#addLog('log', 'vault', '🔒', 'Vault locked', undefined);
this.#consoleLog('log', 'Vault locked');
}
logVaultCreated() {
this.#assureInitialized();
this.#addLog('log', 'vault', '🆕', 'Vault created', undefined);
this.#consoleLog('log', 'Vault created');
}
logVaultExport(fileName: string) {
this.#assureInitialized();
this.#addLog('log', 'vault', '📤', `Vault exported: ${fileName}`, { fileName });
this.#consoleLog('log', `Vault exported: ${fileName}`);
}
logVaultImport(fileName: string) {
this.#assureInitialized();
this.#addLog('log', 'vault', '📥', `Vault imported: ${fileName}`, { fileName });
this.#consoleLog('log', `Vault imported: ${fileName}`);
}
logVaultReset() {
this.#assureInitialized();
this.#addLog('warn', 'vault', '🗑️', 'Extension reset', undefined);
this.#consoleLog('warn', 'Extension reset');
}
// ============================================
// Profile Operations Logging
// ============================================
logProfileFetchError(pubkey: string, error: string) {
this.#assureInitialized();
const shortPubkey = pubkey.substring(0, 8) + '...';
this.#addLog('error', 'profile', '👤', `Failed to fetch profile for ${shortPubkey}: ${error}`, {
pubkey,
error,
});
this.#consoleLog('error', `Failed to fetch profile for ${shortPubkey}: ${error}`);
}
logProfileParseError(pubkey: string) {
this.#assureInitialized();
const shortPubkey = pubkey.substring(0, 8) + '...';
this.#addLog('error', 'profile', '👤', `Failed to parse profile content for ${shortPubkey}`, {
pubkey,
});
this.#consoleLog('error', `Failed to parse profile content for ${shortPubkey}`);
}
logNip05ValidationError(nip05: string, error: string) {
this.#assureInitialized();
this.#addLog('error', 'profile', '🔗', `NIP-05 validation failed for ${nip05}: ${error}`, {
nip05,
error,
});
this.#consoleLog('error', `NIP-05 validation failed for ${nip05}: ${error}`);
}
logNip05ValidationSuccess(nip05: string, pubkey: string) {
this.#assureInitialized();
const shortPubkey = pubkey.substring(0, 8) + '...';
this.#addLog('log', 'profile', '✓', `NIP-05 verified: ${nip05}${shortPubkey}`, {
nip05,
pubkey,
});
this.#consoleLog('log', `NIP-05 verified: ${nip05}${shortPubkey}`);
}
logProfileEdit(identityNick: string, field: string) {
this.#assureInitialized();
this.#addLog('log', 'profile', '✏️', `Profile edited: ${identityNick} - ${field}`, {
identityNick,
field,
});
this.#consoleLog('log', `Profile edited: ${identityNick} - ${field}`);
}
logIdentityCreated(nick: string) {
this.#assureInitialized();
this.#addLog('log', 'profile', '🆕', `Identity created: ${nick}`, { nick });
this.#consoleLog('log', `Identity created: ${nick}`);
}
logIdentityDeleted(nick: string) {
this.#assureInitialized();
this.#addLog('warn', 'profile', '🗑️', `Identity deleted: ${nick}`, { nick });
this.#consoleLog('warn', `Identity deleted: ${nick}`);
}
logIdentitySelected(nick: string) {
this.#assureInitialized();
this.#addLog('log', 'profile', '👆', `Identity selected: ${nick}`, { nick });
this.#consoleLog('log', `Identity selected: ${nick}`);
}
// ============================================
// Bookmark Operations Logging
// ============================================
logBookmarkAdded(url: string, title: string) {
this.#assureInitialized();
this.#addLog('log', 'bookmark', '🔖', `Bookmark added: ${title}`, { url, title });
this.#consoleLog('log', `Bookmark added: ${title}`);
}
logBookmarkRemoved(url: string, title: string) {
this.#assureInitialized();
this.#addLog('log', 'bookmark', '🗑️', `Bookmark removed: ${title}`, { url, title });
this.#consoleLog('log', `Bookmark removed: ${title}`);
}
// ============================================
// System/Error Logging
// ============================================
logRelayFetchError(identityNick: string, error: string) {
this.#assureInitialized();
this.#addLog('error', 'system', '📡', `Failed to fetch relays for ${identityNick}: ${error}`, {
identityNick,
error,
});
this.#consoleLog('error', `Failed to fetch relays for ${identityNick}: ${error}`);
}
logStorageError(operation: string, error: string) {
this.#assureInitialized();
this.#addLog('error', 'system', '💾', `Storage error (${operation}): ${error}`, {
operation,
error,
});
this.#consoleLog('error', `Storage error (${operation}): ${error}`);
}
logCryptoError(operation: string, error: string) {
this.#assureInitialized();
this.#addLog('error', 'system', '🔐', `Crypto error (${operation}): ${error}`, {
operation,
error,
});
this.#consoleLog('error', `Crypto error (${operation}): ${error}`);
}
// ============================================
// Internal methods
// ============================================
async clear(): Promise<void> {
this.#logs = [];
await this.#saveLogsToStorage();
}
#addLog(level: LogEntry['level'], message: any, data?: any) {
#addLog(
level: LogEntry['level'],
category: LogCategory,
icon: string,
message: any,
data?: any
) {
const entry: LogEntry = {
timestamp: new Date(),
level,
category,
icon,
message: typeof message === 'string' ? message : JSON.stringify(message),
data,
};
@@ -69,6 +398,27 @@ export class LoggerService {
if (this.#logs.length > this.#maxLogs) {
this.#logs.pop();
}
// Save to storage asynchronously (don't block)
this.#saveLogsToStorage();
}
#consoleLog(level: 'log' | 'warn' | 'error' | 'debug', message: string) {
const nowString = new Date().toLocaleString();
const formattedMsg = `[${this.#namespace} - ${nowString}] ${message}`;
switch (level) {
case 'warn':
console.warn(formattedMsg);
break;
case 'error':
console.error(formattedMsg);
break;
case 'debug':
console.debug(formattedMsg);
break;
default:
console.log(formattedMsg);
}
}
#assureInitialized() {
@@ -79,3 +429,87 @@ export class LoggerService {
}
}
}
// ============================================
// Standalone functions for background script
// (Background script runs in different context without Angular DI)
// ============================================
export async function backgroundLog(
category: LogCategory,
icon: string,
level: LogEntry['level'],
message: string,
data?: any
): Promise<void> {
try {
if (typeof chrome === 'undefined' || !chrome.storage?.session) {
console.log(`[Background] ${message}`);
return;
}
const result = await chrome.storage.session.get(LOGS_STORAGE_KEY);
const existingLogs: StoredLogEntry[] = result[LOGS_STORAGE_KEY] || [];
const newEntry: StoredLogEntry = {
timestamp: new Date().toISOString(),
level,
category,
icon,
message,
data,
};
const updatedLogs = [newEntry, ...existingLogs].slice(0, 500);
await chrome.storage.session.set({ [LOGS_STORAGE_KEY]: updatedLogs });
} catch (error) {
console.error('Failed to add background log:', error);
}
}
export async function backgroundLogNip07Action(
method: string,
host: string,
approved: boolean,
autoApproved: boolean,
details?: { kind?: number; peerPubkey?: string }
): Promise<void> {
const approvalType = autoApproved
? 'auto-approved'
: approved
? 'approved'
: 'denied';
const icon = approved ? '✅' : '🚫';
let message = `${method} from ${host} - ${approvalType}`;
if (details?.kind !== undefined) {
message += ` (kind: ${details.kind})`;
}
await backgroundLog('nip07', icon, 'log', message, {
method,
host,
approved,
autoApproved,
...details,
});
}
export async function backgroundLogPermissionStored(
host: string,
method: string,
policy: string,
kind?: number
): Promise<void> {
const icon = policy === 'allow' ? '🔓' : '🔒';
let message = `Permission stored: ${method} for ${host} - ${policy}`;
if (kind !== undefined) {
message += ` (kind: ${kind})`;
}
await backgroundLog('permission', icon, 'log', message, {
host,
method,
policy,
kind,
});
}

View File

@@ -1,7 +1,8 @@
import { Injectable } from '@angular/core';
import { inject, Injectable } from '@angular/core';
import { SimplePool } from 'nostr-tools/pool';
import { FALLBACK_PROFILE_RELAYS } from '../../constants/fallback-relays';
import { ProfileMetadata, ProfileMetadataCache } from '../storage/types';
import { LoggerService } from '../logger/logger.service';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
declare const chrome: any;
@@ -14,6 +15,7 @@ const STORAGE_KEY = 'profileMetadataCache';
providedIn: 'root',
})
export class ProfileMetadataService {
readonly #logger = inject(LoggerService);
#cache: ProfileMetadataCache = {};
#pool: SimplePool | null = null;
#fetchPromises = new Map<string, Promise<ProfileMetadata | null>>();
@@ -52,7 +54,8 @@ export class ProfileMetadataService {
}
}
} catch (error) {
console.error('Failed to load profile cache from storage:', error);
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
this.#logger.logStorageError('load profile cache', errorMsg);
}
}
@@ -65,7 +68,8 @@ export class ProfileMetadataService {
await chrome.storage.session.set({ [STORAGE_KEY]: this.#cache });
}
} catch (error) {
console.error('Failed to save profile cache to storage:', error);
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
this.#logger.logStorageError('save profile cache', errorMsg);
}
}
@@ -209,7 +213,7 @@ export class ProfileMetadataService {
this.#cache[pubkey] = profile;
results.set(pubkey, profile);
} catch {
console.error(`Failed to parse profile for ${pubkey}`);
this.#logger.logProfileParseError(pubkey);
results.set(pubkey, null);
}
}
@@ -225,7 +229,8 @@ export class ProfileMetadataService {
await this.#saveCacheToStorage();
} catch (error) {
console.error('Failed to fetch profiles:', error);
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
this.#logger.logProfileFetchError('multiple', errorMsg);
// Set null for all unfetched pubkeys on error
for (const pubkey of uncachedPubkeys) {
if (!results.has(pubkey)) {
@@ -283,11 +288,12 @@ export class ProfileMetadataService {
return profile;
} catch {
console.error(`Failed to parse profile content for ${pubkey}`);
this.#logger.logProfileParseError(pubkey);
return null;
}
} catch (error) {
console.error(`Failed to fetch profile for ${pubkey}:`, error);
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
this.#logger.logProfileFetchError(pubkey, errorMsg);
return null;
}
}

View File

@@ -20,6 +20,10 @@ export abstract class BrowserSessionHandler {
this.#browserSessionData = JSON.parse(JSON.stringify(data));
}
clearInMemoryData() {
this.#browserSessionData = undefined;
}
/**
* Persist the full data to the session data storage.
*

View File

@@ -1,5 +1,5 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { BrowserSyncFlow, SignerMetaData } from './types';
import { Bookmark, BrowserSyncFlow, SignerMetaData } from './types';
export abstract class SignerMetaHandler {
get signerMetaData(): SignerMetaData | undefined {
@@ -8,7 +8,7 @@ export abstract class SignerMetaHandler {
#signerMetaData?: SignerMetaData;
readonly metaProperties = ['syncFlow', 'vaultSnapshots', 'recklessMode', 'whitelistedHosts'];
readonly metaProperties = ['syncFlow', 'vaultSnapshots', 'recklessMode', 'whitelistedHosts', 'bookmarks'];
/**
* 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),
@@ -89,4 +89,26 @@ export abstract class SignerMetaHandler {
await this.saveFullData(this.#signerMetaData);
}
/**
* Sets the bookmarks array and immediately saves it.
*/
async setBookmarks(bookmarks: Bookmark[]): Promise<void> {
if (!this.#signerMetaData) {
this.#signerMetaData = {
bookmarks,
};
} else {
this.#signerMetaData.bookmarks = bookmarks;
}
await this.saveFullData(this.#signerMetaData);
}
/**
* Gets the current bookmarks.
*/
getBookmarks(): Bookmark[] {
return this.#signerMetaData?.bookmarks ?? [];
}
}

View File

@@ -124,6 +124,14 @@ export class StorageService {
this.isInitialized = false;
}
async lockVault(): Promise<void> {
this.assureIsInitialized();
await this.getBrowserSessionHandler().clearData();
this.getBrowserSessionHandler().clearInMemoryData();
// Note: We don't set isInitialized = false here because the sync data
// (encrypted vault) is still loaded and we need it to unlock again
}
async unlockVault(password: string): Promise<void> {
await unlockVault.call(this, password);
}

View File

@@ -94,6 +94,16 @@ export const SIGNER_META_DATA_KEY = {
vaultSnapshots: 'vaultSnapshots',
};
/**
* Bookmark entry for storing user bookmarks
*/
export interface Bookmark {
id: string;
url: string;
title: string;
createdAt: number;
}
export interface SignerMetaData {
syncFlow?: number; // 0 = no sync, 1 = browser sync, (future: 2 = Signer sync, 3 = Custom sync (bring your own sync))
@@ -104,6 +114,9 @@ export interface SignerMetaData {
// Whitelisted hosts: auto-approve all actions from these hosts
whitelistedHosts?: string[];
// User bookmarks
bookmarks?: Bookmark[];
}
/**

View File

@@ -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 {

View File

@@ -84,3 +84,11 @@ h2.font-heading {
h3.font-heading {
font-size: 1.4rem;
}
// Emoji styling
.emoji {
font-family: var(--font-emoji);
font-style: normal;
font-weight: normal;
line-height: 1;
}

View File

@@ -16,6 +16,7 @@
--font-sans: 'IBM Plex Mono', monospace;
--font-heading: 'reglisse', sans-serif;
--font-theylive: 'theylive', sans-serif;
--font-emoji: 'Noto Color Emoji', 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Android Emoji', sans-serif;
// Border radius (from market)
--radius: 0.25rem;

View File

@@ -2,7 +2,7 @@
"manifest_version": 3,
"name": "Plebeian Signer",
"description": "Nostr Identity Manager & Signer",
"version": "1.0.3",
"version": "1.0.8",
"homepage_url": "https://git.mleku.dev/mleku/plebeian-signer",
"options_page": "options.html",
"permissions": [

View File

@@ -7,6 +7,8 @@ import { IdentityComponent } from './components/home/identity/identity.component
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 { 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';
@@ -71,6 +73,14 @@ export const routes: Routes = [
path: 'logs',
component: LogsComponent,
},
{
path: 'bookmarks',
component: BookmarksComponent,
},
{
path: 'wallet',
component: WalletComponent,
},
],
},
{

View File

@@ -28,7 +28,7 @@
</div>
<div class="info-banner">
<i class="bi bi-info-circle"></i>
<span class="emoji">💡</span>
<span>These relays are fetched from your NIP-65 relay list (kind 10002). To update your relay list, use a Nostr client that supports NIP-65.</span>
</div>

View File

@@ -0,0 +1,39 @@
<!-- 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>Bookmarks</span>
<button class="add-btn" title="Bookmark This Page" (click)="onBookmarkThisPage()">
<span class="emoji"></span>
</button>
</div>
<div class="bookmarks-container">
@if (isLoading) {
<div class="loading-state">Loading...</div>
} @else if (bookmarks.length === 0) {
<div class="empty-state">
<span class="sam-text-muted">
No bookmarks yet. Click "Bookmark This Page" to add the current page.
</span>
</div>
} @else {
@for (bookmark of bookmarks; track bookmark.id) {
<div class="bookmark-item" (click)="openBookmark(bookmark)">
<div class="bookmark-info">
<span class="bookmark-title">{{ bookmark.title }}</span>
<span class="bookmark-url">{{ getDomain(bookmark.url) }}</span>
</div>
<button
class="remove-btn"
title="Remove bookmark"
(click)="onRemoveBookmark(bookmark); $event.stopPropagation()"
>
<span class="emoji"></span>
</button>
</div>
}
}
</div>

View File

@@ -0,0 +1,112 @@
:host {
height: 100%;
display: flex;
flex-direction: column;
padding-top: var(--size);
padding-bottom: var(--size);
overflow: hidden;
> *:not(.sam-text-header) {
margin-left: var(--size);
margin-right: var(--size);
}
.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 {
flex: 1;
overflow-y: auto;
}
.empty-state,
.loading-state {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
color: var(--muted-foreground);
}
.bookmark-item {
display: flex;
align-items: center;
gap: var(--size-h);
padding: var(--size-h) var(--size);
margin-bottom: var(--size-hh);
background: var(--background-light);
border-radius: var(--radius-md);
cursor: pointer;
transition: background-color 0.15s ease;
&:hover {
background: var(--background-light-hover);
}
}
.bookmark-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.bookmark-title {
font-size: 0.9rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.bookmark-url {
font-size: 0.75rem;
color: var(--muted-foreground);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.remove-btn {
all: unset;
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 50%;
cursor: pointer;
color: var(--muted-foreground);
transition: background-color 0.15s ease, color 0.15s ease;
&:hover {
background: var(--destructive);
color: var(--destructive-foreground);
}
}

View File

@@ -0,0 +1,100 @@
import { Component, inject, OnInit } from '@angular/core';
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';
@Component({
selector: 'app-bookmarks',
templateUrl: './bookmarks.component.html',
styleUrl: './bookmarks.component.scss',
imports: [],
})
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;
async ngOnInit() {
await this.loadBookmarks();
}
async loadBookmarks() {
this.isLoading = true;
try {
const metaData = await this.#metaHandler.loadFullData() as SignerMetaData;
this.#metaHandler.setFullData(metaData);
this.bookmarks = this.#metaHandler.getBookmarks();
} catch (error) {
console.error('Failed to load bookmarks:', error);
} finally {
this.isLoading = false;
}
}
async onBookmarkThisPage() {
try {
// Get the current tab URL and title
const [tab] = await browser.tabs.query({ active: true, currentWindow: true });
if (!tab?.url || !tab?.title) {
console.error('Could not get current tab info');
return;
}
// Check if already bookmarked
if (this.bookmarks.some(b => b.url === tab.url)) {
console.log('Page already bookmarked');
return;
}
const newBookmark: Bookmark = {
id: crypto.randomUUID(),
url: tab.url,
title: tab.title,
createdAt: Date.now(),
};
this.bookmarks = [newBookmark, ...this.bookmarks];
await this.saveBookmarks();
this.#logger.logBookmarkAdded(newBookmark.url, newBookmark.title);
} catch (error) {
console.error('Failed to bookmark page:', error);
}
}
async onRemoveBookmark(bookmark: Bookmark) {
this.bookmarks = this.bookmarks.filter(b => b.id !== bookmark.id);
await this.saveBookmarks();
this.#logger.logBookmarkRemoved(bookmark.url, bookmark.title);
}
async saveBookmarks() {
try {
await this.#metaHandler.setBookmarks(this.bookmarks);
} catch (error) {
console.error('Failed to save bookmarks:', error);
}
}
openBookmark(bookmark: Bookmark) {
browser.tabs.create({ url: bookmark.url });
}
getDomain(url: string): string {
try {
return new URL(url).hostname;
} catch {
return url;
}
}
async onClickLock() {
this.#logger.logVaultLock();
await this.#storage.lockVault();
this.#router.navigateByUrl('/vault-login');
}
}

View File

@@ -3,38 +3,23 @@
</div>
<div class="tabs">
<a
class="tab"
routerLink="/home/identity"
routerLinkActive="active"
title="Your selected identity"
>
<i class="bi bi-person-circle"></i>
<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"
>
<i class="bi bi-people-fill"></i>
<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"
>
<i class="bi bi-gear"></i>
<a class="tab" routerLink="/home/wallet" routerLinkActive="active" title="Wallet">
<span class="emoji">💰</span>
</a>
<a class="tab" routerLink="/home/info" routerLinkActive="active" title="Info">
<i class="bi bi-info-circle"></i>
<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 style="font-size: 1.2rem">🪵</span>
<a class="tab" routerLink="/home/settings" routerLinkActive="active" title="Settings">
<span class="emoji">⚙️</span>
</a>
</div>

View File

@@ -4,17 +4,17 @@
flex-direction: column;
.tab-content {
height: calc(100% - 60px);
height: calc(100% - 48px);
}
.tabs {
height: 60px;
min-height: 60px;
height: 48px;
min-height: 48px;
background: var(--background-light);
display: flex;
flex-direction: row;
a {
a, button {
all: unset;
}
@@ -23,14 +23,17 @@
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
color: var(--muted-foreground);
border-top: 3px solid transparent;
border-top: 2px solid transparent;
transition: background-color 0.2s ease, color 0.2s ease, border-color 0.2s ease;
cursor: pointer;
.emoji {
font-size: 20px;
}
&:hover {
background: var(--background-light-hover);
color: var(--foreground);
@@ -38,7 +41,7 @@
&.active {
color: var(--foreground);
border-top: 3px solid var(--primary);
border-top: 2px solid var(--primary);
}
}
}

View File

@@ -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>
@@ -31,7 +31,7 @@
title="Manage whitelisted apps"
(click)="onClickWhitelistedApps()"
>
<i class="bi bi-gear"></i>
<span class="emoji">⚙️</span>
</button>
</div>
@@ -62,7 +62,7 @@
/>
<span class="name">{{ getDisplayName(identity) }}</span>
<lib-icon-button
icon="gear"
icon="⚙️"
title="Identity settings"
(click)="onClickEditIdentity(identity.id, $event)"
></lib-icon-button>

View File

@@ -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;
}
}

View File

@@ -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');
}
}

View File

@@ -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>

View File

@@ -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;
}
}
}

View File

@@ -2,6 +2,7 @@ import { Component, inject, OnInit } from '@angular/core';
import { Router } from '@angular/router';
import {
Identity_DECRYPTED,
LoggerService,
NostrHelper,
ProfileMetadata,
ProfileMetadataService,
@@ -29,6 +30,7 @@ export class IdentityComponent implements OnInit {
readonly #storage = inject(StorageService);
readonly #router = inject(Router);
readonly #profileMetadata = inject(ProfileMetadataService);
readonly #logger = inject(LoggerService);
ngOnInit(): void {
this.#loadData();
@@ -74,6 +76,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 =
@@ -136,13 +144,16 @@ export class IdentityComponent implements OnInit {
const result = await validateNip05(nip05, pubkey);
this.nip05isValidated = result.valid;
if (!result.valid) {
console.log('NIP-05 validation failed:', result.error);
if (result.valid) {
this.#logger.logNip05ValidationSuccess(nip05, pubkey);
} else {
this.#logger.logNip05ValidationError(nip05, result.error ?? 'Unknown error');
}
this.validating = false;
} catch (error) {
console.error('NIP-05 validation failed:', error);
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
this.#logger.logNip05ValidationError(nip05, errorMsg);
this.nip05isValidated = false;
this.validating = false;
}

View File

@@ -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>

View File

@@ -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%;
}
}

View File

@@ -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');
}
}

View File

@@ -1,20 +1,23 @@
<div class="logs-header">
<span class="logs-title">Logs</span>
<button class="btn btn-sm btn-secondary" (click)="onClear()">Clear Log</button>
<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" title="Refresh logs" (click)="onRefresh()">Refresh</button>
<button class="btn btn-sm btn-secondary" title="Clear logs" (click)="onClear()">Clear</button>
</div>
</div>
<div class="logs-container">
@if (logs.length === 0) {
<div class="logs-empty">No logs yet</div>
<div class="logs-empty">No activity logged yet</div>
}
@for (log of logs; track log.timestamp) {
<div class="log-entry" [class]="getLevelClass(log.level)">
<span class="log-icon emoji">{{ log.icon }}</span>
<span class="log-time">{{ log.timestamp | date:'HH:mm:ss' }}</span>
<span class="log-level">{{ log.level }}</span>
<span class="log-message">{{ log.message }}</span>
@if (log.data) {
<pre class="log-data">{{ log.data | json }}</pre>
}
</div>
}
</div>

View File

@@ -2,21 +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-title {
font-weight: 600;
font-size: 1.1rem;
.sam-text-header {
margin-bottom: var(--size);
flex-shrink: 0;
.logs-actions {
position: absolute;
right: 0;
display: flex;
gap: 8px;
}
}
}
.logs-container {
@@ -34,15 +39,14 @@
}
.log-entry {
font-family: monospace;
font-family: var(--font-sans);
font-size: 11px;
padding: 4px 8px;
padding: 6px 8px;
border-radius: 4px;
margin-bottom: 2px;
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: flex-start;
align-items: center;
&.log-error {
background: rgba(220, 53, 69, 0.15);
@@ -65,29 +69,20 @@
}
}
.log-icon {
font-size: 14px;
flex-shrink: 0;
width: 18px;
text-align: center;
}
.log-time {
color: var(--muted-foreground);
flex-shrink: 0;
}
.log-level {
font-weight: 600;
text-transform: uppercase;
width: 40px;
flex-shrink: 0;
font-size: 10px;
}
.log-message {
flex: 1;
word-break: break-word;
}
.log-data {
width: 100%;
margin: 4px 0 0 0;
padding: 4px;
background: rgba(0, 0, 0, 0.2);
border-radius: 4px;
font-size: 10px;
overflow-x: auto;
}

View File

@@ -1,22 +1,34 @@
import { Component, inject } from '@angular/core';
import { LoggerService, LogEntry } from '@common';
import { DatePipe, JsonPipe } from '@angular/common';
import { Component, inject, OnInit } from '@angular/core';
import { Router } from '@angular/router';
import { LoggerService, LogEntry, StorageService } from '@common';
import { DatePipe } from '@angular/common';
@Component({
selector: 'app-logs',
templateUrl: './logs.component.html',
styleUrl: './logs.component.scss',
imports: [DatePipe, JsonPipe],
imports: [DatePipe],
})
export class LogsComponent {
export class LogsComponent implements OnInit {
readonly #logger = inject(LoggerService);
readonly #storage = inject(StorageService);
readonly #router = inject(Router);
get logs(): LogEntry[] {
return this.#logger.logs;
}
onClear() {
this.#logger.clear();
ngOnInit() {
// Refresh logs from storage to get background script logs
this.#logger.refreshLogs();
}
async onRefresh() {
await this.#logger.refreshLogs();
}
async onClear() {
await this.#logger.clear();
}
getLevelClass(level: LogEntry['level']): string {
@@ -31,4 +43,10 @@ export class LogsComponent {
return 'log-info';
}
}
async onClickLock() {
this.#logger.logVaultLock();
await this.#storage.lockVault();
this.#router.navigateByUrl('/vault-login');
}
}

View File

@@ -1,4 +1,7 @@
<div class="sam-text-header">
<button class="lock-btn" title="Lock" (click)="onClickLock()">
<span class="emoji">🔒</span>
</button>
<span> Settings </span>
</div>
@@ -12,6 +15,9 @@
Import Vault
</button>
<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

View File

@@ -4,8 +4,11 @@
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;

View File

@@ -1,9 +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';
@@ -11,15 +14,17 @@ 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);
readonly #startup = inject(StartupService);
readonly #logger = inject(LoggerService);
ngOnInit(): void {
const vault = JSON.stringify(
@@ -43,6 +48,7 @@ export class SettingsComponent extends NavComponent implements OnInit {
async onResetExtension() {
try {
this.#logger.logVaultReset();
await this.#storage.resetExtension();
this.#startup.startOver(getNewStorageServiceConfig());
} catch (error) {
@@ -58,6 +64,7 @@ export class SettingsComponent extends NavComponent implements OnInit {
const fileName = `Plebeian Signer Firefox - Vault Export - ${dateTimeString}.json`;
this.#downloadJson(jsonVault, fileName);
this.#logger.logVaultExport(fileName);
}
#downloadJson(jsonString: string, fileName: string) {
@@ -70,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');
}
}

View File

@@ -0,0 +1,14 @@
<div class="sam-text-header">
<button class="lock-btn" title="Lock" (click)="onClickLock()">
<span class="emoji">🔒</span>
</button>
<span>Wallet</span>
</div>
<div class="wallet-container">
<div class="empty-state">
<span class="sam-text-muted">
Wallet functionality coming soon.
</span>
</div>
</div>

View File

@@ -0,0 +1,32 @@
:host {
height: 100%;
display: flex;
flex-direction: column;
padding-top: var(--size);
padding-bottom: var(--size);
overflow: hidden;
> *:not(.sam-text-header) {
margin-left: var(--size);
margin-right: var(--size);
}
.sam-text-header {
margin-bottom: var(--size);
flex-shrink: 0;
}
}
.wallet-container {
flex: 1;
overflow-y: auto;
}
.empty-state {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
color: var(--muted-foreground);
}

View File

@@ -0,0 +1,21 @@
import { Component, inject } from '@angular/core';
import { Router } from '@angular/router';
import { LoggerService, StorageService } from '@common';
@Component({
selector: 'app-wallet',
templateUrl: './wallet.component.html',
styleUrl: './wallet.component.scss',
imports: [],
})
export class WalletComponent {
readonly #logger = inject(LoggerService);
readonly #storage = inject(StorageService);
readonly #router = inject(Router);
async onClickLock() {
this.#logger.logVaultLock();
await this.#storage.lockVault();
this.#router.navigateByUrl('/vault-login');
}
}

View File

@@ -1,7 +1,7 @@
import { Component, inject, ViewChild } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { NavComponent, StorageService, DerivingModalComponent } from '@common';
import { LoggerService, NavComponent, StorageService, DerivingModalComponent } from '@common';
@Component({
selector: 'app-new',
@@ -16,6 +16,7 @@ export class NewComponent extends NavComponent {
readonly #router = inject(Router);
readonly #storage = inject(StorageService);
readonly #logger = inject(LoggerService);
toggleType(element: HTMLInputElement) {
if (element.type === 'password') {
@@ -35,6 +36,7 @@ export class NewComponent extends NavComponent {
try {
await this.#storage.createNewVault(this.password);
this.derivingModal.hide();
this.#logger.logVaultCreated();
this.#router.navigateByUrl('/home/identities');
} catch (error) {
this.derivingModal.hide();

View File

@@ -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 -->
<!----------->

View File

@@ -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;
}
}
}

View File

@@ -4,6 +4,7 @@ import { Router } from '@angular/router';
import {
ConfirmComponent,
DerivingModalComponent,
LoggerService,
NostrHelper,
ProfileMetadataService,
StartupService,
@@ -28,6 +29,7 @@ export class VaultLoginComponent implements AfterViewInit {
readonly #router = inject(Router);
readonly #startup = inject(StartupService);
readonly #profileMetadata = inject(ProfileMetadataService);
readonly #logger = inject(LoggerService);
ngAfterViewInit() {
this.passwordInput.nativeElement.focus();
@@ -69,6 +71,7 @@ export class VaultLoginComponent implements AfterViewInit {
// Unlock succeeded - hide modal and navigate
console.log('[login] Hiding modal and navigating');
this.derivingModal.hide();
this.#logger.logVaultUnlock();
// Fetch profile metadata for all identities in the background
this.#fetchAllProfiles();
@@ -102,6 +105,7 @@ export class VaultLoginComponent implements AfterViewInit {
async onClickResetExtension() {
try {
this.#logger.logVaultReset();
await this.#storage.resetExtension();
this.#startup.startOver(getNewStorageServiceConfig());
} catch (error) {

View File

@@ -1,5 +1,9 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { NostrHelper } from '@common';
import {
backgroundLogNip07Action,
backgroundLogPermissionStored,
NostrHelper,
} from '@common';
import {
BackgroundRequestMessage,
checkPermissions,
@@ -109,17 +113,28 @@ browser.runtime.onMessage.addListener(async (message /*, sender*/) => {
});
debug(response);
if (response === 'approve' || response === 'reject') {
const policy = response === 'approve' ? 'allow' : 'deny';
await storePermission(
browserSessionData,
currentIdentity,
req.host,
req.method,
response === 'approve' ? 'allow' : 'deny',
policy,
req.params?.kind
);
await backgroundLogPermissionStored(
req.host,
req.method,
policy,
req.params?.kind
);
}
if (['reject', 'reject-once'].includes(response)) {
await backgroundLogNip07Action(req.method, req.host, false, false, {
kind: req.params?.kind,
peerPubkey: req.params?.peerPubkey,
});
throw new Error('Permission denied');
}
} else {
@@ -128,46 +143,71 @@ browser.runtime.onMessage.addListener(async (message /*, sender*/) => {
}
const relays: Relays = {};
let result: any;
switch (req.method) {
case 'getPublicKey':
return NostrHelper.pubkeyFromPrivkey(currentIdentity.privkey);
result = NostrHelper.pubkeyFromPrivkey(currentIdentity.privkey);
await backgroundLogNip07Action(req.method, req.host, true, recklessApprove);
return result;
case 'signEvent':
return signEvent(req.params, currentIdentity.privkey);
result = signEvent(req.params, currentIdentity.privkey);
await backgroundLogNip07Action(req.method, req.host, true, recklessApprove, {
kind: req.params?.kind,
});
return result;
case 'getRelays':
browserSessionData.relays.forEach((x) => {
relays[x.url] = { read: x.read, write: x.write };
});
await backgroundLogNip07Action(req.method, req.host, true, recklessApprove);
return relays;
case 'nip04.encrypt':
return await nip04Encrypt(
result = await nip04Encrypt(
currentIdentity.privkey,
req.params.peerPubkey,
req.params.plaintext
);
await backgroundLogNip07Action(req.method, req.host, true, recklessApprove, {
peerPubkey: req.params.peerPubkey,
});
return result;
case 'nip44.encrypt':
return await nip44Encrypt(
result = await nip44Encrypt(
currentIdentity.privkey,
req.params.peerPubkey,
req.params.plaintext
);
await backgroundLogNip07Action(req.method, req.host, true, recklessApprove, {
peerPubkey: req.params.peerPubkey,
});
return result;
case 'nip04.decrypt':
return await nip04Decrypt(
result = await nip04Decrypt(
currentIdentity.privkey,
req.params.peerPubkey,
req.params.ciphertext
);
await backgroundLogNip07Action(req.method, req.host, true, recklessApprove, {
peerPubkey: req.params.peerPubkey,
});
return result;
case 'nip44.decrypt':
return await nip44Decrypt(
result = await nip44Decrypt(
currentIdentity.privkey,
req.params.peerPubkey,
req.params.ciphertext
);
await backgroundLogNip07Action(req.method, req.host, true, recklessApprove, {
peerPubkey: req.params.peerPubkey,
});
return result;
default:
throw new Error(`Not supported request method '${req.method}'.`);

View File

@@ -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;
}

147
scripts/fetch-kinds.js Normal file
View 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();