Compare commits

...

19 Commits

Author SHA1 Message Date
pdmarf
f76c5ac33b v1.0.40: bump version.json so auto-updater delivers update to staff
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 17:07:17 +01:00
pdmarf
6e0976a284 v1.0.40: fix duplicate timer race condition on rapid double-press
onKeyDown is async and calls await startTimer (~1s network). A second
press before that resolves saw the same state (isRunning=false,
memRunningEntryId=null) and created a second Notion entry. Only the
last startTimer call's ID was tracked, orphaning the first entry
running indefinitely in Notion.

pendingKeyDown Set acts as a per-action mutex: a second press while the
first is in-flight is dropped. try/finally guarantees the lock is always
released.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 18:18:52 +01:00
pdmarf
060a2bc917 v1.0.39: fix button flash by making setRunningEntry synchronous
Remove await from running-state updates so onWillAppear cannot
fire between the optimistic setState(1) and the API call completing,
eliminating the green→blue→green flash on button press.
2026-04-24 09:29:43 +01:00
pdmarf
4171b2f6e9 v1.0.38: eliminate setSettings from onKeyDown to fix flash
setSettings always resets the button visual state — no workaround
exists. Removed activeEntryId from per-button settings entirely.
Running state now tracked via runningActionId in global settings
(alongside runningEntryId). onWillAppear restores state from
memRunningActionId. onKeyDown only calls setState — clean, instant.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 09:20:41 +01:00
pdmarf
35a0bbb867 v1.0.37: send setSettings and setState simultaneously to avoid flash
Previously setSettings was awaited first (causing blue flash), then
setState was called. Now both are sent in the same Promise.all so
Stream Deck processes setState in the same batch, overriding the
visual reset from setSettings with no visible blue transition.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 09:15:48 +01:00
pdmarf
5cdc77ccf3 v1.0.36: eliminate blue flash between setSettings and setState
setState was being called after setRunningEntry, which makes two
async global settings calls and takes ~1 second. During that time
the button sat in the blue reset state from setSettings. Now
setState is called immediately after setSettings, before
setRunningEntry, so the blue flash is imperceptibly brief.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 09:13:38 +01:00
pdmarf
bb021633a3 v1.0.35: re-assert setState(0) after setSettings on stop path
setSettings() resets the visual state in both directions. The start
path already re-asserted setState(1) after setSettings. The stop path
was missing the equivalent setState(0), leaving the button green.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 09:11:30 +01:00
pdmarf
93616d3a1a v1.0.34: fix both buttons showing green simultaneously
When pressing B while A was running, B went green immediately but A
stayed green until getGlobal()+getRunningEntryId() resolved. Now uses
settingsCache to turn off all running buttons in the same synchronous
pass as turning B on — no async gap where both appear active.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 09:07:55 +01:00
pdmarf
2fd2b6ad8a v1.0.33: instant button feedback on key press
Visual state change now happens before any async work (getGlobal,
getRunningEntryId, API calls). Previously the button waited for
getGlobal() to resolve before going green, causing a 1-2s delay.
Also reverts optimistic state on API error.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 09:05:41 +01:00
pdmarf
e3a19234a9 v1.0.32: re-assert button state after setSettings resets it
setSettings() causes Stream Deck to reset the button visual back to
its default state (state 0). The optimistic setState(1) was correct
but got overridden. Now setState(1) is called again after setSettings
completes to ensure the button stays green.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 09:01:55 +01:00
pdmarf
a233cfc4fd v1.0.31: fix name selection being wiped when refreshProjects fires
Selecting a name called saveCredentials, which sent refreshProjects,
which re-fetched users and called populateUsers(users, globalUserId)
with globalUserId still empty — immediately clearing the selection.

