From 94727a238bc37a50850056265709616cc5dbd4e7 Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Wed, 8 Jul 2026 00:07:14 +0530 Subject: [PATCH] feat(x): add Caffeinate toggle to keep the system awake MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a Caffeinate switch (Settings → Mobile channels) that uses Electron's powerSaveBlocker ('prevent-app-suspension', equivalent to caffeinate -i) to stop the machine from idle-sleeping so the WhatsApp/Telegram bridge stays connected. While active, an amber coffee icon in the titlebar shows the state and can be clicked to turn it off; a power:caffeinateChanged push keeps the titlebar indicator and the settings switch in sync. Co-Authored-By: Claude Fable 5 --- apps/x/apps/main/src/ipc.ts | 28 ++++++++- apps/x/apps/renderer/src/App.tsx | 2 + .../src/components/caffeinate-indicator.tsx | 61 +++++++++++++++++++ .../settings/mobile-channels-settings.tsx | 43 ++++++++++++- apps/x/packages/shared/src/ipc.ts | 14 +++++ 5 files changed, 145 insertions(+), 3 deletions(-) create mode 100644 apps/x/apps/renderer/src/components/caffeinate-indicator.tsx diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index 6c60ea63..c781ab54 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -1,4 +1,4 @@ -import { ipcMain, BrowserWindow, shell, dialog, systemPreferences, desktopCapturer, app, screen } from 'electron'; +import { ipcMain, BrowserWindow, shell, dialog, systemPreferences, desktopCapturer, app, screen, powerSaveBlocker } from 'electron'; import { ipc } from '@x/shared'; import path from 'node:path'; import os from 'node:os'; @@ -23,6 +23,9 @@ import z from 'zod'; const execFileAsync = promisify(execFile); +// Active powerSaveBlocker id while Caffeinate is toggled on; null when off. +let caffeinateBlockerId: number | null = null; + import { RunEvent } from '@x/shared/dist/runs.js'; import { ServiceEvent } from '@x/shared/dist/service-events.js'; import type { SessionBusEvent } from '@x/shared/dist/sessions.js'; @@ -1310,6 +1313,29 @@ export function setupIpcHandlers() { return { success: true }; }, + // ── Caffeinate (keep system awake, like macOS `caffeinate`) ── + 'power:getCaffeinate': async () => { + return { enabled: caffeinateBlockerId !== null && powerSaveBlocker.isStarted(caffeinateBlockerId) }; + }, + 'power:setCaffeinate': async (_event, args) => { + if (args.enabled) { + if (caffeinateBlockerId === null || !powerSaveBlocker.isStarted(caffeinateBlockerId)) { + caffeinateBlockerId = powerSaveBlocker.start('prevent-app-suspension'); + } + } else if (caffeinateBlockerId !== null) { + if (powerSaveBlocker.isStarted(caffeinateBlockerId)) { + powerSaveBlocker.stop(caffeinateBlockerId); + } + caffeinateBlockerId = null; + } + const enabled = caffeinateBlockerId !== null; + for (const win of BrowserWindow.getAllWindows()) { + if (!win.isDestroyed() && win.webContents) { + win.webContents.send('power:caffeinateChanged', { enabled }); + } + } + return { enabled }; + }, // ── Mobile channels (WhatsApp / Telegram bridge) ───────────── 'channels:getConfig': async () => { return container.resolve('channelsConfigRepo').getConfig(); diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 4a45f419..09279126 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -95,6 +95,7 @@ import { MarkdownPreOverride } from '@/components/ai-elements/markdown-code-over import { defaultRemarkPlugins } from 'streamdown' import remarkBreaks from 'remark-breaks' import { TabBar, type ChatTab, type FileTab } from '@/components/tab-bar' +import { CaffeinateIndicator } from '@/components/caffeinate-indicator' import { type ChatMessage, type ChatViewportAnchorState, @@ -6262,6 +6263,7 @@ function App() { New chat )} + {/* Trailing layout control. Always mounted (just toggled invisible when inactive) so its -webkit-app-region:no-drag rect is stable — a freshly-mounted no-drag button inside the drag-region header diff --git a/apps/x/apps/renderer/src/components/caffeinate-indicator.tsx b/apps/x/apps/renderer/src/components/caffeinate-indicator.tsx new file mode 100644 index 00000000..a7e79fdf --- /dev/null +++ b/apps/x/apps/renderer/src/components/caffeinate-indicator.tsx @@ -0,0 +1,61 @@ +import { useEffect, useState } from "react" +import { Coffee } from "lucide-react" +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" +import { cn } from "@/lib/utils" +import { toast } from "sonner" + +/** + * Titlebar indicator shown while Caffeinate (keep-system-awake) is on. + * Always mounted and toggled invisible when off — a freshly-mounted no-drag + * button inside the drag-region header has its first click swallowed by the + * window drag (see the trailing layout control in App.tsx). + */ +export function CaffeinateIndicator() { + const [enabled, setEnabled] = useState(false) + + useEffect(() => { + let cancelled = false + void window.ipc + .invoke("power:getCaffeinate", null) + .then((res) => { + if (!cancelled) setEnabled(res.enabled) + }) + .catch(() => {}) + const unsubscribe = window.ipc.on("power:caffeinateChanged", ({ enabled }) => { + setEnabled(enabled) + }) + return () => { + cancelled = true + unsubscribe() + } + }, []) + + return ( + + + + + {enabled && ( + + Caffeinate is on — your Mac won't sleep. Click to turn off. + + )} + + ) +} diff --git a/apps/x/apps/renderer/src/components/settings/mobile-channels-settings.tsx b/apps/x/apps/renderer/src/components/settings/mobile-channels-settings.tsx index 9ca775e5..9974ee94 100644 --- a/apps/x/apps/renderer/src/components/settings/mobile-channels-settings.tsx +++ b/apps/x/apps/renderer/src/components/settings/mobile-channels-settings.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useState } from "react" import type { z } from "zod" -import { Loader2, MessageCircle, Send, Smartphone } from "lucide-react" +import { Coffee, Loader2, MessageCircle, Send, Smartphone } from "lucide-react" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Separator } from "@/components/ui/separator" @@ -30,19 +30,22 @@ export function MobileChannelsSettings({ dialogOpen }: { dialogOpen: boolean }) const [waAllowDraft, setWaAllowDraft] = useState("") const [tgAllowDraft, setTgAllowDraft] = useState("") const [saving, setSaving] = useState(false) + const [caffeinated, setCaffeinated] = useState(false) useEffect(() => { if (!dialogOpen) return let cancelled = false void (async () => { try { - const [cfg, st] = await Promise.all([ + const [cfg, st, caf] = await Promise.all([ window.ipc.invoke("channels:getConfig", null), window.ipc.invoke("channels:getStatus", null), + window.ipc.invoke("power:getCaffeinate", null), ]) if (cancelled) return setConfig(cfg) setStatus(st) + setCaffeinated(caf.enabled) setTokenDraft(cfg.telegram.botToken) setWaAllowDraft(cfg.whatsapp.allowFrom.join(", ")) setTgAllowDraft(cfg.telegram.allowFrom.join(", ")) @@ -53,9 +56,13 @@ export function MobileChannelsSettings({ dialogOpen }: { dialogOpen: boolean }) const unsubscribe = window.ipc.on("channels:status", (st) => { setStatus(st) }) + const unsubscribeCaffeinate = window.ipc.on("power:caffeinateChanged", ({ enabled }) => { + setCaffeinated(enabled) + }) return () => { cancelled = true unsubscribe() + unsubscribeCaffeinate() } }, [dialogOpen]) @@ -94,6 +101,38 @@ export function MobileChannelsSettings({ dialogOpen }: { dialogOpen: boolean })

+ {/* Caffeinate */} +
+
+
+ +
+
+ Caffeinate + + {caffeinated + ? "Your computer will stay awake while Rowboat is running" + : "Keep your computer awake so channels stay connected"} + +
+
+ { + setCaffeinated(enabled) + void window.ipc + .invoke("power:setCaffeinate", { enabled }) + .then((res) => setCaffeinated(res.enabled)) + .catch(() => { + setCaffeinated(!enabled) + toast.error("Failed to update Caffeinate") + }) + }} + /> +
+ + + {/* WhatsApp */}
diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index 6ac706d3..b4f84314 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -1004,6 +1004,20 @@ const ipcSchemas = { success: z.literal(true), }), }, + // ── Caffeinate (keep system awake, like macOS `caffeinate`) ── + 'power:getCaffeinate': { + req: z.null(), + res: z.object({ enabled: z.boolean() }), + }, + 'power:setCaffeinate': { + req: z.object({ enabled: z.boolean() }), + res: z.object({ enabled: z.boolean() }), + }, + // Push: main → renderer when caffeinate state changes, so indicators stay live. + 'power:caffeinateChanged': { + req: z.object({ enabled: z.boolean() }), + res: z.null(), + }, // ── Mobile channels (WhatsApp / Telegram bridge) ───────────── 'channels:getConfig': { req: z.null(),