const CURRENT_VERSION = "1.0.64"; 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): Promise { try { const resp = await fetchWithTimeout(`${GITEA_BASE}/version.json`); if (!resp.ok) { streamDeck.logger.warn(`Update check: version.json fetch returned ${resp.status}`); sendStatus?.("Update check failed"); return; } const { version } = await resp.json() as { version: string }; if (!/^\d+\.\d+\.\d+$/.test(version)) { streamDeck.logger.warn(`Update check: version.json had unexpected content: ${JSON.stringify(version)}`); return; } if (!isNewerVersion(version, CURRENT_VERSION)) { streamDeck.logger.info(`Update check: up to date (v${CURRENT_VERSION}, server has v${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"); const path = await import("path"); const pluginRoot = path.join(path.dirname(__filename), ".."); const ASSETS = [ "ui/property-inspector.html", "ui/global-property-inspector.html", "imgs/idle.png", "imgs/running.png", ]; const PLUGIN_BASE = `${GITEA_BASE}/com.pdma.notion-timer.sdPlugin`; const assetResps = await Promise.all(ASSETS.map(p => fetchWithTimeout(`${PLUGIN_BASE}/${p}`))); for (let i = 0; i < ASSETS.length; i++) { if (assetResps[i].ok) { fs.writeFileSync(path.join(pluginRoot, ASSETS[i]), Buffer.from(await assetResps[i].arrayBuffer())); } } const LEGACY = ["imgs/idle.svg", "imgs/running.svg"]; for (const f of LEGACY) { try { fs.unlinkSync(path.join(pluginRoot, f)); } catch { /* already gone */ } } fs.writeFileSync(__filename, newCode); streamDeck.logger.info(`Updated to ${version}, restarting…`); // Await + a short delay so the message actually reaches the socket before // the process dies. The panel can't reliably learn its own new version // number after this restart, so it just tells the user how to confirm it. await sendStatus?.(`Updated to v${version}. Click any other button above to confirm version update has been successful.`); await new Promise((resolve) => setTimeout(resolve, 200)); 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, fetchTagOptions, fetchUsers, startTimer, stopTimer } from "./notion.js"; interface GlobalSettings { notionToken: string; timingDbId: string; projectsDbId: string; userId: string; runningEntryId?: string | null; runningActionId?: string | null; } interface TimerSettings { projectId: string; projectName: string; tag?: string; tagEnabled?: boolean; askDescription?: boolean; } 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 running state — avoids async round-trips on every button press let memRunningEntryId: string | null | undefined = undefined; let memRunningActionId: string | null | undefined = undefined; async function loadRunningState(): Promise { if (memRunningEntryId !== undefined) return; const stored = await streamDeck.settings.getGlobalSettings(); memRunningEntryId = stored.runningEntryId ?? null; memRunningActionId = stored.runningActionId ?? null; } function setRunningEntry(entryId: string | null, actionId: string | null): void { memRunningEntryId = entryId; memRunningActionId = actionId; // Persist in background — do not await, so the visual is never blocked streamDeck.settings.getGlobalSettings() .then(stored => streamDeck.settings.setGlobalSettings({ ...stored, runningEntryId: entryId, runningActionId: actionId })) .catch(err => streamDeck.logger.error("Failed to persist running state:", err)); } 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, tagsResult] = await Promise.all([ fetchProjects(token, global.projectsDbId), fetchUsers(token).catch((err) => { streamDeck.logger.error("Failed to fetch users:", err); return []; }), fetchTagOptions(token, global.timingDbId, "Timer Tags").catch((err) => { streamDeck.logger.error("Failed to fetch tag options:", err); return []; }), ]); await streamDeck.ui.sendToPropertyInspector({ event: "projects", data: projects, users: usersResult, tags: tagsResult, 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); } function buttonTitle(projectName: string, tag?: string): string { const base = projectName.replace(/^[\p{Extended_Pictographic}\uFE0F\s]+/u, "").trim(); return tag ? `${base} \u00B7 ${tag}` : base; } const ELAPSED_CHECK_MS = 60 * 60 * 1000; // 1 hour const DIALOG_TIMEOUT_SECONDS = 300; // 5 minutes async function promptContinue(title: string): Promise<"stop" | "continue" | "timeout"> { const { execFile } = await import("node:child_process"); const safeTitle = title.replace(/[\\"]/g, ""); const script = `display dialog "Timer for ${safeTitle} has been running for over an hour.\n\nShould it continue?" buttons {"Stop", "Continue"} default button "Continue" giving up after ${DIALOG_TIMEOUT_SECONDS}`; return new Promise((resolve) => { execFile("osascript", ["-e", script], (err, stdout) => { if (err || /gave up:true/.test(String(stdout))) { resolve("timeout"); return; } resolve(/button returned:Stop/.test(String(stdout)) ? "stop" : "continue"); }); }); } // Cancelling or letting the dialog time out means "don't start the timer" — // the caller treats ok:false as a no-op, not as "start with no description". async function promptDescription(title: string): Promise<{ ok: boolean; text: string }> { const { execFile } = await import("node:child_process"); const safeTitle = title.replace(/[\\"]/g, ""); const script = `display dialog "Description for ${safeTitle} (optional):" default answer "" buttons {"Cancel", "Start"} default button "Start" giving up after ${DIALOG_TIMEOUT_SECONDS}`; return new Promise((resolve) => { execFile("osascript", ["-e", script], (err, stdout) => { const out = String(stdout); if (err || /gave up:true/.test(out) || !/button returned:Start/.test(out)) { resolve({ ok: false, text: "" }); return; } const match = /text returned:(.*?)(?:, gave up:|$)/.exec(out); resolve({ ok: true, text: match ? match[1].trim() : "" }); }); }); } @action({ UUID: "com.pdma.notion-timer.toggle" }) class TimerToggle extends SingletonAction { private projectCache = new Map(); private keyDownBusy = false; private elapsedCheckTimer: NodeJS.Timeout | null = null; private clearElapsedCheck(): void { if (this.elapsedCheckTimer) clearTimeout(this.elapsedCheckTimer); this.elapsedCheckTimer = null; } private scheduleElapsedCheck(entryId: string, actionId: string, title: string): void { this.clearElapsedCheck(); this.elapsedCheckTimer = setTimeout(() => { this.checkElapsed(entryId, actionId, title); }, ELAPSED_CHECK_MS); } private async checkElapsed(entryId: string, actionId: string, title: string): Promise { if (memRunningEntryId !== entryId) return; const answer = await promptContinue(title); if (memRunningEntryId !== entryId) return; if (answer === "continue") { this.scheduleElapsedCheck(entryId, actionId, title); return; } try { const global = await getGlobal(); await stopTimer(global.notionToken, entryId); } catch (err) { streamDeck.logger.error("Auto-stop after elapsed-check failed:", err); } setRunningEntry(null, null); this.clearElapsedCheck(); for (const action of this.actions) { if (action.id === actionId) { await Promise.all([action.setState(0), action.setTitle(title)]); break; } } } async onWillAppear(ev: WillAppearEvent): Promise { this.projectCache.set(ev.action.id, ev.payload.settings); const title = buttonTitle(ev.payload.settings.projectName || "", ev.payload.settings.tag); await loadRunningState(); const isRunning = memRunningActionId === ev.action.id; 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 { // Global lock across ALL buttons of this action, not just this one — otherwise // near-simultaneous presses on two different buttons both read memRunningActionId // as "nothing running" and both start a timer in Notion. if (this.keyDownBusy) { // Blocked press: redraw THIS button to match known truth instead of leaving // whatever it was already showing (which can be stale from an out-of-band // Notion edit) — a pure visual correction, no Notion calls. Delayed slightly // because redrawing immediately can collide with Stream Deck's own native // key-press flash animation and get silently dropped. const title = buttonTitle(ev.payload.settings.projectName || "", ev.payload.settings.tag); const shouldBeRunning = memRunningActionId === ev.action.id; await new Promise((resolve) => setTimeout(resolve, 150)); await Promise.all([ ev.action.setState(shouldBeRunning ? 1 : 0), ev.action.setTitle(shouldBeRunning ? `⏱ ${title}` : title), ]); return; } this.keyDownBusy = true; try { const { projectId, projectName, tag, askDescription } = ev.payload.settings; const title = buttonTitle(projectName || "", tag); const isRunning = memRunningActionId === ev.action.id; // Asking for a description blocks on a native dialog, so it has to happen // before any optimistic UI update — a cancel/timeout must leave everything // exactly as it was, not flip the button and then silently revert it. let description = ""; if (!isRunning && projectId && askDescription) { const result = await promptDescription(title); if (!result.ok) return; description = result.text; } // Instant visual feedback — no setSettings, no flash if (projectId) { if (isRunning) { await Promise.all([ev.action.setState(0), ev.action.setTitle(title)]); } else { for (const other of this.actions) { if (other.id === ev.action.id) continue; if (memRunningActionId === other.id) { const s = this.projectCache.get(other.id); await Promise.all([other.setState(0), other.setTitle(buttonTitle(s?.projectName || "", s?.tag))]); } } await Promise.all([ev.action.setState(1), ev.action.setTitle(`⏱ ${title}`)]); } } const global = await getGlobal(); if (!isConfigured(global)) { await ev.action.showAlert(); return; } if (!projectId) { await ev.action.showAlert(); return; } try { if (isRunning) { // Stopping the entry in Notion is best-effort: if the page is already // gone/archived (e.g. edited outside the plugin) this throws, but the // local "running" tracking must clear anyway or every future keypress // retries this same doomed stop call and the deck gets stuck forever. try { await stopTimer(global.notionToken, memRunningEntryId!); } catch (err) { streamDeck.logger.error("Failed to stop timer in Notion (clearing local state anyway):", err); } setRunningEntry(null, null); this.clearElapsedCheck(); await Promise.all([ev.action.setState(0), ev.action.setTitle(title)]); } else { if (memRunningEntryId) { try { await stopTimer(global.notionToken, memRunningEntryId); } catch (err) { streamDeck.logger.error("Failed to stop previous timer in Notion (clearing local state anyway):", err); } setRunningEntry(null, null); this.clearElapsedCheck(); } const entryId = await startTimer(global.notionToken, global.timingDbId, projectId, projectName, global.userId, tag, description); setRunningEntry(entryId, ev.action.id); this.scheduleElapsedCheck(entryId, ev.action.id, title); await Promise.all([ev.action.setState(1), ev.action.setTitle(`⏱ ${title}`)]); } } catch (err) { // Revert visual on error await Promise.all([ ev.action.setState(isRunning ? 1 : 0), ev.action.setTitle(isRunning ? `⏱ ${title}` : title), ]); streamDeck.logger.error("Timer toggle failed:", err); await ev.action.showAlert(); } } finally { this.keyDownBusy = false; } } } const NEXTCLOUD_BIN = "/Applications/Nextcloud.app/Contents/MacOS/Nextcloud"; @action({ UUID: "com.pdma.notion-timer.launch-nextcloud" }) class LaunchNextcloud extends SingletonAction { async onKeyDown(ev: KeyDownEvent): Promise { try { const { spawn } = await import("node:child_process"); const child = spawn(NEXTCLOUD_BIN, [], { detached: true, stdio: "ignore" }); // "error" (e.g. ENOENT if Nextcloud isn't installed at this path) fires // asynchronously — without this listener it would crash the plugin process. child.once("error", (err) => { streamDeck.logger.error("Failed to launch Nextcloud:", err); }); child.unref(); await ev.action.showOk(); } catch (err) { streamDeck.logger.error("Failed to launch Nextcloud:", err); await ev.action.showAlert(); } } } const timerAction = new TimerToggle(); streamDeck.actions.registerAction(timerAction); streamDeck.actions.registerAction(new LaunchNextcloud()); 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 || "", ev.payload.settings.tag); 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); } }); const UPDATE_CHECK_INTERVAL_MS = 30 * 60 * 1000; // 30 minutes streamDeck.connect(); setTimeout(() => { streamDeck.logger.info("Running initial (10s post-launch) update check"); checkForUpdates(); }, 10_000); setInterval(() => { streamDeck.logger.info("Running scheduled (30min) update check"); checkForUpdates(); }, UPDATE_CHECK_INTERVAL_MS);