diff --git a/.gitignore b/.gitignore index 9569838..a8ab3fb 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,8 @@ logs node_modules dist +out +extras # Editor directories and files .vscode/* diff --git a/electron-builder.yaml b/electron-builder.yaml new file mode 100644 index 0000000..07cb06a --- /dev/null +++ b/electron-builder.yaml @@ -0,0 +1,29 @@ +appId: com.arx-ccn.eve +productName: Eve +executableName: Eve +icon: public/icon512x512.png +linux: + category: Network + target: + - AppImage + - flatpak + - deb + desktop: + desktopActions: {} + extraFiles: + - from: extras/linux/relay + to: usr/bin/eve-relay +flatpak: + runtimeVersion: "24.08" + license: "LICENSE" +directories: + buildResources: build +files: + - '!**/.vscode/*' + - '!src/*' + - '!electron.vite.config.{js,ts,mjs,cjs}' + - '!{.eslintignore,.eslintrc.cjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}' + - '!{.env,.env.*,.npmrc,pnpm-lock.yaml}' + - '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}' +asarUnpack: + - resources/** \ No newline at end of file diff --git a/electron.vite.config.ts b/electron.vite.config.ts new file mode 100644 index 0000000..ad64ea2 --- /dev/null +++ b/electron.vite.config.ts @@ -0,0 +1,51 @@ +import { fileURLToPath, URL } from "node:url"; +import { defineConfig, externalizeDepsPlugin } from "electron-vite"; +import { resolve } from "path"; + +export default defineConfig({ + main: { + plugins: [externalizeDepsPlugin()], + build: { + lib: { + entry: resolve(__dirname, "src/electron/main.ts"), + }, + }, + }, + preload: { + plugins: [externalizeDepsPlugin()], + build: { + lib: { + entry: resolve(__dirname, "src/electron/preload.ts"), + }, + }, + }, + renderer: { + root: resolve(__dirname, "src"), + build: { + target: "es2024", + rollupOptions: { + input: { + index: resolve(__dirname, "src/index.html"), + }, + }, + }, + resolve: { + alias: { + "@utils": fileURLToPath(new URL("./src/utils", import.meta.url)), + "@routes": fileURLToPath(new URL("./src/routes", import.meta.url)), + "@styles": fileURLToPath(new URL("./src/styles", import.meta.url)), + "@widgets": fileURLToPath( + new URL("./src/components/Widgets", import.meta.url) + ), + "@components": fileURLToPath( + new URL("./src/components", import.meta.url) + ), + "@": fileURLToPath(new URL("./src", import.meta.url)), + }, + }, + // server: { + // port: 5173, + // open: true, + // }, + }, +}); diff --git a/package.json b/package.json index d97f2db..5d57864 100644 --- a/package.json +++ b/package.json @@ -1,16 +1,31 @@ { "name": "eve", - "private": true, - "version": "0.0.0", + "description": "Closed Community Networks", + "version": "0.0.1", "type": "module", - "browserslist": ["not dead"], + "license": "AGPL-3.0-only", + "browserslist": [ + "electron >= 22.0.0" + ], + "main": "./out/main/main.js", + "homepage": "https://arx-ccn.com/eve", + "author": { + "name": "arx-ccn", + "email": "developers@arx-ccn.com" + }, "scripts": { - "dev": "vite", - "build": "tsc && vite build", - "preview": "vite preview" + "build": "tsc && electron-vite build", + "build:linux": "bun run build && electron-builder --linux", + "start": "electron-vite preview", + "dev": "electron-vite dev", + "prebuild": "electron-vite build" }, "devDependencies": { - "@biomejs/biome": "1.9.4", + "@electron-toolkit/preload": "^3.0.1", + "@electron-toolkit/utils": "^4.0.0", + "electron-builder": "^25.1.8", + "electron-vite": "^3.0.0", + "electron": "^34.2.0", "@tsconfig/node22": "^22.0.0", "@types/markdown-it": "^14.1.2", "@types/node": "^22.10.2", @@ -21,11 +36,13 @@ }, "dependencies": { "@lit-labs/motion": "^1.0.8", + "@noble/ciphers": "^1.2.1", "@nostr-dev-kit/ndk": "^2.10.7", + "@nostr/tools": "npm:@jsr/nostr__tools", "@open-wc/lit-helpers": "^0.7.0", + "@std/encoding": "npm:@jsr/std__encoding", "iconify-icon": "^2.2.0", "lit": "^3.2.1", - "markdown-it": "^14.1.0", - "nostr-tools": "^2.10.4" + "markdown-it": "^14.1.0" } } diff --git a/public/icon512x512.png b/public/icon512x512.png new file mode 100644 index 0000000..02e81ef Binary files /dev/null and b/public/icon512x512.png differ diff --git a/src/components/InitialSetup.ts b/src/components/InitialSetup.ts index d15d389..414a086 100644 --- a/src/components/InitialSetup.ts +++ b/src/components/InitialSetup.ts @@ -1,12 +1,14 @@ import { LitElement, html, css } from "lit"; -import { customElement, property, query, state } from "lit/decorators.js"; +import { customElement, state } from "lit/decorators.js"; import { animate } from "@lit-labs/motion"; -import * as nostrTools from "nostr-tools/pure"; -import * as nip06 from "nostr-tools/nip06"; -import * as nip19 from "nostr-tools/nip19"; -import * as nip49 from "nostr-tools/nip49"; +import * as nostrTools from "@nostr/tools/pure"; +import * as nip06 from "@nostr/tools/nip06"; +import * as nip19 from "@nostr/tools/nip19"; +import * as nip49 from "@nostr/tools/nip49"; import { ndk, setSigner } from "@/ndk"; import { NDKEvent, NDKKind, NDKPrivateKeySigner } from "@nostr-dev-kit/ndk"; +import { encodeBase64 } from "@std/encoding/base64"; +import { randomBytes } from "@noble/ciphers/webcrypto"; @customElement("arx-initial-setup") export class InitialSetup extends LitElement { @@ -17,6 +19,15 @@ export class InitialSetup extends LitElement { @state() private profileImage = ""; @state() private lightningAddress = ""; + get encryptionPassphrase() { + let encryptionPassphrase = localStorage.getItem("encryption_key"); + if (!encryptionPassphrase) { + encryptionPassphrase = encodeBase64(randomBytes(32)); + localStorage.setItem("encryption_key", encryptionPassphrase); + } + return encryptionPassphrase; + } + static override styles = css` :host { display: block; @@ -409,32 +420,9 @@ export class InitialSetup extends LitElement { `; } - private getSetupCode() { - const userAgent = navigator.userAgent.toLowerCase(); - if (userAgent.includes("mac")) { - return ` - mkdir -p ~/.config/arx/eve && cd ~/.config/arx/eve - echo "${this.seedPhrase}" > ccn.seed - launchctl load ~/Library/LaunchAgents/com.user.eve-relay.plist - launchctl start com.user.eve-relay - ` - .split("\n") - .map((x) => x.trim()) - .join("\n"); - } - - if (userAgent.includes("linux")) { - return ` - mkdir -p ~/.config/arx/eve && cd ~/.config/arx/eve - echo "${this.seedPhrase}" > ccn.seed - systemctl --user enable eve-relay.service - systemctl --user start eve-relay.service - ` - .split("\n") - .map((x) => x.trim()) - .join("\n"); - } - return "Unsupported OS"; + private async startRelay() { + await window.relay.writeSeed(this.seedPhrase); + await window.relay.start(this.encryptionPassphrase); } private renderPageThree() { @@ -446,11 +434,10 @@ export class InitialSetup extends LitElement { During this alpha phase, manual relay configuration is required. This process will be automated in future releases.
-Open your terminal and run following commands:
-
- ${this.getSetupCode()}
-
+ Please press the button below to start the relay.
+Having trouble? Our team is here to help if you encounter any issues. @@ -586,15 +573,11 @@ export class InitialSetup extends LitElement { } private async goToFinalStep() { - let encryptionPassphrase = localStorage.getItem("encryption_key"); - if (!encryptionPassphrase) { - encryptionPassphrase = - Math.random().toString(36).substring(2, 15) + - Math.random().toString(36).substring(2, 15); - localStorage.setItem("encryption_key", encryptionPassphrase); - } const randomPrivateKey = nostrTools.generateSecretKey(); - const encryptedNsec = nip49.encrypt(randomPrivateKey, encryptionPassphrase); + const encryptedNsec = nip49.encrypt( + randomPrivateKey, + this.encryptionPassphrase + ); const npub = nip19.npubEncode(nostrTools.getPublicKey(randomPrivateKey)); if (!this.lightningAddress) this.lightningAddress = `${npub}@npub.cash`; @@ -602,6 +585,7 @@ export class InitialSetup extends LitElement { localStorage.setItem("ncryptsec", encryptedNsec); setSigner(new NDKPrivateKeySigner(randomPrivateKey)); + await ndk.connect(5000); const event = new NDKEvent(ndk); event.kind = NDKKind.Metadata; diff --git a/src/components/MarkdownContent.ts b/src/components/MarkdownContent.ts index 20b2c62..b318e60 100644 --- a/src/components/MarkdownContent.ts +++ b/src/components/MarkdownContent.ts @@ -1,18 +1,18 @@ -import { css, LitElement } from 'lit'; -import { customElement, property } from 'lit/decorators.js'; -import { unsafeHTML } from 'lit/directives/unsafe-html.js'; -import MarkdownIt, { type StateCore, type Token } from 'markdown-it'; +import { css, LitElement } from "lit"; +import { customElement, property } from "lit/decorators.js"; +import { unsafeHTML } from "lit/directives/unsafe-html.js"; +import MarkdownIt, { type StateCore, type Token } from "markdown-it"; function nostrPlugin(md: MarkdownIt): void { const npubRegex = /(npub[0-9a-zA-Z]{59})/g; - md.core.ruler.after('inline', 'nostr_npub', (state: StateCore): boolean => { + md.core.ruler.after("inline", "nostr_npub", (state: StateCore): boolean => { for (const token of state.tokens) { - if (token.type === 'inline' && token.children) { + if (token.type === "inline" && token.children) { for (let i = 0; i < token.children.length; i++) { const child = token.children[i]; - if (child.type === 'text') { + if (child.type === "text") { const matches = child.content.match(npubRegex); if (!matches) continue; @@ -21,28 +21,29 @@ function nostrPlugin(md: MarkdownIt): void { child.content.replace( npubRegex, + // @ts-ignore this is an issue with the types (match: string, npub: string, offset: number) => { if (offset > lastIndex) { - const textToken = new state.Token('text', '', 0); + const textToken = new state.Token("text", "", 0); textToken.content = child.content.slice(lastIndex, offset); newTokens.push(textToken); } - const linkOpen = new state.Token('link_open', 'a', 1); - linkOpen.attrs = [['href', `nostr:${npub}`]]; + const linkOpen = new state.Token("link_open", "a", 1); + linkOpen.attrs = [["href", `nostr:${npub}`]]; - const text = new state.Token('text', '', 0); + const text = new state.Token("text", "", 0); text.content = npub; - const linkClose = new state.Token('link_close', 'a', -1); + const linkClose = new state.Token("link_close", "a", -1); newTokens.push(linkOpen, text, linkClose); lastIndex = offset + match.length; - }, + } ); if (lastIndex < child.content.length) { - const textToken = new state.Token('text', '', 0); + const textToken = new state.Token("text", "", 0); textToken.content = child.content.slice(lastIndex); newTokens.push(textToken); } @@ -58,7 +59,7 @@ function nostrPlugin(md: MarkdownIt): void { }); } -@customElement('arx-markdown-content') +@customElement("arx-markdown-content") export class MarkdownContent extends LitElement { private md = new MarkdownIt({ html: false, @@ -68,7 +69,7 @@ export class MarkdownContent extends LitElement { }); @property({ type: String }) - content = ''; + content = ""; static override styles = [ css` @@ -94,7 +95,16 @@ export class MarkdownContent extends LitElement { display: contents; } - h1, h2, h3, h4, h5, h6, code, ul, ol, blockquote { + h1, + h2, + h3, + h4, + h5, + h6, + code, + ul, + ol, + blockquote { width: 100%; margin: 0; display: block; diff --git a/src/electron/main.ts b/src/electron/main.ts new file mode 100644 index 0000000..f6f3371 --- /dev/null +++ b/src/electron/main.ts @@ -0,0 +1,77 @@ +import { app, shell, BrowserWindow, ipcMain } from "electron"; +import { optimizer, is } from "@electron-toolkit/utils"; +import { RelayManager } from "./relayManager"; +import path from "node:path"; +import fs from "node:fs"; +import os from "node:os"; + +const relay = new RelayManager(); + +ipcMain.handle("relay:writeSeed", (_, ...args: any) => { + if (!args[0]) throw new Error("No seed provided"); + const seed = args[0] as string; + const configPath = path.join(os.homedir(), ".config", "arx", "Eve"); + const seedPath = path.join(configPath, "ccn.seed"); + fs.mkdirSync(configPath, { recursive: true }); + fs.writeFileSync(seedPath, seed); +}); + +ipcMain.handle("relay:start", (_, ...args: any) => { + if (!args[0]) throw new Error("No encryption key provided"); + const encryptionKey = args[0] as string; + return relay.start(encryptionKey); +}); + +ipcMain.handle("relay:stop", () => { + return relay.stop(); +}); + +ipcMain.handle("relay:status", () => { + return { + running: relay.isRunning, + pid: relay.pid, + logs: relay.getLogs(), + }; +}); + +ipcMain.handle("relay:getLogs", () => { + return relay.getLogs(); +}); + +function createWindow(): void { + const mainWindow = new BrowserWindow({ + width: 1024, + height: 768, + show: false, + autoHideMenuBar: true, + webPreferences: { + preload: path.join(__dirname, "../preload/preload.mjs"), + sandbox: false, + }, + }); + + mainWindow.on("ready-to-show", () => { + mainWindow.show(); + }); + + mainWindow.webContents.setWindowOpenHandler((details) => { + shell.openExternal(details.url); + return { action: "deny" }; + }); + + if (is.dev && process.env["ELECTRON_RENDERER_URL"]) + mainWindow.loadURL(process.env["ELECTRON_RENDERER_URL"]); + else mainWindow.loadFile(path.join(__dirname, "../renderer/index.html")); +} + +app.whenReady().then(() => { + app.on("browser-window-created", (_, window) => { + optimizer.watchWindowShortcuts(window); + }); + + createWindow(); +}); + +app.on("window-all-closed", () => { + app.quit(); +}); diff --git a/src/electron/preload.ts b/src/electron/preload.ts new file mode 100644 index 0000000..42eeb40 --- /dev/null +++ b/src/electron/preload.ts @@ -0,0 +1,21 @@ +import { contextBridge, ipcRenderer } from "electron"; +import { electronAPI } from "@electron-toolkit/preload"; + +if (process.contextIsolated) { + try { + contextBridge.exposeInMainWorld("electron", electronAPI); + contextBridge.exposeInMainWorld("relay", { + writeSeed: (seed: string) => ipcRenderer.invoke("relay:writeSeed", seed), + start: (encryptionKey: string) => + ipcRenderer.invoke("relay:start", encryptionKey), + stop: () => ipcRenderer.invoke("relay:stop"), + getStatus: () => ipcRenderer.invoke("relay:status"), + getLogs: () => ipcRenderer.invoke("relay:logs"), + }); + } catch (error) { + console.error(error); + } +} else { + // @ts-ignore (define in dts) + window.electron = electronAPI; +} diff --git a/src/electron/relayManager.ts b/src/electron/relayManager.ts new file mode 100644 index 0000000..7ab941f --- /dev/null +++ b/src/electron/relayManager.ts @@ -0,0 +1,207 @@ +import { spawn, ChildProcess } from "child_process"; +import { join } from "path"; +import { is } from "@electron-toolkit/utils"; + +type PackageEnvironment = "flatpak" | "appimage" | "system" | "dev"; + +export class RelayManager { + private process: ChildProcess | null; + private readonly relayPath: string; + private isShuttingDown: boolean; + private restartAttempts: number; + private readonly maxRestartAttempts: number; + private readonly restartDelay: number; + private restartTimeout: NodeJS.Timeout | null; + private relayLogs: string[]; + private readonly maxLogs: number; + private encryptionKey: string | null; + + /** + * Checks if the relay is currently running. + * + * @returns {boolean} True if the relay is running, otherwise false. + */ + get isRunning(): boolean { + return !!this.process; + } + + /** + * Retrieves the process identifier (PID) of the running relay process. + * + * @returns {number | undefined} The PID of the relay process if it is running, otherwise undefined. + */ + + get pid(): number | undefined { + return this.process?.pid; + } + + constructor(maxRestartAttempts = 5, restartDelay = 1000, maxLogs = 100) { + this.process = null; + this.relayPath = this.getRelayPath(); + this.isShuttingDown = false; + this.restartAttempts = 0; + this.maxRestartAttempts = maxRestartAttempts; + this.restartDelay = restartDelay; + this.restartTimeout = null; + this.relayLogs = []; + this.maxLogs = maxLogs; + this.encryptionKey = null; + } + + private detectEnvironment(): PackageEnvironment { + if (is.dev) return "dev"; + if (process.env.FLATPAK_ID) return "flatpak"; + if (process.env.APPIMAGE) return "appimage"; + return "system"; + } + + private getRelayPath(): string { + const environment = this.detectEnvironment(); + + switch (environment) { + case "dev": + return join(__dirname, "../../extras/linux/relay"); + case "flatpak": + return "/app/lib/com.arx_ccn.eve/usr/bin/eve-relay"; + case "appimage": + return join(process.env.APPDIR || "", "usr/bin/eve-relay"); + case "system": + return "/usr/bin/eve-relay"; + } + } + + private handleProcessExit(code: number | null): void { + this.process = null; + console.log(`Relay exited with code ${code}`); + + if (!this.isShuttingDown) this.restartProcess(); + } + + private restartProcess(): void { + if (this.restartAttempts >= this.maxRestartAttempts) { + console.error( + `Failed to restart relay after ${this.maxRestartAttempts} attempts` + ); + return; + } + + this.restartAttempts++; + console.log( + `Attempting restart #${this.restartAttempts} in ${this.restartDelay}ms...` + ); + + if (this.restartTimeout) clearTimeout(this.restartTimeout); + + this.restartTimeout = setTimeout(() => { + this.start(this.encryptionKey!); + }, this.restartDelay); + } + + private addLog(data: string): void { + this.relayLogs.push(data); + if (this.relayLogs.length > this.maxLogs) { + this.relayLogs = this.relayLogs.slice(-this.maxLogs); + } + } + + /** + * Start the Eve Relay. + * + * If the process is already running, do nothing. + * + * Logs from the Relay process are captured and can be retrieved with the + * `getLogs()` method. + * + * If the process exits unexpectedly, it will be restarted according to the + * configured restart policy. + * + * @param {string} encryptionKey - The key to use for encrypting data saved in + * the Relay. + */ + public start(encryptionKey: string): void { + if (this.process) return; + + this.encryptionKey = encryptionKey; + + try { + this.process = spawn(this.relayPath, [], { + env: { + ...process.env, + LD_LIBRARY_PATH: process.env.LD_LIBRARY_PATH, + PATH: process.env.PATH, + ENCRYPTION_KEY: encryptionKey, + }, + }); + + if (this.process.stdout) { + this.process.stdout.on("data", (data: Buffer) => { + const logLine = data.toString().trim(); + this.addLog(logLine); + console.log(logLine); + }); + } + + if (this.process.stderr) { + this.process.stderr.on("data", (data: Buffer) => { + const logLine = data.toString().trim(); + this.addLog(logLine); + console.error(logLine); + }); + } + + this.process.on("error", (err: Error) => { + console.error(`Failed to start Relay: ${err.message}`); + this.process = null; + this.restartProcess(); + }); + + this.process.on("exit", this.handleProcessExit.bind(this)); + + if (this.process.pid) { + this.restartAttempts = 0; + } + } catch (error) { + console.error( + `Error starting Relay: ${ + error instanceof Error ? error.message : "Unknown error" + }` + ); + this.restartProcess(); + } + } + + /** + * Stop the Eve Relay. + * + * If the Relay process is currently running, it will be terminated. + * + * If the process is not running, do nothing. + * + * All pending restarts will be cancelled. + */ + public stop(): void { + this.isShuttingDown = true; + + if (this.restartTimeout) { + clearTimeout(this.restartTimeout); + this.restartTimeout = null; + } + + if (this.process) { + this.process.kill(); + this.process = null; + } + } + + /** + * Returns a copy of all the log lines the Relay has written to date. + * + * The returned array is a copy of the internal log state, and will not be + * modified by future log activity. + * + * @returns A copy of all log lines from the Relay since it was last started. + */ + public getLogs(): string[] { + return [...this.relayLogs]; + } +} diff --git a/index.html b/src/index.html similarity index 85% rename from index.html rename to src/index.html index a566c23..44a6910 100644 --- a/index.html +++ b/src/index.html @@ -7,6 +7,6 @@
- +