diff --git a/electron/main.ts b/electron/main.ts index b693508e..9abb5c83 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -1,11 +1,72 @@ +import {readFileSync} from "node:fs" import {join} from "node:path" -import {app} from "electron" +import {app, Menu, Tray} from "electron" import {createCapacitorElectronApp} from "@capawesome/capacitor-electron" +const {appId, appName} = JSON.parse( + readFileSync(join(app.getAppPath(), "generated/capacitor.config.json"), "utf8"), +) +let tray: Tray | undefined +let quitting = false + +const destroyTray = () => { + tray?.destroy() + tray = undefined +} + +app.on("before-quit", () => { + quitting = true + destroyTray() +}) + createCapacitorElectronApp({ window: {width: 1200, height: 800}, hooks: { + beforeReady: () => { + if (process.platform === "win32") { + app.setAppUserModelId(appId) + } + }, onWindowCreated: window => { + const show = () => { + if (window.isMinimized()) { + window.restore() + } + window.show() + window.focus() + } + if (process.platform === "darwin") { + window.on("close", event => { + if (!quitting) { + event.preventDefault() + window.hide() + } + }) + app.on("activate", show) + window.on("closed", () => app.removeListener("activate", show)) + } + const icon = + process.platform === "darwin" + ? "trayTemplate.png" + : process.platform === "win32" + ? "icon.ico" + : "icon.png" + tray = new Tray( + join( + app.getAppPath(), + app.isPackaged ? "generated" : "app", + app.isPackaged ? icon : "pwa-192x192.png", + ), + ) + tray.setToolTip(appName) + tray.setContextMenu( + Menu.buildFromTemplate([ + {label: "Show", click: show}, + {label: "Quit", click: () => app.quit()}, + ]), + ) + tray.on("click", show) + window.on("closed", destroyTray) if (process.platform === "linux" && app.isPackaged) { window.setIcon(join(app.getAppPath(), "generated/icon.png")) } diff --git a/package.json b/package.json index 40eb22d5..3a6395ef 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "bump": "node scripts/bump-version.mjs", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", - "lint": "prettier --check src e2e playwright.config.ts capacitor.config.ts electron/*.ts electron/electron-builder.config.mjs scripts/dev-desktop.mjs scripts/package-desktop.mjs && eslint src e2e capacitor.config.ts electron/*.ts electron/electron-builder.config.mjs scripts/dev-desktop.mjs scripts/package-desktop.mjs", + "lint": "prettier --check src e2e packages playwright.config.ts capacitor.config.ts electron/*.ts electron/electron-builder.config.mjs scripts/dev-desktop.mjs scripts/package-desktop.mjs && eslint src e2e packages capacitor.config.ts electron/*.ts electron/electron-builder.config.mjs scripts/dev-desktop.mjs scripts/package-desktop.mjs", "test": "playwright test", "test:desktop": "playwright test --config e2e/desktop/playwright.config.ts", "test:ui": "playwright test --ui", @@ -74,6 +74,7 @@ "@capawesome/capacitor-android-dark-mode-support": "^8.0.0", "@capawesome/capacitor-badge": "^8.0.0", "@capawesome/capacitor-electron": "0.1.1", + "@flotilla/desktop-window": "file:packages/desktop-window", "@getalby/lightning-tools": "^6.1.0", "@getalby/sdk": "^5.1.2", "@hono/node-server": "^2.0.0", diff --git a/packages/desktop-window/README.md b/packages/desktop-window/README.md new file mode 100644 index 00000000..106b7958 --- /dev/null +++ b/packages/desktop-window/README.md @@ -0,0 +1,4 @@ +# Desktop window plugin + +Capawesome 0.1.1 discovers Electron plugins at `/dist/plugin.mjs`. +This package contains plain JavaScript at that required path; it needs no compilation step. diff --git a/packages/desktop-window/electron/dist/plugin.mjs b/packages/desktop-window/electron/dist/plugin.mjs new file mode 100644 index 00000000..316a14ab --- /dev/null +++ b/packages/desktop-window/electron/dist/plugin.mjs @@ -0,0 +1,24 @@ +import {app} from "electron" + +export class DesktopWindow { + static __capacitorElectronPlugin = {name: "DesktopWindow", methods: ["show"]} + + window + + load() { + app.on("browser-window-created", (_event, window) => { + this.window = window + }) + } + + async show() { + if (!this.window || this.window.isDestroyed()) { + throw new Error("The application window is closed") + } + if (this.window.isMinimized()) { + this.window.restore() + } + this.window.show() + this.window.focus() + } +} diff --git a/packages/desktop-window/package.json b/packages/desktop-window/package.json new file mode 100644 index 00000000..64361f04 --- /dev/null +++ b/packages/desktop-window/package.json @@ -0,0 +1,11 @@ +{ + "name": "@flotilla/desktop-window", + "version": "0.0.0", + "private": true, + "type": "module", + "capacitor": { + "electron": { + "src": "electron" + } + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3674ecfe..c2d7cfc0 100644 Binary files a/pnpm-lock.yaml and b/pnpm-lock.yaml differ diff --git a/scripts/package-desktop.mjs b/scripts/package-desktop.mjs index 60b12445..114a3789 100644 --- a/scripts/package-desktop.mjs +++ b/scripts/package-desktop.mjs @@ -43,6 +43,18 @@ try { await run("bash", ["scripts/build-desktop.sh"]) await cp(join(root, env.VITE_PLATFORM_LOGO), join(root, "electron/generated/icon.png")) + await cp(join(root, "static/favicon.ico"), join(root, "electron/generated/icon.ico")) + for (const [size, name] of [ + [16, "trayTemplate.png"], + [32, "trayTemplate@2x.png"], + ]) { + await sharp({create: {width: size, height: size, channels: 3, background: "black"}}) + .joinChannel( + await sharp(logo).resize(size, size).ensureAlpha().extractChannel("alpha").toBuffer(), + ) + .png() + .toFile(join(root, "electron/generated", name)) + } await cp(join(root, "LICENSE"), join(root, "electron/generated/LICENSE")) await run( process.execPath, diff --git a/src/app/push/adapters/common.ts b/src/app/push/adapters/common.ts index 59fe3539..76691880 100644 --- a/src/app/push/adapters/common.ts +++ b/src/app/push/adapters/common.ts @@ -58,7 +58,6 @@ export const onNotification = call(() => { {kinds: [MESSAGE, ...CONTENT_KINDS, ...DM_KINDS]}, makeCommentFilter(CONTENT_KINDS), ] - const filters = allFilters.map(assoc("since", now())) const subscribers: Subscriber[] = [] let unsubscribe: Unsubscriber | undefined @@ -67,29 +66,36 @@ export const onNotification = call(() => { subscribers.push(f) if (!unsubscribe) { - unsubscribe = on(app.get().repository, "update", ({added}: RepositoryUpdate) => { - const $pubkey = app.get().user?.pubkey + let unsubscribeRepository: Unsubscriber | undefined + const unsubscribeApp = app.subscribe($app => { + unsubscribeRepository?.() + const filters = allFilters.map(assoc("since", now())) + unsubscribeRepository = on($app.repository, "update", ({added}: RepositoryUpdate) => { + const $pubkey = $app.user?.pubkey - for (const event of added) { - if (event.pubkey == $pubkey) { - continue - } + for (const event of added) { + if (event.pubkey == $pubkey) { + continue + } - const h = tagValue(tagSpec("h"), event.tags) + const h = tagValue(tagSpec("h"), event.tags) - if ( - Array.from(app.get().tracker.getRelays(event.id)).every(url => !shouldNotify(url, h)) - ) { - continue - } + if (Array.from($app.tracker.getRelays(event.id)).every(url => !shouldNotify(url, h))) { + continue + } - if (matchFilters(filters, event)) { - for (const f of subscribers) { - f(event) + if (matchFilters(filters, event)) { + for (const f of subscribers) { + f(event) + } } } - } + }) }) + unsubscribe = () => { + unsubscribeApp() + unsubscribeRepository?.() + } } return () => { diff --git a/src/app/push/adapters/electron.ts b/src/app/push/adapters/electron.ts new file mode 100644 index 00000000..00e3b7a4 --- /dev/null +++ b/src/app/push/adapters/electron.ts @@ -0,0 +1,72 @@ +import {registerPlugin} from "@capacitor/core" +import type {Unsubscriber} from "svelte/store" +import type {TrustedEvent} from "@welshman/util" +import {app} from "@app/core" +import {WebNotifications} from "@app/push/adapters/web" +import {goToEvent} from "@app/routes" +import {pushToast} from "@app/toast" + +const DesktopWindow = registerPlugin<{show: () => Promise}>("DesktopWindow") + +export class ElectronNotifications extends WebNotifications { + _unsubscribeApp: Unsubscriber | undefined + _notifications = new Set() + + _closeNotifications = () => { + for (const notification of this._notifications) { + notification.close() + } + this._notifications.clear() + } + + _onVisibilityChange = () => { + if (document.visibilityState === "visible") { + this._closeNotifications() + } + } + + _notify(event: TrustedEvent, title: string, body: string) { + const owner = app.get() + const notification = new Notification(title, { + body, + tag: event.id, + icon: "/icon.png", + badge: "/icon.png", + }) + + this._notifications.add(notification) + notification.onclose = () => this._notifications.delete(notification) + notification.onclick = async () => { + try { + if (app.get() === owner) { + await DesktopWindow.show() + if (app.get() === owner) { + goToEvent(event) + } + } + } catch (error) { + console.error(error) + pushToast({message: "Unable to open the notification", theme: "error"}) + } finally { + notification.close() + this._notifications.delete(notification) + } + } + } + + async enable() { + if (!this._unsubscriber) { + this._unsubscribeApp = app.subscribe(this._closeNotifications) + document.addEventListener("visibilitychange", this._onVisibilityChange) + } + return super.enable() + } + + async disable() { + this._unsubscribeApp?.() + this._unsubscribeApp = undefined + document.removeEventListener("visibilitychange", this._onVisibilityChange) + this._closeNotifications() + return super.disable() + } +} diff --git a/src/app/push/index.ts b/src/app/push/index.ts index c108735a..af695d67 100644 --- a/src/app/push/index.ts +++ b/src/app/push/index.ts @@ -1,6 +1,7 @@ import {Capacitor} from "@capacitor/core" import {notificationSettings} from "@app/settings" import {pushState} from "@app/push/adapters/common" +import {ElectronNotifications} from "@app/push/adapters/electron" import {WebNotifications} from "@app/push/adapters/web" import {CapacitorNotifications} from "@app/push/adapters/capacitor" import {AndroidFallbackNotifications} from "@app/push/adapters/android" @@ -15,7 +16,9 @@ export class Push { if (!Push._adapter) { const {useFallback} = pushState.get() - if (Capacitor.getPlatform() === "android" && useFallback) { + if (Capacitor.getPlatform() === "electron") { + Push._adapter = new ElectronNotifications() + } else if (Capacitor.getPlatform() === "android" && useFallback) { Push._adapter = new AndroidFallbackNotifications() } else if (Capacitor.isPluginAvailable("PushNotifications")) { Push._adapter = new CapacitorNotifications()