Added desktop tray controls and reliable notification activation (#550)

This commit is contained in:
Gaurav Chaudhary 2026-09-16 16:59:22 +00:00 committed by hodlbod
parent a7ca6bb9fb
commit ed7641117a
10 changed files with 214 additions and 20 deletions

View file

@ -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"))
}

View file

@ -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",

View file

@ -0,0 +1,4 @@
# Desktop window plugin
Capawesome 0.1.1 discovers Electron plugins at `<capacitor.electron.src>/dist/plugin.mjs`.
This package contains plain JavaScript at that required path; it needs no compilation step.

View file

@ -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()
}
}

View file

@ -0,0 +1,11 @@
{
"name": "@flotilla/desktop-window",
"version": "0.0.0",
"private": true,
"type": "module",
"capacitor": {
"electron": {
"src": "electron"
}
}
}

Binary file not shown.

View file

@ -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,

View file

@ -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<TrustedEvent>[] = []
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 () => {

View file

@ -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<void>}>("DesktopWindow")
export class ElectronNotifications extends WebNotifications {
_unsubscribeApp: Unsubscriber | undefined
_notifications = new Set<Notification>()
_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()
}
}

View file

@ -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()