Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions extensions/coffee/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
8 changes: 8 additions & 0 deletions extensions/coffee/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
9 changes: 9 additions & 0 deletions extensions/coffee/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
5 changes: 4 additions & 1 deletion extensions/coffee/src/hooks/useLoadStoredSchedules.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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));
Expand Down
6 changes: 6 additions & 0 deletions extensions/coffee/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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);
Expand Down
71 changes: 69 additions & 2 deletions extensions/coffee/src/status.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> {
if (!schedule || Object.keys(schedule).length === 0) {
return false;
Expand Down Expand Up @@ -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<boolean> {
const currentSessionId = getRaycastSessionId();
const storedSessionId = await LocalStorage.getItem<string>(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<Preferences>().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";
}

Expand Down
23 changes: 1 addition & 22 deletions extensions/coffee/src/tools/list-caffeination-schedules.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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<Schedule>;
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());
}
21 changes: 21 additions & 0 deletions extensions/coffee/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Schedule>;
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": {
Expand Down
Loading