diff --git a/extensions/coffee/CHANGELOG.md b/extensions/coffee/CHANGELOG.md index 46105ccf328..7c0078fa007 100644 --- a/extensions/coffee/CHANGELOG.md +++ b/extensions/coffee/CHANGELOG.md @@ -1,5 +1,9 @@ # Coffee Changelog +## [Enhancement] - {PR_MERGE_DATE} + +- Added an "instant on" feature: when the new *Start caffeination when Raycast starts* preference is enabled, Coffee automatically keeps your Mac awake (indefinitely) shortly after Raycast launches. Manual decaffeination persists until the next Raycast restart. + ## [AI Extension] - 2026-07-22 - Added AI tools to caffeinate until a specific date and time. diff --git a/extensions/coffee/README.md b/extensions/coffee/README.md index bb19b2fbf6c..917c89fc52e 100644 --- a/extensions/coffee/README.md +++ b/extensions/coffee/README.md @@ -76,3 +76,11 @@ Get the current state of caffeination. ### 7. **Caffeinate Status Menu Bar** Get the status of current caffeination in your menu bar. + +### 8. **Auto-Caffeinate on Launch** + +Optionally have Coffee start caffeinating your Mac (indefinitely) automatically whenever Raycast launches. Enable the **Start caffeination when Raycast starts** toggle under the **Launch** section in the extension's preferences. + +> **Requirements:** Either the *Caffeinate Status* command (15 s background interval) or the *Caffeinate Status Menu Bar* command (1 m background interval) must be enabled in Raycast — the auto-start feature piggybacks on these background ticks. + +**How session detection works:** Coffee tracks the Raycast process PID. A new PID means Raycast actually relaunched, so the Mac is caffeinated automatically. Putting the Mac to sleep and waking it does **not** trigger a re-caffeination, because the PID stays the same — your manual decaffeination is honoured until you truly restart Raycast. diff --git a/extensions/coffee/package.json b/extensions/coffee/package.json index aac35c25112..23aec25c6a9 100644 --- a/extensions/coffee/package.json +++ b/extensions/coffee/package.json @@ -226,6 +226,15 @@ "value": "paper-cup" } ] + }, + { + "name": "startCaffeinateOnLaunch", + "description": "Automatically keep your Mac awake (indefinitely) when Raycast starts. Requires the Caffeinate Status command (15 s interval) or the Caffeinate Status Menu Bar command (1 m interval) to be enabled.", + "type": "checkbox", + "required": false, + "default": false, + "title": "Launch", + "label": "Start caffeination when Raycast starts" } ], "dependencies": { diff --git a/extensions/coffee/src/hooks/useLoadStoredSchedules.tsx b/extensions/coffee/src/hooks/useLoadStoredSchedules.tsx index f4959c70869..eb3a7065773 100644 --- a/extensions/coffee/src/hooks/useLoadStoredSchedules.tsx +++ b/extensions/coffee/src/hooks/useLoadStoredSchedules.tsx @@ -1,6 +1,7 @@ import { LocalStorage } from "@raycast/api"; import { Schedule } from "../interfaces"; import { useEffect, useRef } from "react"; +import { parseSchedule } from "../utils"; const dayOrder: { [key: string]: number } = { sunday: 0, @@ -35,7 +36,9 @@ export function useLoadStoredSchedules( if (!isMounted) return; - const schedules: Schedule[] = Object.values(allStoredItems).map((item) => JSON.parse(item) as Schedule); + const schedules: Schedule[] = Object.values(allStoredItems) + .map(parseSchedule) + .filter((schedule): schedule is Schedule => schedule !== undefined); if (schedules.length > 0) { schedules.sort((a, b) => (dayOrder[a.day] ?? -1) - (dayOrder[b.day] ?? -1)); diff --git a/extensions/coffee/src/index.tsx b/extensions/coffee/src/index.tsx index f2fd05e1a78..66220f34ed1 100644 --- a/extensions/coffee/src/index.tsx +++ b/extensions/coffee/src/index.tsx @@ -11,6 +11,7 @@ import { import { useExec } from "@raycast/utils"; import { useEffect, useState } from "react"; import { formatDuration, startCaffeinate, stopCaffeinate } from "./utils"; +import { maybeAutoCaffeinate } from "./status"; function parseEtime(etime: string): number { const parts = etime.split(":").reverse(); @@ -92,6 +93,11 @@ export default function Command(props: LaunchProps) { setLocalCaffeinateStatus(null); }, [caffeinateStatus]); + useEffect(() => { + if (isLoading) return; + void maybeAutoCaffeinate(); + }, [isLoading]); + useEffect(() => { if (!displayCaffeinateStatus || data.totalSeconds === null || data.startTime === null) return; const interval = setInterval(() => setTick((t) => t + 1), 1000); diff --git a/extensions/coffee/src/status.ts b/extensions/coffee/src/status.ts index 04a3afc030e..8c0cfff726f 100644 --- a/extensions/coffee/src/status.ts +++ b/extensions/coffee/src/status.ts @@ -1,6 +1,38 @@ -import { LocalStorage, updateCommandMetadata } from "@raycast/api"; +import { getPreferenceValues, LocalStorage, updateCommandMetadata } from "@raycast/api"; +import { execFileSync } from "node:child_process"; import { Schedule, startCaffeinate, getSchedule, stopCaffeinate, isCaffeinateRunning } from "./utils"; +const AUTO_CAFFEINATE_PID_KEY = "autoCaffeinateRaycastPid"; + +/** + * Returns a session identifier for the currently-running Raycast instance. + * + * Primary: `lsappinfo` queries Raycast's start time via LaunchServices using + * the bundle ID — immune to process-table visibility restrictions that cause + * `pgrep` to return nothing from within the extension context. + * + * Fallback: `process.ppid` (the Raycast Helper PID) which is stable within a + * session in production builds, though it may vary across commands in dev mode. + */ +function getRaycastSessionId(): string { + try { + const out = execFileSync("/usr/bin/lsappinfo", ["info", "-app", "com.raycast.macos"], { encoding: "utf8" }).trim(); + + const pidMatch = out.match(/pid\s*=\s*(\d+)/); + if (pidMatch?.[1]) { + return `pid:${pidMatch[1]}`; + } + + const dateMatch = out.match(/\d{4}\/\d{2}\/\d{2}\s+\d{2}:\d{2}:\d{2}/); + if (dateMatch?.[0]) { + return `launch:${dateMatch[0]}`; + } + } catch { + // lsappinfo unavailable or Raycast not registered yet — fall through. + } + return `ppid:${process.ppid}`; +} + async function handleScheduledCaffeinate(schedule: Schedule): Promise { if (!schedule || Object.keys(schedule).length === 0) { return false; @@ -50,13 +82,48 @@ export async function checkSchedule() { return false; } +/** + * Starts caffeination (indefinitely) once per Raycast session when the + * "Start caffeination when Raycast starts" preference is enabled and the Mac + * is not already caffeinated or covered by a schedule. Returns true when + * caffeination was started. + * + * Session detection is keyed off the Raycast launch time stored in + * LocalStorage. The session ID changes only when Raycast actually relaunches, so + * sleep/wake cycles (where background intervals simply don't fire but the ID + * stays the same) cannot trigger a spurious auto-caffeinate. + */ +export async function maybeAutoCaffeinate(isScheduled?: boolean): Promise { + const currentSessionId = getRaycastSessionId(); + const storedSessionId = await LocalStorage.getItem(AUTO_CAFFEINATE_PID_KEY); + + // Always refresh the stored session ID so it reflects the running Raycast process. + // This must happen before the early returns so that after a manual decaf the + // marker stays current and won't look like a new session on the next tick. + await LocalStorage.setItem(AUTO_CAFFEINATE_PID_KEY, currentSessionId); + + if (!getPreferenceValues().startCaffeinateOnLaunch) return false; + if (isCaffeinateRunning()) return false; + + const scheduled = isScheduled ?? (await checkSchedule()); + if (scheduled) return false; + + // A different (or absent) session ID means Raycast relaunched — treat as new session. + // Sleep/wake does not change the session ID, so it cannot trigger a false positive. + if (currentSessionId === storedSessionId) return false; + + await startCaffeinate({ menubar: true, status: true }, "Auto-caffeinating your Mac"); + return true; +} + export default async function Command() { const isCaffeinated = isCaffeinateRunning(); const isScheduled = await checkSchedule(); + const autoStarted = await maybeAutoCaffeinate(isScheduled); let subtitle = "✖ Decaffeinated"; - if (isCaffeinated || isScheduled) { + if (isCaffeinated || isScheduled || autoStarted) { subtitle = "✔ Caffeinated"; } diff --git a/extensions/coffee/src/tools/list-caffeination-schedules.ts b/extensions/coffee/src/tools/list-caffeination-schedules.ts index d410f50c499..098c1068a14 100644 --- a/extensions/coffee/src/tools/list-caffeination-schedules.ts +++ b/extensions/coffee/src/tools/list-caffeination-schedules.ts @@ -1,6 +1,6 @@ import { LocalStorage } from "@raycast/api"; import { Schedule } from "../interfaces"; -import { numberToDayString } from "../utils"; +import { numberToDayString, parseSchedule } from "../utils"; /** * Lists all recurring caffeination schedules in weekday order. @@ -24,27 +24,6 @@ export default async function tool() { }; } -function parseSchedule(value: string | number | boolean): Schedule | undefined { - if (typeof value !== "string") return undefined; - - try { - const schedule = JSON.parse(value) as Partial; - if ( - typeof schedule.day === "string" && - typeof schedule.from === "string" && - typeof schedule.to === "string" && - typeof schedule.IsManuallyDecafed === "boolean" && - typeof schedule.IsRunning === "boolean" - ) { - return schedule as Schedule; - } - } catch { - // Ignore unrelated local storage values. - } - - return undefined; -} - function dayIndex(day: string): number { return Array.from({ length: 7 }, (_, index) => numberToDayString(index).toLowerCase()).indexOf(day.toLowerCase()); } diff --git a/extensions/coffee/src/utils.ts b/extensions/coffee/src/utils.ts index c45faebbe7f..f5fd66591d1 100644 --- a/extensions/coffee/src/utils.ts +++ b/extensions/coffee/src/utils.ts @@ -87,6 +87,27 @@ export async function getSchedule() { return schedule; } +export function parseSchedule(value: string | number | boolean): Schedule | undefined { + if (typeof value !== "string") return undefined; + + try { + const schedule = JSON.parse(value) as Partial; + if ( + typeof schedule.day === "string" && + typeof schedule.from === "string" && + typeof schedule.to === "string" && + typeof schedule.IsManuallyDecafed === "boolean" && + typeof schedule.IsRunning === "boolean" + ) { + return schedule as Schedule; + } + } catch { + // Ignore unrelated local storage values. + } + + return undefined; +} + export async function changeScheduleState(operation: string, schedule: Schedule) { switch (operation) { case "caffeinate": {