Merge pull request #691 from rowboatlabs/feat/caffeinate-toggle

feat(x): Caffeinate toggle to keep the system awake
This commit is contained in:
arkml 2026-07-08 00:10:03 +05:30 committed by GitHub
commit 3261d64dd4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 145 additions and 3 deletions

View file

@ -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';
@ -1311,6 +1314,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<IChannelsConfigRepo>('channelsConfigRepo').getConfig();

View file

@ -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,
@ -6263,6 +6264,7 @@ function App() {
<TooltipContent side="bottom">New chat</TooltipContent>
</Tooltip>
)}
<CaffeinateIndicator />
{/* 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

View file

@ -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 (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => {
void window.ipc.invoke("power:setCaffeinate", { enabled: false }).catch(() => {
toast.error("Failed to turn off Caffeinate")
})
}}
disabled={!enabled}
aria-hidden={!enabled}
aria-label="Caffeinate is on — click to turn off"
className={cn(
"titlebar-no-drag flex h-8 w-8 items-center justify-center rounded-md text-amber-500 transition-colors self-center shrink-0",
enabled ? "hover:bg-accent hover:text-amber-600" : "invisible pointer-events-none",
)}
>
<Coffee className="size-4" />
</button>
</TooltipTrigger>
{enabled && (
<TooltipContent side="bottom">
Caffeinate is on your Mac won't sleep. Click to turn off.
</TooltipContent>
)}
</Tooltip>
)
}

View file

@ -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 })
</p>
</div>
{/* Caffeinate */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2.5">
<div className="flex size-8 items-center justify-center rounded-md bg-muted">
<Coffee className="size-4" />
</div>
<div className="flex flex-col">
<span className="text-sm font-medium">Caffeinate</span>
<span className="text-xs text-muted-foreground">
{caffeinated
? "Your computer will stay awake while Rowboat is running"
: "Keep your computer awake so channels stay connected"}
</span>
</div>
</div>
<Switch
checked={caffeinated}
onCheckedChange={(enabled) => {
setCaffeinated(enabled)
void window.ipc
.invoke("power:setCaffeinate", { enabled })
.then((res) => setCaffeinated(res.enabled))
.catch(() => {
setCaffeinated(!enabled)
toast.error("Failed to update Caffeinate")
})
}}
/>
</div>
<Separator />
{/* WhatsApp */}
<div className="space-y-3">
<div className="flex items-center justify-between">

View file

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