From 37b9f0c11344fb4ca3af99cf222495a1f77aadd6 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 21 Sep 2026 15:01:16 -0600 Subject: [PATCH 01/28] feat(desktop): add main-owned app settings model Signed-off-by: Samuel K --- desktop/src/main/app-settings.ts | 100 +++++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 desktop/src/main/app-settings.ts diff --git a/desktop/src/main/app-settings.ts b/desktop/src/main/app-settings.ts new file mode 100644 index 000000000..72a42abf1 --- /dev/null +++ b/desktop/src/main/app-settings.ts @@ -0,0 +1,100 @@ +import { readFileSync, renameSync, writeFileSync } from "node:fs" +import type { AppSettings, TrayNotificationLevel } from "../shared/app-settings.js" + +export type { AppSettings, TrayNotificationLevel } + +export const DEFAULT_APP_SETTINGS: AppSettings = { + runAtStartup: false, + openToTrayOnStartup: false, + trayNotifications: "failures", +} + +const LEVELS: readonly TrayNotificationLevel[] = ["off", "failures", "all"] + +// Open-to-tray only exists as a modifier of an automatic startup launch, so +// it can never stay on while run-at-startup is off. +export function normalizeAppSettings(raw: unknown): AppSettings { + const input = ( + typeof raw === "object" && raw !== null ? raw : {} + ) as Record + const runAtStartup = input.runAtStartup === true + const openToTrayOnStartup = runAtStartup && input.openToTrayOnStartup === true + const trayNotifications = LEVELS.includes( + input.trayNotifications as TrayNotificationLevel, + ) + ? (input.trayNotifications as TrayNotificationLevel) + : DEFAULT_APP_SETTINGS.trayNotifications + return { runAtStartup, openToTrayOnStartup, trayNotifications } +} + +export function patchAppSettings( + current: AppSettings, + patch: Partial, +): AppSettings { + return normalizeAppSettings({ ...current, ...patch }) +} + +// Throws on a mistyped value so a renderer bug cannot silently persist a +// corrupt preference. +export function sanitizeAppSettingsPatch(raw: unknown): Partial { + if (!raw || typeof raw !== "object") return {} + const input = raw as Record + const patch: Partial = {} + if ("runAtStartup" in input) { + if (typeof input.runAtStartup !== "boolean") + throw new Error("runAtStartup must be boolean") + patch.runAtStartup = input.runAtStartup + } + if ("openToTrayOnStartup" in input) { + if (typeof input.openToTrayOnStartup !== "boolean") + throw new Error("openToTrayOnStartup must be boolean") + patch.openToTrayOnStartup = input.openToTrayOnStartup + } + if ("trayNotifications" in input) { + if (!LEVELS.includes(input.trayNotifications as TrayNotificationLevel)) + throw new Error("trayNotifications must be off, failures, or all") + patch.trayNotifications = input.trayNotifications as TrayNotificationLevel + } + return patch +} + +export class AppSettingsStore { + private settings: AppSettings + private listeners = new Set<() => void>() + + constructor(private readonly filePath: string) { + this.settings = DEFAULT_APP_SETTINGS + } + + load(): AppSettings { + let raw: unknown = {} + try { + raw = JSON.parse(readFileSync(this.filePath, "utf-8")) + } catch { + raw = {} + } + this.settings = normalizeAppSettings(raw) + return this.settings + } + + get(): AppSettings { + return this.settings + } + + save(next: AppSettings): void { + this.settings = normalizeAppSettings(next) + try { + const tmp = `${this.filePath}.tmp` + writeFileSync(tmp, JSON.stringify(this.settings, null, 2)) + renameSync(tmp, this.filePath) + } catch (err) { + console.warn("[app-settings] failed to persist settings:", err) + } + for (const listener of this.listeners) listener() + } + + onChange(listener: () => void): () => void { + this.listeners.add(listener) + return () => this.listeners.delete(listener) + } +} From 023a4924b6ea0e2dd6a9923373e467cea8657e03 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 21 Sep 2026 15:03:54 -0600 Subject: [PATCH 02/28] feat(desktop): add dbus-next dependency for portal background access Signed-off-by: Samuel K --- desktop/package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/desktop/package.json b/desktop/package.json index 901623021..6db222207 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -22,6 +22,7 @@ }, "dependencies": { "chokidar": "^5.0.0", + "dbus-next": "^0.10.2", "dompurify": "^3.4.7", "electron-updater": "^6.8.3", "node-pty": "^1.0.0", From c3aff37eca8fa32f09828a635500286662279cd9 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 21 Sep 2026 15:04:41 -0600 Subject: [PATCH 03/28] feat(desktop): add shared app settings types Signed-off-by: Samuel K --- desktop/src/shared/app-settings.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 desktop/src/shared/app-settings.ts diff --git a/desktop/src/shared/app-settings.ts b/desktop/src/shared/app-settings.ts new file mode 100644 index 000000000..bbc9642c5 --- /dev/null +++ b/desktop/src/shared/app-settings.ts @@ -0,0 +1,19 @@ +export type TrayNotificationLevel = "off" | "failures" | "all" + +export interface AppSettings { + runAtStartup: boolean + openToTrayOnStartup: boolean + trayNotifications: TrayNotificationLevel +} + +export interface StartupStatus { + applied: boolean + enabled: boolean + status: "enabled" | "disabled" | "denied" | "unavailable" | "error" + detail?: string +} + +export interface AppSettingsState { + settings: AppSettings + startup: StartupStatus +} From e8c3eaf16fafc3f0c3232d951d2f03dd9c3aa625 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 21 Sep 2026 15:07:09 -0600 Subject: [PATCH 04/28] feat(desktop): add platform autostart management Signed-off-by: Samuel K --- desktop/src/main/autostart.ts | 199 ++++++++++++++++++++++++++++++++++ 1 file changed, 199 insertions(+) create mode 100644 desktop/src/main/autostart.ts diff --git a/desktop/src/main/autostart.ts b/desktop/src/main/autostart.ts new file mode 100644 index 000000000..d5a1e049f --- /dev/null +++ b/desktop/src/main/autostart.ts @@ -0,0 +1,199 @@ +import { existsSync } from "node:fs" +import { mkdir, rm, writeFile } from "node:fs/promises" +import { join } from "node:path" +import { app } from "electron" +import type { AppSettings } from "./app-settings.js" +import { AUTO_LAUNCH_ARG } from "./launch-context.js" +import { requestPortalBackground } from "./portal-background.js" + +export interface AutostartApplyResult { + applied: boolean + enabled: boolean + status: "enabled" | "disabled" | "denied" | "unavailable" | "error" + detail?: string +} + +export interface AutostartEnvironment { + platform: string + isFlatpak: boolean + flatpakId?: string + execPath: string + homeDir: string + packaged: boolean +} + +export function detectAutostartEnvironment(): AutostartEnvironment { + return { + platform: process.platform, + isFlatpak: Boolean(process.env.FLATPAK_ID), + flatpakId: process.env.FLATPAK_ID, + execPath: process.execPath, + homeDir: app.getPath("home"), + packaged: app.isPackaged, + } +} + +function autostartDir(homeDir: string): string { + return join(homeDir, ".config", "autostart") +} + +function xdgDesktopFilePath(env: AutostartEnvironment): string { + return join(autostartDir(env.homeDir), "devsy.desktop") +} + +function flatpakDesktopFilePath(env: AutostartEnvironment): string { + return join(autostartDir(env.homeDir), `${env.flatpakId}.desktop`) +} + +function xdgDesktopEntry(execPath: string): string { + return [ + "[Desktop Entry]", + "Type=Application", + "Name=Devsy", + `Exec="${execPath}" ${AUTO_LAUNCH_ARG}`, + "X-GNOME-Autostart-enabled=true", + "", + ].join("\n") +} + +export function readAutostartEnabled( + env: AutostartEnvironment, +): boolean | undefined { + if (env.platform === "darwin" || env.platform === "win32") { + return app.getLoginItemSettings().openAtLogin + } + try { + if (env.isFlatpak) { + const file = flatpakDesktopFilePath(env) + return existsSync(file) ? true : false + } + return existsSync(xdgDesktopFilePath(env)) + } catch { + return undefined + } +} + +export async function applyAutostart( + settings: AppSettings, + env: AutostartEnvironment, +): Promise { + if (env.platform === "darwin") { + try { + app.setLoginItemSettings({ + openAtLogin: settings.runAtStartup, + openAsHidden: settings.openToTrayOnStartup, + }) + return { applied: true, enabled: settings.runAtStartup, status: settings.runAtStartup ? "enabled" : "disabled" } + } catch (error) { + return { + applied: false, + enabled: false, + status: "error", + detail: errorMessage(error), + } + } + } + if (env.platform === "win32") { + try { + app.setLoginItemSettings({ + openAtLogin: settings.runAtStartup, + args: [AUTO_LAUNCH_ARG], + }) + return { applied: true, enabled: settings.runAtStartup, status: settings.runAtStartup ? "enabled" : "disabled" } + } catch (error) { + return { + applied: false, + enabled: false, + status: "error", + detail: errorMessage(error), + } + } + } + if (env.platform !== "linux") { + return { + applied: false, + enabled: false, + status: "unavailable", + detail: `Run at startup is not supported on ${env.platform}.`, + } + } + if (env.isFlatpak) { + return applyFlatpakAutostart(settings, env) + } + return applyXdgAutostart(settings, env) +} + +async function applyXdgAutostart( + settings: AppSettings, + env: AutostartEnvironment, +): Promise { + const file = xdgDesktopFilePath(env) + try { + if (settings.runAtStartup) { + await mkdir(autostartDir(env.homeDir), { recursive: true }) + await writeFile(file, xdgDesktopEntry(env.execPath), "utf-8") + } else { + await rm(file, { force: true }) + } + return { applied: true, enabled: settings.runAtStartup, status: settings.runAtStartup ? "enabled" : "disabled" } + } catch (error) { + return { + applied: false, + enabled: readAutostartEnabled(env) === true, + status: "error", + detail: errorMessage(error), + } + } +} + +// Flatpak autostart must go through the Background portal so the user sees +// the system consent prompt. The portal has no removal call, so disabling +// removes the portal-created desktop file directly; --filesystem=home in the +// manifest makes it visible inside the sandbox. Denial is a normal result +// and is reported truthfully instead of claiming the setting is on. +async function applyFlatpakAutostart( + settings: AppSettings, + env: AutostartEnvironment, +): Promise { + if (!settings.runAtStartup) { + try { + await rm(flatpakDesktopFilePath(env), { force: true }) + } catch (error) { + return { + applied: false, + enabled: readAutostartEnabled(env) === true, + status: "error", + detail: errorMessage(error), + } + } + return { applied: true, enabled: false, status: "disabled" } + } + try { + const response = await requestPortalBackground({ + reason: "Allow Devsy to start automatically after you sign in.", + autostart: true, + commandline: ["devsy-wrapper", AUTO_LAUNCH_ARG], + }) + if (response.autostart) { + return { applied: true, enabled: true, status: "enabled" } + } + return { + applied: false, + enabled: false, + status: "denied", + detail: + "The system did not grant background autostart permission. Run at startup stays off; you can allow it from your desktop settings and try again.", + } + } catch (error) { + return { + applied: false, + enabled: false, + status: "error", + detail: errorMessage(error), + } + } +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} From 181345cafe6502652bf8dd9bd28d01da74492915 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 21 Sep 2026 15:07:43 -0600 Subject: [PATCH 05/28] feat(desktop): add launch context detection Signed-off-by: Samuel K --- desktop/src/main/launch-context.ts | 36 ++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 desktop/src/main/launch-context.ts diff --git a/desktop/src/main/launch-context.ts b/desktop/src/main/launch-context.ts new file mode 100644 index 000000000..651d4b2e4 --- /dev/null +++ b/desktop/src/main/launch-context.ts @@ -0,0 +1,36 @@ +import type { AppSettings } from "./app-settings.js" + +// Passed by every automatic-launch mechanism (Windows login item args, XDG +// autostart Exec line, Flatpak Background portal commandline) so the app can +// distinguish an automatic login launch from an explicit user launch. macOS +// reports the same through login item settings instead of argv. +export const AUTO_LAUNCH_ARG = "--opened-at-login" + +export interface LaunchEnvironment { + argv: readonly string[] + platform: string + wasOpenedAtLogin?: boolean + wasOpenedAsHidden?: boolean +} + +export function isAutomaticLoginLaunch(env: LaunchEnvironment): boolean { + if (env.argv.includes(AUTO_LAUNCH_ARG)) return true + if (env.platform === "darwin") { + return env.wasOpenedAtLogin === true || env.wasOpenedAsHidden === true + } + return false +} + +// Only an automatic login launch may open to the tray. An explicit launch +// always creates the window, otherwise clicking Devsy would appear to do +// nothing. +export function shouldSuppressInitialWindow( + settings: AppSettings, + automaticLoginLaunch: boolean, +): boolean { + return ( + automaticLoginLaunch && + settings.runAtStartup && + settings.openToTrayOnStartup + ) +} From 1ade85c2c9bcfb1563a5ae11c28af87aaa3a3ab4 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 21 Sep 2026 15:08:14 -0600 Subject: [PATCH 06/28] feat(desktop): add XDG Background portal autostart for Flatpak Signed-off-by: Samuel K --- desktop/src/main/portal-background.ts | 80 +++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 desktop/src/main/portal-background.ts diff --git a/desktop/src/main/portal-background.ts b/desktop/src/main/portal-background.ts new file mode 100644 index 000000000..88eba22db --- /dev/null +++ b/desktop/src/main/portal-background.ts @@ -0,0 +1,80 @@ +import { randomUUID } from "node:crypto" + +export interface PortalBackgroundResponse { + background: boolean + autostart: boolean +} + +export interface PortalBackgroundOptions { + reason: string + autostart: boolean + commandline?: string[] +} + +// The portal Response signal is delivered on a Request object whose path is +// only known after the method returns. Precomputing the path from the +// handle_token lets us subscribe before the call so an instant response +// cannot race past the listener. +export function portalRequestPath(senderUniqueName: string, token: string): string { + const sender = senderUniqueName.replace(/^:/, "").replace(/\./g, "_") + return `/org/freedesktop/portal/desktop/request/${sender}/${token}` +} + +export function parsePortalResponse( + code: number, + results: Record | undefined, +): PortalBackgroundResponse { + if (code !== 0) return { background: false, autostart: false } + return { + background: results?.background?.value === true, + autostart: results?.autostart?.value === true, + } +} + +const PORTAL_TIMEOUT_MS = 120_000 + +export async function requestPortalBackground( + options: PortalBackgroundOptions, +): Promise { + // Imported lazily so non-Flatpak platforms never load the D-Bus stack. + const { sessionBus, Variant } = await import("dbus-next") + const bus = sessionBus() + try { + const desktop = await bus.getProxyObject( + "org.freedesktop.portal.Desktop", + "/org/freedesktop/portal/desktop", + ) + const background = desktop.getInterface("org.freedesktop.portal.Background") + const token = `devsy${randomUUID().replace(/-/g, "")}` + const path = portalRequestPath(bus.name, token) + const requestObject = await bus.getProxyObject( + "org.freedesktop.portal.Desktop", + path, + ) + const request = requestObject.getInterface("org.freedesktop.portal.Request") + const response = new Promise((resolve) => { + const timer = setTimeout(() => { + resolve({ background: false, autostart: false }) + }, PORTAL_TIMEOUT_MS) + request.on( + "Response", + (code: number, results: Record) => { + clearTimeout(timer) + resolve(parsePortalResponse(code, results)) + }, + ) + }) + const methodOptions: Record> = { + handle_token: new Variant("s", token), + reason: new Variant("s", options.reason), + autostart: new Variant("b", options.autostart), + } + if (options.commandline) { + methodOptions.commandline = new Variant("as", options.commandline) + } + await background.RequestBackground("", methodOptions) + return await response + } finally { + bus.disconnect() + } +} From 5c6530e52a99b9f90dbd51eef4900da24bc856e4 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 21 Sep 2026 15:08:41 -0600 Subject: [PATCH 07/28] feat(desktop): add settings service coordinating autostart Signed-off-by: Samuel K --- desktop/src/main/settings-service.ts | 65 ++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 desktop/src/main/settings-service.ts diff --git a/desktop/src/main/settings-service.ts b/desktop/src/main/settings-service.ts new file mode 100644 index 000000000..9be2a0127 --- /dev/null +++ b/desktop/src/main/settings-service.ts @@ -0,0 +1,65 @@ +import { + type AppSettings, + type AppSettingsStore, + patchAppSettings, +} from "./app-settings.js" +import type { AutostartApplyResult } from "./autostart.js" + +export interface SettingsUpdateResult { + settings: AppSettings + startup: AutostartApplyResult +} + +export interface SettingsServiceDeps { + store: AppSettingsStore + applyAutostart: (settings: AppSettings) => Promise + currentAutostartEnabled: () => boolean | undefined + onChanged: (result: SettingsUpdateResult) => void +} + +export class SettingsService { + constructor(private deps: SettingsServiceDeps) {} + + get(): AppSettings { + return this.deps.store.get() + } + + status(): SettingsUpdateResult { + const enabled = this.deps.currentAutostartEnabled() + return { + settings: this.get(), + startup: { + applied: enabled !== undefined, + enabled: enabled === true, + status: + enabled === undefined + ? "unavailable" + : enabled + ? "enabled" + : "disabled", + }, + } + } + + async update(patch: Partial): Promise { + const current = this.deps.store.get() + const next = patchAppSettings(current, patch) + let startup: AutostartApplyResult = this.status().startup + const startupRelevant = + next.runAtStartup !== current.runAtStartup || + next.openToTrayOnStartup !== current.openToTrayOnStartup + if (startupRelevant) { + startup = await this.deps.applyAutostart(next) + // A denied or failed enable must not persist: Settings and the tray + // keep showing Run at startup off instead of claiming it is on. + if (next.runAtStartup && !startup.enabled) { + const reverted = { ...next, runAtStartup: false, openToTrayOnStartup: false } + return { settings: reverted, startup } + } + } + this.deps.store.save(next) + const result = { settings: next, startup } + this.deps.onChanged(result) + return result + } +} From 87114303c54bf864db115fec0951dc85c84cb6e3 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 21 Sep 2026 15:09:12 -0600 Subject: [PATCH 08/28] feat(desktop): add leveled tray notifications Signed-off-by: Samuel K --- desktop/src/main/tray-notifications.ts | 162 +++++++++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 desktop/src/main/tray-notifications.ts diff --git a/desktop/src/main/tray-notifications.ts b/desktop/src/main/tray-notifications.ts new file mode 100644 index 000000000..3eabc748c --- /dev/null +++ b/desktop/src/main/tray-notifications.ts @@ -0,0 +1,162 @@ +import type { TrayNotificationLevel } from "./app-settings.js" +import type { WorkspaceJob } from "../shared/workspace-operation.js" +import type { UpdateStatus } from "./updater.js" + +export interface NotificationRequest { + title: string + body: string + onClick: () => void +} + +export type NotificationSink = (request: NotificationRequest) => void + +export interface TerminalOutcome { + workspaceId: string + commandId: string + activity: WorkspaceJob["activity"] + state: "succeeded" | "failed" +} + +const ACTIVITY_VERBS: Record = { + creating: "Create", + starting: "Start", + stopping: "Stop", + deleting: "Delete", + rebuilding: "Rebuild", + resetting: "Reset", +} + +// Jobs move running -> reconciling -> succeeded|failed; only the final +// transition is terminal. Reconciling states and refresh errors never +// notify. +export function collectTerminalOutcomes( + previous: Record, + current: Record, + alreadyNotified: ReadonlySet, +): TerminalOutcome[] { + const outcomes: TerminalOutcome[] = [] + for (const [workspaceId, job] of Object.entries(current)) { + if (job.state !== "succeeded" && job.state !== "failed") continue + if (alreadyNotified.has(job.commandId)) continue + const before = previous[workspaceId] + if ( + before?.commandId === job.commandId && + (before.state === "succeeded" || before.state === "failed") + ) + continue + outcomes.push({ + workspaceId, + commandId: job.commandId, + activity: job.activity, + state: job.state, + }) + } + return outcomes +} + +export function outcomeNotifies( + outcome: TerminalOutcome, + level: TrayNotificationLevel, +): boolean { + if (level === "off") return false + if (level === "failures") return outcome.state === "failed" + return true +} + +export function outcomeNotificationBody(outcome: TerminalOutcome): string { + const verb = ACTIVITY_VERBS[outcome.activity] + return outcome.state === "succeeded" + ? `${outcome.workspaceId}: ${verb} completed` + : `${outcome.workspaceId}: ${verb} failed` +} + +// Update notifications fire once per version so a re-emitted status does not +// re-notify. +export function updateNotifies( + status: UpdateStatus, + level: TrayNotificationLevel, + lastNotifiedVersion: string | undefined, +): { notifies: boolean; version?: string; title: string; body: string } { + const none = { notifies: false, title: "", body: "" } + if (level === "off") return none + const version = status.availableVersion ?? "" + if (!version || version === lastNotifiedVersion) return none + if (status.state === "downloaded") { + return { + notifies: true, + version, + title: "Devsy update ready", + body: `Version ${version} is ready to install.`, + } + } + if (status.state === "error" && status.code === "install-failed") { + return { + notifies: true, + version, + title: "Devsy update failed", + body: `Version ${version} could not be installed.`, + } + } + return none +} + +export interface TrayNotifierDeps { + getLevel: () => TrayNotificationLevel + isAppFocused: () => boolean + sink: NotificationSink + openWorkspace: (workspaceId: string) => void + openWorkspaceLogs: (workspaceId: string) => void + openUpdates: () => void +} + +const NOTIFIED_CAP = 200 + +export class TrayNotifier { + private previous: Record = {} + private notified = new Set() + private lastUpdateVersion: string | undefined + + constructor(private deps: TrayNotifierDeps) {} + + onJobsChanged(current: Record): void { + const outcomes = collectTerminalOutcomes(this.previous, current, this.notified) + this.previous = current + for (const outcome of outcomes) { + this.markNotified(outcome.commandId) + if (!outcomeNotifies(outcome, this.deps.getLevel())) continue + if (this.deps.isAppFocused()) continue + this.deps.sink({ + title: "Devsy", + body: outcomeNotificationBody(outcome), + onClick: () => + outcome.state === "failed" + ? this.deps.openWorkspaceLogs(outcome.workspaceId) + : this.deps.openWorkspace(outcome.workspaceId), + }) + } + } + + onUpdateStatus(status: UpdateStatus): void { + const decision = updateNotifies( + status, + this.deps.getLevel(), + this.lastUpdateVersion, + ) + if (!decision.notifies || !decision.version) return + this.lastUpdateVersion = decision.version + if (this.deps.isAppFocused()) return + this.deps.sink({ + title: decision.title, + body: decision.body, + onClick: () => this.deps.openUpdates(), + }) + } + + private markNotified(commandId: string): void { + this.notified.add(commandId) + if (this.notified.size > NOTIFIED_CAP) { + const oldest = this.notified.values().next().value + if (oldest !== undefined) this.notified.delete(oldest) + } + } +} From 8beb980f6dd68c32a71145ed5a33a3f2f58c0ff6 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 21 Sep 2026 15:09:38 -0600 Subject: [PATCH 09/28] feat(desktop): add workspace actions and settings to tray Signed-off-by: Samuel K --- desktop/src/main/tray.ts | 324 +++++++++++++++++++++++++++------------ 1 file changed, 229 insertions(+), 95 deletions(-) diff --git a/desktop/src/main/tray.ts b/desktop/src/main/tray.ts index 67471dfb5..64ee6919d 100644 --- a/desktop/src/main/tray.ts +++ b/desktop/src/main/tray.ts @@ -7,6 +7,7 @@ import { import type { WorkspaceJobs } from "./workspace-jobs.js" import { join } from "node:path" import { app, Menu, nativeImage, nativeTheme, Tray } from "electron" +import type { AppSettings } from "./app-settings.js" import type { DaemonState, Workspace } from "./state.js" import { getLastStatus, @@ -14,7 +15,14 @@ import { onUpdateStatusChanged, type UpdateStatus, } from "./updater.js" -import { isActiveWorkspaceStatus } from "./workspace-status.js" +import { + isActiveWorkspaceStatus, + normalizeWorkspaceStatus, +} from "./workspace-status.js" + +// Native menus do not scroll well, so the tray shows only the most recently +// used workspaces and links into the app for the rest. +export const TRAY_WORKSPACE_LIMIT = 5 export function buildUpdateMenuItems( status: UpdateStatus, @@ -32,79 +40,182 @@ export function buildUpdateMenuItems( return [{ label, click: onInstall }, { type: "separator" }] } +export type TrayWorkspaceState = + | "running" + | "stopped" + | "failed" + | "busy" + | "unknown" + +export function trayWorkspaceState( + workspace: Workspace, + job?: WorkspaceJob, +): TrayWorkspaceState { + if (workspaceJobBusy(job)) return "busy" + if (job?.error || job?.state === "failed") return "failed" + const status = normalizeWorkspaceStatus(workspace.status ?? "")?.toLowerCase() + if (status === "running" || status === "busy") return "running" + if (status === "stopped") return "stopped" + if (status === "failed" || status === "error") return "failed" + return "unknown" +} + +const STATE_GLYPHS: Record = { + running: "●", + stopped: "○", + failed: "✖", + busy: "◐", + unknown: "◌", +} + +const STATE_TEXT: Record = { + running: "Running", + stopped: "Stopped", + failed: "Failed", + busy: "Busy", + unknown: "Unknown", +} + +export function countRunningWorkspaces( + workspaces: Workspace[], + jobs: Record, +): number { + return workspaces.filter( + (workspace) => + isActiveWorkspaceStatus(workspace.status) || + workspaceJobBusy(jobs[workspace.id]), + ).length +} + export interface TrayMenuModel { - activeWorkspaces: Workspace[] + workspaces: Workspace[] jobs?: Record pendingStops: ReadonlySet + pendingStarts: ReadonlySet updateStatus: UpdateStatus + settings: AppSettings } export interface TrayMenuActions { showDevsy: () => void showWorkspace: (id: string) => void + showWorkspaceLogs: (id: string) => void showAllWorkspaces: () => void + showSettings: () => void + startWorkspace: (id: string) => void stopWorkspace: (id: string) => void + toggleRunAtStartup: () => void + toggleOpenToTray: () => void installUpdate: () => void quit: () => void } +// Action meaning lives in text labels because native menu-item images are +// best-effort on Windows and several Linux panels; glyphs only mark state. export function buildTrayMenuTemplate( model: TrayMenuModel, actions: TrayMenuActions, ): Electron.MenuItemConstructorOptions[] { - const active = model.activeWorkspaces - const workspaceItems: Electron.MenuItemConstructorOptions[] = active - .slice(0, 10) - .map((workspace) => { - const job = model.jobs?.[workspace.id] + const jobs = model.jobs ?? {} + const running = countRunningWorkspaces(model.workspaces, jobs) + const header: Electron.MenuItemConstructorOptions[] = [ + { + label: + running === 0 + ? "Devsy — No running workspaces" + : `Devsy — ${running} running workspace${running === 1 ? "" : "s"}`, + enabled: false, + }, + { type: "separator" }, + ] + + const shown = model.workspaces.slice(0, TRAY_WORKSPACE_LIMIT) + const workspaceItems: Electron.MenuItemConstructorOptions[] = shown.map( + (workspace) => { + const job = jobs[workspace.id] + const state = trayWorkspaceState(workspace, job) const pending = - model.pendingStops.has(workspace.id) || workspaceJobBusy(job) - const busy = workspace.status?.trim().toLowerCase() === "busy" - const disabled = pending && !workspaceJobInterruptible(job) - const label = workspaceJobLabel(job) + model.pendingStops.has(workspace.id) || + model.pendingStarts.has(workspace.id) || + state === "busy" + const jobLabel = workspaceJobLabel(job) + const submenu: Electron.MenuItemConstructorOptions[] = [ + { + label: "Open in Devsy", + click: () => actions.showWorkspace(workspace.id), + }, + ] + if (state === "running") { + const stopping = + model.pendingStops.has(workspace.id) || + (workspaceJobBusy(job) && !workspaceJobInterruptible(job)) + submenu.push({ + label: stopping ? `${jobLabel ?? "Stopping"}…` : "Stop Workspace", + enabled: !stopping, + click: stopping + ? undefined + : () => actions.stopWorkspace(workspace.id), + }) + } else if (state === "stopped") { + submenu.push({ + label: model.pendingStarts.has(workspace.id) + ? "Starting…" + : "Start Workspace", + enabled: !model.pendingStarts.has(workspace.id), + click: model.pendingStarts.has(workspace.id) + ? undefined + : () => actions.startWorkspace(workspace.id), + }) + } + if (state === "failed" || state === "busy" || state === "unknown") { + submenu.push({ + label: "View Logs", + click: () => actions.showWorkspaceLogs(workspace.id), + }) + } return { - label: `${workspace.id}${label ? ` — ${label}` : busy && !pending ? " — Busy" : ""}`, - submenu: [ - { - label: "Open in Devsy", - click: () => actions.showWorkspace(workspace.id), - }, - { - label: disabled - ? `${workspaceJobLabel(job) ?? "Stopping"}…` - : "Stop Workspace", - enabled: !disabled, - click: disabled - ? undefined - : () => actions.stopWorkspace(workspace.id), - }, - ], + label: `${STATE_GLYPHS[state]} ${workspace.id} — ${jobLabel && (state === "busy" || state === "failed") ? jobLabel : STATE_TEXT[state]}`, + submenu, } - }) - - const activeSubmenu: Electron.MenuItemConstructorOptions[] = - workspaceItems.length > 0 - ? [ - ...workspaceItems, - ...(active.length > 10 ? [{ type: "separator" as const }] : []), - { - label: "Show All Workspaces…", - click: actions.showAllWorkspaces, - }, - ] - : [ - { label: "No Active Workspaces", enabled: false }, - { type: "separator" }, - { - label: "Open Workspaces in Devsy…", - click: actions.showAllWorkspaces, - }, - ] + }, + ) + if (workspaceItems.length === 0) { + workspaceItems.push({ label: "No Workspaces", enabled: false }) + } else if (model.workspaces.length > TRAY_WORKSPACE_LIMIT) { + workspaceItems.push( + { type: "separator" }, + { + label: `View All ${model.workspaces.length} Workspaces in Devsy`, + click: actions.showAllWorkspaces, + }, + ) + } return [ - { label: "Show Devsy", click: actions.showDevsy }, + ...header, + ...workspaceItems, { type: "separator" }, - { label: `Active Workspaces (${active.length})`, submenu: activeSubmenu }, + { label: "Open Devsy", click: actions.showDevsy }, + { + label: "Preferences", + submenu: [ + { + label: "Run at Startup", + type: "checkbox", + checked: model.settings.runAtStartup, + click: actions.toggleRunAtStartup, + }, + { + label: "Open to Tray on Startup", + type: "checkbox", + checked: model.settings.openToTrayOnStartup, + enabled: model.settings.runAtStartup, + click: actions.toggleOpenToTray, + }, + { type: "separator" }, + { label: "Open Settings…", click: actions.showSettings }, + ], + }, ...buildUpdateMenuItems(model.updateStatus, actions.installUpdate), { type: "separator" }, { label: "Quit Devsy", click: actions.quit }, @@ -115,6 +226,10 @@ interface TrayDeps { workspaceJobs?: WorkspaceJobs state: DaemonState showDevsy: (route?: string) => void + getSettings: () => AppSettings + toggleRunAtStartup: () => void + toggleOpenToTray: () => void + startWorkspace: (workspaceId: string) => Promise stopWorkspace: (workspaceId: string) => Promise refreshWorkspace: (workspaceId: string) => Promise refreshWorkspaces: () => Promise @@ -123,6 +238,7 @@ interface TrayDeps { export class AppTray { private tray: Tray | null = null private pendingStops = new Set() + private pendingStarts = new Set() private unsubscribeWorkspaceState: (() => void) | null = null private unsubscribeWorkspaceJobs: (() => void) | null = null private unsubscribeUpdateStatus: (() => void) | null = null @@ -135,7 +251,7 @@ export class AppTray { setup(): void { if (this.tray) return this.tray = new Tray(this.createTrayIcon()) - this.tray.setToolTip("Devsy — No active workspaces") + this.tray.setToolTip("Devsy — No running workspaces") this.unsubscribeWorkspaceState = this.deps.state.onWorkspacesChange(() => this.rebuildMenu(), ) @@ -161,53 +277,35 @@ export class AppTray { nativeTheme.off("updated", this.onThemeUpdated) } this.pendingStops.clear() + this.pendingStarts.clear() this.tray?.destroy() this.tray = null } - private createTrayIcon(): Electron.NativeImage { - const trayDir = join(__dirname, "../../resources/tray") - if (process.platform === "darwin") { - const icon = nativeImage.createFromPath( - join(trayDir, "icon-trayTemplate.png"), - ) - icon.setTemplateImage(true) - return icon - } - const variant = nativeTheme.shouldUseDarkColors ? "dark" : "light" - return nativeImage.createFromPath(join(trayDir, `icon-tray-${variant}.png`)) - } - - private rebuildMenu(): void { + rebuildMenu(): void { if (!this.tray) return const jobs = this.deps.workspaceJobs?.snapshot() ?? {} - const workspaces = this.deps.state.workspaceList() - const activeWorkspaces = workspaces.filter( - (workspace) => - isActiveWorkspaceStatus(workspace.status) || - workspaceJobBusy(jobs[workspace.id]), - ) - for (const [id, job] of Object.entries(jobs)) { - if ( - workspaceJobBusy(job) && - !workspaces.some((workspace) => workspace.id === id) - ) - activeWorkspaces.push({ id }) - } - const template = buildTrayMenuTemplate( { - activeWorkspaces, + workspaces: this.deps.state.workspaceList(), pendingStops: this.pendingStops, - jobs: this.deps.workspaceJobs?.snapshot(), + pendingStarts: this.pendingStarts, + jobs, updateStatus: getLastStatus(), + settings: this.deps.getSettings(), }, { showDevsy: () => this.deps.showDevsy(), showWorkspace: (id) => this.deps.showDevsy(`/workspaces/${encodeURIComponent(id)}`), + showWorkspaceLogs: (id) => + this.deps.showDevsy(`/workspaces/${encodeURIComponent(id)}?tab=logs`), showAllWorkspaces: () => this.deps.showDevsy("/workspaces"), + showSettings: () => this.deps.showDevsy("/settings"), + startWorkspace: (id) => void this.startFromTray(id), stopWorkspace: (id) => void this.stopFromTray(id), + toggleRunAtStartup: () => this.deps.toggleRunAtStartup(), + toggleOpenToTray: () => this.deps.toggleOpenToTray(), installUpdate: () => void installUpdate().catch((error) => console.warn("[tray] failed to install update:", error), @@ -216,12 +314,34 @@ export class AppTray { }, ) this.tray.setContextMenu(Menu.buildFromTemplate(template)) - const count = activeWorkspaces.length + const running = countRunningWorkspaces( + this.deps.state.workspaceList(), + jobs, + ) this.tray.setToolTip( - `Devsy — ${count} active workspace${count === 1 ? "" : "s"}`, + running === 0 + ? "Devsy — No running workspaces" + : `Devsy — ${running} running workspace${running === 1 ? "" : "s"}`, ) } + // Repeat clicks deduplicate through the pending sets and through + // WorkspaceJobs itself, which rejects a second in-progress operation. + private async startFromTray(workspaceId: string): Promise { + if (this.pendingStarts.has(workspaceId)) return + this.pendingStarts.add(workspaceId) + this.rebuildMenu() + try { + await this.deps.startWorkspace(workspaceId) + } catch (error) { + console.warn(`[tray] failed to start workspace ${workspaceId}:`, error) + } finally { + await this.refreshAfterAction(workspaceId) + this.pendingStarts.delete(workspaceId) + this.rebuildMenu() + } + } + private async stopFromTray(workspaceId: string): Promise { if (this.pendingStops.has(workspaceId)) return this.pendingStops.add(workspaceId) @@ -231,21 +351,35 @@ export class AppTray { } catch (error) { console.warn(`[tray] failed to stop workspace ${workspaceId}:`, error) } finally { - try { - await this.deps.refreshWorkspace(workspaceId) - } catch (error) { - console.warn( - `[tray] failed to refresh workspace ${workspaceId}:`, - error, - ) - } - try { - await this.deps.refreshWorkspaces() - } catch (error) { - console.warn("[tray] failed to refresh workspaces:", error) - } + await this.refreshAfterAction(workspaceId) this.pendingStops.delete(workspaceId) this.rebuildMenu() } } + + private async refreshAfterAction(workspaceId: string): Promise { + try { + await this.deps.refreshWorkspace(workspaceId) + } catch (error) { + console.warn(`[tray] failed to refresh workspace ${workspaceId}:`, error) + } + try { + await this.deps.refreshWorkspaces() + } catch (error) { + console.warn("[tray] failed to refresh workspaces:", error) + } + } + + private createTrayIcon(): Electron.NativeImage { + const trayDir = join(__dirname, "../../resources/tray") + if (process.platform === "darwin") { + const icon = nativeImage.createFromPath( + join(trayDir, "icon-trayTemplate.png"), + ) + icon.setTemplateImage(true) + return icon + } + const variant = nativeTheme.shouldUseDarkColors ? "dark" : "light" + return nativeImage.createFromPath(join(trayDir, `icon-tray-${variant}.png`)) + } } From cf8b1733b571804a1ce96f6f363bbf2f2f7668d1 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 21 Sep 2026 15:10:05 -0600 Subject: [PATCH 10/28] feat(desktop): wire settings service and tray notifications in main Signed-off-by: Samuel K --- desktop/src/main/index.ts | 85 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 82 insertions(+), 3 deletions(-) diff --git a/desktop/src/main/index.ts b/desktop/src/main/index.ts index f516c25fa..d9ffe65a3 100644 --- a/desktop/src/main/index.ts +++ b/desktop/src/main/index.ts @@ -1,8 +1,17 @@ import { homedir } from "node:os" import { join } from "node:path" -import { app, BrowserWindow, session } from "electron" +import { app, BrowserWindow, Notification, session } from "electron" import { initAnalytics, shutdownAnalytics, trackEvent } from "./analytics.js" import { isAppQuitting, markAppQuitting } from "./app-lifecycle.js" +import { AppSettingsStore } from "./app-settings.js" +import { + applyAutostart, + detectAutostartEnvironment, + readAutostartEnabled, +} from "./autostart.js" +import { isAutomaticLoginLaunch, shouldSuppressInitialWindow } from "./launch-context.js" +import { SettingsService } from "./settings-service.js" +import { TrayNotifier } from "./tray-notifications.js" import { CliRunner } from "./cli.js" import { DaemonManager } from "./daemon-manager.js" import { registerIpcHandlers } from "./ipc.js" @@ -13,7 +22,11 @@ import { ProviderJobs } from "./provider-jobs.js" import { PtyManager } from "./pty.js" import { DaemonState } from "./state.js" import { AppTray } from "./tray.js" -import { initAutoUpdater, stopAutoUpdater } from "./updater.js" +import { + initAutoUpdater, + onUpdateStatusChanged, + stopAutoUpdater, +} from "./updater.js" import { Watcher } from "./watcher.js" import { WorkspaceJobs } from "./workspace-jobs.js" @@ -198,6 +211,47 @@ app.whenReady().then(() => { const providerJobs = new ProviderJobs() const workspaceJobs = new WorkspaceJobs() + const appSettingsStore = new AppSettingsStore( + join(app.getPath("userData"), "app-settings.json"), + ) + appSettingsStore.load() + const autostartEnv = detectAutostartEnvironment() + const settingsService = new SettingsService({ + store: appSettingsStore, + applyAutostart: (settings) => applyAutostart(settings, autostartEnv), + currentAutostartEnabled: () => readAutostartEnabled(autostartEnv), + onChanged: (result) => { + appTray?.rebuildMenu() + const win = mainWindow + if (win && !win.isDestroyed()) { + win.webContents.send("app-settings-changed", result) + } + }, + }) + + const notifier = new TrayNotifier({ + getLevel: () => appSettingsStore.get().trayNotifications, + isAppFocused: () => { + const win = mainWindow + return Boolean(win && !win.isDestroyed() && win.isFocused()) + }, + sink: (request) => { + if (!Notification.isSupported()) return + const notification = new Notification({ + title: request.title, + body: request.body, + }) + notification.on("click", request.onClick) + notification.show() + }, + openWorkspace: (id) => showDevsy(`/workspaces/${encodeURIComponent(id)}`), + openWorkspaceLogs: (id) => + showDevsy(`/workspaces/${encodeURIComponent(id)}?tab=logs`), + openUpdates: () => showDevsy("/settings"), + }) + workspaceJobs.onChange(() => notifier.onJobsChanged(workspaceJobs.snapshot())) + onUpdateStatusChanged((status) => notifier.onUpdateStatus(status)) + // Register IPC handlers const { tunnelProcesses, @@ -229,6 +283,7 @@ app.whenReady().then(() => { } }, workspaceSnapshot: () => watcher?.workspaceSnapshot(), + settingsService, }) // Start state watcher @@ -267,6 +322,16 @@ app.whenReady().then(() => { state, workspaceJobs, showDevsy, + getSettings: () => appSettingsStore.get(), + toggleRunAtStartup: () => + void settingsService.update({ + runAtStartup: !appSettingsStore.get().runAtStartup, + }), + toggleOpenToTray: () => + void settingsService.update({ + openToTrayOnStartup: !appSettingsStore.get().openToTrayOnStartup, + }), + startWorkspace: workspaceActions.start, stopWorkspace: workspaceActions.stop, refreshWorkspace: (id) => watcher ? watcher.refreshWorkspaceStatus(id) : Promise.resolve(), @@ -275,7 +340,21 @@ app.whenReady().then(() => { }) appTray.setup() - createWindow() + // Open-to-tray suppresses the window only for automatic login launches; an + // explicit launch always shows the window. + const loginItems = + process.platform === "darwin" || process.platform === "win32" + ? app.getLoginItemSettings() + : undefined + const automaticLaunch = isAutomaticLoginLaunch({ + argv: process.argv, + platform: process.platform, + wasOpenedAtLogin: loginItems?.wasOpenedAtLogin, + wasOpenedAsHidden: loginItems?.wasOpenedAsHidden, + }) + if (!shouldSuppressInitialWindow(appSettingsStore.get(), automaticLaunch)) { + createWindow() + } if (app.isPackaged) { initAutoUpdater(() => mainWindow) From cfbd3f8097f8591e3f21097a4e428aacaf69b458 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 21 Sep 2026 15:10:42 -0600 Subject: [PATCH 11/28] feat(desktop): add app settings and workspace start IPC handlers Signed-off-by: Samuel K --- desktop/src/main/ipc.ts | 64 +++++++++++++++++++++++++++-------------- 1 file changed, 42 insertions(+), 22 deletions(-) diff --git a/desktop/src/main/ipc.ts b/desktop/src/main/ipc.ts index 16a6e2f56..eade35bec 100644 --- a/desktop/src/main/ipc.ts +++ b/desktop/src/main/ipc.ts @@ -22,6 +22,8 @@ import type { ProviderJobs, } from "./provider-jobs.js" import type { PtyManager } from "./pty.js" +import { sanitizeAppSettingsPatch } from "./app-settings.js" +import type { SettingsService } from "./settings-service.js" import type { DaemonState } from "./state.js" import { checkForUpdates, @@ -108,6 +110,7 @@ interface IpcDependencies { workspaceJobs: WorkspaceJobs workspaceSnapshot?: () => unknown onRendererReady?: (sender: Electron.WebContents) => void + settingsService?: SettingsService } /** Format a line in zap console format so log-parser.ts can parse it. */ @@ -262,7 +265,10 @@ export function registerIpcHandlers(deps: IpcDependencies): { tunnelProcesses: Map scheduleProviderUpdateCheck: () => void runInitialProviderUpdateCheck: () => void - workspaceActions: { stop: (workspaceId: string) => Promise } + workspaceActions: { + stop: (workspaceId: string) => Promise + start: (workspaceId: string) => Promise + } } { const { cli, state, logStore, pty, providerJobs, workspaceJobs, machineDiagnosticsStore, machineDiagnosticsManager, getMainWindow } = deps const tunnelProcesses = new Map< @@ -1091,25 +1097,20 @@ export function registerIpcHandlers(deps: IpcDependencies): { }, ) - ipcMain.handle( - "workspace_up", - async ( - _event, - args: { - source: string - workspaceId?: string - provider?: string - ide?: string - ideLaunch?: "auto" | "headless" | "skip" - debug?: boolean - workspaceFolder?: string - devcontainer?: string - prebuildRepository?: string - platform?: string - recovery?: boolean - commandId?: string - }, - ) => { + const runWorkspaceUp = async (args: { + source: string + workspaceId?: string + provider?: string + ide?: string + ideLaunch?: "auto" | "headless" | "skip" + debug?: boolean + workspaceFolder?: string + devcontainer?: string + prebuildRepository?: string + platform?: string + recovery?: boolean + commandId?: string + }): Promise => { trackEvent("workspace_create", { provider: args.provider, workspace_ref: hashWorkspaceRef(args.workspaceId ?? args.source), @@ -1326,7 +1327,10 @@ export function registerIpcHandlers(deps: IpcDependencies): { ) }) return cmdId - }, + } + + ipcMain.handle("workspace_up", (_event, args: Parameters[0]) => + runWorkspaceUp(args), ) async function reconcileDetachedTask( @@ -1834,6 +1838,19 @@ export function registerIpcHandlers(deps: IpcDependencies): { }, ) + ipcMain.handle("get_app_settings", () => { + if (!deps.settingsService) throw new Error("Settings service unavailable") + return deps.settingsService.status() + }) + + ipcMain.handle( + "set_app_settings", + async (_event, args: { patch?: Record }) => { + if (!deps.settingsService) throw new Error("Settings service unavailable") + return deps.settingsService.update(sanitizeAppSettingsPatch(args?.patch)) + }, + ) + // ── Analytics ── ipcMain.handle( "analytics_track", @@ -1878,6 +1895,9 @@ export function registerIpcHandlers(deps: IpcDependencies): { ) await completion }, + async start(workspaceId: string): Promise { + await runWorkspaceUp({ source: workspaceId, commandId: crypto.randomUUID() }) + }, }, } } @@ -1893,4 +1913,4 @@ function sanitizeAnalyticsProperties( out[k] = typeof v === "string" ? v.slice(0, 256) : v } return out -} + } From 7087adaa35aa73a8f1523d36ff447db6978f7800 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 21 Sep 2026 15:11:07 -0600 Subject: [PATCH 12/28] test(desktop): cover app settings model Signed-off-by: Samuel K --- .../src/main/__tests__/app-settings.test.ts | 174 ++++++++++++++++++ 1 file changed, 174 insertions(+) create mode 100644 desktop/src/main/__tests__/app-settings.test.ts diff --git a/desktop/src/main/__tests__/app-settings.test.ts b/desktop/src/main/__tests__/app-settings.test.ts new file mode 100644 index 000000000..6841481f9 --- /dev/null +++ b/desktop/src/main/__tests__/app-settings.test.ts @@ -0,0 +1,174 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { afterEach, beforeEach, describe, expect, it } from "vitest" +import { + AppSettingsStore, + DEFAULT_APP_SETTINGS, + normalizeAppSettings, + patchAppSettings, + sanitizeAppSettingsPatch, +} from "../app-settings.js" + +describe("normalizeAppSettings", () => { + it("returns defaults for missing or malformed input", () => { + expect(normalizeAppSettings(undefined)).toEqual(DEFAULT_APP_SETTINGS) + expect(normalizeAppSettings(null)).toEqual(DEFAULT_APP_SETTINGS) + expect(normalizeAppSettings("nope")).toEqual(DEFAULT_APP_SETTINGS) + expect(normalizeAppSettings({})).toEqual(DEFAULT_APP_SETTINGS) + }) + + it("keeps valid persisted values", () => { + expect( + normalizeAppSettings({ + runAtStartup: true, + openToTrayOnStartup: true, + trayNotifications: "all", + }), + ).toEqual({ + runAtStartup: true, + openToTrayOnStartup: true, + trayNotifications: "all", + }) + }) + + it("forces open-to-tray off when run-at-startup is off", () => { + expect( + normalizeAppSettings({ runAtStartup: false, openToTrayOnStartup: true }), + ).toEqual({ + runAtStartup: false, + openToTrayOnStartup: false, + trayNotifications: "failures", + }) + }) + + it("falls back to the default notification level for unknown values", () => { + expect( + normalizeAppSettings({ trayNotifications: "loud" }).trayNotifications, + ).toBe("failures") + }) + + it("ignores non-boolean toggles", () => { + expect( + normalizeAppSettings({ runAtStartup: "yes" }).runAtStartup, + ).toBe(false) + }) +}) + +describe("patchAppSettings", () => { + it("clears the dependent toggle when run-at-startup is disabled", () => { + const current = { + runAtStartup: true, + openToTrayOnStartup: true, + trayNotifications: "all" as const, + } + expect(patchAppSettings(current, { runAtStartup: false })).toEqual({ + runAtStartup: false, + openToTrayOnStartup: false, + trayNotifications: "all", + }) + }) + + it("leaves unrelated settings untouched", () => { + const current = { + runAtStartup: true, + openToTrayOnStartup: false, + trayNotifications: "off" as const, + } + expect( + patchAppSettings(current, { openToTrayOnStartup: true }), + ).toEqual({ + runAtStartup: true, + openToTrayOnStartup: true, + trayNotifications: "off", + }) + }) +}) + +describe("sanitizeAppSettingsPatch", () => { + it("accepts a valid partial patch", () => { + expect( + sanitizeAppSettingsPatch({ runAtStartup: true, trayNotifications: "all" }), + ).toEqual({ runAtStartup: true, trayNotifications: "all" }) + }) + + it("returns an empty patch for non-object input", () => { + expect(sanitizeAppSettingsPatch(undefined)).toEqual({}) + expect(sanitizeAppSettingsPatch("x")).toEqual({}) + }) + + it("rejects mistyped values", () => { + expect(() => sanitizeAppSettingsPatch({ runAtStartup: 1 })).toThrow() + expect(() => + sanitizeAppSettingsPatch({ trayNotifications: "everything" }), + ).toThrow() + }) +}) + +describe("AppSettingsStore", () => { + let dir: string + let file: string + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "app-settings-")) + file = join(dir, "app-settings.json") + }) + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }) + }) + + it("loads defaults when the file does not exist", () => { + const store = new AppSettingsStore(file) + expect(store.load()).toEqual(DEFAULT_APP_SETTINGS) + }) + + it("survives a corrupt file", () => { + writeFileSync(file, "{not json") + const store = new AppSettingsStore(file) + expect(store.load()).toEqual(DEFAULT_APP_SETTINGS) + }) + + it("round-trips saved settings", () => { + const store = new AppSettingsStore(file) + store.load() + store.save({ + runAtStartup: true, + openToTrayOnStartup: true, + trayNotifications: "off", + }) + const reloaded = new AppSettingsStore(file) + expect(reloaded.load()).toEqual({ + runAtStartup: true, + openToTrayOnStartup: true, + trayNotifications: "off", + }) + }) + + it("normalizes on save", () => { + const store = new AppSettingsStore(file) + store.load() + store.save({ + runAtStartup: false, + openToTrayOnStartup: true, + trayNotifications: "failures", + }) + expect(JSON.parse(readFileSync(file, "utf-8"))).toEqual({ + runAtStartup: false, + openToTrayOnStartup: false, + trayNotifications: "failures", + }) + }) + + it("notifies listeners on save", () => { + const store = new AppSettingsStore(file) + store.load() + let calls = 0 + const off = store.onChange(() => calls++) + store.save({ ...DEFAULT_APP_SETTINGS, runAtStartup: true }) + expect(calls).toBe(1) + off() + store.save({ ...DEFAULT_APP_SETTINGS }) + expect(calls).toBe(1) + }) +}) From b0f824d0dc3aa518329427ddb8811c99cef44bdd Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 21 Sep 2026 15:11:33 -0600 Subject: [PATCH 13/28] test(desktop): cover launch context detection Signed-off-by: Samuel K --- .../src/main/__tests__/launch-context.test.ts | 91 +++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 desktop/src/main/__tests__/launch-context.test.ts diff --git a/desktop/src/main/__tests__/launch-context.test.ts b/desktop/src/main/__tests__/launch-context.test.ts new file mode 100644 index 000000000..79298a6ea --- /dev/null +++ b/desktop/src/main/__tests__/launch-context.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "vitest" +import { + AUTO_LAUNCH_ARG, + isAutomaticLoginLaunch, + shouldSuppressInitialWindow, +} from "../launch-context.js" + +const base = { runAtStartup: true, openToTrayOnStartup: true, trayNotifications: "failures" as const } + +describe("isAutomaticLoginLaunch", () => { + it("detects the launch argument on Windows and Linux", () => { + expect( + isAutomaticLoginLaunch({ + argv: ["/app/devsy", AUTO_LAUNCH_ARG], + platform: "win32", + }), + ).toBe(true) + expect( + isAutomaticLoginLaunch({ + argv: ["/app/devsy", AUTO_LAUNCH_ARG], + platform: "linux", + }), + ).toBe(true) + }) + + it("detects a macOS login launch from login item settings", () => { + expect( + isAutomaticLoginLaunch({ + argv: ["/app/devsy"], + platform: "darwin", + wasOpenedAtLogin: true, + }), + ).toBe(true) + expect( + isAutomaticLoginLaunch({ + argv: ["/app/devsy"], + platform: "darwin", + wasOpenedAsHidden: true, + }), + ).toBe(true) + }) + + it("treats an explicit launch as manual", () => { + expect( + isAutomaticLoginLaunch({ argv: ["/app/devsy"], platform: "linux" }), + ).toBe(false) + expect( + isAutomaticLoginLaunch({ + argv: ["/app/devsy"], + platform: "darwin", + wasOpenedAtLogin: false, + wasOpenedAsHidden: false, + }), + ).toBe(false) + }) + + it("ignores the macOS flags on other platforms", () => { + expect( + isAutomaticLoginLaunch({ + argv: ["/app/devsy"], + platform: "linux", + wasOpenedAtLogin: true, + }), + ).toBe(false) + }) +}) + +describe("shouldSuppressInitialWindow", () => { + it("suppresses only an automatic launch with both toggles on", () => { + expect(shouldSuppressInitialWindow(base, true)).toBe(true) + }) + + it("never suppresses an explicit launch", () => { + expect(shouldSuppressInitialWindow(base, false)).toBe(false) + }) + + it("never suppresses when open-to-tray is off", () => { + expect( + shouldSuppressInitialWindow({ ...base, openToTrayOnStartup: false }, true), + ).toBe(false) + }) + + it("never suppresses when run-at-startup is off", () => { + expect( + shouldSuppressInitialWindow( + { ...base, runAtStartup: false, openToTrayOnStartup: false }, + true, + ), + ).toBe(false) + }) +}) From 1550f2807802445eaa8c57bf01b44eb65277f47d Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 21 Sep 2026 15:12:03 -0600 Subject: [PATCH 14/28] test(desktop): cover settings service autostart wiring Signed-off-by: Samuel K --- .../main/__tests__/settings-service.test.ts | 103 ++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 desktop/src/main/__tests__/settings-service.test.ts diff --git a/desktop/src/main/__tests__/settings-service.test.ts b/desktop/src/main/__tests__/settings-service.test.ts new file mode 100644 index 000000000..2ab197d67 --- /dev/null +++ b/desktop/src/main/__tests__/settings-service.test.ts @@ -0,0 +1,103 @@ +import { mkdtempSync, rmSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { AppSettingsStore, DEFAULT_APP_SETTINGS } from "../app-settings.js" +import type { AutostartApplyResult } from "../autostart.js" +import { SettingsService } from "../settings-service.js" + +const enabled: AutostartApplyResult = { applied: true, enabled: true, status: "enabled" } +const disabled: AutostartApplyResult = { applied: true, enabled: false, status: "disabled" } + +describe("SettingsService", () => { + let dir: string + let store: AppSettingsStore + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "settings-service-")) + store = new AppSettingsStore(join(dir, "app-settings.json")) + store.load() + }) + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }) + }) + + function service(applyAutostart: (s: typeof DEFAULT_APP_SETTINGS) => Promise) { + const onChanged = vi.fn() + const svc = new SettingsService({ + store, + applyAutostart, + currentAutostartEnabled: () => false, + onChanged, + }) + return { svc, onChanged } + } + + it("applies autostart and persists when enabling succeeds", async () => { + const { svc, onChanged } = service(async () => enabled) + const result = await svc.update({ runAtStartup: true }) + expect(result.settings.runAtStartup).toBe(true) + expect(store.get().runAtStartup).toBe(true) + expect(onChanged).toHaveBeenCalledTimes(1) + }) + + it("does not persist run-at-startup when the platform denies it", async () => { + const { svc, onChanged } = service(async () => ({ + applied: false, + enabled: false, + status: "denied", + detail: "denied by the system", + })) + const result = await svc.update({ runAtStartup: true }) + expect(result.settings.runAtStartup).toBe(false) + expect(result.settings.openToTrayOnStartup).toBe(false) + expect(result.startup.status).toBe("denied") + expect(store.get().runAtStartup).toBe(false) + expect(onChanged).not.toHaveBeenCalled() + }) + + it("keeps autostart off when enabling errors", async () => { + const { svc } = service(async () => ({ + applied: false, + enabled: false, + status: "error", + detail: "boom", + })) + const result = await svc.update({ runAtStartup: true, openToTrayOnStartup: true }) + expect(result.settings.runAtStartup).toBe(false) + expect(store.get().runAtStartup).toBe(false) + }) + + it("reapplies autostart when the dependent toggle changes", async () => { + const calls: boolean[] = [] + const { svc } = service(async (s) => { + calls.push(s.openToTrayOnStartup) + return enabled + }) + await svc.update({ runAtStartup: true }) + await svc.update({ openToTrayOnStartup: true }) + expect(calls).toEqual([false, true]) + expect(store.get().openToTrayOnStartup).toBe(true) + }) + + it("does not touch autostart for unrelated changes", async () => { + const apply = vi.fn(async () => enabled) + const { svc } = service(apply) + await svc.update({ trayNotifications: "all" }) + expect(apply).not.toHaveBeenCalled() + expect(store.get().trayNotifications).toBe("all") + }) + + it("disables autostart when run-at-startup turns off", async () => { + const { svc } = service(async (s) => (s.runAtStartup ? enabled : disabled)) + await svc.update({ runAtStartup: true, openToTrayOnStartup: true }) + const result = await svc.update({ runAtStartup: false }) + expect(result.settings).toEqual({ + runAtStartup: false, + openToTrayOnStartup: false, + trayNotifications: "failures", + }) + expect(result.startup.status).toBe("disabled") + }) +}) From 149973ff182e3309f7252ad319a4eb8ebae787bb Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 21 Sep 2026 15:12:30 -0600 Subject: [PATCH 15/28] test(desktop): cover tray notification levels Signed-off-by: Samuel K --- .../main/__tests__/tray-notifications.test.ts | 182 ++++++++++++++++++ 1 file changed, 182 insertions(+) create mode 100644 desktop/src/main/__tests__/tray-notifications.test.ts diff --git a/desktop/src/main/__tests__/tray-notifications.test.ts b/desktop/src/main/__tests__/tray-notifications.test.ts new file mode 100644 index 000000000..bad2225d3 --- /dev/null +++ b/desktop/src/main/__tests__/tray-notifications.test.ts @@ -0,0 +1,182 @@ +import { describe, expect, it, vi } from "vitest" +import type { WorkspaceJob } from "../../shared/workspace-operation.js" +import { + collectTerminalOutcomes, + outcomeNotificationBody, + outcomeNotifies, + TrayNotifier, + updateNotifies, + type NotificationRequest, +} from "../tray-notifications.js" +import type { UpdateStatus } from "../updater.js" + +function job(partial: Partial): WorkspaceJob { + return { + commandId: "cmd-1", + activity: "starting", + state: "running", + phase: "Preparing", + ...partial, + } +} + +describe("collectTerminalOutcomes", () => { + it("emits an outcome when a job reaches a terminal state", () => { + const previous = { ws: job({ state: "running" }) } + const current = { ws: job({ state: "succeeded" }) } + expect(collectTerminalOutcomes(previous, current, new Set())).toEqual([ + { workspaceId: "ws", commandId: "cmd-1", activity: "starting", state: "succeeded" }, + ]) + }) + + it("does not emit for non-terminal states", () => { + const current = { ws: job({ state: "reconciling", refreshError: "x" }) } + expect(collectTerminalOutcomes({}, current, new Set())).toEqual([]) + }) + + it("does not re-emit for an already terminal command", () => { + const previous = { ws: job({ state: "failed", error: "boom" }) } + const current = { ws: job({ state: "failed", error: "boom" }) } + expect(collectTerminalOutcomes(previous, current, new Set())).toEqual([]) + }) + + it("skips commands already notified", () => { + const current = { ws: job({ state: "failed", error: "boom" }) } + expect( + collectTerminalOutcomes({}, current, new Set(["cmd-1"])), + ).toEqual([]) + }) + + it("treats a new command id as a new outcome", () => { + const previous = { ws: job({ state: "failed", error: "boom" }) } + const current = { ws: job({ commandId: "cmd-2", state: "failed", error: "boom" }) } + const outcomes = collectTerminalOutcomes(previous, current, new Set(["cmd-1"])) + expect(outcomes).toHaveLength(1) + expect(outcomes[0].commandId).toBe("cmd-2") + }) +}) + +describe("outcomeNotifies", () => { + const failed = { workspaceId: "ws", commandId: "c", activity: "stopping" as const, state: "failed" as const } + const succeeded = { ...failed, state: "succeeded" as const } + + it("notifies nothing when off", () => { + expect(outcomeNotifies(failed, "off")).toBe(false) + expect(outcomeNotifies(succeeded, "off")).toBe(false) + }) + + it("notifies only failures by default", () => { + expect(outcomeNotifies(failed, "failures")).toBe(true) + expect(outcomeNotifies(succeeded, "failures")).toBe(false) + }) + + it("notifies all terminal outcomes when set to all", () => { + expect(outcomeNotifies(failed, "all")).toBe(true) + expect(outcomeNotifies(succeeded, "all")).toBe(true) + }) +}) + +describe("outcomeNotificationBody", () => { + it("uses text verbs", () => { + expect( + outcomeNotificationBody({ workspaceId: "api", commandId: "c", activity: "stopping", state: "succeeded" }), + ).toBe("api: Stop completed") + expect( + outcomeNotificationBody({ workspaceId: "api", commandId: "c", activity: "starting", state: "failed" }), + ).toBe("api: Start failed") + }) +}) + +describe("updateNotifies", () => { + const downloaded: UpdateStatus = { + state: "downloaded", + currentVersion: "1.0.0", + availableVersion: "1.1.0", + } + + it("notifies once per version when enabled", () => { + const first = updateNotifies(downloaded, "failures", undefined) + expect(first.notifies).toBe(true) + expect(first.version).toBe("1.1.0") + expect(updateNotifies(downloaded, "failures", "1.1.0").notifies).toBe(false) + }) + + it("never notifies when off", () => { + expect(updateNotifies(downloaded, "off", undefined).notifies).toBe(false) + }) + + it("notifies for install failures once per version", () => { + const failed: UpdateStatus = { + state: "error", + currentVersion: "1.0.0", + availableVersion: "1.1.0", + code: "install-failed", + error: "x", + } + expect(updateNotifies(failed, "failures", undefined).notifies).toBe(true) + expect(updateNotifies(failed, "failures", "1.1.0").notifies).toBe(false) + }) + + it("ignores other update states", () => { + expect( + updateNotifies({ state: "checking", currentVersion: "1.0.0" }, "all", undefined).notifies, + ).toBe(false) + }) +}) + +function makeNotifier(level: () => "off" | "failures" | "all", focused: () => boolean) { + const sent: NotificationRequest[] = [] + const notifier = new TrayNotifier({ + getLevel: level, + isAppFocused: focused, + sink: (request) => sent.push(request), + openWorkspace: vi.fn(), + openWorkspaceLogs: vi.fn(), + openUpdates: vi.fn(), + }) + return { notifier, sent } +} + +describe("TrayNotifier", () => { + it("sends a notification for a failure under the default level", () => { + const { notifier, sent } = makeNotifier(() => "failures", () => false) + notifier.onJobsChanged({ ws: job({ state: "running" }) }) + notifier.onJobsChanged({ ws: job({ state: "failed", error: "boom" }) }) + expect(sent).toHaveLength(1) + expect(sent[0].body).toBe("ws: Start failed") + }) + + it("deduplicates repeated snapshots of the same command", () => { + const { notifier, sent } = makeNotifier(() => "failures", () => false) + const terminal = { ws: job({ state: "failed", error: "boom" }) } + notifier.onJobsChanged(terminal) + notifier.onJobsChanged(terminal) + expect(sent).toHaveLength(1) + }) + + it("suppresses notifications while the app is focused", () => { + const { notifier, sent } = makeNotifier(() => "failures", () => true) + notifier.onJobsChanged({ ws: job({ state: "failed", error: "boom" }) }) + expect(sent).toHaveLength(0) + }) + + it("routes failure clicks to logs and success clicks to the workspace", () => { + const openWorkspace = vi.fn() + const openWorkspaceLogs = vi.fn() + const sent: NotificationRequest[] = [] + const notifier = new TrayNotifier({ + getLevel: () => "all", + isAppFocused: () => false, + sink: (request) => sent.push(request), + openWorkspace, + openWorkspaceLogs, + openUpdates: vi.fn(), + }) + notifier.onJobsChanged({ ws: job({ state: "failed", error: "boom" }) }) + sent[0].onClick() + expect(openWorkspaceLogs).toHaveBeenCalledWith("ws") + notifier.onJobsChanged({ ws: job({ commandId: "cmd-2", state: "succeeded" }) }) + sent[1].onClick() + expect(openWorkspace).toHaveBeenCalledWith("ws") + }) +}) From 707363c63609544622e4119c06e721a6f12eee89 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 21 Sep 2026 15:12:56 -0600 Subject: [PATCH 16/28] test(desktop): update tray tests for workspace actions Signed-off-by: Samuel K --- desktop/src/main/__tests__/tray.test.ts | 345 +++++++++++++++++++----- 1 file changed, 281 insertions(+), 64 deletions(-) diff --git a/desktop/src/main/__tests__/tray.test.ts b/desktop/src/main/__tests__/tray.test.ts index aed9eb07f..9885902b8 100644 --- a/desktop/src/main/__tests__/tray.test.ts +++ b/desktop/src/main/__tests__/tray.test.ts @@ -119,89 +119,306 @@ describe("buildUpdateMenuItems", () => { }) describe("buildTrayMenuTemplate", () => { - const actions = { - showDevsy: vi.fn(), - showWorkspace: vi.fn(), - showAllWorkspaces: vi.fn(), - stopWorkspace: vi.fn(), - installUpdate: vi.fn(), - quit: vi.fn(), + const settings = { + runAtStartup: true, + openToTrayOnStartup: false, + trayNotifications: "failures" as const, } - it("shows only active workspaces with open and stop actions", () => { + function makeActions() { + return { + showDevsy: vi.fn(), + showWorkspace: vi.fn(), + showWorkspaceLogs: vi.fn(), + showAllWorkspaces: vi.fn(), + showSettings: vi.fn(), + startWorkspace: vi.fn(), + stopWorkspace: vi.fn(), + toggleRunAtStartup: vi.fn(), + toggleOpenToTray: vi.fn(), + installUpdate: vi.fn(), + quit: vi.fn(), + } + } + + function model(partial: Partial[0]>) { + return { + workspaces: [], + pendingStops: new Set(), + pendingStarts: new Set(), + updateStatus: { state: "idle" as const, currentVersion: "1.0.0" }, + settings, + ...partial, + } + } + + function labels(items: Electron.MenuItemConstructorOptions[]): (string | undefined)[] { + return items.map((item) => item.label) + } + + it("shows an empty state when there are no workspaces", () => { + const items = buildTrayMenuTemplate(model({}), makeActions()) + expect(labels(items)).toContain("No Workspaces") + }) + + it("shows the running count in the header", () => { const items = buildTrayMenuTemplate( - { - activeWorkspaces: [ - { id: "running", status: "running" }, - { id: "busy", status: "busy" }, + model({ + workspaces: [ + { id: "a", status: "running" }, + { id: "b", status: "running" }, + { id: "c", status: "stopped" }, ], - pendingStops: new Set(), - updateStatus: { state: "idle", currentVersion: "1.0.0" }, - }, - actions, - ) - expect(items[0]).toMatchObject({ label: "Show Devsy" }) - const submenu = items[2].submenu as Array> - expect(submenu[0]).toMatchObject({ label: "running" }) - expect(submenu[1]).toMatchObject({ label: "busy — Busy" }) - expect(submenu[0].submenu).toEqual([ - expect.objectContaining({ label: "Open in Devsy" }), - expect.objectContaining({ label: "Stop Workspace" }), - ]) - expect(items.at(-1)).toMatchObject({ label: "Quit Devsy" }) - }) - - it("shows an empty state and disables a pending stop", () => { - const empty = buildTrayMenuTemplate( - { - activeWorkspaces: [], - pendingStops: new Set(), - updateStatus: { state: "idle", currentVersion: "1.0.0" }, - }, - actions, + }), + makeActions(), ) - expect( - (empty[2].submenu as Array>)[0], - ).toMatchObject({ - label: "No Active Workspaces", + expect(items[0]).toMatchObject({ + label: "Devsy — 2 running workspaces", enabled: false, }) + }) - const pending = buildTrayMenuTemplate( - { - activeWorkspaces: [{ id: "ws-1", status: "running" }], - pendingStops: new Set(["ws-1"]), - updateStatus: { state: "idle", currentVersion: "1.0.0" }, - }, + it("limits the list to five most recently used workspaces with an overflow link", () => { + const workspaces = Array.from({ length: 7 }, (_, i) => ({ + id: `ws-${i}`, + status: "stopped", + })) + const actions = makeActions() + const items = buildTrayMenuTemplate(model({ workspaces }), actions) + const rows = items.filter((item) => item.label?.startsWith("○")) + expect(rows).toHaveLength(5) + const overflow = items.find((item) => + item.label?.includes("View All 7 Workspaces in Devsy"), + ) + expect(overflow).toBeDefined() + ;(overflow as { click?: () => void }).click?.() + expect(actions.showAllWorkspaces).toHaveBeenCalledTimes(1) + }) + + it("does not add an overflow link at or under the limit", () => { + const workspaces = Array.from({ length: 5 }, (_, i) => ({ + id: `ws-${i}`, + status: "stopped", + })) + const items = buildTrayMenuTemplate(model({ workspaces }), makeActions()) + expect( + items.some((item) => item.label?.includes("View All")), + ).toBe(false) + }) + + it("exposes Stop for running workspaces with text labels and state glyphs", () => { + const actions = makeActions() + const items = buildTrayMenuTemplate( + model({ workspaces: [{ id: "api", status: "running" }] }), actions, ) - const stop = ( - (pending[2].submenu as Array>)[0] - .submenu as Array> - )[1] - expect(stop).toMatchObject({ label: "Stopping…", enabled: false }) + const row = items.find((item) => item.label === "● api — Running") + expect(row).toBeDefined() + const submenu = row?.submenu as Electron.MenuItemConstructorOptions[] + const stop = submenu.find((item) => item.label === "Stop Workspace") + expect(stop).toBeDefined() + expect(stop?.enabled).not.toBe(false) + ;(stop as { click?: () => void }).click?.() + expect(actions.stopWorkspace).toHaveBeenCalledWith("api") }) - it("shows shared progress and permits stopping an active start", () => { + + it("exposes Start for stopped workspaces", () => { + const actions = makeActions() const items = buildTrayMenuTemplate( - { - activeWorkspaces: [{ id: "ws", status: "Stopped" }], - pendingStops: new Set(), + model({ workspaces: [{ id: "api", status: "stopped" }] }), + actions, + ) + const row = items.find((item) => item.label === "○ api — Stopped") + const submenu = row?.submenu as Electron.MenuItemConstructorOptions[] + const start = submenu.find((item) => item.label === "Start Workspace") + expect(start).toBeDefined() + ;(start as { click?: () => void }).click?.() + expect(actions.startWorkspace).toHaveBeenCalledWith("api") + }) + + it("offers View Logs for failed, busy, and unknown rows instead of lifecycle actions", () => { + const items = buildTrayMenuTemplate( + model({ + workspaces: [ + { id: "failed-ws", status: "stopped" }, + { id: "busy-ws", status: "busy" }, + { id: "unknown-ws" }, + ], jobs: { - ws: { - commandId: "start", + "failed-ws": { + commandId: "c1", + activity: "stopping", + state: "failed", + phase: "See workspace logs for details", + error: "boom", + }, + "busy-ws": { + commandId: "c2", activity: "starting", state: "running", - phase: "Building image", + phase: "Building", }, }, - updateStatus: { state: "idle", currentVersion: "1.0.0" }, - }, + }), + makeActions(), + ) + const row = (name: string) => + items.find((item) => item.label?.includes(name)) + ?.submenu as Electron.MenuItemConstructorOptions[] + expect(row("failed-ws").some((item) => item.label === "View Logs")).toBe(true) + expect(row("failed-ws").some((item) => item.label === "Stop Workspace")).toBe(false) + expect(row("busy-ws").some((item) => item.label === "View Logs")).toBe(true) + expect(row("busy-ws").some((item) => item.label === "Start Workspace")).toBe(false) + expect(row("unknown-ws").some((item) => item.label === "View Logs")).toBe(true) + }) + + it("marks failed rows with a failed glyph and text", () => { + const items = buildTrayMenuTemplate( + model({ + workspaces: [{ id: "api", status: "stopped" }], + jobs: { + api: { + commandId: "c1", + activity: "stopping", + state: "failed", + phase: "See workspace logs for details", + error: "boom", + }, + }, + }), + makeActions(), + ) + expect(items.some((item) => item.label === "✖ api — Stop failed")).toBe(true) + }) + + it("disables Stop while a stop is pending", () => { + const items = buildTrayMenuTemplate( + model({ + workspaces: [{ id: "api", status: "running" }], + pendingStops: new Set(["api"]), + }), + makeActions(), + ) + const row = items.find((item) => item.label?.includes("api")) + const submenu = row?.submenu as Electron.MenuItemConstructorOptions[] + const stop = submenu.find((item) => item.label?.includes("Stop")) + expect(stop?.enabled).toBe(false) + }) + + it("disables Start while a start is pending", () => { + const items = buildTrayMenuTemplate( + model({ + workspaces: [{ id: "api", status: "stopped" }], + pendingStarts: new Set(["api"]), + }), + makeActions(), + ) + const row = items.find((item) => item.label === "○ api — Stopped") + const submenu = row?.submenu as Electron.MenuItemConstructorOptions[] + const start = submenu.find((item) => item.label?.includes("Start")) + expect(start?.enabled).toBe(false) + expect(start?.click).toBeUndefined() + }) + + it("mirrors the startup toggles as native checkboxes", () => { + const actions = makeActions() + const items = buildTrayMenuTemplate( + model({ + settings: { + runAtStartup: true, + openToTrayOnStartup: false, + trayNotifications: "failures", + }, + }), actions, ) - const workspace = (items[2].submenu as Array>)[0] - expect(workspace.label).toBe("ws — Starting") + const prefs = items.find((item) => item.label === "Preferences") + const submenu = prefs?.submenu as Electron.MenuItemConstructorOptions[] + const runAtStartup = submenu.find((item) => item.label === "Run at Startup") + const openToTray = submenu.find( + (item) => item.label === "Open to Tray on Startup", + ) + expect(runAtStartup).toMatchObject({ type: "checkbox", checked: true }) + expect(openToTray).toMatchObject({ + type: "checkbox", + checked: false, + enabled: true, + }) + ;(runAtStartup as { click?: () => void }).click?.() + expect(actions.toggleRunAtStartup).toHaveBeenCalledTimes(1) + ;(openToTray as { click?: () => void }).click?.() + expect(actions.toggleOpenToTray).toHaveBeenCalledTimes(1) + }) + + it("disables the dependent toggle when run at startup is off", () => { + const items = buildTrayMenuTemplate( + model({ + settings: { + runAtStartup: false, + openToTrayOnStartup: false, + trayNotifications: "failures", + }, + }), + makeActions(), + ) + const prefs = items.find((item) => item.label === "Preferences") + const submenu = prefs?.submenu as Electron.MenuItemConstructorOptions[] + expect( + submenu.find((item) => item.label === "Open to Tray on Startup")?.enabled, + ).toBe(false) + }) + + it("links into DevSy settings from the preferences submenu", () => { + const actions = makeActions() + const items = buildTrayMenuTemplate(model({}), actions) + const prefs = items.find((item) => item.label === "Preferences") + const submenu = prefs?.submenu as Electron.MenuItemConstructorOptions[] + const openSettings = submenu.find((item) => item.label === "Open Settings…") + ;(openSettings as { click?: () => void }).click?.() + expect(actions.showSettings).toHaveBeenCalledTimes(1) + }) + + it("keeps update items and Quit", () => { + const items = buildTrayMenuTemplate( + model({ + updateStatus: { + state: "downloaded", + currentVersion: "1.0.0", + availableVersion: "9.9.9", + }, + }), + makeActions(), + ) + expect(labels(items)).toContain("Update to 9.9.9") + expect(labels(items)).toContain("Quit Devsy") + }) +}) + +describe("trayWorkspaceState", () => { + it("maps statuses and jobs to row states", async () => { + const { trayWorkspaceState } = await import("../tray.js") + expect(trayWorkspaceState({ id: "a", status: "running" })).toBe("running") + expect(trayWorkspaceState({ id: "a", status: "stopped" })).toBe("stopped") + expect( + trayWorkspaceState({ id: "a", status: '{"state":"stopped"}' }), + ).toBe("stopped") + expect(trayWorkspaceState({ id: "a" })).toBe("unknown") + expect( + trayWorkspaceState({ id: "a", status: "stopped" }, { + commandId: "c", + activity: "starting", + state: "running", + phase: "Building", + }), + ).toBe("busy") expect( - (workspace.submenu as Array>)[1], - ).toMatchObject({ label: "Stop Workspace", enabled: true }) + trayWorkspaceState({ id: "a", status: "stopped" }, { + commandId: "c", + activity: "starting", + state: "failed", + phase: "", + error: "x", + }), + ).toBe("failed") }) }) From 89c9062430e9665833dcad4ec422f2113ef98065 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 21 Sep 2026 15:13:36 -0600 Subject: [PATCH 17/28] feat(desktop): load desktop settings at startup Signed-off-by: Samuel K --- desktop/src/renderer/src/App.svelte | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/desktop/src/renderer/src/App.svelte b/desktop/src/renderer/src/App.svelte index f23e58901..e43ade120 100644 --- a/desktop/src/renderer/src/App.svelte +++ b/desktop/src/renderer/src/App.svelte @@ -16,7 +16,9 @@ import { initContexts, destroyContexts } from "$lib/stores/contexts.js" import { initSecrets } from "$lib/stores/secrets.js" import { initEnv } from "$lib/stores/env.js" import { + initDesktopSettingsListener, initSettings, + syncDesktopSettingsFromMain, syncAutoUpdateFromMain, autoUpdate, } from "$lib/stores/settings.js" @@ -73,6 +75,7 @@ const routes = { } let destroySettings: (() => void) | undefined +let unsubDesktopSettings: (() => void) | undefined let updateDialogOpen = $state(false) let unsubscribeToasts: (() => void) | null = null @@ -175,6 +178,10 @@ onMount(async () => { await initUpdateStore() await syncAutoUpdateFromMain() + await syncDesktopSettingsFromMain() + const desktopSettingsUnlisten = await initDesktopSettingsListener() + if (destroyed) desktopSettingsUnlisten() + else unsubDesktopSettings = desktopSettingsUnlisten unsubscribeToasts = initUpdateToasts(() => { let value = true autoUpdate.subscribe((v) => (value = v))() @@ -185,6 +192,7 @@ onMount(async () => { onDestroy(() => { destroyed = true + unsubDesktopSettings?.() unsubscribeToasts?.() disposeUpdateStore() stopSessionTracking?.() From db358830f7259692896fb663c1d3c2c156991e55 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 21 Sep 2026 15:14:03 -0600 Subject: [PATCH 18/28] feat(desktop): add app settings and workspace start commands Signed-off-by: Samuel K --- desktop/src/renderer/src/lib/ipc/commands.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/desktop/src/renderer/src/lib/ipc/commands.ts b/desktop/src/renderer/src/lib/ipc/commands.ts index d735341f1..5ddc27e7f 100644 --- a/desktop/src/renderer/src/lib/ipc/commands.ts +++ b/desktop/src/renderer/src/lib/ipc/commands.ts @@ -441,3 +441,21 @@ export function analyticsTrack( ): void { invoke("analytics_track", { name, properties }).catch(() => {}) } + +// Desktop app settings (owned by the main process) +export async function getAppSettings(): Promise< + import("$shared/app-settings.js").AppSettingsState +> { + return invoke( + "get_app_settings", + ) +} + +export async function setAppSettings( + patch: Partial, +): Promise { + return invoke( + "set_app_settings", + { patch }, + ) +} From b65967e4be5be7b82b3a9e0d43185bf231b10593 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 21 Sep 2026 15:14:29 -0600 Subject: [PATCH 19/28] Update events.ts Signed-off-by: Samuel K --- desktop/src/renderer/src/lib/ipc/events.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/desktop/src/renderer/src/lib/ipc/events.ts b/desktop/src/renderer/src/lib/ipc/events.ts index b56bd440c..26d3d0802 100644 --- a/desktop/src/renderer/src/lib/ipc/events.ts +++ b/desktop/src/renderer/src/lib/ipc/events.ts @@ -92,6 +92,7 @@ export const EVENT_NAMES = { COMMAND_PROGRESS: "command-progress", WORKSPACE_STATUS: "workspace-status", UPDATE_STATUS: "update-status", + APP_SETTINGS_CHANGED: "app-settings-changed", } as const interface WorkspacesPayload { @@ -182,3 +183,14 @@ export function onUpdateStatus( callback(event.payload) }) } + +export function onAppSettingsChanged( + callback: (state: import("$shared/app-settings.js").AppSettingsState) => void, +): Promise { + return listen( + EVENT_NAMES.APP_SETTINGS_CHANGED, + (event) => { + callback(event.payload) + }, + ) +} From 47138494e30efc7e949d87d05f9eeda10e8ddd3a Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 21 Sep 2026 15:14:56 -0600 Subject: [PATCH 20/28] test(desktop): mock app settings and workspace start commands Signed-off-by: Samuel K --- desktop/src/renderer/src/lib/ipc/mock.ts | 45 ++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/desktop/src/renderer/src/lib/ipc/mock.ts b/desktop/src/renderer/src/lib/ipc/mock.ts index aebe97087..1bf0771f0 100644 --- a/desktop/src/renderer/src/lib/ipc/mock.ts +++ b/desktop/src/renderer/src/lib/ipc/mock.ts @@ -107,6 +107,27 @@ const MACHINES: Machine[] = [ }, ] +let MOCK_APP_SETTINGS = { + settings: { + runAtStartup: false, + openToTrayOnStartup: false, + trayNotifications: "failures", + }, + startup: { applied: true, enabled: false, status: "disabled" }, +} as { + settings: { + runAtStartup: boolean + openToTrayOnStartup: boolean + trayNotifications: "off" | "failures" | "all" + } + startup: { + applied: boolean + enabled: boolean + status: "enabled" | "disabled" | "denied" | "unavailable" | "error" + detail?: string + } +} + const CONTEXTS: Context[] = [{ name: "default" }, { name: "staging" }] const SSH_KEYS: SshKeyInfo[] = [ @@ -283,6 +304,30 @@ const COMMANDS: Record = { image_inspect_platforms: () => ["linux/amd64", "linux/arm64"], + // App settings (startup + tray notifications) + get_app_settings: () => MOCK_APP_SETTINGS, + set_app_settings: (args) => { + const patch = (args?.patch ?? {}) as Partial + MOCK_APP_SETTINGS = { + settings: { + ...MOCK_APP_SETTINGS.settings, + ...patch, + openToTrayOnStartup: + (patch.runAtStartup ?? MOCK_APP_SETTINGS.settings.runAtStartup) === false + ? false + : (patch.openToTrayOnStartup ?? MOCK_APP_SETTINGS.settings.openToTrayOnStartup), + }, + startup: { + applied: true, + enabled: patch.runAtStartup ?? MOCK_APP_SETTINGS.settings.runAtStartup, + status: (patch.runAtStartup ?? MOCK_APP_SETTINGS.settings.runAtStartup) + ? ("enabled" as const) + : ("disabled" as const), + }, + } + return MOCK_APP_SETTINGS + }, + // Release channel get_release_channel: () => "stable", set_release_channel: () => undefined, From 0719798b165738decd894635042abfae35f02e6e Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 21 Sep 2026 15:15:23 -0600 Subject: [PATCH 21/28] feat(desktop): add main-owned desktop settings stores Signed-off-by: Samuel K --- .../src/renderer/src/lib/stores/settings.ts | 69 ++++++++++++++++++- 1 file changed, 68 insertions(+), 1 deletion(-) diff --git a/desktop/src/renderer/src/lib/stores/settings.ts b/desktop/src/renderer/src/lib/stores/settings.ts index bce06b10d..dbdd71f4f 100644 --- a/desktop/src/renderer/src/lib/stores/settings.ts +++ b/desktop/src/renderer/src/lib/stores/settings.ts @@ -1,5 +1,17 @@ import { writable } from "svelte/store" -import { getAutoDownload, setAutoDownload } from "$lib/ipc/commands.js" +import { + getAppSettings, + getAutoDownload, + setAppSettings, + setAutoDownload, +} from "$lib/ipc/commands.js" +import { onAppSettingsChanged } from "$lib/ipc/events.js" +import type { + AppSettings, + AppSettingsState, + StartupStatus, + TrayNotificationLevel, +} from "$shared/app-settings.js" const browser = typeof window !== "undefined" @@ -347,6 +359,61 @@ export function parseContextOptions( } } +// ── Desktop settings (main-process owned) ─────────────────────────── +// Startup and tray notification preferences live in the main process so the +// tray and Settings always share one truth. Main pushes +// app-settings-changed when the tray toggles them; stores apply the payload +// unless a renderer write is in flight. +export const runAtStartup = writable(false) +export const openToTrayOnStartup = writable(false) +export const trayNotifications = writable("failures") +export const startupStatus = writable(null) + +let desktopSettingsWriteInFlight = false + +function applyAppSettingsState(state: AppSettingsState): void { + runAtStartup.set(state.settings.runAtStartup) + openToTrayOnStartup.set(state.settings.openToTrayOnStartup) + trayNotifications.set(state.settings.trayNotifications) + startupStatus.set(state.startup) +} + +export async function syncDesktopSettingsFromMain(): Promise { + try { + const state = await getAppSettings() + if (desktopSettingsWriteInFlight) return + applyAppSettingsState(state) + } catch (err) { + console.warn("[settings] getAppSettings failed:", err) + } +} + +export async function updateDesktopSettings( + patch: Partial, +): Promise { + desktopSettingsWriteInFlight = true + try { + applyAppSettingsState(await setAppSettings(patch)) + } catch (err) { + console.warn("[settings] setAppSettings failed:", err) + try { + applyAppSettingsState(await getAppSettings()) + } catch { + // Keep the previous store values. + } + } finally { + desktopSettingsWriteInFlight = false + } +} + +export async function initDesktopSettingsListener(): Promise<() => void> { + const unlisten = await onAppSettingsChanged((state) => { + if (desktopSettingsWriteInFlight) return + applyAppSettingsState(state) + }) + return unlisten +} + // ── Init ──────────────────────────────────────────────────────────── export function initSettings() { From f88c724f8136cc886ccb597d17fa0ed2402a6be9 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 21 Sep 2026 15:15:48 -0600 Subject: [PATCH 22/28] feat(desktop): add startup and notification settings sections Signed-off-by: Samuel K --- .../renderer/src/pages/SettingsPage.svelte | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/desktop/src/renderer/src/pages/SettingsPage.svelte b/desktop/src/renderer/src/pages/SettingsPage.svelte index e8d016acb..8bf4616de 100644 --- a/desktop/src/renderer/src/pages/SettingsPage.svelte +++ b/desktop/src/renderer/src/pages/SettingsPage.svelte @@ -19,6 +19,12 @@ import { localOptions as localOptionsStore, loadLocalOptions, saveLocalOption, + runAtStartup, + openToTrayOnStartup, + trayNotifications, + startupStatus, + syncDesktopSettingsFromMain, + updateDesktopSettings, } from "$lib/stores/settings.js" import type { Theme, @@ -26,6 +32,7 @@ import type { LocalOptions, OnBuildFailure, } from "$lib/stores/settings.js" +import type { TrayNotificationLevel } from "$shared/app-settings.js" import * as Select from "$lib/components/ui/select/index.js" import UpdatesPanel from "$lib/components/update/UpdatesPanel.svelte" import { Skeleton } from "$lib/components/ui/skeleton/index.js" @@ -121,10 +128,17 @@ const shortcuts = [ { keys: "Escape", action: "Close dialogs and palette" }, ] +const NOTIFICATION_OPTIONS: { value: TrayNotificationLevel; label: string }[] = [ + { value: "off", label: "Off" }, + { value: "failures", label: "Failures only" }, + { value: "all", label: "All terminal outcomes" }, +] + onMount(() => { local = loadLocalOptions() localOptionsStore.set(local) loading = false + void syncDesktopSettingsFromMain() }) function saveLocal(key: keyof LocalOptions, value: string | boolean) { @@ -273,6 +287,68 @@ function toggleLocal(key: keyof LocalOptions) { {/if} + +
+

