mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-12 21:02:17 +02:00
Merge pull request #691 from rowboatlabs/feat/caffeinate-toggle
feat(x): Caffeinate toggle to keep the system awake
This commit is contained in:
commit
3261d64dd4
5 changed files with 145 additions and 3 deletions
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
61
apps/x/apps/renderer/src/components/caffeinate-indicator.tsx
Normal file
61
apps/x/apps/renderer/src/components/caffeinate-indicator.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue