Compare commits

...

6 Commits

Author SHA1 Message Date
pdmarf
d9ca28d125 Merge stable-rebuild into master; everything now on master
Install script and auto-updater both point at master. No more
separate branches needed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 07:52:00 +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
pdmarf
d9a91fe5b1 v1.0.23: stable-rebuild base from v1.0.15
Known-good state: blue icons, cross-folder timer fix, name dropdown.
Versioned above master (1.0.22) so auto-updater does not overwrite.
Install script points at stable-rebuild branch.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 21:13:39 +01:00
4 changed files with 44 additions and 91 deletions

View File

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

View File

@@ -127,7 +127,6 @@
<option value="">— Select your name —</option>
</select>
</div>
<p id="userError" class="hint" style="color:#e57373;padding-left:0;"></p>
<p class="hint">Shared across all buttons. Select once per device.</p>
<p id="credStatus"></p>
<hr class="divider">
@@ -195,9 +194,6 @@
};
$PI.setGlobalSettings(creds);
setCredStatus("Credentials saved.", "ok");
if (creds.notionToken) {
$PI.sendToPlugin({ event: "refresh", notionToken: creds.notionToken });
}
}
function populateUsers(users, savedUserId) {
@@ -300,25 +296,14 @@
var payload = jsn.payload;
if (payload.event === "updateStatus") {
document.getElementById("updateStatus").textContent = payload.message;
if (payload.message && payload.message.indexOf("Updating") === 0) {
setTimeout(function() { location.reload(); }, 4000);
}
}
if (payload.event === "projects") {
if (payload.version) {
document.getElementById("versionText").textContent = "v" + payload.version;
}
if (payload.users !== undefined) {
if (payload.users) {
var savedUserId = document.getElementById("userId").value;
populateUsers(payload.users, savedUserId);
var userErr = document.getElementById("userError");
if (payload.usersError) {
userErr.textContent = "Could not load names: " + payload.usersError;
} else if (payload.users.length === 0) {
userErr.textContent = "No users found — check the integration has \u201cRead user information\u201d enabled.";
} else {
userErr.textContent = "";
}
}
if (payload.error) {
setStatus(payload.error, "error");

Binary file not shown.

View File

@@ -1,20 +1,16 @@
const CURRENT_VERSION = "1.0.22";
const GITEA_BASE = "https://gitea.pdmarf.co.uk/pdm/stream_deck_notion_timer/raw/branch/master";
const CURRENT_VERSION = "1.0.26";
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 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;
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<Response> {
@@ -28,17 +24,16 @@ async function checkForUpdates(sendStatus?: (msg: string) => void): Promise<void
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 (!/^\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 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`),
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; }
@@ -54,32 +49,7 @@ async function checkForUpdates(sendStatus?: (msg: string) => void): Promise<void
}
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) {
@@ -139,30 +109,6 @@ async function setRunningEntry(entryId: string | null): Promise<void> {
await streamDeck.settings.setGlobalSettings({ ...stored, runningEntryId: entryId });
}
async function sendProjectsToPI(overrideToken?: string): Promise<void> {
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<ReturnType<typeof fetchUsers>> = [];
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);
}
@@ -200,8 +146,25 @@ class TimerToggle extends SingletonAction<TimerSettings> {
}
}
async onPropertyInspectorDidAppear(_ev: PropertyInspectorDidAppearEvent<TimerSettings>): Promise<void> {
await sendProjectsToPI();
async onPropertyInspectorDidAppear(ev: PropertyInspectorDidAppearEvent<TimerSettings>): Promise<void> {
try {
const global = await getGlobal();
if (!global.notionToken) {
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(global.notionToken, global.projectsDbId),
fetchUsers(global.notionToken).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 });
}
}
async onKeyDown(ev: KeyDownEvent<TimerSettings>): Promise<void> {
@@ -232,7 +195,19 @@ class TimerToggle extends SingletonAction<TimerSettings> {
} else {
const prevEntryId = await getRunningEntryId();
// Stop previous timer
// 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) {
@@ -242,8 +217,6 @@ class TimerToggle extends SingletonAction<TimerSettings> {
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 || ""));
}
}
}
@@ -259,8 +232,6 @@ class TimerToggle extends SingletonAction<TimerSettings> {
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);
@@ -279,9 +250,6 @@ streamDeck.ui.onSendToPlugin<{ event: string; settings?: TimerSettings }>(async
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…");