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:
commit
7e3bac345c
68 changed files with 18262 additions and 0 deletions
193
frontend/electron/main.ts
Normal file
193
frontend/electron/main.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
7
frontend/electron/preload.ts
Normal file
7
frontend/electron/preload.ts
Normal 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),
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue