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