Fix: saveCredentials now updates globalUserId immediately, and
refreshProjects is only triggered by token changes (scheduleCredSave),
not by name changes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 08:58:42 +01:00
pdmarf
036567bc7c v1.0.30: auto-updater now updates HTML and images, not just plugin.js
Previous versions of checkForUpdates only wrote plugin.js, leaving
property-inspector.html stale on all machines that used Check for
Updates. PI fixes (name dropdown, refreshProjects) never reached them.
Now downloads and writes all UI and image assets before restarting.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 08:56:37 +01:00
pdmarf
ccc6d90578 v1.0.29: fix userId not restoring due to race condition
onSendToPropertyInspector (users list) arrives before
onDidReceiveGlobalSettings (saved userId) in most cases, leaving
globalUserId empty when populateUsers runs. Now cachedUsers stores
the list, and onDidReceiveGlobalSettings re-populates if users
already arrived — handles both orderings.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 08:53:15 +01:00
pdmarf
c3add8da27 v1.0.28: fix name dropdown not restoring when switching buttons
onDidReceiveGlobalSettings fired before the users list was populated,
so the saved userId couldn't be selected. Now stored in globalUserId
variable and applied when populateUsers runs, regardless of order.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 08:50:39 +01:00
pdmarf
5426a388ba v1.0.27: auto-load projects/users after API token is entered
Previously staff had to close and reopen the property inspector
after entering their token to trigger the fetch. Now saving the
token immediately sends a refreshProjects event to the plugin,
which fetches and returns the project list and name dropdown.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 08:43:12 +01:00
pdmarf
034c951d9b v1.0.26: point auto-updater at stable-rebuild branch
GITEA_BASE was pointing at master (v1.0.22), so Check for Updates
always said "already up to date". Now points at stable-rebuild so
updates are served from the correct branch. Also bumps version.json
to 1.0.26.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 07:49:03 +01:00
pdmarf
d6d695e6c0 v1.0.26: eliminate double-green flash when switching timers
Both buttons now flip to their final visual state immediately on key
press, before any API calls. Previously the old button stayed green
throughout the stopTimer network round-trip (~0.5–2s), causing a
window where both buttons appeared green simultaneously.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 07:47:32 +01:00
pdmarf
47d8c7d98d v1.0.25: update manifest version to 1.0.25
Stream Deck plugin settings panel was showing "version 1" because
manifest.json had a hardcoded "Version": "1.0.0".

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 21:43:46 +01:00
pdmarf
3a91fafe8f v1.0.25: add Check for Updates button
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 21:41:29 +01:00
7 changed files with 328 additions and 231 deletions

View File

@@ -6438,8 +6438,8 @@ async function stopTimer(token, entryId) {
} }
// src/plugin.ts // src/plugin.ts
var CURRENT_VERSION = "1.0.23"; var CURRENT_VERSION = "1.0.40";
var GITEA_BASE = "https://gitea.pdmarf.co.uk/pdm/stream_deck_notion_timer/raw/branch/master"; var GITEA_BASE = "https://gitea.pdmarf.co.uk/pdm/stream_deck_notion_timer/raw/branch/stable-rebuild";
var SIGNING_PUBLIC_KEY = `-----BEGIN PUBLIC KEY----- var SIGNING_PUBLIC_KEY = `-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEAN7ko8TUpuPzPAJuKAZCRjV0c4ZSlou5d9pUAF6o12b4= MCowBQYDK2VwAyEAN7ko8TUpuPzPAJuKAZCRjV0c4ZSlou5d9pUAF6o12b4=
-----END PUBLIC KEY-----`; -----END PUBLIC KEY-----`;
@@ -6456,32 +6456,67 @@ function fetchWithTimeout2(url) {
const timer = setTimeout(() => controller.abort(), 1e4); const timer = setTimeout(() => controller.abort(), 1e4);
return fetch(url, { signal: controller.signal }).finally(() => clearTimeout(timer)); return fetch(url, { signal: controller.signal }).finally(() => clearTimeout(timer));
} }
async function checkForUpdates() { async function checkForUpdates(sendStatus) {
try { try {
const resp = await fetchWithTimeout2(`${GITEA_BASE}/version.json`); const resp = await fetchWithTimeout2(`${GITEA_BASE}/version.json`);
if (!resp.ok) return; if (!resp.ok) {
sendStatus?.("Update check failed");
return;
}
const { version } = await resp.json(); const { version } = await resp.json();
if (!/^\d+\.\d+\.\d+$/.test(version)) return; if (!/^\d+\.\d+\.\d+$/.test(version)) return;
if (!isNewerVersion(version, CURRENT_VERSION)) return; if (!isNewerVersion(version, CURRENT_VERSION)) {
sendStatus?.(`Already up to date (v${CURRENT_VERSION})`);
return;
}
sendStatus?.(`Updating to v${version}\u2026`);
const [pluginResp, sigResp] = await Promise.all([ const [pluginResp, sigResp] = await Promise.all([
fetchWithTimeout2(`${GITEA_BASE}/com.pdma.notion-timer.sdPlugin/bin/plugin.js`), fetchWithTimeout2(`${GITEA_BASE}/com.pdma.notion-timer.sdPlugin/bin/plugin.js`),
fetchWithTimeout2(`${GITEA_BASE}/com.pdma.notion-timer.sdPlugin/bin/plugin.js.sig`) fetchWithTimeout2(`${GITEA_BASE}/com.pdma.notion-timer.sdPlugin/bin/plugin.js.sig`)
]); ]);
if (!pluginResp.ok || !sigResp.ok) return; if (!pluginResp.ok || !sigResp.ok) {
sendStatus?.("Download failed");
return;
}
const newCode = await pluginResp.text(); const newCode = await pluginResp.text();
const sigBytes = Buffer.from(await sigResp.arrayBuffer()); const sigBytes = Buffer.from(await sigResp.arrayBuffer());
const { verify } = await import("node:crypto"); const { verify } = await import("node:crypto");
const valid = verify(null, Buffer.from(newCode), SIGNING_PUBLIC_KEY, sigBytes); const valid = verify(null, Buffer.from(newCode), SIGNING_PUBLIC_KEY, sigBytes);
if (!valid) { if (!valid) {
plugin_default.logger.error("Update rejected: signature verification failed"); plugin_default.logger.error("Update rejected: signature verification failed");
sendStatus?.("Update rejected: invalid signature");
return; return;
} }
const fs3 = await import("fs"); const fs3 = await import("fs");
const path5 = await import("path");
const pluginRoot = path5.join(path5.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) => fetchWithTimeout2(`${PLUGIN_BASE}/${p}`)));
for (let i = 0; i < ASSETS.length; i++) {
if (assetResps[i].ok) {
fs3.writeFileSync(path5.join(pluginRoot, ASSETS[i]), Buffer.from(await assetResps[i].arrayBuffer()));
}
}
const LEGACY = ["imgs/idle.svg", "imgs/running.svg"];
for (const f of LEGACY) {
try {
fs3.unlinkSync(path5.join(pluginRoot, f));
} catch {
}
}
fs3.writeFileSync(__filename, newCode); fs3.writeFileSync(__filename, newCode);
plugin_default.logger.info(`Updated to ${version}, restarting\u2026`); plugin_default.logger.info(`Updated to ${version}, restarting\u2026`);
process.exit(0); process.exit(0);
} catch (err) { } catch (err) {
plugin_default.logger.error(`Update check failed: ${err instanceof Error ? err.message : String(err)}`); const msg = err instanceof Error ? err.message : String(err);
plugin_default.logger.error(`Update check failed: ${msg}`);
sendStatus?.(`Error: ${msg}`);
} }
} }
var HARDCODED = { var HARDCODED = {
@@ -6493,55 +6528,29 @@ async function getGlobal() {
return { ...stored, ...HARDCODED }; return { ...stored, ...HARDCODED };
} }
var memRunningEntryId = void 0; var memRunningEntryId = void 0;
async function getRunningEntryId() { var memRunningActionId = void 0;
if (memRunningEntryId === void 0) { async function loadRunningState() {
if (memRunningEntryId !== void 0) return;
const stored = await plugin_default.settings.getGlobalSettings(); const stored = await plugin_default.settings.getGlobalSettings();
memRunningEntryId = stored.runningEntryId ?? null; memRunningEntryId = stored.runningEntryId ?? null;
memRunningActionId = stored.runningActionId ?? null;
} }
return memRunningEntryId; function setRunningEntry(entryId, actionId) {
}
async function setRunningEntry(entryId) {
memRunningEntryId = entryId; memRunningEntryId = entryId;
const stored = await plugin_default.settings.getGlobalSettings(); memRunningActionId = actionId;
await plugin_default.settings.setGlobalSettings({ ...stored, runningEntryId: entryId }); plugin_default.settings.getGlobalSettings().then((stored) => plugin_default.settings.setGlobalSettings({ ...stored, runningEntryId: entryId, runningActionId: actionId })).catch((err) => plugin_default.logger.error("Failed to persist running state:", err));
} }
function isConfigured(g) { async function sendProjectsToPI(tokenOverride) {
return !!(g.notionToken && g.userId);
}
function buttonTitle(projectName) {
return projectName.replace(/^[\p{Extended_Pictographic}\uFE0F\s]+/u, "").trim();
}
var TimerToggle = class extends SingletonAction {
settingsCache = /* @__PURE__ */ new Map();
async onWillAppear(ev) {
const { activeEntryId, projectName } = ev.payload.settings;
const title = buttonTitle(projectName || "");
const running = await getRunningEntryId();
const isRunning = !!activeEntryId && activeEntryId === running;
if (activeEntryId && !isRunning) {
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(`\u23F1 ${title}`)]);
} else {
await Promise.all([ev.action.setState(0), ev.action.setTitle(title)]);
}
}
}
async onPropertyInspectorDidAppear(ev) {
try { try {
const global = await getGlobal(); const global = await getGlobal();
if (!global.notionToken) { const token = tokenOverride ?? global.notionToken;
if (!token) {
await plugin_default.ui.sendToPropertyInspector({ event: "projects", data: [], error: "Enter your Notion API token above.", version: CURRENT_VERSION }); await plugin_default.ui.sendToPropertyInspector({ event: "projects", data: [], error: "Enter your Notion API token above.", version: CURRENT_VERSION });
return; return;
} }
const [projects, usersResult] = await Promise.all([ const [projects, usersResult] = await Promise.all([
fetchProjects(global.notionToken, global.projectsDbId), fetchProjects(token, global.projectsDbId),
fetchUsers(global.notionToken).catch((err) => { fetchUsers(token).catch((err) => {
plugin_default.logger.error("Failed to fetch users:", err); plugin_default.logger.error("Failed to fetch users:", err);
return []; return [];
}) })
@@ -6552,11 +6561,51 @@ var TimerToggle = class extends SingletonAction {
await plugin_default.ui.sendToPropertyInspector({ event: "projects", data: [], error: String(err), version: CURRENT_VERSION }); await plugin_default.ui.sendToPropertyInspector({ event: "projects", data: [], error: String(err), version: CURRENT_VERSION });
} }
} }
function isConfigured(g) {
return !!(g.notionToken && g.userId);
}
function buttonTitle(projectName) {
return projectName.replace(/^[\p{Extended_Pictographic}\uFE0F\s]+/u, "").trim();
}
var TimerToggle = class extends SingletonAction {
projectCache = /* @__PURE__ */ new Map();
pendingKeyDown = /* @__PURE__ */ new Set();
async onWillAppear(ev) {
this.projectCache.set(ev.action.id, ev.payload.settings);
const title = buttonTitle(ev.payload.settings.projectName || "");
await loadRunningState();
const isRunning = memRunningActionId === ev.action.id;
if (isRunning) {
await Promise.all([ev.action.setState(1), ev.action.setTitle(`\u23F1 ${title}`)]);
} else {
await Promise.all([ev.action.setState(0), ev.action.setTitle(title)]);
}
}
async onPropertyInspectorDidAppear(_ev) {
await sendProjectsToPI();
}
async onKeyDown(ev) { async onKeyDown(ev) {
this.settingsCache.set(ev.action.id, ev.payload.settings); if (this.pendingKeyDown.has(ev.action.id)) return;
const global = await getGlobal(); this.pendingKeyDown.add(ev.action.id);
const { projectId, projectName, activeEntryId } = ev.payload.settings; try {
const { projectId, projectName } = ev.payload.settings;
const title = buttonTitle(projectName || ""); const title = buttonTitle(projectName || "");
const isRunning = memRunningActionId === ev.action.id;
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 || ""))]);
}
}
await Promise.all([ev.action.setState(1), ev.action.setTitle(`\u23F1 ${title}`)]);
}
}
const global = await getGlobal();
if (!isConfigured(global)) { if (!isConfigured(global)) {
await ev.action.showAlert(); await ev.action.showAlert();
return; return;
@@ -6566,48 +6615,29 @@ var TimerToggle = class extends SingletonAction {
return; return;
} }
try { try {
if (activeEntryId) { if (isRunning) {
await stopTimer(global.notionToken, activeEntryId); await stopTimer(global.notionToken, memRunningEntryId);
const stopped = { ...ev.payload.settings, activeEntryId: null }; setRunningEntry(null, null);
await ev.action.setSettings(stopped); await Promise.all([ev.action.setState(0), ev.action.setTitle(title)]);
this.settingsCache.set(ev.action.id, stopped);
await ev.action.setState(0);
await ev.action.setTitle(title);
await setRunningEntry(null);
} else { } else {
const prevEntryId = await getRunningEntryId(); if (memRunningEntryId) {
if (prevEntryId) { await stopTimer(global.notionToken, memRunningEntryId);
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);
} setRunningEntry(entryId, ev.action.id);
const entryId = await startTimer( await Promise.all([ev.action.setState(1), ev.action.setTitle(`\u23F1 ${title}`)]);
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(`\u23F1 ${title}`);
} }
} catch (err) { } catch (err) {
await Promise.all([
ev.action.setState(isRunning ? 1 : 0),
ev.action.setTitle(isRunning ? `\u23F1 ${title}` : title)
]);
plugin_default.logger.error("Timer toggle failed:", err); plugin_default.logger.error("Timer toggle failed:", err);
await ev.action.showAlert(); await ev.action.showAlert();
} }
} finally {
this.pendingKeyDown.delete(ev.action.id);
}
} }
}; };
TimerToggle = __decorateClass([ TimerToggle = __decorateClass([
@@ -6621,6 +6651,14 @@ plugin_default.ui.onSendToPlugin(async (ev) => {
const title = buttonTitle(ev.payload.settings.projectName || ""); const title = buttonTitle(ev.payload.settings.projectName || "");
if (title) await ev.action.setTitle(title); 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) => plugin_default.ui.sendToPropertyInspector({ event: "updateStatus", message: msg });
send("Checking\u2026");
await checkForUpdates(send);
}
}); });
plugin_default.connect(); plugin_default.connect();
setTimeout(() => checkForUpdates(), 1e4); setTimeout(() => checkForUpdates(), 1e4);

View File

@@ -1 +1 @@
ä(=uõÐüIXˆ<58>»pTüD0tQ ŽÚãεFïÊr•!Ÿ(\ç$À€{Þó {¿óîj¶? ÍzDA+ÌOÎi °<u,&/Õ+ødeÆá¤"÷œSw+&öO.RC& óä^H-¯

View File

@@ -2,7 +2,7 @@
"Author": "Pete Marfleet", "Author": "Pete Marfleet",
"Description": "Toggle Notion time tracking for a project with a single button press.", "Description": "Toggle Notion time tracking for a project with a single button press.",
"Name": "Notion Timer", "Name": "Notion Timer",
"Version": "1.0.0", "Version": "1.0.39",
"SDKVersion": 2, "SDKVersion": 2,
"Software": { "MinimumVersion": "5.0" }, "Software": { "MinimumVersion": "5.0" },
"OS": [{ "Platform": "mac", "MinimumVersion": "10.11" }], "OS": [{ "Platform": "mac", "MinimumVersion": "10.11" }],

View File

@@ -88,6 +88,26 @@
} }
#credStatus.ok { color: #4caf50; } #credStatus.ok { color: #4caf50; }
#credStatus.error { color: #e57373; } #credStatus.error { color: #e57373; }
#updateBtn {
display: block;
width: 100%;
margin-top: 10px;
padding: 6px;
background: #2a2a2a;
border: 1px solid #444;
border-radius: 4px;
color: #ccc;
font-size: 12px;
cursor: pointer;
}
#updateBtn:hover { background: #333; }
#updateStatus {
font-size: 11px;
color: #888;
text-align: center;
margin-top: 4px;
min-height: 14px;
}
</style> </style>
</head> </head>
<body> <body>
@@ -125,6 +145,8 @@
<p id="statusText"></p> <p id="statusText"></p>
<p id="versionText"></p> <p id="versionText"></p>
<button id="updateBtn">Check for Updates</button>
<p id="updateStatus"></p>
<script src="libs/constants.js"></script> <script src="libs/constants.js"></script>
<script src="libs/prototypes.js"></script> <script src="libs/prototypes.js"></script>
@@ -139,6 +161,8 @@
var currentSettings = {}; var currentSettings = {};
var credSaveTimer = null; var credSaveTimer = null;
var credConfigured = false; var credConfigured = false;
var globalUserId = "";
var cachedUsers = [];
function setStatus(msg, cls) { function setStatus(msg, cls) {
var el = document.getElementById("statusText"); var el = document.getElementById("statusText");
@@ -171,27 +195,35 @@
userId: document.getElementById("userId").value, userId: document.getElementById("userId").value,
}; };
$PI.setGlobalSettings(creds); $PI.setGlobalSettings(creds);
globalUserId = creds.userId;
setCredStatus("Credentials saved.", "ok"); setCredStatus("Credentials saved.", "ok");
} }
function populateUsers(users, savedUserId) { function scheduleCredSave() {
clearTimeout(credSaveTimer);
credSaveTimer = setTimeout(function() {
saveCredentials();
var token = document.getElementById("notionToken").value.trim();
if (token) {
setStatus("Loading…", "");
$PI.sendToPlugin({ event: "refreshProjects", token: token });
}
}, 600);
}
function populateUsers(users, userId) {
cachedUsers = users;
var sel = document.getElementById("userId"); var sel = document.getElementById("userId");
var current = savedUserId || sel.value;
sel.innerHTML = '<option value="">— Select your name —</option>'; sel.innerHTML = '<option value="">— Select your name —</option>';
users.forEach(function(u) { users.forEach(function(u) {
var opt = document.createElement("option"); var opt = document.createElement("option");
opt.value = u.id; opt.value = u.id;
opt.textContent = u.name; opt.textContent = u.name;
if (u.id === current) opt.selected = true; if (u.id === userId) opt.selected = true;
sel.appendChild(opt); sel.appendChild(opt);
}); });
} }
function scheduleCredSave() {
clearTimeout(credSaveTimer);
credSaveTimer = setTimeout(saveCredentials, 600);
}
function save() { function save() {
var sel = document.getElementById("projectSelect"); var sel = document.getElementById("projectSelect");
var opt = sel.options[sel.selectedIndex]; var opt = sel.options[sel.selectedIndex];
@@ -229,16 +261,19 @@
document.getElementById("projectSelect").addEventListener("change", save); document.getElementById("projectSelect").addEventListener("change", save);
document.getElementById("notionToken").addEventListener("input", scheduleCredSave); document.getElementById("notionToken").addEventListener("input", scheduleCredSave);
document.getElementById("userId").addEventListener("change", saveCredentials); document.getElementById("userId").addEventListener("change", saveCredentials);
document.getElementById("updateBtn").addEventListener("click", function() {
document.getElementById("updateStatus").textContent = "";
$PI.sendToPlugin({ event: "checkForUpdates" });
});
}); });
$PI.onDidReceiveGlobalSettings(function(jsn) { $PI.onDidReceiveGlobalSettings(function(jsn) {
var s = jsn.payload.settings || {}; var s = jsn.payload.settings || {};
document.getElementById("notionToken").value = s.notionToken || ""; document.getElementById("notionToken").value = s.notionToken || "";
if (s.userId) { globalUserId = s.userId || "";
var sel = document.getElementById("userId"); if (globalUserId && cachedUsers.length > 0) {
if (sel.querySelector('option[value="' + s.userId + '"]')) { // Users already loaded — re-populate with correct selection
sel.value = s.userId; populateUsers(cachedUsers, globalUserId);
}
} }
credConfigured = !!(s.notionToken && s.userId); credConfigured = !!(s.notionToken && s.userId);
@@ -268,13 +303,15 @@
$PI.onSendToPropertyInspector(ACTION_UUID, function(jsn) { $PI.onSendToPropertyInspector(ACTION_UUID, function(jsn) {
var payload = jsn.payload; var payload = jsn.payload;
if (payload.event === "updateStatus") {
document.getElementById("updateStatus").textContent = payload.message;
}
if (payload.event === "projects") { if (payload.event === "projects") {
if (payload.version) { if (payload.version) {
document.getElementById("versionText").textContent = "v" + payload.version; document.getElementById("versionText").textContent = "v" + payload.version;
} }
if (payload.users) { if (payload.users) {
var savedUserId = document.getElementById("userId").value; populateUsers(payload.users, globalUserId);
populateUsers(payload.users, savedUserId);
} }
if (payload.error) { if (payload.error) {
setStatus(payload.error, "error"); setStatus(payload.error, "error");

Binary file not shown.

View File

@@ -1,5 +1,5 @@
const CURRENT_VERSION = "1.0.23"; const CURRENT_VERSION = "1.0.40";
const GITEA_BASE = "https://gitea.pdmarf.co.uk/pdm/stream_deck_notion_timer/raw/branch/master"; const GITEA_BASE = "https://gitea.pdmarf.co.uk/pdm/stream_deck_notion_timer/raw/branch/stable-rebuild";
const SIGNING_PUBLIC_KEY = `-----BEGIN PUBLIC KEY----- const SIGNING_PUBLIC_KEY = `-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEAN7ko8TUpuPzPAJuKAZCRjV0c4ZSlou5d9pUAF6o12b4= MCowBQYDK2VwAyEAN7ko8TUpuPzPAJuKAZCRjV0c4ZSlou5d9pUAF6o12b4=
-----END PUBLIC KEY-----`; -----END PUBLIC KEY-----`;
@@ -19,19 +19,23 @@ function fetchWithTimeout(url: string): Promise<Response> {
return fetch(url, { signal: controller.signal }).finally(() => clearTimeout(timer)); return fetch(url, { signal: controller.signal }).finally(() => clearTimeout(timer));
} }
async function checkForUpdates(): Promise<void> { async function checkForUpdates(sendStatus?: (msg: string) => void): Promise<void> {
try { try {
const resp = await fetchWithTimeout(`${GITEA_BASE}/version.json`); const resp = await fetchWithTimeout(`${GITEA_BASE}/version.json`);
if (!resp.ok) return; if (!resp.ok) { sendStatus?.("Update check failed"); return; }
const { version } = await resp.json() as { version: string }; const { version } = await resp.json() as { version: string };
if (!/^\d+\.\d+\.\d+$/.test(version)) return; if (!/^\d+\.\d+\.\d+$/.test(version)) return;
if (!isNewerVersion(version, CURRENT_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([ 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`),
fetchWithTimeout(`${GITEA_BASE}/com.pdma.notion-timer.sdPlugin/bin/plugin.js.sig`), fetchWithTimeout(`${GITEA_BASE}/com.pdma.notion-timer.sdPlugin/bin/plugin.js.sig`),
]); ]);
if (!pluginResp.ok || !sigResp.ok) return; if (!pluginResp.ok || !sigResp.ok) { sendStatus?.("Download failed"); return; }
const newCode = await pluginResp.text(); const newCode = await pluginResp.text();
const sigBytes = Buffer.from(await sigResp.arrayBuffer()); const sigBytes = Buffer.from(await sigResp.arrayBuffer());
@@ -40,15 +44,40 @@ async function checkForUpdates(): Promise<void> {
const valid = verify(null, Buffer.from(newCode), SIGNING_PUBLIC_KEY, sigBytes); const valid = verify(null, Buffer.from(newCode), SIGNING_PUBLIC_KEY, sigBytes);
if (!valid) { if (!valid) {
streamDeck.logger.error("Update rejected: signature verification failed"); streamDeck.logger.error("Update rejected: signature verification failed");
sendStatus?.("Update rejected: invalid signature");
return; return;
} }
const fs = await import("fs"); 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); fs.writeFileSync(__filename, newCode);
streamDeck.logger.info(`Updated to ${version}, restarting…`); streamDeck.logger.info(`Updated to ${version}, restarting…`);
process.exit(0); process.exit(0);
} catch (err) { } catch (err) {
streamDeck.logger.error(`Update check failed: ${err instanceof Error ? err.message : String(err)}`); const msg = err instanceof Error ? err.message : String(err);
streamDeck.logger.error(`Update check failed: ${msg}`);
sendStatus?.(`Error: ${msg}`);
} }
} }
@@ -67,12 +96,12 @@ interface GlobalSettings {
projectsDbId: string; projectsDbId: string;
userId: string; userId: string;
runningEntryId?: string | null; runningEntryId?: string | null;
runningActionId?: string | null;
} }
interface TimerSettings { interface TimerSettings {
projectId: string; projectId: string;
projectName: string; projectName: string;
activeEntryId: string | null;
} }
const HARDCODED = { const HARDCODED = {
@@ -85,70 +114,37 @@ async function getGlobal(): Promise<GlobalSettings> {
return { ...stored, ...HARDCODED }; return { ...stored, ...HARDCODED };
} }
// In-memory cache so onWillAppear can check running state without an async round-trip // In-memory running state — avoids async round-trips on every button press
let memRunningEntryId: string | null | undefined = undefined; // undefined = not yet loaded let memRunningEntryId: string | null | undefined = undefined;
let memRunningActionId: string | null | undefined = undefined;
async function getRunningEntryId(): Promise<string | null> { async function loadRunningState(): Promise<void> {
if (memRunningEntryId === undefined) { if (memRunningEntryId !== undefined) return;
const stored = await streamDeck.settings.getGlobalSettings<GlobalSettings>(); const stored = await streamDeck.settings.getGlobalSettings<GlobalSettings>();
memRunningEntryId = stored.runningEntryId ?? null; memRunningEntryId = stored.runningEntryId ?? null;
} memRunningActionId = stored.runningActionId ?? null;
return memRunningEntryId;
} }
async function setRunningEntry(entryId: string | null): Promise<void> { function setRunningEntry(entryId: string | null, actionId: string | null): void {
memRunningEntryId = entryId; memRunningEntryId = entryId;
const stored = await streamDeck.settings.getGlobalSettings<GlobalSettings>(); memRunningActionId = actionId;
await streamDeck.settings.setGlobalSettings({ ...stored, runningEntryId: entryId }); // Persist in background — do not await, so the visual is never blocked
streamDeck.settings.getGlobalSettings<GlobalSettings>()
.then(stored => streamDeck.settings.setGlobalSettings({ ...stored, runningEntryId: entryId, runningActionId: actionId }))
.catch(err => streamDeck.logger.error("Failed to persist running state:", err));
} }
function isConfigured(g: GlobalSettings): boolean { async function sendProjectsToPI(tokenOverride?: string): Promise<void> {
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<TimerSettings> {
private settingsCache = new Map<string, TimerSettings>();
async onWillAppear(ev: WillAppearEvent<TimerSettings>): Promise<void> {
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<TimerSettings>): Promise<void> {
try { try {
const global = await getGlobal(); const global = await getGlobal();
if (!global.notionToken) { const token = tokenOverride ?? global.notionToken;
if (!token) {
await streamDeck.ui.sendToPropertyInspector({ event: "projects", data: [], error: "Enter your Notion API token above.", version: CURRENT_VERSION }); await streamDeck.ui.sendToPropertyInspector({ event: "projects", data: [], error: "Enter your Notion API token above.", version: CURRENT_VERSION });
return; return;
} }
const [projects, usersResult] = await Promise.all([ const [projects, usersResult] = await Promise.all([
fetchProjects(global.notionToken, global.projectsDbId), fetchProjects(token, global.projectsDbId),
fetchUsers(global.notionToken).catch((err) => { fetchUsers(token).catch((err) => {
streamDeck.logger.error("Failed to fetch users:", err); streamDeck.logger.error("Failed to fetch users:", err);
return []; return [];
}), }),
@@ -160,84 +156,110 @@ class TimerToggle extends SingletonAction<TimerSettings> {
} }
} }
async onKeyDown(ev: KeyDownEvent<TimerSettings>): Promise<void> { function isConfigured(g: GlobalSettings): boolean {
this.settingsCache.set(ev.action.id, ev.payload.settings); return !!(g.notionToken && g.userId);
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) { function buttonTitle(projectName: string): string {
await ev.action.showAlert(); return projectName.replace(/^[\p{Extended_Pictographic}\uFE0F\s]+/u, "").trim();
return;
} }
try { @action({ UUID: "com.pdma.notion-timer.toggle" })
if (activeEntryId) { class TimerToggle extends SingletonAction<TimerSettings> {
await stopTimer(global.notionToken, activeEntryId); private projectCache = new Map<string, TimerSettings>();
const stopped = { ...ev.payload.settings, activeEntryId: null }; private pendingKeyDown = new Set<string>();
await ev.action.setSettings(stopped);
this.settingsCache.set(ev.action.id, stopped); async onWillAppear(ev: WillAppearEvent<TimerSettings>): Promise<void> {
await ev.action.setState(0); this.projectCache.set(ev.action.id, ev.payload.settings);
await ev.action.setTitle(title); const title = buttonTitle(ev.payload.settings.projectName || "");
await setRunningEntry(null); await loadRunningState();
const isRunning = memRunningActionId === ev.action.id;
if (isRunning) {
await Promise.all([ev.action.setState(1), ev.action.setTitle(`${title}`)]);
} else { } else {
const prevEntryId = await getRunningEntryId(); await Promise.all([ev.action.setState(0), ev.action.setTitle(title)]);
}
}
// Stop previous timer async onPropertyInspectorDidAppear(_ev: PropertyInspectorDidAppearEvent<TimerSettings>): Promise<void> {
if (prevEntryId) { await sendProjectsToPI();
await stopTimer(global.notionToken, prevEntryId); }
async onKeyDown(ev: KeyDownEvent<TimerSettings>): Promise<void> {
if (this.pendingKeyDown.has(ev.action.id)) return;
this.pendingKeyDown.add(ev.action.id);
try {
const { projectId, projectName } = ev.payload.settings;
const title = buttonTitle(projectName || "");
const isRunning = memRunningActionId === ev.action.id;
// 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) { for (const other of this.actions) {
if (other.id === ev.action.id) continue; if (other.id === ev.action.id) continue;
const otherSettings = this.settingsCache.get(other.id); if (memRunningActionId === other.id) {
if (otherSettings?.activeEntryId === prevEntryId) { const s = this.projectCache.get(other.id);
const stopped = { ...otherSettings, activeEntryId: null }; await Promise.all([other.setState(0), other.setTitle(buttonTitle(s?.projectName || ""))]);
await other.setSettings(stopped);
this.settingsCache.set(other.id, stopped);
await other.setState(0);
await other.setTitle(buttonTitle(otherSettings.projectName || ""));
} }
} }
await Promise.all([ev.action.setState(1), ev.action.setTitle(`${title}`)]);
}
} }
const entryId = await startTimer( const global = await getGlobal();
global.notionToken, if (!isConfigured(global)) { await ev.action.showAlert(); return; }
global.timingDbId, if (!projectId) { await ev.action.showAlert(); return; }
projectId,
projectName, try {
global.userId, if (isRunning) {
); await stopTimer(global.notionToken, memRunningEntryId!);
const started = { ...ev.payload.settings, activeEntryId: entryId }; setRunningEntry(null, null);
await ev.action.setSettings(started); await Promise.all([ev.action.setState(0), ev.action.setTitle(title)]);
this.settingsCache.set(ev.action.id, started); } else {
await setRunningEntry(entryId); if (memRunningEntryId) {
await ev.action.setState(1); await stopTimer(global.notionToken, memRunningEntryId);
await ev.action.setTitle(`${title}`); }
const entryId = await startTimer(global.notionToken, global.timingDbId, projectId, projectName, global.userId);
setRunningEntry(entryId, ev.action.id);
await Promise.all([ev.action.setState(1), ev.action.setTitle(`${title}`)]);
} }
} catch (err) { } 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); streamDeck.logger.error("Timer toggle failed:", err);
await ev.action.showAlert(); await ev.action.showAlert();
} }
} finally {
this.pendingKeyDown.delete(ev.action.id);
}
} }
} }
const timerAction = new TimerToggle(); const timerAction = new TimerToggle();
streamDeck.actions.registerAction(timerAction); 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) => {
streamDeck.ui.onSendToPlugin<{ event: string; settings?: TimerSettings }>(async (ev) => {
if (ev.payload.event === "saveSettings" && ev.payload.settings) { if (ev.payload.event === "saveSettings" && ev.payload.settings) {
await ev.action.setSettings(ev.payload.settings); await ev.action.setSettings(ev.payload.settings);
const title = buttonTitle(ev.payload.settings.projectName || ""); const title = buttonTitle(ev.payload.settings.projectName || "");
if (title) await ev.action.setTitle(title); 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(); streamDeck.connect();
// Check for updates 10 seconds after startup to avoid disrupting initial connection
setTimeout(() => checkForUpdates(), 10_000); setTimeout(() => checkForUpdates(), 10_000);

View File

@@ -1 +1 @@
{ "version": "1.0.15" } { "version": "1.0.40" }