Release v0.0.9 - Add reckless mode with whitelisted apps

- Add reckless mode checkbox to auto-approve signing requests
- Implement whitelisted apps management page
- Reckless mode logic: allow all if whitelist empty, otherwise only whitelisted hosts
- Add shouldRecklessModeApprove() in background service worker
- Update default avatar to Plebeian Market Account icon
- Fix manifest version scripts to strip v prefix for browsers

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-19 10:56:45 +01:00
parent 5550d41293
commit ddb74c61b2
27 changed files with 952 additions and 116 deletions

View File

@@ -16,6 +16,7 @@ import { WelcomeComponent } from './components/welcome/welcome.component';
import { VaultLoginComponent } from './components/vault-login/vault-login.component';
import { VaultCreateComponent } from './components/vault-create/vault-create.component';
import { VaultImportComponent } from './components/vault-import/vault-import.component';
import { WhitelistedAppsComponent } from './components/whitelisted-apps/whitelisted-apps.component';
export const routes: Routes = [
{
@@ -70,6 +71,10 @@ export const routes: Routes = [
path: 'new-identity',
component: NewIdentityComponent,
},
{
path: 'whitelisted-apps',
component: WhitelistedAppsComponent,
},
{
path: 'edit-identity/:id',
component: EditIdentityComponent,

View File

@@ -11,6 +11,30 @@
</button>
</div>
<div class="reckless-mode-row">
<label class="reckless-label" (click)="onToggleRecklessMode()">
<input
type="checkbox"
[checked]="isRecklessMode"
(click)="$event.stopPropagation()"
(change)="onToggleRecklessMode()"
/>
<span
class="reckless-text"
data-bs-toggle="tooltip"
data-bs-placement="bottom"
title="Auto-approve all actions. If whitelist has entries, only those apps are auto-approved."
>Reckless mode</span>
</label>
<button
class="gear-btn"
title="Manage whitelisted apps"
(click)="onClickWhitelistedApps()"
>
<i class="bi bi-gear"></i>
</button>
</div>
@let sessionData = storage.getBrowserSessionHandler().browserSessionData;
@let identities = sessionData?.identities ?? [];
@@ -34,7 +58,7 @@
class="avatar"
[src]="getAvatarUrl(identity)"
alt=""
(error)="$any($event.target).src = 'assets/person-fill.svg'"
(error)="$any($event.target).src = 'person-fill.svg'"
/>
<span class="name">{{ getDisplayName(identity) }}</span>
<lib-icon-button

View File

@@ -28,13 +28,66 @@
grid-column-end: 2;
grid-row-start: 1;
grid-row-end: 2;
font-size: 20px;
font-weight: 500;
font-family: var(--font-heading);
font-size: 24px;
font-weight: 700;
letter-spacing: 0.1rem;
justify-self: center;
height: 32px;
}
}
.reckless-mode-row {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
margin-bottom: 12px;
background: var(--background-light);
border-radius: 8px;
.reckless-label {
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
cursor: pointer;
user-select: none;
input[type="checkbox"] {
width: 16px;
height: 16px;
accent-color: var(--primary);
cursor: pointer;
}
.reckless-text {
font-size: 14px;
color: var(--foreground);
}
}
.gear-btn {
background: transparent;
border: none;
color: var(--muted-foreground);
padding: 4px 8px;
cursor: pointer;
border-radius: 4px;
transition: color 0.15s ease, background-color 0.15s ease;
&:hover {
color: var(--foreground);
background: var(--background-light-hover);
}
i {
font-size: 16px;
}
}
}
.empty-state {
height: 100%;
display: flex;

View File

@@ -24,6 +24,10 @@ export class IdentitiesComponent implements OnInit {
// Cache of pubkey -> profile for quick lookup
#profileCache = new Map<string, ProfileMetadata | null>();
get isRecklessMode(): boolean {
return this.storage.getSignerMetaHandler().signerMetaData?.recklessMode ?? false;
}
async ngOnInit() {
await this.#profileMetadata.initialize();
this.#loadProfiles();
@@ -40,7 +44,7 @@ export class IdentitiesComponent implements OnInit {
getAvatarUrl(identity: Identity_DECRYPTED): string {
const profile = this.#profileCache.get(identity.id);
return profile?.picture || 'assets/person-fill.svg';
return profile?.picture || 'person-fill.svg';
}
getDisplayName(identity: Identity_DECRYPTED): string {
@@ -60,4 +64,13 @@ export class IdentitiesComponent implements OnInit {
async onClickSelectIdentity(identityId: string) {
await this.storage.switchIdentity(identityId);
}
async onToggleRecklessMode() {
const newValue = !this.isRecklessMode;
await this.storage.getSignerMetaHandler().setRecklessMode(newValue);
}
onClickWhitelistedApps() {
this.#router.navigateByUrl('/whitelisted-apps');
}
}

View File

@@ -0,0 +1,45 @@
<div class="custom-header">
<button class="back-btn" (click)="onClickBack()">
<i class="bi bi-chevron-left"></i>
</button>
<span class="text">Whitelisted Apps</span>
</div>
<div class="content">
<button
class="btn btn-primary whitelist-btn"
(click)="onClickWhitelistCurrentTab()"
>
<i class="bi bi-plus-lg"></i>
<span>Whitelist current tab</span>
</button>
<div class="hosts-list">
@if (whitelistedHosts.length === 0) {
<div class="empty-state">
<span class="sam-text-muted">No whitelisted apps yet</span>
</div>
@if (isRecklessMode) {
<div class="warning-note">
<span>&#9888; All sites will be auto-approved without prompting</span>
</div>
}
}
@for (host of whitelistedHosts; track host) {
<div class="host-item">
<span class="host-name">{{ host }}</span>
<button
class="remove-btn"
title="Remove from whitelist"
(click)="onClickRemoveHost(host)"
>
<i class="bi bi-trash"></i>
</button>
</div>
}
</div>
</div>
<lib-toast #toast></lib-toast>
<lib-confirm #confirm></lib-confirm>

View File

@@ -0,0 +1,134 @@
:host {
height: 100%;
display: flex;
flex-direction: column;
overflow-y: auto;
.custom-header {
padding: var(--size);
display: grid;
grid-template-columns: 1fr;
grid-template-rows: auto;
align-items: center;
background: var(--background);
position: sticky;
top: 0;
z-index: 10;
.back-btn {
grid-column-start: 1;
grid-column-end: 2;
grid-row-start: 1;
grid-row-end: 2;
justify-self: start;
background: transparent;
border: none;
color: var(--foreground);
padding: 4px 8px;
cursor: pointer;
border-radius: 4px;
z-index: 1;
&:hover {
background: var(--background-light);
}
i {
font-size: 20px;
}
}
.text {
grid-column-start: 1;
grid-column-end: 2;
grid-row-start: 1;
grid-row-end: 2;
font-family: var(--font-heading);
font-size: 20px;
font-weight: 700;
letter-spacing: 0.05rem;
justify-self: center;
}
}
.content {
padding: 0 var(--size) var(--size) var(--size);
flex-grow: 1;
display: flex;
flex-direction: column;
.whitelist-btn {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 8px;
margin-bottom: var(--size);
}
.hosts-list {
flex-grow: 1;
.empty-state {
display: flex;
align-items: center;
justify-content: center;
padding: 32px;
text-align: center;
}
.warning-note {
display: flex;
align-items: center;
justify-content: center;
padding: 12px;
background: rgba(255, 193, 7, 0.15);
border: 1px solid rgba(255, 193, 7, 0.4);
border-radius: 8px;
text-align: center;
span {
font-size: 13px;
color: #ffc107;
}
}
.host-item {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
padding: 12px;
background: var(--background-light);
border-radius: 8px;
margin-bottom: 8px;
.host-name {
font-size: 14px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.remove-btn {
background: transparent;
border: none;
color: var(--muted-foreground);
padding: 4px 8px;
cursor: pointer;
border-radius: 4px;
transition: color 0.15s ease, background-color 0.15s ease;
&:hover {
color: var(--destructive);
background: var(--background-light-hover);
}
i {
font-size: 14px;
}
}
}
}
}
}

View File

@@ -0,0 +1,73 @@
import { Component, inject, ViewChild } from '@angular/core';
import { Router } from '@angular/router';
import {
ConfirmComponent,
NavComponent,
StorageService,
ToastComponent,
} from '@common';
import browser from 'webextension-polyfill';
@Component({
selector: 'app-whitelisted-apps',
templateUrl: './whitelisted-apps.component.html',
styleUrl: './whitelisted-apps.component.scss',
imports: [ToastComponent, ConfirmComponent],
})
export class WhitelistedAppsComponent extends NavComponent {
@ViewChild('toast') toast!: ToastComponent;
@ViewChild('confirm') confirm!: ConfirmComponent;
readonly storage = inject(StorageService);
readonly #router = inject(Router);
get whitelistedHosts(): string[] {
return this.storage.getSignerMetaHandler().signerMetaData?.whitelistedHosts ?? [];
}
get isRecklessMode(): boolean {
return this.storage.getSignerMetaHandler().signerMetaData?.recklessMode ?? false;
}
async onClickWhitelistCurrentTab() {
try {
// Get current active tab
const tabs = await browser.tabs.query({ active: true, currentWindow: true });
if (tabs.length === 0 || !tabs[0].url) {
this.toast.show('No active tab found');
return;
}
const url = new URL(tabs[0].url);
const host = url.host;
if (!host) {
this.toast.show('Cannot get host from current tab');
return;
}
// Check if already whitelisted
if (this.whitelistedHosts.includes(host)) {
this.toast.show(`${host} is already whitelisted`);
return;
}
await this.storage.getSignerMetaHandler().addWhitelistedHost(host);
this.toast.show(`Added ${host} to whitelist`);
} catch (error) {
console.error('Error getting current tab:', error);
this.toast.show('Error getting current tab');
}
}
onClickRemoveHost(host: string) {
this.confirm.show(`Remove ${host} from whitelist?`, async () => {
await this.storage.getSignerMetaHandler().removeWhitelistedHost(host);
this.toast.show(`Removed ${host} from whitelist`);
});
}
onClickBack() {
this.#router.navigateByUrl('/home/identities');
}
}

View File

@@ -49,6 +49,40 @@ export const getBrowserSessionData = async function (): Promise<
return browserSessionData as unknown as BrowserSessionData;
};
export const getSignerMetaData = async function (): Promise<SignerMetaData> {
const signerMetaHandler = new FirefoxMetaHandler();
return (await signerMetaHandler.loadFullData()) as SignerMetaData;
};
/**
* Check if reckless mode should auto-approve the request.
* Returns true if should auto-approve, false if should use normal permission flow.
*
* Logic:
* - If reckless mode is OFF → return false (use normal flow)
* - If reckless mode is ON and whitelist is empty → return true (approve all)
* - If reckless mode is ON and whitelist has entries → return true only if host is in whitelist
*/
export const shouldRecklessModeApprove = async function (
host: string
): Promise<boolean> {
const signerMetaData = await getSignerMetaData();
if (!signerMetaData.recklessMode) {
return false;
}
const whitelistedHosts = signerMetaData.whitelistedHosts ?? [];
if (whitelistedHosts.length === 0) {
// Reckless mode ON, no whitelist → approve all
return true;
}
// Reckless mode ON, whitelist has entries → only approve if host is whitelisted
return whitelistedHosts.includes(host);
};
export const getBrowserSyncData = async function (): Promise<
BrowserSyncData | undefined
> {

View File

@@ -12,6 +12,7 @@ import {
nip44Encrypt,
PromptResponse,
PromptResponseMessage,
shouldRecklessModeApprove,
signEvent,
storePermission,
} from './background-common';
@@ -63,57 +64,65 @@ browser.runtime.onMessage.addListener(async (message /*, sender*/) => {
}
const req = request as BackgroundRequestMessage;
const permissionState = checkPermissions(
browserSessionData,
currentIdentity,
req.host,
req.method,
req.params
);
if (permissionState === false) {
throw new Error('Permission denied');
}
// Check reckless mode first
const recklessApprove = await shouldRecklessModeApprove(req.host);
if (recklessApprove) {
debug('Request auto-approved via reckless mode.');
} else {
// Normal permission flow
const permissionState = checkPermissions(
browserSessionData,
currentIdentity,
req.host,
req.method,
req.params
);
if (permissionState === undefined) {
// Ask user for permission.
const width = 375;
const height = 600;
const { top, left } = await getPosition(width, height);
const base64Event = Buffer.from(
JSON.stringify(req.params ?? {}, undefined, 2)
).toString('base64');
const response = await new Promise<PromptResponse>((resolve, reject) => {
const id = crypto.randomUUID();
openPrompts.set(id, { resolve, reject });
browser.windows.create({
type: 'popup',
url: `prompt.html?method=${req.method}&host=${req.host}&id=${id}&nick=${currentIdentity.nick}&event=${base64Event}`,
height,
width,
top,
left,
});
});
debug(response);
if (response === 'approve' || response === 'reject') {
await storePermission(
browserSessionData,
currentIdentity,
req.host,
req.method,
response === 'approve' ? 'allow' : 'deny',
req.params?.kind
);
}
if (['reject', 'reject-once'].includes(response)) {
if (permissionState === false) {
throw new Error('Permission denied');
}
} else {
debug('Request allowed (via saved permission).');
if (permissionState === undefined) {
// Ask user for permission.
const width = 375;
const height = 600;
const { top, left } = await getPosition(width, height);
const base64Event = Buffer.from(
JSON.stringify(req.params ?? {}, undefined, 2)
).toString('base64');
const response = await new Promise<PromptResponse>((resolve, reject) => {
const id = crypto.randomUUID();
openPrompts.set(id, { resolve, reject });
browser.windows.create({
type: 'popup',
url: `prompt.html?method=${req.method}&host=${req.host}&id=${id}&nick=${currentIdentity.nick}&event=${base64Event}`,
height,
width,
top,
left,
});
});
debug(response);
if (response === 'approve' || response === 'reject') {
await storePermission(
browserSessionData,
currentIdentity,
req.host,
req.method,
response === 'approve' ? 'allow' : 'deny',
req.params?.kind
);
}
if (['reject', 'reject-once'].includes(response)) {
throw new Error('Permission denied');
}
} else {
debug('Request allowed (via saved permission).');
}
}
const relays: Relays = {};