const CURRENT_VERSION = "1.0.27"; const GITEA_BASE = "https://gitea.pdmarf.co.uk/pdm/stream_deck_notion_timer/raw/branch/stable-rebuild"; const SIGNING_PUBLIC_KEY = `-----BEGIN PUBLIC KEY----- MCowBQYDK2VwAyEAN7ko8TUpuPzPAJuKAZCRjV0c4ZSlou5d9pUAF6o12b4= -----END PUBLIC KEY-----`; function isNewerVersion(remote: string, current: string): boolean { const parse = (v: string) => v.split(".").map(Number); const [rMaj, rMin, rPat] = parse(remote); const [cMaj, cMin, cPat] = parse(current); if (rMaj !== cMaj) return rMaj > cMaj; if (rMin !== cMin) return rMin > cMin; return rPat > cPat; } function fetchWithTimeout(url: string): Promise { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), 10_000); return fetch(url, { signal: controller.signal }).finally(() => clearTimeout(timer)); } async function checkForUpdates(sendStatus?: (msg: string) => void): Promise { try { const resp = await fetchWithTimeout(`${GITEA_BASE}/version.json`); if (!resp.ok) { sendStatus?.("Update check failed"); return; } const { version } = await resp.json() as { version: string }; if (!/^\d+\.\d+\.\d+$/.test(version)) return; if (!isNewerVersion(version, CURRENT_VERSION)) { sendStatus?.(`Already up to date (v${CURRENT_VERSION})`); return; } sendStatus?.(`Updating to v${version}…`); const [pluginResp, sigResp] = await Promise.all([ fetchWithTimeout(`${GITEA_BASE}/com.pdma.notion-timer.sdPlugin/bin/plugin.js`), fetchWithTimeout(`${GITEA_BASE}/com.pdma.notion-timer.sdPlugin/bin/plugin.js.sig`), ]); if (!pluginResp.ok || !sigResp.ok) { sendStatus?.("Download failed"); return; } const newCode = await pluginResp.text(); const sigBytes = Buffer.from(await sigResp.arrayBuffer()); const { verify } = await import("node:crypto"); const valid = verify(null, Buffer.from(newCode), SIGNING_PUBLIC_KEY, sigBytes); if (!valid) { streamDeck.logger.error("Update rejected: signature verification failed"); sendStatus?.("Update rejected: invalid signature"); return; } const fs = await import("fs"); fs.writeFileSync(__filename, newCode); streamDeck.logger.info(`Updated to ${version}, restarting…`); process.exit(0); } catch (err) { const msg = err instanceof Error ? err.message : String(err); streamDeck.logger.error(`Update check failed: ${msg}`); sendStatus?.(`Error: ${msg}`); } } import streamDeck, { action, KeyDownEvent, PropertyInspectorDidAppearEvent, SingletonAction, WillAppearEvent, } from "@elgato/streamdeck"; import { fetchProjects, fetchUsers, startTimer, stopTimer } from "./notion.js"; interface GlobalSettings { notionToken: string; timingDbId: string; projectsDbId: string; userId: string; runningEntryId?: string | null; } interface TimerSettings { projectId: string; projectName: string; activeEntryId: string | null; } const HARDCODED = { timingDbId: "2ec93117da3a80799ee2cf91244ee264", projectsDbId: "cd65c760-0f0a-4d6e-a262-d572c9e31585", }; async function getGlobal(): Promise { const stored = await streamDeck.settings.getGlobalSettings(); return { ...stored, ...HARDCODED }; } // In-memory cache so onWillAppear can check running state without an async round-trip let memRunningEntryId: string | null | undefined = undefined; // undefined = not yet loaded async function getRunningEntryId(): Promise { if (memRunningEntryId === undefined) { const stored = await streamDeck.settings.getGlobalSettings(); memRunningEntryId = stored.runningEntryId ?? null; } return memRunningEntryId; } async function setRunningEntry(entryId: string | null): Promise { memRunningEntryId = entryId; const stored = await streamDeck.settings.getGlobalSettings(); await streamDeck.settings.setGlobalSettings({ ...stored, runningEntryId: entryId }); } async function sendProjectsToPI(tokenOverride?: string): Promise { try { const global = await getGlobal(); const token = tokenOverride ?? global.notionToken; if (!token) { await streamDeck.ui.sendToPropertyInspector({ event: "projects", data: [], error: "Enter your Notion API token above.", version: CURRENT_VERSION }); return; } const [projects, usersResult] = await Promise.all([ fetchProjects(token, global.projectsDbId), fetchUsers(token).catch((err) => { streamDeck.logger.error("Failed to fetch users:", err); return []; }), ]); await streamDeck.ui.sendToPropertyInspector({ event: "projects", data: projects, users: usersResult, version: CURRENT_VERSION }); } catch (err) { streamDeck.logger.error("Failed to fetch projects:", err); await streamDeck.ui.sendToPropertyInspector({ event: "projects", data: [], error: String(err), version: CURRENT_VERSION }); } } function isConfigured(g: GlobalSettings): boolean { return !!(g.notionToken && g.userId); } // Strip leading emoji characters so the button title shows just the project name function buttonTitle(projectName: string): string { return projectName.replace(/^[\p{Extended_Pictographic}\uFE0F\s]+/u, "").trim(); } @action({ UUID: "com.pdma.notion-timer.toggle" }) class TimerToggle extends SingletonAction { private settingsCache = new Map(); async onWillAppear(ev: WillAppearEvent): Promise { const { activeEntryId, projectName } = ev.payload.settings; const title = buttonTitle(projectName || ""); // Use in-memory cache to determine correct state before rendering — no flash const running = await getRunningEntryId(); const isRunning = !!activeEntryId && activeEntryId === running; if (activeEntryId && !isRunning) { // Self-heal: this button thinks it's running but it's not — clear it const cleared = { ...ev.payload.settings, activeEntryId: null }; await ev.action.setSettings(cleared); this.settingsCache.set(ev.action.id, cleared); await Promise.all([ev.action.setState(0), ev.action.setTitle(title)]); } else { this.settingsCache.set(ev.action.id, ev.payload.settings); if (isRunning) { await Promise.all([ev.action.setState(1), ev.action.setTitle(`⏱ ${title}`)]); } else { await Promise.all([ev.action.setState(0), ev.action.setTitle(title)]); } } } async onPropertyInspectorDidAppear(_ev: PropertyInspectorDidAppearEvent): Promise { await sendProjectsToPI(); } async onKeyDown(ev: KeyDownEvent): Promise { this.settingsCache.set(ev.action.id, ev.payload.settings); const global = await getGlobal(); const { projectId, projectName, activeEntryId } = ev.payload.settings; const title = buttonTitle(projectName || ""); if (!isConfigured(global)) { await ev.action.showAlert(); return; } if (!projectId) { await ev.action.showAlert(); return; } try { if (activeEntryId) { await stopTimer(global.notionToken, activeEntryId); const stopped = { ...ev.payload.settings, activeEntryId: null }; await ev.action.setSettings(stopped); this.settingsCache.set(ev.action.id, stopped); await ev.action.setState(0); await ev.action.setTitle(title); await setRunningEntry(null); } else { const prevEntryId = await getRunningEntryId(); // Optimistically update visuals immediately — no waiting for API if (prevEntryId) { for (const other of this.actions) { if (other.id === ev.action.id) continue; const otherSettings = this.settingsCache.get(other.id); if (otherSettings?.activeEntryId === prevEntryId) { await Promise.all([other.setState(0), other.setTitle(buttonTitle(otherSettings.projectName || ""))]); } } } await Promise.all([ev.action.setState(1), ev.action.setTitle(`⏱ ${title}`)]); // Now do the API calls if (prevEntryId) { await stopTimer(global.notionToken, prevEntryId); for (const other of this.actions) { if (other.id === ev.action.id) continue; const otherSettings = this.settingsCache.get(other.id); if (otherSettings?.activeEntryId === prevEntryId) { const stopped = { ...otherSettings, activeEntryId: null }; await other.setSettings(stopped); this.settingsCache.set(other.id, stopped); } } } const entryId = await startTimer( global.notionToken, global.timingDbId, projectId, projectName, global.userId, ); const started = { ...ev.payload.settings, activeEntryId: entryId }; await ev.action.setSettings(started); this.settingsCache.set(ev.action.id, started); await setRunningEntry(entryId); } } catch (err) { streamDeck.logger.error("Timer toggle failed:", err); await ev.action.showAlert(); } } } const timerAction = new TimerToggle(); streamDeck.actions.registerAction(timerAction); // v2 requires using streamDeck.ui.onSendToPlugin — the SingletonAction method does not fire streamDeck.ui.onSendToPlugin<{ event: string; settings?: TimerSettings; token?: string }>(async (ev) => { if (ev.payload.event === "saveSettings" && ev.payload.settings) { await ev.action.setSettings(ev.payload.settings); const title = buttonTitle(ev.payload.settings.projectName || ""); if (title) await ev.action.setTitle(title); } if (ev.payload.event === "refreshProjects") { await sendProjectsToPI(ev.payload.token); } if (ev.payload.event === "checkForUpdates") { const send = (msg: string) => streamDeck.ui.sendToPropertyInspector({ event: "updateStatus", message: msg }); send("Checking…"); await checkForUpdates(send); } }); streamDeck.connect(); // Check for updates 10 seconds after startup to avoid disrupting initial connection setTimeout(() => checkForUpdates(), 10_000);