mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-15 21:11:08 +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 { ipc } from '@x/shared';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
|
|
@ -23,6 +23,9 @@ import z from 'zod';
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
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 { RunEvent } from '@x/shared/dist/runs.js';
|
||||||
import { ServiceEvent } from '@x/shared/dist/service-events.js';
|
import { ServiceEvent } from '@x/shared/dist/service-events.js';
|
||||||
import type { SessionBusEvent } from '@x/shared/dist/sessions.js';
|
import type { SessionBusEvent } from '@x/shared/dist/sessions.js';
|
||||||
|
|
@ -1311,6 +1314,29 @@ export function setupIpcHandlers() {
|
||||||
|
|
||||||
return { success: true };
|
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) ─────────────
|
// ── Mobile channels (WhatsApp / Telegram bridge) ─────────────
|
||||||
'channels:getConfig': async () => {
|
'channels:getConfig': async () => {
|
||||||
return container.resolve<IChannelsConfigRepo>('channelsConfigRepo').getConfig();
|
return container.resolve<IChannelsConfigRepo>('channelsConfigRepo').getConfig();
|
||||||
|
|
|
||||||
|
|
@ -95,6 +95,7 @@ import { MarkdownPreOverride } from '@/components/ai-elements/markdown-code-over
|
||||||
import { defaultRemarkPlugins } from 'streamdown'
|
import { defaultRemarkPlugins } from 'streamdown'
|
||||||
import remarkBreaks from 'remark-breaks'
|
import remarkBreaks from 'remark-breaks'
|
||||||
import { TabBar, type ChatTab, type FileTab } from '@/components/tab-bar'
|
import { TabBar, type ChatTab, type FileTab } from '@/components/tab-bar'
|
||||||
|
import { CaffeinateIndicator } from '@/components/caffeinate-indicator'
|
||||||
import {
|
import {
|
||||||
type ChatMessage,
|
type ChatMessage,
|
||||||
type ChatViewportAnchorState,
|
type ChatViewportAnchorState,
|
||||||
|
|
@ -6263,6 +6264,7 @@ function App() {
|
||||||
<TooltipContent side="bottom">New chat</TooltipContent>
|
<TooltipContent side="bottom">New chat</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
|
<CaffeinateIndicator />
|
||||||
{/* Trailing layout control. Always mounted (just toggled invisible
|
{/* Trailing layout control. Always mounted (just toggled invisible
|
||||||
when inactive) so its -webkit-app-region:no-drag rect is stable —
|
when inactive) so its -webkit-app-region:no-drag rect is stable —
|
||||||
a freshly-mounted no-drag button inside the drag-region header
|
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 { useCallback, useEffect, useState } from "react"
|
||||||
import type { z } from "zod"
|
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 { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Separator } from "@/components/ui/separator"
|
import { Separator } from "@/components/ui/separator"
|
||||||
|
|
@ -30,19 +30,22 @@ export function MobileChannelsSettings({ dialogOpen }: { dialogOpen: boolean })
|
||||||
const [waAllowDraft, setWaAllowDraft] = useState("")
|
const [waAllowDraft, setWaAllowDraft] = useState("")
|
||||||
const [tgAllowDraft, setTgAllowDraft] = useState("")
|
const [tgAllowDraft, setTgAllowDraft] = useState("")
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [caffeinated, setCaffeinated] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!dialogOpen) return
|
if (!dialogOpen) return
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
const [cfg, st] = await Promise.all([
|
const [cfg, st, caf] = await Promise.all([
|
||||||
window.ipc.invoke("channels:getConfig", null),
|
window.ipc.invoke("channels:getConfig", null),
|
||||||
window.ipc.invoke("channels:getStatus", null),
|
window.ipc.invoke("channels:getStatus", null),
|
||||||
|
window.ipc.invoke("power:getCaffeinate", null),
|
||||||
])
|
])
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
setConfig(cfg)
|
setConfig(cfg)
|
||||||
setStatus(st)
|
setStatus(st)
|
||||||
|
setCaffeinated(caf.enabled)
|
||||||
setTokenDraft(cfg.telegram.botToken)
|
setTokenDraft(cfg.telegram.botToken)
|
||||||
setWaAllowDraft(cfg.whatsapp.allowFrom.join(", "))
|
setWaAllowDraft(cfg.whatsapp.allowFrom.join(", "))
|
||||||
setTgAllowDraft(cfg.telegram.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) => {
|
const unsubscribe = window.ipc.on("channels:status", (st) => {
|
||||||
setStatus(st)
|
setStatus(st)
|
||||||
})
|
})
|
||||||
|
const unsubscribeCaffeinate = window.ipc.on("power:caffeinateChanged", ({ enabled }) => {
|
||||||
|
setCaffeinated(enabled)
|
||||||
|
})
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true
|
||||||
unsubscribe()
|
unsubscribe()
|
||||||
|
unsubscribeCaffeinate()
|
||||||
}
|
}
|
||||||
}, [dialogOpen])
|
}, [dialogOpen])
|
||||||
|
|
||||||
|
|
@ -94,6 +101,38 @@ export function MobileChannelsSettings({ dialogOpen }: { dialogOpen: boolean })
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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 */}
|
{/* WhatsApp */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
|
|
|
||||||
|
|
@ -1004,6 +1004,20 @@ const ipcSchemas = {
|
||||||
success: z.literal(true),
|
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) ─────────────
|
// ── Mobile channels (WhatsApp / Telegram bridge) ─────────────
|
||||||
'channels:getConfig': {
|
'channels:getConfig': {
|
||||||
req: z.null(),
|
req: z.null(),
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue