const CURRENT_VERSION = "1.0.22"; const GITEA_BASE = "https://gitea.pdmarf.co.uk/pdm/stream_deck_notion_timer/raw/branch/master"; 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 r = parse(remote); const c = parse(current); const len = Math.max(r.length, c.length); for (let i = 0; i < len; i++) { const rv = r[i] ?? 0; const cv = c[i] ?? 0; if (rv !== cv) return rv > cv; } return false; } 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+)+$/.test(version)) return; if (!isNewerVersion(version, CURRENT_VERSION)) { sendStatus?.(`Already up to date (v${CURRENT_VERSION})`); return; } sendStatus?.(`Updating to v${version}…`); const PLUGIN_BASE = `${GITEA_BASE}/com.pdma.notion-timer.sdPlugin`; const [pluginResp, sigResp] = await Promise.all([ fetchWithTimeout(`${PLUGIN_BASE}/bin/plugin.js`), fetchWithTimeout(`${PLUGIN_BASE}/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"); const path = await import("path"); const pluginRoot = path.join(path.dirname(__filename), ".."); // Also update UI and image assets. These are not separately signed — they run in a browser // sandbox (not Node), and are fetched over HTTPS from the same trusted server. const ASSETS = [ "ui/property-inspector.html", "ui/global-property-inspector.html", "imgs/idle.png", "imgs/running.png", ]; const assetResps = await Promise.all(ASSETS.map(p => fetchWithTimeout(`${PLUGIN_BASE}/${p}`))); fs.writeFileSync(__filename, newCode); for (let i = 0; i < ASSETS.length; i++) { if (!assetResps[i].ok) { streamDeck.logger.warn(`Asset download failed: ${ASSETS[i]}`); continue; } fs.writeFileSync(path.join(pluginRoot, ASSETS[i]), Buffer.from(await assetResps[i].arrayBuffer())); } // Remove legacy files that were replaced in older versions but persist on disk // because Stream Deck merges rather than replaces the plugin folder on reinstall. const LEGACY = ["imgs/idle.svg", "imgs/running.svg"]; for (const f of LEGACY) { try { fs.unlinkSync(path.join(pluginRoot, f)); } catch { /* already gone */ } } 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(overrideToken?: string): Promise { try { const global = await getGlobal(); const token = overrideToken || global.notionToken; if (!token) { await streamDeck.ui.sendToPropertyInspector({ event: "projects", data: [], error: "Enter your Notion API token above.", version: CURRENT_VERSION }); return; } let usersResult: Awaited> = []; let usersError: string | undefined; const [projects] = await Promise.all([ fetchProjects(token, global.projectsDbId), fetchUsers(token).then((u) => { usersResult = u; }).catch((err) => { streamDeck.logger.error("Failed to fetch users:", err); usersError = err instanceof Error ? err.message : String(err); }), ]); await streamDeck.ui.sendToPropertyInspector({ event: "projects", data: projects, users: usersResult, usersError, 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(); // Stop previous timer 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); await other.setState(0); await other.setTitle(buttonTitle(otherSettings.projectName || "")); } } } 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); await ev.action.setState(1); await ev.action.setTitle(`⏱ ${title}`); } } 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 }>(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 === "refresh") { await sendProjectsToPI(ev.payload.notionToken as string | undefined); } 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);