Add Nostr Feed Manager: Rust backend with Electron + React GUI

- Rust library (nostr-manager-backend) with CLI and JSON-lines IPC serve mode:
  profiles, publishing with per-relay reports, relays, settings, vault storage
  and legacy-vault migration
- Electron + React + TypeScript desktop GUI using the same backend over stdio IPC
- Vitest suite with a fake backend speaking the real protocol
- electron-builder linux packaging; README with build and usage instructions
This commit is contained in:
Avi 2026-08-03 16:05:59 -05:00
commit 7e3bac345c
68 changed files with 18262 additions and 0 deletions

193
frontend/electron/main.ts Normal file
View file

@ -0,0 +1,193 @@
import { app, BrowserWindow, clipboard, ipcMain, protocol } from 'electron';
import { spawn, type ChildProcess } from 'node:child_process';
import { readFileSync } from 'node:fs';
import { createInterface } from 'node:readline';
import * as path from 'node:path';
const MIME: Record<string, string> = {
'.html': 'text/html; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.svg': 'image/svg+xml',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.ico': 'image/x-icon',
'.woff': 'font/woff',
'.woff2': 'font/woff2',
'.ttf': 'font/ttf',
'.json': 'application/json',
'.map': 'application/json',
};
protocol.registerSchemesAsPrivileged([
{ scheme: 'app', privileges: { standard: true, secure: true, supportFetchAPI: true } },
]);
interface PendingRequest {
resolve: (value: unknown) => void;
reject: (reason: Error) => void;
}
let backend: ChildProcess | null = null;
let backendStarted = false;
const pending = new Map<number, PendingRequest>();
let nextId = 1;
function resolveBackendPath(): string {
if (app.isPackaged) {
return path.join(process.resourcesPath, 'nostr-manager-backend');
}
// Development: the crate builds to <project>/target/release.
return path.join(app.getAppPath(), '..', 'target', 'release', 'nostr-manager-backend');
}
function startBackend(): void {
if (backendStarted && backend && backend.exitCode === null) {
return;
}
backendStarted = true;
const bin = resolveBackendPath();
backend = spawn(bin, ['serve'], { stdio: ['pipe', 'pipe', 'pipe'] });
const stdout = backend.stdout;
const stderr = backend.stderr;
const stdin = backend.stdin;
if (!stdout || !stderr || !stdin) {
console.error('[backend] failed to capture backend streams.');
return;
}
const reader = createInterface({ input: stdout });
reader.on('line', (line) => {
let envelope: { id?: number };
try {
envelope = JSON.parse(line);
} catch {
return;
}
if (typeof envelope.id === 'number') {
const entry = pending.get(envelope.id);
if (entry) {
pending.delete(envelope.id);
entry.resolve(envelope);
}
}
});
stderr.on('data', (chunk: Buffer) => {
// Backend diagnostics go to stderr only; never secrets, never forwarded.
console.error('[backend]', chunk.toString().trim());
});
backend.on('error', (err) => {
console.error('[backend] failed to start:', err.message);
});
backend.on('exit', (code) => {
const error = new Error(
`The Rust backend exited unexpectedly${code === null ? '' : ` (code ${code})`}.`,
);
for (const [, entry] of pending) {
entry.reject(error);
}
pending.clear();
backend = null;
});
}
async function backendRequest(method: string, params: Record<string, unknown>): Promise<unknown> {
startBackend();
const stdin = backend?.stdin;
if (!backend || !stdin || stdin.destroyed) {
throw new Error('The Rust backend is not available.');
}
const id = nextId++;
const payload = { id, method, ...params };
const response = new Promise<unknown>((resolve, reject) => {
pending.set(id, { resolve, reject });
});
stdin.write(`${JSON.stringify(payload)}\n`);
return response;
}
function createWindow(): void {
const window = new BrowserWindow({
width: 1160,
height: 760,
minWidth: 920,
minHeight: 640,
title: 'Nostr Feed Manager',
backgroundColor: '#f6f4f0',
autoHideMenuBar: true,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
},
});
const devServer = process.env.NOSTR_GUI_DEV_URL;
if (devServer) {
void window.loadURL(devServer);
} else {
void window.loadURL('app://nfm/index.html');
}
}
app.whenReady().then(() => {
protocol.handle('app', (request) => {
const { pathname } = new URL(request.url);
let relative = decodeURIComponent(pathname);
if (relative.endsWith('/')) {
relative += 'index.html';
}
const safe = path
.normalize(relative)
.replace(/^(\.\.[/\\])+/, '')
.replace(/^[/\\]+/, '');
const filePath = path.join(app.getAppPath(), 'dist', safe);
try {
const data = readFileSync(filePath);
const type = MIME[path.extname(filePath)] ?? 'application/octet-stream';
return new Response(data, { headers: { 'content-type': type } });
} catch {
return new Response('Not found', { status: 404, headers: { 'content-type': 'text/plain' } });
}
});
ipcMain.handle(
'backend:request',
(_event, payload: { method: string; params?: Record<string, unknown> }) => {
const params = payload.params ?? {};
return backendRequest(payload.method, params);
},
);
ipcMain.handle('clipboard:write', (_event, text: string) => {
clipboard.writeText(String(text));
return true;
});
createWindow();
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
}
});
});
app.on('before-quit', () => {
if (backend) {
backend.kill();
}
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});

View file

@ -0,0 +1,7 @@
import { contextBridge, ipcRenderer } from 'electron';
contextBridge.exposeInMainWorld('backend', {
request: (method: string, params?: Record<string, unknown>): Promise<unknown> =>
ipcRenderer.invoke('backend:request', { method, params }),
copyText: (text: string): Promise<void> => ipcRenderer.invoke('clipboard:write', text),
});