Startup

+
+
+
+ +

Launch Devsy automatically after you sign in

+
+ updateDesktopSettings({ runAtStartup: v })} + disabled={loading} + /> +
+ + {#if $startupStatus?.status === "denied" || $startupStatus?.status === "error"} +

{$startupStatus.detail}

+ {/if} + +
+
+ +

Start in the system tray without opening a window. Applies only when Devsy starts automatically; clicking Devsy yourself always opens the window.

+
+ updateDesktopSettings({ openToTrayOnStartup: v })} + disabled={loading || !$runAtStartup} + /> +
+
+
+ +
+

Notifications

+
+
+
+ +

System notifications when workspace start and stop operations finish

+
+ { + if (v) updateDesktopSettings({ trayNotifications: v as TrayNotificationLevel }) + }} + > + + {NOTIFICATION_OPTIONS.find((o) => o.value === $trayNotifications)?.label ?? "Failures only"} + + + {#each NOTIFICATION_OPTIONS as o (o.value)} + + {/each} + + +
+
+
+

Appearance

From 849508408794dec2a9005b8f0ce8c52a0c829716 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 21 Sep 2026 15:16:15 -0600 Subject: [PATCH 23/28] test(desktop): cover startup and notification settings sections Signed-off-by: Samuel K --- .../renderer/src/pages/SettingsPage.test.ts | 25 ++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/desktop/src/renderer/src/pages/SettingsPage.test.ts b/desktop/src/renderer/src/pages/SettingsPage.test.ts index aae0ce7c5..a1a2a161e 100644 --- a/desktop/src/renderer/src/pages/SettingsPage.test.ts +++ b/desktop/src/renderer/src/pages/SettingsPage.test.ts @@ -12,13 +12,36 @@ describe("SettingsPage layout", () => { document.body.innerHTML = "" }) + it("renders the startup toggles with the dependent open-to-tray switch disabled", async () => { + render(SettingsPage) + + const section = document.querySelector("#startup") + expect(section).toBeTruthy() + const switches = section!.querySelectorAll('[role="switch"]') + expect(switches).toHaveLength(2) + const [runAtStartup, openToTray] = switches + expect(runAtStartup.hasAttribute("disabled")).toBe(false) + // The mock main process defaults to runAtStartup off, so the dependent + // toggle must be disabled. + await vi.waitFor(() => { + expect(openToTray.hasAttribute("disabled")).toBe(true) + }) + }) + + it("renders the notification level select", () => { + render(SettingsPage) + + expect(screen.getByText("Workspace Notifications")).toBeTruthy() + expect(screen.getByText("Failures only")).toBeTruthy() + }) + it("renders all settings sections as one page without section navigation links", () => { render(SettingsPage) expect(screen.queryByRole("navigation", { name: "Settings sections" })).toBeNull() expect(document.querySelectorAll('a[href^="#"]')).toHaveLength(0) - for (const name of ["General", "Appearance", "Updates", "Advanced"]) { + for (const name of ["General", "Startup", "Notifications", "Appearance", "Updates", "Advanced"]) { expect(screen.getByRole("heading", { name, level: 2 })).toBeTruthy() } }) From 144552b6ef1f16e78fc8a96d75e78d6d0796c7a4 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 21 Sep 2026 15:16:47 -0600 Subject: [PATCH 24/28] feat(desktop): support logs tab deep link Signed-off-by: Samuel K --- desktop/src/renderer/src/pages/WorkspaceDetailPage.svelte | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/desktop/src/renderer/src/pages/WorkspaceDetailPage.svelte b/desktop/src/renderer/src/pages/WorkspaceDetailPage.svelte index d4c585ec5..745864079 100644 --- a/desktop/src/renderer/src/pages/WorkspaceDetailPage.svelte +++ b/desktop/src/renderer/src/pages/WorkspaceDetailPage.svelte @@ -297,6 +297,10 @@ onMount(async () => { void reconcileRecoveryFromStatus() const qs = new URLSearchParams($querystring ?? "") + const tab = qs.get("tab") + if (tab === "logs" || tab === "terminal" || tab === "overview") { + activeTab = tab + } const action = qs.get("action") if (action === "open-ide" || action === "start") { // Clear query param so refresh doesn't re-trigger From f9072b6cd6f1b3817db0d9705c7d259b32cacf78 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 21 Sep 2026 15:17:12 -0600 Subject: [PATCH 25/28] test(desktop): add Xfce tray smoke script Signed-off-by: Samuel K --- desktop/scripts/tray-smoke-xfce.sh | 45 ++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 desktop/scripts/tray-smoke-xfce.sh diff --git a/desktop/scripts/tray-smoke-xfce.sh b/desktop/scripts/tray-smoke-xfce.sh new file mode 100644 index 000000000..73b3c63c5 --- /dev/null +++ b/desktop/scripts/tray-smoke-xfce.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# Tier 2 Linux smoke check: launch the built desktop app under a minimal +# Xfce session (Xvfb + xfce4-panel) and assert the tray icon registers with +# the panel's StatusNotifier watcher. +set -euo pipefail + +export DISPLAY="${DISPLAY:-:99}" + +Xvfb "$DISPLAY" -screen 0 1280x800x24 & +XVFB_PID=$! +trap 'kill "$XVFB_PID" 2>/dev/null || true' EXIT + +for _ in $(seq 1 50); do + if xdpyinfo -display "$DISPLAY" >/dev/null 2>&1; then + break + fi + sleep 0.2 +done + +dbus-run-session -- bash <<'INNER' +set -euo pipefail + +xfce4-panel & +PANEL_PID=$! + +DEVSY_DISABLE_DAEMON=true DEVSY_CLI_PATH=/bin/true npx electron . --no-sandbox & +APP_PID=$! +trap 'kill "$APP_PID" "$PANEL_PID" 2>/dev/null || true' EXIT + +# The panel needs a moment to claim org.kde.StatusNotifierWatcher. +for _ in $(seq 1 90); do + ITEMS=$(busctl --user get-property org.kde.StatusNotifierWatcher \ + /StatusNotifierWatcher org.kde.StatusNotifierWatcher \ + RegisteredStatusNotifierItems 2>/dev/null || true) + COUNT=$(printf '%s' "$ITEMS" | awk '{print $2}') + if [[ -n "$COUNT" && "$COUNT" != "0" ]]; then + echo "tray icon registered with the Xfce panel: $ITEMS" + exit 0 + fi + sleep 1 +done + +echo "tray icon did not register with the Xfce panel" >&2 +exit 1 +INNER From 0fb135470bc60744ec52e87254277edb47ff6f10 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 21 Sep 2026 15:21:20 -0600 Subject: [PATCH 26/28] ci(desktop): add Xfce tray smoke job Signed-off-by: Samuel K --- .github/workflows/desktop-ci.yml | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/.github/workflows/desktop-ci.yml b/.github/workflows/desktop-ci.yml index 3fe0bc07c..2f0b86ff8 100644 --- a/.github/workflows/desktop-ci.yml +++ b/.github/workflows/desktop-ci.yml @@ -175,6 +175,37 @@ jobs: desktop/release/*.rpm if-no-files-found: warn + tray-smoke-xfce: + needs: [changes, lint-and-test] + if: needs.changes.outputs.desktop == 'true' + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + with: + node-version: 24 + cache: npm + cache-dependency-path: desktop/package-lock.json + + - name: Install Xfce panel and Xvfb + run: | + sudo apt-get update + sudo apt-get install -y xvfb xfce4-panel dbus-x11 x11-utils + + - name: Install dependencies + working-directory: desktop + run: npm ci + + - name: Build desktop app + working-directory: desktop + run: npm run electron:build + + - name: Assert tray icon registers with the Xfce panel + working-directory: desktop + run: bash scripts/tray-smoke-xfce.sh + build-flatpak: needs: [changes, build-desktop] if: needs.changes.outputs.desktop == 'true' From 27119c9023f34476909d9f2630b86533c72ef451 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 21 Sep 2026 15:27:03 -0600 Subject: [PATCH 27/28] chore(desktop): update lockfile for dbus-next Signed-off-by: Samuel K --- desktop/package-lock.json | 1108 +++++++++++++++++++++++++++++++++---- 1 file changed, 1002 insertions(+), 106 deletions(-) diff --git a/desktop/package-lock.json b/desktop/package-lock.json index 445e126d2..d05e72544 100644 --- a/desktop/package-lock.json +++ b/desktop/package-lock.json @@ -10,6 +10,7 @@ "hasInstallScript": true, "dependencies": { "chokidar": "^5.0.0", + "dbus-next": "^0.10.2", "dompurify": "^3.4.7", "electron-updater": "^6.8.3", "node-pty": "^1.0.0", @@ -1493,9 +1494,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1505,6 +1503,15 @@ "node": "^22.20 || ^24.12 || >=25" } }, + "node_modules/@nornagon/put": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/@nornagon/put/-/put-0.0.8.tgz", + "integrity": "sha512-ugvXJjwF5ldtUpa7D95kruNJ41yFQDEKyF5CW4TgKJnh+W/zmlBzXXeKTyqIgwMFrkePN2JqOBqcF0M0oOunow==", + "license": "MIT/X11", + "engines": { + "node": ">=0.3.0" + } + }, "node_modules/@peculiar/asn1-schema": { "version": "2.9.4", "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.9.4.tgz", @@ -1683,9 +1690,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1700,9 +1704,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1717,9 +1718,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1734,9 +1732,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1751,9 +1746,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1768,9 +1760,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1785,9 +1774,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1802,9 +1788,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1819,9 +1802,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1836,9 +1816,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1853,9 +1830,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1870,9 +1844,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1887,9 +1858,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2214,9 +2182,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2234,9 +2199,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2254,9 +2216,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2274,9 +2233,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2853,7 +2809,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=8" @@ -3077,6 +3033,25 @@ "node": "^18.17.0 || >=20.5.0" } }, + "node_modules/aproba": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/aproba/-/aproba-1.2.0.tgz", + "integrity": "sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw==", + "license": "ISC", + "optional": true + }, + "node_modules/are-we-there-yet": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-1.1.7.tgz", + "integrity": "sha512-nxwy40TuMiUGqMyRHgCSWZ9FM4VAoRP4xUYSTv5ImRog+h9yISPbVH7H8fASCIzYn9wlEv4zvFL7uKDMCFQm3g==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "optional": true, + "dependencies": { + "delegates": "^1.0.0", + "readable-stream": "^2.0.6" + } + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -3093,6 +3068,16 @@ "dequal": "^2.0.3" } }, + "node_modules/asn1": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", + "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "safer-buffer": "~2.1.0" + } + }, "node_modules/asn1js": { "version": "3.0.10", "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz", @@ -3108,6 +3093,16 @@ "node": ">=12.0.0" } }, + "node_modules/assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.8" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -3139,7 +3134,7 @@ "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/at-least-node": { @@ -3152,11 +3147,21 @@ "node": ">= 4.0.0" } }, + "node_modules/aws-sign2": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz", + "integrity": "sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": "*" + } + }, "node_modules/aws4": { "version": "1.13.2", "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.13.2.tgz", "integrity": "sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/axobject-query": { @@ -3212,6 +3217,16 @@ "node": ">=6.0.0" } }, + "node_modules/bcrypt-pbkdf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", + "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "tweetnacl": "^0.14.3" + } + }, "node_modules/bidi-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", @@ -3222,6 +3237,16 @@ "require-from-string": "^2.0.2" } }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, "node_modules/bits-ui": { "version": "2.19.0", "resolved": "https://registry.npmjs.org/bits-ui/-/bits-ui-2.19.0.tgz", @@ -3440,6 +3465,13 @@ ], "license": "CC-BY-4.0" }, + "node_modules/caseless": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", + "integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==", + "license": "Apache-2.0", + "optional": true + }, "node_modules/chai": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", @@ -3552,6 +3584,16 @@ "node": ">=6" } }, + "node_modules/code-point-at": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz", + "integrity": "sha512-RpAVKQA5T63xEj6/giIbUEtZwJ4UFIc3ZtvEkiaUERylqe8xb5IvqcgOurZLahv93CLKfxcw5YI+DZcUBRyLXA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -3576,7 +3618,7 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "delayed-stream": "~1.0.0" @@ -3609,9 +3651,16 @@ "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, + "devOptional": true, "license": "MIT" }, + "node_modules/console-control-strings": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", + "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", + "license": "ISC", + "optional": true + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -3623,7 +3672,7 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/cross-dirname": { @@ -3671,6 +3720,19 @@ "dev": true, "license": "MIT" }, + "node_modules/dashdash": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", + "integrity": "sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==", + "license": "MIT", + "optional": true, + "dependencies": { + "assert-plus": "^1.0.0" + }, + "engines": { + "node": ">=0.10" + } + }, "node_modules/data-urls": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", @@ -3700,6 +3762,24 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/dbus-next": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/dbus-next/-/dbus-next-0.10.2.tgz", + "integrity": "sha512-kLNQoadPstLgKKGIXKrnRsMgtAK/o+ix3ZmcfTfvBHzghiO9yHXpoKImGnB50EXwnfSFaSAullW/7UrSkAISSQ==", + "license": "MIT", + "dependencies": { + "@nornagon/put": "0.0.8", + "event-stream": "3.3.4", + "hexy": "^0.2.10", + "jsbi": "^2.0.5", + "long": "^4.0.0", + "safe-buffer": "^5.1.1", + "xml2js": "^0.4.17" + }, + "optionalDependencies": { + "usocket": "^0.3.0" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -3815,12 +3895,19 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=0.4.0" } }, + "node_modules/delegates": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", + "license": "MIT", + "optional": true + }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -3970,6 +4057,12 @@ "node": ">= 0.4" } }, + "node_modules/duplexer": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz", + "integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==", + "license": "MIT" + }, "node_modules/duplexer2": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", @@ -3980,6 +4073,17 @@ "readable-stream": "^2.0.2" } }, + "node_modules/ecc-jsbn": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", + "integrity": "sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw==", + "license": "MIT", + "optional": true, + "dependencies": { + "jsbn": "~0.1.0", + "safer-buffer": "^2.1.0" + } + }, "node_modules/ejs": { "version": "3.1.10", "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", @@ -4201,7 +4305,7 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/end-of-stream": { @@ -4424,6 +4528,21 @@ "@types/estree": "^1.0.0" } }, + "node_modules/event-stream": { + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/event-stream/-/event-stream-3.3.4.tgz", + "integrity": "sha512-QHpkERcGsR0T7Qm3HNJSyXKEEj8AHNxkY3PK8TS2KJvQ7NiSHe3DDpwVKKtoYprL/AreyzFBeIkBIWChAqn60g==", + "license": "MIT", + "dependencies": { + "duplexer": "~0.1.1", + "from": "~0", + "map-stream": "~0.1.0", + "pause-stream": "0.0.11", + "split": "0.3", + "stream-combiner": "~0.0.4", + "through": "~2.3.1" + } + }, "node_modules/expect-type": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", @@ -4441,13 +4560,37 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT", + "optional": true + }, + "node_modules/extsprintf": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz", + "integrity": "sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g==", + "engines": [ + "node >=0.6.0" + ], + "license": "MIT", + "optional": true + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, + "devOptional": true, "license": "MIT" }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "license": "MIT", + "optional": true + }, "node_modules/fast-uri": { "version": "3.1.7", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz", @@ -4483,6 +4626,13 @@ } } }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT", + "optional": true + }, "node_modules/filelist": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz", @@ -4523,6 +4673,16 @@ "node": ">=10" } }, + "node_modules/forever-agent": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", + "integrity": "sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": "*" + } + }, "node_modules/form-data": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", @@ -4540,6 +4700,12 @@ "node": ">= 6" } }, + "node_modules/from": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/from/-/from-0.1.7.tgz", + "integrity": "sha512-twe20eF1OxVxp/ML/kq2p1uc6KvFK/+vs8WjEbeKmV2He22MKm7YF2ANIt+EOqhJ5L3K/SuuPhk0hWQDjOM23g==", + "license": "MIT" + }, "node_modules/fs-extra": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", @@ -4554,11 +4720,44 @@ "node": ">=12" } }, + "node_modules/fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "license": "ISC", + "optional": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/fs-minipass/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fs-minipass/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC", + "optional": true + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true, + "devOptional": true, "license": "ISC" }, "node_modules/fsevents": { @@ -4586,6 +4785,75 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/gauge": { + "version": "2.7.4", + "resolved": "https://registry.npmjs.org/gauge/-/gauge-2.7.4.tgz", + "integrity": "sha512-14x4kjc6lkD3ltw589k0NrPD6cCNTD6CWoVUNpB85+DrtONoZn+Rug6xZU5RvSC4+TZPxA5AnBibQYAvZn41Hg==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "optional": true, + "dependencies": { + "aproba": "^1.0.3", + "console-control-strings": "^1.0.0", + "has-unicode": "^2.0.0", + "object-assign": "^4.1.0", + "signal-exit": "^3.0.0", + "string-width": "^1.0.1", + "strip-ansi": "^3.0.1", + "wide-align": "^1.1.0" + } + }, + "node_modules/gauge/node_modules/ansi-regex": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", + "integrity": "sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/gauge/node_modules/is-fullwidth-code-point": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz", + "integrity": "sha512-1pqUqRjkhPJ9miNq9SwMfdvi6lBJcd6eFxvfaivQhaH3SgisfiuudvFntdKOmxuee/77l+FPjKrQjWvmPjWrRw==", + "license": "MIT", + "optional": true, + "dependencies": { + "number-is-nan": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/gauge/node_modules/string-width": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz", + "integrity": "sha512-0XsVpQLnVCXHJfyEs8tC0zpTVIr5PKKsQtkT29IwupnPTjtPmQ3xT/4yCREF9hYkV/3M3kzcUTSAZT6a6h81tw==", + "license": "MIT", + "optional": true, + "dependencies": { + "code-point-at": "^1.0.0", + "is-fullwidth-code-point": "^1.0.0", + "strip-ansi": "^3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/gauge/node_modules/strip-ansi": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", + "integrity": "sha512-VhumSSbBqDTP8p2ZLKj40UjBCV4+v8bUSEpUb4KjRgWk9pbqGF4REFj6KEagidb2f/M6AzC0EmFyDNGaw9OCzg==", + "license": "MIT", + "optional": true, + "dependencies": { + "ansi-regex": "^2.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -4661,12 +4929,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/getpass": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", + "integrity": "sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng==", + "license": "MIT", + "optional": true, + "dependencies": { + "assert-plus": "^1.0.0" + } + }, "node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "dev": true, + "devOptional": true, "license": "ISC", "dependencies": { "fs.realpath": "^1.0.0", @@ -4687,14 +4965,14 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/glob/node_modules/brace-expansion": { "version": "1.1.18", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -4705,7 +4983,7 @@ "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, + "devOptional": true, "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" @@ -4796,6 +5074,55 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, + "node_modules/har-schema": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz", + "integrity": "sha512-Oqluz6zhGX8cyRaTQlFMPw80bSJVG2x/cFb8ZPhUILGgHka9SsokCCOQgpveePerqidZOrT14ipqfJb7ILcW5Q==", + "license": "ISC", + "optional": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/har-validator": { + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/har-validator/-/har-validator-5.1.5.tgz", + "integrity": "sha512-nmT2T0lljbxdQZfspsno9hgrG3Uir6Ks5afism62poxqBM6sDnMEuPmzTq8XN0OEwqKLLdh1jQI3qyE66Nzb3w==", + "deprecated": "this library is no longer supported", + "license": "MIT", + "optional": true, + "dependencies": { + "ajv": "^6.12.3", + "har-schema": "^2.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/har-validator/node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "license": "MIT", + "optional": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/har-validator/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "license": "MIT", + "optional": true + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -4849,6 +5176,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-unicode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", + "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", + "license": "ISC", + "optional": true + }, "node_modules/hasown": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", @@ -4862,6 +5196,15 @@ "node": ">= 0.4" } }, + "node_modules/hexy": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/hexy/-/hexy-0.2.11.tgz", + "integrity": "sha512-ciq6hFsSG/Bpt2DmrZJtv+56zpPdnq+NQ4ijEFrveKN0ZG1mhl/LdT1NQZ9se6ty1fACcI4d4vYqC9v8EYpH2A==", + "license": "MIT", + "bin": { + "hexy": "bin/hexy_cmd.js" + } + }, "node_modules/hosted-git-info": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", @@ -4929,11 +5272,27 @@ "node": ">= 14" } }, - "node_modules/http2-wrapper": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz", - "integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==", - "dev": true, + "node_modules/http-signature": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz", + "integrity": "sha512-CAbnr6Rz4CYQkLYUtSNXxQPUH2gK8f3iWexVlsnMeD+GjlsQ0Xsy1cOX+mN3dtxYomRy21CiOzU8Uhw6OwncEQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "assert-plus": "^1.0.0", + "jsprim": "^1.2.2", + "sshpk": "^1.7.0" + }, + "engines": { + "node": ">=0.8", + "npm": ">=1.3.7" + } + }, + "node_modules/http2-wrapper": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz", + "integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==", + "dev": true, "license": "MIT", "dependencies": { "quick-lru": "^5.1.1", @@ -4972,7 +5331,7 @@ "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", - "dev": true, + "devOptional": true, "license": "ISC", "dependencies": { "once": "^1.3.0", @@ -4983,7 +5342,7 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true, + "devOptional": true, "license": "ISC" }, "node_modules/inline-style-parser": { @@ -4997,7 +5356,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=8" @@ -5019,11 +5378,18 @@ "@types/estree": "^1.0.6" } }, + "node_modules/is-typedarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", + "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==", + "license": "MIT", + "optional": true + }, "node_modules/isarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/isbinaryfile": { @@ -5043,9 +5409,16 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, + "devOptional": true, "license": "ISC" }, + "node_modules/isstream": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", + "integrity": "sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==", + "license": "MIT", + "optional": true + }, "node_modules/jake": { "version": "10.9.4", "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz", @@ -5103,6 +5476,18 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsbi": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/jsbi/-/jsbi-2.0.5.tgz", + "integrity": "sha512-TzO/62Hxeb26QMb4IGlI/5X+QLr9Uqp1FPkwp2+KOICW+Q+vSuFj61c8pkT6wAns4WcK56X7CmSHhJeDGWOqxQ==" + }, + "node_modules/jsbn": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", + "integrity": "sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==", + "license": "MIT", + "optional": true + }, "node_modules/jsdom": { "version": "30.0.1", "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.0.1.tgz", @@ -5184,6 +5569,13 @@ "dev": true, "license": "MIT" }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "license": "(AFL-2.1 OR BSD-3-Clause)", + "optional": true + }, "node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", @@ -5195,7 +5587,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", - "dev": true, "license": "ISC", "optional": true }, @@ -5224,6 +5615,22 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/jsprim": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.2.tgz", + "integrity": "sha512-P2bSOMAc/ciLz6DzgjVlGJP9+BrJWu5UDGK70C2iweC5QBIeFf0ZXRvGjEj2uYgrY2MkAAhsSWHDWlFtEroZWw==", + "license": "MIT", + "optional": true, + "dependencies": { + "assert-plus": "1.0.0", + "extsprintf": "1.3.0", + "json-schema": "0.4.0", + "verror": "1.10.0" + }, + "engines": { + "node": ">=0.6.0" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -5383,9 +5790,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5407,9 +5811,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5431,9 +5832,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5455,9 +5853,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5539,6 +5934,12 @@ "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", "license": "MIT" }, + "node_modules/long": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz", + "integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==", + "license": "Apache-2.0" + }, "node_modules/lowercase-keys": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", @@ -5578,6 +5979,11 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/map-stream": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/map-stream/-/map-stream-0.1.0.tgz", + "integrity": "sha512-CkYQrPYZfWnu/DAmVCpTSX/xHpKZ80eKh2lAkyA6AJTef6bW+6JpbQZN5rofum7da+SyN1bi5ctTm+lTfcCW3g==" + }, "node_modules/matcher": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", @@ -5626,7 +6032,7 @@ "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -5636,7 +6042,7 @@ "version": "2.1.35", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "mime-db": "1.52.0" @@ -5744,6 +6150,13 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/nan": { + "version": "2.29.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.29.0.tgz", + "integrity": "sha512-GlGk3HIvitbvs+LT3g6XUP1kpirKNvmDFwF/bmo6XNWSb/eYEs/O4bfgIEIXCZ+lIOTS5xNwDvSGMw6FJdAhtA==", + "license": "MIT", + "optional": true + }, "node_modules/nanoid": { "version": "3.3.16", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", @@ -5919,6 +6332,50 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/npmlog": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-4.1.2.tgz", + "integrity": "sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "optional": true, + "dependencies": { + "are-we-there-yet": "~1.1.2", + "console-control-strings": "~1.1.0", + "gauge": "~2.7.3", + "set-blocking": "~2.0.0" + } + }, + "node_modules/number-is-nan": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz", + "integrity": "sha512-4jbtZXNAsfZbAHiiqjLPBiCl16dES1zI4Hpzzxw61Tk+loF+sBDBKx1ICKKKwIqQ7M0mFn1TmkN7euSncWgHiQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/oauth-sign": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz", + "integrity": "sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": "*" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/object-keys": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", @@ -5948,7 +6405,7 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, + "devOptional": true, "license": "ISC", "dependencies": { "wrappy": "1" @@ -6065,7 +6522,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -6088,6 +6545,18 @@ "dev": true, "license": "MIT" }, + "node_modules/pause-stream": { + "version": "0.0.11", + "resolved": "https://registry.npmjs.org/pause-stream/-/pause-stream-0.0.11.tgz", + "integrity": "sha512-e3FBlXLmN/D1S+zHzanP4E/4Z60oFAa3O051qt1pxa7DEJWKAyil6upYVXCWadEnuoqa4Pkc9oUx9zsxYeRv8A==", + "license": [ + "MIT", + "Apache2" + ], + "dependencies": { + "through": "~2.3" + } + }, "node_modules/pe-library": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/pe-library/-/pe-library-0.4.1.tgz", @@ -6103,6 +6572,13 @@ "url": "https://github.com/sponsors/jet2jet" } }, + "node_modules/performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", + "license": "MIT", + "optional": true + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -6322,7 +6798,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/progress": { @@ -6361,6 +6837,19 @@ "signal-exit": "^3.0.2" } }, + "node_modules/psl": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", + "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", + "license": "MIT", + "optional": true, + "dependencies": { + "punycode": "^2.3.1" + }, + "funding": { + "url": "https://github.com/sponsors/lupomontero" + } + }, "node_modules/pump": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", @@ -6376,7 +6865,7 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6" @@ -6402,6 +6891,16 @@ "node": ">=16.0.0" } }, + "node_modules/qs": { + "version": "6.5.5", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.5.tgz", + "integrity": "sha512-mzR4sElr1bfCaPJe7m8ilJ6ZXdDaGoObcYR0ZHSsktM/Lt21MVHj5De30GQH2eiZ1qGRTO7LCAzQsUeXTNexWQ==", + "license": "BSD-3-Clause", + "optional": true, + "engines": { + "node": ">=0.6" + } + }, "node_modules/quick-lru": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", @@ -6439,7 +6938,7 @@ "version": "2.3.8", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "core-util-is": "~1.0.0", @@ -6487,6 +6986,68 @@ "node": ">=8" } }, + "node_modules/request": { + "version": "2.88.2", + "resolved": "https://registry.npmjs.org/request/-/request-2.88.2.tgz", + "integrity": "sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw==", + "deprecated": "request has been deprecated, see https://github.com/request/request/issues/3142", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "aws-sign2": "~0.7.0", + "aws4": "^1.8.0", + "caseless": "~0.12.0", + "combined-stream": "~1.0.6", + "extend": "~3.0.2", + "forever-agent": "~0.6.1", + "form-data": "~2.3.2", + "har-validator": "~5.1.3", + "http-signature": "~1.2.0", + "is-typedarray": "~1.0.0", + "isstream": "~0.1.2", + "json-stringify-safe": "~5.0.1", + "mime-types": "~2.1.19", + "oauth-sign": "~0.9.0", + "performance-now": "^2.1.0", + "qs": "~6.5.2", + "safe-buffer": "^5.1.2", + "tough-cookie": "~2.5.0", + "tunnel-agent": "^0.6.0", + "uuid": "^3.3.2" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/request/node_modules/form-data": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.3.tgz", + "integrity": "sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.6", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 0.12" + } + }, + "node_modules/request/node_modules/tough-cookie": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz", + "integrity": "sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==", + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "psl": "^1.1.28", + "punycode": "^2.1.1" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -6677,9 +7238,15 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "dev": true, "license": "MIT" }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT", + "optional": true + }, "node_modules/sanitize-filename": { "version": "1.6.4", "resolved": "https://registry.npmjs.org/sanitize-filename/-/sanitize-filename-1.6.4.tgz", @@ -6749,6 +7316,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC", + "optional": true + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -6783,7 +7357,7 @@ "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "dev": true, + "devOptional": true, "license": "ISC" }, "node_modules/simple-update-notifier": { @@ -6830,6 +7404,18 @@ "source-map": "^0.6.0" } }, + "node_modules/split": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/split/-/split-0.3.3.tgz", + "integrity": "sha512-wD2AeVmxXRBoX44wAycgjVpMhvbwdI2aZjCkvfNcH1YqHQvJVa1duWc73OyVGJUc05fhFaTZeQ/PYsrmyH0JVA==", + "license": "MIT", + "dependencies": { + "through": "2" + }, + "engines": { + "node": "*" + } + }, "node_modules/sprintf-js": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", @@ -6838,6 +7424,32 @@ "license": "BSD-3-Clause", "optional": true }, + "node_modules/sshpk": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.18.0.tgz", + "integrity": "sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "asn1": "~0.2.3", + "assert-plus": "^1.0.0", + "bcrypt-pbkdf": "^1.0.0", + "dashdash": "^1.12.0", + "ecc-jsbn": "~0.1.1", + "getpass": "^0.1.1", + "jsbn": "~0.1.0", + "safer-buffer": "^2.0.2", + "tweetnacl": "~0.14.0" + }, + "bin": { + "sshpk-conv": "bin/sshpk-conv", + "sshpk-sign": "bin/sshpk-sign", + "sshpk-verify": "bin/sshpk-verify" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -6862,11 +7474,20 @@ "dev": true, "license": "MIT" }, + "node_modules/stream-combiner": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/stream-combiner/-/stream-combiner-0.0.4.tgz", + "integrity": "sha512-rT00SPnTVyRsaSz5zgSPma/aHSOic5U1prhYdRy5HS2kTZviFpmDgzilbtsJsxiroqACmayynDN/9VzIbX5DOw==", + "license": "MIT", + "dependencies": { + "duplexer": "~0.1.1" + } + }, "node_modules/string_decoder": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "safe-buffer": "~5.1.0" @@ -6876,7 +7497,7 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -6891,7 +7512,7 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -7227,6 +7848,12 @@ "fs-extra": "^10.0.0" } }, + "node_modules/through": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", + "license": "MIT" + }, "node_modules/tiny-async-pool": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/tiny-async-pool/-/tiny-async-pool-1.3.0.tgz", @@ -7380,6 +8007,19 @@ "dev": true, "license": "0BSD" }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, "node_modules/tw-animate-css": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz", @@ -7390,6 +8030,13 @@ "url": "https://github.com/sponsors/Wombosvideo" } }, + "node_modules/tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==", + "license": "Unlicense", + "optional": true + }, "node_modules/type-fest": { "version": "0.13.1", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", @@ -7514,6 +8161,190 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "license": "BSD-2-Clause", + "optional": true, + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/usocket": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/usocket/-/usocket-0.3.0.tgz", + "integrity": "sha512-V/H02RNiaOCJZuPoKont/y12VJaImC6C5xW7OzPFjYu9qnig0yv9hyp9E7Wqjm6d8yZuZouH3NAfDATVMgh2SQ==", + "hasInstallScript": true, + "license": "ISC", + "optional": true, + "dependencies": { + "bindings": "^1.5.0", + "nan": "^2.14.2", + "node-gyp": "^7.1.2" + } + }, + "node_modules/usocket/node_modules/abbrev": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", + "license": "ISC", + "optional": true + }, + "node_modules/usocket/node_modules/chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "license": "ISC", + "optional": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/usocket/node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/usocket/node_modules/minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "license": "ISC", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/usocket/node_modules/minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "license": "MIT", + "optional": true, + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/usocket/node_modules/minizlib/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/usocket/node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "license": "MIT", + "optional": true, + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/usocket/node_modules/node-gyp": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-7.1.2.tgz", + "integrity": "sha512-CbpcIo7C3eMu3dL1c3d0xw449fHIGALIJsRP4DDPHpyiW8vcriNY7ubh9TE4zEKfSxscY7PjeFnshE7h75ynjQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "env-paths": "^2.2.0", + "glob": "^7.1.4", + "graceful-fs": "^4.2.3", + "nopt": "^5.0.0", + "npmlog": "^4.1.2", + "request": "^2.88.2", + "rimraf": "^3.0.2", + "semver": "^7.3.2", + "tar": "^6.0.2", + "which": "^2.0.2" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" + }, + "engines": { + "node": ">= 10.12.0" + } + }, + "node_modules/usocket/node_modules/nopt": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", + "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==", + "license": "ISC", + "optional": true, + "dependencies": { + "abbrev": "1" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/usocket/node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "license": "ISC", + "optional": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/usocket/node_modules/tar": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", + "deprecated": "Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "optional": true, + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/usocket/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC", + "optional": true + }, "node_modules/utf8-byte-length": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.5.tgz", @@ -7525,9 +8356,20 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true, + "devOptional": true, "license": "MIT" }, + "node_modules/uuid": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", + "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "license": "MIT", + "optional": true, + "bin": { + "uuid": "bin/uuid" + } + }, "node_modules/vaul-svelte": { "version": "1.0.0-next.7", "resolved": "https://registry.npmjs.org/vaul-svelte/-/vaul-svelte-1.0.0-next.7.tgz", @@ -7583,6 +8425,28 @@ "svelte": "^5.0.0" } }, + "node_modules/verror": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", + "integrity": "sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==", + "engines": [ + "node >=0.6.0" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + } + }, + "node_modules/verror/node_modules/core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", + "license": "MIT", + "optional": true + }, "node_modules/vite": { "version": "7.3.6", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", @@ -8333,7 +9197,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, + "devOptional": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -8362,6 +9226,16 @@ "node": ">=8" } }, + "node_modules/wide-align": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", + "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", + "license": "ISC", + "optional": true, + "dependencies": { + "string-width": "^1.0.2 || 2 || 3 || 4" + } + }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", @@ -8384,7 +9258,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true, + "devOptional": true, "license": "ISC" }, "node_modules/xml-name-validator": { @@ -8397,6 +9271,28 @@ "node": ">=18" } }, + "node_modules/xml2js": { + "version": "0.4.23", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.23.tgz", + "integrity": "sha512-ySPiMjM0+pLDftHgXY4By0uswI3SPKLDw/i3UXbnO8M/p28zqexCUoPmQFrYD+/1BzhGJSs2i1ERWKJAtiLrug==", + "license": "MIT", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xml2js/node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, "node_modules/xmlbuilder": { "version": "15.1.1", "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", From f72beb97bf57b86e452d1bbc1bced0f19596506b Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 21 Sep 2026 15:29:05 -0600 Subject: [PATCH 28/28] chore(desktop): trim editor-added trailing whitespace in ipc Signed-off-by: Samuel K --- desktop/src/main/ipc.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/desktop/src/main/ipc.ts b/desktop/src/main/ipc.ts index eade35bec..956871dc4 100644 --- a/desktop/src/main/ipc.ts +++ b/desktop/src/main/ipc.ts @@ -1913,4 +1913,4 @@ function sanitizeAnalyticsProperties( out[k] = typeof v === "string" ? v.slice(0, 256) : v } return out - } +}